diff --git a/package-lock.json b/package-lock.json index f1397e5..cfff746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,9 @@ "": { "name": "auth-auth", "dependencies": { - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/oauth-provider": "1.7.7", "@hono/node-server": "^2.1.0", - "better-auth": "^1.7.5", + "better-auth": "1.7.7", "date-fns": "^4.4.0", "hono": "^4.13.1", "hono-pino": "^0.10.3", @@ -91,9 +91,9 @@ } }, "node_modules/@better-auth/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.5.tgz", - "integrity": "sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", + "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", "license": "MIT", "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", @@ -116,12 +116,12 @@ } }, "node_modules/@better-auth/drizzle-adapter": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.5.tgz", - "integrity": "sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", + "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.5", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, @@ -132,12 +132,12 @@ } }, "node_modules/@better-auth/kysely-adapter": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.5.tgz", - "integrity": "sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", + "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.5", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, @@ -148,22 +148,22 @@ } }, "node_modules/@better-auth/memory-adapter": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.5.tgz", - "integrity": "sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", + "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.5", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2" } }, "node_modules/@better-auth/mongo-adapter": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.5.tgz", - "integrity": "sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", + "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.5", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, @@ -174,29 +174,29 @@ } }, "node_modules/@better-auth/oauth-provider": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/oauth-provider/-/oauth-provider-1.7.5.tgz", - "integrity": "sha512-hVHb1TfydO8hWJXeZaGeJ+1506Fn+8hsffes+TFwi6/gl/ui+GqJVliPhHhuaOqpC1gN8ZFiLr0u8bh0aSnTLg==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/oauth-provider/-/oauth-provider-1.7.7.tgz", + "integrity": "sha512-dc4C/ZF+8qc88ebcU8kaFp8yladCtfTkuUt2zBSmYw25YCNC5BoUP9rpAkIx4nemlZ11gLtSnkEPc0sA7MwVmw==", "license": "MIT", "dependencies": { "jose": "^6.2.3", "zod": "^4.5.4" }, "peerDependencies": { - "@better-auth/core": "^1.7.5", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", - "better-auth": "^1.7.5", + "better-auth": "^1.7.7", "better-call": "1.4.0" } }, "node_modules/@better-auth/prisma-adapter": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.5.tgz", - "integrity": "sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", + "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.5", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" @@ -211,12 +211,12 @@ } }, "node_modules/@better-auth/telemetry": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.5.tgz", - "integrity": "sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", + "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.5", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2" } @@ -1977,9 +1977,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", - "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", "dev": true, "license": "MIT", "peer": true, @@ -2653,18 +2653,18 @@ } }, "node_modules/better-auth": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.5.tgz", - "integrity": "sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.7.5", - "@better-auth/drizzle-adapter": "1.7.5", - "@better-auth/kysely-adapter": "1.7.5", - "@better-auth/memory-adapter": "1.7.5", - "@better-auth/mongo-adapter": "1.7.5", - "@better-auth/prisma-adapter": "1.7.5", - "@better-auth/telemetry": "1.7.5", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", + "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.7", + "@better-auth/drizzle-adapter": "1.7.7", + "@better-auth/kysely-adapter": "1.7.7", + "@better-auth/memory-adapter": "1.7.7", + "@better-auth/mongo-adapter": "1.7.7", + "@better-auth/prisma-adapter": "1.7.7", + "@better-auth/telemetry": "1.7.7", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@noble/ciphers": "^2.2.0", diff --git a/package.json b/package.json index a7a0feb..76b77f7 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "e2e": "playwright test" }, "dependencies": { - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/oauth-provider": "1.7.7", "@hono/node-server": "^2.1.0", - "better-auth": "^1.7.5", + "better-auth": "1.7.7", "date-fns": "^4.4.0", "hono": "^4.13.1", "hono-pino": "^0.10.3", diff --git a/src/auth.js b/src/auth.js index ab3696c..6e11d97 100644 --- a/src/auth.js +++ b/src/auth.js @@ -4,7 +4,7 @@ import { admin, magicLink, jwt } from "better-auth/plugins"; import { oauthProvider } from "@better-auth/oauth-provider"; import appConfig from "./config.js"; import { getGithubUserInfo } from "./auth/github-provider.js"; -import { getGithubAccountId } from "./auth/id-token-claims.js"; +import { plannerIdTokenClaims } from "./auth/id-token-claims.js"; import { devMagicLinks } from "./dev/magic-links.js"; import { buildMagicLinkPayload } from "./app/utils/magic-link-email.js"; @@ -50,11 +50,11 @@ export const auth = betterAuth({ telemetry: { enabled: false, }, - // ponytail: the database strategy's extra signed state cookie check is - // redundant — the state is already validated against the `verification` - // table. Cloudflare strips `__Secure-` prefix cookies on ingress, so the - // cookie fails on the GitHub OAuth callback redirect. skipStateCookieCheck - // skips this check. Better Auth's own oauth-proxy plugin does the same. + // The database strategy's extra signed state cookie check is redundant: + // the state is already validated against the `verification` table. + // Cloudflare strips `__Secure-` prefix cookies on ingress, so the cookie + // fails on the GitHub OAuth callback redirect. skipStateCookieCheck skips + // this check. Better Auth's own oauth-proxy plugin does the same. account: { skipStateCookieCheck: true, }, @@ -83,10 +83,7 @@ export const auth = betterAuth({ accessTokenExpiresIn: 900, // 15 minutes validAudiences: ["planner"], allowDynamicClientRegistration: false, - customIdTokenClaims: async ({ user }) => { - const githubId = await getGithubAccountId(db, user.id); - return githubId ? { github_id: String(githubId) } : {}; - }, + customIdTokenClaims: plannerIdTokenClaims(db), }), magicLink({ sendMagicLink: async ({ email, url }) => { diff --git a/src/auth/id-token-claims.js b/src/auth/id-token-claims.js index 5fe66b9..9c60d6d 100644 --- a/src/auth/id-token-claims.js +++ b/src/auth/id-token-claims.js @@ -17,3 +17,20 @@ export async function getGithubAccountId(db, userId) { return result.rows[0]?.accountId ?? null; } + +/** + * Build the `customIdTokenClaims` hook shared by the app and the tests. + * + * Every id_token carries the user-record `email` and `name` claims: the + * planner resolves members by `email` and falls back to `sub` (the + * better-auth user id) when the claim is absent, which keys a duplicate + * member. `github_id` is added for linked accounts so returning members + * resolve even when their GitHub email diverges from the stored one. + */ +export function plannerIdTokenClaims(db) { + return async ({ user }) => { + const claims = { email: user.email, name: user.name }; + const githubId = await getGithubAccountId(db, user.id); + return githubId ? { ...claims, github_id: String(githubId) } : claims; + }; +} diff --git a/test/helpers/oauth-flow.js b/test/helpers/oauth-flow.js index 1fd24c9..79e83e9 100644 --- a/test/helpers/oauth-flow.js +++ b/test/helpers/oauth-flow.js @@ -9,7 +9,7 @@ export function authorizeParams(overrides = {}) { client_id: "planner", redirect_uri: REDIRECT_URI, response_type: "code", - scope: "openid profile", + scope: "openid profile email", code_challenge: CODE_CHALLENGE, code_challenge_method: "S256", ...overrides, diff --git a/test/helpers/test-instance.js b/test/helpers/test-instance.js index 02af420..46a1525 100644 --- a/test/helpers/test-instance.js +++ b/test/helpers/test-instance.js @@ -4,7 +4,7 @@ import { admin, magicLink, jwt } from "better-auth/plugins"; import { oauthProvider } from "@better-auth/oauth-provider"; import { getMigrations } from "better-auth/db/migration"; import { seedPlannerClient } from "../../src/app/db/seed-client.js"; -import { getGithubAccountId } from "../../src/auth/id-token-claims.js"; +import { plannerIdTokenClaims } from "../../src/auth/id-token-claims.js"; import { AUTH_DEFAULT_PORT, PLANNER_DEFAULT_PORT } from "../../src/config.js"; /** @@ -92,14 +92,11 @@ export async function getTestInstance(t) { }), oauthProvider({ loginPage: "/login", - scopes: ["openid", "profile"], + scopes: ["openid", "profile", "email"], accessTokenExpiresIn: 900, validAudiences: ["planner"], allowDynamicClientRegistration: false, - customIdTokenClaims: async ({ user }) => { - const githubId = await getGithubAccountId(pool, user.id); - return githubId ? { github_id: String(githubId) } : {}; - }, + customIdTokenClaims: plannerIdTokenClaims(pool), }), magicLink({ sendMagicLink: async ({ email, token, url }) => { diff --git a/test/integration/jwt-payload.test.js b/test/integration/jwt-payload.test.js index e3c1855..bde7a65 100644 --- a/test/integration/jwt-payload.test.js +++ b/test/integration/jwt-payload.test.js @@ -21,7 +21,7 @@ test("id_token includes github_id for users with a linked GitHub account", async const authHeaders = await getAuthHeaders(email); const userResult = await testInstance.db.query( - 'SELECT id FROM "user" WHERE email = $1', + 'SELECT id, email, name FROM "user" WHERE email = $1', [email], ); const userId = userResult.rows[0]?.id; @@ -47,6 +47,16 @@ test("id_token includes github_id for users with a linked GitHub account", async const payload = decodeJwtPayload(body.id_token); t.equal(payload.github_id, "987654321", "payload includes linked github_id"); + t.equal( + payload.email, + userResult.rows[0].email, + "payload carries the user's email claim", + ); + t.equal( + payload.name, + userResult.rows[0].name, + "payload carries the user's name claim", + ); }); test("id_token omits github_id for users without a linked GitHub account", async (t) => { diff --git a/test/integration/oauth-flow.test.js b/test/integration/oauth-flow.test.js index a050d99..e225d1b 100644 --- a/test/integration/oauth-flow.test.js +++ b/test/integration/oauth-flow.test.js @@ -3,7 +3,7 @@ import { getTestInstance } from "../helpers/test-instance.js"; import { createApp } from "../../src/app/app.js"; test("end-to-end OAuth 2.1 flow", async (t) => { - const testInstance = await getTestInstance(); + const testInstance = await getTestInstance(t); const app = createApp(testInstance.auth, testInstance.db); const { getAuthHeaders } = testInstance; @@ -21,7 +21,7 @@ test("end-to-end OAuth 2.1 flow", async (t) => { redirect_uri: "http://localhost:3000/auth/codebar/callback", response_type: "code", state: "integration-state", - scope: "openid profile", + scope: "openid profile email", code_challenge: codeChallenge, code_challenge_method: "S256", }); @@ -98,6 +98,25 @@ test("end-to-end OAuth 2.1 flow", async (t) => { t.ok(payload.iat, "payload has issued-at"); t.ok(payload.exp, "payload has expiration"); + // The planner resolves members by the id_token email claim. When the claim + // is missing it falls back to `sub` (the better-auth user id), which creates + // a duplicate planner member keyed by an opaque id. + const userRow = await testInstance.pool.query( + 'SELECT email, name FROM "user" WHERE email = $1', + [email], + ); + t.equal(userRow.rows.length, 1, "test user exists"); + t.equal( + payload.email, + userRow.rows[0].email, + "payload carries the user's email claim", + ); + t.equal( + payload.name, + userRow.rows[0].name, + "payload carries the user's name claim", + ); + // Step 5: Verify the access token is usable (e.g., for userinfo if we had one) // Note: introspection requires client authentication, which is skipped here // since the core flow (authorize -> code -> token -> JWT) is fully validated. diff --git a/test/unit/id-token-claims.test.js b/test/unit/id-token-claims.test.js index 809d108..c74ff52 100644 --- a/test/unit/id-token-claims.test.js +++ b/test/unit/id-token-claims.test.js @@ -1,5 +1,8 @@ import { test } from "tap"; -import { getGithubAccountId } from "../../src/auth/id-token-claims.js"; +import { + getGithubAccountId, + plannerIdTokenClaims, +} from "../../src/auth/id-token-claims.js"; function makeDb(rows) { return { @@ -34,3 +37,34 @@ test("propagates database errors instead of swallowing them", async (t) => { "database error is propagated", ); }); + +test("claims carry the user-record email and name", async (t) => { + const claims = plannerIdTokenClaims(makeDb([])); + const result = await claims({ + user: { id: "user-1", email: "ada@example.com", name: "Ada" }, + }); + + t.same(result, { email: "ada@example.com", name: "Ada" }); +}); + +test("claims add github_id for a linked GitHub account", async (t) => { + const claims = plannerIdTokenClaims(makeDb([{ accountId: "12345" }])); + const result = await claims({ + user: { id: "user-1", email: "ada@example.com", name: "Ada" }, + }); + + t.same(result, { + email: "ada@example.com", + name: "Ada", + github_id: "12345", + }); +}); + +test("claims omit github_id without a linked GitHub account", async (t) => { + const claims = plannerIdTokenClaims(makeDb([])); + const result = await claims({ + user: { id: "user-1", email: "ada@example.com", name: "Ada" }, + }); + + t.notOk(Object.prototype.hasOwnProperty.call(result, "github_id")); +});