@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.
Files changed (62) hide show
  1. package/README.md +20 -0
  2. package/dist/routes/api/code-verify.d.ts.map +1 -1
  3. package/dist/routes/api/code-verify.js +1 -1
  4. package/dist/routes/api/code-verify.js.map +1 -1
  5. package/dist/routes/api/password-verify.d.ts.map +1 -1
  6. package/dist/routes/api/password-verify.js +1 -1
  7. package/dist/routes/api/password-verify.js.map +1 -1
  8. package/dist/routes/api/shared.d.ts +1 -1
  9. package/dist/routes/api/shared.d.ts.map +1 -1
  10. package/dist/routes/api/shared.js +25 -4
  11. package/dist/routes/api/shared.js.map +1 -1
  12. package/dist/routes/callback.d.ts.map +1 -1
  13. package/dist/routes/callback.js +4 -1
  14. package/dist/routes/callback.js.map +1 -1
  15. package/dist/routes/login.js.map +1 -1
  16. package/dist/utils/analytics.d.ts +2 -2
  17. package/dist/utils/analytics.d.ts.map +1 -1
  18. package/dist/utils/analytics.js +2 -1
  19. package/dist/utils/analytics.js.map +1 -1
  20. package/dist/utils/cookies.d.ts.map +1 -1
  21. package/dist/utils/cookies.js +1 -0
  22. package/dist/utils/cookies.js.map +1 -1
  23. package/package.json +8 -3
  24. package/src/client/login-page.ts +12 -0
  25. package/src/cloudflare-workers.d.ts +3 -0
  26. package/src/config-store.ts +70 -0
  27. package/src/index.ts +173 -0
  28. package/src/middleware/auth.ts +88 -0
  29. package/src/options.ts +75 -0
  30. package/src/renderers.ts +182 -0
  31. package/src/routes/api/code-request.ts +33 -0
  32. package/src/routes/api/code-verify.ts +35 -0
  33. package/src/routes/api/password-reset-confirm.ts +36 -0
  34. package/src/routes/api/password-reset-request.ts +32 -0
  35. package/src/routes/api/password-verify.ts +35 -0
  36. package/src/routes/api/shared.ts +130 -0
  37. package/src/routes/callback.ts +105 -0
  38. package/src/routes/flow.ts +53 -0
  39. package/src/routes/forgot-password.ts +31 -0
  40. package/src/routes/login.ts +130 -0
  41. package/src/routes/logout.ts +59 -0
  42. package/src/routes/password.ts +27 -0
  43. package/src/routes/reset-password.ts +33 -0
  44. package/src/routes/verify-code.ts +28 -0
  45. package/src/runtime-bridge.ts +15 -0
  46. package/src/runtime.ts +5 -0
  47. package/src/server.ts +185 -0
  48. package/src/types.ts +158 -0
  49. package/src/utils/analytics.ts +56 -0
  50. package/src/utils/client-secret.ts +21 -0
  51. package/src/utils/cookies.ts +134 -0
  52. package/src/utils/encoding.ts +5 -0
  53. package/src/utils/env-marker.ts +39 -0
  54. package/src/utils/identity-cookie.ts +138 -0
  55. package/src/utils/identity.ts +240 -0
  56. package/src/utils/index.ts +19 -0
  57. package/src/utils/issuer.ts +7 -0
  58. package/src/utils/openauth.ts +163 -0
  59. package/src/utils/pkce.ts +16 -0
  60. package/src/utils/state.ts +20 -0
  61. package/src/utils/tokens.ts +128 -0
  62. package/src/virtual.d.ts +5 -0
