@growth-labs/auth 0.7.0 → 0.7.2

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.
Files changed (43) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +4 -1
  3. package/dist/index.js.map +1 -1
  4. package/package.json +7 -2
  5. package/src/client/login-page.ts +12 -0
  6. package/src/cloudflare-workers.d.ts +3 -0
  7. package/src/config-store.ts +70 -0
  8. package/src/index.ts +177 -0
  9. package/src/middleware/auth.ts +88 -0
  10. package/src/options.ts +75 -0
  11. package/src/renderers.ts +182 -0
  12. package/src/routes/api/code-request.ts +33 -0
  13. package/src/routes/api/code-verify.ts +35 -0
  14. package/src/routes/api/password-reset-confirm.ts +36 -0
  15. package/src/routes/api/password-reset-request.ts +32 -0
  16. package/src/routes/api/password-verify.ts +35 -0
  17. package/src/routes/api/shared.ts +130 -0
  18. package/src/routes/callback.ts +105 -0
  19. package/src/routes/flow.ts +53 -0
  20. package/src/routes/forgot-password.ts +31 -0
  21. package/src/routes/login.ts +130 -0
  22. package/src/routes/logout.ts +59 -0
  23. package/src/routes/password.ts +27 -0
  24. package/src/routes/reset-password.ts +33 -0
  25. package/src/routes/verify-code.ts +28 -0
  26. package/src/runtime-bridge.ts +15 -0
  27. package/src/runtime.ts +5 -0
  28. package/src/server.ts +185 -0
  29. package/src/types.ts +158 -0
  30. package/src/utils/analytics.ts +56 -0
  31. package/src/utils/client-secret.ts +21 -0
  32. package/src/utils/cookies.ts +134 -0
  33. package/src/utils/encoding.ts +5 -0
  34. package/src/utils/env-marker.ts +39 -0
  35. package/src/utils/identity-cookie.ts +138 -0
  36. package/src/utils/identity.ts +240 -0
  37. package/src/utils/index.ts +19 -0
  38. package/src/utils/issuer.ts +7 -0
  39. package/src/utils/openauth.ts +163 -0
  40. package/src/utils/pkce.ts +16 -0
  41. package/src/utils/state.ts +20 -0
  42. package/src/utils/tokens.ts +128 -0
  43. package/src/virtual.d.ts +5 -0
