@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,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
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
|
+
}
|