@warlock.js/auth 5.25.0 → 5.27.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +25 -9
  2. package/esm/contracts/index.d.mts +1 -1
  3. package/esm/contracts/request-locals.d.mts +1 -1
  4. package/esm/contracts/types.d.mts +8 -3
  5. package/esm/contracts/types.d.mts.map +1 -1
  6. package/esm/contracts/types.mjs.map +1 -1
  7. package/esm/index.d.mts +4 -4
  8. package/esm/index.mjs +3 -3
  9. package/esm/middleware/auth.middleware.d.mts +19 -1
  10. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  11. package/esm/middleware/auth.middleware.mjs +19 -1
  12. package/esm/middleware/auth.middleware.mjs.map +1 -1
  13. package/esm/middleware/index.d.mts +1 -1
  14. package/esm/middleware/index.mjs +1 -1
  15. package/esm/services/auth-config.mjs +3 -1
  16. package/esm/services/auth-config.mjs.map +1 -1
  17. package/esm/services/auth.service.d.mts +9 -4
  18. package/esm/services/auth.service.d.mts.map +1 -1
  19. package/esm/services/auth.service.mjs +35 -8
  20. package/esm/services/auth.service.mjs.map +1 -1
  21. package/esm/services/index.d.mts +1 -1
  22. package/esm/services/index.mjs +1 -1
  23. package/esm/services/resolve-request-user.d.mts +3 -2
  24. package/esm/services/resolve-request-user.d.mts.map +1 -1
  25. package/esm/services/resolve-request-user.mjs +8 -2
  26. package/esm/services/resolve-request-user.mjs.map +1 -1
  27. package/esm/session/page-session.d.mts +3 -1
  28. package/esm/session/page-session.d.mts.map +1 -1
  29. package/esm/session/page-session.mjs +2 -1
  30. package/esm/session/page-session.mjs.map +1 -1
  31. package/llms-full.txt +43 -20
  32. package/package.json +8 -8
  33. package/skills/handle-login-and-logout/SKILL.md +36 -21
  34. package/skills/protect-routes/SKILL.md +4 -0
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-request-user.mjs","names":[],"sources":["../../../../../../../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 { 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};\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 });\n authService.setAuthCookie(response, pair.refreshToken, {\n name: refreshCredential.slice(\"cookie:\".length),\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":["../../../../../../../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 } | { 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,3 +1,4 @@
1
+ import { CookieDomainResolver } from "../contracts/types.mjs";
1
2
  import { Middleware, Request, Response } from "@warlock.js/core";
2
3
 
3
4
  //#region ../auth/src/session/page-session.d.ts
@@ -21,7 +22,8 @@ type SessionResolverOptions = {
21
22
  renew?: boolean; /** Allowed user types. @default auth.defaultUserType */
22
23
  userTypes?: string | string[]; /** Absolute family lifetime, an `ms` string or milliseconds. @default auth.session.maxAge, else "30d" */
23
24
  maxAge?: string | number; /** Refresh overlap window forwarded to renewal. */
24
- overlapMs?: number;
25
+ overlapMs?: number; /** Select a request-scoped Domain for renewed cookies; `undefined` keeps them host-only. */
26
+ cookieDomain?: CookieDomainResolver;
25
27
  };
26
28
  type PageSessionOptions<TModel, TUser> = SessionResolverOptions & {
27
29
  /** Model → wire projection. Required: a model never crosses the wire by default. */project: (model: TModel) => TUser | Promise<TUser>;
@@ -1 +1 @@
1
- {"version":3,"file":"page-session.d.mts","names":[],"sources":["../../../../../../../auth/src/session/page-session.ts"],"mappings":";;;;KAaY,aAAA;AAAZ;;;;AAAyB;AAAzB,KAUY,mBAAA;EACV,OAAA,CAAQ,OAAA,EAAS,OAAA,EAAS,QAAA,EAAU,QAAA,GAAW,OAAA;IAAU,KAAA,EAAO,MAAA;IAAQ,IAAA,EAAM,KAAA;EAAA;AAAA;AAAA,KAGpE,sBAAA;EAHqC,oDAK/C,MAAA,WALsD;EAOtD,aAAA,WARgD;EAUhD,OAAA,GAAU,aAAa,IATN;EAWjB,KAAA,YAXoC;EAapC,SAAA,sBAb+C;EAe/C,MAAA,oBAfgE;EAiBhE,SAAA;AAAA;AAAA,KAGU,kBAAA,kBAAoC,sBAAA;EApBqC,oFAsBnF,OAAA,GAAU,KAAA,EAAO,MAAA,KAAW,KAAA,GAAQ,OAAA,CAAQ,KAAA;AAAA;AAAA,KAGlC,wBAAA,GAA2B,sBAAsB;EAhBpC,uEAkBvB,QAAQ;AAAA;;;;;;;;iBA+EM,WAAA,gCACd,OAAA,EAAS,kBAAA,CAAmB,MAAA,EAAQ,KAAA,IACnC,mBAAA,CAAoB,MAAA,EAAQ,KAAA;AAxF/B;;;;;;AAAA,iBA2HgB,iBAAA,CAAkB,OAAA,GAAS,wBAAA,GAAgC,UAAU"}
1
+ {"version":3,"file":"page-session.d.mts","names":[],"sources":["../../../../../../../auth/src/session/page-session.ts"],"mappings":";;;;;KAcY,aAAA;AAAZ;;;;AAAyB;AAAzB,KAUY,mBAAA;EACV,OAAA,CAAQ,OAAA,EAAS,OAAA,EAAS,QAAA,EAAU,QAAA,GAAW,OAAA;IAAU,KAAA,EAAO,MAAA;IAAQ,IAAA,EAAM,KAAA;EAAA;AAAA;AAAA,KAGpE,sBAAA;EAHqC,oDAK/C,MAAA,WALsD;EAOtD,aAAA,WARgD;EAUhD,OAAA,GAAU,aAAA,IATO;EAWjB,KAAA,YAXoC;EAapC,SAAA,sBAb+C;EAe/C,MAAA,oBAfgE;EAiBhE,SAAA,WAjB8E;EAmB9E,YAAA,GAAe,oBAAoB;AAAA;AAAA,KAGzB,kBAAA,kBAAoC,sBAAA;EAnBd,oFAqBhC,OAAA,GAAU,KAAA,EAAO,MAAA,KAAW,KAAA,GAAQ,OAAA,CAAQ,KAAA;AAAA;AAAA,KAGlC,wBAAA,GAA2B,sBAAsB;EApB3D,uEAsBA,QAAQ;AAAA;;;;;;;;iBAgFM,WAAA,gCACd,OAAA,EAAS,kBAAA,CAAmB,MAAA,EAAQ,KAAA,IACnC,mBAAA,CAAoB,MAAA,EAAQ,KAAA;AAzF/B;;;;;;AAAA,iBA4HgB,iBAAA,CAAkB,OAAA,GAAS,wBAAA,GAAgC,UAAU"}
@@ -45,7 +45,8 @@ function createModelResolver(options) {
45
45
  overlapMs: options.overlapMs,
46
46
  maxAgeMs: resolveMaxAgeMs(options.maxAge),
47
47
  allowedTypes,
48
- renewalUserType
48
+ renewalUserType,
49
+ cookieDomain: options.cookieDomain ?? authConfig.cookie.cookieDomain()
49
50
  });
50
51
  if (!model) return null;
51
52
  request.locals.user = model;
@@ -1 +1 @@
1
- {"version":3,"file":"page-session.mjs","names":[],"sources":["../../../../../../../auth/src/session/page-session.ts"],"sourcesContent":["import { config, t, type Middleware, type Request, type Response } from \"@warlock.js/core\";\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};\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 });\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":";;;;;;;;;;AAgBA,MAAM,0BAA0B;AAsChC,SAAS,gBAAgB,QAA6C;CACpE,MAAM,MAAM,UAAU,OAAO,IAAI,uBAAuB,uBAAuB;CAC/E,MAAM,SAAS,OAAO,QAAQ,WAAW,MAAM,GAAG,GAAqB;CAEvE,IAAI,OAAO,WAAW,YAAY,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GACtE,MAAM,IAAI,MACR,mBAAmB,KAAK,UAAU,GAAG,EAAE,qEACzC;CAGF,OAAO;AACT;AAEA,SAAS,iBAAiB,OAAgD;CACxE,IAAI,UAAU,QAAW,OAAO,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK;CAErE,MAAM,aAAa,OAAO,IAAI,sBAAsB;CAEpD,OAAO,aAAa,CAAC,UAAoB,IAAI,CAAC;AAChD;;;;;;AAOA,SAAS,oBAAoB,SAAiC;CAC5D,MAAM,UAAU,QAAQ,SAAS,SAAS,QAAQ,UAAW,CAAC,UAAU,QAAQ;CAChF,MAAM,YAAY,QAAQ,SAAS,QAAQ;CAC3C,MAAM,YAAY,QAAQ,SAAS,QAAQ;CAC3C,MAAM,QAAQ,QAAQ,SAAS;CAE/B,MAAM,eAAe,OACnB,SACA,aACoE;EACpE,MAAM,aAAa,QAAQ,UAAU,WAAW,OAAO,KAAK;EAC5D,MAAM,cAAc,QAAQ,iBAAiB,WAAW,OAAO,YAAY;EAE3E,MAAM,aAAa,aAAa,QAAQ,QAAQ,kBAAkB;EAElE,IAAI,CAAC,cAAc,CAAC,WAAW,OAAO;EAGtC,IAAI,CAAC,cAAc,CAAC,QAAQ,OAAO,UAAU,KAAK,CAAC,QAAQ,OAAO,WAAW,GAAG,OAAO;EAEvF,MAAM,eAAe,iBAAiB,QAAQ,SAAS;EACvD,MAAM,kBAAkB,aAAa,WAAW,IAAI,aAAa,KAAK;EACtE,MAAM,WAAW,CAAC,cAAc,SAAS,QAAQ,eAAe;EAEhE,MAAM,QAAQ,MAAM,mBAAmB,SAAS,UAAU;GACxD,WAAW,aAAa,WAAW,UAAU;GAC7C,mBAAmB,WAAW,UAAU,gBAAgB;GACxD,WAAW,QAAQ;GACnB,UAAU,gBAAgB,QAAQ,MAAM;GACxC;GACA;EACF,CAAC;EAED,IAAI,CAAC,OAAO,OAAO;EAEnB,QAAQ,OAAO,OAAO;EAEtB,OAAO;GAAE;GAAO,QAAQ,aAAa,WAAW;EAAS;CAC3D;CAEA,OAAO;AACT;;;;;;;;AASA,SAAgB,YACd,SACoC;CACpC,IAAI,OAAO,SAAS,YAAY,YAC9B,MAAM,IAAI,MACR,8FACF;CAGF,MAAM,eAAe,oBAAoB,OAAO;CAChD,MAAM,8BAAc,IAAI,QAAkC;CAE1D,OAAO,EACL,MAAM,QAAQ,SAAS,UAAU;EAC/B,MAAM,WAAW,MAAM,aAAa,SAAS,QAAQ;EAErD,IAAI,CAAC,UAAU,OAAO;EAEtB,MAAM,QAAQ,SAAS;EACvB,IAAI,YAAY,YAAY,IAAI,OAAiB;EAEjD,IAAI,CAAC,WAAW;GACd,YAAY,QAAQ,QAAQ,QAAQ,QAAQ,KAAK,CAAC;GAClD,YAAY,IAAI,SAAmB,SAAS;EAC9C;EAEA,OAAO;GAAE;GAAO,MAAO,MAAM;EAAoB;CACnD,EACF;AACF;;;;;;;AAQA,SAAgB,kBAAkB,UAAoC,CAAC,GAAe;CACpF,MAAM,eAAe,oBAAoB,OAAO;CAChD,MAAM,UAAU,QAAQ,SAAS,SAAS,QAAQ,UAAU,CAAC,UAAU,QAAQ;CAE/E,OAAO,OAAO,EAAE,SAAS,eAAe;EAGtC,IAAI,EAFe,QAAQ,SAAS,QAAQ,KAAK,QAAQ,QAAQ,kBAAkB,MAEhE,wBAAwB,YAAY,QAAQ,MAAM,GAAG;GACtE,MAAM,aAAa,QAAQ,UAAU,WAAW,OAAO,KAAK;GAC5D,MAAM,cAAc,QAAQ,iBAAiB,WAAW,OAAO,YAAY;GAE3E,IAAI,QAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,WAAW,GAC1D,IAAI;IACF,wBAAwB,OAAO;GACjC,SAAS,OAAO;IACd,IAAI,EAAE,iBAAiB,0BAA0B,MAAM;IAEvD,IAAI,MAAM,QAAQ,QAAQ,KAAK;IAE/B,OAAO,SAAS,UAAU;KACxB,OAAO,EAAE,gCAAgC;KACzC;IACF,CAAC;GACH;EAEJ;EAIA,IAAI,MAFmB,aAAa,SAAS,QAAQ,KAErC,QAAQ,UAAU;EAElC,OAAO,SAAS,aAAa;GAC3B,OAAO,EAAE,0BAA0B;GACnC;EACF,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"page-session.mjs","names":[],"sources":["../../../../../../../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":";;;;;;;;;;AAiBA,MAAM,0BAA0B;AAwChC,SAAS,gBAAgB,QAA6C;CACpE,MAAM,MAAM,UAAU,OAAO,IAAI,uBAAuB,uBAAuB;CAC/E,MAAM,SAAS,OAAO,QAAQ,WAAW,MAAM,GAAG,GAAqB;CAEvE,IAAI,OAAO,WAAW,YAAY,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GACtE,MAAM,IAAI,MACR,mBAAmB,KAAK,UAAU,GAAG,EAAE,qEACzC;CAGF,OAAO;AACT;AAEA,SAAS,iBAAiB,OAAgD;CACxE,IAAI,UAAU,QAAW,OAAO,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK;CAErE,MAAM,aAAa,OAAO,IAAI,sBAAsB;CAEpD,OAAO,aAAa,CAAC,UAAoB,IAAI,CAAC;AAChD;;;;;;AAOA,SAAS,oBAAoB,SAAiC;CAC5D,MAAM,UAAU,QAAQ,SAAS,SAAS,QAAQ,UAAW,CAAC,UAAU,QAAQ;CAChF,MAAM,YAAY,QAAQ,SAAS,QAAQ;CAC3C,MAAM,YAAY,QAAQ,SAAS,QAAQ;CAC3C,MAAM,QAAQ,QAAQ,SAAS;CAE/B,MAAM,eAAe,OACnB,SACA,aACoE;EACpE,MAAM,aAAa,QAAQ,UAAU,WAAW,OAAO,KAAK;EAC5D,MAAM,cAAc,QAAQ,iBAAiB,WAAW,OAAO,YAAY;EAE3E,MAAM,aAAa,aAAa,QAAQ,QAAQ,kBAAkB;EAElE,IAAI,CAAC,cAAc,CAAC,WAAW,OAAO;EAGtC,IAAI,CAAC,cAAc,CAAC,QAAQ,OAAO,UAAU,KAAK,CAAC,QAAQ,OAAO,WAAW,GAAG,OAAO;EAEvF,MAAM,eAAe,iBAAiB,QAAQ,SAAS;EACvD,MAAM,kBAAkB,aAAa,WAAW,IAAI,aAAa,KAAK;EACtE,MAAM,WAAW,CAAC,cAAc,SAAS,QAAQ,eAAe;EAEhE,MAAM,QAAQ,MAAM,mBAAmB,SAAS,UAAU;GACxD,WAAW,aAAa,WAAW,UAAU;GAC7C,mBAAmB,WAAW,UAAU,gBAAgB;GACxD,WAAW,QAAQ;GACnB,UAAU,gBAAgB,QAAQ,MAAM;GACxC;GACA;GACA,cAAc,QAAQ,gBAAgB,WAAW,OAAO,aAAa;EACvE,CAAC;EAED,IAAI,CAAC,OAAO,OAAO;EAEnB,QAAQ,OAAO,OAAO;EAEtB,OAAO;GAAE;GAAO,QAAQ,aAAa,WAAW;EAAS;CAC3D;CAEA,OAAO;AACT;;;;;;;;AASA,SAAgB,YACd,SACoC;CACpC,IAAI,OAAO,SAAS,YAAY,YAC9B,MAAM,IAAI,MACR,8FACF;CAGF,MAAM,eAAe,oBAAoB,OAAO;CAChD,MAAM,8BAAc,IAAI,QAAkC;CAE1D,OAAO,EACL,MAAM,QAAQ,SAAS,UAAU;EAC/B,MAAM,WAAW,MAAM,aAAa,SAAS,QAAQ;EAErD,IAAI,CAAC,UAAU,OAAO;EAEtB,MAAM,QAAQ,SAAS;EACvB,IAAI,YAAY,YAAY,IAAI,OAAiB;EAEjD,IAAI,CAAC,WAAW;GACd,YAAY,QAAQ,QAAQ,QAAQ,QAAQ,KAAK,CAAC;GAClD,YAAY,IAAI,SAAmB,SAAS;EAC9C;EAEA,OAAO;GAAE;GAAO,MAAO,MAAM;EAAoB;CACnD,EACF;AACF;;;;;;;AAQA,SAAgB,kBAAkB,UAAoC,CAAC,GAAe;CACpF,MAAM,eAAe,oBAAoB,OAAO;CAChD,MAAM,UAAU,QAAQ,SAAS,SAAS,QAAQ,UAAU,CAAC,UAAU,QAAQ;CAE/E,OAAO,OAAO,EAAE,SAAS,eAAe;EAGtC,IAAI,EAFe,QAAQ,SAAS,QAAQ,KAAK,QAAQ,QAAQ,kBAAkB,MAEhE,wBAAwB,YAAY,QAAQ,MAAM,GAAG;GACtE,MAAM,aAAa,QAAQ,UAAU,WAAW,OAAO,KAAK;GAC5D,MAAM,cAAc,QAAQ,iBAAiB,WAAW,OAAO,YAAY;GAE3E,IAAI,QAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,WAAW,GAC1D,IAAI;IACF,wBAAwB,OAAO;GACjC,SAAS,OAAO;IACd,IAAI,EAAE,iBAAiB,0BAA0B,MAAM;IAEvD,IAAI,MAAM,QAAQ,QAAQ,KAAK;IAE/B,OAAO,SAAS,UAAU;KACxB,OAAO,EAAE,gCAAgC;KACzC;IACF,CAAC;GACH;EAEJ;EAIA,IAAI,MAFmB,aAAa,SAAS,QAAQ,KAErC,QAAQ,UAAU;EAElC,OAAO,SAAS,aAAa;GAC3B,OAAO,EAAE,0BAA0B;GACnC;EACF,CAAC;CACH;AACF"}
package/llms-full.txt CHANGED
@@ -497,13 +497,17 @@ import { type RequestHandler } from "@warlock.js/core";
497
497
  import { User } from "app/users/models/user/user.model";
498
498
 
499
499
  export const loginController: RequestHandler = async ({ request, response }) => {
500
- const result = await authService.login(User, {
501
- email: request.input("email"),
502
- password: request.input("password"),
503
- }, {
504
- userAgent: request.header("user-agent"),
505
- ip: request.ip,
506
- });
500
+ const result = await authService.login(
501
+ User,
502
+ {
503
+ email: request.input("email"),
504
+ password: request.input("password"),
505
+ },
506
+ {
507
+ userAgent: request.header("user-agent"),
508
+ ip: request.ip,
509
+ },
510
+ );
507
511
 
508
512
  if (!result) {
509
513
  return response.unauthorized({ error: "Invalid credentials" });
@@ -555,8 +559,8 @@ The optional `deviceInfo` carries metadata into the refresh token row:
555
559
  authService.login(User, credentials, {
556
560
  userAgent: request.header("user-agent"),
557
561
  ip: request.ip,
558
- deviceId: "...", // your client-side device fingerprint
559
- familyId: "...", // pre-existing family for token rotation, usually omitted
562
+ deviceId: "...", // your client-side device fingerprint
563
+ familyId: "...", // pre-existing family for token rotation, usually omitted
560
564
  });
561
565
  ```
562
566
 
@@ -570,8 +574,8 @@ import { type RequestHandler } from "@warlock.js/core";
570
574
  export const logoutController: RequestHandler = async ({ request, response }) => {
571
575
  await authService.logout(
572
576
  request.locals.user!,
573
- request.authorizationValue, // access token from the Authorization header
574
- request.input("refreshToken"), // refresh token from the request body
577
+ request.authorizationValue, // access token from the Authorization header
578
+ request.input("refreshToken"), // refresh token from the request body
575
579
  );
576
580
 
577
581
  return response.success({ message: "Logged out" });
@@ -579,6 +583,7 @@ export const logoutController: RequestHandler = async ({ request, response }) =>
579
583
  ```
580
584
 
581
585
  The contract:
586
+
582
587
  - **Pass the access token** → that specific access-token row is deleted.
583
588
  - **Pass the refresh token** → that specific refresh-token row is revoked.
584
589
  - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
@@ -602,10 +607,10 @@ Useful for "logout from all devices" buttons. Fires `token.revoked` per token +
602
607
  import { type RequestHandler } from "@warlock.js/core";
603
608
 
604
609
  export const refreshController: RequestHandler = async ({ request, response }) => {
605
- const tokens = await authService.refreshTokens(
606
- request.input("refreshToken"),
607
- { userAgent: request.header("user-agent"), ip: request.ip },
608
- );
610
+ const tokens = await authService.refreshTokens(request.input("refreshToken"), {
611
+ userAgent: request.header("user-agent"),
612
+ ip: request.ip,
613
+ });
609
614
 
610
615
  if (!tokens) {
611
616
  return response.unauthorized({ error: "Invalid refresh token" });
@@ -624,11 +629,21 @@ Returns a new token pair or `null` (token expired, revoked, or replay-detected).
624
629
  ```ts
625
630
  import { authEvents } from "@warlock.js/auth";
626
631
 
627
- authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
628
- authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
629
- authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
630
- authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
631
- authEvents.on("cleanup.completed", (count) => { /* metrics */ });
632
+ authEvents.on("login.success", (user, tokens, deviceInfo) => {
633
+ /* audit */
634
+ });
635
+ authEvents.on("login.failed", (credentials, reason) => {
636
+ /* alert on brute force */
637
+ });
638
+ authEvents.on("logout", (user) => {
639
+ /* clear server-side session, if any */
640
+ });
641
+ authEvents.on("token.refreshed", (user, newPair, oldToken) => {
642
+ /* track rotation */
643
+ });
644
+ authEvents.on("cleanup.completed", (count) => {
645
+ /* metrics */
646
+ });
632
647
  ```
633
648
 
634
649
  Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `logout.all`, `logout.failsafe`, `token.created`, `token.refreshed`, `token.revoked`, `token.expired`, `token.familyRevoked`, `session.created`, `session.destroyed`, `cleanup.completed`.
@@ -646,6 +661,10 @@ Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `lo
646
661
  - [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — sign-up that issues tokens after creation
647
662
  - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — where the access token gets consumed
648
663
 
664
+ ## Browser sessions for Web pages
665
+
666
+ For cookie sessions read by `@warlock.js/web` pages, log in with `authService.loginWithSessionCookies(request, response, Model, credentials)`. It requires a same-origin `Origin`/`Referer` even without cookies (login CSRF) and sets the access and refresh cookies (`auth.cookie.name`, `auth.cookie.refreshName`, `Path=/`). To share a session across subdomains, configure `auth.cookie.cookieDomain(request)` to return (for example) `".example.com"`; return `undefined` for host-only cookies. With `cookieDomain` configured, always pass the request to cookie helpers so they select the same Domain: `authService.setSessionCookies(response, tokens, request)`, `authService.clearSessionCookies(response, request)`, and `authService.clearAuthCookie(response, options, request)`. Omitting it throws `CookieDomainRequestRequiredError`. Domain clears also remove the legacy host-only names. Resolve pages with `pageSession({ project })` (`web.session`); header beats cookie; `auth.session.maxAge` defaults to `"30d"`. A page session with multiple `userTypes` cannot renew its cookie: the resolver only supplies a renewal user type when exactly one type is allowed, so it reads the session but skips automatic renewal. Multi-instance: share the JWT secret and the cache store, keep clocks in sync.
667
+
649
668
 
650
669
  ## login-with-providers `@warlock.js/auth/login-with-providers/SKILL.md`
651
670
 
@@ -1500,6 +1519,10 @@ export default {
1500
1519
 
1501
1520
  This is an Origin check, not a double-submit CSRF token — cheaper, and closes the gap `SameSite=Lax` leaves open, but it doesn't help a client whose privacy proxy strips both `Origin` and `Referer` (that request is rejected outright, fail-closed, rather than silently allowed). A double-submit token mechanism is deferred to a later release; track it if your threat model includes Origin-stripping clients.
1502
1521
 
1522
+ ## Tooling can read how a route authenticates
1523
+
1524
+ `authMiddleware()` tags the function it returns with a frozen, non-enumerable descriptor under `Symbol.for("warlock.auth")` (exported as `AUTH_MIDDLEWARE_DESCRIPTOR`, typed `AuthMiddlewareDescriptor`): `{ sources: ("header" | { cookie: string })[], userTypes: string[] }`. `warlock generate.openapi` reads it to emit bearer/cookie security for guarded routes. It never appears in logs, spreads or JSON, and changes nothing at runtime. Custom guards without it are simply documented as unauthenticated.
1525
+
1503
1526
  ## Things NOT to do
1504
1527
 
1505
1528
  - Don't call `authMiddleware` outside route definition. It returns a function — the function is what runs per-request. Calling it once per request creates a fresh middleware on every hit (wasteful) and a fresh allowed-types Set (correctness if the input changes per call).
package/package.json CHANGED
@@ -12,13 +12,13 @@
12
12
  "ms": "^2.1.3"
13
13
  },
14
14
  "peerDependencies": {
15
- "@warlock.js/fs": "5.25.0",
16
- "@warlock.js/cache": "5.25.0",
17
- "@warlock.js/cascade": "5.25.0",
18
- "@warlock.js/core": "5.25.0",
19
- "@warlock.js/logger": "5.25.0",
20
- "@warlock.js/seal": "5.25.0",
21
- "@warlock.js/notifications": "5.25.0",
15
+ "@warlock.js/fs": "5.27.0",
16
+ "@warlock.js/cache": "5.27.0",
17
+ "@warlock.js/cascade": "5.27.0",
18
+ "@warlock.js/core": "5.27.0",
19
+ "@warlock.js/logger": "5.27.0",
20
+ "@warlock.js/seal": "5.27.0",
21
+ "@warlock.js/notifications": "5.27.0",
22
22
  "@simplewebauthn/server": "^13.1.0",
23
23
  "jose": "^6.1.0"
24
24
  },
@@ -47,7 +47,7 @@
47
47
  ],
48
48
  "author": "hassanzohdy",
49
49
  "license": "MIT",
50
- "version": "5.25.0",
50
+ "version": "5.27.0",
51
51
  "type": "module",
52
52
  "main": "./esm/index.mjs",
53
53
  "module": "./esm/index.mjs",
@@ -29,13 +29,17 @@ import { type RequestHandler } from "@warlock.js/core";
29
29
  import { User } from "app/users/models/user/user.model";
30
30
 
31
31
  export const loginController: RequestHandler = async ({ request, response }) => {
32
- const result = await authService.login(User, {
33
- email: request.input("email"),
34
- password: request.input("password"),
35
- }, {
36
- userAgent: request.header("user-agent"),
37
- ip: request.ip,
38
- });
32
+ const result = await authService.login(
33
+ User,
34
+ {
35
+ email: request.input("email"),
36
+ password: request.input("password"),
37
+ },
38
+ {
39
+ userAgent: request.header("user-agent"),
40
+ ip: request.ip,
41
+ },
42
+ );
39
43
 
40
44
  if (!result) {
41
45
  return response.unauthorized({ error: "Invalid credentials" });
@@ -87,8 +91,8 @@ The optional `deviceInfo` carries metadata into the refresh token row:
87
91
  authService.login(User, credentials, {
88
92
  userAgent: request.header("user-agent"),
89
93
  ip: request.ip,
90
- deviceId: "...", // your client-side device fingerprint
91
- familyId: "...", // pre-existing family for token rotation, usually omitted
94
+ deviceId: "...", // your client-side device fingerprint
95
+ familyId: "...", // pre-existing family for token rotation, usually omitted
92
96
  });
93
97
  ```
94
98
 
@@ -102,8 +106,8 @@ import { type RequestHandler } from "@warlock.js/core";
102
106
  export const logoutController: RequestHandler = async ({ request, response }) => {
103
107
  await authService.logout(
104
108
  request.locals.user!,
105
- request.authorizationValue, // access token from the Authorization header
106
- request.input("refreshToken"), // refresh token from the request body
109
+ request.authorizationValue, // access token from the Authorization header
110
+ request.input("refreshToken"), // refresh token from the request body
107
111
  );
108
112
 
109
113
  return response.success({ message: "Logged out" });
@@ -111,6 +115,7 @@ export const logoutController: RequestHandler = async ({ request, response }) =>
111
115
  ```
112
116
 
113
117
  The contract:
118
+
114
119
  - **Pass the access token** → that specific access-token row is deleted.
115
120
  - **Pass the refresh token** → that specific refresh-token row is revoked.
116
121
  - **Omit refresh token** → behavior depends on `config.auth.refreshToken.logoutWithoutToken`:
@@ -134,10 +139,10 @@ Useful for "logout from all devices" buttons. Fires `token.revoked` per token +
134
139
  import { type RequestHandler } from "@warlock.js/core";
135
140
 
136
141
  export const refreshController: RequestHandler = async ({ request, response }) => {
137
- const tokens = await authService.refreshTokens(
138
- request.input("refreshToken"),
139
- { userAgent: request.header("user-agent"), ip: request.ip },
140
- );
142
+ const tokens = await authService.refreshTokens(request.input("refreshToken"), {
143
+ userAgent: request.header("user-agent"),
144
+ ip: request.ip,
145
+ });
141
146
 
142
147
  if (!tokens) {
143
148
  return response.unauthorized({ error: "Invalid refresh token" });
@@ -156,11 +161,21 @@ Returns a new token pair or `null` (token expired, revoked, or replay-detected).
156
161
  ```ts
157
162
  import { authEvents } from "@warlock.js/auth";
158
163
 
159
- authEvents.on("login.success", (user, tokens, deviceInfo) => { /* audit */ });
160
- authEvents.on("login.failed", (credentials, reason) => { /* alert on brute force */ });
161
- authEvents.on("logout", (user) => { /* clear server-side session, if any */ });
162
- authEvents.on("token.refreshed", (user, newPair, oldToken) => { /* track rotation */ });
163
- authEvents.on("cleanup.completed", (count) => { /* metrics */ });
164
+ authEvents.on("login.success", (user, tokens, deviceInfo) => {
165
+ /* audit */
166
+ });
167
+ authEvents.on("login.failed", (credentials, reason) => {
168
+ /* alert on brute force */
169
+ });
170
+ authEvents.on("logout", (user) => {
171
+ /* clear server-side session, if any */
172
+ });
173
+ authEvents.on("token.refreshed", (user, newPair, oldToken) => {
174
+ /* track rotation */
175
+ });
176
+ authEvents.on("cleanup.completed", (count) => {
177
+ /* metrics */
178
+ });
164
179
  ```
165
180
 
166
181
  Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `logout.all`, `logout.failsafe`, `token.created`, `token.refreshed`, `token.revoked`, `token.expired`, `token.familyRevoked`, `session.created`, `session.destroyed`, `cleanup.completed`.
@@ -180,4 +195,4 @@ Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `lo
180
195
 
181
196
  ## Browser sessions for Web pages
182
197
 
183
- For cookie sessions read by `@warlock.js/web` pages, log in with `authService.loginWithSessionCookies(request, response, Model, credentials)`. It requires a same-origin `Origin`/`Referer` even without cookies (login CSRF) and sets the access and refresh cookies (`auth.cookie.name`, `auth.cookie.refreshName`, `Path=/`). Log out with `authService.logout(user, access, refresh)` then `authService.clearSessionCookies(response)`. Resolve pages with `pageSession({ project })` (`web.session`); header beats cookie; `auth.session.maxAge` defaults to `"30d"`. Multi-instance: share the JWT secret and the cache store, keep clocks in sync.
198
+ For cookie sessions read by `@warlock.js/web` pages, log in with `authService.loginWithSessionCookies(request, response, Model, credentials)`. It requires a same-origin `Origin`/`Referer` even without cookies (login CSRF) and sets the access and refresh cookies (`auth.cookie.name`, `auth.cookie.refreshName`, `Path=/`). To share a session across subdomains, configure `auth.cookie.cookieDomain(request)` to return (for example) `".example.com"`; return `undefined` for host-only cookies. With `cookieDomain` configured, always pass the request to cookie helpers so they select the same Domain: `authService.setSessionCookies(response, tokens, request)`, `authService.clearSessionCookies(response, request)`, and `authService.clearAuthCookie(response, options, request)`. Omitting it throws `CookieDomainRequestRequiredError`. Domain clears also remove the legacy host-only names. Resolve pages with `pageSession({ project })` (`web.session`); header beats cookie; `auth.session.maxAge` defaults to `"30d"`. A page session with multiple `userTypes` cannot renew its cookie: the resolver only supplies a renewal user type when exactly one type is allowed, so it reads the session but skips automatic renewal. Multi-instance: share the JWT secret and the cache store, keep clocks in sync.
@@ -284,6 +284,10 @@ export default {
284
284
 
285
285
  This is an Origin check, not a double-submit CSRF token — cheaper, and closes the gap `SameSite=Lax` leaves open, but it doesn't help a client whose privacy proxy strips both `Origin` and `Referer` (that request is rejected outright, fail-closed, rather than silently allowed). A double-submit token mechanism is deferred to a later release; track it if your threat model includes Origin-stripping clients.
286
286
 
287
+ ## Tooling can read how a route authenticates
288
+
289
+ `authMiddleware()` tags the function it returns with a frozen, non-enumerable descriptor under `Symbol.for("warlock.auth")` (exported as `AUTH_MIDDLEWARE_DESCRIPTOR`, typed `AuthMiddlewareDescriptor`): `{ sources: ("header" | { cookie: string })[], userTypes: string[] }`. `warlock generate.openapi` reads it to emit bearer/cookie security for guarded routes. It never appears in logs, spreads or JSON, and changes nothing at runtime. Custom guards without it are simply documented as unauthenticated.
290
+
287
291
  ## Things NOT to do
288
292
 
289
293
  - Don't call `authMiddleware` outside route definition. It returns a function — the function is what runs per-request. Calling it once per request creates a fresh middleware on every hit (wasteful) and a fresh allowed-types Set (correctness if the input changes per call).