@@ -0,0 +1,88 @@
1
+ import { getConfig } from '../config-store.js'
2
+ import '../runtime-bridge.js'
3
+ import type { User } from '../types.js'
4
+ import { resolveClientSecret } from '../utils/client-secret.js'
5
+ import { getCookie, setTokenCookies } from '../utils/cookies.js'
6
+ import { resolveInternalIssuer } from '../utils/issuer.js'
7
+ import { refreshTokens, verifyAccessToken } from '../utils/tokens.js'
8
+
9
+ export async function onRequest(
10
+ context: { request: Request; url: URL; locals: Record<string, unknown> },
11
+ next: () => Promise<Response>,
12
+ ): Promise<Response> {
13
+ const options = getConfig()
14
+ const issuer = resolveInternalIssuer(options)
15
+ const { request } = context
16
+
17
+ // 1. Read tokens from cookies
18
+ const accessToken = getCookie(request, `${options.cookiePrefix}_at`)
19
+ const refreshToken = getCookie(request, `${options.cookiePrefix}_rt`)
20
+
21
+ // 2. Attempt to authenticate
22
+ let user: User | null = null
23
+ let refreshed: Awaited<ReturnType<typeof refreshTokens>> = null
24
+
25
+ if (accessToken) {
26
+ const verified = await verifyAccessToken(issuer, accessToken)
27
+ if (verified) {
28
+ user = {
29
+ id: verified.sub,
30
+ email: verified.email,
31
+ name: verified.name ?? null,
32
+ avatarUrl: verified.image ?? null,
33
+ }
34
+ }
35
+ }
36
+
37
+ if (!user && refreshToken) {
38
+ const clientId = options.clientId || context.url.hostname
39
+ const clientSecret = resolveClientSecret(options.clientSecret)
40
+ refreshed = await refreshTokens(issuer, refreshToken, clientId, clientSecret, options.resource)
41
+ if (refreshed) {
42
+ user = {
43
+ id: refreshed.claims.sub,
44
+ email: refreshed.claims.email,
45
+ name: refreshed.claims.name ?? null,
46
+ avatarUrl: refreshed.claims.image ?? null,
47
+ }
48
+ }
49
+ }
50
+
51
+ // 3. Populate context.locals.user
52
+ context.locals.user = user
53
+
54
+ // 4. Gate protected paths
55
+ if (!user && isGatedPath(context.url.pathname, options.gatedPaths)) {
56
+ const loginUrl = new URL(
57
+ `${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}`,
58
+ context.url.origin,
59
+ )
60
+ return Response.redirect(loginUrl.toString(), 302)
61
+ }
62
+
63
+ // 5. Continue to the page
64
+ const response = await next()
65
+
66
+ // 6. If tokens were refreshed, set new cookies on the response
67
+ if (refreshed) {
68
+ const modifiedResponse = new Response(response.body, {
69
+ status: response.status,
70
+ statusText: response.statusText,
71
+ headers: new Headers(response.headers),
72
+ })
73
+ setTokenCookies(modifiedResponse, options, refreshed.accessToken, refreshed.refreshToken)
74
+ return modifiedResponse
75
+ }
76
+
77
+ return response
78
+ }
79
+
80
+ function isGatedPath(pathname: string, gatedPaths: string[]): boolean {
81
+ return gatedPaths.some((pattern) => {
82
+ if (pattern.endsWith('/*')) {
83
+ const prefix = pattern.slice(0, -1) // '/premium/*' → '/premium/'
84
+ return pathname.startsWith(prefix)
85
+ }
86
+ return pathname === pattern
87
+ })
88
+ }
package/src/options.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod'
2
+ import type { AuthRenderers } from './types.js'
3
+
4
+ // String accepted as either a literal URL or an "env:VAR_NAME" marker that
5
+ // gets resolved against the Worker runtime env at request time. See
6
+ // `src/utils/env-marker.ts` and `src/config-store.ts`. Bare "env:" (no
7
+ // binding name) is rejected — it can never resolve to anything useful.
8
+ const urlOrEnvMarker = z
9
+ .string()
10
+ .refine((v) => (v.startsWith('env:') && v.length > 4) || /^https?:\/\//i.test(v), {
11
+ message: 'Must be a URL (https?://...) or an "env:VAR_NAME" marker',
12
+ })
13
+
14
+ // String that, if shaped as an "env:VAR_NAME" marker, must include a
15
+ // non-empty binding name. Literal values pass through unconstrained
16
+ // (they can be any string, e.g. OAuth client IDs). A literal value that
17
+ // happens to start with the reserved "env:" prefix will be treated as a
18
+ // marker — pick a non-colliding name in that edge case.
19
+ const stringOrEnvMarker = z.string().refine((v) => !v.startsWith('env:') || v.length > 4, {
20
+ message: '"env:" marker must include a binding name (e.g. "env:AUTH_CLIENT_ID")',
21
+ })
22
+
23
+ export const authOptionsSchema = z.object({
24
+ // ─── Issuer URLs ───
25
+ issuer: urlOrEnvMarker,
26
+ issuerInternal: urlOrEnvMarker.optional(),
27
+
28
+ // ─── Client identity ───
29
+ clientId: stringOrEnvMarker.optional(),
30
+ clientSecret: z.string().optional(),
31
+ resource: urlOrEnvMarker.optional(),
32
+
33
+ // ─── Cookie configuration ───
34
+ cookiePrefix: z.string(),
35
+ sessionCookieDomain: z.string().optional(),
36
+
37
+ // ─── Token lifetimes ───
38
+ accessTokenMaxAge: z.number().default(900),
39
+ refreshTokenMaxAge: z.number().default(2592000),
40
+
41
+ // ─── Path configuration ───
42
+ gatedPaths: z.array(z.string()).default([]),
43
+ loginPath: z.string().default('/login'),
44
+ logoutRedirect: z.string().default('/'),
45
+ callbackPath: z.string().default('/api/auth/callback'),
46
+ passwordPath: z.string().default('/password'),
47
+ verifyCodePath: z.string().default('/verify-code'),
48
+ forgotPasswordPath: z.string().default('/forgot-password'),
49
+ resetPasswordPath: z.string().default('/reset-password'),
50
+ passwordVerifyPath: z.string().default('/api/auth/password/verify'),
51
+ codeRequestPath: z.string().default('/api/auth/code/request'),
52
+ codeVerifyPath: z.string().default('/api/auth/code/verify'),
53
+ passwordResetRequestPath: z.string().default('/api/auth/password/reset/request'),
54
+ passwordResetConfirmPath: z.string().default('/api/auth/password/reset/confirm'),
55
+
56
+ // ─── Providers ───
57
+ providers: z
58
+ .array(z.enum(['google', 'password', 'email-code']))
59
+ .default(['google', 'password', 'email-code']),
60
+
61
+ // ─── Login page customization ───
62
+ loginPage: z
63
+ .object({
64
+ title: z.string().default('Sign in'),
65
+ brandName: z.string().optional(),
66
+ logoUrl: z.string().optional(),
67
+ })
68
+ .default({}),
69
+
70
+ // ─── Consumer-owned UI renderers ───
71
+ renderers: z.custom<AuthRenderers>().default({}),
72
+ })
73
+
74
+ export type AuthOptions = z.input<typeof authOptionsSchema>
75
+ export type ResolvedAuthOptions = z.output<typeof authOptionsSchema>
@@ -0,0 +1,182 @@
1
+ import { renderers as bundledRenderers } from 'virtual:growth-labs/auth/renderers'
2
+ import type {
3
+ AuthRendererBrand,
4
+ AuthRendererResult,
5
+ ForgotPasswordRendererPayload,
6
+ LoginRendererPayload,
7
+ PasswordRendererPayload,
8
+ ResetPasswordRendererPayload,
9
+ ResolvedAuthRenderers,
10
+ VerifyCodeRendererPayload,
11
+ } from './types.js'
12
+
13
+ /**
14
+ * Look up the bundled renderer for a surface. After 0.3.7 the lookup is
15
+ * build-time only — renderers are resolved into the virtual module by the
16
+ * Vite plugin via real static `import` statements. The runtime fallback
17
+ * that previously read `options.renderers?.[surface]` is gone; it could
18
+ * never have worked in production (functions captured in `astro.config.mjs`
19
+ * don't survive serialization), and the new `AuthRenderers` shape is a
20
+ * `{ module, export }` spec rather than a function anyway.
21
+ */
22
+ export function getRenderer<K extends keyof ResolvedAuthRenderers>(
23
+ surface: K,
24
+ ): ResolvedAuthRenderers[K] | undefined {
25
+ return bundledRenderers[surface]
26
+ }
27
+
28
+ export async function renderHtmlResponse(result: AuthRendererResult): Promise<Response> {
29
+ const rendered = await result
30
+ if (rendered instanceof Response) {
31
+ return rendered
32
+ }
33
+ return new Response(rendered, {
34
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
35
+ })
36
+ }
37
+
38
+ export function rendererBrand(options: {
39
+ loginPage: { title: string; brandName?: string; logoUrl?: string }
40
+ }): AuthRendererBrand {
41
+ return {
42
+ title: options.loginPage.title,
43
+ brandName: options.loginPage.brandName ?? '',
44
+ logoUrl: options.loginPage.logoUrl ?? '',
45
+ }
46
+ }
47
+
48
+ export function loginRendererPayload(
49
+ options: {
50
+ loginPage: { title: string; brandName?: string; logoUrl?: string }
51
+ loginPath: string
52
+ passwordPath: string
53
+ verifyCodePath: string
54
+ providers: string[]
55
+ },
56
+ data: {
57
+ redirectPath: string
58
+ error: string | null
59
+ reset: string | null
60
+ useLocalPassword?: boolean
61
+ useLocalVerifyCode?: boolean
62
+ },
63
+ ): LoginRendererPayload {
64
+ const makeLoginStartUrl = (provider: string) =>
65
+ `${options.loginPath}?provider=${encodeURIComponent(provider)}&redirect=${encodeURIComponent(data.redirectPath)}`
66
+ const makeSurfaceUrl = (path: string) =>
67
+ `${path}?redirect=${encodeURIComponent(data.redirectPath)}`
68
+
69
+ return {
70
+ providerUrls: {
71
+ google: options.providers.includes('google') ? makeLoginStartUrl('google') : undefined,
72
+ password: options.providers.includes('password')
73
+ ? data.useLocalPassword
74
+ ? makeSurfaceUrl(options.passwordPath)
75
+ : makeLoginStartUrl('password')
76
+ : undefined,
77
+ emailCode: options.providers.includes('email-code')
78
+ ? data.useLocalVerifyCode
79
+ ? makeSurfaceUrl(options.verifyCodePath)
80
+ : makeLoginStartUrl('email-code')
81
+ : undefined,
82
+ },
83
+ errorCode: data.error ?? undefined,
84
+ reset: data.reset ? true : undefined,
85
+ redirect: data.redirectPath,
86
+ brand: rendererBrand(options),
87
+ }
88
+ }
89
+
90
+ export function forgotPasswordRendererPayload(
91
+ options: {
92
+ loginPage: { title: string; brandName?: string; logoUrl?: string }
93
+ loginPath: string
94
+ passwordResetRequestPath: string
95
+ },
96
+ data: {
97
+ forgotPasswordUrl: string
98
+ error: string | null
99
+ sent: string | null
100
+ },
101
+ ): ForgotPasswordRendererPayload {
102
+ return {
103
+ forgotPasswordUrl: data.forgotPasswordUrl,
104
+ passwordResetRequestUrl: options.passwordResetRequestPath,
105
+ errorCode: data.error ?? undefined,
106
+ sent: data.sent ? true : undefined,
107
+ loginUrl: options.loginPath,
108
+ brand: rendererBrand(options),
109
+ }
110
+ }
111
+
112
+ export function resetPasswordRendererPayload(
113
+ options: {
114
+ loginPage: { title: string; brandName?: string; logoUrl?: string }
115
+ loginPath: string
116
+ passwordResetConfirmPath: string
117
+ },
118
+ data: {
119
+ resetPasswordUrl: string
120
+ error: string | null
121
+ token: string | null
122
+ },
123
+ ): ResetPasswordRendererPayload {
124
+ return {
125
+ resetPasswordUrl: data.resetPasswordUrl,
126
+ passwordResetConfirmUrl: options.passwordResetConfirmPath,
127
+ errorCode: data.error ?? undefined,
128
+ token: data.token ?? undefined,
129
+ loginUrl: options.loginPath,
130
+ brand: rendererBrand(options),
131
+ }
132
+ }
133
+
134
+ export function passwordRendererPayload(
135
+ options: {
136
+ loginPage: { title: string; brandName?: string; logoUrl?: string }
137
+ loginPath: string
138
+ forgotPasswordPath: string
139
+ passwordVerifyPath: string
140
+ },
141
+ data: {
142
+ email: string | null
143
+ error: string | null
144
+ redirectPath: string
145
+ },
146
+ ): PasswordRendererPayload {
147
+ return {
148
+ passwordVerifyUrl: options.passwordVerifyPath,
149
+ forgotPasswordUrl: options.forgotPasswordPath,
150
+ email: data.email ?? undefined,
151
+ errorCode: data.error ?? undefined,
152
+ redirect: data.redirectPath,
153
+ loginUrl: options.loginPath,
154
+ brand: rendererBrand(options),
155
+ }
156
+ }
157
+
158
+ export function verifyCodeRendererPayload(
159
+ options: {
160
+ loginPage: { title: string; brandName?: string; logoUrl?: string }
161
+ loginPath: string
162
+ codeRequestPath: string
163
+ codeVerifyPath: string
164
+ },
165
+ data: {
166
+ email: string | null
167
+ error: string | null
168
+ sent: string | null
169
+ redirectPath: string
170
+ },
171
+ ): VerifyCodeRendererPayload {
172
+ return {
173
+ codeRequestUrl: options.codeRequestPath,
174
+ codeVerifyUrl: options.codeVerifyPath,
175
+ email: data.email ?? undefined,
176
+ errorCode: data.error ?? undefined,
177
+ sent: data.sent ? true : undefined,
178
+ redirect: data.redirectPath,
179
+ loginUrl: options.loginPath,
180
+ brand: rendererBrand(options),
181
+ }
182
+ }
@@ -0,0 +1,33 @@
1
+ import {
2
+ authClient,
3
+ getConfig,
4
+ type RouteContext,
5
+ readForm,
6
+ readFormString,
7
+ rejectCrossSitePost,
8
+ statusRedirect,
9
+ } from './shared.js'
10
+
11
+ export async function POST(context: RouteContext): Promise<Response> {
12
+ const crossSitePost = rejectCrossSitePost(context)
13
+ if (crossSitePost) return crossSitePost
14
+
15
+ const options = getConfig()
16
+ if (!options.providers.includes('email-code')) {
17
+ return new Response('Not found', { status: 404 })
18
+ }
19
+
20
+ const form = await readForm(context)
21
+ const email = await readFormString(form, 'email')
22
+ const redirect = form.get('redirect')?.toString()
23
+ const result = await authClient(context).code.request({ email })
24
+
25
+ return statusRedirect(
26
+ context,
27
+ result,
28
+ options.verifyCodePath,
29
+ { email, sent: '1', redirect },
30
+ options.verifyCodePath,
31
+ { email, redirect },
32
+ )
33
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ authClient,
3
+ callbackRedirect,
4
+ getConfig,
5
+ type RouteContext,
6
+ readForm,
7
+ readFormString,
8
+ rejectCrossSitePost,
9
+ } from './shared.js'
10
+
11
+ export async function POST(context: RouteContext): Promise<Response> {
12
+ const crossSitePost = rejectCrossSitePost(context)
13
+ if (crossSitePost) return crossSitePost
14
+
15
+ const options = getConfig()
16
+ if (!options.providers.includes('email-code')) {
17
+ return new Response('Not found', { status: 404 })
18
+ }
19
+
20
+ const form = await readForm(context)
21
+ const email = await readFormString(form, 'email')
22
+ const code = await readFormString(form, 'code')
23
+ const result = await authClient(context).code.verify({ email, code })
24
+
25
+ return callbackRedirect(
26
+ context,
27
+ result,
28
+ options.verifyCodePath,
29
+ {
30
+ email,
31
+ redirect: form.get('redirect')?.toString(),
32
+ },
33
+ 'email-code',
34
+ )
35
+ }
@@ -0,0 +1,36 @@
1
+ import {
2
+ authClient,
3
+ getConfig,
4
+ type RouteContext,
5
+ readForm,
6
+ readFormString,
7
+ rejectCrossSitePost,
8
+ statusRedirect,
9
+ } from './shared.js'
10
+
11
+ export async function POST(context: RouteContext): Promise<Response> {
12
+ const crossSitePost = rejectCrossSitePost(context)
13
+ if (crossSitePost) return crossSitePost
14
+
15
+ const options = getConfig()
16
+ if (!options.providers.includes('password')) {
17
+ return new Response('Not found', { status: 404 })
18
+ }
19
+
20
+ const form = await readForm(context)
21
+ const token = await readFormString(form, 'token')
22
+ const password = await readFormString(form, 'password', { trim: false })
23
+ const result = await authClient(context).password.reset.confirm({
24
+ token,
25
+ newPassword: password,
26
+ })
27
+
28
+ return statusRedirect(
29
+ context,
30
+ result,
31
+ options.loginPath,
32
+ { reset: 'success' },
33
+ options.resetPasswordPath,
34
+ { token },
35
+ )
36
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ authClient,
3
+ getConfig,
4
+ type RouteContext,
5
+ readForm,
6
+ readFormString,
7
+ rejectCrossSitePost,
8
+ statusRedirect,
9
+ } from './shared.js'
10
+
11
+ export async function POST(context: RouteContext): Promise<Response> {
12
+ const crossSitePost = rejectCrossSitePost(context)
13
+ if (crossSitePost) return crossSitePost
14
+
15
+ const options = getConfig()
16
+ if (!options.providers.includes('password')) {
17
+ return new Response('Not found', { status: 404 })
18
+ }
19
+
20
+ const form = await readForm(context)
21
+ const email = await readFormString(form, 'email')
22
+ const result = await authClient(context).password.reset.request({ email })
23
+
24
+ return statusRedirect(
25
+ context,
26
+ result,
27
+ options.forgotPasswordPath,
28
+ { sent: '1' },
29
+ options.forgotPasswordPath,
30
+ { email },
31
+ )
32
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ authClient,
3
+ callbackRedirect,
4
+ getConfig,
5
+ type RouteContext,
6
+ readForm,
7
+ readFormString,
8
+ rejectCrossSitePost,
9
+ } from './shared.js'
10
+
11
+ export async function POST(context: RouteContext): Promise<Response> {
12
+ const crossSitePost = rejectCrossSitePost(context)
13
+ if (crossSitePost) return crossSitePost
14
+
15
+ const options = getConfig()
16
+ if (!options.providers.includes('password')) {
17
+ return new Response('Not found', { status: 404 })
18
+ }
19
+
20
+ const form = await readForm(context)
21
+ const email = await readFormString(form, 'email')
22
+ const password = await readFormString(form, 'password', { trim: false })
23
+ const result = await authClient(context).password.verify({ email, password })
24
+
25
+ return callbackRedirect(
26
+ context,
27
+ result,
28
+ options.passwordPath,
29
+ {
30
+ email,
31
+ redirect: form.get('redirect')?.toString(),
32
+ },
33
+ 'password',
34
+ )
35
+ }
@@ -0,0 +1,130 @@
1
+ import { getConfig } from '../../config-store.js'
2
+ import { type AuthClientCodeResult, type AuthClientOkResult, authClient } from '../../server.js'
3
+ import { getCookie, setCookie } from '../../utils/cookies.js'
4
+ import { type RouteContext, readForm, redirectLocation, redirectTo } from '../flow.js'
5
+
6
+ const CROSS_SITE_POST_MESSAGE = 'Cross-site POST form submissions are forbidden'
7
+
8
+ export async function readFormString(
9
+ form: FormData,
10
+ key: string,
11
+ options: { trim?: boolean } = {},
12
+ ): Promise<string> {
13
+ const value = form.get(key)?.toString() ?? ''
14
+ return options.trim === false ? value : value.trim()
15
+ }
16
+
17
+ export function rejectCrossSitePost(context: RouteContext): Response | null {
18
+ const origin = context.request.headers.get('origin')
19
+ if (!origin) {
20
+ return new Response(CROSS_SITE_POST_MESSAGE, { status: 403 })
21
+ }
22
+
23
+ try {
24
+ if (new URL(origin).origin === context.url.origin) {
25
+ return null
26
+ }
27
+ } catch {
28
+ return new Response(CROSS_SITE_POST_MESSAGE, { status: 403 })
29
+ }
30
+
31
+ return new Response(CROSS_SITE_POST_MESSAGE, { status: 403 })
32
+ }
33
+
34
+ export function callbackRedirect(
35
+ context: RouteContext,
36
+ result: AuthClientCodeResult,
37
+ errorPath: string,
38
+ errorParams: Record<string, string | undefined>,
39
+ provider?: 'password' | 'email-code',
40
+ ): Response {
41
+ if ('error' in result) {
42
+ return routeResult(context, errorPath, { ...errorParams, error: result.error }, result.error)
43
+ }
44
+
45
+ const options = getConfig()
46
+ const state = getCookie(context.request, `${options.cookiePrefix}_state`)
47
+ if (!state) {
48
+ return routeResult(
49
+ context,
50
+ errorPath,
51
+ { ...errorParams, error: 'missing_auth_flow' },
52
+ 'missing_auth_flow',
53
+ )
54
+ }
55
+
56
+ const response = mutableHeaders(
57
+ routeResult(context, options.callbackPath, {
58
+ code: result.code,
59
+ state,
60
+ }),
61
+ )
62
+ setCookie(
63
+ response,
64
+ `${options.cookiePrefix}_auth_path`,
65
+ context.url.pathname,
66
+ authFlowCookieAttrs(),
67
+ )
68
+ if (provider) {
69
+ setCookie(response, `${options.cookiePrefix}_provider`, provider, authFlowCookieAttrs())
70
+ }
71
+ return response
72
+ }
73
+
74
+ export function statusRedirect(
75
+ context: RouteContext,
76
+ result: AuthClientOkResult,
77
+ successPath: string,
78
+ successParams: Record<string, string | undefined>,
79
+ errorPath: string,
80
+ errorParams: Record<string, string | undefined>,
81
+ ): Response {
82
+ if ('error' in result) {
83
+ return routeResult(context, errorPath, { ...errorParams, error: result.error }, result.error)
84
+ }
85
+ return routeResult(context, successPath, successParams)
86
+ }
87
+
88
+ function wantsJson(context: RouteContext): boolean {
89
+ const accept = context.request.headers.get('accept') ?? ''
90
+ return accept
91
+ .split(',')
92
+ .some((value) => value.trim().toLowerCase().split(';')[0] === 'application/json')
93
+ }
94
+
95
+ function routeResult(
96
+ context: RouteContext,
97
+ pathname: string,
98
+ params: Record<string, string | undefined>,
99
+ error?: string,
100
+ ): Response {
101
+ const location = redirectLocation(context, pathname, params)
102
+ if (!wantsJson(context)) {
103
+ return redirectTo(context, pathname, params)
104
+ }
105
+
106
+ return Response.json(error ? { error, redirect: location } : { ok: true, redirect: location }, {
107
+ status: error ? 400 : 200,
108
+ })
109
+ }
110
+
111
+ export type { RouteContext }
112
+ export { authClient, getConfig, readForm, redirectTo }
113
+
114
+ function mutableHeaders(response: Response): Response {
115
+ return new Response(response.body, {
116
+ status: response.status,
117
+ statusText: response.statusText,
118
+ headers: new Headers(response.headers),
119
+ })
120
+ }
121
+
122
+ function authFlowCookieAttrs() {
123
+ return {
124
+ path: '/',
125
+ httpOnly: true,
126
+ secure: true,
127
+ sameSite: 'lax' as const,
128
+ maxAge: 600,
129
+ }
130
+ }