@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,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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getConfig } from '../config-store.js'
|
|
2
|
+
import { getRuntimeEnv } from '../runtime.js'
|
|
3
|
+
import { analyticsAvailable, emitAuthEvent } from '../utils/analytics.js'
|
|
4
|
+
import {
|
|
5
|
+
clearAuthFlowCookies,
|
|
6
|
+
getCookie,
|
|
7
|
+
setIdentityCookie,
|
|
8
|
+
setTokenCookies,
|
|
9
|
+
} from '../utils/cookies.js'
|
|
10
|
+
import { createIdentityCookiePayload, signIdentityCookie } from '../utils/identity-cookie.js'
|
|
11
|
+
import { exchangeCode } from '../utils/openauth.js'
|
|
12
|
+
import { verifyState } from '../utils/state.js'
|
|
13
|
+
|
|
14
|
+
interface CallbackContext {
|
|
15
|
+
request: Request
|
|
16
|
+
url: URL
|
|
17
|
+
locals?: Record<string, unknown>
|
|
18
|
+
waitUntil?: (promise: Promise<unknown>) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getIdentitySecret(): string | null {
|
|
22
|
+
const secret = getRuntimeEnv().GL_IDENTITY_SECRET
|
|
23
|
+
return typeof secret === 'string' && secret.length > 0 ? secret : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function GET(context: CallbackContext): Promise<Response> {
|
|
27
|
+
const options = getConfig()
|
|
28
|
+
const { url, request } = context
|
|
29
|
+
|
|
30
|
+
const code = url.searchParams.get('code')
|
|
31
|
+
const state = url.searchParams.get('state')
|
|
32
|
+
|
|
33
|
+
// Verify CSRF state
|
|
34
|
+
const storedState = getCookie(request, `${options.cookiePrefix}_state`)
|
|
35
|
+
if (!state || !storedState || !verifyState(state, storedState)) {
|
|
36
|
+
return new Response('Invalid state parameter', { status: 403 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Read PKCE verifier
|
|
40
|
+
const codeVerifier = getCookie(request, `${options.cookiePrefix}_pkce`)
|
|
41
|
+
if (!codeVerifier) {
|
|
42
|
+
return new Response('Missing PKCE verifier', { status: 400 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const provider = getCookie(request, `${options.cookiePrefix}_provider`)
|
|
46
|
+
const authPath = getCookie(request, `${options.cookiePrefix}_auth_path`) || url.pathname
|
|
47
|
+
|
|
48
|
+
// Exchange code for tokens
|
|
49
|
+
const tokenResponse = await exchangeCode(
|
|
50
|
+
url,
|
|
51
|
+
code!,
|
|
52
|
+
codeVerifier,
|
|
53
|
+
provider === 'google' || provider === 'password' || provider === 'email-code'
|
|
54
|
+
? provider
|
|
55
|
+
: undefined,
|
|
56
|
+
)
|
|
57
|
+
if (!tokenResponse) {
|
|
58
|
+
return Response.redirect(
|
|
59
|
+
new URL(`${options.loginPath}?error=auth_failed`, url.origin).toString(),
|
|
60
|
+
302,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build redirect response
|
|
65
|
+
const redirectPath = getCookie(request, `${options.cookiePrefix}_redirect`) || '/'
|
|
66
|
+
const response = Response.redirect(new URL(redirectPath, url.origin).toString(), 302)
|
|
67
|
+
|
|
68
|
+
// Mutable copy for setting cookies
|
|
69
|
+
const mutableResponse = new Response(response.body, {
|
|
70
|
+
status: response.status,
|
|
71
|
+
statusText: response.statusText,
|
|
72
|
+
headers: new Headers(response.headers),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
setTokenCookies(mutableResponse, options, tokenResponse.accessToken, tokenResponse.refreshToken)
|
|
76
|
+
clearAuthFlowCookies(mutableResponse, options)
|
|
77
|
+
const identitySecret = getIdentitySecret()
|
|
78
|
+
if (identitySecret) {
|
|
79
|
+
try {
|
|
80
|
+
setIdentityCookie(
|
|
81
|
+
mutableResponse,
|
|
82
|
+
await signIdentityCookie(
|
|
83
|
+
await createIdentityCookiePayload(tokenResponse.claims.sub, identitySecret),
|
|
84
|
+
identitySecret,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
} catch {
|
|
88
|
+
// Keep login working if the optional analytics identity secret is misconfigured.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Emit analytics event (non-blocking)
|
|
93
|
+
if (analyticsAvailable(context)) {
|
|
94
|
+
const eventName = tokenResponse.isNewUser ? 'signup_completed' : 'login_completed'
|
|
95
|
+
const promise = emitAuthEvent(context, eventName, {
|
|
96
|
+
provider: tokenResponse.provider,
|
|
97
|
+
isNewUser: tokenResponse.isNewUser,
|
|
98
|
+
auth_path: authPath,
|
|
99
|
+
identity_user_id: tokenResponse.claims.sub,
|
|
100
|
+
})
|
|
101
|
+
context.waitUntil?.(promise)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return mutableResponse
|
|
105
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ResolvedAuthOptions } from '../options.js'
|
|
2
|
+
import { setAuthFlowCookies } from '../utils/cookies.js'
|
|
3
|
+
import { createAuthFlow } from '../utils/openauth.js'
|
|
4
|
+
|
|
5
|
+
export interface RouteContext {
|
|
6
|
+
request: Request
|
|
7
|
+
url: URL
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function withAuthFlowCookies(
|
|
11
|
+
context: RouteContext,
|
|
12
|
+
options: ResolvedAuthOptions,
|
|
13
|
+
response: Response,
|
|
14
|
+
redirectPath: string,
|
|
15
|
+
provider?: string,
|
|
16
|
+
): Promise<Response> {
|
|
17
|
+
const authFlow = await createAuthFlow(context)
|
|
18
|
+
const mutable = new Response(response.body, {
|
|
19
|
+
status: response.status,
|
|
20
|
+
statusText: response.statusText,
|
|
21
|
+
headers: new Headers(response.headers),
|
|
22
|
+
})
|
|
23
|
+
setAuthFlowCookies(mutable, options, authFlow.verifier, authFlow.state, redirectPath, provider, {
|
|
24
|
+
pkcePath: '/',
|
|
25
|
+
additionalPkcePaths: [options.callbackPath],
|
|
26
|
+
})
|
|
27
|
+
return mutable
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function redirectLocation(
|
|
31
|
+
context: RouteContext,
|
|
32
|
+
pathname: string,
|
|
33
|
+
params: Record<string, string | undefined>,
|
|
34
|
+
): string {
|
|
35
|
+
const target = new URL(pathname, context.url.origin)
|
|
36
|
+
for (const [key, value] of Object.entries(params)) {
|
|
37
|
+
if (value) target.searchParams.set(key, value)
|
|
38
|
+
}
|
|
39
|
+
return target.toString()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function redirectTo(
|
|
43
|
+
context: RouteContext,
|
|
44
|
+
pathname: string,
|
|
45
|
+
params: Record<string, string | undefined>,
|
|
46
|
+
status = 303,
|
|
47
|
+
): Response {
|
|
48
|
+
return Response.redirect(redirectLocation(context, pathname, params), status)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readForm(context: RouteContext): Promise<FormData> {
|
|
52
|
+
return context.request.formData()
|
|
53
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getConfig } from '../config-store.js'
|
|
2
|
+
import { forgotPasswordRendererPayload, getRenderer, renderHtmlResponse } from '../renderers.js'
|
|
3
|
+
import { getForgotPasswordUrl } from '../utils/openauth.js'
|
|
4
|
+
import { type RouteContext, withAuthFlowCookies } from './flow.js'
|
|
5
|
+
|
|
6
|
+
export async function GET(context: RouteContext): Promise<Response> {
|
|
7
|
+
const options = getConfig()
|
|
8
|
+
|
|
9
|
+
if (!options.providers.includes('password')) {
|
|
10
|
+
return new Response('Not found', { status: 404 })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const target = new URL(getForgotPasswordUrl())
|
|
14
|
+
target.search = context.url.search
|
|
15
|
+
const renderer = getRenderer('forgotPassword')
|
|
16
|
+
if (renderer) {
|
|
17
|
+
const redirectPath = context.url.searchParams.get('redirect') || '/'
|
|
18
|
+
const response = await renderHtmlResponse(
|
|
19
|
+
renderer(
|
|
20
|
+
forgotPasswordRendererPayload(options, {
|
|
21
|
+
forgotPasswordUrl: target.toString(),
|
|
22
|
+
error: context.url.searchParams.get('error'),
|
|
23
|
+
sent: context.url.searchParams.get('sent'),
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
return withAuthFlowCookies(context, options, response, redirectPath)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Response.redirect(target.toString(), 302)
|
|
31
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { loginPageScript } from '../client/login-page.js'
|
|
2
|
+
import { getConfig } from '../config-store.js'
|
|
3
|
+
import { getRenderer, loginRendererPayload, renderHtmlResponse } from '../renderers.js'
|
|
4
|
+
import { setAuthFlowCookies } from '../utils/cookies.js'
|
|
5
|
+
import { createAuthFlow, getAuthorizationUrl } from '../utils/openauth.js'
|
|
6
|
+
|
|
7
|
+
interface LoginContext {
|
|
8
|
+
request: Request
|
|
9
|
+
url: URL
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function _shouldShowForgotLink(options: ReturnType<typeof getConfig>): boolean {
|
|
13
|
+
return options.providers.includes('password')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function GET(context: LoginContext): Promise<Response> {
|
|
17
|
+
const options = getConfig()
|
|
18
|
+
const redirectPath = context.url.searchParams.get('redirect') || '/'
|
|
19
|
+
const selectedProvider = context.url.searchParams.get('provider')
|
|
20
|
+
const error = context.url.searchParams.get('error')
|
|
21
|
+
const reset = context.url.searchParams.get('reset')
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
selectedProvider &&
|
|
25
|
+
options.providers.includes(selectedProvider as (typeof options.providers)[number])
|
|
26
|
+
) {
|
|
27
|
+
const authFlow = await createAuthFlow(context)
|
|
28
|
+
const issuerProvider = selectedProvider === 'email-code' ? 'code' : selectedProvider
|
|
29
|
+
const { url } = await getAuthorizationUrl(context, issuerProvider, redirectPath, authFlow)
|
|
30
|
+
const redirectResponse = Response.redirect(url, 302)
|
|
31
|
+
const response = new Response(redirectResponse.body, {
|
|
32
|
+
status: redirectResponse.status,
|
|
33
|
+
statusText: redirectResponse.statusText,
|
|
34
|
+
headers: new Headers(redirectResponse.headers),
|
|
35
|
+
})
|
|
36
|
+
setAuthFlowCookies(
|
|
37
|
+
response,
|
|
38
|
+
options,
|
|
39
|
+
authFlow.verifier,
|
|
40
|
+
authFlow.state,
|
|
41
|
+
redirectPath,
|
|
42
|
+
selectedProvider,
|
|
43
|
+
)
|
|
44
|
+
return response
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data = {
|
|
48
|
+
redirectPath,
|
|
49
|
+
error,
|
|
50
|
+
reset,
|
|
51
|
+
useLocalPassword: Boolean(getRenderer('password')),
|
|
52
|
+
useLocalVerifyCode: Boolean(getRenderer('verifyCode')),
|
|
53
|
+
}
|
|
54
|
+
const renderer = getRenderer('login')
|
|
55
|
+
if (renderer) {
|
|
56
|
+
return renderHtmlResponse(renderer(loginRendererPayload(options, data)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const html = renderLoginPage(options, data)
|
|
60
|
+
return new Response(html, {
|
|
61
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface LoginPageData {
|
|
66
|
+
redirectPath: string
|
|
67
|
+
error: string | null
|
|
68
|
+
reset: string | null
|
|
69
|
+
useLocalPassword?: boolean
|
|
70
|
+
useLocalVerifyCode?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderLoginPage(options: ReturnType<typeof getConfig>, data: LoginPageData): string {
|
|
74
|
+
const { loginPage, providers } = options
|
|
75
|
+
const availableProviders = providers.filter((provider) =>
|
|
76
|
+
['google', 'password', 'email-code'].includes(provider),
|
|
77
|
+
)
|
|
78
|
+
const hasGoogle = availableProviders.includes('google')
|
|
79
|
+
const hasAlternativeProvider =
|
|
80
|
+
availableProviders.includes('password') || availableProviders.includes('email-code')
|
|
81
|
+
const payload = loginRendererPayload(options, data)
|
|
82
|
+
|
|
83
|
+
return `<!DOCTYPE html>
|
|
84
|
+
<html lang="en">
|
|
85
|
+
<head>
|
|
86
|
+
<meta charset="utf-8">
|
|
87
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
88
|
+
<title>${loginPage.title}</title>
|
|
89
|
+
<style>
|
|
90
|
+
body { font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 20px; }
|
|
91
|
+
h1 { text-align: center; }
|
|
92
|
+
.logo { display: block; margin: 0 auto 16px; max-height: 48px; }
|
|
93
|
+
.btn { display: block; width: 100%; padding: 12px; margin: 8px 0; border: 1px solid #ccc; border-radius: 6px; font-size: 16px; cursor: pointer; text-align: center; text-decoration: none; box-sizing: border-box; }
|
|
94
|
+
.btn-google { background: #4285f4; color: white; border: none; }
|
|
95
|
+
.divider { text-align: center; margin: 20px 0; color: #888; }
|
|
96
|
+
input[type="email"], input[type="password"] { display: block; width: 100%; padding: 10px; margin: 8px 0; border: 1px solid #ccc; border-radius: 6px; font-size: 16px; box-sizing: border-box; }
|
|
97
|
+
.error { color: #c00; text-align: center; margin: 12px 0; }
|
|
98
|
+
.success { color: #080; text-align: center; margin: 12px 0; }
|
|
99
|
+
.brand { text-align: center; color: #666; margin-bottom: 4px; }
|
|
100
|
+
.forgot-link { display: block; text-align: right; font-size: 14px; margin: -4px 0 8px; color: #555; }
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
${loginPage.logoUrl ? `<img src="${loginPage.logoUrl}" alt="${loginPage.brandName || ''}" class="logo">` : ''}
|
|
105
|
+
${loginPage.brandName ? `<p class="brand">${loginPage.brandName}</p>` : ''}
|
|
106
|
+
<h1>${loginPage.title}</h1>
|
|
107
|
+
${data.reset ? '<p class="success">Password reset successfully. Sign in with your new password.</p>' : ''}
|
|
108
|
+
${data.error ? '<p class="error">Authentication failed. Please try again.</p>' : ''}
|
|
109
|
+
${
|
|
110
|
+
hasGoogle
|
|
111
|
+
? `<a href="${payload.providerUrls.google}" class="btn btn-google" data-provider="google">Sign in with Google</a>`
|
|
112
|
+
: ''
|
|
113
|
+
}
|
|
114
|
+
${hasGoogle && hasAlternativeProvider ? '<div class="divider">or</div>' : ''}
|
|
115
|
+
${
|
|
116
|
+
availableProviders.includes('email-code')
|
|
117
|
+
? `<a href="${payload.providerUrls.emailCode}" class="btn" data-provider="email-code">Sign in with email</a>`
|
|
118
|
+
: ''
|
|
119
|
+
}
|
|
120
|
+
${
|
|
121
|
+
availableProviders.includes('password')
|
|
122
|
+
? `
|
|
123
|
+
<a href="${payload.providerUrls.password}" class="btn" data-provider="password">Sign in with password</a>
|
|
124
|
+
<a href="${options.forgotPasswordPath}" class="forgot-link">Forgot password?</a>`
|
|
125
|
+
: ''
|
|
126
|
+
}
|
|
127
|
+
<script>${loginPageScript()}</script>
|
|
128
|
+
</body>
|
|
129
|
+
</html>`
|
|
130
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getConfig } from '../config-store.js'
|
|
2
|
+
import { analyticsAvailable, emitAuthEvent } from '../utils/analytics.js'
|
|
3
|
+
import { resolveClientSecret } from '../utils/client-secret.js'
|
|
4
|
+
import {
|
|
5
|
+
clearAuthFlowCookies,
|
|
6
|
+
clearIdentityCookie,
|
|
7
|
+
clearTokenCookies,
|
|
8
|
+
getCookie,
|
|
9
|
+
} from '../utils/cookies.js'
|
|
10
|
+
import { revokeToken } from '../utils/openauth.js'
|
|
11
|
+
|
|
12
|
+
interface LogoutContext {
|
|
13
|
+
request: Request
|
|
14
|
+
url: URL
|
|
15
|
+
waitUntil?: (promise: Promise<unknown>) => void
|
|
16
|
+
locals?: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function GET(context: LogoutContext): Promise<Response> {
|
|
20
|
+
const options = getConfig()
|
|
21
|
+
const refreshToken = getCookie(context.request, `${options.cookiePrefix}_rt`)
|
|
22
|
+
const clientId = options.clientId || context.url.hostname
|
|
23
|
+
|
|
24
|
+
// Revoke at issuer (non-blocking)
|
|
25
|
+
if (refreshToken) {
|
|
26
|
+
const clientSecret = resolveClientSecret(options.clientSecret)
|
|
27
|
+
const promise = clientSecret
|
|
28
|
+
? revokeToken(refreshToken, clientId, clientSecret)
|
|
29
|
+
: revokeToken(refreshToken, clientId)
|
|
30
|
+
context.waitUntil?.(promise)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Emit auth_logout (non-blocking) when the consumer has @growth-labs/analytics
|
|
34
|
+
// wired. Without analytics installed this is a no-op (analyticsAvailable
|
|
35
|
+
// returns false). Documented in packages-docs/event-registry.md.
|
|
36
|
+
if (analyticsAvailable(context as { locals?: Record<string, unknown> })) {
|
|
37
|
+
const promise = emitAuthEvent(context, 'auth_logout', {
|
|
38
|
+
revoked: refreshToken ? 'true' : 'false',
|
|
39
|
+
})
|
|
40
|
+
context.waitUntil?.(promise)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Build redirect response
|
|
44
|
+
const redirectUrl = new URL(options.logoutRedirect, context.url.origin).toString()
|
|
45
|
+
const response = Response.redirect(redirectUrl, 302)
|
|
46
|
+
|
|
47
|
+
// Mutable copy for clearing cookies
|
|
48
|
+
const mutableResponse = new Response(response.body, {
|
|
49
|
+
status: response.status,
|
|
50
|
+
statusText: response.statusText,
|
|
51
|
+
headers: new Headers(response.headers),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
clearTokenCookies(mutableResponse, options)
|
|
55
|
+
clearIdentityCookie(mutableResponse)
|
|
56
|
+
clearAuthFlowCookies(mutableResponse, options)
|
|
57
|
+
|
|
58
|
+
return mutableResponse
|
|
59
|
+
}
|