@@ -0,0 +1,39 @@
1
+ const ENV_MARKER_PREFIX = 'env:'
2
+
3
+ /**
4
+ * Pure resolver: takes an `"env:VAR_NAME"` marker and an env bag, returns
5
+ * the bound value or throws when the binding is missing / empty. Plain
6
+ * strings pass through unchanged. Kept pure (no `cloudflare:workers`
7
+ * import) so `src/config-store.ts` — and therefore the package main entry
8
+ * — stays clear of the runtime-only specifier and the pure-Node import
9
+ * gate stays green.
10
+ *
11
+ * The runtime env is supplied at call sites (middleware/routes) which can
12
+ * pull from `cloudflare:workers` themselves, OR by `src/runtime-bridge.ts`
13
+ * which stashes it onto `globalThis.__GL_AUTH_RUNTIME_ENV__` for
14
+ * `getConfig()` to read implicitly.
15
+ */
16
+ export function resolveEnvMarker(
17
+ value: string | undefined,
18
+ env: Record<string, unknown>,
19
+ ): string | undefined {
20
+ if (typeof value !== 'string') return value
21
+ if (!value.startsWith(ENV_MARKER_PREFIX)) return value
22
+ const name = value.slice(ENV_MARKER_PREFIX.length)
23
+ if (!name) {
24
+ throw new Error(
25
+ '@growth-labs/auth: "env:" marker missing binding name (expected "env:VAR_NAME")',
26
+ )
27
+ }
28
+ const resolved = env[name]
29
+ if (typeof resolved !== 'string' || resolved.length === 0) {
30
+ throw new Error(
31
+ `@growth-labs/auth: env:${name} is not set in the Worker runtime env (declared as "env:${name}" in auth() options)`,
32
+ )
33
+ }
34
+ return resolved
35
+ }
36
+
37
+ export function isEnvMarker(value: unknown): boolean {
38
+ return typeof value === 'string' && value.startsWith(ENV_MARKER_PREFIX)
39
+ }
@@ -0,0 +1,138 @@
1
+ import { base64url } from './encoding.js'
2
+
3
+ const SECRET_BYTES = 32
4
+ const encoder = new TextEncoder()
5
+ const decoder = new TextDecoder()
6
+
7
+ export type IdentityCookieVerification = { valid: true; payload: string } | { valid: false }
8
+
9
+ export async function signIdentityCookie(payload: string, secret: string): Promise<string> {
10
+ if (!payload) {
11
+ throw new Error('identity cookie payload is required')
12
+ }
13
+
14
+ const payloadBytes = encoder.encode(payload)
15
+ const key = await importSecret(secret)
16
+ if (!key) {
17
+ throw new Error('GL_IDENTITY_SECRET must be a base64-encoded 32-byte secret')
18
+ }
19
+
20
+ const signature = new Uint8Array(
21
+ await crypto.subtle.sign('HMAC', key, toArrayBuffer(payloadBytes)),
22
+ )
23
+ return `${base64url(payloadBytes)}.${base64url(signature)}`
24
+ }
25
+
26
+ export async function createIdentityCookiePayload(
27
+ subject: string,
28
+ secret: string,
29
+ ): Promise<string> {
30
+ if (!subject || !secret) {
31
+ throw new Error('identity cookie subject and secret are required')
32
+ }
33
+
34
+ const digest = new Uint8Array(
35
+ await crypto.subtle.digest('SHA-256', encoder.encode(subject + secret)),
36
+ )
37
+ return [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')
38
+ }
39
+
40
+ export async function verifyIdentityCookie(
41
+ cookieValue: string,
42
+ secret: string,
43
+ ): Promise<IdentityCookieVerification> {
44
+ if (!cookieValue || !secret) {
45
+ return { valid: false }
46
+ }
47
+
48
+ const parts = cookieValue.split('.')
49
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
50
+ return { valid: false }
51
+ }
52
+
53
+ const payloadBytes = decodeBase64Url(parts[0])
54
+ const signatureBytes = decodeBase64Url(parts[1])
55
+ const key = await importSecret(secret)
56
+ if (!payloadBytes || !signatureBytes || !key) {
57
+ return { valid: false }
58
+ }
59
+
60
+ const expected = new Uint8Array(
61
+ await crypto.subtle.sign('HMAC', key, toArrayBuffer(payloadBytes)),
62
+ )
63
+ if (!timingSafeEqual(expected, signatureBytes)) {
64
+ return { valid: false }
65
+ }
66
+
67
+ return {
68
+ valid: true,
69
+ payload: decoder.decode(payloadBytes),
70
+ }
71
+ }
72
+
73
+ export function timingSafeEqual(left: ArrayLike<number>, right: ArrayLike<number>): boolean {
74
+ const length = Math.max(left.length, right.length)
75
+ let diff = left.length ^ right.length
76
+
77
+ for (let index = 0; index < length; index += 1) {
78
+ const leftByte = index < left.length ? left[index] : 0
79
+ const rightByte = index < right.length ? right[index] : 0
80
+ diff |= leftByte ^ rightByte
81
+ }
82
+
83
+ return diff === 0
84
+ }
85
+
86
+ async function importSecret(secret: string): Promise<CryptoKey | null> {
87
+ const bytes = decodeBase64(secret)
88
+ if (!bytes || bytes.byteLength !== SECRET_BYTES) {
89
+ return null
90
+ }
91
+
92
+ return crypto.subtle.importKey(
93
+ 'raw',
94
+ toArrayBuffer(bytes),
95
+ { name: 'HMAC', hash: 'SHA-256' },
96
+ false,
97
+ ['sign'],
98
+ )
99
+ }
100
+
101
+ function decodeBase64(value: string): Uint8Array | null {
102
+ const normalized = value.trim().replace(/-/g, '+').replace(/_/g, '/')
103
+ if (!normalized) {
104
+ return null
105
+ }
106
+
107
+ try {
108
+ return binaryStringToBytes(atob(padBase64(normalized)))
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ function decodeBase64Url(value: string): Uint8Array | null {
115
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
116
+ return null
117
+ }
118
+
119
+ return decodeBase64(value)
120
+ }
121
+
122
+ function padBase64(value: string): string {
123
+ return value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=')
124
+ }
125
+
126
+ function binaryStringToBytes(value: string): Uint8Array {
127
+ const bytes = new Uint8Array(value.length)
128
+ for (let index = 0; index < value.length; index += 1) {
129
+ bytes[index] = value.charCodeAt(index)
130
+ }
131
+ return bytes
132
+ }
133
+
134
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
135
+ const buffer = new ArrayBuffer(bytes.byteLength)
136
+ new Uint8Array(buffer).set(bytes)
137
+ return buffer
138
+ }
@@ -0,0 +1,240 @@
1
+ // Identity-bridge helpers. The OpenAuth issuer this package targets emits
2
+ // `sub` claims in the form `user:<ULID>` (e.g. `user:01KP8KA31Z3AGRCC578R9HXW0H`).
3
+ // The unprefixed bare-ULID form was used in early issuer releases and is
4
+ // no longer the contract.
5
+ //
6
+ // Two scenarios this module addresses:
7
+ //
8
+ // 1. **Sub-format drift detection.** A consumer's auth code that pulls
9
+ // the identity ID from a JWT should fail loudly if the issuer emits
10
+ // something that doesn't match the documented contract. Use
11
+ // `extractIdentityId` for the canonical extract-and-validate path.
12
+ //
13
+ // 2. **Legacy-row bridge migration.** A consumer that started using the
14
+ // issuer before the prefix change has rows in their `users` table
15
+ // whose `identity_user_id` column holds the bare ULID. After the
16
+ // issuer migration, every login by those users tries to bridge the
17
+ // new `user:<ULID>` to a row that holds the old `<ULID>` and throws
18
+ // `IdentityBridgeConflictError` from the consumer's bridge layer.
19
+ // `buildLegacyIdentitySubjectMigration` returns a parameterized SQL
20
+ // statement set the consumer can run as a one-shot data migration
21
+ // BEFORE deploying the issuer change. `findLegacyIdentitySubjects`
22
+ // is a canary query consumers can run any time to count remaining
23
+ // legacy rows.
24
+
25
+ /**
26
+ * The literal prefix the issuer emits on every `sub` claim. Pinned as
27
+ * the contract for this package's auth flow. Any change is breaking
28
+ * and triggers a major bump.
29
+ */
30
+ export const IDENTITY_SUBJECT_PREFIX = 'user:'
31
+
32
+ /**
33
+ * Documented ULID shape: 26 chars of Crockford base32. The issuer
34
+ * uses ULIDv1 and never falls back to a generic UUID — pin the
35
+ * stricter pattern so drift is caught early.
36
+ */
37
+ const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/
38
+
39
+ /** Stable error code for sub-format drift. */
40
+ export const IDENTITY_SUB_FORMAT_DRIFT = 'IDENTITY_SUB_FORMAT_DRIFT'
41
+
42
+ export class IdentitySubFormatDriftError extends Error {
43
+ readonly code = IDENTITY_SUB_FORMAT_DRIFT
44
+ constructor(
45
+ message: string,
46
+ readonly observedSubject: string,
47
+ ) {
48
+ super(message)
49
+ this.name = 'IdentitySubFormatDriftError'
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Validate and unwrap a `sub` claim. Returns the bare ULID portion
55
+ * (the value Fronts stores in `users.identity_user_id` post-migration
56
+ * is the prefixed form; the bare ULID is the convenient handle for
57
+ * matching against business-side records that pre-date the prefix).
58
+ *
59
+ * Throws `IdentitySubFormatDriftError` when the subject does not match
60
+ * `user:<ULID>`. The error code `IDENTITY_SUB_FORMAT_DRIFT` is stable
61
+ * for observability matching.
62
+ */
63
+ export function extractIdentityId(sub: string): string {
64
+ if (typeof sub !== 'string' || sub.length === 0) {
65
+ throw new IdentitySubFormatDriftError('JWT sub claim is missing or not a string', String(sub))
66
+ }
67
+ if (!sub.startsWith(IDENTITY_SUBJECT_PREFIX)) {
68
+ throw new IdentitySubFormatDriftError(
69
+ `JWT sub claim "${sub}" is missing the required "${IDENTITY_SUBJECT_PREFIX}" prefix. ` +
70
+ `The issuer contract pins the format as "${IDENTITY_SUBJECT_PREFIX}<ULID>". ` +
71
+ 'If your issuer was migrated from an older release, see ' +
72
+ 'packages-docs/auth-identity-bridge.md for the bridge-row migration helper.',
73
+ sub,
74
+ )
75
+ }
76
+ const id = sub.slice(IDENTITY_SUBJECT_PREFIX.length)
77
+ if (!ULID_PATTERN.test(id)) {
78
+ throw new IdentitySubFormatDriftError(
79
+ `JWT sub claim "${sub}" has the "${IDENTITY_SUBJECT_PREFIX}" prefix but the suffix ` +
80
+ 'is not a valid 26-char Crockford base32 ULID.',
81
+ sub,
82
+ )
83
+ }
84
+ return id
85
+ }
86
+
87
+ /**
88
+ * Convenience: normalize either form to the canonical prefixed `sub`.
89
+ * Idempotent on already-prefixed input. Throws on invalid ULID.
90
+ *
91
+ * Useful inside a one-shot migration: pass each row's stored
92
+ * `identity_user_id` through this and write back the result.
93
+ */
94
+ export function normalizeIdentitySubject(value: string): string {
95
+ if (typeof value !== 'string' || value.length === 0) {
96
+ throw new IdentitySubFormatDriftError('value is missing or not a string', String(value))
97
+ }
98
+ if (value.startsWith(IDENTITY_SUBJECT_PREFIX)) {
99
+ const id = value.slice(IDENTITY_SUBJECT_PREFIX.length)
100
+ if (!ULID_PATTERN.test(id)) {
101
+ throw new IdentitySubFormatDriftError(
102
+ `value "${value}" has the "${IDENTITY_SUBJECT_PREFIX}" prefix but the suffix is not a valid ULID.`,
103
+ value,
104
+ )
105
+ }
106
+ return value
107
+ }
108
+ if (!ULID_PATTERN.test(value)) {
109
+ throw new IdentitySubFormatDriftError(
110
+ `value "${value}" is neither a prefixed "${IDENTITY_SUBJECT_PREFIX}<ULID>" nor a valid bare ULID.`,
111
+ value,
112
+ )
113
+ }
114
+ return `${IDENTITY_SUBJECT_PREFIX}${value}`
115
+ }
116
+
117
+ // ─── Bridge migration helpers ───
118
+
119
+ export interface BridgeMigrationOptions {
120
+ /** Table that holds the bridge column. Default: `users`. */
121
+ table?: string
122
+ /** Column that stores the issuer subject. Default: `identity_user_id`. */
123
+ column?: string
124
+ }
125
+
126
+ export interface BridgeMigrationStatement {
127
+ sql: string
128
+ bindings?: readonly unknown[]
129
+ }
130
+
131
+ /**
132
+ * Build the read-only canary query consumers run before deploying the
133
+ * issuer subject-format change. Returns the count of rows that hold
134
+ * legacy unprefixed values. A non-zero count means some logins will
135
+ * throw `IdentityBridgeConflictError` until those rows are rewritten
136
+ * by `buildLegacyIdentitySubjectMigration`.
137
+ *
138
+ * ```sql
139
+ * SELECT COUNT(*) FROM users WHERE identity_user_id IS NOT NULL
140
+ * AND identity_user_id NOT LIKE 'user:%';
141
+ * ```
142
+ */
143
+ export function buildLegacyIdentitySubjectCanary(
144
+ options: BridgeMigrationOptions = {},
145
+ ): BridgeMigrationStatement {
146
+ const { table = 'users', column = 'identity_user_id' } = options
147
+ assertSafeSqlIdentifier(table)
148
+ assertSafeSqlIdentifier(column)
149
+ return {
150
+ sql: `SELECT COUNT(*) AS legacy_count FROM ${table} WHERE ${column} IS NOT NULL AND ${column} NOT LIKE 'user:%'`,
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Build the rewrite statement that prefixes legacy bare-ULID values to
156
+ * the canonical `user:<ULID>` form. Idempotent: rows whose value
157
+ * already starts with `user:` are not touched. Run as a one-shot
158
+ * migration BEFORE the issuer subject-format change goes live.
159
+ *
160
+ * ```sql
161
+ * UPDATE users
162
+ * SET identity_user_id = 'user:' || identity_user_id
163
+ * WHERE identity_user_id IS NOT NULL
164
+ * AND identity_user_id NOT LIKE 'user:%';
165
+ * ```
166
+ *
167
+ * D1 / SQLite syntax. Adapt the concatenation operator (`||`) for
168
+ * other engines if you fork.
169
+ */
170
+ export function buildLegacyIdentitySubjectMigration(
171
+ options: BridgeMigrationOptions = {},
172
+ ): BridgeMigrationStatement {
173
+ const { table = 'users', column = 'identity_user_id' } = options
174
+ assertSafeSqlIdentifier(table)
175
+ assertSafeSqlIdentifier(column)
176
+ return {
177
+ sql:
178
+ `UPDATE ${table} SET ${column} = 'user:' || ${column} ` +
179
+ `WHERE ${column} IS NOT NULL AND ${column} NOT LIKE 'user:%'`,
180
+ }
181
+ }
182
+
183
+ /**
184
+ * D1-binding-compatible: count legacy rows. Designed for consumer
185
+ * code like:
186
+ *
187
+ * ```ts
188
+ * const count = await findLegacyIdentitySubjects(env.SITE_DB)
189
+ * if (count > 0) throw new Error(`${count} rows need migration`)
190
+ * ```
191
+ */
192
+ interface D1Like {
193
+ prepare(sql: string): {
194
+ first<T = unknown>(): Promise<T | null>
195
+ }
196
+ }
197
+
198
+ export async function findLegacyIdentitySubjects(
199
+ db: D1Like,
200
+ options: BridgeMigrationOptions = {},
201
+ ): Promise<number> {
202
+ const { sql } = buildLegacyIdentitySubjectCanary(options)
203
+ const row = await db.prepare(sql).first<{ legacy_count: number }>()
204
+ return row?.legacy_count ?? 0
205
+ }
206
+
207
+ /**
208
+ * D1-binding-compatible: rewrite legacy rows in place. Returns the
209
+ * number of rows updated.
210
+ *
211
+ * ```ts
212
+ * const rewritten = await rewriteLegacyIdentitySubjects(env.SITE_DB)
213
+ * console.log(`Migrated ${rewritten} rows`)
214
+ * ```
215
+ */
216
+ interface D1RunLike {
217
+ prepare(sql: string): {
218
+ run(): Promise<{ meta?: { changes?: number; rows_written?: number } }>
219
+ }
220
+ }
221
+
222
+ export async function rewriteLegacyIdentitySubjects(
223
+ db: D1RunLike,
224
+ options: BridgeMigrationOptions = {},
225
+ ): Promise<number> {
226
+ const { sql } = buildLegacyIdentitySubjectMigration(options)
227
+ const result = await db.prepare(sql).run()
228
+ return result?.meta?.changes ?? result?.meta?.rows_written ?? 0
229
+ }
230
+
231
+ // ─── Internal ───
232
+
233
+ function assertSafeSqlIdentifier(value: string): void {
234
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
235
+ throw new Error(
236
+ `@growth-labs/auth: identifier "${value}" is not a safe SQL identifier ` +
237
+ '(letters, digits, underscores; must not start with a digit).',
238
+ )
239
+ }
240
+ }
@@ -0,0 +1,19 @@
1
+ export { emitAuthEvent, trackAuthEvent } from './analytics.js'
2
+ export {
3
+ type BridgeMigrationOptions,
4
+ type BridgeMigrationStatement,
5
+ buildLegacyIdentitySubjectCanary,
6
+ buildLegacyIdentitySubjectMigration,
7
+ extractIdentityId,
8
+ findLegacyIdentitySubjects,
9
+ IDENTITY_SUB_FORMAT_DRIFT,
10
+ IDENTITY_SUBJECT_PREFIX,
11
+ IdentitySubFormatDriftError,
12
+ normalizeIdentitySubject,
13
+ rewriteLegacyIdentitySubjects,
14
+ } from './identity.js'
15
+ export { signIdentityCookie, verifyIdentityCookie } from './identity-cookie.js'
16
+ export { getAuthorizationUrl, getMagicLinkUrl, getPasswordLoginUrl } from './openauth.js'
17
+ export { createPKCEChallenge, generatePKCE } from './pkce.js'
18
+ export { generateState, verifyState } from './state.js'
19
+ export type { VerifiedClaims } from './tokens.js'
@@ -0,0 +1,7 @@
1
+ import type { ResolvedAuthOptions } from '../options.js'
2
+
3
+ export function resolveInternalIssuer(
4
+ options: Pick<ResolvedAuthOptions, 'issuer' | 'issuerInternal'>,
5
+ ): string {
6
+ return options.issuerInternal ?? options.issuer
7
+ }
@@ -0,0 +1,163 @@
1
+ import { getConfig } from '../config-store.js'
2
+ import type { TokenExchangeResult } from '../types.js'
3
+ import { createClientSecretBasicAuthHeader, resolveClientSecret } from './client-secret.js'
4
+ import { resolveInternalIssuer } from './issuer.js'
5
+ import { generatePKCE } from './pkce.js'
6
+ import { generateState } from './state.js'
7
+ import { decodeJwtPayload, extractTokenClaims } from './tokens.js'
8
+
9
+ export interface AuthFlow {
10
+ verifier: string
11
+ challenge: string
12
+ state: string
13
+ clientId: string
14
+ redirectUri: string
15
+ }
16
+
17
+ export async function createAuthFlow(context: { url: URL }): Promise<AuthFlow> {
18
+ const options = getConfig()
19
+ const { verifier, challenge } = await generatePKCE()
20
+ const state = generateState()
21
+
22
+ return {
23
+ verifier,
24
+ challenge,
25
+ state,
26
+ clientId: options.clientId || context.url.hostname,
27
+ redirectUri: new URL(options.callbackPath, context.url.origin).toString(),
28
+ }
29
+ }
30
+
31
+ export async function getAuthorizationUrl(
32
+ context: { url: URL },
33
+ provider: string,
34
+ _redirectPath: string,
35
+ authFlow?: AuthFlow,
36
+ ): Promise<{ url: string; pkceVerifier: string; state: string }> {
37
+ const options = getConfig()
38
+ const flow = authFlow ?? (await createAuthFlow(context))
39
+
40
+ const params = new URLSearchParams({
41
+ response_type: 'code',
42
+ client_id: flow.clientId,
43
+ redirect_uri: flow.redirectUri,
44
+ scope: 'openid email profile',
45
+ state: flow.state,
46
+ code_challenge: flow.challenge,
47
+ code_challenge_method: 'S256',
48
+ provider,
49
+ })
50
+ if (options.resource) params.set('resource', options.resource)
51
+
52
+ return {
53
+ url: `${options.issuer}/authorize?${params}`,
54
+ pkceVerifier: flow.verifier,
55
+ state: flow.state,
56
+ }
57
+ }
58
+
59
+ export function getPasswordLoginUrl(_context: { url: URL }): string {
60
+ const options = getConfig()
61
+ return `${options.issuer}/authorize/password`
62
+ }
63
+
64
+ export function getMagicLinkUrl(_context: { url: URL }): string {
65
+ const options = getConfig()
66
+ return `${options.issuer}/authorize/email-code`
67
+ }
68
+
69
+ export function getForgotPasswordUrl(): string {
70
+ const options = getConfig()
71
+ return `${options.issuer}/forgot-password`
72
+ }
73
+
74
+ export function getResetPasswordUrl(): string {
75
+ const options = getConfig()
76
+ return `${options.issuer}/reset-password`
77
+ }
78
+
79
+ export async function exchangeCode(
80
+ callbackUrl: URL,
81
+ code: string,
82
+ codeVerifier: string,
83
+ providerFromCookie?: TokenExchangeResult['provider'],
84
+ ): Promise<TokenExchangeResult | null> {
85
+ const options = getConfig()
86
+ const issuer = resolveInternalIssuer(options)
87
+ const clientId = options.clientId || callbackUrl.hostname
88
+ const redirectUri = new URL(options.callbackPath, callbackUrl.origin).toString()
89
+ const clientSecret = resolveClientSecret(options.clientSecret)
90
+ const headers: Record<string, string> = {
91
+ 'Content-Type': 'application/x-www-form-urlencoded',
92
+ }
93
+ if (clientSecret) {
94
+ headers.Authorization = createClientSecretBasicAuthHeader(clientId, clientSecret)
95
+ }
96
+ const body = new URLSearchParams({
97
+ grant_type: 'authorization_code',
98
+ code,
99
+ code_verifier: codeVerifier,
100
+ redirect_uri: redirectUri,
101
+ client_id: clientId,
102
+ })
103
+ if (options.resource) body.set('resource', options.resource)
104
+
105
+ try {
106
+ const response = await fetch(`${issuer}/token`, {
107
+ method: 'POST',
108
+ headers,
109
+ body: body.toString(),
110
+ })
111
+
112
+ if (!response.ok) return null
113
+
114
+ const data = (await response.json()) as {
115
+ access_token: string
116
+ refresh_token: string
117
+ }
118
+
119
+ const claims = extractTokenClaims(decodeJwtPayload(data.access_token))
120
+
121
+ return {
122
+ accessToken: data.access_token,
123
+ refreshToken: data.refresh_token,
124
+ isNewUser: claims.isNewUser,
125
+ provider: providerFromCookie ?? 'unknown',
126
+ claims: {
127
+ sub: claims.sub,
128
+ email: claims.email,
129
+ name: claims.name,
130
+ image: claims.image,
131
+ },
132
+ }
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ export async function revokeToken(
139
+ refreshToken: string,
140
+ clientId?: string,
141
+ clientSecret?: string,
142
+ ): Promise<void> {
143
+ const options = getConfig()
144
+ const issuer = resolveInternalIssuer(options)
145
+ const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded' }
146
+ if (clientId && clientSecret) {
147
+ headers.Authorization = createClientSecretBasicAuthHeader(clientId, clientSecret)
148
+ }
149
+
150
+ try {
151
+ await fetch(`${issuer}/revoke`, {
152
+ method: 'POST',
153
+ headers,
154
+ body: new URLSearchParams({
155
+ token: refreshToken,
156
+ token_type_hint: 'refresh_token',
157
+ ...(clientId && { client_id: clientId }),
158
+ }).toString(),
159
+ })
160
+ } catch {
161
+ // Fire and forget — ignore errors
162
+ }
163
+ }
@@ -0,0 +1,16 @@
1
+ import { base64url } from './encoding.js'
2
+
3
+ export async function createPKCEChallenge(verifier: string): Promise<string> {
4
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
5
+ return base64url(new Uint8Array(digest))
6
+ }
7
+
8
+ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
9
+ const array = new Uint8Array(32)
10
+ crypto.getRandomValues(array)
11
+ const verifier = base64url(array)
12
+
13
+ const challenge = await createPKCEChallenge(verifier)
14
+
15
+ return { verifier, challenge }
16
+ }
@@ -0,0 +1,20 @@
1
+ import { base64url } from './encoding.js'
2
+
3
+ export function generateState(): string {
4
+ const array = new Uint8Array(32)
5
+ crypto.getRandomValues(array)
6
+ return base64url(array)
7
+ }
8
+
9
+ export function verifyState(returned: string, stored: string): boolean {
10
+ if (!returned || !stored) return false
11
+ if (returned.length !== stored.length) return false
12
+
13
+ const a = new TextEncoder().encode(returned)
14
+ const b = new TextEncoder().encode(stored)
15
+ let diff = 0
16
+ for (let i = 0; i < a.length; i++) {
17
+ diff |= a[i] ^ b[i]
18
+ }
19
+ return diff === 0
20
+ }