@growth-labs/auth 0.6.2 → 0.7.1
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/README.md +20 -0
- package/dist/routes/api/code-verify.d.ts.map +1 -1
- package/dist/routes/api/code-verify.js +1 -1
- package/dist/routes/api/code-verify.js.map +1 -1
- package/dist/routes/api/password-verify.d.ts.map +1 -1
- package/dist/routes/api/password-verify.js +1 -1
- package/dist/routes/api/password-verify.js.map +1 -1
- package/dist/routes/api/shared.d.ts +1 -1
- package/dist/routes/api/shared.d.ts.map +1 -1
- package/dist/routes/api/shared.js +25 -4
- package/dist/routes/api/shared.js.map +1 -1
- package/dist/routes/callback.d.ts.map +1 -1
- package/dist/routes/callback.js +4 -1
- package/dist/routes/callback.js.map +1 -1
- package/dist/routes/login.js.map +1 -1
- package/dist/utils/analytics.d.ts +2 -2
- package/dist/utils/analytics.d.ts.map +1 -1
- package/dist/utils/analytics.js +2 -1
- package/dist/utils/analytics.js.map +1 -1
- package/dist/utils/cookies.d.ts.map +1 -1
- package/dist/utils/cookies.js +1 -0
- package/dist/utils/cookies.js.map +1 -1
- package/package.json +8 -3
- 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 +173 -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
package/src/types.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export interface User {
|
|
2
|
+
id: string
|
|
3
|
+
email: string
|
|
4
|
+
name?: string | null
|
|
5
|
+
avatarUrl?: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type AuthenticatedUser = User
|
|
9
|
+
export type UnauthenticatedUser = null
|
|
10
|
+
|
|
11
|
+
export interface AuthRendererBrand {
|
|
12
|
+
title: string
|
|
13
|
+
brandName: string
|
|
14
|
+
logoUrl: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoginRendererPayload {
|
|
18
|
+
providerUrls: {
|
|
19
|
+
google?: string
|
|
20
|
+
password?: string
|
|
21
|
+
emailCode?: string
|
|
22
|
+
}
|
|
23
|
+
errorCode?: 'auth_failed' | 'session_expired' | string
|
|
24
|
+
reset?: boolean
|
|
25
|
+
redirect: string
|
|
26
|
+
brand: AuthRendererBrand
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ForgotPasswordRendererPayload {
|
|
30
|
+
forgotPasswordUrl: string
|
|
31
|
+
passwordResetRequestUrl: string
|
|
32
|
+
errorCode?: string
|
|
33
|
+
sent?: boolean
|
|
34
|
+
loginUrl: string
|
|
35
|
+
brand: AuthRendererBrand
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResetPasswordRendererPayload {
|
|
39
|
+
resetPasswordUrl: string
|
|
40
|
+
passwordResetConfirmUrl: string
|
|
41
|
+
errorCode?: string
|
|
42
|
+
token?: string
|
|
43
|
+
loginUrl: string
|
|
44
|
+
brand: AuthRendererBrand
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PasswordRendererPayload {
|
|
48
|
+
passwordVerifyUrl: string
|
|
49
|
+
forgotPasswordUrl: string
|
|
50
|
+
email?: string
|
|
51
|
+
errorCode?: string
|
|
52
|
+
redirect: string
|
|
53
|
+
loginUrl: string
|
|
54
|
+
brand: AuthRendererBrand
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface VerifyCodeRendererPayload {
|
|
58
|
+
codeRequestUrl: string
|
|
59
|
+
codeVerifyUrl: string
|
|
60
|
+
email?: string
|
|
61
|
+
errorCode?: string
|
|
62
|
+
sent?: boolean
|
|
63
|
+
redirect: string
|
|
64
|
+
loginUrl: string
|
|
65
|
+
brand: AuthRendererBrand
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EmailCodeConfirmRendererPayload {
|
|
69
|
+
loginUrl: string
|
|
70
|
+
brand: AuthRendererBrand
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type AuthRendererResult = string | Response | Promise<string | Response>
|
|
74
|
+
|
|
75
|
+
export type LoginRenderer = (payload: LoginRendererPayload) => AuthRendererResult
|
|
76
|
+
export type ForgotPasswordRenderer = (payload: ForgotPasswordRendererPayload) => AuthRendererResult
|
|
77
|
+
export type ResetPasswordRenderer = (payload: ResetPasswordRendererPayload) => AuthRendererResult
|
|
78
|
+
export type PasswordRenderer = (payload: PasswordRendererPayload) => AuthRendererResult
|
|
79
|
+
export type VerifyCodeRenderer = (payload: VerifyCodeRendererPayload) => AuthRendererResult
|
|
80
|
+
export type EmailCodeConfirmRenderer = (
|
|
81
|
+
payload: EmailCodeConfirmRendererPayload,
|
|
82
|
+
) => AuthRendererResult
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A renderer registration for the `renderers` integration option.
|
|
86
|
+
*
|
|
87
|
+
* `module` is an ES module specifier resolvable by the consumer's bundler
|
|
88
|
+
* (path alias, package name, or relative path). `export` is the named
|
|
89
|
+
* export within that module that holds the renderer function. The Vite
|
|
90
|
+
* plugin emits a real static `import { <export> as <local> } from <module>`
|
|
91
|
+
* at the top of the virtual `virtual:growth-labs/auth/renderers` module,
|
|
92
|
+
* which makes the renderer fully bundleable and tree-shakeable in
|
|
93
|
+
* production. **Do not pass an inline async function** — that approach
|
|
94
|
+
* (via `Function.prototype.toString()`) crashed in production bundles in
|
|
95
|
+
* 0.3.6 because Vite's SSR transform rewrote dynamic imports inside the
|
|
96
|
+
* serialized body to `__vite_ssr_dynamic_import__()`, a helper that does
|
|
97
|
+
* not exist in production bundles. See `MIGRATION.md`.
|
|
98
|
+
*/
|
|
99
|
+
export interface AuthRendererSpec {
|
|
100
|
+
module: string
|
|
101
|
+
export: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface AuthRenderers {
|
|
105
|
+
login?: AuthRendererSpec
|
|
106
|
+
forgotPassword?: AuthRendererSpec
|
|
107
|
+
resetPassword?: AuthRendererSpec
|
|
108
|
+
password?: AuthRendererSpec
|
|
109
|
+
verifyCode?: AuthRendererSpec
|
|
110
|
+
emailCodeConfirm?: AuthRendererSpec
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The shape of `virtual:growth-labs/auth/renderers` after the plugin
|
|
115
|
+
* resolves all registered specs into static ES imports. Internal — only
|
|
116
|
+
* the package's own routes import from this virtual module.
|
|
117
|
+
*/
|
|
118
|
+
export interface ResolvedAuthRenderers {
|
|
119
|
+
login?: LoginRenderer
|
|
120
|
+
forgotPassword?: ForgotPasswordRenderer
|
|
121
|
+
resetPassword?: ResetPasswordRenderer
|
|
122
|
+
password?: PasswordRenderer
|
|
123
|
+
verifyCode?: VerifyCodeRenderer
|
|
124
|
+
emailCodeConfirm?: EmailCodeConfirmRenderer
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface TokenRefreshResult {
|
|
128
|
+
accessToken: string
|
|
129
|
+
refreshToken: string
|
|
130
|
+
claims: {
|
|
131
|
+
sub: string
|
|
132
|
+
email: string
|
|
133
|
+
name?: string | null
|
|
134
|
+
image?: string
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TokenExchangeResult {
|
|
139
|
+
accessToken: string
|
|
140
|
+
refreshToken: string
|
|
141
|
+
isNewUser: boolean
|
|
142
|
+
provider: 'google' | 'password' | 'email-code' | 'unknown'
|
|
143
|
+
claims: {
|
|
144
|
+
sub: string
|
|
145
|
+
email: string
|
|
146
|
+
name?: string | null
|
|
147
|
+
image?: string
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Augment Astro's locals
|
|
152
|
+
declare global {
|
|
153
|
+
namespace App {
|
|
154
|
+
interface Locals {
|
|
155
|
+
user: User | null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
type TrackFn = (context: unknown, event: string, props?: Record<string, unknown>) => Promise<void>
|
|
2
|
+
|
|
3
|
+
let trackFn: TrackFn | null = null
|
|
4
|
+
let resolved = false
|
|
5
|
+
const ANALYTICS_UTILS_MODULE = '@growth-labs/analytics/utils'
|
|
6
|
+
|
|
7
|
+
async function importOptionalModule(modulePath: string): Promise<Record<string, unknown> | null> {
|
|
8
|
+
try {
|
|
9
|
+
return (await import(/* @vite-ignore */ modulePath)) as Record<string, unknown>
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function resolveAnalytics(): Promise<void> {
|
|
16
|
+
if (resolved) return
|
|
17
|
+
resolved = true
|
|
18
|
+
const mod = await importOptionalModule(ANALYTICS_UTILS_MODULE)
|
|
19
|
+
let candidate: unknown
|
|
20
|
+
try {
|
|
21
|
+
candidate = mod?.trackServerEvent
|
|
22
|
+
} catch {
|
|
23
|
+
candidate = undefined
|
|
24
|
+
}
|
|
25
|
+
if (typeof candidate === 'function') {
|
|
26
|
+
trackFn = candidate as TrackFn
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function emitAuthEvent(
|
|
31
|
+
context: unknown,
|
|
32
|
+
eventName: string,
|
|
33
|
+
data: Record<string, unknown>,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
await resolveAnalytics()
|
|
36
|
+
if (!trackFn) return
|
|
37
|
+
return trackFn(context, eventName, data)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function analyticsAvailable(context: { locals?: Record<string, unknown> }): boolean {
|
|
41
|
+
void context
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function trackAuthEvent(
|
|
46
|
+
context: unknown,
|
|
47
|
+
eventName: string,
|
|
48
|
+
data: Record<string, unknown> = {},
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
return emitAuthEvent(context, eventName, data)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function _resetAnalytics(): void {
|
|
54
|
+
trackFn = null
|
|
55
|
+
resolved = false
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getRuntimeEnv } from '../runtime.js'
|
|
2
|
+
|
|
3
|
+
export function resolveClientSecret(clientSecret?: string): string | undefined {
|
|
4
|
+
if (!clientSecret) return undefined
|
|
5
|
+
|
|
6
|
+
const runtimeValue = getRuntimeEnv()[clientSecret]
|
|
7
|
+
if (typeof runtimeValue === 'string' && runtimeValue.length > 0) {
|
|
8
|
+
return runtimeValue
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return clientSecret
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createClientSecretBasicAuthHeader(clientId: string, clientSecret: string): string {
|
|
15
|
+
return `Basic ${btoa(`${formEncode(clientId)}:${formEncode(clientSecret)}`)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formEncode(value: string): string {
|
|
19
|
+
const params = new URLSearchParams({ value })
|
|
20
|
+
return params.toString().slice('value='.length)
|
|
21
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ResolvedAuthOptions } from '../options.js'
|
|
2
|
+
|
|
3
|
+
export interface CookieAttributes {
|
|
4
|
+
path?: string
|
|
5
|
+
maxAge?: number
|
|
6
|
+
httpOnly?: boolean
|
|
7
|
+
secure?: boolean
|
|
8
|
+
sameSite?: 'strict' | 'lax' | 'none'
|
|
9
|
+
domain?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const IDENTITY_COOKIE_NAME = 'gl_id'
|
|
13
|
+
const IDENTITY_COOKIE_MAX_AGE = 2592000
|
|
14
|
+
|
|
15
|
+
export function getCookie(request: Request, name: string): string | null {
|
|
16
|
+
const header = request.headers.get('cookie')
|
|
17
|
+
if (!header) return null
|
|
18
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
|
|
19
|
+
return match ? decodeURIComponent(match[1]) : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setCookie(
|
|
23
|
+
response: Response,
|
|
24
|
+
name: string,
|
|
25
|
+
value: string,
|
|
26
|
+
attributes: CookieAttributes = {},
|
|
27
|
+
): void {
|
|
28
|
+
const parts = [`${name}=${encodeURIComponent(value)}`]
|
|
29
|
+
if (attributes.path) parts.push(`Path=${attributes.path}`)
|
|
30
|
+
if (attributes.maxAge !== undefined) parts.push(`Max-Age=${attributes.maxAge}`)
|
|
31
|
+
if (attributes.httpOnly) parts.push('HttpOnly')
|
|
32
|
+
if (attributes.secure) parts.push('Secure')
|
|
33
|
+
if (attributes.sameSite) parts.push(`SameSite=${attributes.sameSite}`)
|
|
34
|
+
if (attributes.domain) parts.push(`Domain=${attributes.domain}`)
|
|
35
|
+
response.headers.append('Set-Cookie', parts.join('; '))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearCookie(response: Response, name: string, domain?: string, path = '/'): void {
|
|
39
|
+
setCookie(response, name, '', {
|
|
40
|
+
path,
|
|
41
|
+
maxAge: 0,
|
|
42
|
+
...(domain && { domain }),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function setIdentityCookie(response: Response, value: string): void {
|
|
47
|
+
setCookie(response, IDENTITY_COOKIE_NAME, value, {
|
|
48
|
+
path: '/',
|
|
49
|
+
maxAge: IDENTITY_COOKIE_MAX_AGE,
|
|
50
|
+
httpOnly: true,
|
|
51
|
+
secure: true,
|
|
52
|
+
sameSite: 'lax',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function clearIdentityCookie(response: Response): void {
|
|
57
|
+
setCookie(response, IDENTITY_COOKIE_NAME, '', {
|
|
58
|
+
path: '/',
|
|
59
|
+
maxAge: 0,
|
|
60
|
+
httpOnly: true,
|
|
61
|
+
secure: true,
|
|
62
|
+
sameSite: 'lax',
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setTokenCookies(
|
|
67
|
+
response: Response,
|
|
68
|
+
options: ResolvedAuthOptions,
|
|
69
|
+
accessToken: string,
|
|
70
|
+
refreshToken: string,
|
|
71
|
+
): void {
|
|
72
|
+
const base: CookieAttributes = {
|
|
73
|
+
path: '/',
|
|
74
|
+
httpOnly: true,
|
|
75
|
+
secure: true,
|
|
76
|
+
sameSite: 'lax',
|
|
77
|
+
...(options.sessionCookieDomain && { domain: options.sessionCookieDomain }),
|
|
78
|
+
}
|
|
79
|
+
setCookie(response, `${options.cookiePrefix}_at`, accessToken, {
|
|
80
|
+
...base,
|
|
81
|
+
maxAge: options.accessTokenMaxAge,
|
|
82
|
+
})
|
|
83
|
+
setCookie(response, `${options.cookiePrefix}_rt`, refreshToken, {
|
|
84
|
+
...base,
|
|
85
|
+
maxAge: options.refreshTokenMaxAge,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clearTokenCookies(response: Response, options: ResolvedAuthOptions): void {
|
|
90
|
+
clearCookie(response, `${options.cookiePrefix}_at`, options.sessionCookieDomain)
|
|
91
|
+
clearCookie(response, `${options.cookiePrefix}_rt`, options.sessionCookieDomain)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function clearAuthFlowCookies(response: Response, options: ResolvedAuthOptions): void {
|
|
95
|
+
clearCookie(response, `${options.cookiePrefix}_pkce`, undefined, options.callbackPath)
|
|
96
|
+
clearCookie(response, `${options.cookiePrefix}_pkce`)
|
|
97
|
+
clearCookie(response, `${options.cookiePrefix}_state`)
|
|
98
|
+
clearCookie(response, `${options.cookiePrefix}_redirect`)
|
|
99
|
+
clearCookie(response, `${options.cookiePrefix}_provider`)
|
|
100
|
+
clearCookie(response, `${options.cookiePrefix}_auth_path`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setAuthFlowCookies(
|
|
104
|
+
response: Response,
|
|
105
|
+
options: ResolvedAuthOptions,
|
|
106
|
+
verifier: string,
|
|
107
|
+
state: string,
|
|
108
|
+
redirectPath: string,
|
|
109
|
+
provider?: string,
|
|
110
|
+
flowOptions: { pkcePath?: string; additionalPkcePaths?: string[] } = {},
|
|
111
|
+
): void {
|
|
112
|
+
const baseAttrs: CookieAttributes = {
|
|
113
|
+
path: '/',
|
|
114
|
+
httpOnly: true,
|
|
115
|
+
secure: true,
|
|
116
|
+
sameSite: 'lax',
|
|
117
|
+
maxAge: 600,
|
|
118
|
+
}
|
|
119
|
+
const pkcePaths = new Set([
|
|
120
|
+
flowOptions.pkcePath ?? options.callbackPath,
|
|
121
|
+
...(flowOptions.additionalPkcePaths ?? []),
|
|
122
|
+
])
|
|
123
|
+
for (const path of pkcePaths) {
|
|
124
|
+
setCookie(response, `${options.cookiePrefix}_pkce`, verifier, {
|
|
125
|
+
...baseAttrs,
|
|
126
|
+
path,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
setCookie(response, `${options.cookiePrefix}_state`, state, baseAttrs)
|
|
130
|
+
setCookie(response, `${options.cookiePrefix}_redirect`, redirectPath, baseAttrs)
|
|
131
|
+
if (provider) {
|
|
132
|
+
setCookie(response, `${options.cookiePrefix}_provider`, provider, baseAttrs)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -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
|
+
}
|