@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,27 @@
|
|
|
1
|
+
import { getConfig } from '../config-store.js'
|
|
2
|
+
import { getRenderer, passwordRendererPayload, renderHtmlResponse } from '../renderers.js'
|
|
3
|
+
import { type RouteContext, withAuthFlowCookies } from './flow.js'
|
|
4
|
+
|
|
5
|
+
export async function GET(context: RouteContext): Promise<Response> {
|
|
6
|
+
const options = getConfig()
|
|
7
|
+
if (!options.providers.includes('password')) {
|
|
8
|
+
return new Response('Not found', { status: 404 })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const renderer = getRenderer('password')
|
|
12
|
+
if (!renderer) {
|
|
13
|
+
return new Response('Not found', { status: 404 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const redirectPath = context.url.searchParams.get('redirect') || '/'
|
|
17
|
+
const response = await renderHtmlResponse(
|
|
18
|
+
renderer(
|
|
19
|
+
passwordRendererPayload(options, {
|
|
20
|
+
email: context.url.searchParams.get('email'),
|
|
21
|
+
error: context.url.searchParams.get('error'),
|
|
22
|
+
redirectPath,
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
return withAuthFlowCookies(context, options, response, redirectPath, 'password')
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getConfig } from '../config-store.js'
|
|
2
|
+
import { getRenderer, renderHtmlResponse, resetPasswordRendererPayload } from '../renderers.js'
|
|
3
|
+
import { getResetPasswordUrl } from '../utils/openauth.js'
|
|
4
|
+
|
|
5
|
+
interface ResetPasswordContext {
|
|
6
|
+
request: Request
|
|
7
|
+
url: URL
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function GET(context: ResetPasswordContext): Promise<Response> {
|
|
11
|
+
const options = getConfig()
|
|
12
|
+
|
|
13
|
+
if (!options.providers.includes('password')) {
|
|
14
|
+
return new Response('Not found', { status: 404 })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const target = new URL(getResetPasswordUrl())
|
|
18
|
+
target.search = context.url.search
|
|
19
|
+
const renderer = getRenderer('resetPassword')
|
|
20
|
+
if (renderer) {
|
|
21
|
+
return renderHtmlResponse(
|
|
22
|
+
renderer(
|
|
23
|
+
resetPasswordRendererPayload(options, {
|
|
24
|
+
resetPasswordUrl: target.toString(),
|
|
25
|
+
error: context.url.searchParams.get('error'),
|
|
26
|
+
token: context.url.searchParams.get('token'),
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Response.redirect(target.toString(), 302)
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getConfig } from '../config-store.js'
|
|
2
|
+
import { getRenderer, renderHtmlResponse, verifyCodeRendererPayload } from '../renderers.js'
|
|
3
|
+
import { type RouteContext, withAuthFlowCookies } from './flow.js'
|
|
4
|
+
|
|
5
|
+
export async function GET(context: RouteContext): Promise<Response> {
|
|
6
|
+
const options = getConfig()
|
|
7
|
+
if (!options.providers.includes('email-code')) {
|
|
8
|
+
return new Response('Not found', { status: 404 })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const renderer = getRenderer('verifyCode')
|
|
12
|
+
if (!renderer) {
|
|
13
|
+
return new Response('Not found', { status: 404 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const redirectPath = context.url.searchParams.get('redirect') || '/'
|
|
17
|
+
const response = await renderHtmlResponse(
|
|
18
|
+
renderer(
|
|
19
|
+
verifyCodeRendererPayload(options, {
|
|
20
|
+
email: context.url.searchParams.get('email'),
|
|
21
|
+
error: context.url.searchParams.get('error'),
|
|
22
|
+
sent: context.url.searchParams.get('sent'),
|
|
23
|
+
redirectPath,
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
return withAuthFlowCookies(context, options, response, redirectPath, 'email-code')
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Side-effect module: stashes the Cloudflare Worker runtime env onto
|
|
2
|
+
// `globalThis.__GL_AUTH_RUNTIME_ENV__` so that `src/config-store.ts` can
|
|
3
|
+
// read env values without statically importing `cloudflare:workers`.
|
|
4
|
+
//
|
|
5
|
+
// Loaded by `src/middleware/auth.ts` only. The package main entry
|
|
6
|
+
// (`dist/index.js`) does NOT statically import this file, which keeps the
|
|
7
|
+
// pure-Node import gate green — `cloudflare:workers` only enters the
|
|
8
|
+
// graph through middleware/routes that already needed it.
|
|
9
|
+
import { getRuntimeEnv } from './runtime.js'
|
|
10
|
+
|
|
11
|
+
;(
|
|
12
|
+
globalThis as typeof globalThis & {
|
|
13
|
+
__GL_AUTH_RUNTIME_ENV__?: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
).__GL_AUTH_RUNTIME_ENV__ = getRuntimeEnv()
|
package/src/runtime.ts
ADDED
package/src/server.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { getConfig } from './config-store.js'
|
|
2
|
+
import type { ResolvedAuthOptions } from './options.js'
|
|
3
|
+
import { createClientSecretBasicAuthHeader, resolveClientSecret } from './utils/client-secret.js'
|
|
4
|
+
import { getCookie } from './utils/cookies.js'
|
|
5
|
+
import { resolveInternalIssuer } from './utils/issuer.js'
|
|
6
|
+
import { createPKCEChallenge } from './utils/pkce.js'
|
|
7
|
+
|
|
8
|
+
export type AuthClientCodeResult = { code: string } | { error: string }
|
|
9
|
+
export type AuthClientOkResult = { ok: true } | { error: string }
|
|
10
|
+
|
|
11
|
+
export interface AuthClientContext {
|
|
12
|
+
request: Request
|
|
13
|
+
url: URL
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AuthClient {
|
|
17
|
+
password: {
|
|
18
|
+
verify(input: { email: string; password: string }): Promise<AuthClientCodeResult>
|
|
19
|
+
reset: {
|
|
20
|
+
request(input: { email: string }): Promise<AuthClientOkResult>
|
|
21
|
+
confirm(input: { token: string; newPassword: string }): Promise<AuthClientOkResult>
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
code: {
|
|
25
|
+
request(input: { email: string }): Promise<AuthClientOkResult>
|
|
26
|
+
verify(input: { email: string; code: string }): Promise<AuthClientCodeResult>
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AuthFlowPayload {
|
|
31
|
+
client_id: string
|
|
32
|
+
redirect_uri: string
|
|
33
|
+
code_challenge: string
|
|
34
|
+
code_challenge_method: 'S256'
|
|
35
|
+
state: string
|
|
36
|
+
resource?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clientId(options: ResolvedAuthOptions, context: AuthClientContext): string {
|
|
40
|
+
return options.clientId || context.url.hostname
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function redirectUri(options: ResolvedAuthOptions, context: AuthClientContext): string {
|
|
44
|
+
return new URL(options.callbackPath, context.url.origin).toString()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function authFlowPayload(
|
|
48
|
+
options: ResolvedAuthOptions,
|
|
49
|
+
context: AuthClientContext,
|
|
50
|
+
): Promise<AuthFlowPayload | { error: string }> {
|
|
51
|
+
const verifier = getCookie(context.request, `${options.cookiePrefix}_pkce`)
|
|
52
|
+
const state = getCookie(context.request, `${options.cookiePrefix}_state`)
|
|
53
|
+
if (!verifier || !state) {
|
|
54
|
+
return { error: 'missing_auth_flow' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
client_id: clientId(options, context),
|
|
59
|
+
redirect_uri: redirectUri(options, context),
|
|
60
|
+
code_challenge: await createPKCEChallenge(verifier),
|
|
61
|
+
code_challenge_method: 'S256',
|
|
62
|
+
state,
|
|
63
|
+
...(options.resource && { resource: options.resource }),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function headersFor(
|
|
68
|
+
options: ResolvedAuthOptions,
|
|
69
|
+
context: AuthClientContext,
|
|
70
|
+
): Record<string, string> {
|
|
71
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
72
|
+
const secret = resolveClientSecret(options.clientSecret)
|
|
73
|
+
if (secret) {
|
|
74
|
+
headers.Authorization = createClientSecretBasicAuthHeader(clientId(options, context), secret)
|
|
75
|
+
}
|
|
76
|
+
return headers
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
return typeof value === 'object' && value !== null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
|
84
|
+
try {
|
|
85
|
+
const body = await response.json()
|
|
86
|
+
return isRecord(body) ? body : {}
|
|
87
|
+
} catch {
|
|
88
|
+
return {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function postJson<T extends AuthClientCodeResult | AuthClientOkResult>(
|
|
93
|
+
options: ResolvedAuthOptions,
|
|
94
|
+
context: AuthClientContext,
|
|
95
|
+
pathname: string,
|
|
96
|
+
body: Record<string, unknown>,
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(`${resolveInternalIssuer(options)}${pathname}`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: headersFor(options, context),
|
|
102
|
+
body: JSON.stringify(body),
|
|
103
|
+
})
|
|
104
|
+
const json = await readJson(response)
|
|
105
|
+
|
|
106
|
+
if (typeof json.error === 'string') {
|
|
107
|
+
return { error: json.error } as T
|
|
108
|
+
}
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
return { error: 'request_failed' } as T
|
|
111
|
+
}
|
|
112
|
+
if (typeof json.code === 'string') {
|
|
113
|
+
return { code: json.code } as T
|
|
114
|
+
}
|
|
115
|
+
if (json.ok === true) {
|
|
116
|
+
return { ok: true } as T
|
|
117
|
+
}
|
|
118
|
+
return { error: 'invalid_response' } as T
|
|
119
|
+
} catch {
|
|
120
|
+
return { error: 'request_failed' } as T
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function authClient(context: AuthClientContext): AuthClient {
|
|
125
|
+
const options = getConfig()
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
password: {
|
|
129
|
+
async verify(input) {
|
|
130
|
+
const flow = await authFlowPayload(options, context)
|
|
131
|
+
if ('error' in flow) return flow
|
|
132
|
+
return postJson<AuthClientCodeResult>(options, context, '/api/v1/auth/password/verify', {
|
|
133
|
+
...flow,
|
|
134
|
+
email: input.email,
|
|
135
|
+
password: input.password,
|
|
136
|
+
provider: 'password',
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
reset: {
|
|
140
|
+
request(input) {
|
|
141
|
+
return postJson<AuthClientOkResult>(
|
|
142
|
+
options,
|
|
143
|
+
context,
|
|
144
|
+
'/api/v1/auth/password/reset/request',
|
|
145
|
+
{
|
|
146
|
+
email: input.email,
|
|
147
|
+
client_id: clientId(options, context),
|
|
148
|
+
reset_url_base: context.url.origin,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
confirm(input) {
|
|
153
|
+
return postJson<AuthClientOkResult>(
|
|
154
|
+
options,
|
|
155
|
+
context,
|
|
156
|
+
'/api/v1/auth/password/reset/confirm',
|
|
157
|
+
{
|
|
158
|
+
client_id: clientId(options, context),
|
|
159
|
+
token: input.token,
|
|
160
|
+
new_password: input.newPassword,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
code: {
|
|
167
|
+
request(input) {
|
|
168
|
+
return postJson<AuthClientOkResult>(options, context, '/api/v1/auth/code/request', {
|
|
169
|
+
email: input.email,
|
|
170
|
+
client_id: clientId(options, context),
|
|
171
|
+
})
|
|
172
|
+
},
|
|
173
|
+
async verify(input) {
|
|
174
|
+
const flow = await authFlowPayload(options, context)
|
|
175
|
+
if ('error' in flow) return flow
|
|
176
|
+
return postJson<AuthClientCodeResult>(options, context, '/api/v1/auth/code/verify', {
|
|
177
|
+
...flow,
|
|
178
|
+
email: input.email,
|
|
179
|
+
code: input.code,
|
|
180
|
+
provider: 'code',
|
|
181
|
+
})
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
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
|
+
}
|