Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 49 additions & 49 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 7 additions & 10 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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 }) => {
Expand Down
17 changes: 17 additions & 0 deletions src/auth/id-token-claims.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}
2 changes: 1 addition & 1 deletion test/helpers/oauth-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 3 additions & 6 deletions test/helpers/test-instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 }) => {
Expand Down
12 changes: 11 additions & 1 deletion test/integration/jwt-payload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down
23 changes: 21 additions & 2 deletions test/integration/oauth-flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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",
});
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading