@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
|
@@ -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>
|
package/src/renderers.ts
ADDED
|
@@ -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
|
+
}
|