From 914bf0ab0558634d99a4b5b43554189903988f0d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 11 Sep 2026 11:49:08 -0700 Subject: [PATCH 1/2] Support trusted provider emails and optional-email OAuth accounts --- PACKAGES.md | 21 ++- examples/better-auth/README.md | 6 +- packages/auth/src/better-auth-email.ts | 26 +++ packages/auth/src/better-auth-oauth.ts | 55 ++++++- packages/auth/src/better-auth-security.ts | 14 +- packages/auth/src/better-auth-workers.ts | 8 +- packages/auth/src/better-auth.ts | 31 +++- tests/integration/better-auth-oauth.test.ts | 174 +++++++++++++++++++- 8 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 packages/auth/src/better-auth-email.ts diff --git a/PACKAGES.md b/PACKAGES.md index 72fc1c9..d245745 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -49,6 +49,8 @@ const auth = createBetterAuthWorker({ appName: 'Type The Rhythm', sessionPolicy: 'single', // Dormouse uses 'multiple'. accountLinking: 'same-email', // Default: 'explicit'. + trustedEmailProviders: ['google', 'apple', 'facebook'], // Skip the extra mailbox code. + allowMissingEmail: true, // Optional: provider-only accounts have public email: null. successPath: '/profile', errorPath: '/login', email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), @@ -87,11 +89,14 @@ 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 +By default, 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 +still fails closed. `trustedEmailProviders` skips the extra email-OTP proof for the +listed providers. It trusts their verified email assertions (including Facebook’s +authenticated profile email) for both signup and automatic same-email linking. +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 @@ -102,3 +107,15 @@ 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. + +With `allowMissingEmail: true`, a verified provider identity can sign up without +an email. Better Auth requires a non-null email column, so pgstencil uses an +unverified, provider/client/subject-specific address at `identity.pgstencil.invalid`. +HTTP session responses expose that address as `email: null`; email-code routes +and the email sender reject that namespace. Never use internal Better Auth user +emails as delivery addresses without checking them. No SQL migration is needed. +Existing bindings retain their account and canonical email if provider email +permission disappears. Provider-only accounts stay provider-only even if the +provider later supplies an email; adopting an address or merging existing accounts +requires a separate account-recovery flow. Without a common email or an existing +binding, different providers cannot be matched automatically. diff --git a/examples/better-auth/README.md b/examples/better-auth/README.md index 86127e6..ba20f05 100644 --- a/examples/better-auth/README.md +++ b/examples/better-auth/README.md @@ -46,7 +46,8 @@ and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment. 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; + Consumers can choose automatic `same-email` linking and trust selected providers’ + emails without an extra code; the conservative default keeps 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. @@ -57,6 +58,9 @@ and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment. nonce verification through its plugin API. Negative tests cover each check. GitHub requires a verified primary email; Facebook uses the authenticated email after Better Auth validates that the access token belongs to our app and user. +- `allowMissingEmail` optionally permits provider-only accounts. Public sessions + expose `email: null`; the reserved internal address cannot receive login codes. + See [package policy options](../../PACKAGES.md#better-auth-integration) for matching and recovery limits. - Provider access, refresh and ID tokens are discarded after identity verification. They are not kept in the database or returned to the browser. diff --git a/packages/auth/src/better-auth-email.ts b/packages/auth/src/better-auth-email.ts new file mode 100644 index 0000000..a8aa909 --- /dev/null +++ b/packages/auth/src/better-auth-email.ts @@ -0,0 +1,26 @@ +import { createHash } from 'node:crypto'; + +const identityDomain = '@identity.pgstencil.invalid'; + +export function isIdentityEmail(email: string) { + return email.toLowerCase().endsWith(identityDomain); +} + +/** Better Auth requires an email column, even for a provider-only account. + * This reserved address identifies an account; it is never a delivery address. + * Include the client ID because provider subjects can be scoped to an app. + */ +export function identityEmail( + provider: string, + clientId: string, + subject: unknown, +) { + if (typeof subject !== 'string' && typeof subject !== 'number') + throw new Error('Missing provider identity'); + if (!String(subject)) throw new Error('Missing provider identity'); + return ( + createHash('sha256') + .update(JSON.stringify([provider, clientId, String(subject)])) + .digest('hex') + identityDomain + ); +} diff --git a/packages/auth/src/better-auth-oauth.ts b/packages/auth/src/better-auth-oauth.ts index ec76883..a1c9664 100644 --- a/packages/auth/src/better-auth-oauth.ts +++ b/packages/auth/src/better-auth-oauth.ts @@ -4,6 +4,7 @@ import type { GithubProfile } from 'better-auth/social-providers'; import { makeSignature } from 'better-auth/crypto'; import { sql } from 'kysely'; import { equal, keyed } from './better-auth-security.ts'; +import { identityEmail, isIdentityEmail } from './better-auth-email.ts'; import type { connectDatabase } from 'pgstencil/postgres'; export const providers = ['google', 'apple', 'facebook', 'github'] as const; @@ -73,9 +74,47 @@ export function oauthFromEnvironment( export function socialProviders( settings: OAuthSettings = {}, + allowMissingEmail = false, ): BetterAuthOptions['socialProviders'] { + const missingEmail = ( + provider: Provider, + profile: { email?: string | null; sub?: string; id?: string | number }, + ) => { + if (profile.email) { + if (isIdentityEmail(profile.email)) + return { email: '', emailVerified: false }; + return {}; + } + return allowMissingEmail + ? { + email: identityEmail( + provider, + settings[provider]!.clientId, + profile.sub ?? profile.id, + ), + emailVerified: false, + } + : {}; + }; return { ...settings, + ...(settings.google + ? { + google: { + ...settings.google, + mapProfileToUser: async (profile) => + missingEmail('google', profile), + }, + } + : {}), + ...(settings.apple + ? { + apple: { + ...settings.apple, + mapProfileToUser: async (profile) => missingEmail('apple', profile), + }, + } + : {}), ...(settings.facebook ? { facebook: { @@ -84,6 +123,7 @@ export function socialProviders( // Facebook's authenticated primary email is our proof, as in the old adapter. mapProfileToUser: async (profile) => ({ emailVerified: !!profile.email, + ...missingEmail('facebook', profile), }), }, } @@ -133,7 +173,20 @@ export function socialProviders( }; if (emails.length < 100) break; } - return null; + return allowMissingEmail + ? { + user: { + name: profile.name ?? profile.login ?? '', + email: identityEmail( + 'github', + settings.github!.clientId, + profile.id, + ), + emailVerified: false, + }, + data: profile, + } + : null; }, }, } diff --git a/packages/auth/src/better-auth-security.ts b/packages/auth/src/better-auth-security.ts index d5d223b..4936311 100644 --- a/packages/auth/src/better-auth-security.ts +++ b/packages/auth/src/better-auth-security.ts @@ -1,6 +1,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { sql } from 'kysely'; import type { Hono } from 'hono'; +import { isIdentityEmail } from './better-auth-email.ts'; import { bodyLimit } from 'hono/body-limit'; import type { connectDatabase } from 'pgstencil/postgres'; @@ -143,7 +144,11 @@ export function protectAuth( ) { const email = typeof body.email === 'string' ? body.email.toLowerCase() : ''; - if (email.length > 254 || !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email)) + if ( + isIdentityEmail(email) || + email.length > 254 || + !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email) + ) return c.json({ message: 'Invalid email' }, 400); const send = path === '/email-otp/send-verification-otp'; if (send && body.type !== 'sign-in') @@ -211,7 +216,12 @@ export async function publicAuthResponse(response: Response) { 'emailAuthenticated', ].includes(key), ) - .map(([key, item]) => [key, scrub(item)]), + .map(([key, item]) => [ + key, + key === 'email' && typeof item === 'string' && isIdentityEmail(item) + ? null + : scrub(item), + ]), ); }; const headers = new Headers(response.headers); diff --git a/packages/auth/src/better-auth-workers.ts b/packages/auth/src/better-auth-workers.ts index 4ce5fbf..3989a28 100644 --- a/packages/auth/src/better-auth-workers.ts +++ b/packages/auth/src/better-auth-workers.ts @@ -22,7 +22,13 @@ export type BetterAuthWorkerBindings = { export function createBetterAuthWorker( options: Pick< AuthAppOptions, - 'sessionPolicy' | 'accountLinking' | 'appName' | 'successPath' | 'errorPath' + | 'sessionPolicy' + | 'accountLinking' + | 'trustedEmailProviders' + | 'allowMissingEmail' + | 'appName' + | 'successPath' + | 'errorPath' > & { email: (env: E) => EmailSender; }, diff --git a/packages/auth/src/better-auth.ts b/packages/auth/src/better-auth.ts index 78c6c14..f0cbb1f 100644 --- a/packages/auth/src/better-auth.ts +++ b/packages/auth/src/better-auth.ts @@ -15,8 +15,11 @@ import { verifiedOidc, oauthRequest, type OAuthSettings, + type Provider, } from './better-auth-oauth.ts'; +import { identityEmail, isIdentityEmail } from './better-auth-email.ts'; + export interface AuthOptions { database: ReturnType; origin: string; @@ -25,6 +28,10 @@ export interface AuthOptions { ipAddressHeaders?: string[]; sessionPolicy?: 'single' | 'multiple'; accountLinking?: 'explicit' | 'same-email'; + /** Accept these providers' verified email assertions without a local email code. */ + trustedEmailProviders?: Provider[]; + /** Permit provider-only accounts; their public session email is null. */ + allowMissingEmail?: boolean; oauth?: OAuthSettings; appName?: string; successPath?: string; @@ -57,10 +64,28 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { database: { db: options.database, type: 'postgres', transaction: true }, telemetry: { enabled: false }, logger: { disabled: true }, - socialProviders: socialProviders(options.oauth), + socialProviders: socialProviders(options.oauth, options.allowMissingEmail), onAPIError: { errorURL: options.origin + (options.errorPath ?? '/') }, user: { validateUserInfo: async ({ user, source }, context) => { + if (typeof user.email === 'string' && isIdentityEmail(user.email)) { + const provider = source.oauth?.providerId as Provider; + const profile = source.oauth?.profile; + if ( + options.allowMissingEmail && + source.method === 'oauth' && + options.oauth?.[provider] && + user.emailVerified === false && + user.email === + identityEmail( + provider, + options.oauth[provider].clientId, + profile?.sub ?? profile?.id, + ) + ) + return; + return { error: 'Reserved email address' }; + } if ( source.method === 'oauth' && (user.emailVerified !== true || @@ -76,6 +101,9 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { const email = user.email?.toLowerCase() ?? ''; const profile = source.oauth?.profile; const authoritative = + options.trustedEmailProviders?.includes( + source.oauth?.providerId as Provider, + ) || source.oauth?.providerId === 'apple' || (source.oauth?.providerId === 'google' && (email.endsWith('@gmail.com') || @@ -196,6 +224,7 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { hash: async (otp) => keyed(options.secret, 'email-otp', otp), }, async sendVerificationOTP({ email, otp, type }) { + if (isIdentityEmail(email)) throw new Error('Not a delivery address'); if (type !== 'sign-in') throw new Error('This example only supports sign-in email'); await options.email.send({ diff --git a/tests/integration/better-auth-oauth.test.ts b/tests/integration/better-auth-oauth.test.ts index 26c27e2..7ee8582 100644 --- a/tests/integration/better-auth-oauth.test.ts +++ b/tests/integration/better-auth-oauth.test.ts @@ -12,6 +12,8 @@ import { allOAuthCredentials, type GrantOptions, } from '../support/oauth-server.ts'; +import type { AuthOptions } from '../../packages/auth/src/better-auth.ts'; +import { identityEmail } from '../../packages/auth/src/better-auth-email.ts'; import type { createDeterministicApp } from '../support/better-auth-entry.ts'; import { providers, @@ -42,6 +44,10 @@ const built = (async () => { async function fixture( policy: 'single' | 'multiple' = 'multiple', accountLinking: 'explicit' | 'same-email' = 'explicit', + emailPolicy: Pick< + AuthOptions, + 'trustedEmailProviders' | 'allowMissingEmail' + > = {}, ) { const context = await createTestContext({ migrations: resolve('packages/auth/better-auth-migrations'), @@ -68,6 +74,7 @@ async function fixture( secret: 'better-auth-local-oauth-secret-32-characters', sessionPolicy: policy, accountLinking, + ...emailPolicy, oauth: allOAuthCredentials, outboundFetch, }); @@ -153,7 +160,7 @@ async function login( } async function session(browser: Browser) { return (await (await browser.get('/api/auth/get-session')).json()) as { - user: { id: string; email: string }; + user: { id: string; email: string | null }; session: { id: string }; } | null; } @@ -499,6 +506,171 @@ async function emailLogin(f: Fixture, browser: Browser, email: string) { return (await session(browser))!.user.id; } +for (const oauthFirst of [false, true]) + test(`Trusted Facebook email: ${oauthFirst ? 'OAuth then email' : 'email then OAuth'} shares an account without an extra code`, async ({ + onTestFinished, + }) => { + const f = await fixture('single', 'same-email', { + trustedEmailProviders: ['facebook', 'google'], + }); + onTestFinished(() => f.close()); + const email = 'player@example.test'; + const emailBrowser = await f.browser(), + facebook = await f.browser(); + let id: string; + if (oauthFirst) { + expect( + (await login(f, facebook, 'facebook', { email })).response.headers.get( + 'location', + ), + ).toBe(origin + '/'); + id = (await session(facebook))!.user.id; + expect(await emailLogin(f, emailBrowser, email)).toBe(id); + expect(await session(facebook)).toBeNull(); + } else { + id = await emailLogin(f, emailBrowser, email); + expect( + (await login(f, facebook, 'facebook', { email })).response.headers.get( + 'location', + ), + ).toBe(origin + '/'); + expect((await session(facebook))!.user.id).toBe(id); + expect(await session(emailBrowser)).toBeNull(); + } + // A third method can join directly, including a Google account with a third-party email. + const google = await f.browser(); + expect( + (await login(f, google, 'google', { email })).response.headers.get( + 'location', + ), + ).toBe(origin + '/'); + expect((await session(google))!.user.id).toBe(id); + expect(await session(facebook)).toBeNull(); + expect(await session(emailBrowser)).toBeNull(); + expect( + await queryDatabase(f.database.url, 'SELECT id FROM "user"'), + ).toEqual([{ id }]); + }); + +for (const provider of providers) + test(`Optional email: ${provider} signs in by stable identity without a mailbox`, async ({ + onTestFinished, + }) => { + const f = await fixture('single', 'same-email', { + allowMissingEmail: true, + trustedEmailProviders: [...providers], + }); + onTestFinished(() => f.close()); + const first = await f.browser(), + second = await f.browser(); + expect( + ( + await login(f, first, provider, { email: '', githubEmails: [] }) + ).response.headers.get('location'), + ).toBe(origin + '/'); + const user = (await session(first))!.user; + expect(user.email).toBeNull(); + expect( + ( + await login(f, second, provider, { email: '', githubEmails: [] }) + ).response.headers.get('location'), + ).toBe(origin + '/'); + expect((await session(second))!.user).toMatchObject(user); + expect(await session(first)).toBeNull(); + const other = await f.browser(); + await login(f, other, provider, { + subject: '987654321', + email: '', + githubEmails: [], + }); + expect((await session(other))!.user.id).not.toBe(user.id); + expect((await session(second))!.user.id).toBe(user.id); + const rows = await queryDatabase( + f.database.url, + 'SELECT email, "emailVerified" FROM "user"', + ); + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.emailVerified === false)).toBe(true); + const internalEmail = String(rows[0]!.email); + for (const path of ['email-otp/send-verification-otp', 'sign-in/email-otp']) + expect( + ( + await other.post(path, { + email: internalEmail, + type: 'sign-in', + otp: '12345678', + }) + ).status, + ).toBe(400); + // A provider must not supply somebody else's internal address as its email. + const attacker = await f.browser(); + expect( + ( + await login(f, attacker, 'facebook', { + subject: 'attacker', + email: internalEmail, + }) + ).response.headers.get('location'), + ).toContain('error=oauth_failed'); + expect(await session(attacker)).toBeNull(); + expect(f.email.all()).toEqual([]); + // A returning subject owns its original account, even when a newly supplied + // email belongs to another existing account. Never silently move purchases. + const emailOwner = await f.browser(); + const emailId = await emailLogin(f, emailOwner, 'later@example.test'); + await login(f, second, provider, { email: 'later@example.test' }); + expect((await session(second))!.user).toMatchObject(user); + expect((await session(emailOwner))!.user.id).toBe(emailId); + expect(emailId).not.toBe(user.id); + }); + +test('Optional email: losing email permission preserves an existing account and its real email', async ({ + onTestFinished, +}) => { + const f = await fixture('single', 'same-email', { + allowMissingEmail: true, + trustedEmailProviders: ['facebook'], + }); + onTestFinished(() => f.close()); + const first = await f.browser(), + returning = await f.browser(); + await login(f, first, 'facebook'); + const user = (await session(first))!.user; + await login(f, returning, 'facebook', { email: '' }); + expect((await session(returning))!.user).toEqual(user); + expect(await session(first)).toBeNull(); +}); + +test('Optional email never accepts a supplied but unverified email or invalid OIDC identity', async ({ + onTestFinished, +}) => { + const f = await fixture('single', 'same-email', { + allowMissingEmail: true, + trustedEmailProviders: [...providers], + }); + onTestFinished(() => f.close()); + for (const options of [ + { verified: false }, + { email: '', badSignature: true }, + { email: '', claims: { nonce: 'wrong' } }, + { + email: identityEmail( + 'google', + allOAuthCredentials.google!.clientId, + 'google-subject', + ), + }, + ]) { + const browser = await f.browser(); + expect( + (await login(f, browser, 'google', options)).response.headers.get( + 'location', + ), + ).toContain('error=oauth_failed'); + expect(await session(browser)).toBeNull(); + } +}); + 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, From 2927f54f59c9f7acf3cb0181759ec67b11590253 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 11 Sep 2026 11:55:26 -0700 Subject: [PATCH 2/2] Bound real-clock session timestamps by the login request window --- tests/integration/better-auth-workers.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integration/better-auth-workers.test.ts b/tests/integration/better-auth-workers.test.ts index 0b79348..e9ce50b 100644 --- a/tests/integration/better-auth-workers.test.ts +++ b/tests/integration/better-auth-workers.test.ts @@ -241,15 +241,19 @@ test('normal Workers build uses real time and randomness and contains no test cl }) => { const f = await fixture(false); onTestFinished(() => f.close()); + const before = Date.now(); const first = await login(f); + const after = Date.now(); const session = await f.get(first.cookie); - expect( - Math.abs(Date.parse(session!.session.createdAt) - Date.now()), - ).toBeLessThan(10_000); - expect( - Date.parse(session!.session.expiresAt) - - Date.parse(session!.session.createdAt), - ).toBe(86_400_000); + // Better Auth reads the real clock separately for creation and expiration. + // Both timestamps must fall inside the request window, offset by the TTL. + for (const [timestamp, offset] of [ + [session!.session.createdAt, 0], + [session!.session.expiresAt, 86_400_000], + ] as const) { + expect(Date.parse(timestamp) - offset).toBeGreaterThanOrEqual(before); + expect(Date.parse(timestamp) - offset).toBeLessThanOrEqual(after); + } const second = await login(f, 'second@example.test'); expect(first.cookie).not.toBe(second.cookie); expect((await f.setTime('2020-01-01')).status).toBe(404);