@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.
Files changed (43) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +4 -1
  3. package/dist/index.js.map +1 -1
  4. package/package.json +7 -2
  5. package/src/client/login-page.ts +12 -0
  6. package/src/cloudflare-workers.d.ts +3 -0
  7. package/src/config-store.ts +70 -0
  8. package/src/index.ts +177 -0
  9. package/src/middleware/auth.ts +88 -0
  10. package/src/options.ts +75 -0
  11. package/src/renderers.ts +182 -0
  12. package/src/routes/api/code-request.ts +33 -0
  13. package/src/routes/api/code-verify.ts +35 -0
  14. package/src/routes/api/password-reset-confirm.ts +36 -0
  15. package/src/routes/api/password-reset-request.ts +32 -0
  16. package/src/routes/api/password-verify.ts +35 -0
  17. package/src/routes/api/shared.ts +130 -0
  18. package/src/routes/callback.ts +105 -0
  19. package/src/routes/flow.ts +53 -0
  20. package/src/routes/forgot-password.ts +31 -0
  21. package/src/routes/login.ts +130 -0
  22. package/src/routes/logout.ts +59 -0
  23. package/src/routes/password.ts +27 -0
  24. package/src/routes/reset-password.ts +33 -0
  25. package/src/routes/verify-code.ts +28 -0
  26. package/src/runtime-bridge.ts +15 -0
  27. package/src/runtime.ts +5 -0
  28. package/src/server.ts +185 -0
  29. package/src/types.ts +158 -0
  30. package/src/utils/analytics.ts +56 -0
  31. package/src/utils/client-secret.ts +21 -0
  32. package/src/utils/cookies.ts +134 -0
  33. package/src/utils/encoding.ts +5 -0
  34. package/src/utils/env-marker.ts +39 -0
  35. package/src/utils/identity-cookie.ts +138 -0
  36. package/src/utils/identity.ts +240 -0
  37. package/src/utils/index.ts +19 -0
  38. package/src/utils/issuer.ts +7 -0
  39. package/src/utils/openauth.ts +163 -0
  40. package/src/utils/pkce.ts +16 -0
  41. package/src/utils/state.ts +20 -0
  42. package/src/utils/tokens.ts +128 -0
  43. 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
+ }