@warlock.js/auth 5.19.1 → 5.21.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 -1
- package/esm/contracts/request-locals.d.mts +5 -0
- package/esm/contracts/request-locals.d.mts.map +1 -1
- package/esm/contracts/types.d.mts +13 -0
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/index.d.mts +3 -1
- package/esm/index.mjs +6 -3
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +37 -114
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/login-throttle.middleware.mjs +6 -2
- package/esm/middleware/login-throttle.middleware.mjs.map +1 -1
- package/esm/services/auth-config.mjs +2 -0
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +36 -1
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +67 -0
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/index.d.mts +2 -1
- package/esm/services/index.mjs +1 -0
- package/esm/services/jwt.d.mts.map +1 -1
- package/esm/services/jwt.mjs +4 -1
- package/esm/services/jwt.mjs.map +1 -1
- package/esm/services/resolve-request-user.d.mts +49 -0
- package/esm/services/resolve-request-user.d.mts.map +1 -0
- package/esm/services/resolve-request-user.mjs +131 -0
- package/esm/services/resolve-request-user.mjs.map +1 -0
- package/esm/session/index.d.mts +1 -0
- package/esm/session/index.mjs +3 -0
- package/esm/session/page-session.d.mts +49 -0
- package/esm/session/page-session.d.mts.map +1 -0
- package/esm/session/page-session.mjs +119 -0
- package/esm/session/page-session.mjs.map +1 -0
- package/esm/utils/index.mjs +1 -1
- package/llms-full.txt +1565 -1564
- package/llms.txt +8 -8
- package/package.json +9 -9
- package/skills/customize-token-storage/SKILL.md +160 -160
- package/skills/customize-user-type/SKILL.md +168 -168
- package/skills/handle-login-and-logout/SKILL.md +183 -179
- package/skills/index.md +22 -0
- package/skills/login-with-providers/SKILL.md +283 -283
- package/skills/manage-tokens/SKILL.md +15 -15
- package/skills/protect-routes/SKILL.md +288 -288
- package/skills/register-user/SKILL.md +147 -147
- package/skills/run-auth-commands/SKILL.md +159 -159
- package/skills/throttle-login-attempts/SKILL.md +2 -1
- package/skills/verify-email-and-reset-password/SKILL.md +137 -137
package/esm/services/jwt.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jwt.mjs","names":[],"sources":["../../../../../../../auth/src/services/jwt.ts"],"sourcesContent":["import { createSigner, createVerifier, type SignerOptions, type VerifierOptions } from \"fast-jwt\";\
|
|
1
|
+
{"version":3,"file":"jwt.mjs","names":[],"sources":["../../../../../../../auth/src/services/jwt.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { createSigner, createVerifier, type SignerOptions, type VerifierOptions } from \"fast-jwt\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\nimport { authConfig } from \"./auth-config\";\n\nconst getSecretKey = () => authConfig.accessToken.secret();\nconst getAlgorithm = () => authConfig.accessToken.algorithm();\n\n// Refresh tokens may declare their own secret; when unset/empty we fall back to\n// the access-token secret (the documented optional behavior).\nconst getRefreshSecretKey = () => authConfig.refreshToken.secret() || getSecretKey();\n\n/**\n * Token class. Stamped as the `tokenType` claim on every signed token and\n * checked on verify so an access token can never be accepted where a refresh\n * token is expected (and vice versa) — even when both share the same secret\n * under the documented refresh-secret fallback. Legacy tokens minted before\n * this claim existed carry no `tokenType` and remain accepted; a *mismatched*\n * type is always rejected.\n */\nexport type TokenType = \"access\" | \"refresh\";\n\nconst ACCESS_TOKEN_TYPE: TokenType = \"access\";\nconst REFRESH_TOKEN_TYPE: TokenType = \"refresh\";\n\n/**\n * Error codes that mean \"the credential itself is bad\", as opposed to \"this\n * server could not check it\". This is an ALLOWLIST and must stay one. The codes\n * deliberately left out — `FAST_JWT_INVALID_KEY`, `FAST_JWT_MISSING_KEY`,\n * `FAST_JWT_KEY_FETCHING_ERROR`, `FAST_JWT_INVALID_OPTION`,\n * `FAST_JWT_VERIFY_ERROR`, and `FAST_JWT_SIGN_ERROR` — describe a broken\n * server, not a broken token, and so does every unknown future code.\n */\nconst INVALID_CREDENTIAL_ERROR_CODES = new Set<string>([\n \"FAST_JWT_MALFORMED\",\n \"FAST_JWT_INVALID_SIGNATURE\",\n \"FAST_JWT_MISSING_SIGNATURE\",\n \"FAST_JWT_INVALID_ALGORITHM\",\n \"FAST_JWT_EXPIRED\",\n \"FAST_JWT_INACTIVE\",\n // How a token carrying no `exp` is rejected — `jwt.verify` forces `exp` into\n // `requiredClaims`, so this code IS the missing-deadline guard firing.\n \"FAST_JWT_MISSING_REQUIRED_CLAIM\",\n \"FAST_JWT_INVALID_CLAIM_VALUE\",\n \"FAST_JWT_INVALID_CLAIM_TYPE\",\n \"FAST_JWT_INVALID_CRIT_HEADER\",\n \"FAST_JWT_INVALID_TYPE\",\n \"FAST_JWT_INVALID_PAYLOAD\",\n AuthErrorCodes.InvalidTokenType,\n]);\n\n/**\n * Only coded credential failures become authentication misses. Plain errors,\n * including missing-secret configuration failures, propagate to the central\n * server-error path instead of reading as \"everyone's token is bad\".\n */\nexport function isInvalidCredentialError(error: unknown): boolean {\n const code = (error as { code?: unknown } | null | undefined)?.code;\n\n return typeof code === \"string\" && INVALID_CREDENTIAL_ERROR_CODES.has(code);\n}\n\n/**\n * A `tokenType` claim that does not match what the caller expects. This IS a\n * bad credential (an access-token cookie can only hold a refresh token\n * through an app bug) — but unlike `fast-jwt`'s own rejections it was\n * previously a plain `Error`, unclassifiable by a caller that wants to answer\n * 401 without string-matching a message that could reword on any release.\n * `code` mirrors how `fast-jwt`'s `TokenError` carries its own code, so a\n * caller can classify both through `isInvalidCredentialError` above.\n */\nexport class TokenTypeError extends Error {\n readonly code = AuthErrorCodes.InvalidTokenType;\n\n constructor(expected: TokenType, actual: string) {\n super(`Invalid token type: expected \"${expected}\", received \"${actual}\".`);\n this.name = \"TokenTypeError\";\n }\n}\n\n/**\n * Reject the token when its `tokenType` claim is present and does not match the\n * expected class. Absent claim ⇒ legacy token, accepted (backward compatible).\n */\nfunction assertTokenType(decoded: unknown, expected: TokenType): void {\n const actual = (decoded as { tokenType?: unknown } | null | undefined)?.tokenType;\n\n if (typeof actual === \"string\" && actual !== expected) {\n throw new TokenTypeError(expected, actual);\n }\n}\n\n/**\n * Claims no token may be missing, whatever the caller asks for.\n *\n * A JWT with no `exp` is not \"a token with a long life\" — it is a credential\n * with *no* life, because a verifier with no deadline to check simply succeeds,\n * forever (measured against `fast-jwt@6.2.4`: a token with no `exp` verifies\n * unchanged at `clockTimestamp` + 100 years). Nothing in this package can mint\n * one as of 4.12.0, but tokens minted by an earlier version are already in the\n * wild, so the rejection lives on the *verify* side where it catches a token\n * from any version, including one signed by a service this package never ran.\n *\n * There is no legitimate source to preserve: an app that wants a token that\n * effectively never expires sets `expiresIn: NO_EXPIRATION` (`\"100y\"`), which\n * mints a real `exp` roughly a century out (measured: `ms(\"100y\")` ⇒\n * `3155760000000`, `exp - iat` ⇒ `3155760000` seconds). \"No deadline\" and \"a\n * distant deadline\" are different things, and only the second one is asked for.\n */\nconst REQUIRED_CLAIMS = [\"exp\"];\n\n/**\n * Union the caller's `requiredClaims` with the mandatory ones — a caller may\n * add requirements, never drop them.\n */\nfunction withRequiredClaims(callerClaims?: string[]): string[] {\n if (!callerClaims?.length) return REQUIRED_CLAIMS;\n\n return [...new Set([...callerClaims, ...REQUIRED_CLAIMS])];\n}\n\nexport const jwt = {\n /**\n * Generate a new JWT token for the user.\n * @param payload The payload to encode in the JWT token.\n */\n async generate(\n payload: any,\n {\n key = getSecretKey(),\n algorithm = getAlgorithm(),\n ...options\n }: SignerOptions & { key?: string } = {},\n ): Promise<string> {\n // Create a signer function with predefined options\n const sign = createSigner({ key, ...options, algorithm });\n\n const token = await sign({ jti: randomUUID(), ...payload, tokenType: ACCESS_TOKEN_TYPE });\n return token;\n },\n\n /**\n * Verify the given token.\n * @param token The JWT token to verify.\n * @returns The decoded token payload if verification is successful.\n */\n async verify<T = unknown>(\n token: string,\n {\n key = getSecretKey(),\n algorithms = [getAlgorithm()],\n requiredClaims,\n ...options\n }: VerifierOptions & { key?: string } = {},\n ): Promise<T> {\n const verify = createVerifier({\n key,\n ...options,\n algorithms,\n requiredClaims: withRequiredClaims(requiredClaims),\n });\n\n const decoded = await verify(token as string);\n\n assertTokenType(decoded, ACCESS_TOKEN_TYPE);\n\n return decoded;\n },\n\n /**\n * Generate a new refresh token for the user.\n */\n async generateRefreshToken(\n payload: any,\n {\n key = getRefreshSecretKey(),\n expiresIn,\n algorithm = getAlgorithm(),\n ...options\n }: SignerOptions & { key?: string } = {},\n ): Promise<string> {\n const sign = createSigner({ key, expiresIn, algorithm, ...options });\n return sign({ jti: randomUUID(), ...payload, tokenType: REFRESH_TOKEN_TYPE });\n },\n\n /**\n * Verify the given refresh token.\n */\n async verifyRefreshToken<T = unknown>(\n token: string,\n {\n key = getRefreshSecretKey(),\n algorithms = [getAlgorithm()],\n requiredClaims,\n ...options\n }: VerifierOptions & { key?: string } = {},\n ): Promise<T> {\n const verify = createVerifier({\n key,\n algorithms,\n ...options,\n requiredClaims: withRequiredClaims(requiredClaims),\n });\n\n const decoded = await verify(token);\n\n assertTokenType(decoded, REFRESH_TOKEN_TYPE);\n\n return decoded;\n },\n};\n"],"mappings":";;;;;;AAKA,MAAM,qBAAqB,WAAW,YAAY,OAAO;AACzD,MAAM,qBAAqB,WAAW,YAAY,UAAU;AAI5D,MAAM,4BAA4B,WAAW,aAAa,OAAO,KAAK,aAAa;AAYnF,MAAM,oBAA+B;AACrC,MAAM,qBAAgC;;;;;;;;;AAUtC,MAAM,iCAAiC,IAAI,IAAY;CACrD;CACA;CACA;CACA;CACA;CACA;CAGA;CACA;CACA;CACA;CACA;CACA;;AAEF,CAAC;;;;;;AAOD,SAAgB,yBAAyB,OAAyB;CAChE,MAAM,OAAQ,OAAiD;CAE/D,OAAO,OAAO,SAAS,YAAY,+BAA+B,IAAI,IAAI;AAC5E;;;;;;;;;;AAWA,IAAa,iBAAb,cAAoC,MAAM;CAGxC,YAAY,UAAqB,QAAgB;EAC/C,MAAM,iCAAiC,SAAS,eAAe,OAAO,GAAG;;EACzE,KAAK,OAAO;CACd;AACF;;;;;AAMA,SAAS,gBAAgB,SAAkB,UAA2B;CACpE,MAAM,SAAU,SAAwD;CAExE,IAAI,OAAO,WAAW,YAAY,WAAW,UAC3C,MAAM,IAAI,eAAe,UAAU,MAAM;AAE7C;;;;;;;;;;;;;;;;;;AAmBA,MAAM,kBAAkB,CAAC,KAAK;;;;;AAM9B,SAAS,mBAAmB,cAAmC;CAC7D,IAAI,CAAC,cAAc,QAAQ,OAAO;CAElC,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,cAAc,GAAG,eAAe,CAAC,CAAC;AAC3D;AAEA,MAAa,MAAM;;;;;CAKjB,MAAM,SACJ,SACA,EACE,MAAM,aAAa,GACnB,YAAY,aAAa,GACzB,GAAG,YACiC,CAAC,GACtB;EAKjB,OAAO,MAHM,aAAa;GAAE;GAAK,GAAG;GAAS;EAAU,CAEhC,CAAC,CAAC;GAAE,KAAK,WAAW;GAAG,GAAG;GAAS,WAAW;EAAkB,CAAC;CAE1F;;;;;;CAOA,MAAM,OACJ,OACA,EACE,MAAM,aAAa,GACnB,aAAa,CAAC,aAAa,CAAC,GAC5B,gBACA,GAAG,YACmC,CAAC,GAC7B;EAQZ,MAAM,UAAU,MAPD,eAAe;GAC5B;GACA,GAAG;GACH;GACA,gBAAgB,mBAAmB,cAAc;EACnD,CAE2B,CAAC,CAAC,KAAe;EAE5C,gBAAgB,SAAS,iBAAiB;EAE1C,OAAO;CACT;;;;CAKA,MAAM,qBACJ,SACA,EACE,MAAM,oBAAoB,GAC1B,WACA,YAAY,aAAa,GACzB,GAAG,YACiC,CAAC,GACtB;EAEjB,OADa,aAAa;GAAE;GAAK;GAAW;GAAW,GAAG;EAAQ,CACxD,CAAC,CAAC;GAAE,KAAK,WAAW;GAAG,GAAG;GAAS,WAAW;EAAmB,CAAC;CAC9E;;;;CAKA,MAAM,mBACJ,OACA,EACE,MAAM,oBAAoB,GAC1B,aAAa,CAAC,aAAa,CAAC,GAC5B,gBACA,GAAG,YACmC,CAAC,GAC7B;EAQZ,MAAM,UAAU,MAPD,eAAe;GAC5B;GACA;GACA,GAAG;GACH,gBAAgB,mBAAmB,cAAc;EACnD,CAE2B,CAAC,CAAC,KAAK;EAElC,gBAAgB,SAAS,kBAAkB;EAE3C,OAAO;CACT;AACF"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { TokenFrom } from "../contracts/types.mjs";
|
|
2
|
+
import { Request, Response } from "@warlock.js/core";
|
|
3
|
+
|
|
4
|
+
//#region ../auth/src/services/resolve-request-user.d.ts
|
|
5
|
+
type ResolveRequestUserOptions = {
|
|
6
|
+
/** Where the access token is read from. Defaults to `"header"`. */tokenFrom?: TokenFrom; /** Refresh credential source; enables one automatic renewal per call. */
|
|
7
|
+
refreshCredential?: TokenFrom; /** Refresh overlap window forwarded to `authService.renewAutomaticSession`. */
|
|
8
|
+
overlapMs?: number; /** User types allowed to pass. Empty means any authenticated type. */
|
|
9
|
+
allowedTypes?: string[]; /** Absolute family lifetime in ms; rotation never extends a family past it. */
|
|
10
|
+
maxAgeMs?: number; /** The single user type a renewal mints for. Required for renewal. */
|
|
11
|
+
renewalUserType?: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Why a request did not resolve to a user. The middleware maps each reason to
|
|
15
|
+
* its own response; `resolveRequestUser` collapses them all to `null`.
|
|
16
|
+
*/
|
|
17
|
+
type RequestUserFailure = "missing" | "invalid" | "forbidden" | "unauthorized";
|
|
18
|
+
type RequestUserOutcome<TUser = any> = {
|
|
19
|
+
user: TUser;
|
|
20
|
+
failure?: undefined;
|
|
21
|
+
} | {
|
|
22
|
+
user: null;
|
|
23
|
+
failure: RequestUserFailure;
|
|
24
|
+
};
|
|
25
|
+
/** Read one credential (access or refresh) from the header or a cookie. */
|
|
26
|
+
declare function readCredential(request: Request, tokenFrom: TokenFrom): string;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a request's credential to a user, reporting WHY it failed.
|
|
29
|
+
*
|
|
30
|
+
* Side effects match what `authMiddleware` always did: `request.locals.user` is
|
|
31
|
+
* cleared on every failure, `request.decodedAccessToken` is set once a token
|
|
32
|
+
* verifies, and stale rows are destroyed. It does NOT set `request.locals.user`
|
|
33
|
+
* on success — the caller decides that.
|
|
34
|
+
*
|
|
35
|
+
* At most one renewal is attempted per call. The renewed access token is
|
|
36
|
+
* checked through the same path; no handler or unsafe request replay happens.
|
|
37
|
+
*/
|
|
38
|
+
declare function resolveRequestUserOutcome(request: Request, response: Response, options?: ResolveRequestUserOptions): Promise<RequestUserOutcome>;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the request's credential to its user model, or `null` when there is
|
|
41
|
+
* none (missing, invalid, expired, wrong type, or not allowed to authenticate).
|
|
42
|
+
*
|
|
43
|
+
* Includes the middleware's automatic renewal when `options.refreshCredential`
|
|
44
|
+
* is set. Use `resolveRequestUserOutcome` when the failure reason matters.
|
|
45
|
+
*/
|
|
46
|
+
declare function resolveRequestUser(request: Request, response: Response, options?: ResolveRequestUserOptions): Promise<any>;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { RequestUserFailure, RequestUserOutcome, ResolveRequestUserOptions, readCredential, resolveRequestUser, resolveRequestUserOutcome };
|
|
49
|
+
//# sourceMappingURL=resolve-request-user.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-request-user.d.mts","names":[],"sources":["../../../../../../../auth/src/services/resolve-request-user.ts"],"mappings":";;;;KAgBY,yBAAA;qEAEV,SAAA,GAAY,SAAA,EAFuB;EAInC,iBAAA,GAAoB,SAAS,EAAA;EAE7B,SAAA,WAJY;EAMZ,YAAA,aAJoB;EAMpB,QAAA,WAFA;EAIA,eAAA;AAAA;;AAAe;AAOjB;;KAAY,kBAAA;AAAA,KAEA,kBAAA;EACN,IAAA,EAAM,KAAA;EAAO,OAAA;AAAA;EACb,IAAA;EAAY,OAAA,EAAS,kBAAkB;AAAA;;iBAoB7B,cAAA,CAAe,OAAA,EAAS,OAAA,EAAS,SAAA,EAAW,SAAS;;;;;;AApBxB;AAoB7C;;;;;iBAqBsB,yBAAA,CACpB,OAAA,EAAS,OAAA,EACT,QAAA,EAAU,QAAA,EACV,OAAA,GAAS,yBAAA,GACR,OAAA,CAAQ,kBAAA;;;;AAzB0D;AAqBrE;;;iBAqHgB,kBAAA,CACd,OAAA,EAAS,OAAA,EACT,QAAA,EAAU,QAAA,EACV,OAAA,GAAS,yBAAA,GACR,OAAA"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { AccessToken } from "../models/access-token/access-token.model.mjs";
|
|
2
|
+
import "../models/access-token/index.mjs";
|
|
3
|
+
import { isInvalidCredentialError, jwt } from "./jwt.mjs";
|
|
4
|
+
import { authService } from "./auth.service.mjs";
|
|
5
|
+
import { config } from "@warlock.js/core";
|
|
6
|
+
import { log } from "@warlock.js/logger";
|
|
7
|
+
|
|
8
|
+
//#region ../auth/src/services/resolve-request-user.ts
|
|
9
|
+
/**
|
|
10
|
+
* Whether the persisted row says the token is dead.
|
|
11
|
+
*
|
|
12
|
+
* The answer belongs to the model (`AccessToken.isExpired`), so a registered
|
|
13
|
+
* override that renames or reshapes its expiry column stays authoritative. A
|
|
14
|
+
* row that cannot answer at all — an override that dropped the getter — is
|
|
15
|
+
* treated as **expired**: a check must never quietly answer "fine" when it has
|
|
16
|
+
* nothing to check.
|
|
17
|
+
*
|
|
18
|
+
* This is independent of the `exp` claim required by `jwt.verify`: that guard
|
|
19
|
+
* catches a token whose *claims* carry no deadline; this one catches a token
|
|
20
|
+
* whose *row* says the deadline has passed.
|
|
21
|
+
*/
|
|
22
|
+
function accessTokenRowIsExpired(accessToken) {
|
|
23
|
+
return typeof accessToken.isExpired === "boolean" ? accessToken.isExpired : true;
|
|
24
|
+
}
|
|
25
|
+
/** Read one credential (access or refresh) from the header or a cookie. */
|
|
26
|
+
function readCredential(request, tokenFrom) {
|
|
27
|
+
if (tokenFrom === "header") return request.authorizationValue;
|
|
28
|
+
const value = request.cookie(tokenFrom.slice(7));
|
|
29
|
+
return value ? String(value) : "";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a request's credential to a user, reporting WHY it failed.
|
|
33
|
+
*
|
|
34
|
+
* Side effects match what `authMiddleware` always did: `request.locals.user` is
|
|
35
|
+
* cleared on every failure, `request.decodedAccessToken` is set once a token
|
|
36
|
+
* verifies, and stale rows are destroyed. It does NOT set `request.locals.user`
|
|
37
|
+
* on success — the caller decides that.
|
|
38
|
+
*
|
|
39
|
+
* At most one renewal is attempted per call. The renewed access token is
|
|
40
|
+
* checked through the same path; no handler or unsafe request replay happens.
|
|
41
|
+
*/
|
|
42
|
+
async function resolveRequestUserOutcome(request, response, options = {}) {
|
|
43
|
+
const tokenFrom = options.tokenFrom ?? "header";
|
|
44
|
+
const allowedTypes = options.allowedTypes ?? [];
|
|
45
|
+
const { refreshCredential } = options;
|
|
46
|
+
let authorizationValue = readCredential(request, tokenFrom);
|
|
47
|
+
let renewalAttempted = false;
|
|
48
|
+
const renew = async () => {
|
|
49
|
+
if (!refreshCredential || renewalAttempted) return false;
|
|
50
|
+
renewalAttempted = true;
|
|
51
|
+
const refreshToken = readCredential(request, refreshCredential);
|
|
52
|
+
if (!refreshToken) return false;
|
|
53
|
+
const pair = await authService.renewAutomaticSession(refreshToken, options.renewalUserType, {
|
|
54
|
+
overlapMs: options.overlapMs,
|
|
55
|
+
maxAgeMs: options.maxAgeMs
|
|
56
|
+
});
|
|
57
|
+
if (!pair?.refreshToken) return false;
|
|
58
|
+
authService.setAuthCookie(response, pair.accessToken, { name: tokenFrom.slice(7) });
|
|
59
|
+
authService.setAuthCookie(response, pair.refreshToken, { name: refreshCredential.slice(7) });
|
|
60
|
+
authorizationValue = pair.accessToken.token;
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
const fail = (failure) => {
|
|
64
|
+
request.locals.user = void 0;
|
|
65
|
+
return {
|
|
66
|
+
user: null,
|
|
67
|
+
failure
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
let decoded;
|
|
71
|
+
let accessToken;
|
|
72
|
+
while (true) {
|
|
73
|
+
if (!authorizationValue) {
|
|
74
|
+
request.locals.user = void 0;
|
|
75
|
+
if (await renew()) continue;
|
|
76
|
+
return fail("missing");
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
decoded = await jwt.verify(authorizationValue);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!isInvalidCredentialError(error)) throw error;
|
|
82
|
+
log.error("http", "auth", error);
|
|
83
|
+
request.locals.user = void 0;
|
|
84
|
+
if (await renew()) continue;
|
|
85
|
+
return fail("invalid");
|
|
86
|
+
}
|
|
87
|
+
request.decodedAccessToken = decoded;
|
|
88
|
+
accessToken = await config.key("auth.accessToken.model", AccessToken).findByToken(authorizationValue);
|
|
89
|
+
if (!accessToken) {
|
|
90
|
+
request.locals.user = void 0;
|
|
91
|
+
if (await renew()) continue;
|
|
92
|
+
return fail("invalid");
|
|
93
|
+
}
|
|
94
|
+
if (accessTokenRowIsExpired(accessToken)) {
|
|
95
|
+
await accessToken.destroy();
|
|
96
|
+
request.locals.user = void 0;
|
|
97
|
+
if (await renew()) continue;
|
|
98
|
+
return fail("invalid");
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const userType = decoded.userType ?? accessToken.userType;
|
|
103
|
+
if (allowedTypes.length && !allowedTypes.includes(userType)) return {
|
|
104
|
+
user: null,
|
|
105
|
+
failure: "forbidden"
|
|
106
|
+
};
|
|
107
|
+
const UserModel = config.key(`auth.userType.${userType}`);
|
|
108
|
+
if (!UserModel) throw new Error(`User type ${userType} is unknown type.`);
|
|
109
|
+
const currentUser = await UserModel.find(decoded.id);
|
|
110
|
+
if (!currentUser) {
|
|
111
|
+
await accessToken.destroy();
|
|
112
|
+
return fail("invalid");
|
|
113
|
+
}
|
|
114
|
+
if (!await authService.canAuthenticate(currentUser)) return fail("unauthorized");
|
|
115
|
+
return { user: currentUser };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the request's credential to its user model, or `null` when there is
|
|
119
|
+
* none (missing, invalid, expired, wrong type, or not allowed to authenticate).
|
|
120
|
+
*
|
|
121
|
+
* Includes the middleware's automatic renewal when `options.refreshCredential`
|
|
122
|
+
* is set. Use `resolveRequestUserOutcome` when the failure reason matters.
|
|
123
|
+
*/
|
|
124
|
+
function resolveRequestUser(request, response, options = {}) {
|
|
125
|
+
if (!request.locals.session) request.locals.session = resolveRequestUserOutcome(request, response, options).then((outcome) => outcome.user);
|
|
126
|
+
return request.locals.session;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
export { readCredential, resolveRequestUser, resolveRequestUserOutcome };
|
|
131
|
+
//# sourceMappingURL=resolve-request-user.mjs.map
|
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { PageSessionOptions, PageSessionResolver, SessionMiddlewareOptions, SessionResolverOptions, SessionSource, pageSession, sessionMiddleware } from "./page-session.mjs";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Middleware, Request, Response } from "@warlock.js/core";
|
|
2
|
+
|
|
3
|
+
//#region ../auth/src/session/page-session.d.ts
|
|
4
|
+
/** Credential sources a session can be read from. */
|
|
5
|
+
type SessionSource = "cookie" | "header";
|
|
6
|
+
/**
|
|
7
|
+
* The seam web consumes. Declared here, not imported: web and auth never
|
|
8
|
+
* depend on each other, and `pageSession()` satisfies web's `SessionResolver`
|
|
9
|
+
* structurally.
|
|
10
|
+
*/
|
|
11
|
+
type PageSessionResolver<TModel = unknown, TUser = unknown> = {
|
|
12
|
+
resolve(request: Request, response: Response): Promise<{
|
|
13
|
+
model: TModel;
|
|
14
|
+
user: TUser;
|
|
15
|
+
} | null>;
|
|
16
|
+
};
|
|
17
|
+
type SessionResolverOptions = {
|
|
18
|
+
/** Access cookie name. @default auth.cookie.name */cookie?: string; /** Refresh cookie name. @default auth.cookie.refreshName */
|
|
19
|
+
refreshCookie?: string; /** Credential sources. A present Authorization header always wins. @default ["cookie", "header"] */
|
|
20
|
+
sources?: SessionSource[]; /** Renew an expired access cookie from the refresh cookie. Cookie source only. @default true */
|
|
21
|
+
renew?: boolean; /** Allowed user types. @default auth.defaultUserType */
|
|
22
|
+
userTypes?: string | string[]; /** Absolute family lifetime, an `ms` string or milliseconds. @default auth.session.maxAge, else "30d" */
|
|
23
|
+
maxAge?: string | number; /** Refresh overlap window forwarded to renewal. */
|
|
24
|
+
overlapMs?: number;
|
|
25
|
+
};
|
|
26
|
+
type PageSessionOptions<TModel, TUser> = SessionResolverOptions & {
|
|
27
|
+
/** Model → wire projection. Required: a model never crosses the wire by default. */project: (model: TModel) => TUser | Promise<TUser>;
|
|
28
|
+
};
|
|
29
|
+
type SessionMiddlewareOptions = SessionResolverOptions & {
|
|
30
|
+
/** Let guests through with no user instead of a 401. @default false */optional?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Session resolver for pages: cookie-or-header credential, reactive renewal,
|
|
34
|
+
* one resolution per request. Plug it into `web.session`.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* export default { session: pageSession({ project: (user: User) => userResource(user) }) };
|
|
38
|
+
*/
|
|
39
|
+
declare function pageSession<TModel = any, TUser = unknown>(options: PageSessionOptions<TModel, TUser>): PageSessionResolver<TModel, TUser>;
|
|
40
|
+
/**
|
|
41
|
+
* Middleware for cookie API routes over the same resolver as `pageSession`.
|
|
42
|
+
* A cookie-authenticated unsafe request must pass the Origin/Referer check
|
|
43
|
+
* (renewal can mint cookies, so it runs even when the access cookie is gone).
|
|
44
|
+
* Guests get a 401 unless `optional`. The user lands on `request.locals.user`.
|
|
45
|
+
*/
|
|
46
|
+
declare function sessionMiddleware(options?: SessionMiddlewareOptions): Middleware;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { PageSessionOptions, PageSessionResolver, SessionMiddlewareOptions, SessionResolverOptions, SessionSource, pageSession, sessionMiddleware };
|
|
49
|
+
//# sourceMappingURL=page-session.d.mts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
|
|
2
|
+
import { CsrfOriginMismatchError, assertCsrfOriginAllowed, requiresCsrfOriginCheck } from "../middleware/csrf-origin-check.mjs";
|
|
3
|
+
import { authConfig } from "../services/auth-config.mjs";
|
|
4
|
+
import { resolveRequestUser } from "../services/resolve-request-user.mjs";
|
|
5
|
+
import { config, t } from "@warlock.js/core";
|
|
6
|
+
import { log } from "@warlock.js/logger";
|
|
7
|
+
import ms from "ms";
|
|
8
|
+
|
|
9
|
+
//#region ../auth/src/session/page-session.ts
|
|
10
|
+
/** Absolute family lifetime used when `session.maxAge` is not configured. */
|
|
11
|
+
const DEFAULT_SESSION_MAX_AGE = "30d";
|
|
12
|
+
function resolveMaxAgeMs(maxAge) {
|
|
13
|
+
const raw = maxAge ?? config.key("auth.session.maxAge", DEFAULT_SESSION_MAX_AGE);
|
|
14
|
+
const parsed = typeof raw === "number" ? raw : ms(raw);
|
|
15
|
+
if (typeof parsed !== "number" || !Number.isFinite(parsed) || parsed <= 0) throw new Error(`session.maxAge: ${JSON.stringify(raw)} is not a valid ms duration — use a positive duration such as "30d".`);
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
function resolveUserTypes(value) {
|
|
19
|
+
if (value !== void 0) return Array.isArray(value) ? value : [value];
|
|
20
|
+
const configured = config.key("auth.defaultUserType");
|
|
21
|
+
return configured ? [configured] : [];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The shared "credential → model" step behind `pageSession` and
|
|
25
|
+
* `sessionMiddleware`. Resolved once per request through
|
|
26
|
+
* `resolveRequestUser`'s single-flight memo.
|
|
27
|
+
*/
|
|
28
|
+
function createModelResolver(options) {
|
|
29
|
+
const sources = options.sources?.length ? options.sources : ["cookie", "header"];
|
|
30
|
+
const useHeader = sources.includes("header");
|
|
31
|
+
const useCookie = sources.includes("cookie");
|
|
32
|
+
const renew = options.renew ?? true;
|
|
33
|
+
const resolveModel = async (request, response) => {
|
|
34
|
+
const accessName = options.cookie ?? authConfig.cookie.name();
|
|
35
|
+
const refreshName = options.refreshCookie ?? authConfig.cookie.refreshName();
|
|
36
|
+
const headerWins = useHeader && Boolean(request.authorizationValue);
|
|
37
|
+
if (!headerWins && !useCookie) return null;
|
|
38
|
+
if (!headerWins && !request.cookie(accessName) && !request.cookie(refreshName)) return null;
|
|
39
|
+
const allowedTypes = resolveUserTypes(options.userTypes);
|
|
40
|
+
const renewalUserType = allowedTypes.length === 1 ? allowedTypes[0] : void 0;
|
|
41
|
+
const canRenew = !headerWins && renew && Boolean(renewalUserType);
|
|
42
|
+
const model = await resolveRequestUser(request, response, {
|
|
43
|
+
tokenFrom: headerWins ? "header" : `cookie:${accessName}`,
|
|
44
|
+
refreshCredential: canRenew ? `cookie:${refreshName}` : void 0,
|
|
45
|
+
overlapMs: options.overlapMs,
|
|
46
|
+
maxAgeMs: resolveMaxAgeMs(options.maxAge),
|
|
47
|
+
allowedTypes,
|
|
48
|
+
renewalUserType
|
|
49
|
+
});
|
|
50
|
+
if (!model) return null;
|
|
51
|
+
request.locals.user = model;
|
|
52
|
+
return {
|
|
53
|
+
model,
|
|
54
|
+
source: headerWins ? "header" : "cookie"
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
return resolveModel;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Session resolver for pages: cookie-or-header credential, reactive renewal,
|
|
61
|
+
* one resolution per request. Plug it into `web.session`.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* export default { session: pageSession({ project: (user: User) => userResource(user) }) };
|
|
65
|
+
*/
|
|
66
|
+
function pageSession(options) {
|
|
67
|
+
if (typeof options?.project !== "function") throw new Error("pageSession requires a `project(model)` function: a model never crosses the wire by default.");
|
|
68
|
+
const resolveModel = createModelResolver(options);
|
|
69
|
+
const projections = /* @__PURE__ */ new WeakMap();
|
|
70
|
+
return { async resolve(request, response) {
|
|
71
|
+
const resolved = await resolveModel(request, response);
|
|
72
|
+
if (!resolved) return null;
|
|
73
|
+
const model = resolved.model;
|
|
74
|
+
let projected = projections.get(request);
|
|
75
|
+
if (!projected) {
|
|
76
|
+
projected = Promise.resolve(options.project(model));
|
|
77
|
+
projections.set(request, projected);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
model,
|
|
81
|
+
user: await projected
|
|
82
|
+
};
|
|
83
|
+
} };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Middleware for cookie API routes over the same resolver as `pageSession`.
|
|
87
|
+
* A cookie-authenticated unsafe request must pass the Origin/Referer check
|
|
88
|
+
* (renewal can mint cookies, so it runs even when the access cookie is gone).
|
|
89
|
+
* Guests get a 401 unless `optional`. The user lands on `request.locals.user`.
|
|
90
|
+
*/
|
|
91
|
+
function sessionMiddleware(options = {}) {
|
|
92
|
+
const resolveModel = createModelResolver(options);
|
|
93
|
+
const sources = options.sources?.length ? options.sources : ["cookie", "header"];
|
|
94
|
+
return async ({ request, response }) => {
|
|
95
|
+
if (!(sources.includes("header") && Boolean(request.authorizationValue)) && requiresCsrfOriginCheck("cookie:x", request.method)) {
|
|
96
|
+
const accessName = options.cookie ?? authConfig.cookie.name();
|
|
97
|
+
const refreshName = options.refreshCookie ?? authConfig.cookie.refreshName();
|
|
98
|
+
if (request.cookie(accessName) || request.cookie(refreshName)) try {
|
|
99
|
+
assertCsrfOriginAllowed(request);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!(error instanceof CsrfOriginMismatchError)) throw error;
|
|
102
|
+
log.error("http", "auth", error);
|
|
103
|
+
return response.forbidden({
|
|
104
|
+
error: t("auth.errors.csrfOriginMismatch"),
|
|
105
|
+
errorCode: "EC006"
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (await resolveModel(request, response) || options.optional) return;
|
|
110
|
+
return response.unauthorized({
|
|
111
|
+
error: t("auth.errors.unauthorized"),
|
|
112
|
+
errorCode: "EC003"
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
//#endregion
|
|
118
|
+
export { pageSession, sessionMiddleware };
|
|
119
|
+
//# sourceMappingURL=page-session.mjs.map
|
|
@@ -0,0 +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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"}
|
package/esm/utils/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isNeverExpiring, isUsableExpiry, tokenHasExpClaim } from "./token-expiry.mjs";
|
|
2
1
|
import { AuthErrorCodes } from "./auth-error-codes.mjs";
|
|
2
|
+
import { isNeverExpiring, isUsableExpiry, tokenHasExpClaim } from "./token-expiry.mjs";
|
|
3
3
|
import { safeEqual } from "./safe-equal.mjs";
|
|
4
4
|
|
|
5
5
|
export { };
|