@warlock.js/auth 5.34.3 → 5.36.0
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/CHANGELOG.md +11 -0
- package/esm/contracts/providers.d.mts +10 -0
- package/esm/contracts/providers.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.d.mts +1 -0
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +1 -0
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.d.mts +1 -0
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs +5 -6
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/providers/google-provider.d.mts +7 -1
- package/esm/providers/google-provider.d.mts.map +1 -1
- package/esm/providers/google-provider.mjs +10 -6
- package/esm/providers/google-provider.mjs.map +1 -1
- package/esm/services/resolve-request-user.d.mts.map +1 -1
- package/esm/services/resolve-request-user.mjs +3 -2
- package/esm/services/resolve-request-user.mjs.map +1 -1
- package/esm/services/token-family-operation.mjs +7 -3
- package/esm/services/token-family-operation.mjs.map +1 -1
- package/esm/session/page-session.d.mts +1 -1
- package/esm/session/page-session.d.mts.map +1 -1
- package/esm/session/page-session.mjs +11 -2
- package/esm/session/page-session.mjs.map +1 -1
- package/llms-full.txt +69 -0
- package/package.json +8 -8
- package/skills/login-with-providers/SKILL.md +32 -0
- package/skills/manage-tokens/SKILL.md +17 -0
- package/skills/protect-routes/SKILL.md +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to `@warlock.js/auth` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
6
|
|
|
7
|
+
## 5.35.0 - 2026-10-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Restore session middleware response fixtures, keep session projection defaults typed as unknown, and skip renewal without an explicit user type.
|
|
12
|
+
|
|
13
|
+
- Exclude expired refresh rows from session caps while preserving oldest-first eviction.
|
|
14
|
+
- Bound Google token and JWKS requests with configurable `timeoutMs`, `fetch` and `endpoints` options; retain all identity checks and isolate JWKS caches.
|
|
15
|
+
- Reject unavailable refresh families outside the transaction callback so expected credential rejection is not logged as a database failure.
|
|
16
|
+
- Publish required/optional session guard descriptors and markers for grouped route inspection.
|
|
17
|
+
|
|
7
18
|
## 5.34.3 - 2026-10-20
|
|
8
19
|
|
|
9
20
|
### Changed
|
|
@@ -61,6 +61,16 @@ export type GoogleProviderConfig = {
|
|
|
61
61
|
redirectUri: string;
|
|
62
62
|
/** @default ["openid", "email", "profile"] */
|
|
63
63
|
scopes?: string[];
|
|
64
|
+
/** Positive integer deadline per token/JWKS request in milliseconds. @default 5000 */
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
/** Trusted transport override, also used for JWKS retrieval. Must honor the abort signal. */
|
|
67
|
+
fetch?: typeof globalThis.fetch;
|
|
68
|
+
/** Trusted endpoint overrides for controlled provider tests; Google issuers remain enforced. */
|
|
69
|
+
endpoints?: {
|
|
70
|
+
authorization?: string;
|
|
71
|
+
token?: string;
|
|
72
|
+
jwks?: string;
|
|
73
|
+
};
|
|
64
74
|
};
|
|
65
75
|
/** `auth.providers.github`. */
|
|
66
76
|
export type GitHubProviderConfig = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"providers.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/contracts/providers.ts"],"mappings":";;;;;;;YAOY;EACV;EACA;EACA;;;;;;;YAQU;EACV,OAAO;EACP,UAAU;;;YAIA;;EAEV;;EAEA;EACA;;EAEA;EACA;EACA;;EAEA,KAAK;;;;;;;iBAQU;WACN;;;;;;;;WAQA;;EAET,iBAAiB,OAAO,sCAAsC;;EAE9D,eAAe,QAAQ,yBAAyB,QAAQ;;;YAI9C;EACV;EACA;;EAEA;;EAEA
|
|
1
|
+
{"version":3,"file":"providers.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/contracts/providers.ts"],"mappings":";;;;;;;YAOY;EACV;EACA;EACA;;;;;;;YAQU;EACV,OAAO;EACP,UAAU;;;YAIA;;EAEV;;EAEA;EACA;;EAEA;EACA;EACA;;EAEA,KAAK;;;;;;;iBAQU;WACN;;;;;;;;WAQA;;EAET,iBAAiB,OAAO,sCAAsC;;EAE9D,eAAe,QAAQ,yBAAyB,QAAQ;;;YAI9C;EACV;EACA;;EAEA;;EAEA;;EAEA;;EAEA,eAAe,WAAW;;EAE1B;IAAc;IAAwB;IAAgB;;;;YAI5C;EACV;EACA;;EAEA;;EAEA;;;YAIU;EACV;EACA;;EAEA;;EAEA;;;YAIU;EACV;EACA;;EAEA;;EAEA;;;YAIU;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;YAIU;EACV;EACA;;EAEA;;EAEA;;;YAIU;EACV;EACA;;EAEA;;EAEA;;;YAIU,uBACV,SAAS,iBACT,OAAO,WAAW,UACf,OAAO,QAAQ;;YAGR;EACV,SAAS;EACT,SAAS;EACT,UAAU;EACV,WAAW;EACX,QAAQ;EACR,WAAW;EACX,IAAI;;;;;EAKJ;;EAEA,aAAa;;EAEb,SAAS,eAAe;;;YAId;;EAEV;;EAEA;;EAEA;;;;;EAKA;;EAEA,YAAY,MAAM;;;YAIR;;EAEV;EACA;EACA,WAAW;;;YAID,aAAa,eAAe,SAAS,YAAY;;YAGjD;;;;;;EAMV;;EAEA;;EAEA;;EAEA;;EAEA,WAAW;;EAEX,OAAO"}
|
|
@@ -18,6 +18,7 @@ export type AuthMiddlewareDescriptorSource = "header" | {
|
|
|
18
18
|
export type AuthMiddlewareDescriptor = {
|
|
19
19
|
/** Resolved credential sources; with dual sources the header comes first. */
|
|
20
20
|
sources: AuthMiddlewareDescriptorSource[];
|
|
21
|
+
required?: boolean;
|
|
21
22
|
/** Resolved allowed user types; an empty array means any authenticated user. */
|
|
22
23
|
userTypes: string[];
|
|
23
24
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/middleware/auth.middleware.ts"],"mappings":";;;;;;;;qBAsBa;;YAGD;EAA8C;;;;;;YAM9C;;EAEV,SAAS;;
|
|
1
|
+
{"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/middleware/auth.middleware.ts"],"mappings":";;;;;;;;qBAsBa;;YAGD;EAA8C;;;;;;YAM9C;;EAEV,SAAS;EACT;;EAEA;;;;;;;;;;;;;;;;;;;;;;;wBAoGc,kBAAkB;wBAClB,eAAe,SAAS,wBAAwB;wBAChD,eACd,oCACA,UAAU,wBACT;wBACa,eACd,oCACA,YAAY,YACX"}
|
|
@@ -122,6 +122,7 @@ function authMiddleware(userTypeOrOptions, legacyTokenFromOrOptions) {
|
|
|
122
122
|
};
|
|
123
123
|
const descriptorSources = dualSources ? [...dualHeader ? ["header"] : [], ...dualCookies.map(descriptorSource)] : [descriptorSource(tokenFrom)];
|
|
124
124
|
const descriptor = Object.freeze({
|
|
125
|
+
...optional ? { required: false } : {},
|
|
125
126
|
sources: Object.freeze(descriptorSources.map((source) => typeof source === "string" ? source : Object.freeze({ ...source }))),
|
|
126
127
|
userTypes: Object.freeze([...allowedTypes])
|
|
127
128
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, type HttpContext, markAuthGuard, t, type Middleware } from \"@warlock.js/core\";\nimport { log } from \"@warlock.js/logger\";\nimport type {\n AuthCredentialDescriptor,\n AuthMiddlewareOptions,\n TokenFrom,\n} from \"../contracts/types\";\nimport { authConfig } from \"../services/auth-config\";\nimport { readCredential, resolveRequestUserOutcome } from \"../services/resolve-request-user\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\nimport {\n assertCsrfOriginAllowed,\n CsrfOriginMismatchError,\n requiresCsrfOriginCheck,\n} from \"./csrf-origin-check\";\nimport { localizedLoginPath } from \"./localized-login-path\";\n\n/**\n * Well-known symbol under which `authMiddleware()` tags the middleware it\n * returns. Shared via the global symbol registry so other packages can read the\n * descriptor structurally (`Symbol.for(\"warlock.auth\")`) without importing auth.\n */\nexport const AUTH_MIDDLEWARE_DESCRIPTOR = Symbol.for(\"warlock.auth\");\n\n/** A credential source an auth middleware reads: the Authorization header or a named cookie. */\nexport type AuthMiddlewareDescriptorSource = \"header\" | { cookie: string };\n\n/**\n * Frozen, JSON-safe description of how an `authMiddleware()` instance\n * authenticates, stored (non-enumerably) under {@link AUTH_MIDDLEWARE_DESCRIPTOR}.\n */\nexport type AuthMiddlewareDescriptor = {\n /** Resolved credential sources; with dual sources the header comes first. */\n sources: AuthMiddlewareDescriptorSource[];\n /** Resolved allowed user types; an empty array means any authenticated user. */\n userTypes: string[];\n};\n\n/** The 401 body shape every rejection in this middleware carries. */\ntype UnauthorizedPayload = { error: string; errorCode: string };\n\n/**\n * Reject an unauthenticated request, choosing the representation by ROUTE KIND.\n *\n * An API route always gets the JSON 401 it has always gotten. A PAGE route\n * (`request.route.isPage` — the React-SSR pages) instead redirects a logged-out\n * browser to the configured login path with a `returnUrl`, so a human\n * navigating to a guarded page lands on the login screen rather than reading a\n * raw JSON error blob — the defect in finding b9ab9804.\n *\n * The redirect is OPT-IN and backward-compatible: with no `auth.pageAuth.loginPath`\n * configured, even a page route falls back to the JSON 401, so an app that has\n * not asked for page-login UX keeps exactly its current behavior. The API 401\n * contract is never changed for API routes.\n *\n * `returnUrl` carries `request.url` — the relative path + query the browser was\n * on — never the absolute URL: a relative target cannot be turned into an\n * open-redirect off this app's origin.\n */\nfunction rejectUnauthorized(\n { request, response }: HttpContext,\n payload: UnauthorizedPayload,\n redirect?: AuthMiddlewareOptions[\"redirect\"],\n) {\n const loginPath = redirect?.to ?? authConfig.pageAuth.loginPath();\n const returnUrlParam = redirect?.returnUrlParam ?? authConfig.pageAuth.returnUrlParam();\n\n if (loginPath && request.route?.isPage) {\n // Under an active `web.localeRouting` strategy, the login destination\n // itself must carry the request's locale prefix — otherwise an anonymous\n // `/ar/admin` bounces to the DEFAULT locale's `/login` rather than\n // `/ar/login`, even though `returnUrl` below correctly points back at\n // `/ar/admin`. See `./localized-login-path.ts`.\n const destination = localizedLoginPath(loginPath, request.locale);\n const separator = destination.includes(\"?\") ? \"&\" : \"?\";\n const returnUrl = encodeURIComponent(request.url);\n\n return response.redirect(`${destination}${separator}${returnUrlParam}=${returnUrl}`);\n }\n\n return response.unauthorized(payload);\n}\n\nfunction tokenFromDescriptor(descriptor: Partial<AuthCredentialDescriptor>): TokenFrom {\n if (!descriptor.source || descriptor.source === \"header\") return \"header\";\n\n if (!descriptor.key) {\n throw new Error(\"authMiddleware cookie credentials require a cookie key.\");\n }\n\n return `cookie:${descriptor.key}`;\n}\n\nfunction descriptorSource(tokenFrom: TokenFrom): AuthMiddlewareDescriptorSource {\n return tokenFrom === \"header\" ? \"header\" : { cookie: tokenFrom.slice(\"cookie:\".length) };\n}\n\nfunction resolveAllowedTypes(value: string | string[] | undefined): string[] {\n if (value !== undefined) return Array.isArray(value) ? value : [value];\n\n const configuredDefault = config.key(\"auth.defaultUserType\") as string | undefined;\n\n if (configuredDefault) return [configuredDefault];\n\n const userTypes = config.key(\"auth.userType\", {}) as Record<string, unknown>;\n const configuredTypes = Object.keys(userTypes);\n\n if (configuredTypes.length === 1) return configuredTypes;\n\n throw new Error(\n \"authMiddleware requires a user type when auth.defaultUserType is unset and multiple user types exist.\",\n );\n}\n\n/**\n * Build a route gate. Authentication is required unless optional is explicit.\n *\n * An omitted user type selects the configured default; an explicit argument\n * selects which user types may pass:\n * - `[]` — any authenticated user (token required, type not checked).\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\n * `userType` must be one of the listed types.\n *\n * `{ optional: true }` allows anonymous requests. All other forms reject\n * missing or invalid credentials with 401, or a configured page redirect.\n *\n * `tokenFrom` selects one credential source and defaults to `\"header\"`.\n * A single source avoids making credential precedence depend on array order.\n *\n * @example\n * router.get(\"/account\", authMiddleware([]), accountController);\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\n * router.get(\"/browser-account\", authMiddleware([], \"cookie:token\"), accountController);\n */\nexport function authMiddleware(): Middleware;\nexport function authMiddleware(options: AuthMiddlewareOptions): Middleware;\nexport function authMiddleware(\n allowedUserType: string | string[],\n options?: AuthMiddlewareOptions,\n): Middleware;\nexport function authMiddleware(\n allowedUserType: string | string[],\n tokenFrom?: TokenFrom,\n): Middleware;\nexport function authMiddleware(\n userTypeOrOptions?: string | string[] | AuthMiddlewareOptions,\n legacyTokenFromOrOptions?: TokenFrom | AuthMiddlewareOptions,\n): Middleware {\n const options =\n typeof userTypeOrOptions === \"object\" && !Array.isArray(userTypeOrOptions)\n ? userTypeOrOptions\n : typeof legacyTokenFromOrOptions === \"object\"\n ? legacyTokenFromOrOptions\n : undefined;\n const selectedUserType =\n typeof userTypeOrOptions === \"string\" || Array.isArray(userTypeOrOptions)\n ? userTypeOrOptions\n : undefined;\n const allowedTypes = resolveAllowedTypes(selectedUserType);\n const legacyTokenFrom =\n typeof legacyTokenFromOrOptions === \"string\" ? legacyTokenFromOrOptions : undefined;\n const dualSources = options?.sources?.length\n ? options.sources.map(tokenFromDescriptor)\n : undefined;\n\n if (dualSources && options?.source) {\n throw new Error(\"authMiddleware accepts either `source` or `sources`, not both.\");\n }\n\n const dualHeader = dualSources?.includes(\"header\") ?? false;\n const dualCookies = dualSources?.filter((source) => source !== \"header\") ?? [];\n\n if (dualSources && (dualCookies.length > 1 || dualSources.length - dualCookies.length > 1)) {\n throw new Error(\"authMiddleware `sources` accepts at most one header and one cookie.\");\n }\n\n // With dual sources the configured cookie is the base credential; the header\n // is chosen per request, ahead of it.\n const cookieTokenFrom: TokenFrom = dualSources\n ? (dualCookies[0] ?? \"header\")\n : options\n ? tokenFromDescriptor(options)\n : (legacyTokenFrom ?? \"header\");\n const tokenFrom = cookieTokenFrom;\n const refreshCredential = options?.refresh ? tokenFromDescriptor(options.refresh) : undefined;\n const renewalUserType = allowedTypes.length === 1 ? allowedTypes[0] : undefined;\n\n if (\n refreshCredential &&\n (tokenFrom === \"header\" || refreshCredential === \"header\" || !renewalUserType)\n ) {\n throw new Error(\n \"authMiddleware automatic renewal requires one allowed user type and cookie access and refresh credentials.\",\n );\n }\n\n const optional = options?.optional === true;\n\n const auth: Middleware = async ({ request, response }) => {\n const reject = (payload: UnauthorizedPayload) =>\n rejectUnauthorized({ request, response }, payload, options?.redirect);\n\n // A present Authorization header always wins and never falls back to the\n // cookie: the credential used is the header, so no renewal and no CSRF check.\n const headerWins = dualHeader && Boolean(request.authorizationValue);\n const usedTokenFrom: TokenFrom = headerWins ? \"header\" : tokenFrom;\n const usedRefresh = headerWins ? undefined : refreshCredential;\n\n // Renewal can mint cookies, so a cookie-authenticated unsafe request must\n // pass the same CSRF check even when its access cookie is missing.\n if (\n (readCredential(request, usedTokenFrom) ||\n (usedRefresh && readCredential(request, usedRefresh))) &&\n requiresCsrfOriginCheck(usedTokenFrom, request.method)\n ) {\n try {\n assertCsrfOriginAllowed(request);\n } catch (error) {\n if (!(error instanceof CsrfOriginMismatchError)) throw error;\n\n log.error(\"http\", \"auth\", error);\n return response.forbidden({\n error: t(\"auth.errors.csrfOriginMismatch\"),\n errorCode: AuthErrorCodes.CsrfOriginMismatch,\n });\n }\n }\n\n const outcome = await resolveRequestUserOutcome(request, response, {\n tokenFrom: usedTokenFrom,\n refreshCredential: usedRefresh,\n overlapMs: options?.refresh?.overlapMs,\n allowedTypes,\n renewalUserType,\n });\n\n if (outcome.user) {\n request.locals.user = outcome.user;\n return;\n }\n\n if (outcome.failure === \"forbidden\") {\n return response.forbidden({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n }\n\n if (optional) return;\n\n if (outcome.failure === \"missing\") {\n return reject({\n error: t(\"auth.errors.missingAccessToken\"),\n errorCode: AuthErrorCodes.MissingAccessToken,\n });\n }\n\n if (outcome.failure === \"unauthorized\") {\n return reject({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n }\n\n return reject({\n error: t(\"auth.errors.invalidAccessToken\"),\n errorCode: AuthErrorCodes.InvalidAccessToken,\n });\n };\n\n const descriptorSources: AuthMiddlewareDescriptorSource[] = dualSources\n ? [...(dualHeader ? ([\"header\"] as const) : []), ...dualCookies.map(descriptorSource)]\n : [descriptorSource(tokenFrom)];\n const descriptor: AuthMiddlewareDescriptor = Object.freeze({\n sources: Object.freeze(\n descriptorSources.map((source) =>\n typeof source === \"string\" ? source : Object.freeze({ ...source }),\n ),\n ) as AuthMiddlewareDescriptorSource[],\n userTypes: Object.freeze([...allowedTypes]) as string[],\n });\n\n // Non-enumerable so the tag never shows up in logs, spreads or JSON.\n Object.defineProperty(auth, AUTH_MIDDLEWARE_DESCRIPTOR, { value: descriptor, enumerable: false });\n\n // Tells `warlock dev` and `warlock inspect` that routes behind this\n // middleware are authenticated. `{ optional: true }`\n // lets anonymous requests through, so it does not count as a guard.\n return markAuthGuard(auth, !optional);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, type HttpContext, markAuthGuard, t, type Middleware } from \"@warlock.js/core\";\nimport { log } from \"@warlock.js/logger\";\nimport type {\n AuthCredentialDescriptor,\n AuthMiddlewareOptions,\n TokenFrom,\n} from \"../contracts/types\";\nimport { authConfig } from \"../services/auth-config\";\nimport { readCredential, resolveRequestUserOutcome } from \"../services/resolve-request-user\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\nimport {\n assertCsrfOriginAllowed,\n CsrfOriginMismatchError,\n requiresCsrfOriginCheck,\n} from \"./csrf-origin-check\";\nimport { localizedLoginPath } from \"./localized-login-path\";\n\n/**\n * Well-known symbol under which `authMiddleware()` tags the middleware it\n * returns. Shared via the global symbol registry so other packages can read the\n * descriptor structurally (`Symbol.for(\"warlock.auth\")`) without importing auth.\n */\nexport const AUTH_MIDDLEWARE_DESCRIPTOR = Symbol.for(\"warlock.auth\");\n\n/** A credential source an auth middleware reads: the Authorization header or a named cookie. */\nexport type AuthMiddlewareDescriptorSource = \"header\" | { cookie: string };\n\n/**\n * Frozen, JSON-safe description of how an `authMiddleware()` instance\n * authenticates, stored (non-enumerably) under {@link AUTH_MIDDLEWARE_DESCRIPTOR}.\n */\nexport type AuthMiddlewareDescriptor = {\n /** Resolved credential sources; with dual sources the header comes first. */\n sources: AuthMiddlewareDescriptorSource[];\n required?: boolean;\n /** Resolved allowed user types; an empty array means any authenticated user. */\n userTypes: string[];\n};\n\n/** The 401 body shape every rejection in this middleware carries. */\ntype UnauthorizedPayload = { error: string; errorCode: string };\n\n/**\n * Reject an unauthenticated request, choosing the representation by ROUTE KIND.\n *\n * An API route always gets the JSON 401 it has always gotten. A PAGE route\n * (`request.route.isPage` — the React-SSR pages) instead redirects a logged-out\n * browser to the configured login path with a `returnUrl`, so a human\n * navigating to a guarded page lands on the login screen rather than reading a\n * raw JSON error blob — the defect in finding b9ab9804.\n *\n * The redirect is OPT-IN and backward-compatible: with no `auth.pageAuth.loginPath`\n * configured, even a page route falls back to the JSON 401, so an app that has\n * not asked for page-login UX keeps exactly its current behavior. The API 401\n * contract is never changed for API routes.\n *\n * `returnUrl` carries `request.url` — the relative path + query the browser was\n * on — never the absolute URL: a relative target cannot be turned into an\n * open-redirect off this app's origin.\n */\nfunction rejectUnauthorized(\n { request, response }: HttpContext,\n payload: UnauthorizedPayload,\n redirect?: AuthMiddlewareOptions[\"redirect\"],\n) {\n const loginPath = redirect?.to ?? authConfig.pageAuth.loginPath();\n const returnUrlParam = redirect?.returnUrlParam ?? authConfig.pageAuth.returnUrlParam();\n\n if (loginPath && request.route?.isPage) {\n // Under an active `web.localeRouting` strategy, the login destination\n // itself must carry the request's locale prefix — otherwise an anonymous\n // `/ar/admin` bounces to the DEFAULT locale's `/login` rather than\n // `/ar/login`, even though `returnUrl` below correctly points back at\n // `/ar/admin`. See `./localized-login-path.ts`.\n const destination = localizedLoginPath(loginPath, request.locale);\n const separator = destination.includes(\"?\") ? \"&\" : \"?\";\n const returnUrl = encodeURIComponent(request.url);\n\n return response.redirect(`${destination}${separator}${returnUrlParam}=${returnUrl}`);\n }\n\n return response.unauthorized(payload);\n}\n\nfunction tokenFromDescriptor(descriptor: Partial<AuthCredentialDescriptor>): TokenFrom {\n if (!descriptor.source || descriptor.source === \"header\") return \"header\";\n\n if (!descriptor.key) {\n throw new Error(\"authMiddleware cookie credentials require a cookie key.\");\n }\n\n return `cookie:${descriptor.key}`;\n}\n\nfunction descriptorSource(tokenFrom: TokenFrom): AuthMiddlewareDescriptorSource {\n return tokenFrom === \"header\" ? \"header\" : { cookie: tokenFrom.slice(\"cookie:\".length) };\n}\n\nfunction resolveAllowedTypes(value: string | string[] | undefined): string[] {\n if (value !== undefined) return Array.isArray(value) ? value : [value];\n\n const configuredDefault = config.key(\"auth.defaultUserType\") as string | undefined;\n\n if (configuredDefault) return [configuredDefault];\n\n const userTypes = config.key(\"auth.userType\", {}) as Record<string, unknown>;\n const configuredTypes = Object.keys(userTypes);\n\n if (configuredTypes.length === 1) return configuredTypes;\n\n throw new Error(\n \"authMiddleware requires a user type when auth.defaultUserType is unset and multiple user types exist.\",\n );\n}\n\n/**\n * Build a route gate. Authentication is required unless optional is explicit.\n *\n * An omitted user type selects the configured default; an explicit argument\n * selects which user types may pass:\n * - `[]` — any authenticated user (token required, type not checked).\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\n * `userType` must be one of the listed types.\n *\n * `{ optional: true }` allows anonymous requests. All other forms reject\n * missing or invalid credentials with 401, or a configured page redirect.\n *\n * `tokenFrom` selects one credential source and defaults to `\"header\"`.\n * A single source avoids making credential precedence depend on array order.\n *\n * @example\n * router.get(\"/account\", authMiddleware([]), accountController);\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\n * router.get(\"/browser-account\", authMiddleware([], \"cookie:token\"), accountController);\n */\nexport function authMiddleware(): Middleware;\nexport function authMiddleware(options: AuthMiddlewareOptions): Middleware;\nexport function authMiddleware(\n allowedUserType: string | string[],\n options?: AuthMiddlewareOptions,\n): Middleware;\nexport function authMiddleware(\n allowedUserType: string | string[],\n tokenFrom?: TokenFrom,\n): Middleware;\nexport function authMiddleware(\n userTypeOrOptions?: string | string[] | AuthMiddlewareOptions,\n legacyTokenFromOrOptions?: TokenFrom | AuthMiddlewareOptions,\n): Middleware {\n const options =\n typeof userTypeOrOptions === \"object\" && !Array.isArray(userTypeOrOptions)\n ? userTypeOrOptions\n : typeof legacyTokenFromOrOptions === \"object\"\n ? legacyTokenFromOrOptions\n : undefined;\n const selectedUserType =\n typeof userTypeOrOptions === \"string\" || Array.isArray(userTypeOrOptions)\n ? userTypeOrOptions\n : undefined;\n const allowedTypes = resolveAllowedTypes(selectedUserType);\n const legacyTokenFrom =\n typeof legacyTokenFromOrOptions === \"string\" ? legacyTokenFromOrOptions : undefined;\n const dualSources = options?.sources?.length\n ? options.sources.map(tokenFromDescriptor)\n : undefined;\n\n if (dualSources && options?.source) {\n throw new Error(\"authMiddleware accepts either `source` or `sources`, not both.\");\n }\n\n const dualHeader = dualSources?.includes(\"header\") ?? false;\n const dualCookies = dualSources?.filter((source) => source !== \"header\") ?? [];\n\n if (dualSources && (dualCookies.length > 1 || dualSources.length - dualCookies.length > 1)) {\n throw new Error(\"authMiddleware `sources` accepts at most one header and one cookie.\");\n }\n\n // With dual sources the configured cookie is the base credential; the header\n // is chosen per request, ahead of it.\n const cookieTokenFrom: TokenFrom = dualSources\n ? (dualCookies[0] ?? \"header\")\n : options\n ? tokenFromDescriptor(options)\n : (legacyTokenFrom ?? \"header\");\n const tokenFrom = cookieTokenFrom;\n const refreshCredential = options?.refresh ? tokenFromDescriptor(options.refresh) : undefined;\n const renewalUserType = allowedTypes.length === 1 ? allowedTypes[0] : undefined;\n\n if (\n refreshCredential &&\n (tokenFrom === \"header\" || refreshCredential === \"header\" || !renewalUserType)\n ) {\n throw new Error(\n \"authMiddleware automatic renewal requires one allowed user type and cookie access and refresh credentials.\",\n );\n }\n\n const optional = options?.optional === true;\n\n const auth: Middleware = async ({ request, response }) => {\n const reject = (payload: UnauthorizedPayload) =>\n rejectUnauthorized({ request, response }, payload, options?.redirect);\n\n // A present Authorization header always wins and never falls back to the\n // cookie: the credential used is the header, so no renewal and no CSRF check.\n const headerWins = dualHeader && Boolean(request.authorizationValue);\n const usedTokenFrom: TokenFrom = headerWins ? \"header\" : tokenFrom;\n const usedRefresh = headerWins ? undefined : refreshCredential;\n\n // Renewal can mint cookies, so a cookie-authenticated unsafe request must\n // pass the same CSRF check even when its access cookie is missing.\n if (\n (readCredential(request, usedTokenFrom) ||\n (usedRefresh && readCredential(request, usedRefresh))) &&\n requiresCsrfOriginCheck(usedTokenFrom, request.method)\n ) {\n try {\n assertCsrfOriginAllowed(request);\n } catch (error) {\n if (!(error instanceof CsrfOriginMismatchError)) throw error;\n\n log.error(\"http\", \"auth\", error);\n return response.forbidden({\n error: t(\"auth.errors.csrfOriginMismatch\"),\n errorCode: AuthErrorCodes.CsrfOriginMismatch,\n });\n }\n }\n\n const outcome = await resolveRequestUserOutcome(request, response, {\n tokenFrom: usedTokenFrom,\n refreshCredential: usedRefresh,\n overlapMs: options?.refresh?.overlapMs,\n allowedTypes,\n renewalUserType,\n });\n\n if (outcome.user) {\n request.locals.user = outcome.user;\n return;\n }\n\n if (outcome.failure === \"forbidden\") {\n return response.forbidden({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n }\n\n if (optional) return;\n\n if (outcome.failure === \"missing\") {\n return reject({\n error: t(\"auth.errors.missingAccessToken\"),\n errorCode: AuthErrorCodes.MissingAccessToken,\n });\n }\n\n if (outcome.failure === \"unauthorized\") {\n return reject({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n }\n\n return reject({\n error: t(\"auth.errors.invalidAccessToken\"),\n errorCode: AuthErrorCodes.InvalidAccessToken,\n });\n };\n\n const descriptorSources: AuthMiddlewareDescriptorSource[] = dualSources\n ? [...(dualHeader ? ([\"header\"] as const) : []), ...dualCookies.map(descriptorSource)]\n : [descriptorSource(tokenFrom)];\n const descriptor: AuthMiddlewareDescriptor = Object.freeze({\n ...(optional ? { required: false } : {}),\n sources: Object.freeze(\n descriptorSources.map((source) =>\n typeof source === \"string\" ? source : Object.freeze({ ...source }),\n ),\n ) as AuthMiddlewareDescriptorSource[],\n userTypes: Object.freeze([...allowedTypes]) as string[],\n });\n\n // Non-enumerable so the tag never shows up in logs, spreads or JSON.\n Object.defineProperty(auth, AUTH_MIDDLEWARE_DESCRIPTOR, { value: descriptor, enumerable: false });\n\n // Tells `warlock dev` and `warlock inspect` that routes behind this\n // middleware are authenticated. `{ optional: true }`\n // lets anonymous requests through, so it does not count as a guard.\n return markAuthGuard(auth, !optional);\n}\n"],"mappings":"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"}
|
|
@@ -164,6 +164,7 @@ export declare class RefreshToken extends Model {
|
|
|
164
164
|
* Active, unexpired sessions for the user, newest first.
|
|
165
165
|
*/
|
|
166
166
|
static activeFor(user: Auth): Promise<RefreshToken[]>;
|
|
167
|
+
private static activeQuery;
|
|
167
168
|
/**
|
|
168
169
|
* Revoke every still-active refresh token for the user, returning the rows
|
|
169
170
|
* that were revoked so the caller can emit a per-token event for each.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"refresh-token.model.d.mts","names":[],"sources":["../../../../../../../../../packages/auth/src/models/refresh-token/refresh-token.model.ts"],"mappings":";;;;;;;;;;;;;;;;qBAkBa,+CAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAiBnB;EACV;EACA;EACA,aAAa;;;;;;;;;;;;qBAaF,qBAAqB;SAClB;SAEA,mCAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SAQN;;;;;;;SAQA;;MAGH;;;;;;;;;;;MAcA;;;;;MAYA;;MAKA;;MAKA;;;;EAOE,UAAU;;;;;;;;EAWV,kBAAkB;;;;EAclB,cAAc;;;;SAOb,MAAM,MAAM,MAAM,eAAe,SAAS,2BAAwB,QAAA;;;;SAoBlE,YAAY,gBAAgB,QAAQ;;;;;SAQpC,YAAY,MAAM,MAAM,gBAAgB,QAAQ;;SAKhD,aAAa,mBAAmB,QAAQ;;;;;SAQxC,gBAAgB,MAAM,OAAO,QAAQ;;;;SAOrC,cAAc,MAAM,MAAM,gBAAa;;;;SAOvC,UAAU,MAAM,OAAO,QAAQ;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"refresh-token.model.d.mts","names":[],"sources":["../../../../../../../../../packages/auth/src/models/refresh-token/refresh-token.model.ts"],"mappings":";;;;;;;;;;;;;;;;qBAkBa,+CAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAiBnB;EACV;EACA;EACA,aAAa;;;;;;;;;;;;qBAaF,qBAAqB;SAClB;SAEA,mCAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SAQN;;;;;;;SAQA;;MAGH;;;;;;;;;;;MAcA;;;;;MAYA;;MAKA;;MAKA;;;;EAOE,UAAU;;;;;;;;EAWV,kBAAkB;;;;EAclB,cAAc;;;;SAOb,MAAM,MAAM,MAAM,eAAe,SAAS,2BAAwB,QAAA;;;;SAoBlE,YAAY,gBAAgB,QAAQ;;;;;SAQpC,YAAY,MAAM,MAAM,gBAAgB,QAAQ;;SAKhD,aAAa,mBAAmB,QAAQ;;;;;SAQxC,gBAAgB,MAAM,OAAO,QAAQ;;;;SAOrC,cAAc,MAAM,MAAM,gBAAa;;;;SAOvC,UAAU,MAAM,OAAO,QAAQ;iBAI9B;;;;;;;;;SAcK,aAAa,MAAM,OAAO,QAAQ;;;;;SAgBlC,aAAa,mBAAmB,QAAQ;;;;;;SAexC,WAAW,MAAM,MAAM,cAAc;;;;;;SAiBrC,gBAAgB,QAAQ;;;;;;;SAgBxB,qBAAqB,QAAQ;;;;;;;;;;SAe7B,sBAAsB,QAAQ"}
|
|
@@ -170,11 +170,14 @@ var RefreshToken = class extends Model {
|
|
|
170
170
|
* Active, unexpired sessions for the user, newest first.
|
|
171
171
|
*/
|
|
172
172
|
static activeFor(user) {
|
|
173
|
+
return this.activeQuery(user).orderBy("created_at", "desc").get();
|
|
174
|
+
}
|
|
175
|
+
static activeQuery(user) {
|
|
173
176
|
return this.query().where({
|
|
174
177
|
user_id: user.id,
|
|
175
178
|
user_type: user.userType,
|
|
176
179
|
revoked_at: null
|
|
177
|
-
}).where("expires_at", ">", /* @__PURE__ */ new Date())
|
|
180
|
+
}).where("expires_at", ">", /* @__PURE__ */ new Date());
|
|
178
181
|
}
|
|
179
182
|
/**
|
|
180
183
|
* Revoke every still-active refresh token for the user, returning the rows
|
|
@@ -211,11 +214,7 @@ var RefreshToken = class extends Model {
|
|
|
211
214
|
* small.
|
|
212
215
|
*/
|
|
213
216
|
static async enforceMax(user, max) {
|
|
214
|
-
const activeTokens = await this.
|
|
215
|
-
user_id: user.id,
|
|
216
|
-
user_type: user.userType,
|
|
217
|
-
revoked_at: null
|
|
218
|
-
}).orderBy("created_at", "asc").get();
|
|
217
|
+
const activeTokens = await this.activeQuery(user).orderBy("created_at", "asc").get();
|
|
219
218
|
if (activeTokens.length < max) return;
|
|
220
219
|
const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);
|
|
221
220
|
for (const token of tokensToRevoke) await token.revoke();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../../packages/auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\nimport { v } from \"@warlock.js/seal\";\nimport type { DeviceInfo } from \"../../contracts/types\";\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\nimport type { Auth } from \"../auth.model\";\n\n/**\n * Seal schema for the persisted refresh-token record. Exported so an override\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\n * base shape:\n *\n * @example\n * export class AppRefreshToken extends RefreshToken {\n * public static schema = refreshTokenSchema.extend({\n * organization_id: v.string().required(),\n * });\n * }\n */\nexport const refreshTokenSchema = v.object({\n token: v.string().required(),\n user_id: v.scalar().required(),\n user_type: v.string().required(),\n family_id: v.string().required(),\n expires_at: v.date().required(),\n last_used_at: v.date().default(() => new Date()),\n revoked_at: v.date().optional(),\n /** The successor pair a bounded concurrent renewal may reuse. */\n successor_access_token_id: v.string().optional(),\n successor_refresh_token_id: v.string().optional(),\n device_info: v.record(v.any()).optional(),\n});\n\n/**\n * Extra attributes captured when a refresh token is issued.\n */\nexport type RefreshTokenIssueOptions = {\n familyId: string;\n expiresAt: string;\n deviceInfo?: DeviceInfo;\n};\n\n/**\n * Persisted refresh-token record + the data layer for refresh tokens.\n *\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\n * The auth service drives refresh-token state exclusively through this model's\n * named statics and instance methods, so it never hard-codes a column name —\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\n * structurally impossible and lets an override rename or add columns by\n * extending this class and registering it under `config.auth.refreshToken.model`.\n */\nexport class RefreshToken extends Model {\n public static table = \"refresh_tokens\";\n\n public static schema = refreshTokenSchema;\n\n /**\n * The raw signed refresh JWT is a bearer credential — never serialize it.\n * `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a\n * session list returned via `response.json`); `get(\"token\")`,\n * `findByToken` / `findForUser` and every query still read the column.\n */\n public static hidden = [\"token\"];\n\n /**\n * Permanent regardless of the data source default — Mongo's driver default\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\n * never-expiring purge). A purged refresh token must not survive anywhere.\n */\n public static deleteStrategy = \"permanent\" as const;\n\n /** Token family this row belongs to (rotation / replay grouping). */\n public get familyId(): string {\n return this.get(\"family_id\");\n }\n\n /**\n * Whether the token's `expires_at` is in the past.\n *\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\n * counts as expired. It previously answered `false` for both — \"no expiry\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\n * that cannot say when it dies is malformed, not immortal.\n */\n public get isExpired(): boolean {\n const expiresAt = this.get(\"expires_at\");\n\n if (!isUsableExpiry(expiresAt)) return true;\n\n return new Date().getTime() > new Date(expiresAt).getTime();\n }\n\n /**\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\n */\n public get neverExpires(): boolean {\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\n }\n\n /** Whether the token has been revoked. */\n public get isRevoked(): boolean {\n return !!this.get(\"revoked_at\");\n }\n\n /** Whether the token is still usable (not expired and not revoked). */\n public get isValid(): boolean {\n return !this.isExpired && !this.isRevoked;\n }\n\n /**\n * Unconditionally stamp `revoked_at` on this token.\n */\n public async revoke(): Promise<this> {\n return this.merge({ revoked_at: new Date() }).save();\n }\n\n /**\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\n * when this call performed the revoke, `false` when a concurrent request had\n * already revoked it — the win/lose signal that powers rotation replay\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\n * same token can never both succeed.\n */\n public async revokeIfActive(): Promise<boolean> {\n const modelClass = this.constructor as typeof RefreshToken;\n\n const revokedCount = await modelClass.atomic(\n { id: this.id, revoked_at: null },\n { $set: { revoked_at: new Date() } },\n );\n\n return revokedCount > 0;\n }\n\n /**\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\n */\n public async markAsUsed(): Promise<void> {\n await this.merge({ last_used_at: new Date() }).save();\n }\n\n /**\n * Persist a freshly-signed refresh token for the user.\n */\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\n return this.create({\n token,\n user_id: user.id,\n user_type: user.userType,\n family_id: options.familyId,\n expires_at: options.expiresAt,\n device_info: options.deviceInfo\n ? {\n userAgent: options.deviceInfo.userAgent,\n ip: options.deviceInfo.ip,\n deviceId: options.deviceInfo.deviceId,\n }\n : undefined,\n });\n }\n\n /**\n * Find a refresh-token row by its raw token string.\n */\n public static findByToken(token: string): Promise<RefreshToken | null> {\n return this.first({ token });\n }\n\n /**\n * Find a refresh token scoped to a user — used by logout so a caller can only\n * revoke a token that actually belongs to them.\n */\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\n return this.first({ token, user_id: user.id });\n }\n\n /** Find any member of a family, including an already-revoked legacy member. */\n public static findInFamily(familyId: string): Promise<RefreshToken | null> {\n return this.first({ family_id: familyId });\n }\n\n /**\n * Every refresh lineage for a user, including rotated rows. Logout uses this\n * to bootstrap durable family records before it starts family revocations.\n */\n public static familiesForUser(user: Auth): Promise<RefreshToken[]> {\n return this.query().where({ user_id: user.id, user_type: user.userType }).get();\n }\n\n /**\n * Delete a specific refresh token belonging to the user.\n */\n public static deleteForUser(user: Auth, token: string) {\n return this.delete({ token, user_id: user.id });\n }\n\n /**\n * Active, unexpired sessions for the user, newest first.\n */\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\n return this.query()\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\n .where(\"expires_at\", \">\", new Date())\n .orderBy(\"created_at\", \"desc\")\n .get();\n }\n\n /**\n * Revoke every still-active refresh token for the user, returning the rows\n * that were revoked so the caller can emit a per-token event for each.\n *\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\n * `revoked_at: null` would re-query the same predicate after the update and\n * match nothing, returning an empty set.\n */\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\n const tokens = await this.query()\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\n .get();\n\n for (const token of tokens) {\n await token.revoke();\n }\n\n return tokens;\n }\n\n /**\n * Revoke every still-active token in a family (rotation breach containment),\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\n */\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\n\n for (const token of tokens) {\n await token.revoke();\n }\n\n return tokens;\n }\n\n /**\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\n * small.\n */\n public static async enforceMax(user: Auth, max: number): Promise<void> {\n const activeTokens = await this.query()\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\n .orderBy(\"created_at\", \"asc\")\n .get();\n\n if (activeTokens.length < max) return;\n\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\n\n for (const token of tokensToRevoke) {\n await token.revoke();\n }\n }\n\n /**\n * Hard-delete every expired refresh token, returning the deleted rows so the\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\n * (a cold batch path).\n */\n public static async purgeExpired(): Promise<RefreshToken[]> {\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\n\n for (const token of expiredTokens) {\n await token.destroy();\n }\n\n return expiredTokens;\n }\n\n /**\n * Every row that can never retire itself — see {@link neverExpires}. A full\n * scan filtered in memory, for the reason given on\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\n * missing `exp` claim is expressible as a `where`.\n */\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\n const tokens = await this.query().get();\n\n return tokens.filter((token: RefreshToken) => token.neverExpires);\n }\n\n /**\n * Hard-delete every never-expiring row, returning the rows removed.\n *\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\n * table has to carry, and one of the two shapes being removed is a row whose\n * date column cannot be compared at all — leaving it in place keeps a\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\n * these rows can no longer be presented successfully either way.\n */\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\n const tokens = await this.findNeverExpiring();\n\n for (const token of tokens) {\n await token.destroy();\n }\n\n return tokens;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../../packages/auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\nimport { v } from \"@warlock.js/seal\";\nimport type { DeviceInfo } from \"../../contracts/types\";\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\nimport type { Auth } from \"../auth.model\";\n\n/**\n * Seal schema for the persisted refresh-token record. Exported so an override\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\n * base shape:\n *\n * @example\n * export class AppRefreshToken extends RefreshToken {\n * public static schema = refreshTokenSchema.extend({\n * organization_id: v.string().required(),\n * });\n * }\n */\nexport const refreshTokenSchema = v.object({\n token: v.string().required(),\n user_id: v.scalar().required(),\n user_type: v.string().required(),\n family_id: v.string().required(),\n expires_at: v.date().required(),\n last_used_at: v.date().default(() => new Date()),\n revoked_at: v.date().optional(),\n /** The successor pair a bounded concurrent renewal may reuse. */\n successor_access_token_id: v.string().optional(),\n successor_refresh_token_id: v.string().optional(),\n device_info: v.record(v.any()).optional(),\n});\n\n/**\n * Extra attributes captured when a refresh token is issued.\n */\nexport type RefreshTokenIssueOptions = {\n familyId: string;\n expiresAt: string;\n deviceInfo?: DeviceInfo;\n};\n\n/**\n * Persisted refresh-token record + the data layer for refresh tokens.\n *\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\n * The auth service drives refresh-token state exclusively through this model's\n * named statics and instance methods, so it never hard-codes a column name —\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\n * structurally impossible and lets an override rename or add columns by\n * extending this class and registering it under `config.auth.refreshToken.model`.\n */\nexport class RefreshToken extends Model {\n public static table = \"refresh_tokens\";\n\n public static schema = refreshTokenSchema;\n\n /**\n * The raw signed refresh JWT is a bearer credential — never serialize it.\n * `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a\n * session list returned via `response.json`); `get(\"token\")`,\n * `findByToken` / `findForUser` and every query still read the column.\n */\n public static hidden = [\"token\"];\n\n /**\n * Permanent regardless of the data source default — Mongo's driver default\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\n * never-expiring purge). A purged refresh token must not survive anywhere.\n */\n public static deleteStrategy = \"permanent\" as const;\n\n /** Token family this row belongs to (rotation / replay grouping). */\n public get familyId(): string {\n return this.get(\"family_id\");\n }\n\n /**\n * Whether the token's `expires_at` is in the past.\n *\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\n * counts as expired. It previously answered `false` for both — \"no expiry\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\n * that cannot say when it dies is malformed, not immortal.\n */\n public get isExpired(): boolean {\n const expiresAt = this.get(\"expires_at\");\n\n if (!isUsableExpiry(expiresAt)) return true;\n\n return new Date().getTime() > new Date(expiresAt).getTime();\n }\n\n /**\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\n */\n public get neverExpires(): boolean {\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\n }\n\n /** Whether the token has been revoked. */\n public get isRevoked(): boolean {\n return !!this.get(\"revoked_at\");\n }\n\n /** Whether the token is still usable (not expired and not revoked). */\n public get isValid(): boolean {\n return !this.isExpired && !this.isRevoked;\n }\n\n /**\n * Unconditionally stamp `revoked_at` on this token.\n */\n public async revoke(): Promise<this> {\n return this.merge({ revoked_at: new Date() }).save();\n }\n\n /**\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\n * when this call performed the revoke, `false` when a concurrent request had\n * already revoked it — the win/lose signal that powers rotation replay\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\n * same token can never both succeed.\n */\n public async revokeIfActive(): Promise<boolean> {\n const modelClass = this.constructor as typeof RefreshToken;\n\n const revokedCount = await modelClass.atomic(\n { id: this.id, revoked_at: null },\n { $set: { revoked_at: new Date() } },\n );\n\n return revokedCount > 0;\n }\n\n /**\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\n */\n public async markAsUsed(): Promise<void> {\n await this.merge({ last_used_at: new Date() }).save();\n }\n\n /**\n * Persist a freshly-signed refresh token for the user.\n */\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\n return this.create({\n token,\n user_id: user.id,\n user_type: user.userType,\n family_id: options.familyId,\n expires_at: options.expiresAt,\n device_info: options.deviceInfo\n ? {\n userAgent: options.deviceInfo.userAgent,\n ip: options.deviceInfo.ip,\n deviceId: options.deviceInfo.deviceId,\n }\n : undefined,\n });\n }\n\n /**\n * Find a refresh-token row by its raw token string.\n */\n public static findByToken(token: string): Promise<RefreshToken | null> {\n return this.first({ token });\n }\n\n /**\n * Find a refresh token scoped to a user — used by logout so a caller can only\n * revoke a token that actually belongs to them.\n */\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\n return this.first({ token, user_id: user.id });\n }\n\n /** Find any member of a family, including an already-revoked legacy member. */\n public static findInFamily(familyId: string): Promise<RefreshToken | null> {\n return this.first({ family_id: familyId });\n }\n\n /**\n * Every refresh lineage for a user, including rotated rows. Logout uses this\n * to bootstrap durable family records before it starts family revocations.\n */\n public static familiesForUser(user: Auth): Promise<RefreshToken[]> {\n return this.query().where({ user_id: user.id, user_type: user.userType }).get();\n }\n\n /**\n * Delete a specific refresh token belonging to the user.\n */\n public static deleteForUser(user: Auth, token: string) {\n return this.delete({ token, user_id: user.id });\n }\n\n /**\n * Active, unexpired sessions for the user, newest first.\n */\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\n return this.activeQuery(user).orderBy(\"created_at\", \"desc\").get();\n }\n\n private static activeQuery(user: Auth) {\n return this.query()\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\n .where(\"expires_at\", \">\", new Date());\n }\n\n /**\n * Revoke every still-active refresh token for the user, returning the rows\n * that were revoked so the caller can emit a per-token event for each.\n *\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\n * `revoked_at: null` would re-query the same predicate after the update and\n * match nothing, returning an empty set.\n */\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\n const tokens = await this.query()\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\n .get();\n\n for (const token of tokens) {\n await token.revoke();\n }\n\n return tokens;\n }\n\n /**\n * Revoke every still-active token in a family (rotation breach containment),\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\n */\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\n\n for (const token of tokens) {\n await token.revoke();\n }\n\n return tokens;\n }\n\n /**\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\n * small.\n */\n public static async enforceMax(user: Auth, max: number): Promise<void> {\n const activeTokens = await this.activeQuery(user).orderBy(\"created_at\", \"asc\").get();\n\n if (activeTokens.length < max) return;\n\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\n\n for (const token of tokensToRevoke) {\n await token.revoke();\n }\n }\n\n /**\n * Hard-delete every expired refresh token, returning the deleted rows so the\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\n * (a cold batch path).\n */\n public static async purgeExpired(): Promise<RefreshToken[]> {\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\n\n for (const token of expiredTokens) {\n await token.destroy();\n }\n\n return expiredTokens;\n }\n\n /**\n * Every row that can never retire itself — see {@link neverExpires}. A full\n * scan filtered in memory, for the reason given on\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\n * missing `exp` claim is expressible as a `where`.\n */\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\n const tokens = await this.query().get();\n\n return tokens.filter((token: RefreshToken) => token.neverExpires);\n }\n\n /**\n * Hard-delete every never-expiring row, returning the rows removed.\n *\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\n * table has to carry, and one of the two shapes being removed is a row whose\n * date column cannot be compared at all — leaving it in place keeps a\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\n * these rows can no longer be presented successfully either way.\n */\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\n const tokens = await this.findNeverExpiring();\n\n for (const token of tokens) {\n await token.destroy();\n }\n\n return tokens;\n }\n}\n"],"mappings":"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"}
|
|
@@ -4,7 +4,11 @@ import { AuthProvider, GoogleProviderConfig, ProviderAuthorizationState, Provide
|
|
|
4
4
|
export declare const GOOGLE_AUTH_FEATURE = "auth-google";
|
|
5
5
|
/** The slice of `jose` the Google provider uses — declared locally because `jose` is an optional peer. */
|
|
6
6
|
export type JoseModule = {
|
|
7
|
-
|
|
7
|
+
customFetch: symbol;
|
|
8
|
+
createRemoteJWKSet: (url: URL, options?: {
|
|
9
|
+
timeoutDuration: number;
|
|
10
|
+
[key: symbol]: unknown;
|
|
11
|
+
}) => unknown;
|
|
8
12
|
jwtVerify: (token: string, key: unknown, options: {
|
|
9
13
|
issuer: string[];
|
|
10
14
|
audience: string;
|
|
@@ -23,6 +27,8 @@ export declare function pkceChallenge(codeVerifier: string): string;
|
|
|
23
27
|
export declare class GoogleProvider implements AuthProvider {
|
|
24
28
|
private readonly settings;
|
|
25
29
|
readonly name = "google";
|
|
30
|
+
private keySet;
|
|
31
|
+
private readonly timeoutMs;
|
|
26
32
|
constructor(settings: GoogleProviderConfig);
|
|
27
33
|
authorizationUrl(state: ProviderAuthorizationState): string;
|
|
28
34
|
handleCallback({ query, expected }: ProviderCallbackParams): Promise<ProviderProfile>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-provider.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/providers/google-provider.ts"],"mappings":";;;qBAkBa;;YAGD;EACV,
|
|
1
|
+
{"version":3,"file":"google-provider.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/providers/google-provider.ts"],"mappings":";;;qBAkBa;;YAGD;EACV;EACA,qBACE,KAAK,KACL;IAAY;KAA0B;;EAExC,YACE,eACA,cACA;IAAW;IAAkB;QAC1B;IAAU,SAAS;;;;wBAIV,cAAc;;;;;;;qBAUjB,0BAA0B;mBAMD;WALpB;UAER;mBACS;EAEjB,YAAoC,UAAU;EAavC,iBAAiB,OAAO;EAelB,iBACX,OACA,YACC,yBAAyB,QAAQ;;UAkCtB;;UAsCA"}
|
|
@@ -14,8 +14,6 @@ const DEFAULT_SCOPES = [
|
|
|
14
14
|
];
|
|
15
15
|
/** The `warlock add` feature that installs `jose`. */
|
|
16
16
|
const GOOGLE_AUTH_FEATURE = "auth-google";
|
|
17
|
-
/** Google's key set, created once per process; `jose` caches the fetched JWKS inside it. */
|
|
18
|
-
let googleKeySet;
|
|
19
17
|
/** PKCE S256 code challenge for a verifier. */
|
|
20
18
|
function pkceChallenge(codeVerifier) {
|
|
21
19
|
return createHash("sha256").update(codeVerifier).digest("base64url");
|
|
@@ -30,9 +28,11 @@ var GoogleProvider = class {
|
|
|
30
28
|
constructor(settings) {
|
|
31
29
|
this.settings = settings;
|
|
32
30
|
this.name = "google";
|
|
31
|
+
this.timeoutMs = settings.timeoutMs ?? 5e3;
|
|
32
|
+
if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2147483647) throw new Error("Google provider timeoutMs must be a positive integer no greater than 2147483647");
|
|
33
33
|
}
|
|
34
34
|
authorizationUrl(state) {
|
|
35
|
-
const url = new URL(AUTHORIZATION_ENDPOINT);
|
|
35
|
+
const url = new URL(this.settings.endpoints?.authorization ?? AUTHORIZATION_ENDPOINT);
|
|
36
36
|
url.searchParams.set("client_id", this.settings.clientId);
|
|
37
37
|
url.searchParams.set("redirect_uri", this.settings.redirectUri);
|
|
38
38
|
url.searchParams.set("response_type", "code");
|
|
@@ -65,7 +65,8 @@ var GoogleProvider = class {
|
|
|
65
65
|
async exchangeCode(code, codeVerifier) {
|
|
66
66
|
let body;
|
|
67
67
|
try {
|
|
68
|
-
const response = await fetch(TOKEN_ENDPOINT, {
|
|
68
|
+
const response = await (this.settings.fetch ?? globalThis.fetch)(this.settings.endpoints?.token ?? TOKEN_ENDPOINT, {
|
|
69
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
69
70
|
method: "POST",
|
|
70
71
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
71
72
|
body: new URLSearchParams({
|
|
@@ -87,9 +88,12 @@ var GoogleProvider = class {
|
|
|
87
88
|
}
|
|
88
89
|
/** Verify signature, issuer, audience and expiry; resolve the claims. */
|
|
89
90
|
async verifyIdToken(jose, idToken) {
|
|
90
|
-
googleKeySet ??= jose.createRemoteJWKSet(new URL(JWKS_URI));
|
|
91
91
|
try {
|
|
92
|
-
|
|
92
|
+
this.keySet ??= jose.createRemoteJWKSet(new URL(this.settings.endpoints?.jwks ?? JWKS_URI), {
|
|
93
|
+
timeoutDuration: this.timeoutMs,
|
|
94
|
+
[jose.customFetch]: (url, options) => (this.settings.fetch ?? globalThis.fetch)(url, options)
|
|
95
|
+
});
|
|
96
|
+
const { payload } = await jose.jwtVerify(idToken, this.keySet, {
|
|
93
97
|
issuer: ISSUERS,
|
|
94
98
|
audience: this.settings.clientId
|
|
95
99
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-provider.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/providers/google-provider.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type {\n AuthProvider,\n GoogleProviderConfig,\n ProviderAuthorizationState,\n ProviderCallbackParams,\n ProviderProfile,\n} from \"../contracts/providers\";\nimport { InvalidProviderCallbackError } from \"../errors/invalid-provider-callback.error\";\nimport { loadOptionalPeer } from \"../services/optional-peer\";\n\nconst AUTHORIZATION_ENDPOINT = \"https://accounts.google.com/o/oauth2/v2/auth\";\nconst TOKEN_ENDPOINT = \"https://oauth2.googleapis.com/token\";\nconst JWKS_URI = \"https://www.googleapis.com/oauth2/v3/certs\";\nconst ISSUERS = [\"https://accounts.google.com\", \"accounts.google.com\"];\nconst DEFAULT_SCOPES = [\"openid\", \"email\", \"profile\"];\n\n/** The `warlock add` feature that installs `jose`. */\nexport const GOOGLE_AUTH_FEATURE = \"auth-google\";\n\n/** The slice of `jose` the Google provider uses — declared locally because `jose` is an optional peer. */\nexport type JoseModule = {\n createRemoteJWKSet: (url: URL) => unknown;\n jwtVerify: (\n token: string,\n key: unknown,\n options: { issuer: string[]; audience: string },\n ) => Promise<{ payload: Record<string, unknown> }>;\n};\n\n/**
|
|
1
|
+
{"version":3,"file":"google-provider.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/providers/google-provider.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type {\n AuthProvider,\n GoogleProviderConfig,\n ProviderAuthorizationState,\n ProviderCallbackParams,\n ProviderProfile,\n} from \"../contracts/providers\";\nimport { InvalidProviderCallbackError } from \"../errors/invalid-provider-callback.error\";\nimport { loadOptionalPeer } from \"../services/optional-peer\";\n\nconst AUTHORIZATION_ENDPOINT = \"https://accounts.google.com/o/oauth2/v2/auth\";\nconst TOKEN_ENDPOINT = \"https://oauth2.googleapis.com/token\";\nconst JWKS_URI = \"https://www.googleapis.com/oauth2/v3/certs\";\nconst ISSUERS = [\"https://accounts.google.com\", \"accounts.google.com\"];\nconst DEFAULT_SCOPES = [\"openid\", \"email\", \"profile\"];\n\n/** The `warlock add` feature that installs `jose`. */\nexport const GOOGLE_AUTH_FEATURE = \"auth-google\";\n\n/** The slice of `jose` the Google provider uses — declared locally because `jose` is an optional peer. */\nexport type JoseModule = {\n customFetch: symbol;\n createRemoteJWKSet: (\n url: URL,\n options?: { timeoutDuration: number; [key: symbol]: unknown },\n ) => unknown;\n jwtVerify: (\n token: string,\n key: unknown,\n options: { issuer: string[]; audience: string },\n ) => Promise<{ payload: Record<string, unknown> }>;\n};\n\n/** PKCE S256 code challenge for a verifier. */\nexport function pkceChallenge(codeVerifier: string): string {\n return createHash(\"sha256\").update(codeVerifier).digest(\"base64url\");\n}\n\n/**\n * Google sign-in: OpenID Connect authorization code flow with PKCE (S256),\n * `state` and `nonce`. The code is exchanged with plain `fetch`; the id_token\n * is verified with `jose` against Google's JWKS (signature, `iss`, `aud` = the\n * client id, `exp`), then its `nonce` is compared. No Google SDK.\n */\nexport class GoogleProvider implements AuthProvider {\n public readonly name = \"google\";\n\n private keySet: unknown;\n private readonly timeoutMs: number;\n\n public constructor(private readonly settings: GoogleProviderConfig) {\n this.timeoutMs = settings.timeoutMs ?? 5000;\n if (\n !Number.isSafeInteger(this.timeoutMs) ||\n this.timeoutMs <= 0 ||\n this.timeoutMs > 2_147_483_647\n ) {\n throw new Error(\n \"Google provider timeoutMs must be a positive integer no greater than 2147483647\",\n );\n }\n }\n\n public authorizationUrl(state: ProviderAuthorizationState): string {\n const url = new URL(this.settings.endpoints?.authorization ?? AUTHORIZATION_ENDPOINT);\n\n url.searchParams.set(\"client_id\", this.settings.clientId);\n url.searchParams.set(\"redirect_uri\", this.settings.redirectUri);\n url.searchParams.set(\"response_type\", \"code\");\n url.searchParams.set(\"scope\", (this.settings.scopes ?? DEFAULT_SCOPES).join(\" \"));\n url.searchParams.set(\"state\", state.state);\n url.searchParams.set(\"nonce\", state.nonce);\n url.searchParams.set(\"code_challenge\", pkceChallenge(state.codeVerifier));\n url.searchParams.set(\"code_challenge_method\", \"S256\");\n\n return url.toString();\n }\n\n public async handleCallback({\n query,\n expected,\n }: ProviderCallbackParams): Promise<ProviderProfile> {\n // Load the peer first: a missing SDK fails loudly before any network call.\n const jose = await loadOptionalPeer<JoseModule>(\"jose\", GOOGLE_AUTH_FEATURE);\n\n const code = query.code;\n\n if (typeof code !== \"string\" || code.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-code\");\n }\n\n const idToken = await this.exchangeCode(code, expected.codeVerifier);\n const claims = await this.verifyIdToken(jose, idToken);\n\n if (typeof claims.nonce !== \"string\" || claims.nonce !== expected.nonce) {\n throw new InvalidProviderCallbackError(\"nonce-mismatch\");\n }\n\n if (typeof claims.sub !== \"string\" || claims.sub.length === 0) {\n throw new InvalidProviderCallbackError(\"missing-subject\");\n }\n\n return {\n provider: this.name,\n providerUserId: claims.sub,\n email: typeof claims.email === \"string\" ? claims.email : undefined,\n // Google sends a boolean; anything else counts as NOT verified.\n emailVerified: claims.email_verified === true,\n name: typeof claims.name === \"string\" ? claims.name : undefined,\n avatar: typeof claims.picture === \"string\" ? claims.picture : undefined,\n raw: claims,\n };\n }\n\n /** Redeem the authorization code (with the PKCE verifier) for an id_token. */\n private async exchangeCode(code: string, codeVerifier: string): Promise<string> {\n let body: { id_token?: unknown };\n\n try {\n const response = await (this.settings.fetch ?? globalThis.fetch)(\n this.settings.endpoints?.token ?? TOKEN_ENDPOINT,\n {\n signal: AbortSignal.timeout(this.timeoutMs),\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n code,\n client_id: this.settings.clientId,\n client_secret: this.settings.clientSecret,\n redirect_uri: this.settings.redirectUri,\n grant_type: \"authorization_code\",\n code_verifier: codeVerifier,\n }),\n },\n );\n\n if (!response.ok) {\n throw new Error(`Google token endpoint answered ${response.status}`);\n }\n\n body = (await response.json()) as { id_token?: unknown };\n } catch (error) {\n throw new InvalidProviderCallbackError(\"code-exchange-failed\", error);\n }\n\n if (typeof body.id_token !== \"string\") {\n throw new InvalidProviderCallbackError(\"missing-id-token\");\n }\n\n return body.id_token;\n }\n\n /** Verify signature, issuer, audience and expiry; resolve the claims. */\n private async verifyIdToken(jose: JoseModule, idToken: string): Promise<Record<string, unknown>> {\n try {\n // Keep caches isolated when providers use different transports or endpoints.\n this.keySet ??= jose.createRemoteJWKSet(new URL(this.settings.endpoints?.jwks ?? JWKS_URI), {\n timeoutDuration: this.timeoutMs,\n [jose.customFetch]: (url: string, options: RequestInit) =>\n (this.settings.fetch ?? globalThis.fetch)(url, options),\n });\n const { payload } = await jose.jwtVerify(idToken, this.keySet, {\n issuer: ISSUERS,\n audience: this.settings.clientId,\n });\n\n return payload;\n } catch (error) {\n throw new InvalidProviderCallbackError(\"invalid-id-token\", error);\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-request-user.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/services/resolve-request-user.ts"],"mappings":";;;YAgBY;;EAEV,YAAY;;EAEZ,oBAAoB;;EAEpB;;EAEA;;EAEA;;EAEA;;EAEA,eAAe;;;;;;YAOL;YAEA,mBAAmB;EACzB,MAAM;EAAO;;EACb;EAAY,SAAS;;;wBAoBX,eAAe,SAAS,SAAS,WAAW;;;;;;;;;;;;wBAqBtC,0BACpB,SAAS,SACT,UAAU,UACV,UAAS,4BACR,QAAQ;;;;;;;;
|
|
1
|
+
{"version":3,"file":"resolve-request-user.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/services/resolve-request-user.ts"],"mappings":";;;YAgBY;;EAEV,YAAY;;EAEZ,oBAAoB;;EAEpB;;EAEA;;EAEA;;EAEA;;EAEA,eAAe;;;;;;YAOL;YAEA,mBAAmB;EACzB,MAAM;EAAO;;EACb;EAAY,SAAS;;;wBAoBX,eAAe,SAAS,SAAS,WAAW;;;;;;;;;;;;wBAqBtC,0BACpB,SAAS,SACT,UAAU,UACV,UAAS,4BACR,QAAQ;;;;;;;;wBAoHK,mBACd,SAAS,SACT,UAAU,UACV,UAAS,4BACR"}
|
|
@@ -46,11 +46,12 @@ async function resolveRequestUserOutcome(request, response, options = {}) {
|
|
|
46
46
|
let authorizationValue = readCredential(request, tokenFrom);
|
|
47
47
|
let renewalAttempted = false;
|
|
48
48
|
const renew = async () => {
|
|
49
|
-
|
|
49
|
+
const { renewalUserType } = options;
|
|
50
|
+
if (!refreshCredential || !renewalUserType || renewalAttempted) return false;
|
|
50
51
|
renewalAttempted = true;
|
|
51
52
|
const refreshToken = readCredential(request, refreshCredential);
|
|
52
53
|
if (!refreshToken) return false;
|
|
53
|
-
const pair = await authService.renewAutomaticSession(refreshToken,
|
|
54
|
+
const pair = await authService.renewAutomaticSession(refreshToken, renewalUserType, {
|
|
54
55
|
overlapMs: options.overlapMs,
|
|
55
56
|
maxAgeMs: options.maxAgeMs
|
|
56
57
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-request-user.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/services/resolve-request-user.ts"],"sourcesContent":["import { config, type Request, type Response } from \"@warlock.js/core\";\nimport { log } from \"@warlock.js/logger\";\nimport type { CookieDomainResolver, TokenFrom } from \"../contracts/types\";\nimport { AccessToken } from \"../models/access-token\";\nimport { authService } from \"./auth.service\";\nimport { isInvalidCredentialError, jwt } from \"./jwt\";\n\n/**\n * Decoded access-token claims the resolver reads. The full payload carries\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\n */\ntype DecodedAccessToken = {\n id: string | number;\n userType?: string;\n};\n\nexport type ResolveRequestUserOptions = {\n /** Where the access token is read from. Defaults to `\"header\"`. */\n tokenFrom?: TokenFrom;\n /** Refresh credential source; enables one automatic renewal per call. */\n refreshCredential?: TokenFrom;\n /** Refresh overlap window forwarded to `authService.renewAutomaticSession`. */\n overlapMs?: number;\n /** User types allowed to pass. Empty means any authenticated type. */\n allowedTypes?: string[];\n /** Absolute family lifetime in ms; rotation never extends a family past it. */\n maxAgeMs?: number;\n /** The single user type a renewal mints for. Required for renewal. */\n renewalUserType?: string;\n /** Select a request-scoped Domain for renewed cookies. */\n cookieDomain?: CookieDomainResolver;\n};\n\n/**\n * Why a request did not resolve to a user. The middleware maps each reason to\n * its own response; `resolveRequestUser` collapses them all to `null`.\n */\nexport type RequestUserFailure = \"missing\" | \"invalid\" | \"forbidden\" | \"unauthorized\";\n\nexport type RequestUserOutcome<TUser = any> =\n | { user: TUser; failure?: undefined }\n | { user: null; failure: RequestUserFailure };\n\n/**\n * Whether the persisted row says the token is dead.\n *\n * The answer belongs to the model (`AccessToken.isExpired`), so a registered\n * override that renames or reshapes its expiry column stays authoritative. A\n * row that cannot answer at all — an override that dropped the getter — is\n * treated as **expired**: a check must never quietly answer \"fine\" when it has\n * nothing to check.\n *\n * This is independent of the `exp` claim required by `jwt.verify`: that guard\n * catches a token whose *claims* carry no deadline; this one catches a token\n * whose *row* says the deadline has passed.\n */\nfunction accessTokenRowIsExpired(accessToken: AccessToken): boolean {\n return typeof accessToken.isExpired === \"boolean\" ? accessToken.isExpired : true;\n}\n\n/** Read one credential (access or refresh) from the header or a cookie. */\nexport function readCredential(request: Request, tokenFrom: TokenFrom): string {\n if (tokenFrom === \"header\") {\n return request.authorizationValue;\n }\n\n const value = request.cookie(tokenFrom.slice(\"cookie:\".length));\n\n return value ? String(value) : \"\";\n}\n\n/**\n * Resolve a request's credential to a user, reporting WHY it failed.\n *\n * Side effects match what `authMiddleware` always did: `request.locals.user` is\n * cleared on every failure, `request.decodedAccessToken` is set once a token\n * verifies, and stale rows are destroyed. It does NOT set `request.locals.user`\n * on success — the caller decides that.\n *\n * At most one renewal is attempted per call. The renewed access token is\n * checked through the same path; no handler or unsafe request replay happens.\n */\nexport async function resolveRequestUserOutcome(\n request: Request,\n response: Response,\n options: ResolveRequestUserOptions = {},\n): Promise<RequestUserOutcome> {\n const tokenFrom = options.tokenFrom ?? \"header\";\n const allowedTypes = options.allowedTypes ?? [];\n const { refreshCredential } = options;\n let authorizationValue = readCredential(request, tokenFrom);\n let renewalAttempted = false;\n\n const renew = async (): Promise<boolean> => {\n if (!refreshCredential || renewalAttempted) return false;\n\n renewalAttempted = true;\n const refreshToken = readCredential(request, refreshCredential);\n if (!refreshToken) return false;\n\n const pair = await authService.renewAutomaticSession(refreshToken, options.renewalUserType!, {\n overlapMs: options.overlapMs,\n maxAgeMs: options.maxAgeMs,\n });\n if (!pair?.refreshToken) return false;\n\n authService.setAuthCookie(response, pair.accessToken, {\n name: tokenFrom.slice(\"cookie:\".length),\n domain: options.cookieDomain?.(request),\n });\n authService.setAuthCookie(response, pair.refreshToken, {\n name: refreshCredential.slice(\"cookie:\".length),\n domain: options.cookieDomain?.(request),\n });\n authorizationValue = pair.accessToken.token;\n\n return true;\n };\n\n const fail = (failure: RequestUserFailure): RequestUserOutcome => {\n request.locals.user = undefined;\n\n return { user: null, failure };\n };\n\n let decoded: DecodedAccessToken;\n let accessToken: AccessToken | null;\n\n while (true) {\n if (!authorizationValue) {\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"missing\");\n }\n\n try {\n decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\n } catch (error) {\n if (!isInvalidCredentialError(error)) throw error;\n log.error(\"http\", \"auth\", error);\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"invalid\");\n }\n\n request.decodedAccessToken = decoded;\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\n accessToken = await AccessTokenModel.findByToken(authorizationValue);\n\n if (!accessToken) {\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"invalid\");\n }\n\n if (accessTokenRowIsExpired(accessToken)) {\n await accessToken.destroy();\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"invalid\");\n }\n\n break;\n }\n\n const userType = decoded.userType ?? accessToken.userType;\n\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\n return { user: null, failure: \"forbidden\" };\n }\n\n const UserModel = config.key(`auth.userType.${userType}`);\n\n if (!UserModel) {\n // Configuration, not credentials. Throwing keeps a mis-registered app\n // loudly broken instead of quietly rejecting every request of this type.\n throw new Error(`User type ${userType} is unknown type.`);\n }\n\n const currentUser = await UserModel.find(decoded.id);\n\n if (!currentUser) {\n await accessToken.destroy();\n\n return fail(\"invalid\");\n }\n\n if (!(await authService.canAuthenticate(currentUser))) {\n return fail(\"unauthorized\");\n }\n\n return { user: currentUser };\n}\n\n/**\n * Resolve the request's credential to its user model, or `null` when there is\n * none (missing, invalid, expired, wrong type, or not allowed to authenticate).\n *\n * Includes the middleware's automatic renewal when `options.refreshCredential`\n * is set. Use `resolveRequestUserOutcome` when the failure reason matters.\n */\nexport function resolveRequestUser(\n request: Request,\n response: Response,\n options: ResolveRequestUserOptions = {},\n): Promise<any> {\n // Single-flight: the PROMISE is stored, so concurrent and repeat callers in\n // one request share one verify, one lookup and at most one renewal. The first\n // caller's options win for the rest of the request.\n if (!request.locals.session) {\n request.locals.session = resolveRequestUserOutcome(request, response, options).then(\n (outcome) => outcome.user,\n );\n }\n\n return request.locals.session;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"resolve-request-user.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/services/resolve-request-user.ts"],"sourcesContent":["import { config, type Request, type Response } from \"@warlock.js/core\";\nimport { log } from \"@warlock.js/logger\";\nimport type { CookieDomainResolver, TokenFrom } from \"../contracts/types\";\nimport { AccessToken } from \"../models/access-token\";\nimport { authService } from \"./auth.service\";\nimport { isInvalidCredentialError, jwt } from \"./jwt\";\n\n/**\n * Decoded access-token claims the resolver reads. The full payload carries\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\n */\ntype DecodedAccessToken = {\n id: string | number;\n userType?: string;\n};\n\nexport type ResolveRequestUserOptions = {\n /** Where the access token is read from. Defaults to `\"header\"`. */\n tokenFrom?: TokenFrom;\n /** Refresh credential source; enables one automatic renewal per call. */\n refreshCredential?: TokenFrom;\n /** Refresh overlap window forwarded to `authService.renewAutomaticSession`. */\n overlapMs?: number;\n /** User types allowed to pass. Empty means any authenticated type. */\n allowedTypes?: string[];\n /** Absolute family lifetime in ms; rotation never extends a family past it. */\n maxAgeMs?: number;\n /** The single user type a renewal mints for. Required for renewal. */\n renewalUserType?: string;\n /** Select a request-scoped Domain for renewed cookies. */\n cookieDomain?: CookieDomainResolver;\n};\n\n/**\n * Why a request did not resolve to a user. The middleware maps each reason to\n * its own response; `resolveRequestUser` collapses them all to `null`.\n */\nexport type RequestUserFailure = \"missing\" | \"invalid\" | \"forbidden\" | \"unauthorized\";\n\nexport type RequestUserOutcome<TUser = any> =\n | { user: TUser; failure?: undefined }\n | { user: null; failure: RequestUserFailure };\n\n/**\n * Whether the persisted row says the token is dead.\n *\n * The answer belongs to the model (`AccessToken.isExpired`), so a registered\n * override that renames or reshapes its expiry column stays authoritative. A\n * row that cannot answer at all — an override that dropped the getter — is\n * treated as **expired**: a check must never quietly answer \"fine\" when it has\n * nothing to check.\n *\n * This is independent of the `exp` claim required by `jwt.verify`: that guard\n * catches a token whose *claims* carry no deadline; this one catches a token\n * whose *row* says the deadline has passed.\n */\nfunction accessTokenRowIsExpired(accessToken: AccessToken): boolean {\n return typeof accessToken.isExpired === \"boolean\" ? accessToken.isExpired : true;\n}\n\n/** Read one credential (access or refresh) from the header or a cookie. */\nexport function readCredential(request: Request, tokenFrom: TokenFrom): string {\n if (tokenFrom === \"header\") {\n return request.authorizationValue;\n }\n\n const value = request.cookie(tokenFrom.slice(\"cookie:\".length));\n\n return value ? String(value) : \"\";\n}\n\n/**\n * Resolve a request's credential to a user, reporting WHY it failed.\n *\n * Side effects match what `authMiddleware` always did: `request.locals.user` is\n * cleared on every failure, `request.decodedAccessToken` is set once a token\n * verifies, and stale rows are destroyed. It does NOT set `request.locals.user`\n * on success — the caller decides that.\n *\n * At most one renewal is attempted per call. The renewed access token is\n * checked through the same path; no handler or unsafe request replay happens.\n */\nexport async function resolveRequestUserOutcome(\n request: Request,\n response: Response,\n options: ResolveRequestUserOptions = {},\n): Promise<RequestUserOutcome> {\n const tokenFrom = options.tokenFrom ?? \"header\";\n const allowedTypes = options.allowedTypes ?? [];\n const { refreshCredential } = options;\n let authorizationValue = readCredential(request, tokenFrom);\n let renewalAttempted = false;\n\n const renew = async (): Promise<boolean> => {\n const { renewalUserType } = options;\n if (!refreshCredential || !renewalUserType || renewalAttempted) return false;\n\n renewalAttempted = true;\n const refreshToken = readCredential(request, refreshCredential);\n if (!refreshToken) return false;\n\n const pair = await authService.renewAutomaticSession(refreshToken, renewalUserType, {\n overlapMs: options.overlapMs,\n maxAgeMs: options.maxAgeMs,\n });\n if (!pair?.refreshToken) return false;\n\n authService.setAuthCookie(response, pair.accessToken, {\n name: tokenFrom.slice(\"cookie:\".length),\n domain: options.cookieDomain?.(request),\n });\n authService.setAuthCookie(response, pair.refreshToken, {\n name: refreshCredential.slice(\"cookie:\".length),\n domain: options.cookieDomain?.(request),\n });\n authorizationValue = pair.accessToken.token;\n\n return true;\n };\n\n const fail = (failure: RequestUserFailure): RequestUserOutcome => {\n request.locals.user = undefined;\n\n return { user: null, failure };\n };\n\n let decoded: DecodedAccessToken;\n let accessToken: AccessToken | null;\n\n while (true) {\n if (!authorizationValue) {\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"missing\");\n }\n\n try {\n decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\n } catch (error) {\n if (!isInvalidCredentialError(error)) throw error;\n log.error(\"http\", \"auth\", error);\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"invalid\");\n }\n\n request.decodedAccessToken = decoded;\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\n accessToken = await AccessTokenModel.findByToken(authorizationValue);\n\n if (!accessToken) {\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"invalid\");\n }\n\n if (accessTokenRowIsExpired(accessToken)) {\n await accessToken.destroy();\n request.locals.user = undefined;\n if (await renew()) continue;\n return fail(\"invalid\");\n }\n\n break;\n }\n\n const userType = decoded.userType ?? accessToken.userType;\n\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\n return { user: null, failure: \"forbidden\" };\n }\n\n const UserModel = config.key(`auth.userType.${userType}`);\n\n if (!UserModel) {\n // Configuration, not credentials. Throwing keeps a mis-registered app\n // loudly broken instead of quietly rejecting every request of this type.\n throw new Error(`User type ${userType} is unknown type.`);\n }\n\n const currentUser = await UserModel.find(decoded.id);\n\n if (!currentUser) {\n await accessToken.destroy();\n\n return fail(\"invalid\");\n }\n\n if (!(await authService.canAuthenticate(currentUser))) {\n return fail(\"unauthorized\");\n }\n\n return { user: currentUser };\n}\n\n/**\n * Resolve the request's credential to its user model, or `null` when there is\n * none (missing, invalid, expired, wrong type, or not allowed to authenticate).\n *\n * Includes the middleware's automatic renewal when `options.refreshCredential`\n * is set. Use `resolveRequestUserOutcome` when the failure reason matters.\n */\nexport function resolveRequestUser(\n request: Request,\n response: Response,\n options: ResolveRequestUserOptions = {},\n): Promise<any> {\n // Single-flight: the PROMISE is stored, so concurrent and repeat callers in\n // one request share one verify, one lookup and at most one renewal. The first\n // caller's options win for the rest of the request.\n if (!request.locals.session) {\n request.locals.session = resolveRequestUserOutcome(request, response, options).then(\n (outcome) => outcome.user,\n );\n }\n\n return request.locals.session;\n}\n"],"mappings":"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"}
|
|
@@ -52,19 +52,23 @@ async function runTokenFamilyOperation(familyId, operation) {
|
|
|
52
52
|
try {
|
|
53
53
|
result = await operationEffects.run(effects, () => transaction(async () => {
|
|
54
54
|
const family = await AuthTokenFamily.findByFamilyId(familyId);
|
|
55
|
-
if (!family || family.isRevoked)
|
|
55
|
+
if (!family || family.isRevoked) return { type: "unavailable" };
|
|
56
56
|
const revision = family.get("revision");
|
|
57
57
|
if (await AuthTokenFamily.advanceRevision(familyId, revision) !== 1) throw new TokenFamilyConflictError();
|
|
58
|
-
return
|
|
58
|
+
return {
|
|
59
|
+
type: "available",
|
|
60
|
+
value: await operation(family)
|
|
61
|
+
};
|
|
59
62
|
}, { isolationLevel: "SERIALIZABLE" }));
|
|
60
63
|
} catch (error) {
|
|
61
64
|
lastError = error;
|
|
62
65
|
if (joinsExistingTransaction || !isRetryableTransactionError(error) && !(error instanceof TokenFamilyConflictError)) throw error;
|
|
63
66
|
continue;
|
|
64
67
|
}
|
|
68
|
+
if (result.type === "unavailable") throw new TokenFamilyUnavailableError();
|
|
65
69
|
if (parentEffects) parentEffects.push(...effects);
|
|
66
70
|
else for (const effect of effects) effect();
|
|
67
|
-
return result;
|
|
71
|
+
return result.value;
|
|
68
72
|
}
|
|
69
73
|
throw lastError;
|
|
70
74
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token-family-operation.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/services/token-family-operation.ts"],"sourcesContent":["import { databaseTransactionContext, transaction } from \"@warlock.js/cascade\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\nimport { AuthTokenFamily } from \"../models/auth-token-family\";\n\nconst operationEffects = new AsyncLocalStorage<Array<() => void>>();\n\n/** Defer events until this coordinator's owned transaction commits. */\nexport function afterTokenFamilyOperation(effect: () => void): void {\n const effects = operationEffects.getStore();\n if (effects) effects.push(effect);\n else effect();\n}\n\n/** A CAS miss is retried only when this helper owns the transaction boundary. */\nclass TokenFamilyConflictError extends Error {\n public constructor() {\n super(\"Token family changed concurrently\");\n this.name = \"TokenFamilyConflictError\";\n }\n}\n\n/** A missing or revoked family cannot become usable through a retry. */\nexport class TokenFamilyUnavailableError extends Error {\n public constructor() {\n super(\"Token family is unavailable\");\n this.name = \"TokenFamilyUnavailableError\";\n }\n}\n\ntype TransactionError = {\n code?: unknown;\n hasErrorLabel?: (label: string) => boolean;\n};\n\nconst MAX_OWNED_TRANSACTION_ATTEMPTS = 3;\n\nfunction isRetryableTransactionError(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n\n const transactionError = error as Error & TransactionError;\n\n return (\n transactionError.code === \"40001\" ||\n transactionError.hasErrorLabel?.(\"TransientTransactionError\") === true\n );\n}\n\n/**\n * Perform one family state transition in a transaction and advance its durable\n * revision with a conditional update. An existing Cascade transaction is\n * joined, never retried here: PostgreSQL marks an outer transaction aborted on\n * a serialization error, so only that transaction's owner can safely retry.\n *\n * The finite internal cap prevents request handlers from retrying forever.\n * Transaction setup failures are deliberately propagated; this auth boundary\n * has no non-transactional fallback.\n */\nexport async function runTokenFamilyOperation<T>(\n familyId: string,\n operation: (family: AuthTokenFamily) => Promise<T>,\n): Promise<T> {\n const joinsExistingTransaction = databaseTransactionContext.hasActiveTransaction();\n const attempts = joinsExistingTransaction ? 1 : MAX_OWNED_TRANSACTION_ATTEMPTS;\n let lastError: unknown;\n\n for (let attempt = 0; attempt < attempts; attempt++) {\n const parentEffects = operationEffects.getStore();\n const effects: Array<() => void> = [];\n let result: T;\n try {\n result = await operationEffects.run(effects, () =>\n transaction(\n async () => {\n const family = await AuthTokenFamily.findByFamilyId(familyId);\n\n if (!family || family.isRevoked) {\n
|
|
1
|
+
{"version":3,"file":"token-family-operation.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/services/token-family-operation.ts"],"sourcesContent":["import { databaseTransactionContext, transaction } from \"@warlock.js/cascade\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\nimport { AuthTokenFamily } from \"../models/auth-token-family\";\n\nconst operationEffects = new AsyncLocalStorage<Array<() => void>>();\n\n/** Defer events until this coordinator's owned transaction commits. */\nexport function afterTokenFamilyOperation(effect: () => void): void {\n const effects = operationEffects.getStore();\n if (effects) effects.push(effect);\n else effect();\n}\n\n/** A CAS miss is retried only when this helper owns the transaction boundary. */\nclass TokenFamilyConflictError extends Error {\n public constructor() {\n super(\"Token family changed concurrently\");\n this.name = \"TokenFamilyConflictError\";\n }\n}\n\n/** A missing or revoked family cannot become usable through a retry. */\nexport class TokenFamilyUnavailableError extends Error {\n public constructor() {\n super(\"Token family is unavailable\");\n this.name = \"TokenFamilyUnavailableError\";\n }\n}\n\ntype TransactionError = {\n code?: unknown;\n hasErrorLabel?: (label: string) => boolean;\n};\n\nconst MAX_OWNED_TRANSACTION_ATTEMPTS = 3;\n\nfunction isRetryableTransactionError(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n\n const transactionError = error as Error & TransactionError;\n\n return (\n transactionError.code === \"40001\" ||\n transactionError.hasErrorLabel?.(\"TransientTransactionError\") === true\n );\n}\n\n/**\n * Perform one family state transition in a transaction and advance its durable\n * revision with a conditional update. An existing Cascade transaction is\n * joined, never retried here: PostgreSQL marks an outer transaction aborted on\n * a serialization error, so only that transaction's owner can safely retry.\n *\n * The finite internal cap prevents request handlers from retrying forever.\n * Transaction setup failures are deliberately propagated; this auth boundary\n * has no non-transactional fallback.\n */\nexport async function runTokenFamilyOperation<T>(\n familyId: string,\n operation: (family: AuthTokenFamily) => Promise<T>,\n): Promise<T> {\n const joinsExistingTransaction = databaseTransactionContext.hasActiveTransaction();\n const attempts = joinsExistingTransaction ? 1 : MAX_OWNED_TRANSACTION_ATTEMPTS;\n let lastError: unknown;\n\n for (let attempt = 0; attempt < attempts; attempt++) {\n const parentEffects = operationEffects.getStore();\n const effects: Array<() => void> = [];\n let result: { type: \"unavailable\" } | { type: \"available\"; value: T };\n try {\n result = await operationEffects.run(effects, () =>\n transaction(\n async () => {\n const family = await AuthTokenFamily.findByFamilyId(familyId);\n\n if (!family || family.isRevoked) {\n return { type: \"unavailable\" as const };\n }\n\n const revision = family.get<number>(\"revision\");\n const advanced = await AuthTokenFamily.advanceRevision(familyId, revision);\n\n if (advanced !== 1) {\n throw new TokenFamilyConflictError();\n }\n\n return { type: \"available\" as const, value: await operation(family) };\n },\n { isolationLevel: \"SERIALIZABLE\" },\n ),\n );\n } catch (error) {\n lastError = error;\n\n if (\n joinsExistingTransaction ||\n (!isRetryableTransactionError(error) && !(error instanceof TokenFamilyConflictError))\n ) {\n throw error;\n }\n continue;\n }\n\n // No writes were made for an unavailable family. Reject outside the\n // transaction callback so normal credential rejection is not a DB failure.\n if (result.type === \"unavailable\") throw new TokenFamilyUnavailableError();\n\n // Listener failures must never retry a transaction that already committed.\n // Nested family operations join their parent's queue. An app-owned outer\n // transaction has no Cascade commit hook, so its existing event timing is\n // retained; its owner remains responsible for external side effects.\n if (parentEffects) parentEffects.push(...effects);\n else for (const effect of effects) effect();\n return result.value;\n }\n\n throw lastError;\n}\n\nexport { TokenFamilyConflictError };\n"],"mappings":";;;;;;AAIA,MAAM,mBAAmB,IAAI,kBAAqC;;AAGlE,SAAgB,0BAA0B,QAA0B;CAClE,MAAM,UAAU,iBAAiB,SAAS;CAC1C,IAAI,SAAS,QAAQ,KAAK,MAAM;MAC3B,OAAO;AACd;;AAGA,IAAM,2BAAN,cAAuC,MAAM;CAC3C,AAAO,cAAc;EACnB,MAAM,mCAAmC;EACzC,KAAK,OAAO;CACd;AACF;;AAGA,IAAa,8BAAb,cAAiD,MAAM;CACrD,AAAO,cAAc;EACnB,MAAM,6BAA6B;EACnC,KAAK,OAAO;CACd;AACF;AAOA,MAAM,iCAAiC;AAEvC,SAAS,4BAA4B,OAAyB;CAC5D,IAAI,EAAE,iBAAiB,QAAQ,OAAO;CAEtC,MAAM,mBAAmB;CAEzB,OACE,iBAAiB,SAAS,WAC1B,iBAAiB,gBAAgB,2BAA2B,MAAM;AAEtE;;;;;;;;;;;AAYA,eAAsB,wBACpB,UACA,WACY;CACZ,MAAM,2BAA2B,2BAA2B,qBAAqB;CACjF,MAAM,WAAW,2BAA2B,IAAI;CAChD,IAAI;CAEJ,KAAK,IAAI,UAAU,GAAG,UAAU,UAAU,WAAW;EACnD,MAAM,gBAAgB,iBAAiB,SAAS;EAChD,MAAM,UAA6B,CAAC;EACpC,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,iBAAiB,IAAI,eAClC,YACE,YAAY;IACV,MAAM,SAAS,MAAM,gBAAgB,eAAe,QAAQ;IAE5D,IAAI,CAAC,UAAU,OAAO,WACpB,OAAO,EAAE,MAAM,cAAuB;IAGxC,MAAM,WAAW,OAAO,IAAY,UAAU;IAG9C,IAAI,MAFmB,gBAAgB,gBAAgB,UAAU,QAAQ,MAExD,GACf,MAAM,IAAI,yBAAyB;IAGrC,OAAO;KAAE,MAAM;KAAsB,OAAO,MAAM,UAAU,MAAM;IAAE;GACtE,GACA,EAAE,gBAAgB,eAAe,CACnC,CACF;EACF,SAAS,OAAO;GACd,YAAY;GAEZ,IACE,4BACC,CAAC,4BAA4B,KAAK,KAAK,EAAE,iBAAiB,2BAE3D,MAAM;GAER;EACF;EAIA,IAAI,OAAO,SAAS,eAAe,MAAM,IAAI,4BAA4B;EAMzE,IAAI,eAAe,cAAc,KAAK,GAAG,OAAO;OAC3C,KAAK,MAAM,UAAU,SAAS,OAAO;EAC1C,OAAO,OAAO;CAChB;CAEA,MAAM;AACR"}
|
|
@@ -47,7 +47,7 @@ export type SessionMiddlewareOptions = SessionResolverOptions & {
|
|
|
47
47
|
* @example
|
|
48
48
|
* export default { session: pageSession({ project: (user: User) => userResource(user) }) };
|
|
49
49
|
*/
|
|
50
|
-
export declare function pageSession<TModel =
|
|
50
|
+
export declare function pageSession<TModel = unknown, TUser = unknown>(options: PageSessionOptions<TModel, TUser>): PageSessionResolver<TModel, TUser>;
|
|
51
51
|
/**
|
|
52
52
|
* Middleware for cookie API routes over the same resolver as `pageSession`.
|
|
53
53
|
* A cookie-authenticated unsafe request must pass the Origin/Referer check
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-session.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/session/page-session.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"page-session.d.mts","names":[],"sources":["../../../../../../../../packages/auth/src/session/page-session.ts"],"mappings":";;;;YAqBY;;;;;;YAUA,oBAAoB,kBAAkB;EAChD,QAAQ,SAAS,SAAS,UAAU,WAAW;IAAU,OAAO;IAAQ,MAAM;;;YAGpE;;EAEV;;EAEA;;EAEA,UAAU;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA,eAAe;;YAGL,mBAAmB,QAAQ,SAAS;;EAE9C,UAAU,OAAO,WAAW,QAAQ,QAAQ;;YAGlC,2BAA2B;;EAErC;;;;;;;;;wBAgFc,YAAY,kBAAkB,iBAC5C,SAAS,mBAAmB,QAAQ,SACnC,oBAAoB,QAAQ;;;;;;;wBAmCf,kBAAkB,UAAS,2BAAgC"}
|
|
@@ -2,7 +2,7 @@ import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
|
|
|
2
2
|
import { CsrfOriginMismatchError, assertCsrfOriginAllowed, requiresCsrfOriginCheck } from "../middleware/csrf-origin-check.mjs";
|
|
3
3
|
import { authConfig } from "../services/auth-config.mjs";
|
|
4
4
|
import { resolveRequestUser } from "../services/resolve-request-user.mjs";
|
|
5
|
-
import { config, t } from "@warlock.js/core";
|
|
5
|
+
import { config, markAuthGuard, t } from "@warlock.js/core";
|
|
6
6
|
import { log } from "@warlock.js/logger";
|
|
7
7
|
import ms from "ms";
|
|
8
8
|
|
|
@@ -92,7 +92,7 @@ function pageSession(options) {
|
|
|
92
92
|
function sessionMiddleware(options = {}) {
|
|
93
93
|
const resolveModel = createModelResolver(options);
|
|
94
94
|
const sources = options.sources?.length ? options.sources : ["cookie", "header"];
|
|
95
|
-
|
|
95
|
+
const middleware = async ({ request, response }) => {
|
|
96
96
|
if (!(sources.includes("header") && Boolean(request.authorizationValue)) && requiresCsrfOriginCheck("cookie:x", request.method)) {
|
|
97
97
|
const accessName = options.cookie ?? authConfig.cookie.name();
|
|
98
98
|
const refreshName = options.refreshCookie ?? authConfig.cookie.refreshName();
|
|
@@ -113,6 +113,15 @@ function sessionMiddleware(options = {}) {
|
|
|
113
113
|
errorCode: "EC003"
|
|
114
114
|
});
|
|
115
115
|
};
|
|
116
|
+
Object.defineProperty(middleware, Symbol.for("warlock.auth"), {
|
|
117
|
+
value: Object.freeze({
|
|
118
|
+
sources: Object.freeze(sources.map((source) => source === "header" ? "header" : Object.freeze({ cookie: options.cookie ?? authConfig.cookie.name() }))),
|
|
119
|
+
userTypes: Object.freeze([...resolveUserTypes(options.userTypes)]),
|
|
120
|
+
required: options.optional !== true
|
|
121
|
+
}),
|
|
122
|
+
enumerable: false
|
|
123
|
+
});
|
|
124
|
+
return markAuthGuard(middleware, options.optional !== true);
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-session.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/session/page-session.ts"],"sourcesContent":["import { config, t, type Middleware, type Request, type Response } from \"@warlock.js/core\";\nimport type { CookieDomainResolver } from \"../contracts/types\";\nimport { log } from \"@warlock.js/logger\";\nimport ms from \"ms\";\nimport {\n assertCsrfOriginAllowed,\n CsrfOriginMismatchError,\n requiresCsrfOriginCheck,\n} from \"../middleware/csrf-origin-check\";\nimport { authConfig } from \"../services/auth-config\";\nimport { resolveRequestUser } from \"../services/resolve-request-user\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\n/** Credential sources a session can be read from. */\nexport type SessionSource = \"cookie\" | \"header\";\n\n/** Absolute family lifetime used when `session.maxAge` is not configured. */\nconst DEFAULT_SESSION_MAX_AGE = \"30d\";\n\n/**\n * The seam web consumes. Declared here, not imported: web and auth never\n * depend on each other, and `pageSession()` satisfies web's `SessionResolver`\n * structurally.\n */\nexport type PageSessionResolver<TModel = unknown, TUser = unknown> = {\n resolve(request: Request, response: Response): Promise<{ model: TModel; user: TUser } | null>;\n};\n\nexport type SessionResolverOptions = {\n /** Access cookie name. @default auth.cookie.name */\n cookie?: string;\n /** Refresh cookie name. @default auth.cookie.refreshName */\n refreshCookie?: string;\n /** Credential sources. A present Authorization header always wins. @default [\"cookie\", \"header\"] */\n sources?: SessionSource[];\n /** Renew an expired access cookie from the refresh cookie. Cookie source only. @default true */\n renew?: boolean;\n /** Allowed user types. @default auth.defaultUserType */\n userTypes?: string | string[];\n /** Absolute family lifetime, an `ms` string or milliseconds. @default auth.session.maxAge, else \"30d\" */\n maxAge?: string | number;\n /** Refresh overlap window forwarded to renewal. */\n overlapMs?: number;\n /** Select a request-scoped Domain for renewed cookies; `undefined` keeps them host-only. */\n cookieDomain?: CookieDomainResolver;\n};\n\nexport type PageSessionOptions<TModel, TUser> = SessionResolverOptions & {\n /** Model → wire projection. Required: a model never crosses the wire by default. */\n project: (model: TModel) => TUser | Promise<TUser>;\n};\n\nexport type SessionMiddlewareOptions = SessionResolverOptions & {\n /** Let guests through with no user instead of a 401. @default false */\n optional?: boolean;\n};\n\nfunction resolveMaxAgeMs(maxAge: string | number | undefined): number {\n const raw = maxAge ?? config.key(\"auth.session.maxAge\", DEFAULT_SESSION_MAX_AGE);\n const parsed = typeof raw === \"number\" ? raw : ms(raw as ms.StringValue);\n\n if (typeof parsed !== \"number\" || !Number.isFinite(parsed) || parsed <= 0) {\n throw new Error(\n `session.maxAge: ${JSON.stringify(raw)} is not a valid ms duration — use a positive duration such as \"30d\".`,\n );\n }\n\n return parsed;\n}\n\nfunction resolveUserTypes(value: string | string[] | undefined): string[] {\n if (value !== undefined) return Array.isArray(value) ? value : [value];\n\n const configured = config.key(\"auth.defaultUserType\");\n\n return configured ? [configured as string] : [];\n}\n\n/**\n * The shared \"credential → model\" step behind `pageSession` and\n * `sessionMiddleware`. Resolved once per request through\n * `resolveRequestUser`'s single-flight memo.\n */\nfunction createModelResolver(options: SessionResolverOptions) {\n const sources = options.sources?.length ? options.sources : ([\"cookie\", \"header\"] as const);\n const useHeader = sources.includes(\"header\");\n const useCookie = sources.includes(\"cookie\");\n const renew = options.renew ?? true;\n\n const resolveModel = async (\n request: Request,\n response: Response,\n ): Promise<{ model: unknown; source: \"header\" | \"cookie\" } | null> => {\n const accessName = options.cookie ?? authConfig.cookie.name();\n const refreshName = options.refreshCookie ?? authConfig.cookie.refreshName();\n // A present Authorization header wins and never falls back to the cookie.\n const headerWins = useHeader && Boolean(request.authorizationValue);\n\n if (!headerWins && !useCookie) return null;\n\n // Cheap guest path: nothing to decode, so nothing is marked auth-derived.\n if (!headerWins && !request.cookie(accessName) && !request.cookie(refreshName)) return null;\n\n const allowedTypes = resolveUserTypes(options.userTypes);\n const renewalUserType = allowedTypes.length === 1 ? allowedTypes[0] : undefined;\n const canRenew = !headerWins && renew && Boolean(renewalUserType);\n\n const model = await resolveRequestUser(request, response, {\n tokenFrom: headerWins ? \"header\" : `cookie:${accessName}`,\n refreshCredential: canRenew ? `cookie:${refreshName}` : undefined,\n overlapMs: options.overlapMs,\n maxAgeMs: resolveMaxAgeMs(options.maxAge),\n allowedTypes,\n renewalUserType,\n cookieDomain: options.cookieDomain ?? authConfig.cookie.cookieDomain(),\n });\n\n if (!model) return null;\n\n request.locals.user = model;\n\n return { model, source: headerWins ? \"header\" : \"cookie\" };\n };\n\n return resolveModel;\n}\n\n/**\n * Session resolver for pages: cookie-or-header credential, reactive renewal,\n * one resolution per request. Plug it into `web.session`.\n *\n * @example\n * export default { session: pageSession({ project: (user: User) => userResource(user) }) };\n */\nexport function pageSession<TModel = any, TUser = unknown>(\n options: PageSessionOptions<TModel, TUser>,\n): PageSessionResolver<TModel, TUser> {\n if (typeof options?.project !== \"function\") {\n throw new Error(\n \"pageSession requires a `project(model)` function: a model never crosses the wire by default.\",\n );\n }\n\n const resolveModel = createModelResolver(options);\n const projections = new WeakMap<object, Promise<unknown>>();\n\n return {\n async resolve(request, response) {\n const resolved = await resolveModel(request, response);\n\n if (!resolved) return null;\n\n const model = resolved.model as TModel;\n let projected = projections.get(request as object);\n\n if (!projected) {\n projected = Promise.resolve(options.project(model));\n projections.set(request as object, projected);\n }\n\n return { model, user: (await projected) as TUser };\n },\n };\n}\n\n/**\n * Middleware for cookie API routes over the same resolver as `pageSession`.\n * A cookie-authenticated unsafe request must pass the Origin/Referer check\n * (renewal can mint cookies, so it runs even when the access cookie is gone).\n * Guests get a 401 unless `optional`. The user lands on `request.locals.user`.\n */\nexport function sessionMiddleware(options: SessionMiddlewareOptions = {}): Middleware {\n const resolveModel = createModelResolver(options);\n const sources = options.sources?.length ? options.sources : [\"cookie\", \"header\"];\n\n return async ({ request, response }) => {\n const headerWins = sources.includes(\"header\") && Boolean(request.authorizationValue);\n\n if (!headerWins && requiresCsrfOriginCheck(\"cookie:x\", request.method)) {\n const accessName = options.cookie ?? authConfig.cookie.name();\n const refreshName = options.refreshCookie ?? authConfig.cookie.refreshName();\n\n if (request.cookie(accessName) || request.cookie(refreshName)) {\n try {\n assertCsrfOriginAllowed(request);\n } catch (error) {\n if (!(error instanceof CsrfOriginMismatchError)) throw error;\n\n log.error(\"http\", \"auth\", error);\n\n return response.forbidden({\n error: t(\"auth.errors.csrfOriginMismatch\"),\n errorCode: AuthErrorCodes.CsrfOriginMismatch,\n });\n }\n }\n }\n\n const resolved = await resolveModel(request, response);\n\n if (resolved || options.optional) return;\n\n return response.unauthorized({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"page-session.mjs","names":[],"sources":["../../../../../../../../packages/auth/src/session/page-session.ts"],"sourcesContent":["import {\n config,\n markAuthGuard,\n t,\n type Middleware,\n type Request,\n type Response,\n} from \"@warlock.js/core\";\nimport type { CookieDomainResolver } from \"../contracts/types\";\nimport { log } from \"@warlock.js/logger\";\nimport ms from \"ms\";\nimport {\n assertCsrfOriginAllowed,\n CsrfOriginMismatchError,\n requiresCsrfOriginCheck,\n} from \"../middleware/csrf-origin-check\";\nimport { authConfig } from \"../services/auth-config\";\nimport { resolveRequestUser } from \"../services/resolve-request-user\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\n/** Credential sources a session can be read from. */\nexport type SessionSource = \"cookie\" | \"header\";\n\n/** Absolute family lifetime used when `session.maxAge` is not configured. */\nconst DEFAULT_SESSION_MAX_AGE = \"30d\";\n\n/**\n * The seam web consumes. Declared here, not imported: web and auth never\n * depend on each other, and `pageSession()` satisfies web's `SessionResolver`\n * structurally.\n */\nexport type PageSessionResolver<TModel = unknown, TUser = unknown> = {\n resolve(request: Request, response: Response): Promise<{ model: TModel; user: TUser } | null>;\n};\n\nexport type SessionResolverOptions = {\n /** Access cookie name. @default auth.cookie.name */\n cookie?: string;\n /** Refresh cookie name. @default auth.cookie.refreshName */\n refreshCookie?: string;\n /** Credential sources. A present Authorization header always wins. @default [\"cookie\", \"header\"] */\n sources?: SessionSource[];\n /** Renew an expired access cookie from the refresh cookie. Cookie source only. @default true */\n renew?: boolean;\n /** Allowed user types. @default auth.defaultUserType */\n userTypes?: string | string[];\n /** Absolute family lifetime, an `ms` string or milliseconds. @default auth.session.maxAge, else \"30d\" */\n maxAge?: string | number;\n /** Refresh overlap window forwarded to renewal. */\n overlapMs?: number;\n /** Select a request-scoped Domain for renewed cookies; `undefined` keeps them host-only. */\n cookieDomain?: CookieDomainResolver;\n};\n\nexport type PageSessionOptions<TModel, TUser> = SessionResolverOptions & {\n /** Model → wire projection. Required: a model never crosses the wire by default. */\n project: (model: TModel) => TUser | Promise<TUser>;\n};\n\nexport type SessionMiddlewareOptions = SessionResolverOptions & {\n /** Let guests through with no user instead of a 401. @default false */\n optional?: boolean;\n};\n\nfunction resolveMaxAgeMs(maxAge: string | number | undefined): number {\n const raw = maxAge ?? config.key(\"auth.session.maxAge\", DEFAULT_SESSION_MAX_AGE);\n const parsed = typeof raw === \"number\" ? raw : ms(raw as ms.StringValue);\n\n if (typeof parsed !== \"number\" || !Number.isFinite(parsed) || parsed <= 0) {\n throw new Error(\n `session.maxAge: ${JSON.stringify(raw)} is not a valid ms duration — use a positive duration such as \"30d\".`,\n );\n }\n\n return parsed;\n}\n\nfunction resolveUserTypes(value: string | string[] | undefined): string[] {\n if (value !== undefined) return Array.isArray(value) ? value : [value];\n\n const configured = config.key(\"auth.defaultUserType\");\n\n return configured ? [configured as string] : [];\n}\n\n/**\n * The shared \"credential → model\" step behind `pageSession` and\n * `sessionMiddleware`. Resolved once per request through\n * `resolveRequestUser`'s single-flight memo.\n */\nfunction createModelResolver(options: SessionResolverOptions) {\n const sources = options.sources?.length ? options.sources : ([\"cookie\", \"header\"] as const);\n const useHeader = sources.includes(\"header\");\n const useCookie = sources.includes(\"cookie\");\n const renew = options.renew ?? true;\n\n const resolveModel = async (\n request: Request,\n response: Response,\n ): Promise<{ model: unknown; source: \"header\" | \"cookie\" } | null> => {\n const accessName = options.cookie ?? authConfig.cookie.name();\n const refreshName = options.refreshCookie ?? authConfig.cookie.refreshName();\n // A present Authorization header wins and never falls back to the cookie.\n const headerWins = useHeader && Boolean(request.authorizationValue);\n\n if (!headerWins && !useCookie) return null;\n\n // Cheap guest path: nothing to decode, so nothing is marked auth-derived.\n if (!headerWins && !request.cookie(accessName) && !request.cookie(refreshName)) return null;\n\n const allowedTypes = resolveUserTypes(options.userTypes);\n const renewalUserType = allowedTypes.length === 1 ? allowedTypes[0] : undefined;\n const canRenew = !headerWins && renew && Boolean(renewalUserType);\n\n const model = await resolveRequestUser(request, response, {\n tokenFrom: headerWins ? \"header\" : `cookie:${accessName}`,\n refreshCredential: canRenew ? `cookie:${refreshName}` : undefined,\n overlapMs: options.overlapMs,\n maxAgeMs: resolveMaxAgeMs(options.maxAge),\n allowedTypes,\n renewalUserType,\n cookieDomain: options.cookieDomain ?? authConfig.cookie.cookieDomain(),\n });\n\n if (!model) return null;\n\n request.locals.user = model;\n\n return { model, source: headerWins ? \"header\" : \"cookie\" };\n };\n\n return resolveModel;\n}\n\n/**\n * Session resolver for pages: cookie-or-header credential, reactive renewal,\n * one resolution per request. Plug it into `web.session`.\n *\n * @example\n * export default { session: pageSession({ project: (user: User) => userResource(user) }) };\n */\nexport function pageSession<TModel = unknown, TUser = unknown>(\n options: PageSessionOptions<TModel, TUser>,\n): PageSessionResolver<TModel, TUser> {\n if (typeof options?.project !== \"function\") {\n throw new Error(\n \"pageSession requires a `project(model)` function: a model never crosses the wire by default.\",\n );\n }\n\n const resolveModel = createModelResolver(options);\n const projections = new WeakMap<object, Promise<unknown>>();\n\n return {\n async resolve(request, response) {\n const resolved = await resolveModel(request, response);\n\n if (!resolved) return null;\n\n const model = resolved.model as TModel;\n let projected = projections.get(request as object);\n\n if (!projected) {\n projected = Promise.resolve(options.project(model));\n projections.set(request as object, projected);\n }\n\n return { model, user: (await projected) as TUser };\n },\n };\n}\n\n/**\n * Middleware for cookie API routes over the same resolver as `pageSession`.\n * A cookie-authenticated unsafe request must pass the Origin/Referer check\n * (renewal can mint cookies, so it runs even when the access cookie is gone).\n * Guests get a 401 unless `optional`. The user lands on `request.locals.user`.\n */\nexport function sessionMiddleware(options: SessionMiddlewareOptions = {}): Middleware {\n const resolveModel = createModelResolver(options);\n const sources = options.sources?.length ? options.sources : [\"cookie\", \"header\"];\n\n const middleware: Middleware = async ({ request, response }) => {\n const headerWins = sources.includes(\"header\") && Boolean(request.authorizationValue);\n\n if (!headerWins && requiresCsrfOriginCheck(\"cookie:x\", request.method)) {\n const accessName = options.cookie ?? authConfig.cookie.name();\n const refreshName = options.refreshCookie ?? authConfig.cookie.refreshName();\n\n if (request.cookie(accessName) || request.cookie(refreshName)) {\n try {\n assertCsrfOriginAllowed(request);\n } catch (error) {\n if (!(error instanceof CsrfOriginMismatchError)) throw error;\n\n log.error(\"http\", \"auth\", error);\n\n return response.forbidden({\n error: t(\"auth.errors.csrfOriginMismatch\"),\n errorCode: AuthErrorCodes.CsrfOriginMismatch,\n });\n }\n }\n }\n\n const resolved = await resolveModel(request, response);\n\n if (resolved || options.optional) return;\n\n return response.unauthorized({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n };\n Object.defineProperty(middleware, Symbol.for(\"warlock.auth\"), {\n value: Object.freeze({\n sources: Object.freeze(\n sources.map((source) =>\n source === \"header\"\n ? \"header\"\n : Object.freeze({ cookie: options.cookie ?? authConfig.cookie.name() }),\n ),\n ),\n userTypes: Object.freeze([...resolveUserTypes(options.userTypes)]),\n required: options.optional !== true,\n }),\n enumerable: false,\n });\n return markAuthGuard(middleware, options.optional !== true);\n}\n"],"mappings":"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"}
|
package/llms-full.txt
CHANGED
|
@@ -792,6 +792,38 @@ Client side, the button is just a link. It must be a top-level navigation, not a
|
|
|
792
792
|
- **Linking.** An existing `provider_accounts` row (provider plus Google `sub`) always decides the user. Without one, the Google email must be verified (`email_verified: true`), or the call throws `ProviderEmailNotVerifiedError` (403, `EC010`) and nothing is linked or created. A verified email links the matching user, or creates one with `{ email, name, emailVerifiedAt: now }`.
|
|
793
793
|
- Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
|
|
794
794
|
|
|
795
|
+
### Google request deadlines and controlled providers
|
|
796
|
+
|
|
797
|
+
`GoogleProviderConfig.timeoutMs` sets a positive integer deadline per token or
|
|
798
|
+
JWKS request (default `5000`, maximum `2147483647`). Both requests receive an
|
|
799
|
+
abort signal, including response-body reads. Timeout failures become
|
|
800
|
+
`InvalidProviderCallbackError` with `code-exchange-failed` or `invalid-id-token`.
|
|
801
|
+
|
|
802
|
+
For controlled tests, supply trusted endpoints and a fetch-compatible transport:
|
|
803
|
+
|
|
804
|
+
```ts
|
|
805
|
+
import { GoogleProvider } from "@warlock.js/auth";
|
|
806
|
+
|
|
807
|
+
const provider = new GoogleProvider({
|
|
808
|
+
clientId: "test-client",
|
|
809
|
+
clientSecret: "test-secret",
|
|
810
|
+
redirectUri: "https://example.test/callback",
|
|
811
|
+
timeoutMs: 1000,
|
|
812
|
+
fetch: (url, options) => fetch(url, options),
|
|
813
|
+
endpoints: {
|
|
814
|
+
authorization: "https://provider.test/authorize",
|
|
815
|
+
token: "https://provider.test/token",
|
|
816
|
+
jwks: "https://provider.test/keys",
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
The transport must honor `options.signal`. Endpoint overrides are trusted
|
|
822
|
+
configuration, never request input. They do not change the accepted Google
|
|
823
|
+
issuers, client audience, signature, expiry, nonce or PKCE checks. Each provider
|
|
824
|
+
instance owns its JWKS cache so test keys cannot enter another instance's cache.
|
|
825
|
+
The implementation uses the `jose` 6 `customFetch` and `timeoutDuration` options.
|
|
826
|
+
|
|
795
827
|
## GitHub, Discord, LinkedIn
|
|
796
828
|
|
|
797
829
|
Same shape as Google: a config block under `auth.providers.<name>`, a GET
|
|
@@ -1234,6 +1266,23 @@ The package signs access and refresh tokens with independent secrets — `config
|
|
|
1234
1266
|
- The `run-auth-commands` topic — the bundled cleanup command
|
|
1235
1267
|
- The `scheduler-basics` topic of the `warlock-js-scheduler` skill — scheduling cleanup
|
|
1236
1268
|
|
|
1269
|
+
## Active session caps and rejected refreshes
|
|
1270
|
+
|
|
1271
|
+
`RefreshToken.enforceMax(user, max)` uses the same active predicate as
|
|
1272
|
+
`RefreshToken.activeFor(user)`: `revoked_at` is null and `expires_at` is strictly
|
|
1273
|
+
after now. It revokes the oldest active rows until there is room for one new
|
|
1274
|
+
session; expired rows need no cleanup before issuance.
|
|
1275
|
+
|
|
1276
|
+
```ts
|
|
1277
|
+
const sessions = await RefreshToken.activeFor(user); // newest first
|
|
1278
|
+
const next = await authService.refreshTokens(refreshToken); // pair or null
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
A missing or revoked token family rejects outside the family transaction's
|
|
1282
|
+
callback, after a read-only transaction completes. Expected refresh rejections
|
|
1283
|
+
do not produce database rollback errors. Replay containment still revokes the
|
|
1284
|
+
family, and actual query, commit and rollback failures remain visible.
|
|
1285
|
+
|
|
1237
1286
|
|
|
1238
1287
|
## protect-routes `@warlock.js/auth/skills/protect-routes/SKILL.md`
|
|
1239
1288
|
|
|
@@ -1550,6 +1599,26 @@ This is an Origin check, not a double-submit CSRF token — cheaper, and closes
|
|
|
1550
1599
|
- The `throttle-login-attempts` topic — brute-force throttle to stack in front of the login route
|
|
1551
1600
|
- The `warlock-js-access` skill index — permissions, roles, and who-may-act-on-whom policies, when user-type gating isn't enough
|
|
1552
1601
|
|
|
1602
|
+
## Session middleware in groups
|
|
1603
|
+
|
|
1604
|
+
`sessionMiddleware({ userTypes: "user" })` publishes the same `warlock.auth`
|
|
1605
|
+
descriptor as `authMiddleware()`. Group middleware is inherited by nested
|
|
1606
|
+
routes, so `inspect` and the generated `agents-md` summary recognize these guards.
|
|
1607
|
+
`{ optional: true }` reports `auth.required: false`, with sources and user types
|
|
1608
|
+
retained. Such routes remain in the unguarded-write list. Legacy descriptors
|
|
1609
|
+
without `required` still mean required authentication.
|
|
1610
|
+
|
|
1611
|
+
```ts
|
|
1612
|
+
import { router } from "@warlock.js/core";
|
|
1613
|
+
import { sessionMiddleware } from "@warlock.js/auth";
|
|
1614
|
+
|
|
1615
|
+
router.group({ middleware: [sessionMiddleware({ userTypes: "user" })] }, () => {
|
|
1616
|
+
router.post("/settings", async ({ response }) => response.success({ saved: true }));
|
|
1617
|
+
});
|
|
1618
|
+
```
|
|
1619
|
+
|
|
1620
|
+
When wiring `pageSession`, annotate the projection callback model parameter. An unspecified model type defaults to `unknown`. Refresh renewal needs an explicit user type; missing renewal types must not issue cookies.
|
|
1621
|
+
|
|
1553
1622
|
|
|
1554
1623
|
## register-user `@warlock.js/auth/skills/register-user/SKILL.md`
|
|
1555
1624
|
|
package/package.json
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"ms": "^2.1.3"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"@warlock.js/fs": "5.
|
|
16
|
-
"@warlock.js/cache": "5.
|
|
17
|
-
"@warlock.js/cascade": "5.
|
|
18
|
-
"@warlock.js/core": "5.
|
|
19
|
-
"@warlock.js/logger": "5.
|
|
20
|
-
"@warlock.js/seal": "5.
|
|
21
|
-
"@warlock.js/notifications": "5.
|
|
15
|
+
"@warlock.js/fs": "5.36.0",
|
|
16
|
+
"@warlock.js/cache": "5.36.0",
|
|
17
|
+
"@warlock.js/cascade": "5.36.0",
|
|
18
|
+
"@warlock.js/core": "5.36.0",
|
|
19
|
+
"@warlock.js/logger": "5.36.0",
|
|
20
|
+
"@warlock.js/seal": "5.36.0",
|
|
21
|
+
"@warlock.js/notifications": "5.36.0",
|
|
22
22
|
"@simplewebauthn/server": "^13.1.0",
|
|
23
23
|
"jose": "^6.1.0"
|
|
24
24
|
},
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"author": "hassanzohdy",
|
|
50
50
|
"license": "MIT",
|
|
51
|
-
"version": "5.
|
|
51
|
+
"version": "5.36.0",
|
|
52
52
|
"type": "module",
|
|
53
53
|
"main": "./esm/index.mjs",
|
|
54
54
|
"module": "./esm/index.mjs",
|
|
@@ -65,6 +65,38 @@ Client side, the button is just a link. It must be a top-level navigation, not a
|
|
|
65
65
|
- **Linking.** An existing `provider_accounts` row (provider plus Google `sub`) always decides the user. Without one, the Google email must be verified (`email_verified: true`), or the call throws `ProviderEmailNotVerifiedError` (403, `EC010`) and nothing is linked or created. A verified email links the matching user, or creates one with `{ email, name, emailVerifiedAt: now }`.
|
|
66
66
|
- Other OIDC providers: implement `AuthProvider` (`authorizationUrl(state)`, `handleCallback({ query, expected })`) and register it under `auth.providers.custom.<name>`.
|
|
67
67
|
|
|
68
|
+
### Google request deadlines and controlled providers
|
|
69
|
+
|
|
70
|
+
`GoogleProviderConfig.timeoutMs` sets a positive integer deadline per token or
|
|
71
|
+
JWKS request (default `5000`, maximum `2147483647`). Both requests receive an
|
|
72
|
+
abort signal, including response-body reads. Timeout failures become
|
|
73
|
+
`InvalidProviderCallbackError` with `code-exchange-failed` or `invalid-id-token`.
|
|
74
|
+
|
|
75
|
+
For controlled tests, supply trusted endpoints and a fetch-compatible transport:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { GoogleProvider } from "@warlock.js/auth";
|
|
79
|
+
|
|
80
|
+
const provider = new GoogleProvider({
|
|
81
|
+
clientId: "test-client",
|
|
82
|
+
clientSecret: "test-secret",
|
|
83
|
+
redirectUri: "https://example.test/callback",
|
|
84
|
+
timeoutMs: 1000,
|
|
85
|
+
fetch: (url, options) => fetch(url, options),
|
|
86
|
+
endpoints: {
|
|
87
|
+
authorization: "https://provider.test/authorize",
|
|
88
|
+
token: "https://provider.test/token",
|
|
89
|
+
jwks: "https://provider.test/keys",
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The transport must honor `options.signal`. Endpoint overrides are trusted
|
|
95
|
+
configuration, never request input. They do not change the accepted Google
|
|
96
|
+
issuers, client audience, signature, expiry, nonce or PKCE checks. Each provider
|
|
97
|
+
instance owns its JWKS cache so test keys cannot enter another instance's cache.
|
|
98
|
+
The implementation uses the `jose` 6 `customFetch` and `timeoutDuration` options.
|
|
99
|
+
|
|
68
100
|
## GitHub, Discord, LinkedIn
|
|
69
101
|
|
|
70
102
|
Same shape as Google: a config block under `auth.providers.<name>`, a GET
|
|
@@ -194,3 +194,20 @@ The package signs access and refresh tokens with independent secrets — `config
|
|
|
194
194
|
- The `handle-login-and-logout` topic — full login/logout flow that uses these primitives
|
|
195
195
|
- The `run-auth-commands` topic — the bundled cleanup command
|
|
196
196
|
- The `scheduler-basics` topic of the `warlock-js-scheduler` skill — scheduling cleanup
|
|
197
|
+
|
|
198
|
+
## Active session caps and rejected refreshes
|
|
199
|
+
|
|
200
|
+
`RefreshToken.enforceMax(user, max)` uses the same active predicate as
|
|
201
|
+
`RefreshToken.activeFor(user)`: `revoked_at` is null and `expires_at` is strictly
|
|
202
|
+
after now. It revokes the oldest active rows until there is room for one new
|
|
203
|
+
session; expired rows need no cleanup before issuance.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const sessions = await RefreshToken.activeFor(user); // newest first
|
|
207
|
+
const next = await authService.refreshTokens(refreshToken); // pair or null
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
A missing or revoked token family rejects outside the family transaction's
|
|
211
|
+
callback, after a read-only transaction completes. Expected refresh rejections
|
|
212
|
+
do not produce database rollback errors. Replay containment still revokes the
|
|
213
|
+
family, and actual query, commit and rollback failures remain visible.
|
|
@@ -310,3 +310,23 @@ This is an Origin check, not a double-submit CSRF token — cheaper, and closes
|
|
|
310
310
|
- The `handle-login-and-logout` topic — where the access token gets issued in the first place
|
|
311
311
|
- The `throttle-login-attempts` topic — brute-force throttle to stack in front of the login route
|
|
312
312
|
- The `warlock-js-access` skill index — permissions, roles, and who-may-act-on-whom policies, when user-type gating isn't enough
|
|
313
|
+
|
|
314
|
+
## Session middleware in groups
|
|
315
|
+
|
|
316
|
+
`sessionMiddleware({ userTypes: "user" })` publishes the same `warlock.auth`
|
|
317
|
+
descriptor as `authMiddleware()`. Group middleware is inherited by nested
|
|
318
|
+
routes, so `inspect` and the generated `agents-md` summary recognize these guards.
|
|
319
|
+
`{ optional: true }` reports `auth.required: false`, with sources and user types
|
|
320
|
+
retained. Such routes remain in the unguarded-write list. Legacy descriptors
|
|
321
|
+
without `required` still mean required authentication.
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { router } from "@warlock.js/core";
|
|
325
|
+
import { sessionMiddleware } from "@warlock.js/auth";
|
|
326
|
+
|
|
327
|
+
router.group({ middleware: [sessionMiddleware({ userTypes: "user" })] }, () => {
|
|
328
|
+
router.post("/settings", async ({ response }) => response.success({ saved: true }));
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
When wiring `pageSession`, annotate the projection callback model parameter. An unspecified model type defaults to `unknown`. Refresh renewal needs an explicit user type; missing renewal types must not issue cookies.
|