Skip to content

Commit f819759

Browse files
committed
fix(auth): complete Better Auth 1.7 migration
1 parent f863bad commit f819759

23 files changed

Lines changed: 4866 additions & 144 deletions

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"db:generate": "drizzle-kit generate",
1414
"db:push": "drizzle-kit push",
1515
"db:studio": "drizzle-kit studio",
16-
"auth:generate": "npx auth@1.6.25 generate --config ./src/auth.ts --output ./src/db/auth-schema.ts --yes"
16+
"auth:generate": "npx auth@1.7.1 generate --config ./src/auth.ts --output ./src/db/auth-schema.ts --yes"
1717
},
1818
"keywords": [],
1919
"author": "",

apps/api/src/auth-oauth-provider.test.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFileSync } from "node:fs";
22
import { getTableColumns } from "drizzle-orm";
3+
import { getTableConfig } from "drizzle-orm/pg-core";
34
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
45

56
const ORIGINAL_SECRET = process.env.BETTER_AUTH_SECRET;
@@ -29,15 +30,29 @@ function policyIdentity(id: string, role: string) {
2930

3031
beforeAll(() => {
3132
vi.stubEnv("BETTER_AUTH_SECRET", "test-secret-at-least-thirty-two-characters");
33+
vi.stubGlobal(
34+
"fetch",
35+
vi.fn(async () =>
36+
Response.json({
37+
issuer: "https://www.openstreetmap.org",
38+
authorization_endpoint: "https://www.openstreetmap.org/oauth2/authorize",
39+
token_endpoint: "https://www.openstreetmap.org/oauth2/token",
40+
userinfo_endpoint: "https://api.openstreetmap.org/api/0.6/user/details.json",
41+
jwks_uri: "https://www.openstreetmap.org/oauth2/jwks",
42+
id_token_signing_alg_values_supported: ["RS256"],
43+
}),
44+
),
45+
);
3246
});
3347

3448
afterAll(() => {
3549
vi.unstubAllEnvs();
50+
vi.unstubAllGlobals();
3651
if (ORIGINAL_SECRET !== undefined) process.env.BETTER_AUTH_SECRET = ORIGINAL_SECRET;
3752
});
3853

3954
describe("managed OAuth provider policy", () => {
40-
it("pins every runtime Better Auth family to 1.6.26 and the schema CLI to 1.6.25", () => {
55+
it("pins every runtime Better Auth package and the schema CLI to 1.7.1", () => {
4156
const apiManifest = JSON.parse(
4257
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
4358
) as { dependencies: Record<string, string>; scripts: Record<string, string> };
@@ -68,27 +83,42 @@ describe("managed OAuth provider policy", () => {
6883
const webPackages = ["@better-auth/core", "@better-auth/passkey", "better-auth"];
6984

7085
for (const packageName of apiPackages) {
71-
expect(apiManifest.dependencies[packageName]).toBe("1.6.26");
86+
expect(apiManifest.dependencies[packageName]).toBe("1.7.1");
7287
const lockName = packageName.startsWith("@") ? `'${packageName}'` : packageName;
73-
expect(apiLock).toContain(`${lockName}:\n specifier: 1.6.26`);
88+
expect(apiLock).toContain(`${lockName}:\n specifier: 1.7.1`);
7489
}
7590
for (const packageName of corePackages) {
76-
expect(coreManifest.dependencies[packageName]).toBe("1.6.26");
91+
expect(coreManifest.dependencies[packageName]).toBe("1.7.1");
7792
const lockName = packageName.startsWith("@") ? `'${packageName}'` : packageName;
78-
expect(coreLock).toContain(`${lockName}:\n specifier: 1.6.26`);
93+
expect(coreLock).toContain(`${lockName}:\n specifier: 1.7.1`);
7994
}
8095
for (const packageName of webPackages) {
81-
expect(webManifest.dependencies[packageName]).toBe("1.6.26");
96+
expect(webManifest.dependencies[packageName]).toBe("1.7.1");
8297
const lockName = packageName.startsWith("@") ? `'${packageName}'` : packageName;
83-
expect(webLock).toContain(`${lockName}:\n specifier: 1.6.26`);
98+
expect(webLock).toContain(`${lockName}:\n specifier: 1.7.1`);
8499
}
85-
expect(apiManifest.scripts["auth:generate"]).toContain("auth@1.6.25 generate");
100+
expect(apiManifest.scripts["auth:generate"]).toContain("auth@1.7.1 generate");
86101
for (const importer of [apiLock, coreLock, webLock]) {
87-
expect(importer).not.toContain("@better-auth/core@1.6.25");
88-
expect(importer).not.toContain("specifier: ^1.6.25");
102+
expect(importer).not.toContain("specifier: 1.6.");
89103
}
90104
});
91105

106+
it("scopes account identity by required issuer and provider account ID", async () => {
107+
const { account } = await import("./db/schema");
108+
const columns = getTableColumns(account);
109+
const config = getTableConfig(account);
110+
111+
expect(columns.issuer).toMatchObject({ notNull: true });
112+
expect(
113+
config.indexes.some(
114+
(index) =>
115+
index.config.unique &&
116+
index.config.columns.map((column) => ("name" in column ? column.name : "")).join(",") ===
117+
"issuer,account_id",
118+
),
119+
).toBe(true);
120+
});
121+
92122
it("exposes every provider table through the application Drizzle schema", async () => {
93123
const schema = await import("./db/schema");
94124

@@ -103,7 +133,7 @@ describe("managed OAuth provider policy", () => {
103133
);
104134
});
105135

106-
it("keeps the exact 1.6.26 two-factor lockout columns emitted by the pinned generator", async () => {
136+
it("keeps the generated two-factor lockout columns", async () => {
107137
const { twoFactor } = await import("./db/schema");
108138
const columns = getTableColumns(twoFactor);
109139

@@ -144,8 +174,7 @@ describe("managed OAuth provider policy", () => {
144174
expect(managedOAuthProviderOptions).not.toHaveProperty("cachedTrustedClients");
145175
expect(managedOAuthProviderOptions).not.toHaveProperty("disableJwtPlugin");
146176
expect(managedOAuthProviderOptions).not.toHaveProperty("storeClientSecret");
147-
// Better Auth 1.6's resource-indicator implementation is safe from
148-
// cross-audience escalation only with its single default audience.
177+
// The provider exposes only its first-party resource configuration.
149178
expect(managedOAuthProviderOptions).not.toHaveProperty("validAudiences");
150179
expect(managedOAuthProviderOptions).not.toHaveProperty("customAccessTokenClaims");
151180
});

apps/api/src/auth.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ async function fetchProviderImage(
6767
* the sync's own guard absorbs that.
6868
*/
6969
const providerAvatarSync = createProviderAvatarSync({
70-
async resolveAccessToken(providerId, userId) {
70+
async resolveAccessToken(accountId, userId) {
7171
try {
72-
const result = await auth.api.getAccessToken({ body: { providerId, userId } });
72+
const result = await auth.api.getAccessToken({ body: { accountId, userId } });
7373
return result.accessToken ?? undefined;
7474
} catch {
7575
// Revoked, unrefreshable, or undecryptable. The person simply relinks.
@@ -142,14 +142,14 @@ const authOptions = {
142142
account: {
143143
create: {
144144
after: async (account) => {
145-
await providerAvatarSync.onAccountCreated(account.providerId, account.userId);
145+
await providerAvatarSync.onAccountCreated(account.id, account.providerId, account.userId);
146146
},
147147
},
148148
update: {
149149
after: async (account) => {
150150
// Better Auth updates the account on each OAuth sign-in and token
151151
// refresh; re-read the picture so a changed provider avatar follows.
152-
await providerAvatarSync.onAccountUpdated(account.providerId, account.userId);
152+
await providerAvatarSync.onAccountUpdated(account.id, account.providerId, account.userId);
153153
},
154154
},
155155
},
@@ -251,6 +251,17 @@ const authOptions = {
251251
{
252252
providerId: "openstreetmap",
253253
discoveryUrl: getOsmConfig().discoveryUrl,
254+
// Keep the account namespace and core OAuth endpoints available
255+
// when OSM discovery is temporarily unreachable. These values come
256+
// from the same deployment-validated OSM origin; profile identity is
257+
// still proven by the access token against OSM's user-details API.
258+
accountIssuer: new URL(getOsmConfig().webBase).origin,
259+
authorizationUrl: getOsmConfig().webUrl("oauth2/authorize"),
260+
tokenUrl: getOsmConfig().webUrl("oauth2/token"),
261+
// The profile comes from OSM's authenticated user-details endpoint,
262+
// not from Better Auth's local user mapping. Pin its immutable OSM
263+
// numeric ID explicitly so the 1.7 account subject cannot drift.
264+
accountSubject: ({ profile }) => String(profile.id),
254265
clientId: envString("OSM_CLIENT_ID", ""),
255266
clientSecret: envString("OSM_CLIENT_SECRET", ""),
256267
// Ordinary sign-in stays minimal. Contribution write scopes are

apps/api/src/db/auth-schema.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { relations } from "drizzle-orm";
2-
import { boolean, index, integer, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
2+
import {
3+
boolean,
4+
index,
5+
integer,
6+
jsonb,
7+
pgTable,
8+
text,
9+
timestamp,
10+
uniqueIndex,
11+
} from "drizzle-orm/pg-core";
312

413
export const user = pgTable("user", {
514
id: text("id").primaryKey(),
@@ -44,6 +53,7 @@ export const account = pgTable(
4453
"account",
4554
{
4655
id: text("id").primaryKey(),
56+
issuer: text("issuer").notNull(),
4757
accountId: text("account_id").notNull(),
4858
providerId: text("provider_id").notNull(),
4959
userId: text("user_id")
@@ -61,7 +71,10 @@ export const account = pgTable(
6171
.$onUpdate(() => /* @__PURE__ */ new Date())
6272
.notNull(),
6373
},
64-
(table) => [index("account_userId_idx").on(table.userId)],
74+
(table) => [
75+
uniqueIndex("account_issuer_accountId_uidx").on(table.issuer, table.accountId),
76+
index("account_userId_idx").on(table.userId),
77+
],
6578
);
6679

6780
export const verification = pgTable(
@@ -128,6 +141,8 @@ export const jwks = pgTable("jwks", {
128141
privateKey: text("private_key").notNull(),
129142
createdAt: timestamp("created_at").notNull(),
130143
expiresAt: timestamp("expires_at"),
144+
alg: text("alg"),
145+
crv: text("crv"),
131146
});
132147

133148
export const oauthClient = pgTable(
@@ -136,11 +151,13 @@ export const oauthClient = pgTable(
136151
id: text("id").primaryKey(),
137152
clientId: text("client_id").notNull().unique(),
138153
clientSecret: text("client_secret"),
154+
clientDiscoveryId: text("client_discovery_id"),
139155
disabled: boolean("disabled").default(false),
140156
skipConsent: boolean("skip_consent"),
141157
enableEndSession: boolean("enable_end_session"),
142158
subjectType: text("subject_type"),
143159
scopes: text("scopes").array(),
160+
clientCredentialsScopes: text("client_credentials_scopes").array().default([]),
144161
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
145162
createdAt: timestamp("created_at"),
146163
updatedAt: timestamp("updated_at"),
@@ -155,18 +172,63 @@ export const oauthClient = pgTable(
155172
softwareStatement: text("software_statement"),
156173
redirectUris: text("redirect_uris").array().notNull(),
157174
postLogoutRedirectUris: text("post_logout_redirect_uris").array(),
175+
backchannelLogoutUri: text("backchannel_logout_uri"),
176+
backchannelLogoutSessionRequired: boolean("backchannel_logout_session_required"),
158177
tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
178+
applicationType: text("application_type"),
179+
jwks: text("jwks"),
180+
jwksUri: text("jwks_uri"),
159181
grantTypes: text("grant_types").array(),
160182
responseTypes: text("response_types").array(),
161-
public: boolean("public"),
162-
type: text("type"),
163183
requirePKCE: boolean("require_pkce"),
184+
dpopBoundAccessTokens: boolean("dpop_bound_access_tokens").default(false),
164185
referenceId: text("reference_id"),
165186
metadata: jsonb("metadata"),
166187
},
167188
(table) => [index("oauthClient_userId_idx").on(table.userId)],
168189
);
169190

191+
export const oauthResource = pgTable("oauth_resource", {
192+
id: text("id").primaryKey(),
193+
identifier: text("identifier").notNull().unique(),
194+
name: text("name").notNull(),
195+
accessTokenTtl: integer("access_token_ttl"),
196+
refreshTokenTtl: integer("refresh_token_ttl"),
197+
signingAlgorithm: text("signing_algorithm"),
198+
signingKeyId: text("signing_key_id"),
199+
allowedScopes: text("allowed_scopes").array(),
200+
customClaims: jsonb("custom_claims"),
201+
dpopBoundAccessTokensRequired: boolean("dpop_bound_access_tokens_required").default(false),
202+
disabled: boolean("disabled").default(false),
203+
createdAt: timestamp("created_at"),
204+
updatedAt: timestamp("updated_at"),
205+
policyVersion: integer("policy_version").default(1),
206+
metadata: jsonb("metadata"),
207+
});
208+
209+
export const oauthClientResource = pgTable(
210+
"oauth_client_resource",
211+
{
212+
id: text("id").primaryKey(),
213+
clientId: text("client_id")
214+
.notNull()
215+
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
216+
resourceId: text("resource_id")
217+
.notNull()
218+
.references(() => oauthResource.identifier, { onDelete: "cascade" }),
219+
metadata: jsonb("metadata"),
220+
createdAt: timestamp("created_at"),
221+
},
222+
(table) => [
223+
uniqueIndex("oauthClientResource_clientId_resourceId_uidx").on(
224+
table.clientId,
225+
table.resourceId,
226+
),
227+
index("oauthClientResource_clientId_idx").on(table.clientId),
228+
index("oauthClientResource_resourceId_idx").on(table.resourceId),
229+
],
230+
);
231+
170232
export const oauthRefreshToken = pgTable(
171233
"oauth_refresh_token",
172234
{
@@ -182,16 +244,24 @@ export const oauthRefreshToken = pgTable(
182244
.notNull()
183245
.references(() => user.id, { onDelete: "cascade" }),
184246
referenceId: text("reference_id"),
247+
authorizationCodeId: text("authorization_code_id"),
248+
resources: text("resources").array(),
249+
requestedUserInfoClaims: text("requested_user_info_claims").array(),
185250
expiresAt: timestamp("expires_at").notNull(),
186251
createdAt: timestamp("created_at").notNull(),
187252
revoked: timestamp("revoked"),
253+
rotatedAt: timestamp("rotated_at"),
254+
rotationReplayResponse: text("rotation_replay_response"),
255+
rotationReplayExpiresAt: timestamp("rotation_replay_expires_at"),
188256
authTime: timestamp("auth_time"),
257+
confirmation: jsonb("confirmation"),
189258
scopes: text("scopes").array().notNull(),
190259
},
191260
(table) => [
192261
index("oauthRefreshToken_clientId_idx").on(table.clientId),
193262
index("oauthRefreshToken_sessionId_idx").on(table.sessionId),
194263
index("oauthRefreshToken_userId_idx").on(table.userId),
264+
index("oauthRefreshToken_authorizationCodeId_idx").on(table.authorizationCodeId),
195265
],
196266
);
197267

@@ -208,17 +278,23 @@ export const oauthAccessToken = pgTable(
208278
}),
209279
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
210280
referenceId: text("reference_id"),
281+
authorizationCodeId: text("authorization_code_id"),
282+
resources: text("resources").array(),
283+
requestedUserInfoClaims: text("requested_user_info_claims").array(),
211284
refreshId: text("refresh_id").references(() => oauthRefreshToken.id, {
212285
onDelete: "cascade",
213286
}),
214287
expiresAt: timestamp("expires_at").notNull(),
215288
createdAt: timestamp("created_at").notNull(),
289+
revoked: timestamp("revoked"),
290+
confirmation: jsonb("confirmation"),
216291
scopes: text("scopes").array().notNull(),
217292
},
218293
(table) => [
219294
index("oauthAccessToken_clientId_idx").on(table.clientId),
220295
index("oauthAccessToken_sessionId_idx").on(table.sessionId),
221296
index("oauthAccessToken_userId_idx").on(table.userId),
297+
index("oauthAccessToken_authorizationCodeId_idx").on(table.authorizationCodeId),
222298
index("oauthAccessToken_refreshId_idx").on(table.refreshId),
223299
],
224300
);
@@ -232,6 +308,8 @@ export const oauthConsent = pgTable(
232308
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
233309
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
234310
referenceId: text("reference_id"),
311+
resources: text("resources").array(),
312+
requestedUserInfoClaims: text("requested_user_info_claims").array(),
235313
scopes: text("scopes").array().notNull(),
236314
createdAt: timestamp("created_at").notNull(),
237315
updatedAt: timestamp("updated_at").notNull(),
@@ -242,6 +320,11 @@ export const oauthConsent = pgTable(
242320
],
243321
);
244322

323+
export const oauthClientAssertion = pgTable("oauth_client_assertion", {
324+
id: text("id").primaryKey(),
325+
expiresAt: timestamp("expires_at").notNull(),
326+
});
327+
245328
export const userRelations = relations(user, ({ many }) => ({
246329
sessions: many(session),
247330
accounts: many(account),
@@ -288,11 +371,27 @@ export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({
288371
fields: [oauthClient.userId],
289372
references: [user.id],
290373
}),
374+
oauthClientResources: many(oauthClientResource),
291375
oauthRefreshTokens: many(oauthRefreshToken),
292376
oauthAccessTokens: many(oauthAccessToken),
293377
oauthConsents: many(oauthConsent),
294378
}));
295379

380+
export const oauthResourceRelations = relations(oauthResource, ({ many }) => ({
381+
oauthClientResources: many(oauthClientResource),
382+
}));
383+
384+
export const oauthClientResourceRelations = relations(oauthClientResource, ({ one }) => ({
385+
oauthClient: one(oauthClient, {
386+
fields: [oauthClientResource.clientId],
387+
references: [oauthClient.clientId],
388+
}),
389+
oauthResource: one(oauthResource, {
390+
fields: [oauthClientResource.resourceId],
391+
references: [oauthResource.identifier],
392+
}),
393+
}));
394+
296395
export const oauthRefreshTokenRelations = relations(oauthRefreshToken, ({ one, many }) => ({
297396
oauthClient: one(oauthClient, {
298397
fields: [oauthRefreshToken.clientId],

0 commit comments

Comments
 (0)