diff --git a/PACKAGES.md b/PACKAGES.md index 0185ae9..72fc1c9 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -48,6 +48,7 @@ type Env = BetterAuthWorkerBindings & { const auth = createBetterAuthWorker({ appName: 'Type The Rhythm', sessionPolicy: 'single', // Dormouse uses 'multiple'. + accountLinking: 'same-email', // Default: 'explicit'. successPath: '/profile', errorPath: '/login', email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), @@ -77,3 +78,27 @@ The [working example](examples/better-auth/README.md) documents the HTTP protoco security choices, native session-token storage tradeoff, test coverage and provider callback registration. `packages:verify` also installs and exercises this integration from the tarball alongside the legacy auth and Stripe packages. + +### Account linking policies + +`accountLinking: 'explicit'` (default) requires an authenticated Connect action +and permits different verified emails. `same-email` enables automatic matching +and makes POST `/api/auth/link-social` return 404, including for signed-in users. +Google always requests its account chooser. Existing provider bindings remain +stable if a provider later changes its email; policy changes do not unlink them. + +For a new identity in `same-email` mode, Apple and Google Gmail/Workspace can +establish mailbox ownership. Google third-party addresses, Facebook and GitHub +require a live email-OTP session for the same address, less than ten minutes old. +OAuth-only sessions cannot supply this proof. Unverified/missing provider email +still fails closed. Case-insensitive matching does not collapse aliases or relay +addresses. Apple Hide My Email therefore creates a separate account. + +The fallback callback redirects to `errorPath` with +`error=email_verification_required&provider=google` (or the relevant provider). +Show an email-code form, verify that provider account's email with the normal +email-OTP API, then retry `/api/auth/sign-in/social` in that browser. Do not redirect +to Profile merely because this intermediate email login created a session. On +success, the native provider binding makes future OAuth logins code-free. Allow +cancelling the continuation to use ordinary email login instead. The server checks +the proof; query parameters grant no authority and contain no email or tokens. diff --git a/examples/better-auth/README.md b/examples/better-auth/README.md index 3c60356..86127e6 100644 --- a/examples/better-auth/README.md +++ b/examples/better-auth/README.md @@ -41,11 +41,13 @@ and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment. authenticate: the cookie also needs the server signature, and no bearer plugin is enabled. Browser JSON omits these tokens. This is an explicit upstream storage tradeoff; the test suite proves a bare database token is rejected. -- OAuth identities never merge just because their email addresses match. Sign in +- The default `accountLinking: 'explicit'` policy never merges OAuth identities just because their email addresses match. Sign in by email or an existing provider, then explicitly connect another provider. Connecting requires a session less than ten minutes old, and the callback must still carry that same live session. Different verified provider emails are allowed, including Apple's private relay address. An identity cannot belong to two users. + Consumers can choose automatic `same-email` linking with an email-code fallback; + see [the policy and continuation protocol](../../PACKAGES.md#account-linking-policies). - Callbacks check provider, signed browser state, expiry and an atomic Postgres replay claim. Apple form_post relays to a GET that receives the Lax cookies. Callback destinations are fixed to the application origin. Direct provider-token @@ -99,4 +101,4 @@ JWT; its private relay also requires the mail sender to be registered with Apple Enter secrets directly into deployment tooling, not chat, source files or logs. Do not delete Supabase/Pages until email and each required provider pass in a real browser. The TTR test must also confirm that a second device's login signs out -the first, and that explicit linking works without creating a duplicate account. +the first, and that its chosen linking policy preserves account IDs across login methods. diff --git a/packages/auth/better-auth-migrations/004_email_session_proof.sql b/packages/auth/better-auth-migrations/004_email_session_proof.sql new file mode 100644 index 0000000..6c23f1b --- /dev/null +++ b/packages/auth/better-auth-migrations/004_email_session_proof.sql @@ -0,0 +1,4 @@ +-- Up Migration +-- Only sessions created by a successful email OTP can prove current mailbox ownership. +-- Existing sessions must verify again before supplying this proof. +alter table "session" add column "emailAuthenticated" boolean not null default false; diff --git a/packages/auth/src/better-auth-oauth.ts b/packages/auth/src/better-auth-oauth.ts index e827759..ec76883 100644 --- a/packages/auth/src/better-auth-oauth.ts +++ b/packages/auth/src/better-auth-oauth.ts @@ -33,6 +33,8 @@ export const verifiedOidc: BetterAuthPlugin = { if (!data.idTokenNonce) throw new Error('Missing OIDC nonce'); const url = await authorization(data); url.searchParams.set('nonce', data.idTokenNonce); + if (provider.id === 'google') + url.searchParams.set('prompt', 'select_account'); return url; }; const info = provider.getUserInfo.bind(provider); @@ -157,6 +159,7 @@ export async function oauthRequest( origin: string; secret: string; oauth?: OAuthSettings; + accountLinking?: 'explicit' | 'same-email'; successPath?: string; errorPath?: string; }, @@ -247,6 +250,7 @@ export async function oauthRequest( return fail(); } if (data.pgstencilProvider !== provider) return fail(); + if (data.link && options.accountLinking === 'same-email') return fail(); if (data.link) { const session = await auth.api.getSession({ headers: request.headers }); if ( @@ -272,6 +276,15 @@ export async function oauthRequest( const location = response.headers.get('location'); if (path.startsWith('/callback/') && location) { const redirect = new URL(location, options.origin); + if ( + options.accountLinking === 'same-email' && + redirect.searchParams.get('error') === 'email_verification_required' + ) { + const url = new URL(options.errorPath ?? '/', options.origin); + url.searchParams.set('error', 'email_verification_required'); + url.searchParams.set('provider', path.slice('/callback/'.length)); + return Response.redirect(url.href, 303); + } if (redirect.searchParams.has('error')) return fail(); } return response; diff --git a/packages/auth/src/better-auth-security.ts b/packages/auth/src/better-auth-security.ts index de9b81d..d5d223b 100644 --- a/packages/auth/src/better-auth-security.ts +++ b/packages/auth/src/better-auth-security.ts @@ -53,6 +53,7 @@ export function protectAuth( secret: string; database: ReturnType; ipAddressHeaders?: string[]; + accountLinking?: 'explicit' | 'same-email'; }, ) { const secure = options.origin.startsWith('https:'); @@ -91,6 +92,8 @@ export function protectAuth( }); app.use('/api/auth/*', async (c, next) => { const path = c.req.path.slice('/api/auth'.length); + if (path === '/link-social' && options.accountLinking === 'same-email') + return c.json({ message: 'Not found' }, 404); const callback = /^\/callback\/(google|github|apple|facebook)$/.test(path); const reads = ['/get-session', '/list-accounts']; const writes = [ @@ -205,6 +208,7 @@ export async function publicAuthResponse(response: Response) { 'refreshToken', 'idToken', 'singleSession', + 'emailAuthenticated', ].includes(key), ) .map(([key, item]) => [key, scrub(item)]), diff --git a/packages/auth/src/better-auth-workers.ts b/packages/auth/src/better-auth-workers.ts index 5c57fc8..4ce5fbf 100644 --- a/packages/auth/src/better-auth-workers.ts +++ b/packages/auth/src/better-auth-workers.ts @@ -22,7 +22,7 @@ export type BetterAuthWorkerBindings = { export function createBetterAuthWorker( options: Pick< AuthAppOptions, - 'sessionPolicy' | 'appName' | 'successPath' | 'errorPath' + 'sessionPolicy' | 'accountLinking' | 'appName' | 'successPath' | 'errorPath' > & { email: (env: E) => EmailSender; }, diff --git a/packages/auth/src/better-auth.ts b/packages/auth/src/better-auth.ts index b10d473..78c6c14 100644 --- a/packages/auth/src/better-auth.ts +++ b/packages/auth/src/better-auth.ts @@ -1,6 +1,8 @@ import { betterAuth, type BetterAuthOptions } from 'better-auth'; +import { getSessionFromCtx } from 'better-auth/api'; import { emailOTP } from 'better-auth/plugins/email-otp'; import { Hono } from 'hono'; +import { sql } from 'kysely'; import { connectDatabase } from 'pgstencil/postgres'; import type { EmailSender } from 'pgstencil'; import { @@ -22,6 +24,7 @@ export interface AuthOptions { email: EmailSender; ipAddressHeaders?: string[]; sessionPolicy?: 'single' | 'multiple'; + accountLinking?: 'explicit' | 'same-email'; oauth?: OAuthSettings; appName?: string; successPath?: string; @@ -57,7 +60,7 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { socialProviders: socialProviders(options.oauth), onAPIError: { errorURL: options.origin + (options.errorPath ?? '/') }, user: { - validateUserInfo: async ({ user, source }) => { + validateUserInfo: async ({ user, source }, context) => { if ( source.method === 'oauth' && (user.emailVerified !== true || @@ -65,6 +68,35 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(user.email)) ) return { error: 'A verified email address is required' }; + if ( + options.accountLinking === 'same-email' && + source.method === 'oauth' && + source.action !== 'sign-in' + ) { + const email = user.email?.toLowerCase() ?? ''; + const profile = source.oauth?.profile; + const authoritative = + source.oauth?.providerId === 'apple' || + (source.oauth?.providerId === 'google' && + (email.endsWith('@gmail.com') || + (typeof profile?.hd === 'string' && profile.hd.length > 0))); + if (!authoritative) { + // An email OTP proves current ownership where the provider cannot. + // Only the still-live session created by that OTP can supply proof. + const proof = await getSessionFromCtx(context); + if ( + !proof || + proof.user.email.toLowerCase() !== email || + Date.now() - proof.session.createdAt.getTime() >= 600_000 || + !( + await sql`SELECT id FROM session WHERE id = ${proof.session.id} AND "emailAuthenticated" = true AND "expiresAt" > ${new Date()}`.execute( + options.database, + ) + ).rows.length + ) + return { error: 'email_verification_required' }; + } + } }, }, // Keep production security enabled under NODE_ENV=test as well. @@ -90,6 +122,13 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { rateLimit: { enabled: true, storage: 'database' }, session: { additionalFields: { + emailAuthenticated: { + type: 'boolean', + required: true, + defaultValue: false, + input: false, + returned: false, + }, singleSession: { type: 'boolean', required: true, @@ -128,10 +167,11 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { }, session: { create: { - before: async (session) => ({ + before: async (session, context) => ({ data: { ...session, singleSession: options.sessionPolicy === 'single', + emailAuthenticated: context?.path === '/sign-in/email-otp', }, }), }, @@ -142,8 +182,8 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { storeAccountCookie: false, storeStateStrategy: 'database', accountLinking: { - disableImplicitLinking: true, - allowDifferentEmails: true, + disableImplicitLinking: options.accountLinking !== 'same-email', + allowDifferentEmails: options.accountLinking !== 'same-email', }, }, plugins: [ diff --git a/tests/integration/better-auth-oauth.test.ts b/tests/integration/better-auth-oauth.test.ts index 27993bf..26c27e2 100644 --- a/tests/integration/better-auth-oauth.test.ts +++ b/tests/integration/better-auth-oauth.test.ts @@ -39,7 +39,10 @@ const built = (async () => { createDeterministicApp: typeof createDeterministicApp; }; })(); -async function fixture(policy: 'single' | 'multiple' = 'multiple') { +async function fixture( + policy: 'single' | 'multiple' = 'multiple', + accountLinking: 'explicit' | 'same-email' = 'explicit', +) { const context = await createTestContext({ migrations: resolve('packages/auth/better-auth-migrations'), seed: 'better-auth-oauth', @@ -64,6 +67,7 @@ async function fixture(policy: 'single' | 'multiple' = 'multiple') { origin, secret: 'better-auth-local-oauth-secret-32-characters', sessionPolicy: policy, + accountLinking, oauth: allOAuthCredentials, outboundFetch, }); @@ -478,3 +482,146 @@ test('Better Auth OAuth: parallel applications reproduce cookies and session tim ); expect(rows[0]).toEqual(rows[1]); }); + +async function emailLogin(f: Fixture, browser: Browser, email: string) { + expect( + ( + await browser.post('email-otp/send-verification-otp', { + email, + type: 'sign-in', + }) + ).status, + ).toBe(200); + const otp = (await f.email.next()).text.match(/\b\d{8}\b/)![0]; + expect((await browser.post('sign-in/email-otp', { email, otp })).status).toBe( + 200, + ); + return (await session(browser))!.user.id; +} + +for (const oauthFirst of [false, true]) + test(`Same-email linking: ${oauthFirst ? 'Google then email' : 'email then Google'} shares one account and revokes the old session`, async ({ + onTestFinished, + }) => { + const f = await fixture('single', 'same-email'); + onTestFinished(() => f.close()); + const email = 'player@gmail.com'; + const emailBrowser = await f.browser(), + googleBrowser = await f.browser(); + let id: string; + if (oauthFirst) { + const result = await login(f, googleBrowser, 'google', { email }); + expect(result.response.headers.get('location')).toBe(origin + '/'); + expect(result.authorization.searchParams.get('prompt')).toBe( + 'select_account', + ); + id = (await session(googleBrowser))!.user.id; + expect(await emailLogin(f, emailBrowser, email)).toBe(id); + expect(await session(googleBrowser)).toBeNull(); + } else { + id = await emailLogin(f, emailBrowser, email); + expect( + ( + await login(f, googleBrowser, 'google', { email }) + ).response.headers.get('location'), + ).toBe(origin + '/'); + expect((await session(googleBrowser))!.user.id).toBe(id); + expect(await session(emailBrowser)).toBeNull(); + } + expect( + await queryDatabase(f.database.url, 'SELECT id, email FROM "user"'), + ).toEqual([{ id, email }]); + expect( + (await emailBrowser.post('link-social', { provider: 'google' })).status, + ).toBe(404); + }); + +test('Same-email linking: Workspace proof is authoritative; a different email and Apple relay remain separate accounts', async ({ + onTestFinished, +}) => { + const f = await fixture('multiple', 'same-email'); + onTestFinished(() => f.close()); + const emailBrowser = await f.browser(), + google = await f.browser(), + apple = await f.browser(); + const id = await emailLogin(f, emailBrowser, 'owner@example.test'); + await login(f, google, 'google', { + email: 'owner@example.test', + claims: { hd: 'example.test' }, + }); + expect((await session(google))!.user.id).toBe(id); + await login(f, apple, 'apple', { email: 'relay@privaterelay.appleid.com' }); + expect((await session(apple))!.user.id).not.toBe(id); + // Aliases and forwarding do not establish an email match. + const alias = await f.browser(); + await login(f, alias, 'google', { + subject: 'other-google', + email: 'alias@example.test', + claims: { hd: 'example.test' }, + }); + expect((await session(alias))!.user.id).not.toBe(id); +}); + +for (const provider of ['google', 'facebook', 'github'] as const) + test(`Same-email linking: ${provider} requires a fresh email session for non-authoritative email`, async ({ + onTestFinished, + }) => { + const f = await fixture('single', 'same-email'); + onTestFinished(() => f.close()); + const browser = await f.browser(); + const first = await login(f, browser, provider); + expect(first.response.headers.get('location')).toBe( + origin + '/?error=email_verification_required&provider=' + provider, + ); + expect(await session(browser)).toBeNull(); + expect( + await queryDatabase(f.database.url, 'SELECT id FROM "user"'), + ).toEqual([]); + const id = await emailLogin(f, browser, 'oauth@example.test'); + const result = await login(f, browser, provider); + expect(result.response.headers.get('location')).toBe(origin + '/'); + expect((await session(browser))!.user.id).toBe(id); + const other = await f.browser(); + // Once linked, the stable provider identity is sufficient, without another code. + expect( + (await login(f, other, provider)).response.headers.get('location'), + ).toBe(origin + '/'); + expect((await session(other))!.user.id).toBe(id); + expect(await session(browser)).toBeNull(); + }); + +test('Same-email linking: wrong-email, OAuth-only, expired and revoked sessions cannot supply mailbox proof', async ({ + onTestFinished, +}) => { + const f = await fixture('single', 'same-email'); + onTestFinished(() => f.close()); + const browser = await f.browser(); + await emailLogin(f, browser, 'different@example.test'); + expect( + (await login(f, browser, 'google')).response.headers.get('location'), + ).toContain('error=email_verification_required'); + await browser.post('sign-out', {}); + f.time.advanceMilliseconds(11_000); + await login(f, browser, 'apple'); + expect( + (await login(f, browser, 'google')).response.headers.get('location'), + ).toContain('error=email_verification_required'); + await emailLogin(f, browser, 'oauth@example.test'); + const pending = f.provider.authorize( + 'google', + await start(browser, 'google'), + ); + await browser.post('sign-out', {}); + expect((await browser.follow(pending)).headers.get('location')).toContain( + 'error=email_verification_required', + ); + f.time.advanceMilliseconds(60_000); + await emailLogin(f, browser, 'oauth@example.test'); + f.time.advanceMilliseconds(600_000); + expect( + (await login(f, browser, 'google')).response.headers.get('location'), + ).toContain('error=email_verification_required'); + expect( + await queryDatabase(f.database.url, 'SELECT "providerId" FROM account'), + ).toEqual([{ providerId: 'apple' }]); +}); diff --git a/tests/integration/snapshots/better-auth-session.json b/tests/integration/snapshots/better-auth-session.json index 7ac740a..bca2d30 100644 --- a/tests/integration/snapshots/better-auth-session.json +++ b/tests/integration/snapshots/better-auth-session.json @@ -1,6 +1,7 @@ [ { "createdAt": "2020-01-01T00:00:00.000Z", + "emailAuthenticated": true, "expiresAt": "2020-01-02T00:00:00.000Z", "id": "4jTWBPymxPMErp1QsQeJ4O6cDuEjb0zY", "ipAddress": "127.0.0.1",