@warlock.js/auth 5.7.0 → 5.8.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.
@@ -1 +1 @@
1
- {"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"mappings":";;;;;;AAmEA;;;;;;;;;AAGa;;;;;;;;;;iBAHG,cAAA,CACd,eAAA,qBACA,SAAA,GAAW,SAAA,GACV,UAAU"}
1
+ {"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"mappings":";;;;;;AAwGA;;;;;;;;;AAGa;;;;;;;;;;iBAHG,cAAA,CACd,eAAA,qBACA,SAAA,GAAW,SAAA,GACV,UAAU"}
@@ -1,5 +1,6 @@
1
1
  import { AccessToken } from "../models/access-token/access-token.model.mjs";
2
2
  import "../models/access-token/index.mjs";
3
+ import { authConfig } from "../services/auth-config.mjs";
3
4
  import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
4
5
  import { isInvalidCredentialError, jwt } from "../services/jwt.mjs";
5
6
  import { authService } from "../services/auth.service.mjs";
@@ -8,6 +9,33 @@ import { log } from "@warlock.js/logger";
8
9
 
9
10
  //#region ../auth/src/middleware/auth.middleware.ts
10
11
  /**
12
+ * Reject an unauthenticated request, choosing the representation by ROUTE KIND.
13
+ *
14
+ * An API route always gets the JSON 401 it has always gotten. A PAGE route
15
+ * (`request.route.isPage` — the React-SSR pages) instead redirects a logged-out
16
+ * browser to the configured login path with a `returnUrl`, so a human
17
+ * navigating to a guarded page lands on the login screen rather than reading a
18
+ * raw JSON error blob — the defect in finding b9ab9804.
19
+ *
20
+ * The redirect is OPT-IN and backward-compatible: with no `auth.pageAuth.loginPath`
21
+ * configured, even a page route falls back to the JSON 401, so an app that has
22
+ * not asked for page-login UX keeps exactly its current behavior. The API 401
23
+ * contract is never changed for API routes.
24
+ *
25
+ * `returnUrl` carries `request.url` — the relative path + query the browser was
26
+ * on — never the absolute URL: a relative target cannot be turned into an
27
+ * open-redirect off this app's origin.
28
+ */
29
+ function rejectUnauthorized({ request, response }, payload) {
30
+ const loginPath = authConfig.pageAuth.loginPath();
31
+ if (loginPath && request.route?.isPage) {
32
+ const separator = loginPath.includes("?") ? "&" : "?";
33
+ const returnUrl = encodeURIComponent(request.url);
34
+ return response.redirect(`${loginPath}${separator}${authConfig.pageAuth.returnUrlParam()}=${returnUrl}`);
35
+ }
36
+ return response.unauthorized(payload);
37
+ }
38
+ /**
11
39
  * Whether the persisted row says the token is dead.
12
40
  *
13
41
  * The answer belongs to the model (`AccessToken.isExpired`), so a registered
@@ -55,7 +83,10 @@ function authMiddleware(allowedUserType, tokenFrom = "header") {
55
83
  const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];
56
84
  const auth = async ({ request, response }) => {
57
85
  const authorizationValue = readCredential(request, tokenFrom);
58
- if (!authorizationValue) return response.unauthorized({
86
+ if (!authorizationValue) return rejectUnauthorized({
87
+ request,
88
+ response
89
+ }, {
59
90
  error: t("auth.errors.missingAccessToken"),
60
91
  errorCode: "EC001"
61
92
  });
@@ -66,26 +97,38 @@ function authMiddleware(allowedUserType, tokenFrom = "header") {
66
97
  if (!isInvalidCredentialError(error)) throw error;
67
98
  log.error("http", "auth", error);
68
99
  request.clearCurrentUser();
69
- return response.unauthorized({
100
+ return rejectUnauthorized({
101
+ request,
102
+ response
103
+ }, {
70
104
  error: t("auth.errors.invalidAccessToken"),
71
105
  errorCode: "EC002"
72
106
  });
73
107
  }
74
108
  request.decodedAccessToken = decoded;
75
109
  const accessToken = await config.key("auth.accessToken.model", AccessToken).findByToken(authorizationValue);
76
- if (!accessToken) return response.unauthorized({
110
+ if (!accessToken) return rejectUnauthorized({
111
+ request,
112
+ response
113
+ }, {
77
114
  error: t("auth.errors.invalidAccessToken"),
78
115
  errorCode: "EC002"
79
116
  });
80
117
  if (accessTokenRowIsExpired(accessToken)) {
81
118
  await accessToken.destroy();
82
- return response.unauthorized({
119
+ return rejectUnauthorized({
120
+ request,
121
+ response
122
+ }, {
83
123
  error: t("auth.errors.invalidAccessToken"),
84
124
  errorCode: "EC002"
85
125
  });
86
126
  }
87
127
  const userType = decoded.userType ?? accessToken.userType;
88
- if (allowedTypes.length && !allowedTypes.includes(userType)) return response.unauthorized({
128
+ if (allowedTypes.length && !allowedTypes.includes(userType)) return rejectUnauthorized({
129
+ request,
130
+ response
131
+ }, {
89
132
  error: t("auth.errors.unauthorized"),
90
133
  errorCode: "EC003"
91
134
  });
@@ -94,12 +137,18 @@ function authMiddleware(allowedUserType, tokenFrom = "header") {
94
137
  const currentUser = await UserModel.find(decoded.id);
95
138
  if (!currentUser) {
96
139
  await accessToken.destroy();
97
- return response.unauthorized({
140
+ return rejectUnauthorized({
141
+ request,
142
+ response
143
+ }, {
98
144
  error: t("auth.errors.invalidAccessToken"),
99
145
  errorCode: "EC002"
100
146
  });
101
147
  }
102
- if (!await authService.canAuthenticate(currentUser)) return response.unauthorized({
148
+ if (!await authService.canAuthenticate(currentUser)) return rejectUnauthorized({
149
+ request,
150
+ response
151
+ }, {
103
152
  error: t("auth.errors.unauthorized"),
104
153
  errorCode: "EC003"
105
154
  });
@@ -1 +1 @@
1
- {"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, t, type Middleware, type Request } 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 \"../services/auth.service\";\nimport { isInvalidCredentialError, jwt } from \"../services/jwt\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\r\n/**\r\n * Decoded access-token claims the middleware reads. The full payload carries\r\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\r\n */\r\ntype DecodedAccessToken = {\r\n id: string | number;\r\n userType?: string;\r\n};\r\n\r\n/**\n * Whether the persisted row says the token is dead.\n *\r\n * The answer belongs to the model (`AccessToken.isExpired`), so a registered\r\n * override that renames or reshapes its expiry column stays authoritative and\r\n * the middleware never touches a column name. A row that cannot answer at all —\r\n * an override that dropped the getter — is treated as **expired**: the failure\r\n * mode of this whole defect class was a check that quietly answered \"fine\" when\r\n * it had nothing to check, and that is not repeated here.\r\n *\r\n * This is independent of the `exp` claim required by `jwt.verify`. That guard\r\n * catches a token whose *claims* carry no deadline; this one catches a token\r\n * whose *row* says the deadline has passed — a logged-out or expired session\r\n * whose JWT is still within its own lifetime. Neither subsumes the other.\r\n */\r\nfunction accessTokenRowIsExpired(accessToken: AccessToken): boolean {\n return typeof accessToken.isExpired === \"boolean\" ? accessToken.isExpired : true;\n}\n\nfunction 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\r\n/**\r\n * Build a route gate that always requires an authenticated request.\r\n *\r\n * The argument is mandatory and selects which user types may pass:\r\n * - `[]` — any authenticated user (token required, type not checked).\r\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\r\n * `userType` must be one of the listed types.\r\n *\r\n * There is no anonymous/optional mode: a request without a valid access\n * token is always rejected with `401`. Routes that should be public\n * simply omit the middleware.\n *\n * `tokenFrom` selects one credential source and defaults to `\"header\"`.\n * A single source avoids making credential precedence depend on array order.\n *\r\n * @example\r\n * router.get(\"/account\", authMiddleware([]), accountController);\r\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\n * router.get(\"/browser-account\", authMiddleware([], \"cookie:token\"), accountController);\n */\nexport function authMiddleware(\n allowedUserType: string | string[],\n tokenFrom: TokenFrom = \"header\",\n): Middleware {\n const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];\r\n\r\n const auth: Middleware = async ({ request, response }) => {\r\n const authorizationValue = readCredential(request, tokenFrom);\n\r\n if (!authorizationValue) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.missingAccessToken\"),\r\n errorCode: AuthErrorCodes.MissingAccessToken,\r\n });\r\n }\r\n\r\n let decoded: DecodedAccessToken;\r\n\r\n // The ONLY try in this middleware, and it wraps a single call. Everything\r\n // after it is storage and configuration, where an exception means the\r\n // server is broken rather than the caller (D5).\r\n try {\r\n decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\r\n } catch (error) {\r\n if (!isInvalidCredentialError(error)) {\r\n // A DB/cache outage, a missing `JWT_SECRET`, or any other server-side\r\n // fault — not a verdict on this credential. Propagate to the request's\r\n // normal error path (a 500 monitoring can see) instead of answering\r\n // 401 and clearing the caller's session.\r\n throw error;\r\n }\r\n\r\n // A forged, malformed, expired token — or (D6) a `tokenType` mismatch —\r\n // is not an incident: it is a request carrying a credential the server\r\n // will never accept.\r\n log.error(\"http\", \"auth\", error);\r\n\r\n request.clearCurrentUser();\r\n\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n request.decodedAccessToken = decoded;\r\n\r\n // A valid signature is not enough — the token must still exist in storage,\r\n // so deleting the row (logout) invalidates it before its JWT expiry.\r\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\r\n const accessToken = await AccessTokenModel.findByToken(authorizationValue);\r\n\r\n if (!accessToken) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n // ... and the row must still be live. Existence alone was the whole check\r\n // before 4.12.0, so a row whose own `expires_at` had passed still opened\r\n // the gate. The stored expiry is now enforced, and the dead row is\r\n // removed on the way out rather than left for the cleanup command.\r\n if (accessTokenRowIsExpired(accessToken)) {\r\n await accessToken.destroy();\r\n\r\n return response.unauthorized({\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n const userType = decoded.userType ?? accessToken.userType;\r\n\r\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\r\n return response.unauthorized({\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n });\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${userType}`);\r\n\r\n if (!UserModel) {\r\n // Configuration, not credentials. Throwing keeps a mis-registered app\r\n // loudly broken instead of quietly rejecting every request of this type.\r\n throw new Error(`User type ${userType} is unknown type.`);\r\n }\r\n\r\n const currentUser = await UserModel.find(decoded.id);\r\n\r\n if (!currentUser) {\n await accessToken.destroy();\n\n return response.unauthorized({\n error: t(\"auth.errors.invalidAccessToken\"),\n errorCode: AuthErrorCodes.InvalidAccessToken,\n });\n }\n\n if (!(await authService.canAuthenticate(currentUser))) {\n return response.unauthorized({\n error: t(\"auth.errors.unauthorized\"),\n errorCode: AuthErrorCodes.Unauthorized,\n });\n }\n\n request.user = currentUser;\n };\r\n\r\n return auth;\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAgCA,SAAS,wBAAwB,aAAmC;CAClE,OAAO,OAAO,YAAY,cAAc,YAAY,YAAY,YAAY;AAC9E;AAEA,SAAS,eAAe,SAAkB,WAA8B;CACtE,IAAI,cAAc,UAChB,OAAO,QAAQ;CAGjB,MAAM,QAAQ,QAAQ,OAAO,UAAU,MAAM,CAAgB,CAAC;CAE9D,OAAO,QAAQ,OAAO,KAAK,IAAI;AACjC;;;;;;;;;;;;;;;;;;;;;;AAuBA,SAAgB,eACd,iBACA,YAAuB,UACX;CACZ,MAAM,eAAe,MAAM,QAAQ,eAAe,IAAI,kBAAkB,CAAC,eAAe;CAExF,MAAM,OAAmB,OAAO,EAAE,SAAS,eAAe;EACxD,MAAM,qBAAqB,eAAe,SAAS,SAAS;EAE5D,IAAI,CAAC,oBACH,OAAO,SAAS,aAAa;GAC3B,OAAO,EAAE,gCAAgC;GACzC;EACF,CAAC;EAGH,IAAI;EAKJ,IAAI;GACF,UAAU,MAAM,IAAI,OAA2B,kBAAkB;EACnE,SAAS,OAAO;GACd,IAAI,CAAC,yBAAyB,KAAK,GAKjC,MAAM;GAMR,IAAI,MAAM,QAAQ,QAAQ,KAAK;GAE/B,QAAQ,iBAAiB;GAEzB,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;EAEA,QAAQ,qBAAqB;EAK7B,MAAM,cAAc,MADK,OAAO,IAAI,0BAA0B,WACrB,CAAC,CAAC,YAAY,kBAAkB;EAEzE,IAAI,CAAC,aACH,OAAO,SAAS,aAAa;GAC3B,OAAO,EAAE,gCAAgC;GACzC;EACF,CAAC;EAOH,IAAI,wBAAwB,WAAW,GAAG;GACxC,MAAM,YAAY,QAAQ;GAE1B,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;EAEA,MAAM,WAAW,QAAQ,YAAY,YAAY;EAEjD,IAAI,aAAa,UAAU,CAAC,aAAa,SAAS,QAAQ,GACxD,OAAO,SAAS,aAAa;GAC3B,OAAO,EAAE,0BAA0B;GACnC;EACF,CAAC;EAGH,MAAM,YAAY,OAAO,IAAI,iBAAiB,UAAU;EAExD,IAAI,CAAC,WAGH,MAAM,IAAI,MAAM,aAAa,SAAS,kBAAkB;EAG1D,MAAM,cAAc,MAAM,UAAU,KAAK,QAAQ,EAAE;EAEnD,IAAI,CAAC,aAAa;GAChB,MAAM,YAAY,QAAQ;GAE1B,OAAO,SAAS,aAAa;IAC3B,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;EAEA,IAAI,CAAE,MAAM,YAAY,gBAAgB,WAAW,GACjD,OAAO,SAAS,aAAa;GAC3B,OAAO,EAAE,0BAA0B;GACnC;EACF,CAAC;EAGH,QAAQ,OAAO;CACjB;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, type HttpContext, t, type Middleware, type Request } from \"@warlock.js/core\";\r\nimport { log } from \"@warlock.js/logger\";\r\nimport type { TokenFrom } from \"../contracts/types\";\r\nimport { AccessToken } from \"../models/access-token\";\r\nimport { authConfig } from \"../services/auth-config\";\r\nimport { authService } from \"../services/auth.service\";\r\nimport { isInvalidCredentialError, jwt } from \"../services/jwt\";\r\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\r\n\r\n/** The 401 body shape every rejection in this middleware carries. */\r\ntype UnauthorizedPayload = { error: string; errorCode: string };\r\n\r\n/**\r\n * Reject an unauthenticated request, choosing the representation by ROUTE KIND.\r\n *\r\n * An API route always gets the JSON 401 it has always gotten. A PAGE route\r\n * (`request.route.isPage` — the React-SSR pages) instead redirects a logged-out\r\n * browser to the configured login path with a `returnUrl`, so a human\r\n * navigating to a guarded page lands on the login screen rather than reading a\r\n * raw JSON error blob — the defect in finding b9ab9804.\r\n *\r\n * The redirect is OPT-IN and backward-compatible: with no `auth.pageAuth.loginPath`\r\n * configured, even a page route falls back to the JSON 401, so an app that has\r\n * not asked for page-login UX keeps exactly its current behavior. The API 401\r\n * contract is never changed for API routes.\r\n *\r\n * `returnUrl` carries `request.url` — the relative path + query the browser was\r\n * on — never the absolute URL: a relative target cannot be turned into an\r\n * open-redirect off this app's origin.\r\n */\r\nfunction rejectUnauthorized({ request, response }: HttpContext, payload: UnauthorizedPayload) {\r\n const loginPath = authConfig.pageAuth.loginPath();\r\n\r\n if (loginPath && request.route?.isPage) {\r\n const separator = loginPath.includes(\"?\") ? \"&\" : \"?\";\r\n const returnUrl = encodeURIComponent(request.url);\r\n\r\n return response.redirect(\r\n `${loginPath}${separator}${authConfig.pageAuth.returnUrlParam()}=${returnUrl}`,\r\n );\r\n }\r\n\r\n return response.unauthorized(payload);\r\n}\r\n\r\n/**\r\n * Decoded access-token claims the middleware reads. The full payload carries\r\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\r\n */\r\ntype DecodedAccessToken = {\r\n id: string | number;\r\n userType?: string;\r\n};\r\n\r\n/**\r\n * Whether the persisted row says the token is dead.\r\n *\r\n * The answer belongs to the model (`AccessToken.isExpired`), so a registered\r\n * override that renames or reshapes its expiry column stays authoritative and\r\n * the middleware never touches a column name. A row that cannot answer at all —\r\n * an override that dropped the getter — is treated as **expired**: the failure\r\n * mode of this whole defect class was a check that quietly answered \"fine\" when\r\n * it had nothing to check, and that is not repeated here.\r\n *\r\n * This is independent of the `exp` claim required by `jwt.verify`. That guard\r\n * catches a token whose *claims* carry no deadline; this one catches a token\r\n * whose *row* says the deadline has passed — a logged-out or expired session\r\n * whose JWT is still within its own lifetime. Neither subsumes the other.\r\n */\r\nfunction accessTokenRowIsExpired(accessToken: AccessToken): boolean {\r\n return typeof accessToken.isExpired === \"boolean\" ? accessToken.isExpired : true;\r\n}\r\n\r\nfunction readCredential(request: Request, tokenFrom: TokenFrom): string {\r\n if (tokenFrom === \"header\") {\r\n return request.authorizationValue;\r\n }\r\n\r\n const value = request.cookie(tokenFrom.slice(\"cookie:\".length));\r\n\r\n return value ? String(value) : \"\";\r\n}\r\n\r\n/**\r\n * Build a route gate that always requires an authenticated request.\r\n *\r\n * The argument is mandatory and selects which user types may pass:\r\n * - `[]` — any authenticated user (token required, type not checked).\r\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\r\n * `userType` must be one of the listed types.\r\n *\r\n * There is no anonymous/optional mode: a request without a valid access\r\n * token is always rejected with `401`. Routes that should be public\r\n * simply omit the middleware.\r\n *\r\n * `tokenFrom` selects one credential source and defaults to `\"header\"`.\r\n * A single source avoids making credential precedence depend on array order.\r\n *\r\n * @example\r\n * router.get(\"/account\", authMiddleware([]), accountController);\r\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\r\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\r\n * router.get(\"/browser-account\", authMiddleware([], \"cookie:token\"), accountController);\r\n */\r\nexport function authMiddleware(\r\n allowedUserType: string | string[],\r\n tokenFrom: TokenFrom = \"header\",\r\n): Middleware {\r\n const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];\r\n\r\n const auth: Middleware = async ({ request, response }) => {\r\n const authorizationValue = readCredential(request, tokenFrom);\r\n\r\n if (!authorizationValue) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.missingAccessToken\"),\r\n errorCode: AuthErrorCodes.MissingAccessToken,\r\n });\r\n }\r\n\r\n let decoded: DecodedAccessToken;\r\n\r\n // The ONLY try in this middleware, and it wraps a single call. Everything\r\n // after it is storage and configuration, where an exception means the\r\n // server is broken rather than the caller (D5).\r\n try {\r\n decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\r\n } catch (error) {\r\n if (!isInvalidCredentialError(error)) {\r\n // A DB/cache outage, a missing `JWT_SECRET`, or any other server-side\r\n // fault — not a verdict on this credential. Propagate to the request's\r\n // normal error path (a 500 monitoring can see) instead of answering\r\n // 401 and clearing the caller's session.\r\n throw error;\r\n }\r\n\r\n // A forged, malformed, expired token — or (D6) a `tokenType` mismatch —\r\n // is not an incident: it is a request carrying a credential the server\r\n // will never accept.\r\n log.error(\"http\", \"auth\", error);\r\n\r\n request.clearCurrentUser();\r\n\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n request.decodedAccessToken = decoded;\r\n\r\n // A valid signature is not enough — the token must still exist in storage,\r\n // so deleting the row (logout) invalidates it before its JWT expiry.\r\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\r\n const accessToken = await AccessTokenModel.findByToken(authorizationValue);\r\n\r\n if (!accessToken) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n // ... and the row must still be live. Existence alone was the whole check\r\n // before 4.12.0, so a row whose own `expires_at` had passed still opened\r\n // the gate. The stored expiry is now enforced, and the dead row is\r\n // removed on the way out rather than left for the cleanup command.\r\n if (accessTokenRowIsExpired(accessToken)) {\r\n await accessToken.destroy();\r\n\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n const userType = decoded.userType ?? accessToken.userType;\r\n\r\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n });\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${userType}`);\r\n\r\n if (!UserModel) {\r\n // Configuration, not credentials. Throwing keeps a mis-registered app\r\n // loudly broken instead of quietly rejecting every request of this type.\r\n throw new Error(`User type ${userType} is unknown type.`);\r\n }\r\n\r\n const currentUser = await UserModel.find(decoded.id);\r\n\r\n if (!currentUser) {\r\n await accessToken.destroy();\r\n\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n if (!(await authService.canAuthenticate(currentUser))) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n });\r\n }\r\n\r\n request.user = currentUser;\r\n };\r\n\r\n return auth;\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAS,mBAAmB,EAAE,SAAS,YAAyB,SAA8B;CAC5F,MAAM,YAAY,WAAW,SAAS,UAAU;CAEhD,IAAI,aAAa,QAAQ,OAAO,QAAQ;EACtC,MAAM,YAAY,UAAU,SAAS,GAAG,IAAI,MAAM;EAClD,MAAM,YAAY,mBAAmB,QAAQ,GAAG;EAEhD,OAAO,SAAS,SACd,GAAG,YAAY,YAAY,WAAW,SAAS,eAAe,EAAE,GAAG,WACrE;CACF;CAEA,OAAO,SAAS,aAAa,OAAO;AACtC;;;;;;;;;;;;;;;;AA0BA,SAAS,wBAAwB,aAAmC;CAClE,OAAO,OAAO,YAAY,cAAc,YAAY,YAAY,YAAY;AAC9E;AAEA,SAAS,eAAe,SAAkB,WAA8B;CACtE,IAAI,cAAc,UAChB,OAAO,QAAQ;CAGjB,MAAM,QAAQ,QAAQ,OAAO,UAAU,MAAM,CAAgB,CAAC;CAE9D,OAAO,QAAQ,OAAO,KAAK,IAAI;AACjC;;;;;;;;;;;;;;;;;;;;;;AAuBA,SAAgB,eACd,iBACA,YAAuB,UACX;CACZ,MAAM,eAAe,MAAM,QAAQ,eAAe,IAAI,kBAAkB,CAAC,eAAe;CAExF,MAAM,OAAmB,OAAO,EAAE,SAAS,eAAe;EACxD,MAAM,qBAAqB,eAAe,SAAS,SAAS;EAE5D,IAAI,CAAC,oBACH,OAAO,mBAAmB;GAAE;GAAS;EAAS,GAAG;GAC/C,OAAO,EAAE,gCAAgC;GACzC;EACF,CAAC;EAGH,IAAI;EAKJ,IAAI;GACF,UAAU,MAAM,IAAI,OAA2B,kBAAkB;EACnE,SAAS,OAAO;GACd,IAAI,CAAC,yBAAyB,KAAK,GAKjC,MAAM;GAMR,IAAI,MAAM,QAAQ,QAAQ,KAAK;GAE/B,QAAQ,iBAAiB;GAEzB,OAAO,mBAAmB;IAAE;IAAS;GAAS,GAAG;IAC/C,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;EAEA,QAAQ,qBAAqB;EAK7B,MAAM,cAAc,MADK,OAAO,IAAI,0BAA0B,WACrB,CAAC,CAAC,YAAY,kBAAkB;EAEzE,IAAI,CAAC,aACH,OAAO,mBAAmB;GAAE;GAAS;EAAS,GAAG;GAC/C,OAAO,EAAE,gCAAgC;GACzC;EACF,CAAC;EAOH,IAAI,wBAAwB,WAAW,GAAG;GACxC,MAAM,YAAY,QAAQ;GAE1B,OAAO,mBAAmB;IAAE;IAAS;GAAS,GAAG;IAC/C,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;EAEA,MAAM,WAAW,QAAQ,YAAY,YAAY;EAEjD,IAAI,aAAa,UAAU,CAAC,aAAa,SAAS,QAAQ,GACxD,OAAO,mBAAmB;GAAE;GAAS;EAAS,GAAG;GAC/C,OAAO,EAAE,0BAA0B;GACnC;EACF,CAAC;EAGH,MAAM,YAAY,OAAO,IAAI,iBAAiB,UAAU;EAExD,IAAI,CAAC,WAGH,MAAM,IAAI,MAAM,aAAa,SAAS,kBAAkB;EAG1D,MAAM,cAAc,MAAM,UAAU,KAAK,QAAQ,EAAE;EAEnD,IAAI,CAAC,aAAa;GAChB,MAAM,YAAY,QAAQ;GAE1B,OAAO,mBAAmB;IAAE;IAAS;GAAS,GAAG;IAC/C,OAAO,EAAE,gCAAgC;IACzC;GACF,CAAC;EACH;EAEA,IAAI,CAAE,MAAM,YAAY,gBAAgB,WAAW,GACjD,OAAO,mBAAmB;GAAE;GAAS;EAAS,GAAG;GAC/C,OAAO,EAAE,0BAA0B;GACnC;EACF,CAAC;EAGH,QAAQ,OAAO;CACjB;CAEA,OAAO;AACT"}
@@ -8,7 +8,7 @@ const AccessTokenMigration = migrate(AccessToken, {
8
8
  this.createTableIfNotExists();
9
9
  this.primaryUuid();
10
10
  this.text("token").unique();
11
- this.uuid("user_id").index();
11
+ this.foreignId("user_id").index();
12
12
  this.string("user_type", 50).nullable();
13
13
  this.timestamp("expires_at").index().nullable();
14
14
  this.timestamps();
@@ -1 +1 @@
1
- {"version":3,"file":"migration.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/migration.ts"],"sourcesContent":["import { migrate } from \"@warlock.js/cascade\";\r\nimport { AccessToken } from \"./access-token.model\";\r\n\r\nexport const AccessTokenMigration = migrate(AccessToken, {\r\n name: \"accessToken\",\r\n up() {\r\n // Create table\r\n this.createTableIfNotExists();\r\n\r\n // Primary key\r\n this.primaryUuid();\r\n\r\n // Token field\r\n this.text(\"token\").unique();\r\n\r\n // User reference (flat columns for cross-driver compatibility)\r\n this.uuid(\"user_id\").index();\r\n this.string(\"user_type\", 50).nullable();\r\n\r\n // Expiry — enables server-side cleanup of stale access-token rows\r\n this.timestamp(\"expires_at\").index().nullable();\r\n\r\n // Timestamps\r\n this.timestamps();\r\n },\r\n down() {\r\n this.dropTableIfExists();\r\n },\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,uBAAuB,QAAQ,aAAa;CACvD,MAAM;CACN,KAAK;EAEH,KAAK,uBAAuB;EAG5B,KAAK,YAAY;EAGjB,KAAK,KAAK,OAAO,CAAC,CAAC,OAAO;EAG1B,KAAK,KAAK,SAAS,CAAC,CAAC,MAAM;EAC3B,KAAK,OAAO,aAAa,EAAE,CAAC,CAAC,SAAS;EAGtC,KAAK,UAAU,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS;EAG9C,KAAK,WAAW;CAClB;CACA,OAAO;EACL,KAAK,kBAAkB;CACzB;AACF,CAAC"}
1
+ {"version":3,"file":"migration.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/migration.ts"],"sourcesContent":["import { migrate } from \"@warlock.js/cascade\";\r\nimport { AccessToken } from \"./access-token.model\";\r\n\r\nexport const AccessTokenMigration = migrate(AccessToken, {\r\n name: \"accessToken\",\r\n up() {\r\n // Create table\r\n this.createTableIfNotExists();\r\n\r\n // Primary key\r\n this.primaryUuid();\r\n\r\n // Token field\r\n this.text(\"token\").unique();\r\n\r\n // User reference (flat columns for cross-driver compatibility).\r\n // `foreignId` matches the DataSource's default primary-key type, so the\r\n // column stays compatible with the user model's `id` whether the app uses\r\n // integer (the default) or uuid PKs — hardcoding uuid here 500'd every\r\n // login on a default integer-PK app (finding 5e47bdb3).\r\n this.foreignId(\"user_id\").index();\r\n this.string(\"user_type\", 50).nullable();\r\n\r\n // Expiry — enables server-side cleanup of stale access-token rows\r\n this.timestamp(\"expires_at\").index().nullable();\r\n\r\n // Timestamps\r\n this.timestamps();\r\n },\r\n down() {\r\n this.dropTableIfExists();\r\n },\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,uBAAuB,QAAQ,aAAa;CACvD,MAAM;CACN,KAAK;EAEH,KAAK,uBAAuB;EAG5B,KAAK,YAAY;EAGjB,KAAK,KAAK,OAAO,CAAC,CAAC,OAAO;EAO1B,KAAK,UAAU,SAAS,CAAC,CAAC,MAAM;EAChC,KAAK,OAAO,aAAa,EAAE,CAAC,CAAC,SAAS;EAGtC,KAAK,UAAU,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS;EAG9C,KAAK,WAAW;CAClB;CACA,OAAO;EACL,KAAK,kBAAkB;CACzB;AACF,CAAC"}
@@ -8,7 +8,7 @@ const RefreshTokenMigration = migrate(RefreshToken, {
8
8
  this.createTableIfNotExists();
9
9
  this.primaryUuid();
10
10
  this.text("token").unique();
11
- this.uuid("user_id").index();
11
+ this.foreignId("user_id").index();
12
12
  this.string("user_type", 50).nullable();
13
13
  this.text("family_id").index().nullable();
14
14
  this.timestamp("expires_at").index().nullable();
@@ -1 +1 @@
1
- {"version":3,"file":"migration.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/migration.ts"],"sourcesContent":["import { migrate } from \"@warlock.js/cascade\";\r\nimport { RefreshToken } from \"./refresh-token.model\";\r\n\r\nexport const RefreshTokenMigration = migrate(RefreshToken, {\r\n name: \"refreshToken\",\r\n up() {\r\n // Create table\r\n this.createTableIfNotExists();\r\n\r\n // Primary key\r\n this.primaryUuid();\r\n\r\n // Token fields\r\n this.text(\"token\").unique();\r\n this.uuid(\"user_id\").index();\r\n this.string(\"user_type\", 50).nullable();\r\n this.text(\"family_id\").index().nullable();\r\n this.timestamp(\"expires_at\").index().nullable();\r\n this.timestamp(\"last_used_at\").nullable();\r\n this.timestamp(\"revoked_at\").nullable();\r\n this.json(\"device_info\").nullable();\r\n\r\n // Composite index for the per-user active-session lookups\r\n // (revokeAllFor / enforceMax / activeFor all filter on user_id + user_type).\r\n this.index([\"user_id\", \"user_type\"]);\r\n\r\n // Timestamps\r\n this.timestamps();\r\n },\r\n down() {\r\n this.dropTableIfExists();\r\n },\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,wBAAwB,QAAQ,cAAc;CACzD,MAAM;CACN,KAAK;EAEH,KAAK,uBAAuB;EAG5B,KAAK,YAAY;EAGjB,KAAK,KAAK,OAAO,CAAC,CAAC,OAAO;EAC1B,KAAK,KAAK,SAAS,CAAC,CAAC,MAAM;EAC3B,KAAK,OAAO,aAAa,EAAE,CAAC,CAAC,SAAS;EACtC,KAAK,KAAK,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS;EACxC,KAAK,UAAU,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS;EAC9C,KAAK,UAAU,cAAc,CAAC,CAAC,SAAS;EACxC,KAAK,UAAU,YAAY,CAAC,CAAC,SAAS;EACtC,KAAK,KAAK,aAAa,CAAC,CAAC,SAAS;EAIlC,KAAK,MAAM,CAAC,WAAW,WAAW,CAAC;EAGnC,KAAK,WAAW;CAClB;CACA,OAAO;EACL,KAAK,kBAAkB;CACzB;AACF,CAAC"}
1
+ {"version":3,"file":"migration.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/migration.ts"],"sourcesContent":["import { migrate } from \"@warlock.js/cascade\";\r\nimport { RefreshToken } from \"./refresh-token.model\";\r\n\r\nexport const RefreshTokenMigration = migrate(RefreshToken, {\r\n name: \"refreshToken\",\r\n up() {\r\n // Create table\r\n this.createTableIfNotExists();\r\n\r\n // Primary key\r\n this.primaryUuid();\r\n\r\n // Token fields\r\n this.text(\"token\").unique();\r\n // `foreignId` matches the DataSource's default primary-key type so the\r\n // column stays compatible with the user model's `id` (integer by default,\r\n // or uuid) — hardcoding uuid here 500'd login on integer-PK apps (5e47bdb3).\r\n this.foreignId(\"user_id\").index();\r\n this.string(\"user_type\", 50).nullable();\r\n this.text(\"family_id\").index().nullable();\r\n this.timestamp(\"expires_at\").index().nullable();\r\n this.timestamp(\"last_used_at\").nullable();\r\n this.timestamp(\"revoked_at\").nullable();\r\n this.json(\"device_info\").nullable();\r\n\r\n // Composite index for the per-user active-session lookups\r\n // (revokeAllFor / enforceMax / activeFor all filter on user_id + user_type).\r\n this.index([\"user_id\", \"user_type\"]);\r\n\r\n // Timestamps\r\n this.timestamps();\r\n },\r\n down() {\r\n this.dropTableIfExists();\r\n },\r\n});\r\n"],"mappings":";;;;AAGA,MAAa,wBAAwB,QAAQ,cAAc;CACzD,MAAM;CACN,KAAK;EAEH,KAAK,uBAAuB;EAG5B,KAAK,YAAY;EAGjB,KAAK,KAAK,OAAO,CAAC,CAAC,OAAO;EAI1B,KAAK,UAAU,SAAS,CAAC,CAAC,MAAM;EAChC,KAAK,OAAO,aAAa,EAAE,CAAC,CAAC,SAAS;EACtC,KAAK,KAAK,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS;EACxC,KAAK,UAAU,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS;EAC9C,KAAK,UAAU,cAAc,CAAC,CAAC,SAAS;EACxC,KAAK,UAAU,YAAY,CAAC,CAAC,SAAS;EACtC,KAAK,KAAK,aAAa,CAAC,CAAC,SAAS;EAIlC,KAAK,MAAM,CAAC,WAAW,WAAW,CAAC;EAGnC,KAAK,WAAW;CAClB;CACA,OAAO;EACL,KAAK,kBAAkB;CACzB;AACF,CAAC"}
@@ -92,6 +92,24 @@ const authConfig = {
92
92
  */
93
93
  expiresInMs: () => parseDuration("accessToken.expiresIn", authConfig.accessToken.expiresIn() ?? DEFAULT_ACCESS_TOKEN_EXPIRES_IN)
94
94
  },
95
+ pageAuth: {
96
+ /**
97
+ * Login path a guarded PAGE route redirects a logged-out browser to.
98
+ *
99
+ * Unset (the default) ⇒ page-route auth failures keep the JSON 401 API
100
+ * contract, so existing apps see NO behavior change. Set it (e.g. "/login")
101
+ * to opt a browser-facing app into login-redirect UX: a logged-out human
102
+ * navigating to a guarded page lands on the login screen instead of reading
103
+ * a raw JSON error blob. Finding b9ab9804.
104
+ */
105
+ loginPath: () => config.key("auth.pageAuth.loginPath"),
106
+ /**
107
+ * Query parameter the redirect carries the originally-requested path in, so
108
+ * the login flow can send the user back where they were headed. Defaults to
109
+ * `"returnUrl"`.
110
+ */
111
+ returnUrlParam: () => config.key("auth.pageAuth.returnUrlParam", "returnUrl")
112
+ },
95
113
  refreshToken: {
96
114
  /** Separate refresh secret (legacy: `auth.jwt.refresh.secret`); empty ⇒ fall back to the access secret. */
97
115
  secret: () => resolve("refreshToken.secret", "jwt.refresh.secret"),
@@ -1 +1 @@
1
- {"version":3,"file":"auth-config.mjs","names":[],"sources":["../../../../../../../auth/src/services/auth-config.ts"],"sourcesContent":["import { config } from \"@warlock.js/core\";\r\nimport { log } from \"@warlock.js/logger\";\r\nimport { type Algorithm } from \"fast-jwt\";\r\nimport ms from \"ms\";\r\nimport type { CanAuthenticate, LogoutWithoutTokenBehavior } from \"../contracts/types\";\nimport type { Auth } from \"../models/auth.model\";\n\nconst warnedLegacyKeys = new Set<string>();\n\nconst permitAuthentication: CanAuthenticate = (): boolean => true;\n\r\n/**\r\n * `ms` accepts anything at runtime and answers `undefined` for a string it\r\n * cannot parse (`\"30dayz\"`), a *formatted string* for a number (`2592000` ⇒\r\n * `\"43m\"`), and throws for `\"\"`. Its published types claim the far narrower\r\n * `(value: ms.StringValue) => number`, which is exactly what let an\r\n * `as ms.StringValue` cast smuggle arbitrary config text past the compiler and\r\n * hand `undefined` to the JWT signer. Declare the honest runtime signature once,\r\n * here, so every caller is forced to deal with the `undefined`.\r\n */\r\nconst parseMs = ms as unknown as (value: unknown) => number | string | undefined;\r\n\r\n/**\r\n * Turn a configured lifetime into a positive number of milliseconds, or throw\r\n * naming the key.\r\n *\r\n * A token lifetime is a security boundary, so an unusable value is a hard error\r\n * rather than a silent fallback: substituting a default would trade one\r\n * unintended lifetime for another, just as quietly — and quiet is the whole\r\n * problem. Every non-positive, non-finite or non-numeric parse is rejected:\r\n *\r\n * - `\"30dayz\"` / `\"thirty days\"` ⇒ `undefined` ⇒ a JWT signed with **no `exp`\r\n * claim** and an `Invalid Date` written to the token row.\r\n * - `\"0d\"` ⇒ parses cleanly to `0`, which `fast-jwt` treats as falsy and also\r\n * emits with **no `exp` claim** — it survives any guard that only rejects\r\n * `undefined`. `\"-1h\"` signs an already-expired token.\r\n * - `2592000` (a bare number) ⇒ `ms` *formats* it as `\"43m\"`, a string, which\r\n * then turns `Date.now() + expiresIn` into an `Invalid Date`.\r\n */\r\nfunction parseDuration(key: string, raw: unknown): number {\r\n let parsed: number | string | undefined;\r\n\r\n try {\r\n parsed = parseMs(raw);\r\n } catch {\r\n // ms throws (rather than returning undefined) for \"\" and non-strings\r\n parsed = undefined;\r\n }\r\n\r\n if (typeof parsed !== \"number\" || !Number.isFinite(parsed) || parsed <= 0) {\r\n throw new Error(\r\n `auth.${key}: ${JSON.stringify(raw)} is not a valid ms duration — ` +\r\n `use a positive duration string such as \"1h\", \"7d\", or NO_EXPIRATION.`,\r\n );\r\n }\r\n\r\n return parsed;\r\n}\r\n\r\n/** Access-token lifetime used when nothing is configured. */\r\nconst DEFAULT_ACCESS_TOKEN_EXPIRES_IN = \"1h\";\r\n\r\n/**\r\n * Resolve an auth setting, preferring the new `auth.accessToken.*` /\r\n * `auth.refreshToken.*` key and falling back to the deprecated `auth.jwt.*`\r\n * shape — warning once per legacy key. Returns `fallback` when neither is set.\r\n *\r\n * This is the backward-compatible shim that lets existing `auth.jwt.*` configs\r\n * keep working after the config split.\r\n */\r\nfunction resolve<T>(newKey: string, legacyKey: string, fallback?: T): T {\r\n const fromNew = config.key(`auth.${newKey}`);\r\n\r\n if (fromNew !== undefined && fromNew !== null) {\r\n return fromNew as T;\r\n }\r\n\r\n const fromLegacy = config.key(`auth.${legacyKey}`);\r\n\r\n if (fromLegacy !== undefined && fromLegacy !== null) {\r\n if (!warnedLegacyKeys.has(legacyKey)) {\r\n warnedLegacyKeys.add(legacyKey);\r\n log.warn(\"auth\", \"config-deprecation\", `auth.${legacyKey} is deprecated — use auth.${newKey}`);\r\n }\r\n\r\n return fromLegacy as T;\r\n }\r\n\r\n return fallback as T;\r\n}\r\n\r\n/**\r\n * Typed, backward-compatible access to auth configuration. The service and the\r\n * jwt signer read configuration exclusively through here, so the new split\r\n * config and the legacy `auth.jwt.*` shape both resolve the same way.\r\n */\r\nexport const authConfig = {\n canAuthenticate: async (user: Auth): Promise<boolean> => {\n const canAuthenticate = config.key<CanAuthenticate>(\n \"auth.canAuthenticate\",\n permitAuthentication,\n );\n\n return canAuthenticate(user);\n },\n accessToken: {\n /** Signing secret (legacy: `auth.jwt.secret`). Throws if neither is set. */\r\n secret: (): string => {\r\n const secret = resolve<string | undefined>(\"accessToken.secret\", \"jwt.secret\");\r\n\r\n if (!secret) {\r\n throw new Error(\"auth: no JWT secret configured — set `auth.accessToken.secret`.\");\r\n }\r\n\r\n return secret;\r\n },\r\n /** Signing algorithm (legacy: `auth.jwt.algorithm`). */\r\n algorithm: (): Algorithm => resolve(\"accessToken.algorithm\", \"jwt.algorithm\", \"HS256\"),\r\n /** Lifetime as an `ms`-string (legacy: `auth.jwt.expiresIn`). */\r\n expiresIn: (): string | undefined => resolve(\"accessToken.expiresIn\", \"jwt.expiresIn\"),\r\n /**\r\n * Lifetime in milliseconds — the only form a token issuer may use. Defaults\r\n * to 1 hour when unset; throws naming the key when the configured value is\r\n * not a positive `ms` duration. See {@link parseDuration}.\r\n */\r\n expiresInMs: (): number =>\r\n parseDuration(\r\n \"accessToken.expiresIn\",\r\n authConfig.accessToken.expiresIn() ?? DEFAULT_ACCESS_TOKEN_EXPIRES_IN,\r\n ),\r\n },\r\n refreshToken: {\r\n /** Separate refresh secret (legacy: `auth.jwt.refresh.secret`); empty ⇒ fall back to the access secret. */\r\n secret: (): string | undefined => resolve(\"refreshToken.secret\", \"jwt.refresh.secret\"),\r\n /** Whether refresh tokens are enabled (legacy: `auth.jwt.refresh.enabled`). */\r\n enabled: (): boolean => resolve(\"refreshToken.enabled\", \"jwt.refresh.enabled\", true),\r\n /** Lifetime as an `ms`-string (legacy: `auth.jwt.refresh.expiresIn`). */\r\n expiresIn: (): string => resolve(\"refreshToken.expiresIn\", \"jwt.refresh.expiresIn\", \"7d\"),\r\n /**\r\n * Lifetime in milliseconds — the only form a token issuer may use. Throws\r\n * naming the key when the configured value is not a positive `ms` duration.\r\n */\r\n expiresInMs: (): number =>\r\n parseDuration(\"refreshToken.expiresIn\", authConfig.refreshToken.expiresIn()),\r\n /** Rotate-on-use (legacy: `auth.jwt.refresh.rotation`). */\r\n rotation: (): boolean => resolve(\"refreshToken.rotation\", \"jwt.refresh.rotation\", true),\r\n /** Max active tokens per user (legacy: `auth.jwt.refresh.maxPerUser`). */\r\n maxPerUser: (): number => resolve(\"refreshToken.maxPerUser\", \"jwt.refresh.maxPerUser\", 5),\r\n /** Logout-without-token behavior (legacy: `auth.jwt.refresh.logoutWithoutToken`). */\r\n logoutWithoutToken: (): LogoutWithoutTokenBehavior =>\r\n resolve(\"refreshToken.logoutWithoutToken\", \"jwt.refresh.logoutWithoutToken\", \"revoke-all\"),\r\n },\r\n};\r\n"],"mappings":";;;;;AAOA,MAAM,mCAAmB,IAAI,IAAY;AAEzC,MAAM,6BAAuD;;;;;;;;;;AAW7D,MAAM,UAAU;;;;;;;;;;;;;;;;;;AAmBhB,SAAS,cAAc,KAAa,KAAsB;CACxD,IAAI;CAEJ,IAAI;EACF,SAAS,QAAQ,GAAG;CACtB,QAAQ;EAEN,SAAS;CACX;CAEA,IAAI,OAAO,WAAW,YAAY,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GACtE,MAAM,IAAI,MACR,QAAQ,IAAI,IAAI,KAAK,UAAU,GAAG,EAAE,mGAEtC;CAGF,OAAO;AACT;;AAGA,MAAM,kCAAkC;;;;;;;;;AAUxC,SAAS,QAAW,QAAgB,WAAmB,UAAiB;CACtE,MAAM,UAAU,OAAO,IAAI,QAAQ,QAAQ;CAE3C,IAAI,YAAY,UAAa,YAAY,MACvC,OAAO;CAGT,MAAM,aAAa,OAAO,IAAI,QAAQ,WAAW;CAEjD,IAAI,eAAe,UAAa,eAAe,MAAM;EACnD,IAAI,CAAC,iBAAiB,IAAI,SAAS,GAAG;GACpC,iBAAiB,IAAI,SAAS;GAC9B,IAAI,KAAK,QAAQ,sBAAsB,QAAQ,UAAU,4BAA4B,QAAQ;EAC/F;EAEA,OAAO;CACT;CAEA,OAAO;AACT;;;;;;AAOA,MAAa,aAAa;CACxB,iBAAiB,OAAO,SAAiC;EAMvD,OALwB,OAAO,IAC7B,wBACA,oBAGmB,CAAC,CAAC,IAAI;CAC7B;CACA,aAAa;;EAEX,cAAsB;GACpB,MAAM,SAAS,QAA4B,sBAAsB,YAAY;GAE7E,IAAI,CAAC,QACH,MAAM,IAAI,MAAM,iEAAiE;GAGnF,OAAO;EACT;;EAEA,iBAA4B,QAAQ,yBAAyB,iBAAiB,OAAO;;EAErF,iBAAqC,QAAQ,yBAAyB,eAAe;;;;;;EAMrF,mBACE,cACE,yBACA,WAAW,YAAY,UAAU,KAAK,+BACxC;CACJ;CACA,cAAc;;EAEZ,cAAkC,QAAQ,uBAAuB,oBAAoB;;EAErF,eAAwB,QAAQ,wBAAwB,uBAAuB,IAAI;;EAEnF,iBAAyB,QAAQ,0BAA0B,yBAAyB,IAAI;;;;;EAKxF,mBACE,cAAc,0BAA0B,WAAW,aAAa,UAAU,CAAC;;EAE7E,gBAAyB,QAAQ,yBAAyB,wBAAwB,IAAI;;EAEtF,kBAA0B,QAAQ,2BAA2B,0BAA0B,CAAC;;EAExF,0BACE,QAAQ,mCAAmC,kCAAkC,YAAY;CAC7F;AACF"}
1
+ {"version":3,"file":"auth-config.mjs","names":[],"sources":["../../../../../../../auth/src/services/auth-config.ts"],"sourcesContent":["import { config } from \"@warlock.js/core\";\r\nimport { log } from \"@warlock.js/logger\";\r\nimport { type Algorithm } from \"fast-jwt\";\r\nimport ms from \"ms\";\r\nimport type { CanAuthenticate, LogoutWithoutTokenBehavior } from \"../contracts/types\";\r\nimport type { Auth } from \"../models/auth.model\";\r\n\r\nconst warnedLegacyKeys = new Set<string>();\r\n\r\nconst permitAuthentication: CanAuthenticate = (): boolean => true;\r\n\r\n/**\r\n * `ms` accepts anything at runtime and answers `undefined` for a string it\r\n * cannot parse (`\"30dayz\"`), a *formatted string* for a number (`2592000` ⇒\r\n * `\"43m\"`), and throws for `\"\"`. Its published types claim the far narrower\r\n * `(value: ms.StringValue) => number`, which is exactly what let an\r\n * `as ms.StringValue` cast smuggle arbitrary config text past the compiler and\r\n * hand `undefined` to the JWT signer. Declare the honest runtime signature once,\r\n * here, so every caller is forced to deal with the `undefined`.\r\n */\r\nconst parseMs = ms as unknown as (value: unknown) => number | string | undefined;\r\n\r\n/**\r\n * Turn a configured lifetime into a positive number of milliseconds, or throw\r\n * naming the key.\r\n *\r\n * A token lifetime is a security boundary, so an unusable value is a hard error\r\n * rather than a silent fallback: substituting a default would trade one\r\n * unintended lifetime for another, just as quietly — and quiet is the whole\r\n * problem. Every non-positive, non-finite or non-numeric parse is rejected:\r\n *\r\n * - `\"30dayz\"` / `\"thirty days\"` ⇒ `undefined` ⇒ a JWT signed with **no `exp`\r\n * claim** and an `Invalid Date` written to the token row.\r\n * - `\"0d\"` ⇒ parses cleanly to `0`, which `fast-jwt` treats as falsy and also\r\n * emits with **no `exp` claim** — it survives any guard that only rejects\r\n * `undefined`. `\"-1h\"` signs an already-expired token.\r\n * - `2592000` (a bare number) ⇒ `ms` *formats* it as `\"43m\"`, a string, which\r\n * then turns `Date.now() + expiresIn` into an `Invalid Date`.\r\n */\r\nfunction parseDuration(key: string, raw: unknown): number {\r\n let parsed: number | string | undefined;\r\n\r\n try {\r\n parsed = parseMs(raw);\r\n } catch {\r\n // ms throws (rather than returning undefined) for \"\" and non-strings\r\n parsed = undefined;\r\n }\r\n\r\n if (typeof parsed !== \"number\" || !Number.isFinite(parsed) || parsed <= 0) {\r\n throw new Error(\r\n `auth.${key}: ${JSON.stringify(raw)} is not a valid ms duration — ` +\r\n `use a positive duration string such as \"1h\", \"7d\", or NO_EXPIRATION.`,\r\n );\r\n }\r\n\r\n return parsed;\r\n}\r\n\r\n/** Access-token lifetime used when nothing is configured. */\r\nconst DEFAULT_ACCESS_TOKEN_EXPIRES_IN = \"1h\";\r\n\r\n/**\r\n * Resolve an auth setting, preferring the new `auth.accessToken.*` /\r\n * `auth.refreshToken.*` key and falling back to the deprecated `auth.jwt.*`\r\n * shape — warning once per legacy key. Returns `fallback` when neither is set.\r\n *\r\n * This is the backward-compatible shim that lets existing `auth.jwt.*` configs\r\n * keep working after the config split.\r\n */\r\nfunction resolve<T>(newKey: string, legacyKey: string, fallback?: T): T {\r\n const fromNew = config.key(`auth.${newKey}`);\r\n\r\n if (fromNew !== undefined && fromNew !== null) {\r\n return fromNew as T;\r\n }\r\n\r\n const fromLegacy = config.key(`auth.${legacyKey}`);\r\n\r\n if (fromLegacy !== undefined && fromLegacy !== null) {\r\n if (!warnedLegacyKeys.has(legacyKey)) {\r\n warnedLegacyKeys.add(legacyKey);\r\n log.warn(\"auth\", \"config-deprecation\", `auth.${legacyKey} is deprecated — use auth.${newKey}`);\r\n }\r\n\r\n return fromLegacy as T;\r\n }\r\n\r\n return fallback as T;\r\n}\r\n\r\n/**\r\n * Typed, backward-compatible access to auth configuration. The service and the\r\n * jwt signer read configuration exclusively through here, so the new split\r\n * config and the legacy `auth.jwt.*` shape both resolve the same way.\r\n */\r\nexport const authConfig = {\r\n canAuthenticate: async (user: Auth): Promise<boolean> => {\r\n const canAuthenticate = config.key<CanAuthenticate>(\r\n \"auth.canAuthenticate\",\r\n permitAuthentication,\r\n );\r\n\r\n return canAuthenticate(user);\r\n },\r\n accessToken: {\r\n /** Signing secret (legacy: `auth.jwt.secret`). Throws if neither is set. */\r\n secret: (): string => {\r\n const secret = resolve<string | undefined>(\"accessToken.secret\", \"jwt.secret\");\r\n\r\n if (!secret) {\r\n throw new Error(\"auth: no JWT secret configured — set `auth.accessToken.secret`.\");\r\n }\r\n\r\n return secret;\r\n },\r\n /** Signing algorithm (legacy: `auth.jwt.algorithm`). */\r\n algorithm: (): Algorithm => resolve(\"accessToken.algorithm\", \"jwt.algorithm\", \"HS256\"),\r\n /** Lifetime as an `ms`-string (legacy: `auth.jwt.expiresIn`). */\r\n expiresIn: (): string | undefined => resolve(\"accessToken.expiresIn\", \"jwt.expiresIn\"),\r\n /**\r\n * Lifetime in milliseconds — the only form a token issuer may use. Defaults\r\n * to 1 hour when unset; throws naming the key when the configured value is\r\n * not a positive `ms` duration. See {@link parseDuration}.\r\n */\r\n expiresInMs: (): number =>\r\n parseDuration(\r\n \"accessToken.expiresIn\",\r\n authConfig.accessToken.expiresIn() ?? DEFAULT_ACCESS_TOKEN_EXPIRES_IN,\r\n ),\r\n },\r\n pageAuth: {\r\n /**\r\n * Login path a guarded PAGE route redirects a logged-out browser to.\r\n *\r\n * Unset (the default) ⇒ page-route auth failures keep the JSON 401 API\r\n * contract, so existing apps see NO behavior change. Set it (e.g. \"/login\")\r\n * to opt a browser-facing app into login-redirect UX: a logged-out human\r\n * navigating to a guarded page lands on the login screen instead of reading\r\n * a raw JSON error blob. Finding b9ab9804.\r\n */\r\n loginPath: (): string | undefined => config.key(\"auth.pageAuth.loginPath\"),\r\n /**\r\n * Query parameter the redirect carries the originally-requested path in, so\r\n * the login flow can send the user back where they were headed. Defaults to\r\n * `\"returnUrl\"`.\r\n */\r\n returnUrlParam: (): string => config.key(\"auth.pageAuth.returnUrlParam\", \"returnUrl\"),\r\n },\r\n refreshToken: {\r\n /** Separate refresh secret (legacy: `auth.jwt.refresh.secret`); empty ⇒ fall back to the access secret. */\r\n secret: (): string | undefined => resolve(\"refreshToken.secret\", \"jwt.refresh.secret\"),\r\n /** Whether refresh tokens are enabled (legacy: `auth.jwt.refresh.enabled`). */\r\n enabled: (): boolean => resolve(\"refreshToken.enabled\", \"jwt.refresh.enabled\", true),\r\n /** Lifetime as an `ms`-string (legacy: `auth.jwt.refresh.expiresIn`). */\r\n expiresIn: (): string => resolve(\"refreshToken.expiresIn\", \"jwt.refresh.expiresIn\", \"7d\"),\r\n /**\r\n * Lifetime in milliseconds — the only form a token issuer may use. Throws\r\n * naming the key when the configured value is not a positive `ms` duration.\r\n */\r\n expiresInMs: (): number =>\r\n parseDuration(\"refreshToken.expiresIn\", authConfig.refreshToken.expiresIn()),\r\n /** Rotate-on-use (legacy: `auth.jwt.refresh.rotation`). */\r\n rotation: (): boolean => resolve(\"refreshToken.rotation\", \"jwt.refresh.rotation\", true),\r\n /** Max active tokens per user (legacy: `auth.jwt.refresh.maxPerUser`). */\r\n maxPerUser: (): number => resolve(\"refreshToken.maxPerUser\", \"jwt.refresh.maxPerUser\", 5),\r\n /** Logout-without-token behavior (legacy: `auth.jwt.refresh.logoutWithoutToken`). */\r\n logoutWithoutToken: (): LogoutWithoutTokenBehavior =>\r\n resolve(\"refreshToken.logoutWithoutToken\", \"jwt.refresh.logoutWithoutToken\", \"revoke-all\"),\r\n },\r\n};\r\n"],"mappings":";;;;;AAOA,MAAM,mCAAmB,IAAI,IAAY;AAEzC,MAAM,6BAAuD;;;;;;;;;;AAW7D,MAAM,UAAU;;;;;;;;;;;;;;;;;;AAmBhB,SAAS,cAAc,KAAa,KAAsB;CACxD,IAAI;CAEJ,IAAI;EACF,SAAS,QAAQ,GAAG;CACtB,QAAQ;EAEN,SAAS;CACX;CAEA,IAAI,OAAO,WAAW,YAAY,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GACtE,MAAM,IAAI,MACR,QAAQ,IAAI,IAAI,KAAK,UAAU,GAAG,EAAE,mGAEtC;CAGF,OAAO;AACT;;AAGA,MAAM,kCAAkC;;;;;;;;;AAUxC,SAAS,QAAW,QAAgB,WAAmB,UAAiB;CACtE,MAAM,UAAU,OAAO,IAAI,QAAQ,QAAQ;CAE3C,IAAI,YAAY,UAAa,YAAY,MACvC,OAAO;CAGT,MAAM,aAAa,OAAO,IAAI,QAAQ,WAAW;CAEjD,IAAI,eAAe,UAAa,eAAe,MAAM;EACnD,IAAI,CAAC,iBAAiB,IAAI,SAAS,GAAG;GACpC,iBAAiB,IAAI,SAAS;GAC9B,IAAI,KAAK,QAAQ,sBAAsB,QAAQ,UAAU,4BAA4B,QAAQ;EAC/F;EAEA,OAAO;CACT;CAEA,OAAO;AACT;;;;;;AAOA,MAAa,aAAa;CACxB,iBAAiB,OAAO,SAAiC;EAMvD,OALwB,OAAO,IAC7B,wBACA,oBAGmB,CAAC,CAAC,IAAI;CAC7B;CACA,aAAa;;EAEX,cAAsB;GACpB,MAAM,SAAS,QAA4B,sBAAsB,YAAY;GAE7E,IAAI,CAAC,QACH,MAAM,IAAI,MAAM,iEAAiE;GAGnF,OAAO;EACT;;EAEA,iBAA4B,QAAQ,yBAAyB,iBAAiB,OAAO;;EAErF,iBAAqC,QAAQ,yBAAyB,eAAe;;;;;;EAMrF,mBACE,cACE,yBACA,WAAW,YAAY,UAAU,KAAK,+BACxC;CACJ;CACA,UAAU;;;;;;;;;;EAUR,iBAAqC,OAAO,IAAI,yBAAyB;;;;;;EAMzE,sBAA8B,OAAO,IAAI,gCAAgC,WAAW;CACtF;CACA,cAAc;;EAEZ,cAAkC,QAAQ,uBAAuB,oBAAoB;;EAErF,eAAwB,QAAQ,wBAAwB,uBAAuB,IAAI;;EAEnF,iBAAyB,QAAQ,0BAA0B,yBAAyB,IAAI;;;;;EAKxF,mBACE,cAAc,0BAA0B,WAAW,aAAa,UAAU,CAAC;;EAE7E,gBAAyB,QAAQ,yBAAyB,wBAAwB,IAAI;;EAEtF,kBAA0B,QAAQ,2BAA2B,0BAA0B,CAAC;;EAExF,0BACE,QAAQ,mCAAmC,kCAAkC,YAAY;CAC7F;AACF"}
package/package.json CHANGED
@@ -12,12 +12,12 @@
12
12
  "ms": "^2.1.3"
13
13
  },
14
14
  "peerDependencies": {
15
- "@warlock.js/fs": "5.7.0",
16
- "@warlock.js/cache": "5.7.0",
17
- "@warlock.js/cascade": "5.7.0",
18
- "@warlock.js/core": "5.7.0",
19
- "@warlock.js/logger": "5.7.0",
20
- "@warlock.js/seal": "5.7.0"
15
+ "@warlock.js/fs": "5.8.0",
16
+ "@warlock.js/cache": "5.8.0",
17
+ "@warlock.js/cascade": "5.8.0",
18
+ "@warlock.js/core": "5.8.0",
19
+ "@warlock.js/logger": "5.8.0",
20
+ "@warlock.js/seal": "5.8.0"
21
21
  },
22
22
  "repository": {
23
23
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "author": "hassanzohdy",
35
35
  "license": "MIT",
36
- "version": "5.7.0",
36
+ "version": "5.8.0",
37
37
  "type": "module",
38
38
  "main": "./esm/index.mjs",
39
39
  "module": "./esm/index.mjs",