@growth-labs/auth
v0.7.3
Published
OpenAuth **client** package for Astro + Cloudflare. Verifies JWTs, manages session cookies, gates protected paths, issues a signed server-readable identity cookie, and injects login/logout/callback routes.
Readme
@growth-labs/auth
OpenAuth client package for Astro + Cloudflare. Verifies JWTs, manages session cookies, gates protected paths, issues a signed server-readable identity cookie, and injects login/logout/callback routes.
This is a client only. It does NOT run an OpenAuth issuer. The canonical issuer runtime lives in growth-labs/identity-platform; apps/auth-server/ in this repo is only a minimal sample Worker.
Identity, not authorization. Answers "who is this user?" — not "what can they access?" Roles, entitlements, and premium checks are consumer concerns (e.g. @fulcrum/auth).
Config
import auth from '@growth-labs/auth'
auth({
issuer: 'https://auth.fedweek.com', // Browser-facing OpenAuth issuer URL
issuerInternal: 'https://auth-internal.example.com', // Optional back-channel issuer URL
clientId: 'fedweek-prod', // Optional; defaults to request hostname
clientSecret: 'AUTH_CLIENT_SECRET', // Worker secret binding name, resolved at request time
resource: 'https://fedweek.com', // RFC 8707 resource/audience URI
cookiePrefix: 'fedweek', // → fedweek_at, fedweek_rt, fedweek_provider cookies
sessionCookieDomain: '.fedweek.com', // Cross-subdomain sharing (optional)
gatedPaths: ['/premium/*', '/account/*'], // Glob patterns for protected routes
loginPath: '/login', // Redirect target for unauthenticated users
logoutRedirect: '/',
callbackPath: '/api/auth/callback',
passwordPath: '/password', // Local branded password form when renderers.password is set
verifyCodePath: '/verify-code', // Local branded magic-code form when renderers.verifyCode is set
providers: ['google', 'password', 'email-code'],
loginPage: {
title: 'Sign in',
brandName: 'FEDweek',
logoUrl: 'https://media.fedweek.com/logos/header.png',
},
renderers: {
// Optional all-or-nothing UI overrides per auth surface.
// Flow logic, cookies, callback exchange, and issuer URLs remain package-owned.
login: { module: '/src/auth/auth-render', export: 'renderLogin' },
password: { module: '/src/auth/auth-render', export: 'renderPassword' },
verifyCode: { module: '/src/auth/auth-render', export: 'renderVerifyCode' },
},
accessTokenMaxAge: 900, // 15 min (seconds)
refreshTokenMaxAge: 2592000, // 30 days
})What It Injects
Middleware (runs on every request):
- Reads
{cookiePrefix}_atand{cookiePrefix}_rtcookies - Verifies access token JWT against the internal issuer's JWKS (
{issuerInternal ?? issuer}/.well-known/jwks.json, cached in Worker memory) - If expired but refresh token valid → silent refresh, set new cookies
- Populates
context.locals.userasUser | null - If path matches
gatedPathsanduseris null → redirect tologinPath
Routes:
GET /login— provider-selection page;?provider=google|password|email-codestarts auth locally and redirects into the issuer's generic/authorizeendpointGET /logout— clears cookies, revokes refresh token at the internal issuer, redirectsGET /api/auth/callback— OAuth callback handler (PKCE code exchange, cookie setting)GET /forgot-password/GET /reset-password— local redirects to issuer-owned password UI unless renderers are configuredGET /password/GET /verify-code— local branded password and magic-code views whenrenderers.password/renderers.verifyCodeare configuredPOST /api/auth/password/verify,/api/auth/code/request,/api/auth/code/verify,/api/auth/password/reset/request,/api/auth/password/reset/confirm— package-owned back-channel form handlers that call the issuer JSON API throughissuerInternal, then return a303browser redirect or a JSON redirect contract forAccept: application/jsonrequests
Branded Auth UI
Consumers can supply renderers.login, renderers.forgotPassword, and
renderers.resetPassword in auth() options. Consumers that want every
password and magic-code step to stay on their own domain can also supply
renderers.password and renderers.verifyCode. Each renderer receives a
typed payload from @growth-labs/auth/types with package-built local form
actions, provider or issuer URLs, and brand values, then returns either an
HTML string or a Response.
Renderers replace only GET form views. Callback, logout, token exchange,
cookies, PKCE/state, POST CSRF checks, and issuer communication remain
package-owned. When
renderers.password or renderers.verifyCode is configured, /login
links those provider buttons to the local branded routes; otherwise it
keeps the existing issuer-hosted OpenAuth UI flow.
Forgot/reset renderers receive both the historical issuer URL and the new
local API form target. Use passwordResetRequestUrl and
passwordResetConfirmUrl for consumer-hosted reset forms.
Package-owned POST routes are safe for native forms and fetch-enhanced
renderers. Native forms receive 303 See Other redirects. Fetch callers should
send Accept: application/json; success returns { ok: true, redirect }, and
errors return { error, redirect } with a 400 status. POST requests without
a same-origin Origin header are rejected with 403 before the issuer is
called.
Server SDK
import { authClient } from '@growth-labs/auth/server'
const client = authClient(context)
await client.password.verify({ email, password })
await client.code.request({ email })
await client.code.verify({ email, code })
await client.password.reset.request({ email })
await client.password.reset.confirm({ token, newPassword })authClient(context) reads the resolved integration config and Worker
bindings, posts to {issuerInternal ?? issuer}/api/v1/auth/*, resolves
clientSecret binding names at request time, sends client_secret_basic
for confidential clients, and includes client_id, redirect_uri, PKCE
code_challenge, state, and resource for authorization-code issuing
calls. Password reset request calls also include reset_url_base from the
current consumer origin so the issuer can send reset links back to the same
registered host. The package-owned POST routes use this SDK directly;
consumers can also use it from custom server routes.
Analytics Events
When @growth-labs/[email protected] or newer is installed, the callback
route emits signup_completed or login_completed through
trackServerEvent from @growth-labs/analytics/utils. The payload is:
{
provider: 'google' | 'password' | 'email-code' | 'unknown',
isNewUser: boolean,
auth_path: string,
identity_user_id: string,
}The event is emitted from the consuming site request context, so analytics
can preserve the site's anonymous visitor_id, session_id, attribution
cookies, and gl_identity_links row. The identity platform does not need
to see site attribution cookies.
Using context.locals.user
// Gated page — user guaranteed authenticated
const { user } = Astro.locals
// user.id, user.email, user.name, user.avatarUrl
// Non-gated page — check first
if (user) {
// show personalized content
}TypeScript
interface User {
id: string
email: string
name?: string | null
avatarUrl?: string | null
}
// Available as User | null on context.locals.user (Astro.locals.user in .astro files)Wrangler Bindings
GL_IDENTITY_SECRET is required when analytics dashboards need logged-in vs anonymous segmentation. It must be a 32-byte secret encoded with standard base64 and should be unique per tenant.
Injected routes read Worker bindings from cloudflare:workers, matching Astro 6 + @astrojs/cloudflare v13. They do not rely on Astro.locals.runtime or context.locals.runtime.
The issuer needs KV + D1 — see growth-labs/identity-platform for the production runtime shape, or apps/auth-server/README.md here for the minimal sample.
Integration Order
Auth middleware MUST run before consent and analytics middleware. List auth() first in astro.config.mjs integrations array.
Issuer Unreachable (Cold Start)
JWKS cached in Worker memory. If issuer unreachable on cold start:
- Non-gated paths: fail open —
user = null, page serves normally - Gated paths: fail closed — redirect to loginPath
There is no public runtime/admin API for evicting the JWKS cache. Cache
state follows the Worker isolate lifecycle; _resetJwksCache() is an
internal package-test helper and is not exported from
@growth-labs/auth/utils.
OAuth Flow Summary
- User clicks a provider on the local login page → browser hits
/login?provider=...or a local branded/password//verify-coderoute when that renderer is configured - The local
/loginstart route stores PKCE/state/redirect/provider cookies and redirects to{issuer}/authorize?provider=...; configuredresourceis sent as the RFC 8707 resource indicator. Consumer-hosted/password,/verify-code, and/forgot-passwordpages seed root-scoped flow cookies locally, with the PKCE cookie visible to their package-owned POST handlers. The forgot-password route does not set a provider cookie. - For consumer-hosted password or magic-code flows, package-owned POST routes call
{issuerInternal ?? issuer}/api/v1/auth/*and redirect to/api/auth/callback?code=...&state=...; native forms receive303redirects, whileAccept: application/jsoncallers receive{ ok/error, redirect } - Issuer authenticates or issues a JSON authorization code → callback exchanges code at
{issuerInternal ?? issuer}/tokenvia PKCE, sendsAuthorization: Basic base64(client_id:client_secret)whenclientSecretis configured, includesresource, readsproperties.isNewUserfrom the JWT payload with fallback to top-levelisNewUserfor legacy issuers, uses the{cookiePrefix}_providerauth-flow cookie set at login start for provider attribution, sets session cookies, setsgl_idwhenGL_IDENTITY_SECRETis configured, and redirects to the original page
Signed Identity Cookie
gl_id lets server-side analytics distinguish logged-in requests without storing PII in Workers Analytics Engine and without trusting a client-writable flag.
- Format:
<base64url(payload)>.<base64url(hmac_sha256(payload_bytes, secret_bytes))>with no padding - Payload: lowercase hex
sha256(sub + GL_IDENTITY_SECRET), wheresubis the access-token subject. The raw subject is never written into the cookie. - Secret:
GL_IDENTITY_SECRET, a per-tenant 32-byte standard-base64 value - Attributes:
HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000 - Issued: callback success after token exchange
- Cleared:
/logoutwithMax-Age=0
Verification helper:
import { verifyIdentityCookie } from '@growth-labs/auth/utils'
const result = await verifyIdentityCookie(cookieValue, env.GL_IDENTITY_SECRET)
if (result.valid) {
console.log(result.payload) // 64-char opaque hex digest
}signIdentityCookie(payload, secret) and verifyIdentityCookie(cookieValue, secret) use Web Crypto (crypto.subtle) and are async. Cookie issuance hashes the token subject before signing it.
Analytics Integration
Optional analytics helpers exist for login_completed / signup_completed,
but the package does not currently guarantee WAE writes by itself. The
event name is derived from the access-token JWT isNewUser claim,
preferring payload.properties.isNewUser and falling back to top-level
payload.isNewUser for legacy issuers. Consumers that need dashboard
events should use the public analytics client/server contract available
in their app.
Key Patterns
- Virtual module:
virtual:growth-labs/auth/config(slash, not hyphen) - Cookie names:
{cookiePrefix}_at,{cookiePrefix}_rt,{cookiePrefix}_pkce,{cookiePrefix}_state,{cookiePrefix}_redirect,{cookiePrefix}_provider,gl_id - PKCE flow with
code_verifier/code_challenge(S256);{cookiePrefix}_pkceis scoped tocallbackPathfor issuer redirect flows and to/for consumer-hosted password/code/forgot flows that post before callback issueris browser-facing; optionalissuerInternalis used for token exchange, silent refresh, JWT verification/JWKS lookup, and revocation. If omitted,issuerInternalfalls back toissuer.- Confidential OAuth clients use
client_secret_basicon token exchange and refresh whenclientSecretis configured. Prefer a Worker secret binding name such asAUTH_CLIENT_SECRET; runtime code resolves that binding before calling the issuer. - RFC 8707
resourceis sent on authorize, token exchange, and refresh when configured email-codeis a client-side alias for the issuer's OpenAuthcodeprovider- CSRF state parameter verified on callback
- Access-token user claims live under
payload.properties.*; the client falls back to top-levelpayload.email,payload.name,payload.image, andpayload.isNewUserfor legacy issuers - Forgot/reset password routes can remain issuer-owned redirects or use renderer-provided local API form targets
- Package-owned auth POST routes require a same-origin
Originheader; missing or cross-site form submissions return403 - Branded auth screens use
renderers, not consumer-local shadow routes - No
audclaim validation in this package — single client per issuer assumed .astrocomponent files ship as source, not compiled
