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