From 195716cb23ccf56f8981066a4f4ff0ded71a6b01 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Wed, 7 Oct 2026 12:18:01 +0200 Subject: [PATCH 1/2] test: align the oauthProvider scopes in the test instance with production --- test/helpers/test-instance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/test-instance.js b/test/helpers/test-instance.js index 02af420..90aeb97 100644 --- a/test/helpers/test-instance.js +++ b/test/helpers/test-instance.js @@ -92,7 +92,7 @@ export async function getTestInstance(t) { }), oauthProvider({ loginPage: "/login", - scopes: ["openid", "profile"], + scopes: ["openid", "profile", "email"], accessTokenExpiresIn: 900, validAudiences: ["planner"], allowDynamicClientRegistration: false, From 1a98095e117310da35401f3be95a854ac1b6643d Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Wed, 7 Oct 2026 12:18:01 +0200 Subject: [PATCH 2/2] test: assert the userinfo claims in the OAuth flow Since better-auth 1.7 the id_token is sparse and the scope-gated claims live in the UserInfo response. The end-to-end flow now requests the planner's scopes (openid profile email) and asserts what the planner resolves identity from: the sub claim matching the id_token, plus email, email_verified, and name. --- test/integration/oauth-flow.test.js | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/test/integration/oauth-flow.test.js b/test/integration/oauth-flow.test.js index a050d99..e7c908b 100644 --- a/test/integration/oauth-flow.test.js +++ b/test/integration/oauth-flow.test.js @@ -5,13 +5,19 @@ import { createApp } from "../../src/app/app.js"; test("end-to-end OAuth 2.1 flow", async (t) => { const testInstance = await getTestInstance(); const app = createApp(testInstance.auth, testInstance.db); - const { getAuthHeaders } = testInstance; + const { getAuthHeaders, pool } = testInstance; // Step 1: Authenticate a user via magic link const email = "oauth-integration@example.com"; const { cookie: sessionCookie } = await getAuthHeaders(email); t.ok(sessionCookie, "session token extracted"); + // Give the user a display name so the `name` claim has a real value to assert + await pool.query('UPDATE "user" SET name = $1 WHERE email = $2', [ + "Ada Lovelace", + email, + ]); + // Step 2: Call authorize endpoint with PKCE const codeVerifier = "test-verifier-123456789"; const codeChallenge = "MJk6-W6P2z_PgOvWcEvbyqeIyc-GthZov8-QX37r0Vo"; @@ -21,7 +27,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,7 +104,20 @@ 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"); - // 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. + // Step 5: Fetch the OIDC UserInfo response. Since better-auth 1.7 the id_token + // is sparse and the scope-gated claims (email, name) live here, which is where + // the planner resolves member identity from. + const userinfoRes = await app.request("/api/auth/oauth2/userinfo", { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + + t.equal(userinfoRes.status, 200, "userinfo endpoint returns 200"); + + const userinfo = await userinfoRes.json(); + t.equal(userinfo.sub, payload.sub, "userinfo sub matches the id_token sub"); + t.equal(userinfo.email, email, "userinfo carries the email claim"); + t.equal(userinfo.email_verified, true, "userinfo carries email_verified"); + t.equal(userinfo.name, "Ada Lovelace", "userinfo carries the name claim"); + + // Introspection is not covered here because it requires client authentication. });