@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.
- package/CHANGELOG.md +25 -9
- package/esm/contracts/index.d.mts +1 -1
- package/esm/contracts/request-locals.d.mts +1 -1
- package/esm/contracts/types.d.mts +8 -3
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/index.d.mts +4 -4
- package/esm/index.mjs +3 -3
- package/esm/middleware/auth.middleware.d.mts +19 -1
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +19 -1
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/index.d.mts +1 -1
- package/esm/middleware/index.mjs +1 -1
- package/esm/services/auth-config.mjs +3 -1
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +9 -4
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +35 -8
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/index.d.mts +1 -1
- package/esm/services/index.mjs +1 -1
- package/esm/services/resolve-request-user.d.mts +3 -2
- package/esm/services/resolve-request-user.d.mts.map +1 -1
- package/esm/services/resolve-request-user.mjs +8 -2
- package/esm/services/resolve-request-user.mjs.map +1 -1
- package/esm/session/page-session.d.mts +3 -1
- package/esm/session/page-session.d.mts.map +1 -1
- package/esm/session/page-session.mjs +2 -1
- package/esm/session/page-session.mjs.map +1 -1
- package/llms-full.txt +43 -20
- package/package.json +8 -8
- package/skills/handle-login-and-logout/SKILL.md +36 -21
- 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":"
|
|
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":"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"}
|
|
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":"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"}
|
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(
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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: "...",
|
|
559
|
-
familyId: "...",
|
|
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,
|
|
574
|
-
request.input("refreshToken"),
|
|
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.
|
|
607
|
-
|
|
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) => {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
authEvents.on("
|
|
631
|
-
|
|
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.
|
|
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.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.
|
|
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(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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: "...",
|
|
91
|
-
familyId: "...",
|
|
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,
|
|
106
|
-
request.input("refreshToken"),
|
|
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.
|
|
139
|
-
|
|
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) => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
authEvents.on("
|
|
163
|
-
|
|
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=/`).
|
|
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).
|