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
21 changes: 19 additions & 2 deletions PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const auth = createBetterAuthWorker<Env>({
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),
Expand Down Expand Up @@ -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
Expand All @@ -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.
6 changes: 5 additions & 1 deletion examples/better-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
26 changes: 26 additions & 0 deletions packages/auth/src/better-auth-email.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
55 changes: 54 additions & 1 deletion packages/auth/src/better-auth-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand All @@ -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),
}),
},
}
Expand Down Expand Up @@ -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;
},
},
}
Expand Down
14 changes: 12 additions & 2 deletions packages/auth/src/better-auth-security.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion packages/auth/src/better-auth-workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export type BetterAuthWorkerBindings = {
export function createBetterAuthWorker<E extends BetterAuthWorkerBindings>(
options: Pick<
AuthAppOptions,
'sessionPolicy' | 'accountLinking' | 'appName' | 'successPath' | 'errorPath'
| 'sessionPolicy'
| 'accountLinking'
| 'trustedEmailProviders'
| 'allowMissingEmail'
| 'appName'
| 'successPath'
| 'errorPath'
> & {
email: (env: E) => EmailSender;
},
Expand Down
31 changes: 30 additions & 1 deletion packages/auth/src/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof connectDatabase>;
origin: string;
Expand All @@ -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;
Expand Down Expand Up @@ -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 ||
Expand All @@ -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') ||
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading