@ultimat3/auth 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/package.json +37 -0
- package/src/adapter.ts +157 -0
- package/src/api-keys.ts +141 -0
- package/src/auth.ts +233 -0
- package/src/builtin-adapter.ts +286 -0
- package/src/errors.ts +229 -0
- package/src/guards.ts +43 -0
- package/src/id-token-fixture.ts +16 -0
- package/src/id-token.ts +161 -0
- package/src/index.ts +236 -0
- package/src/memory-adapter.ts +170 -0
- package/src/mfa.ts +209 -0
- package/src/oauth-cookie.ts +209 -0
- package/src/oauth-exchange.ts +244 -0
- package/src/oauth-login.ts +193 -0
- package/src/oauth-profile.ts +213 -0
- package/src/oauth.ts +168 -0
- package/src/password.ts +136 -0
- package/src/policy-bridge.ts +105 -0
- package/src/rate-limit.ts +104 -0
- package/src/session.ts +253 -0
- package/src/tables.ts +86 -0
- package/src/tokens.ts +51 -0
- package/src/verify.ts +127 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Single responsibility: an in-memory `AuthAdapter`. It is the driver `x new` uses before a
|
|
2
|
+
// database exists and the one every test in this package runs against — the same interface
|
|
3
|
+
// Postgres and Better Auth implement, so a flow that works here works there or the seam is wrong.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
AuthAccount,
|
|
7
|
+
AuthAdapter,
|
|
8
|
+
AuthApiKeyRecord,
|
|
9
|
+
AuthSession,
|
|
10
|
+
AuthUser,
|
|
11
|
+
AuthVerification,
|
|
12
|
+
CreateUserInput,
|
|
13
|
+
SessionPatch,
|
|
14
|
+
UserPatch,
|
|
15
|
+
} from './adapter';
|
|
16
|
+
|
|
17
|
+
const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
|
|
18
|
+
|
|
19
|
+
export class MemoryAdapter implements AuthAdapter {
|
|
20
|
+
readonly name = 'memory';
|
|
21
|
+
readonly #users = new Map<string, AuthUser>();
|
|
22
|
+
readonly #sessions = new Map<string, AuthSession>();
|
|
23
|
+
readonly #accounts = new Map<string, AuthAccount>();
|
|
24
|
+
readonly #verifications = new Map<string, AuthVerification>();
|
|
25
|
+
readonly #apiKeys = new Map<string, AuthApiKeyRecord>();
|
|
26
|
+
|
|
27
|
+
async findUserByEmail(email: string): Promise<AuthUser | null> {
|
|
28
|
+
const wanted = email.trim().toLowerCase();
|
|
29
|
+
for (const user of this.#users.values()) {
|
|
30
|
+
if (user.email === wanted) return user;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async findUserById(id: string): Promise<AuthUser | null> {
|
|
36
|
+
return this.#users.get(id) ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
40
|
+
const user: AuthUser = {
|
|
41
|
+
id: input.id,
|
|
42
|
+
email: input.email.trim().toLowerCase(),
|
|
43
|
+
emailVerifiedAt: null,
|
|
44
|
+
passwordHash: input.passwordHash,
|
|
45
|
+
orgId: input.orgId,
|
|
46
|
+
roles: [...input.roles],
|
|
47
|
+
permissions: [],
|
|
48
|
+
mfaSecret: null,
|
|
49
|
+
recoveryCodeHashes: [],
|
|
50
|
+
disabledAt: null,
|
|
51
|
+
createdAt: input.createdAt,
|
|
52
|
+
};
|
|
53
|
+
this.#users.set(user.id, user);
|
|
54
|
+
return user;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
|
|
58
|
+
const user = this.#users.get(id);
|
|
59
|
+
if (user === undefined) return null;
|
|
60
|
+
const next: AuthUser = {
|
|
61
|
+
...user,
|
|
62
|
+
passwordHash: patch.passwordHash === undefined ? user.passwordHash : patch.passwordHash,
|
|
63
|
+
emailVerifiedAt:
|
|
64
|
+
patch.emailVerifiedAt === undefined ? user.emailVerifiedAt : patch.emailVerifiedAt,
|
|
65
|
+
mfaSecret: patch.mfaSecret === undefined ? user.mfaSecret : patch.mfaSecret,
|
|
66
|
+
recoveryCodeHashes: patch.recoveryCodeHashes ?? user.recoveryCodeHashes,
|
|
67
|
+
disabledAt: patch.disabledAt === undefined ? user.disabledAt : patch.disabledAt,
|
|
68
|
+
roles: patch.roles ?? user.roles,
|
|
69
|
+
};
|
|
70
|
+
this.#users.set(id, next);
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getSession(id: string): Promise<AuthSession | null> {
|
|
75
|
+
return this.#sessions.get(id) ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async createSession(session: AuthSession): Promise<AuthSession> {
|
|
79
|
+
this.#sessions.set(session.id, session);
|
|
80
|
+
return session;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async updateSession(id: string, patch: SessionPatch): Promise<AuthSession | null> {
|
|
84
|
+
const session = this.#sessions.get(id);
|
|
85
|
+
if (session === undefined) return null;
|
|
86
|
+
const next: AuthSession = {
|
|
87
|
+
...session,
|
|
88
|
+
lastSeenAt: patch.lastSeenAt ?? session.lastSeenAt,
|
|
89
|
+
ip: patch.ip === undefined ? session.ip : patch.ip,
|
|
90
|
+
userAgent: patch.userAgent === undefined ? session.userAgent : patch.userAgent,
|
|
91
|
+
mfaSatisfied: patch.mfaSatisfied ?? session.mfaSatisfied,
|
|
92
|
+
};
|
|
93
|
+
this.#sessions.set(id, next);
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async deleteSession(id: string): Promise<boolean> {
|
|
98
|
+
return this.#sessions.delete(id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async deleteOtherSessions(userId: string, keepSessionId: string): Promise<number> {
|
|
102
|
+
let killed = 0;
|
|
103
|
+
for (const [id, session] of this.#sessions) {
|
|
104
|
+
if (session.userId !== userId || id === keepSessionId) continue;
|
|
105
|
+
this.#sessions.delete(id);
|
|
106
|
+
killed += 1;
|
|
107
|
+
}
|
|
108
|
+
return killed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async listSessions(userId: string): Promise<readonly AuthSession[]> {
|
|
112
|
+
return [...this.#sessions.values()]
|
|
113
|
+
.filter((session) => session.userId === userId)
|
|
114
|
+
.sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async linkAccount(account: AuthAccount): Promise<AuthAccount> {
|
|
118
|
+
this.#accounts.set(`${account.provider}:${account.providerAccountId}`, account);
|
|
119
|
+
return account;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async findAccount(provider: string, providerAccountId: string): Promise<AuthAccount | null> {
|
|
123
|
+
return this.#accounts.get(`${provider}:${providerAccountId}`) ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async listAccounts(userId: string): Promise<readonly AuthAccount[]> {
|
|
127
|
+
return [...this.#accounts.values()].filter((account) => account.userId === userId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async putVerification(record: AuthVerification): Promise<void> {
|
|
131
|
+
this.#verifications.set(verificationKey(record.purpose, record.identifier), record);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async takeVerification(purpose: string, identifier: string): Promise<AuthVerification | null> {
|
|
135
|
+
const key = verificationKey(purpose, identifier);
|
|
136
|
+
const record = this.#verifications.get(key);
|
|
137
|
+
if (record === undefined || record.consumedAt !== null) return null;
|
|
138
|
+
const consumed: AuthVerification = { ...record, consumedAt: new Date(record.createdAt) };
|
|
139
|
+
this.#verifications.set(key, consumed);
|
|
140
|
+
return consumed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async putApiKey(record: AuthApiKeyRecord): Promise<AuthApiKeyRecord> {
|
|
144
|
+
this.#apiKeys.set(record.id, record);
|
|
145
|
+
return record;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async findApiKeyById(id: string): Promise<AuthApiKeyRecord | null> {
|
|
149
|
+
return this.#apiKeys.get(id) ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async listApiKeys(ownerId: string): Promise<readonly AuthApiKeyRecord[]> {
|
|
153
|
+
return [...this.#apiKeys.values()].filter(
|
|
154
|
+
(key) => key.userId === ownerId || key.orgId === ownerId,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async touchApiKey(id: string, at: Date): Promise<void> {
|
|
159
|
+
const key = this.#apiKeys.get(id);
|
|
160
|
+
if (key === undefined) return;
|
|
161
|
+
this.#apiKeys.set(id, { ...key, lastUsedAt: at });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async revokeApiKey(id: string, at: Date): Promise<boolean> {
|
|
165
|
+
const key = this.#apiKeys.get(id);
|
|
166
|
+
if (key === undefined || key.revokedAt !== null) return false;
|
|
167
|
+
this.#apiKeys.set(id, { ...key, revokedAt: at });
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/mfa.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Single responsibility: TOTP (RFC 6238) and recovery codes. The drift window is explicit and
|
|
2
|
+
// small, and every accepted step is remembered: without replay rejection a code shouted over a
|
|
3
|
+
// phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
|
|
4
|
+
// and single-use, so a database dump is not a permanent MFA bypass.
|
|
5
|
+
|
|
6
|
+
import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
|
|
7
|
+
|
|
8
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
9
|
+
|
|
10
|
+
export const TOTP_STEP_SECONDS = 30;
|
|
11
|
+
export const TOTP_DIGITS = 6;
|
|
12
|
+
/** ±1 step. Two steps is a minute of validity; that is a window, not a clock tolerance. */
|
|
13
|
+
export const TOTP_DRIFT_STEPS = 1;
|
|
14
|
+
|
|
15
|
+
export function base32Encode(bytes: Uint8Array): string {
|
|
16
|
+
let bits = 0;
|
|
17
|
+
let value = 0;
|
|
18
|
+
let out = '';
|
|
19
|
+
for (const byte of bytes) {
|
|
20
|
+
value = (value << 8) | byte;
|
|
21
|
+
bits += 8;
|
|
22
|
+
while (bits >= 5) {
|
|
23
|
+
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31] as string;
|
|
24
|
+
bits -= 5;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31] as string;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tolerant by design: padding, spaces and the dashes authenticator apps display are skipped,
|
|
33
|
+
* and any other character fails the decode closed (empty output -> no code ever matches).
|
|
34
|
+
*/
|
|
35
|
+
export function base32Decode(value: string): Uint8Array {
|
|
36
|
+
const bytes: number[] = [];
|
|
37
|
+
let bits = 0;
|
|
38
|
+
let acc = 0;
|
|
39
|
+
for (const char of value.toUpperCase()) {
|
|
40
|
+
if (char === '=' || char === ' ' || char === '-') continue;
|
|
41
|
+
const index = BASE32_ALPHABET.indexOf(char);
|
|
42
|
+
if (index < 0) return new Uint8Array(0);
|
|
43
|
+
acc = (acc << 5) | index;
|
|
44
|
+
bits += 5;
|
|
45
|
+
if (bits >= 8) {
|
|
46
|
+
bytes.push((acc >>> (bits - 8)) & 0xff);
|
|
47
|
+
bits -= 8;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return Uint8Array.from(bytes);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 160 bits — the HMAC-SHA1 block size every authenticator app agrees on. */
|
|
54
|
+
export function generateTotpSecret(): string {
|
|
55
|
+
return base32Encode(randomBytes(20));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TotpEnrolment {
|
|
59
|
+
readonly secret: string;
|
|
60
|
+
readonly uri: string;
|
|
61
|
+
readonly digits: number;
|
|
62
|
+
readonly periodSeconds: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EnrolTotpInput {
|
|
66
|
+
readonly issuer: string;
|
|
67
|
+
readonly account: string;
|
|
68
|
+
readonly secret?: string | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function enrolTotp(input: EnrolTotpInput): TotpEnrolment {
|
|
72
|
+
const secret = input.secret ?? generateTotpSecret();
|
|
73
|
+
const label = `${encodeURIComponent(input.issuer)}:${encodeURIComponent(input.account)}`;
|
|
74
|
+
const query = new URLSearchParams({
|
|
75
|
+
secret,
|
|
76
|
+
issuer: input.issuer,
|
|
77
|
+
algorithm: 'SHA1',
|
|
78
|
+
digits: String(TOTP_DIGITS),
|
|
79
|
+
period: String(TOTP_STEP_SECONDS),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
secret,
|
|
83
|
+
uri: `otpauth://totp/${label}?${query.toString()}`,
|
|
84
|
+
digits: TOTP_DIGITS,
|
|
85
|
+
periodSeconds: TOTP_STEP_SECONDS,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function totpStep(at: Date, stepSeconds: number = TOTP_STEP_SECONDS): number {
|
|
90
|
+
return Math.floor(at.getTime() / 1000 / stepSeconds);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function counterBytes(step: number): Uint8Array {
|
|
94
|
+
const bytes = new Uint8Array(8);
|
|
95
|
+
let remaining = step;
|
|
96
|
+
for (let index = 7; index >= 0; index -= 1) {
|
|
97
|
+
bytes[index] = remaining % 256;
|
|
98
|
+
remaining = Math.floor(remaining / 256);
|
|
99
|
+
}
|
|
100
|
+
return bytes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** HMAC-SHA1 + RFC 4226 dynamic truncation. SHA1 here is a spec requirement, not a choice. */
|
|
104
|
+
export function totpCode(secret: string, step: number, digits: number = TOTP_DIGITS): string {
|
|
105
|
+
const key = base32Decode(secret);
|
|
106
|
+
const mac = Uint8Array.from(
|
|
107
|
+
new Bun.CryptoHasher('sha1', key).update(counterBytes(step)).digest(),
|
|
108
|
+
);
|
|
109
|
+
const offset = (mac[mac.length - 1] ?? 0) & 0x0f;
|
|
110
|
+
const binary =
|
|
111
|
+
(((mac[offset] ?? 0) & 0x7f) << 24) |
|
|
112
|
+
(((mac[offset + 1] ?? 0) & 0xff) << 16) |
|
|
113
|
+
(((mac[offset + 2] ?? 0) & 0xff) << 8) |
|
|
114
|
+
((mac[offset + 3] ?? 0) & 0xff);
|
|
115
|
+
return String(binary % 10 ** digits).padStart(digits, '0');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface TotpVerification {
|
|
119
|
+
readonly ok: boolean;
|
|
120
|
+
/** The step the code belonged to — feed it to `TotpReplayGuard.remember()`. */
|
|
121
|
+
readonly step: number | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface VerifyTotpInput {
|
|
125
|
+
readonly secret: string;
|
|
126
|
+
readonly code: string;
|
|
127
|
+
readonly at: Date;
|
|
128
|
+
readonly drift?: number | undefined;
|
|
129
|
+
/** Steps already spent by this subject. A match inside the set is a replay, not a login. */
|
|
130
|
+
readonly usedSteps?: ReadonlySet<number> | undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function verifyTotp(input: VerifyTotpInput): TotpVerification {
|
|
134
|
+
const drift = input.drift ?? TOTP_DRIFT_STEPS;
|
|
135
|
+
const current = totpStep(input.at);
|
|
136
|
+
const candidate = input.code.replaceAll(' ', '');
|
|
137
|
+
for (let offset = -drift; offset <= drift; offset += 1) {
|
|
138
|
+
const step = current + offset;
|
|
139
|
+
if (!timingSafeEqual(totpCode(input.secret, step), candidate)) continue;
|
|
140
|
+
if (input.usedSteps?.has(step) === true) return { ok: false, step };
|
|
141
|
+
return { ok: true, step };
|
|
142
|
+
}
|
|
143
|
+
return { ok: false, step: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface TotpReplayGuard {
|
|
147
|
+
isUsed(subject: string, step: number): boolean;
|
|
148
|
+
remember(subject: string, step: number, at: Date): void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* In-memory by default because a single web process is the common case; a multi-process
|
|
153
|
+
* deployment passes a Redis-backed guard with the same two methods. Steps older than the
|
|
154
|
+
* drift window are dropped — nothing outside it can be replayed anyway.
|
|
155
|
+
*/
|
|
156
|
+
export function createTotpReplayGuard(drift: number = TOTP_DRIFT_STEPS): TotpReplayGuard {
|
|
157
|
+
const used = new Map<string, Set<number>>();
|
|
158
|
+
return {
|
|
159
|
+
isUsed: (subject, step) => used.get(subject)?.has(step) === true,
|
|
160
|
+
remember: (subject, step, at) => {
|
|
161
|
+
const steps = used.get(subject) ?? new Set<number>();
|
|
162
|
+
const floor = totpStep(at) - drift;
|
|
163
|
+
for (const known of steps) {
|
|
164
|
+
if (known < floor) steps.delete(known);
|
|
165
|
+
}
|
|
166
|
+
steps.add(step);
|
|
167
|
+
used.set(subject, steps);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RecoveryCodeSet {
|
|
173
|
+
/** Shown once, at enrolment. Never persisted, never re-derivable. */
|
|
174
|
+
readonly codes: readonly string[];
|
|
175
|
+
readonly hashes: readonly string[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const normaliseRecoveryCode = (code: string): string =>
|
|
179
|
+
code.replaceAll('-', '').replaceAll(' ', '').toUpperCase();
|
|
180
|
+
|
|
181
|
+
export function generateRecoveryCodes(count = 10): RecoveryCodeSet {
|
|
182
|
+
const codes: string[] = [];
|
|
183
|
+
for (let index = 0; index < count; index += 1) {
|
|
184
|
+
const raw = base32Encode(randomBytes(10)).slice(0, 16);
|
|
185
|
+
codes.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}`);
|
|
186
|
+
}
|
|
187
|
+
return { codes, hashes: codes.map((code) => sha256Hex(normaliseRecoveryCode(code))) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Returns the remaining hashes with the redeemed one removed, or `null` if nothing matched.
|
|
192
|
+
* The caller persists the result — that write is what makes a code single-use.
|
|
193
|
+
*/
|
|
194
|
+
export function redeemRecoveryCode(
|
|
195
|
+
code: string,
|
|
196
|
+
hashes: readonly string[],
|
|
197
|
+
): readonly string[] | null {
|
|
198
|
+
const candidate = sha256Hex(normaliseRecoveryCode(code));
|
|
199
|
+
let matched = false;
|
|
200
|
+
const remaining: string[] = [];
|
|
201
|
+
for (const hash of hashes) {
|
|
202
|
+
if (!matched && timingSafeEqual(hash, candidate)) {
|
|
203
|
+
matched = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
remaining.push(hash);
|
|
207
|
+
}
|
|
208
|
+
return matched ? remaining : null;
|
|
209
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Single responsibility: where the handshake lives between the redirect and the callback. The
|
|
2
|
+
// two legs of an authorization-code login are separate HTTP requests, so `beginOAuth`'s state,
|
|
3
|
+
// nonce and PKCE verifier have to survive the round trip — and a framework that leaves that to
|
|
4
|
+
// the app gets one hand-rolled store per app, which is exactly where PKCE quietly stops proving
|
|
5
|
+
// anything. Sealed here and opened here, so the cookie below and a server-side store share one
|
|
6
|
+
// format at one trust level.
|
|
7
|
+
|
|
8
|
+
import type { Clock } from '@ultimat3/core';
|
|
9
|
+
import { EnvMissingError, systemClock } from '@ultimat3/core';
|
|
10
|
+
import { oauthStateInvalid } from './errors';
|
|
11
|
+
import { OAUTH_PROVIDERS, type OAuthHandshake, type OAuthProviderId } from './oauth';
|
|
12
|
+
import { type RequestLike, readCookie } from './session';
|
|
13
|
+
import { base64Url, timingSafeEqual } from './tokens';
|
|
14
|
+
|
|
15
|
+
/** `__Host-` for the same reason the session cookie carries it: no subdomain can plant one. */
|
|
16
|
+
export const OAUTH_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One cookie per provider, because a browser is one cookie jar and a user is allowed two tabs.
|
|
20
|
+
* Under a single shared name, starting a `google` login while a `github` one is mid-flight
|
|
21
|
+
* overwrites the github handshake — and the github callback then opens google's, fails
|
|
22
|
+
* `X_OAUTH_STATE_INVALID` and tells the user to restart the flow that just collided again.
|
|
23
|
+
* Scoping the name makes the two handshakes independent instead of the last writer's.
|
|
24
|
+
*/
|
|
25
|
+
export function handshakeCookieName(provider: OAuthProviderId): string {
|
|
26
|
+
return `${OAUTH_HANDSHAKE_COOKIE_PREFIX}_${provider}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Long enough to read a consent screen, short enough that a lifted cookie is already stale. */
|
|
30
|
+
export const DEFAULT_HANDSHAKE_TTL_MS = 10 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** `wiki/Configuration.md` requires it at >=32 chars for the `web` role; this is that gate. */
|
|
33
|
+
const MIN_SECRET_LENGTH = 32;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The app secret, read at call time rather than module scope so importing this file still reads
|
|
37
|
+
* no env — the same rule `oauthCredentials()` follows for the client id and secret.
|
|
38
|
+
*/
|
|
39
|
+
export function handshakeSecret(
|
|
40
|
+
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
41
|
+
): string {
|
|
42
|
+
const secret = env['SESSION_SECRET']?.trim() ?? '';
|
|
43
|
+
if (secret.length < MIN_SECRET_LENGTH) {
|
|
44
|
+
throw new EnvMissingError({
|
|
45
|
+
cause:
|
|
46
|
+
secret === ''
|
|
47
|
+
? 'SESSION_SECRET is not set, so an oauth handshake cannot be signed'
|
|
48
|
+
: `SESSION_SECRET is ${secret.length} characters and at least ${MIN_SECRET_LENGTH} are required`,
|
|
49
|
+
fix: 'export SESSION_SECRET="$(openssl rand -hex 32)"',
|
|
50
|
+
meta: { key: 'SESSION_SECRET', minLength: MIN_SECRET_LENGTH },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return secret;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HandshakeSealOptions {
|
|
57
|
+
/** Defaults to `SESSION_SECRET`. */
|
|
58
|
+
readonly secret?: string | undefined;
|
|
59
|
+
/** No `Date.now()` in this package: the handshake's age is measured against this. */
|
|
60
|
+
readonly clock?: Clock | undefined;
|
|
61
|
+
readonly ttlMs?: number | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface HandshakeCookieOptions extends HandshakeSealOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Defaults to `handshakeCookieName(provider)`. Overriding it opts out of the per-provider
|
|
67
|
+
* scoping, so both legs have to pass the same one — a name set on the redirect and defaulted on
|
|
68
|
+
* the callback reads a cookie that is not there.
|
|
69
|
+
*/
|
|
70
|
+
readonly name?: string | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Signed, not encrypted, for the reason the cursor codec is: every field here is already the
|
|
75
|
+
* browser's own — `state` travelled in the URL it was just sent to, and the verifier and nonce
|
|
76
|
+
* are that browser's halves of this handshake. What the signature buys is that the browser
|
|
77
|
+
* cannot *invent* a handshake, which is what would let an attacker's code land in a victim's
|
|
78
|
+
* session. What secrecy is needed against everyone else is the cookie's `HttpOnly; Secure`.
|
|
79
|
+
*/
|
|
80
|
+
export function sealHandshake(handshake: OAuthHandshake, options?: HandshakeSealOptions): string {
|
|
81
|
+
const clock = options?.clock ?? systemClock;
|
|
82
|
+
const body = base64Url(
|
|
83
|
+
new TextEncoder().encode(
|
|
84
|
+
JSON.stringify([
|
|
85
|
+
handshake.provider,
|
|
86
|
+
handshake.state,
|
|
87
|
+
handshake.nonce,
|
|
88
|
+
handshake.verifier,
|
|
89
|
+
handshake.redirectUri,
|
|
90
|
+
handshake.authorizeUrl,
|
|
91
|
+
clock.now().getTime(),
|
|
92
|
+
]),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
return `${body}.${sign(body, options?.secret ?? handshakeSecret())}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The only way back. `provider` is required rather than read from the payload because a
|
|
100
|
+
* handshake opened without one is a github handshake finishing a google callback — and an
|
|
101
|
+
* optional check is one a call site can forget.
|
|
102
|
+
*
|
|
103
|
+
* Every rejection is `X_OAUTH_STATE_INVALID`, like `assertOAuthCallback`'s: the handshake is one
|
|
104
|
+
* object, and naming which field failed tells an attacker which field to keep guessing at.
|
|
105
|
+
*/
|
|
106
|
+
export function openHandshake(
|
|
107
|
+
sealed: string,
|
|
108
|
+
provider: OAuthProviderId,
|
|
109
|
+
options?: HandshakeSealOptions,
|
|
110
|
+
): OAuthHandshake {
|
|
111
|
+
const dot = sealed.lastIndexOf('.');
|
|
112
|
+
if (dot <= 0) throw oauthStateInvalid(provider, 'the stored handshake is not signed');
|
|
113
|
+
|
|
114
|
+
const body = sealed.slice(0, dot);
|
|
115
|
+
const expected = sign(body, options?.secret ?? handshakeSecret());
|
|
116
|
+
if (!timingSafeEqual(expected, sealed.slice(dot + 1))) {
|
|
117
|
+
throw oauthStateInvalid(
|
|
118
|
+
provider,
|
|
119
|
+
'the stored handshake was tampered with, or the secret rotated',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const parsed = parseBody(body, provider);
|
|
124
|
+
if (!Array.isArray(parsed) || parsed.length !== 7) {
|
|
125
|
+
throw oauthStateInvalid(provider, 'the stored handshake is not a handshake');
|
|
126
|
+
}
|
|
127
|
+
const [sealedProvider, state, nonce, verifier, redirectUri, authorizeUrl, issuedAt] =
|
|
128
|
+
parsed as readonly unknown[];
|
|
129
|
+
if (
|
|
130
|
+
typeof sealedProvider !== 'string' ||
|
|
131
|
+
typeof state !== 'string' ||
|
|
132
|
+
typeof nonce !== 'string' ||
|
|
133
|
+
typeof verifier !== 'string' ||
|
|
134
|
+
typeof redirectUri !== 'string' ||
|
|
135
|
+
typeof authorizeUrl !== 'string' ||
|
|
136
|
+
typeof issuedAt !== 'number' ||
|
|
137
|
+
!Number.isFinite(issuedAt)
|
|
138
|
+
) {
|
|
139
|
+
throw oauthStateInvalid(provider, 'the stored handshake is not a handshake');
|
|
140
|
+
}
|
|
141
|
+
if (!Object.hasOwn(OAUTH_PROVIDERS, sealedProvider) || sealedProvider !== provider) {
|
|
142
|
+
throw oauthStateInvalid(provider, 'the stored handshake belongs to a different provider');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The cookie's own `Max-Age` is the client's copy of this deadline, and a client is free to
|
|
146
|
+
// ignore it — so the age that decides is measured here, against the server's clock.
|
|
147
|
+
const clock = options?.clock ?? systemClock;
|
|
148
|
+
if (clock.now().getTime() - issuedAt > (options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS)) {
|
|
149
|
+
throw oauthStateInvalid(provider, 'the stored handshake expired before the callback arrived');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { provider, state, nonce, verifier, redirectUri, authorizeUrl };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Set on the redirect. `SameSite=Lax` is the one attribute that differs in reasoning from the
|
|
157
|
+
* session cookie's: the callback is a top-level cross-site GET from the provider, which `Lax`
|
|
158
|
+
* still attaches the cookie to and `Strict` would strip — leaving every login to fail its state
|
|
159
|
+
* check. A provider answering with `response_mode=form_post` POSTs instead, and this cookie
|
|
160
|
+
* would not reach it; no provider in `OAUTH_PROVIDERS` is configured that way.
|
|
161
|
+
*/
|
|
162
|
+
export function handshakeCookie(
|
|
163
|
+
handshake: OAuthHandshake,
|
|
164
|
+
options?: HandshakeCookieOptions,
|
|
165
|
+
): string {
|
|
166
|
+
const maxAge = Math.floor((options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS) / 1000);
|
|
167
|
+
// The handshake already names its provider, so the two legs cannot disagree about the name.
|
|
168
|
+
const name = options?.name ?? handshakeCookieName(handshake.provider);
|
|
169
|
+
return `${name}=${sealHandshake(handshake, options)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Send this with the callback's response, always — an authorization code is single-use, so the
|
|
174
|
+
* handshake that authorised it must not outlive it. A mismatched attribute set leaves a live twin.
|
|
175
|
+
*
|
|
176
|
+
* `provider` is required for the reason `openHandshake`'s is: clearing the unscoped name would
|
|
177
|
+
* clear nothing, and clearing every provider's would cancel a login running in another tab.
|
|
178
|
+
*/
|
|
179
|
+
export function clearHandshakeCookie(provider: OAuthProviderId, name?: string): string {
|
|
180
|
+
return `${name ?? handshakeCookieName(provider)}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Reads the cookie set on the redirect and returns the handshake `completeOAuthLogin` takes. */
|
|
184
|
+
export function readHandshakeCookie(
|
|
185
|
+
request: RequestLike,
|
|
186
|
+
provider: OAuthProviderId,
|
|
187
|
+
options?: HandshakeCookieOptions,
|
|
188
|
+
): OAuthHandshake {
|
|
189
|
+
const sealed = readCookie(request, options?.name ?? handshakeCookieName(provider));
|
|
190
|
+
if (sealed === null) {
|
|
191
|
+
throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback');
|
|
192
|
+
}
|
|
193
|
+
return openHandshake(sealed, provider, options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Keyed SHA-256. Untruncated, unlike a cursor's: a cookie has room and this authorises a login. */
|
|
197
|
+
function sign(body: string, secret: string): string {
|
|
198
|
+
return new Bun.CryptoHasher('sha256', secret).update(body).digest('hex');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseBody(body: string, provider: OAuthProviderId): unknown {
|
|
202
|
+
const padded = body.replaceAll('-', '+').replaceAll('_', '/');
|
|
203
|
+
try {
|
|
204
|
+
const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '='));
|
|
205
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
|
|
206
|
+
} catch {
|
|
207
|
+
throw oauthStateInvalid(provider, 'the stored handshake is not readable');
|
|
208
|
+
}
|
|
209
|
+
}
|