@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
- package/src/client/login-page.ts +12 -0
- package/src/cloudflare-workers.d.ts +3 -0
- package/src/config-store.ts +70 -0
- package/src/index.ts +177 -0
- package/src/middleware/auth.ts +88 -0
- package/src/options.ts +75 -0
- package/src/renderers.ts +182 -0
- package/src/routes/api/code-request.ts +33 -0
- package/src/routes/api/code-verify.ts +35 -0
- package/src/routes/api/password-reset-confirm.ts +36 -0
- package/src/routes/api/password-reset-request.ts +32 -0
- package/src/routes/api/password-verify.ts +35 -0
- package/src/routes/api/shared.ts +130 -0
- package/src/routes/callback.ts +105 -0
- package/src/routes/flow.ts +53 -0
- package/src/routes/forgot-password.ts +31 -0
- package/src/routes/login.ts +130 -0
- package/src/routes/logout.ts +59 -0
- package/src/routes/password.ts +27 -0
- package/src/routes/reset-password.ts +33 -0
- package/src/routes/verify-code.ts +28 -0
- package/src/runtime-bridge.ts +15 -0
- package/src/runtime.ts +5 -0
- package/src/server.ts +185 -0
- package/src/types.ts +158 -0
- package/src/utils/analytics.ts +56 -0
- package/src/utils/client-secret.ts +21 -0
- package/src/utils/cookies.ts +134 -0
- package/src/utils/encoding.ts +5 -0
- package/src/utils/env-marker.ts +39 -0
- package/src/utils/identity-cookie.ts +138 -0
- package/src/utils/identity.ts +240 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/issuer.ts +7 -0
- package/src/utils/openauth.ts +163 -0
- package/src/utils/pkce.ts +16 -0
- package/src/utils/state.ts +20 -0
- package/src/utils/tokens.ts +128 -0
- 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,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
|
+
}
|