@growth-labs/auth 0.7.0 → 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/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 +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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/auth",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
36
|
+
"src/**/*.ts",
|
|
37
|
+
"src/**/*.astro",
|
|
38
|
+
"src/**/*.css",
|
|
39
|
+
"src/**/*.md",
|
|
40
|
+
"src/**/*.sql",
|
|
36
41
|
"README.md"
|
|
37
42
|
],
|
|
38
43
|
"publishConfig": {
|
|
@@ -57,7 +62,7 @@
|
|
|
57
62
|
"typescript": "^5.8.0",
|
|
58
63
|
"vite": "^7.3.2",
|
|
59
64
|
"vitest": "^3.0.0",
|
|
60
|
-
"@growth-labs/analytics": "0.4.
|
|
65
|
+
"@growth-labs/analytics": "0.4.1"
|
|
61
66
|
},
|
|
62
67
|
"scripts": {
|
|
63
68
|
"build": "tsc",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function loginPageScript(): string {
|
|
2
|
+
return `
|
|
3
|
+
document.querySelectorAll('[data-provider]').forEach(function(el) {
|
|
4
|
+
el.addEventListener('click', function() {
|
|
5
|
+
var provider = el.getAttribute('data-provider');
|
|
6
|
+
if (window.glAnalytics) {
|
|
7
|
+
window.glAnalytics.track('login_clicked', { provider: provider });
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
`
|
|
12
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ResolvedAuthOptions } from './options.js'
|
|
2
|
+
import { isEnvMarker, resolveEnvMarker } from './utils/env-marker.js'
|
|
3
|
+
|
|
4
|
+
let _config: ResolvedAuthOptions | null = null
|
|
5
|
+
|
|
6
|
+
export function _setConfig(config: ResolvedAuthOptions): void {
|
|
7
|
+
_config = config
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getConfig(): ResolvedAuthOptions {
|
|
11
|
+
const runtimeConfig = (
|
|
12
|
+
globalThis as typeof globalThis & {
|
|
13
|
+
__GL_AUTH_CONFIG__?: ResolvedAuthOptions
|
|
14
|
+
}
|
|
15
|
+
).__GL_AUTH_CONFIG__
|
|
16
|
+
const base = runtimeConfig ?? _config
|
|
17
|
+
if (!base) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'@growth-labs/auth: integration not configured. Add auth() to your astro.config.mjs integrations array.',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return resolveEnvMarkers(base)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves any `"env:VAR_NAME"` markers on auth-tuple fields against the
|
|
27
|
+
* runtime env stashed by `src/runtime-bridge.ts`. The bridge is loaded
|
|
28
|
+
* via `middleware/auth.ts` at module-init time, so by the time any
|
|
29
|
+
* request reaches `getConfig()` the env is on globalThis. Plain string
|
|
30
|
+
* values pass through unchanged, preserving backwards compatibility.
|
|
31
|
+
*
|
|
32
|
+
* If callers exist that don't go through the auth middleware first AND
|
|
33
|
+
* the config has markers, we throw a clear error rather than silently
|
|
34
|
+
* dropping the lookup.
|
|
35
|
+
*/
|
|
36
|
+
function resolveEnvMarkers(base: ResolvedAuthOptions): ResolvedAuthOptions {
|
|
37
|
+
const env = (
|
|
38
|
+
globalThis as typeof globalThis & {
|
|
39
|
+
__GL_AUTH_RUNTIME_ENV__?: Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
).__GL_AUTH_RUNTIME_ENV__
|
|
42
|
+
if (env === undefined) {
|
|
43
|
+
if (anyMarkerPresent(base)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'@growth-labs/auth: runtime env unavailable for "env:" marker resolution. Ensure the auth middleware ran first (it loads src/runtime-bridge.ts which stashes the env on globalThis).',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return base
|
|
49
|
+
}
|
|
50
|
+
const issuer = resolveEnvMarker(base.issuer, env)
|
|
51
|
+
if (typeof issuer !== 'string') {
|
|
52
|
+
throw new Error('@growth-labs/auth: issuer must resolve to a string')
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
...base,
|
|
56
|
+
issuer,
|
|
57
|
+
issuerInternal: resolveEnvMarker(base.issuerInternal, env),
|
|
58
|
+
clientId: resolveEnvMarker(base.clientId, env),
|
|
59
|
+
resource: resolveEnvMarker(base.resource, env),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function anyMarkerPresent(base: ResolvedAuthOptions): boolean {
|
|
64
|
+
return (
|
|
65
|
+
isEnvMarker(base.issuer) ||
|
|
66
|
+
isEnvMarker(base.issuerInternal) ||
|
|
67
|
+
isEnvMarker(base.clientId) ||
|
|
68
|
+
isEnvMarker(base.resource)
|
|
69
|
+
)
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { _setConfig } from './config-store.js'
|
|
2
|
+
import type { AuthOptions } from './options.js'
|
|
3
|
+
import { authOptionsSchema } from './options.js'
|
|
4
|
+
import type { AuthRenderers } from './types.js'
|
|
5
|
+
|
|
6
|
+
interface AstroIntegration {
|
|
7
|
+
name: string
|
|
8
|
+
hooks: {
|
|
9
|
+
'astro:config:setup': (params: {
|
|
10
|
+
addMiddleware: (opts: { entrypoint: string; order: 'pre' | 'post' }) => void
|
|
11
|
+
injectRoute: (opts: { pattern: string; entrypoint: string; prerender?: boolean }) => void
|
|
12
|
+
updateConfig: (config: {
|
|
13
|
+
vite?: { define?: Record<string, string>; plugins?: unknown[] }
|
|
14
|
+
}) => void
|
|
15
|
+
}) => void
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ENTRYPOINT_EXTENSION = import.meta.url.endsWith('.ts') ? '.ts' : '.js'
|
|
20
|
+
|
|
21
|
+
function resolveEntrypoint(path: string): string {
|
|
22
|
+
return decodeURIComponent(new URL(`${path}${ENTRYPOINT_EXTENSION}`, import.meta.url).pathname)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Vite plugin that turns the integration's `renderers` option (per-surface
|
|
27
|
+
* `{ module, export }` specs) into the `virtual:growth-labs/auth/renderers`
|
|
28
|
+
* module by emitting REAL static ES imports — NOT a serialized function
|
|
29
|
+
* body. Static imports are bundleable, tree-shakeable, and immune to the
|
|
30
|
+
* SSR-transform bug that crashed 0.3.6 (where Vite rewrote `await import()`
|
|
31
|
+
* inside a `Function.prototype.toString()`-serialized renderer body to
|
|
32
|
+
* `__vite_ssr_dynamic_import__()`, a helper missing in production bundles).
|
|
33
|
+
*/
|
|
34
|
+
export function renderersPlugin(renderers: AuthRenderers): unknown {
|
|
35
|
+
return {
|
|
36
|
+
name: '@growth-labs/auth:renderers',
|
|
37
|
+
resolveId(id: string) {
|
|
38
|
+
if (id === 'virtual:growth-labs/auth/renderers') return '\0virtual:growth-labs/auth/renderers'
|
|
39
|
+
},
|
|
40
|
+
load(id: string) {
|
|
41
|
+
if (id !== '\0virtual:growth-labs/auth/renderers') return
|
|
42
|
+
|
|
43
|
+
const importLines: string[] = []
|
|
44
|
+
const mapEntries: string[] = []
|
|
45
|
+
let n = 0
|
|
46
|
+
for (const [surface, spec] of Object.entries(renderers)) {
|
|
47
|
+
if (!isValidRendererSpec(spec)) continue
|
|
48
|
+
const local = `__gl_auth_renderer_${n++}`
|
|
49
|
+
importLines.push(
|
|
50
|
+
`import { ${spec.export} as ${local} } from ${JSON.stringify(spec.module)};`,
|
|
51
|
+
)
|
|
52
|
+
mapEntries.push(` ${JSON.stringify(surface)}: ${local},`)
|
|
53
|
+
}
|
|
54
|
+
return [...importLines, 'export const renderers = {', ...mapEntries, '};', ''].join('\n')
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isValidRendererSpec(value: unknown): value is { module: string; export: string } {
|
|
60
|
+
return (
|
|
61
|
+
typeof value === 'object' &&
|
|
62
|
+
value !== null &&
|
|
63
|
+
typeof (value as { module?: unknown }).module === 'string' &&
|
|
64
|
+
typeof (value as { export?: unknown }).export === 'string'
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function auth(userOptions: AuthOptions): AstroIntegration {
|
|
69
|
+
const options = authOptionsSchema.parse(userOptions)
|
|
70
|
+
_setConfig(options)
|
|
71
|
+
const serializableOptions = { ...options, renderers: {} }
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: '@growth-labs/auth',
|
|
75
|
+
hooks: {
|
|
76
|
+
'astro:config:setup': ({ addMiddleware, injectRoute, updateConfig }) => {
|
|
77
|
+
updateConfig({
|
|
78
|
+
vite: {
|
|
79
|
+
define: {
|
|
80
|
+
'globalThis.__GL_AUTH_CONFIG__': JSON.stringify(serializableOptions),
|
|
81
|
+
},
|
|
82
|
+
plugins: [renderersPlugin(options.renderers)],
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
addMiddleware({
|
|
87
|
+
entrypoint: resolveEntrypoint('./middleware/auth'),
|
|
88
|
+
order: 'pre',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
injectRoute({
|
|
92
|
+
pattern: options.loginPath,
|
|
93
|
+
entrypoint: resolveEntrypoint('./routes/login'),
|
|
94
|
+
prerender: false,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
injectRoute({
|
|
98
|
+
pattern: '/logout',
|
|
99
|
+
entrypoint: resolveEntrypoint('./routes/logout'),
|
|
100
|
+
prerender: false,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
injectRoute({
|
|
104
|
+
pattern: options.callbackPath,
|
|
105
|
+
entrypoint: resolveEntrypoint('./routes/callback'),
|
|
106
|
+
prerender: false,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (options.providers.includes('password')) {
|
|
110
|
+
injectRoute({
|
|
111
|
+
pattern: options.forgotPasswordPath,
|
|
112
|
+
entrypoint: resolveEntrypoint('./routes/forgot-password'),
|
|
113
|
+
prerender: false,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
injectRoute({
|
|
117
|
+
pattern: options.resetPasswordPath,
|
|
118
|
+
entrypoint: resolveEntrypoint('./routes/reset-password'),
|
|
119
|
+
prerender: false,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
injectRoute({
|
|
123
|
+
pattern: options.passwordVerifyPath,
|
|
124
|
+
entrypoint: resolveEntrypoint('./routes/api/password-verify'),
|
|
125
|
+
prerender: false,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
injectRoute({
|
|
129
|
+
pattern: options.passwordResetRequestPath,
|
|
130
|
+
entrypoint: resolveEntrypoint('./routes/api/password-reset-request'),
|
|
131
|
+
prerender: false,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
injectRoute({
|
|
135
|
+
pattern: options.passwordResetConfirmPath,
|
|
136
|
+
entrypoint: resolveEntrypoint('./routes/api/password-reset-confirm'),
|
|
137
|
+
prerender: false,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
if (options.renderers.password) {
|
|
141
|
+
injectRoute({
|
|
142
|
+
pattern: options.passwordPath,
|
|
143
|
+
entrypoint: resolveEntrypoint('./routes/password'),
|
|
144
|
+
prerender: false,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (options.providers.includes('email-code')) {
|
|
150
|
+
injectRoute({
|
|
151
|
+
pattern: options.codeRequestPath,
|
|
152
|
+
entrypoint: resolveEntrypoint('./routes/api/code-request'),
|
|
153
|
+
prerender: false,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
injectRoute({
|
|
157
|
+
pattern: options.codeVerifyPath,
|
|
158
|
+
entrypoint: resolveEntrypoint('./routes/api/code-verify'),
|
|
159
|
+
prerender: false,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (options.renderers.verifyCode) {
|
|
163
|
+
injectRoute({
|
|
164
|
+
pattern: options.verifyCodePath,
|
|
165
|
+
entrypoint: resolveEntrypoint('./routes/verify-code'),
|
|
166
|
+
prerender: false,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getConfig } from '../config-store.js'
|
|
2
|
+
import '../runtime-bridge.js'
|
|
3
|
+
import type { User } from '../types.js'
|
|
4
|
+
import { resolveClientSecret } from '../utils/client-secret.js'
|
|
5
|
+
import { getCookie, setTokenCookies } from '../utils/cookies.js'
|
|
6
|
+
import { resolveInternalIssuer } from '../utils/issuer.js'
|
|
7
|
+
import { refreshTokens, verifyAccessToken } from '../utils/tokens.js'
|
|
8
|
+
|
|
9
|
+
export async function onRequest(
|
|
10
|
+
context: { request: Request; url: URL; locals: Record<string, unknown> },
|
|
11
|
+
next: () => Promise<Response>,
|
|
12
|
+
): Promise<Response> {
|
|
13
|
+
const options = getConfig()
|
|
14
|
+
const issuer = resolveInternalIssuer(options)
|
|
15
|
+
const { request } = context
|
|
16
|
+
|
|
17
|
+
// 1. Read tokens from cookies
|
|
18
|
+
const accessToken = getCookie(request, `${options.cookiePrefix}_at`)
|
|
19
|
+
const refreshToken = getCookie(request, `${options.cookiePrefix}_rt`)
|
|
20
|
+
|
|
21
|
+
// 2. Attempt to authenticate
|
|
22
|
+
let user: User | null = null
|
|
23
|
+
let refreshed: Awaited<ReturnType<typeof refreshTokens>> = null
|
|
24
|
+
|
|
25
|
+
if (accessToken) {
|
|
26
|
+
const verified = await verifyAccessToken(issuer, accessToken)
|
|
27
|
+
if (verified) {
|
|
28
|
+
user = {
|
|
29
|
+
id: verified.sub,
|
|
30
|
+
email: verified.email,
|
|
31
|
+
name: verified.name ?? null,
|
|
32
|
+
avatarUrl: verified.image ?? null,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!user && refreshToken) {
|
|
38
|
+
const clientId = options.clientId || context.url.hostname
|
|
39
|
+
const clientSecret = resolveClientSecret(options.clientSecret)
|
|
40
|
+
refreshed = await refreshTokens(issuer, refreshToken, clientId, clientSecret, options.resource)
|
|
41
|
+
if (refreshed) {
|
|
42
|
+
user = {
|
|
43
|
+
id: refreshed.claims.sub,
|
|
44
|
+
email: refreshed.claims.email,
|
|
45
|
+
name: refreshed.claims.name ?? null,
|
|
46
|
+
avatarUrl: refreshed.claims.image ?? null,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Populate context.locals.user
|
|
52
|
+
context.locals.user = user
|
|
53
|
+
|
|
54
|
+
// 4. Gate protected paths
|
|
55
|
+
if (!user && isGatedPath(context.url.pathname, options.gatedPaths)) {
|
|
56
|
+
const loginUrl = new URL(
|
|
57
|
+
`${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}`,
|
|
58
|
+
context.url.origin,
|
|
59
|
+
)
|
|
60
|
+
return Response.redirect(loginUrl.toString(), 302)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 5. Continue to the page
|
|
64
|
+
const response = await next()
|
|
65
|
+
|
|
66
|
+
// 6. If tokens were refreshed, set new cookies on the response
|
|
67
|
+
if (refreshed) {
|
|
68
|
+
const modifiedResponse = new Response(response.body, {
|
|
69
|
+
status: response.status,
|
|
70
|
+
statusText: response.statusText,
|
|
71
|
+
headers: new Headers(response.headers),
|
|
72
|
+
})
|
|
73
|
+
setTokenCookies(modifiedResponse, options, refreshed.accessToken, refreshed.refreshToken)
|
|
74
|
+
return modifiedResponse
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return response
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isGatedPath(pathname: string, gatedPaths: string[]): boolean {
|
|
81
|
+
return gatedPaths.some((pattern) => {
|
|
82
|
+
if (pattern.endsWith('/*')) {
|
|
83
|
+
const prefix = pattern.slice(0, -1) // '/premium/*' → '/premium/'
|
|
84
|
+
return pathname.startsWith(prefix)
|
|
85
|
+
}
|
|
86
|
+
return pathname === pattern
|
|
87
|
+
})
|
|
88
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { AuthRenderers } from './types.js'
|
|
3
|
+
|
|
4
|
+
// String accepted as either a literal URL or an "env:VAR_NAME" marker that
|
|
5
|
+
// gets resolved against the Worker runtime env at request time. See
|
|
6
|
+
// `src/utils/env-marker.ts` and `src/config-store.ts`. Bare "env:" (no
|
|
7
|
+
// binding name) is rejected — it can never resolve to anything useful.
|
|
8
|
+
const urlOrEnvMarker = z
|
|
9
|
+
.string()
|
|
10
|
+
.refine((v) => (v.startsWith('env:') && v.length > 4) || /^https?:\/\//i.test(v), {
|
|
11
|
+
message: 'Must be a URL (https?://...) or an "env:VAR_NAME" marker',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
// String that, if shaped as an "env:VAR_NAME" marker, must include a
|
|
15
|
+
// non-empty binding name. Literal values pass through unconstrained
|
|
16
|
+
// (they can be any string, e.g. OAuth client IDs). A literal value that
|
|
17
|
+
// happens to start with the reserved "env:" prefix will be treated as a
|
|
18
|
+
// marker — pick a non-colliding name in that edge case.
|
|
19
|
+
const stringOrEnvMarker = z.string().refine((v) => !v.startsWith('env:') || v.length > 4, {
|
|
20
|
+
message: '"env:" marker must include a binding name (e.g. "env:AUTH_CLIENT_ID")',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const authOptionsSchema = z.object({
|
|
24
|
+
// ─── Issuer URLs ───
|
|
25
|
+
issuer: urlOrEnvMarker,
|
|
26
|
+
issuerInternal: urlOrEnvMarker.optional(),
|
|
27
|
+
|
|
28
|
+
// ─── Client identity ───
|
|
29
|
+
clientId: stringOrEnvMarker.optional(),
|
|
30
|
+
clientSecret: z.string().optional(),
|
|
31
|
+
resource: urlOrEnvMarker.optional(),
|
|
32
|
+
|
|
33
|
+
// ─── Cookie configuration ───
|
|
34
|
+
cookiePrefix: z.string(),
|
|
35
|
+
sessionCookieDomain: z.string().optional(),
|
|
36
|
+
|
|
37
|
+
// ─── Token lifetimes ───
|
|
38
|
+
accessTokenMaxAge: z.number().default(900),
|
|
39
|
+
refreshTokenMaxAge: z.number().default(2592000),
|
|
40
|
+
|
|
41
|
+
// ─── Path configuration ───
|
|
42
|
+
gatedPaths: z.array(z.string()).default([]),
|
|
43
|
+
loginPath: z.string().default('/login'),
|
|
44
|
+
logoutRedirect: z.string().default('/'),
|
|
45
|
+
callbackPath: z.string().default('/api/auth/callback'),
|
|
46
|
+
passwordPath: z.string().default('/password'),
|
|
47
|
+
verifyCodePath: z.string().default('/verify-code'),
|
|
48
|
+
forgotPasswordPath: z.string().default('/forgot-password'),
|
|
49
|
+
resetPasswordPath: z.string().default('/reset-password'),
|
|
50
|
+
passwordVerifyPath: z.string().default('/api/auth/password/verify'),
|
|
51
|
+
codeRequestPath: z.string().default('/api/auth/code/request'),
|
|
52
|
+
codeVerifyPath: z.string().default('/api/auth/code/verify'),
|
|
53
|
+
passwordResetRequestPath: z.string().default('/api/auth/password/reset/request'),
|
|
54
|
+
passwordResetConfirmPath: z.string().default('/api/auth/password/reset/confirm'),
|
|
55
|
+
|
|
56
|
+
// ─── Providers ───
|
|
57
|
+
providers: z
|
|
58
|
+
.array(z.enum(['google', 'password', 'email-code']))
|
|
59
|
+
.default(['google', 'password', 'email-code']),
|
|
60
|
+
|
|
61
|
+
// ─── Login page customization ───
|
|
62
|
+
loginPage: z
|
|
63
|
+
.object({
|
|
64
|
+
title: z.string().default('Sign in'),
|
|
65
|
+
brandName: z.string().optional(),
|
|
66
|
+
logoUrl: z.string().optional(),
|
|
67
|
+
})
|
|
68
|
+
.default({}),
|
|
69
|
+
|
|
70
|
+
// ─── Consumer-owned UI renderers ───
|
|
71
|
+
renderers: z.custom<AuthRenderers>().default({}),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export type AuthOptions = z.input<typeof authOptionsSchema>
|
|
75
|
+
export type ResolvedAuthOptions = z.output<typeof authOptionsSchema>
|
package/src/renderers.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { renderers as bundledRenderers } from 'virtual:growth-labs/auth/renderers'
|
|
2
|
+
import type {
|
|
3
|
+
AuthRendererBrand,
|
|
4
|
+
AuthRendererResult,
|
|
5
|
+
ForgotPasswordRendererPayload,
|
|
6
|
+
LoginRendererPayload,
|
|
7
|
+
PasswordRendererPayload,
|
|
8
|
+
ResetPasswordRendererPayload,
|
|
9
|
+
ResolvedAuthRenderers,
|
|
10
|
+
VerifyCodeRendererPayload,
|
|
11
|
+
} from './types.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Look up the bundled renderer for a surface. After 0.3.7 the lookup is
|
|
15
|
+
* build-time only — renderers are resolved into the virtual module by the
|
|
16
|
+
* Vite plugin via real static `import` statements. The runtime fallback
|
|
17
|
+
* that previously read `options.renderers?.[surface]` is gone; it could
|
|
18
|
+
* never have worked in production (functions captured in `astro.config.mjs`
|
|
19
|
+
* don't survive serialization), and the new `AuthRenderers` shape is a
|
|
20
|
+
* `{ module, export }` spec rather than a function anyway.
|
|
21
|
+
*/
|
|
22
|
+
export function getRenderer<K extends keyof ResolvedAuthRenderers>(
|
|
23
|
+
surface: K,
|
|
24
|
+
): ResolvedAuthRenderers[K] | undefined {
|
|
25
|
+
return bundledRenderers[surface]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function renderHtmlResponse(result: AuthRendererResult): Promise<Response> {
|
|
29
|
+
const rendered = await result
|
|
30
|
+
if (rendered instanceof Response) {
|
|
31
|
+
return rendered
|
|
32
|
+
}
|
|
33
|
+
return new Response(rendered, {
|
|
34
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function rendererBrand(options: {
|
|
39
|
+
loginPage: { title: string; brandName?: string; logoUrl?: string }
|
|
40
|
+
}): AuthRendererBrand {
|
|
41
|
+
return {
|
|
42
|
+
title: options.loginPage.title,
|
|
43
|
+
brandName: options.loginPage.brandName ?? '',
|
|
44
|
+
logoUrl: options.loginPage.logoUrl ?? '',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loginRendererPayload(
|
|
49
|
+
options: {
|
|
50
|
+
loginPage: { title: string; brandName?: string; logoUrl?: string }
|
|
51
|
+
loginPath: string
|
|
52
|
+
passwordPath: string
|
|
53
|
+
verifyCodePath: string
|
|
54
|
+
providers: string[]
|
|
55
|
+
},
|
|
56
|
+
data: {
|
|
57
|
+
redirectPath: string
|
|
58
|
+
error: string | null
|
|
59
|
+
reset: string | null
|
|
60
|
+
useLocalPassword?: boolean
|
|
61
|
+
useLocalVerifyCode?: boolean
|
|
62
|
+
},
|
|
63
|
+
): LoginRendererPayload {
|
|
64
|
+
const makeLoginStartUrl = (provider: string) =>
|
|
65
|
+
`${options.loginPath}?provider=${encodeURIComponent(provider)}&redirect=${encodeURIComponent(data.redirectPath)}`
|
|
66
|
+
const makeSurfaceUrl = (path: string) =>
|
|
67
|
+
`${path}?redirect=${encodeURIComponent(data.redirectPath)}`
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
providerUrls: {
|
|
71
|
+
google: options.providers.includes('google') ? makeLoginStartUrl('google') : undefined,
|
|
72
|
+
password: options.providers.includes('password')
|
|
73
|
+
? data.useLocalPassword
|
|
74
|
+
? makeSurfaceUrl(options.passwordPath)
|
|
75
|
+
: makeLoginStartUrl('password')
|
|
76
|
+
: undefined,
|
|
77
|
+
emailCode: options.providers.includes('email-code')
|
|
78
|
+
? data.useLocalVerifyCode
|
|
79
|
+
? makeSurfaceUrl(options.verifyCodePath)
|
|
80
|
+
: makeLoginStartUrl('email-code')
|
|
81
|
+
: undefined,
|
|
82
|
+
},
|
|
83
|
+
errorCode: data.error ?? undefined,
|
|
84
|
+
reset: data.reset ? true : undefined,
|
|
85
|
+
redirect: data.redirectPath,
|
|
86
|
+
brand: rendererBrand(options),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function forgotPasswordRendererPayload(
|
|
91
|
+
options: {
|
|
92
|
+
loginPage: { title: string; brandName?: string; logoUrl?: string }
|
|
93
|
+
loginPath: string
|
|
94
|
+
passwordResetRequestPath: string
|
|
95
|
+
},
|
|
96
|
+
data: {
|
|
97
|
+
forgotPasswordUrl: string
|
|
98
|
+
error: string | null
|
|
99
|
+
sent: string | null
|
|
100
|
+
},
|
|
101
|
+
): ForgotPasswordRendererPayload {
|
|
102
|
+
return {
|
|
103
|
+
forgotPasswordUrl: data.forgotPasswordUrl,
|
|
104
|
+
passwordResetRequestUrl: options.passwordResetRequestPath,
|
|
105
|
+
errorCode: data.error ?? undefined,
|
|
106
|
+
sent: data.sent ? true : undefined,
|
|
107
|
+
loginUrl: options.loginPath,
|
|
108
|
+
brand: rendererBrand(options),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resetPasswordRendererPayload(
|
|
113
|
+
options: {
|
|
114
|
+
loginPage: { title: string; brandName?: string; logoUrl?: string }
|
|
115
|
+
loginPath: string
|
|
116
|
+
passwordResetConfirmPath: string
|
|
117
|
+
},
|
|
118
|
+
data: {
|
|
119
|
+
resetPasswordUrl: string
|
|
120
|
+
error: string | null
|
|
121
|
+
token: string | null
|
|
122
|
+
},
|
|
123
|
+
): ResetPasswordRendererPayload {
|
|
124
|
+
return {
|
|
125
|
+
resetPasswordUrl: data.resetPasswordUrl,
|
|
126
|
+
passwordResetConfirmUrl: options.passwordResetConfirmPath,
|
|
127
|
+
errorCode: data.error ?? undefined,
|
|
128
|
+
token: data.token ?? undefined,
|
|
129
|
+
loginUrl: options.loginPath,
|
|
130
|
+
brand: rendererBrand(options),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function passwordRendererPayload(
|
|
135
|
+
options: {
|
|
136
|
+
loginPage: { title: string; brandName?: string; logoUrl?: string }
|
|
137
|
+
loginPath: string
|
|
138
|
+
forgotPasswordPath: string
|
|
139
|
+
passwordVerifyPath: string
|
|
140
|
+
},
|
|
141
|
+
data: {
|
|
142
|
+
email: string | null
|
|
143
|
+
error: string | null
|
|
144
|
+
redirectPath: string
|
|
145
|
+
},
|
|
146
|
+
): PasswordRendererPayload {
|
|
147
|
+
return {
|
|
148
|
+
passwordVerifyUrl: options.passwordVerifyPath,
|
|
149
|
+
forgotPasswordUrl: options.forgotPasswordPath,
|
|
150
|
+
email: data.email ?? undefined,
|
|
151
|
+
errorCode: data.error ?? undefined,
|
|
152
|
+
redirect: data.redirectPath,
|
|
153
|
+
loginUrl: options.loginPath,
|
|
154
|
+
brand: rendererBrand(options),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function verifyCodeRendererPayload(
|
|
159
|
+
options: {
|
|
160
|
+
loginPage: { title: string; brandName?: string; logoUrl?: string }
|
|
161
|
+
loginPath: string
|
|
162
|
+
codeRequestPath: string
|
|
163
|
+
codeVerifyPath: string
|
|
164
|
+
},
|
|
165
|
+
data: {
|
|
166
|
+
email: string | null
|
|
167
|
+
error: string | null
|
|
168
|
+
sent: string | null
|
|
169
|
+
redirectPath: string
|
|
170
|
+
},
|
|
171
|
+
): VerifyCodeRendererPayload {
|
|
172
|
+
return {
|
|
173
|
+
codeRequestUrl: options.codeRequestPath,
|
|
174
|
+
codeVerifyUrl: options.codeVerifyPath,
|
|
175
|
+
email: data.email ?? undefined,
|
|
176
|
+
errorCode: data.error ?? undefined,
|
|
177
|
+
sent: data.sent ? true : undefined,
|
|
178
|
+
redirect: data.redirectPath,
|
|
179
|
+
loginUrl: options.loginPath,
|
|
180
|
+
brand: rendererBrand(options),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authClient,
|
|
3
|
+
getConfig,
|
|
4
|
+
type RouteContext,
|
|
5
|
+
readForm,
|
|
6
|
+
readFormString,
|
|
7
|
+
rejectCrossSitePost,
|
|
8
|
+
statusRedirect,
|
|
9
|
+
} from './shared.js'
|
|
10
|
+
|
|
11
|
+
export async function POST(context: RouteContext): Promise<Response> {
|
|
12
|
+
const crossSitePost = rejectCrossSitePost(context)
|
|
13
|
+
if (crossSitePost) return crossSitePost
|
|
14
|
+
|
|
15
|
+
const options = getConfig()
|
|
16
|
+
if (!options.providers.includes('email-code')) {
|
|
17
|
+
return new Response('Not found', { status: 404 })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const form = await readForm(context)
|
|
21
|
+
const email = await readFormString(form, 'email')
|
|
22
|
+
const redirect = form.get('redirect')?.toString()
|
|
23
|
+
const result = await authClient(context).code.request({ email })
|
|
24
|
+
|
|
25
|
+
return statusRedirect(
|
|
26
|
+
context,
|
|
27
|
+
result,
|
|
28
|
+
options.verifyCodePath,
|
|
29
|
+
{ email, sent: '1', redirect },
|
|
30
|
+
options.verifyCodePath,
|
|
31
|
+
{ email, redirect },
|
|
32
|
+
)
|
|
33
|
+
}
|