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
15 changes: 12 additions & 3 deletions packages/auth/src/better-auth-email.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { createHash } from 'node:crypto';

const identityDomain = '@identity.pgstencil.invalid';
const identityDomain = 'identity.pgstencil.invalid';

export function isIdentityEmail(email: string) {
return email.toLowerCase().endsWith(identityDomain);
const at = email.lastIndexOf('@');
if (at === -1) return false;
// Reserve the whole DNS namespace, including an optional trailing root dot.
const domain = email
.slice(at + 1)
.toLowerCase()
.replace(/\.$/, '');
return domain === identityDomain || domain.endsWith('.' + identityDomain);
}

/** Better Auth requires an email column, even for a provider-only account.
Expand All @@ -21,7 +28,9 @@ export function identityEmail(
return (
createHash('sha256')
.update(JSON.stringify([provider, clientId, String(subject)]))
.digest('hex') + identityDomain
.digest('hex') +
'@' +
identityDomain
);
}

Expand Down
3 changes: 3 additions & 0 deletions tests/integration/better-auth-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,9 @@ test('Optional email never accepts a supplied but unverified email or invalid OI
onTestFinished(() => f.close());
for (const options of [
{ verified: false },
{ email: 'sub@a.identity.pgstencil.invalid' },
{ email: 'SUB@A.B.IDENTITY.PGSTENCIL.INVALID' },
{ email: 'dotted@identity.pgstencil.invalid.' },
{ email: '', badSignature: true },
{ email: '', claims: { nonce: 'wrong' } },
{
Expand Down
47 changes: 47 additions & 0 deletions tests/integration/better-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,53 @@ test('Better Auth email: repeatable cookies, database and email snapshots across
expect(plan.toBeAddedIndexes).toEqual([]);
});

test('Better Auth email rejects the entire reserved identity namespace before sending or signing in', async ({
onTestFinished,
}) => {
const f = await fixture();
onTestFinished(() => f.close());
for (const email of [
'plain@identity.pgstencil.invalid',
'PLAIN@IDENTITY.PGSTENCIL.INVALID',
'sub@a.identity.pgstencil.invalid',
'SUB@A.B.IDENTITY.PGSTENCIL.INVALID',
'dotted@identity.pgstencil.invalid.',
'dotted@a.identity.pgstencil.invalid.',
]) {
for (const path of [
'email-otp/send-verification-otp',
'sign-in/email-otp',
]) {
const response = await f.post(path, {
email,
type: 'sign-in',
otp: '12345678',
});
expect(response.status, `${path}: ${email}`).toBe(400);
expect(response.body).toEqual({ message: 'Invalid email' });
}
}
expect(f.email.all()).toEqual([]);
expect(await queryDatabase(f.database.url, 'SELECT id FROM "user"')).toEqual(
[],
);
expect(
await queryDatabase(f.database.url, 'SELECT id FROM verification'),
).toEqual([]);
// Similar domain names remain ordinary delivery addresses.
for (const email of [
'user@notidentity.pgstencil.invalid',
'user@identity.pgstencil.invalid.example.test',
]) {
const response = await f.post('email-otp/send-verification-otp', {
email,
type: 'sign-in',
});
expect(response.status, email).toBe(200);
expect((await f.email.next()).to).toEqual([email]);
}
});

test('Better Auth time: 23 hours, expiration boundary, isolated async contexts and unchanged host clock', async ({
onTestFinished,
}) => {
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/better-auth-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from 'vitest';
import {
identityEmail,
isIdentityEmail,
} from '../../packages/auth/src/better-auth-email.ts';

test('reserved identity emails include subdomains and DNS root dots', () => {
for (const email of [
'plain@identity.pgstencil.invalid',
'PLAIN@IDENTITY.PGSTENCIL.INVALID',
'sub@a.identity.pgstencil.invalid',
'SUB@A.B.IDENTITY.PGSTENCIL.INVALID',
'dotted@identity.pgstencil.invalid.',
'dotted@a.identity.pgstencil.invalid.',
])
expect(isIdentityEmail(email), email).toBe(true);
});

test('reserved identity email matching respects domain boundaries', () => {
for (const email of [
'user@example.test',
'user@notidentity.pgstencil.invalid',
'user@identity.pgstencil.invalid.example.test',
'identity.pgstencil.invalid@example.test',
'identity.pgstencil.invalid',
])
expect(isIdentityEmail(email), email).toBe(false);
});

test('generated identity addresses retain their exact reserved domain', () => {
const email = identityEmail('google', 'client-id', 'subject');
expect(email).toMatch(/^[0-9a-f]{64}@identity\.pgstencil\.invalid$/);
expect(isIdentityEmail(email)).toBe(true);
});
Loading