Skip to content
Merged
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
25 changes: 25 additions & 0 deletions PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Env = BetterAuthWorkerBindings & {
const auth = createBetterAuthWorker<Env>({
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),
Expand Down Expand Up @@ -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.
6 changes: 4 additions & 2 deletions examples/better-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions packages/auth/src/better-auth-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -157,6 +159,7 @@ export async function oauthRequest(
origin: string;
secret: string;
oauth?: OAuthSettings;
accountLinking?: 'explicit' | 'same-email';
successPath?: string;
errorPath?: string;
},
Expand Down Expand Up @@ -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 (
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/auth/src/better-auth-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function protectAuth(
secret: string;
database: ReturnType<typeof connectDatabase>;
ipAddressHeaders?: string[];
accountLinking?: 'explicit' | 'same-email';
},
) {
const secure = options.origin.startsWith('https:');
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -205,6 +208,7 @@ export async function publicAuthResponse(response: Response) {
'refreshToken',
'idToken',
'singleSession',
'emailAuthenticated',
].includes(key),
)
.map(([key, item]) => [key, scrub(item)]),
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/better-auth-workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type BetterAuthWorkerBindings = {
export function createBetterAuthWorker<E extends BetterAuthWorkerBindings>(
options: Pick<
AuthAppOptions,
'sessionPolicy' | 'appName' | 'successPath' | 'errorPath'
'sessionPolicy' | 'accountLinking' | 'appName' | 'successPath' | 'errorPath'
> & {
email: (env: E) => EmailSender;
},
Expand Down
48 changes: 44 additions & 4 deletions packages/auth/src/better-auth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -22,6 +24,7 @@ export interface AuthOptions {
email: EmailSender;
ipAddressHeaders?: string[];
sessionPolicy?: 'single' | 'multiple';
accountLinking?: 'explicit' | 'same-email';
oauth?: OAuthSettings;
appName?: string;
successPath?: string;
Expand Down Expand Up @@ -57,14 +60,43 @@ 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 ||
typeof user.email !== 'string' ||
!/^[^\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.
Expand All @@ -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,
Expand Down Expand Up @@ -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',
},
}),
},
Expand All @@ -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: [
Expand Down
Loading
Loading