@warlock.js/auth 5.18.0 → 5.19.1
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 +17 -0
- package/esm/contracts/index.d.mts +1 -1
- package/esm/contracts/types.d.mts +23 -2
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +2 -1
- package/esm/middleware/auth.middleware.d.mts +9 -6
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +101 -80
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/models/access-token/access-token.model.d.mts +31 -7
- package/esm/models/access-token/access-token.model.d.mts.map +1 -1
- package/esm/models/access-token/access-token.model.mjs +21 -1
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/access-token/family-association.migration.mjs +18 -0
- package/esm/models/access-token/family-association.migration.mjs.map +1 -0
- package/esm/models/auth-token-family/auth-token-family.model.d.mts +90 -0
- package/esm/models/auth-token-family/auth-token-family.model.d.mts.map +1 -0
- package/esm/models/auth-token-family/auth-token-family.model.mjs +80 -0
- package/esm/models/auth-token-family/auth-token-family.model.mjs.map +1 -0
- package/esm/models/auth-token-family/index.d.mts +1 -0
- package/esm/models/auth-token-family/index.mjs +3 -0
- package/esm/models/auth-token-family/migration.mjs +25 -0
- package/esm/models/auth-token-family/migration.mjs.map +1 -0
- package/esm/models/index.d.mts +1 -0
- package/esm/models/index.d.mts.map +1 -1
- package/esm/models/index.mjs +8 -0
- package/esm/models/index.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.d.mts +43 -0
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs +17 -0
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/models/refresh-token/successor-association.migration.mjs +22 -0
- package/esm/models/refresh-token/successor-association.migration.mjs.map +1 -0
- package/esm/services/auth.service.d.mts +22 -1
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +216 -32
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/token-family-operation.mjs +74 -0
- package/esm/services/token-family-operation.mjs.map +1 -0
- package/llms-full.txt +63 -58
- package/llms.txt +9 -9
- package/package.json +8 -8
- package/skills/auth-basics/SKILL.md +3 -2
- package/skills/customize-token-storage/SKILL.md +1 -1
- package/skills/customize-user-type/SKILL.md +1 -1
- package/skills/handle-login-and-logout/SKILL.md +1 -1
- package/skills/login-with-providers/SKILL.md +1 -1
- package/skills/manage-tokens/SKILL.md +19 -6
- package/skills/overview/SKILL.md +3 -3
- package/skills/protect-routes/SKILL.md +30 -39
- package/skills/register-user/SKILL.md +1 -1
- package/skills/run-auth-commands/SKILL.md +1 -1
- package/skills/throttle-login-attempts/SKILL.md +1 -1
- package/skills/verify-email-and-reset-password/SKILL.md +1 -1
|
@@ -23,6 +23,9 @@ const refreshTokenSchema = v.object({
|
|
|
23
23
|
expires_at: v.date().required(),
|
|
24
24
|
last_used_at: v.date().default(() => /* @__PURE__ */ new Date()),
|
|
25
25
|
revoked_at: v.date().optional(),
|
|
26
|
+
/** The successor pair a bounded concurrent renewal may reuse. */
|
|
27
|
+
successor_access_token_id: v.string().optional(),
|
|
28
|
+
successor_refresh_token_id: v.string().optional(),
|
|
26
29
|
device_info: v.record(v.any()).optional()
|
|
27
30
|
});
|
|
28
31
|
/**
|
|
@@ -140,6 +143,20 @@ var RefreshToken = class extends Model {
|
|
|
140
143
|
user_id: user.id
|
|
141
144
|
});
|
|
142
145
|
}
|
|
146
|
+
/** Find any member of a family, including an already-revoked legacy member. */
|
|
147
|
+
static findInFamily(familyId) {
|
|
148
|
+
return this.first({ family_id: familyId });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Every refresh lineage for a user, including rotated rows. Logout uses this
|
|
152
|
+
* to bootstrap durable family records before it starts family revocations.
|
|
153
|
+
*/
|
|
154
|
+
static familiesForUser(user) {
|
|
155
|
+
return this.query().where({
|
|
156
|
+
user_id: user.id,
|
|
157
|
+
user_type: user.userType
|
|
158
|
+
}).get();
|
|
159
|
+
}
|
|
143
160
|
/**
|
|
144
161
|
* Delete a specific refresh token belonging to the user.
|
|
145
162
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport type { DeviceInfo } from \"../../contracts/types\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted refresh-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape:\r\n *\r\n * @example\r\n * export class AppRefreshToken extends RefreshToken {\r\n * public static schema = refreshTokenSchema.extend({\r\n * organization_id: v.string().required(),\r\n * });\r\n * }\r\n */\r\nexport const refreshTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n family_id: v.string().required(),\r\n expires_at: v.date().required(),\r\n last_used_at: v.date().default(() => new Date()),\r\n revoked_at: v.date().optional(),\r\n device_info: v.record(v.any()).optional(),\r\n});\r\n\r\n/**\r\n * Extra attributes captured when a refresh token is issued.\r\n */\r\nexport type RefreshTokenIssueOptions = {\r\n familyId: string;\r\n expiresAt: string;\r\n deviceInfo?: DeviceInfo;\r\n};\r\n\r\n/**\r\n * Persisted refresh-token record + the data layer for refresh tokens.\r\n *\r\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\r\n * The auth service drives refresh-token state exclusively through this model's\r\n * named statics and instance methods, so it never hard-codes a column name —\r\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\r\n * structurally impossible and lets an override rename or add columns by\r\n * extending this class and registering it under `config.auth.refreshToken.model`.\r\n */\r\nexport class RefreshToken extends Model {\r\n public static table = \"refresh_tokens\";\r\n\r\n public static schema = refreshTokenSchema;\r\n\r\n /**\r\n * The raw signed refresh JWT is a bearer credential — never serialize it.\r\n * `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a\r\n * session list returned via `response.json`); `get(\"token\")`,\r\n * `findByToken` / `findForUser` and every query still read the column.\r\n */\r\n public static hidden = [\"token\"];\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\r\n * never-expiring purge). A purged refresh token must not survive anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** Token family this row belongs to (rotation / replay grouping). */\r\n public get familyId(): string {\r\n return this.get(\"family_id\");\r\n }\r\n\r\n /**\r\n * Whether the token's `expires_at` is in the past.\r\n *\r\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\r\n * counts as expired. It previously answered `false` for both — \"no expiry\r\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\r\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\r\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\r\n * that cannot say when it dies is malformed, not immortal.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date().getTime() > new Date(expiresAt).getTime();\r\n }\r\n\r\n /**\r\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\r\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /** Whether the token has been revoked. */\r\n public get isRevoked(): boolean {\r\n return !!this.get(\"revoked_at\");\r\n }\r\n\r\n /** Whether the token is still usable (not expired and not revoked). */\r\n public get isValid(): boolean {\r\n return !this.isExpired && !this.isRevoked;\r\n }\r\n\r\n /**\r\n * Unconditionally stamp `revoked_at` on this token.\r\n */\r\n public async revoke(): Promise<this> {\r\n return this.merge({ revoked_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\r\n * when this call performed the revoke, `false` when a concurrent request had\r\n * already revoked it — the win/lose signal that powers rotation replay\r\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\r\n * same token can never both succeed.\r\n */\r\n public async revokeIfActive(): Promise<boolean> {\r\n const modelClass = this.constructor as typeof RefreshToken;\r\n\r\n const revokedCount = await modelClass.atomic(\r\n { id: this.id, revoked_at: null },\r\n { $set: { revoked_at: new Date() } },\r\n );\r\n\r\n return revokedCount > 0;\r\n }\r\n\r\n /**\r\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\r\n */\r\n public async markAsUsed(): Promise<void> {\r\n await this.merge({ last_used_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed refresh token for the user.\r\n */\r\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n family_id: options.familyId,\r\n expires_at: options.expiresAt,\r\n device_info: options.deviceInfo\r\n ? {\r\n userAgent: options.deviceInfo.userAgent,\r\n ip: options.deviceInfo.ip,\r\n deviceId: options.deviceInfo.deviceId,\r\n }\r\n : undefined,\r\n });\r\n }\r\n\r\n /**\r\n * Find a refresh-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<RefreshToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Find a refresh token scoped to a user — used by logout so a caller can only\r\n * revoke a token that actually belongs to them.\r\n */\r\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\r\n return this.first({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete a specific refresh token belonging to the user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Active, unexpired sessions for the user, newest first.\r\n */\r\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\r\n return this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .where(\"expires_at\", \">\", new Date())\r\n .orderBy(\"created_at\", \"desc\")\r\n .get();\r\n }\r\n\r\n /**\r\n * Revoke every still-active refresh token for the user, returning the rows\r\n * that were revoked so the caller can emit a per-token event for each.\r\n *\r\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\r\n * `revoked_at: null` would re-query the same predicate after the update and\r\n * match nothing, returning an empty set.\r\n */\r\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\r\n const tokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke every still-active token in a family (rotation breach containment),\r\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\r\n */\r\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\r\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\r\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\r\n * small.\r\n */\r\n public static async enforceMax(user: Auth, max: number): Promise<void> {\r\n const activeTokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .orderBy(\"created_at\", \"asc\")\r\n .get();\r\n\r\n if (activeTokens.length < max) return;\r\n\r\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\r\n\r\n for (const token of tokensToRevoke) {\r\n await token.revoke();\r\n }\r\n }\r\n\r\n /**\r\n * Hard-delete every expired refresh token, returning the deleted rows so the\r\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\r\n * (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<RefreshToken[]> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}. A full\r\n * scan filtered in memory, for the reason given on\r\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\r\n * missing `exp` claim is expressible as a `where`.\r\n */\r\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: RefreshToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed.\r\n *\r\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\r\n * table has to carry, and one of the two shapes being removed is a row whose\r\n * date column cannot be compared at all — leaving it in place keeps a\r\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\r\n * these rows can no longer be presented successfully either way.\r\n */\r\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport type { DeviceInfo } from \"../../contracts/types\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted refresh-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape:\r\n *\r\n * @example\r\n * export class AppRefreshToken extends RefreshToken {\r\n * public static schema = refreshTokenSchema.extend({\r\n * organization_id: v.string().required(),\r\n * });\r\n * }\r\n */\r\nexport const refreshTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n family_id: v.string().required(),\r\n expires_at: v.date().required(),\r\n last_used_at: v.date().default(() => new Date()),\r\n revoked_at: v.date().optional(),\r\n /** The successor pair a bounded concurrent renewal may reuse. */\r\n successor_access_token_id: v.string().optional(),\r\n successor_refresh_token_id: v.string().optional(),\r\n device_info: v.record(v.any()).optional(),\r\n});\r\n\r\n/**\r\n * Extra attributes captured when a refresh token is issued.\r\n */\r\nexport type RefreshTokenIssueOptions = {\r\n familyId: string;\r\n expiresAt: string;\r\n deviceInfo?: DeviceInfo;\r\n};\r\n\r\n/**\r\n * Persisted refresh-token record + the data layer for refresh tokens.\r\n *\r\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\r\n * The auth service drives refresh-token state exclusively through this model's\r\n * named statics and instance methods, so it never hard-codes a column name —\r\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\r\n * structurally impossible and lets an override rename or add columns by\r\n * extending this class and registering it under `config.auth.refreshToken.model`.\r\n */\r\nexport class RefreshToken extends Model {\r\n public static table = \"refresh_tokens\";\r\n\r\n public static schema = refreshTokenSchema;\r\n\r\n /**\r\n * The raw signed refresh JWT is a bearer credential — never serialize it.\r\n * `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a\r\n * session list returned via `response.json`); `get(\"token\")`,\r\n * `findByToken` / `findForUser` and every query still read the column.\r\n */\r\n public static hidden = [\"token\"];\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\r\n * never-expiring purge). A purged refresh token must not survive anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** Token family this row belongs to (rotation / replay grouping). */\r\n public get familyId(): string {\r\n return this.get(\"family_id\");\r\n }\r\n\r\n /**\r\n * Whether the token's `expires_at` is in the past.\r\n *\r\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\r\n * counts as expired. It previously answered `false` for both — \"no expiry\r\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\r\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\r\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\r\n * that cannot say when it dies is malformed, not immortal.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date().getTime() > new Date(expiresAt).getTime();\r\n }\r\n\r\n /**\r\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\r\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /** Whether the token has been revoked. */\r\n public get isRevoked(): boolean {\r\n return !!this.get(\"revoked_at\");\r\n }\r\n\r\n /** Whether the token is still usable (not expired and not revoked). */\r\n public get isValid(): boolean {\r\n return !this.isExpired && !this.isRevoked;\r\n }\r\n\r\n /**\r\n * Unconditionally stamp `revoked_at` on this token.\r\n */\r\n public async revoke(): Promise<this> {\r\n return this.merge({ revoked_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\r\n * when this call performed the revoke, `false` when a concurrent request had\r\n * already revoked it — the win/lose signal that powers rotation replay\r\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\r\n * same token can never both succeed.\r\n */\r\n public async revokeIfActive(): Promise<boolean> {\r\n const modelClass = this.constructor as typeof RefreshToken;\r\n\r\n const revokedCount = await modelClass.atomic(\r\n { id: this.id, revoked_at: null },\r\n { $set: { revoked_at: new Date() } },\r\n );\r\n\r\n return revokedCount > 0;\r\n }\r\n\r\n /**\r\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\r\n */\r\n public async markAsUsed(): Promise<void> {\r\n await this.merge({ last_used_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed refresh token for the user.\r\n */\r\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n family_id: options.familyId,\r\n expires_at: options.expiresAt,\r\n device_info: options.deviceInfo\r\n ? {\r\n userAgent: options.deviceInfo.userAgent,\r\n ip: options.deviceInfo.ip,\r\n deviceId: options.deviceInfo.deviceId,\r\n }\r\n : undefined,\r\n });\r\n }\r\n\r\n /**\r\n * Find a refresh-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<RefreshToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Find a refresh token scoped to a user — used by logout so a caller can only\r\n * revoke a token that actually belongs to them.\r\n */\r\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\r\n return this.first({ token, user_id: user.id });\r\n }\r\n\r\n /** Find any member of a family, including an already-revoked legacy member. */\r\n public static findInFamily(familyId: string): Promise<RefreshToken | null> {\r\n return this.first({ family_id: familyId });\r\n }\r\n\r\n /**\r\n * Every refresh lineage for a user, including rotated rows. Logout uses this\r\n * to bootstrap durable family records before it starts family revocations.\r\n */\r\n public static familiesForUser(user: Auth): Promise<RefreshToken[]> {\r\n return this.query().where({ user_id: user.id, user_type: user.userType }).get();\r\n }\r\n\r\n /**\r\n * Delete a specific refresh token belonging to the user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Active, unexpired sessions for the user, newest first.\r\n */\r\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\r\n return this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .where(\"expires_at\", \">\", new Date())\r\n .orderBy(\"created_at\", \"desc\")\r\n .get();\r\n }\r\n\r\n /**\r\n * Revoke every still-active refresh token for the user, returning the rows\r\n * that were revoked so the caller can emit a per-token event for each.\r\n *\r\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\r\n * `revoked_at: null` would re-query the same predicate after the update and\r\n * match nothing, returning an empty set.\r\n */\r\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\r\n const tokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke every still-active token in a family (rotation breach containment),\r\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\r\n */\r\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\r\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\r\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\r\n * small.\r\n */\r\n public static async enforceMax(user: Auth, max: number): Promise<void> {\r\n const activeTokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .orderBy(\"created_at\", \"asc\")\r\n .get();\r\n\r\n if (activeTokens.length < max) return;\r\n\r\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\r\n\r\n for (const token of tokensToRevoke) {\r\n await token.revoke();\r\n }\r\n }\r\n\r\n /**\r\n * Hard-delete every expired refresh token, returning the deleted rows so the\r\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\r\n * (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<RefreshToken[]> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}. A full\r\n * scan filtered in memory, for the reason given on\r\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\r\n * missing `exp` claim is expressible as a `where`.\r\n */\r\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: RefreshToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed.\r\n *\r\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\r\n * table has to carry, and one of the two shapes being removed is a row whose\r\n * date column cannot be compared at all — leaving it in place keeps a\r\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\r\n * these rows can no longer be presented successfully either way.\r\n */\r\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { RefreshToken } from "./refresh-token.model.mjs";
|
|
2
|
+
import { migrate } from "@warlock.js/cascade";
|
|
3
|
+
|
|
4
|
+
//#region ../auth/src/models/refresh-token/successor-association.migration.ts
|
|
5
|
+
/** Adds nullable successor links for bounded concurrent refresh coalescing. */
|
|
6
|
+
const RefreshTokenSuccessorAssociationMigration = migrate(RefreshToken, {
|
|
7
|
+
name: "refreshTokenSuccessorAssociation",
|
|
8
|
+
up() {
|
|
9
|
+
this.uuid("successor_access_token_id").nullable().index();
|
|
10
|
+
this.uuid("successor_refresh_token_id").nullable().index();
|
|
11
|
+
},
|
|
12
|
+
down() {
|
|
13
|
+
this.dropIndex("successor_access_token_id");
|
|
14
|
+
this.dropIndex("successor_refresh_token_id");
|
|
15
|
+
this.dropColumn("successor_access_token_id");
|
|
16
|
+
this.dropColumn("successor_refresh_token_id");
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
export { RefreshTokenSuccessorAssociationMigration };
|
|
22
|
+
//# sourceMappingURL=successor-association.migration.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"successor-association.migration.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/successor-association.migration.ts"],"sourcesContent":["import { migrate } from \"@warlock.js/cascade\";\nimport { RefreshToken } from \"./refresh-token.model\";\n\n/** Adds nullable successor links for bounded concurrent refresh coalescing. */\nexport const RefreshTokenSuccessorAssociationMigration = migrate(RefreshToken, {\n name: \"refreshTokenSuccessorAssociation\",\n up() {\n this.uuid(\"successor_access_token_id\").nullable().index();\n this.uuid(\"successor_refresh_token_id\").nullable().index();\n },\n down() {\n this.dropIndex(\"successor_access_token_id\");\n this.dropIndex(\"successor_refresh_token_id\");\n this.dropColumn(\"successor_access_token_id\");\n this.dropColumn(\"successor_refresh_token_id\");\n },\n});\n"],"mappings":";;;;;AAIA,MAAa,4CAA4C,QAAQ,cAAc;CAC7E,MAAM;CACN,KAAK;EACH,KAAK,KAAK,2BAA2B,CAAC,CAAC,SAAS,CAAC,CAAC,MAAM;EACxD,KAAK,KAAK,4BAA4B,CAAC,CAAC,SAAS,CAAC,CAAC,MAAM;CAC3D;CACA,OAAO;EACL,KAAK,UAAU,2BAA2B;EAC1C,KAAK,UAAU,4BAA4B;EAC3C,KAAK,WAAW,2BAA2B;EAC3C,KAAK,WAAW,4BAA4B;CAC9C;AACF,CAAC"}
|
|
@@ -37,6 +37,13 @@ declare class AuthService {
|
|
|
37
37
|
*/
|
|
38
38
|
generateAccessToken(user: Auth, payload?: Record<string, unknown>): Promise<AccessTokenOutput>;
|
|
39
39
|
private issueRefreshToken;
|
|
40
|
+
/**
|
|
41
|
+
* Keep issuance into an explicitly reused family in the same durable
|
|
42
|
+
* transition boundary as rotation and revocation. A new, caller-unspecified
|
|
43
|
+
* family has no concurrent session to protect and retains the lightweight
|
|
44
|
+
* issuance path.
|
|
45
|
+
*/
|
|
46
|
+
private issueInSuppliedFamily;
|
|
40
47
|
/**
|
|
41
48
|
* Sign + persist a refresh token for the user (enforcing the per-user cap
|
|
42
49
|
* first). Resolves to `undefined` when refresh tokens are disabled in config.
|
|
@@ -53,6 +60,20 @@ declare class AuthService {
|
|
|
53
60
|
* is treated as a breach — the whole family is revoked and the request fails.
|
|
54
61
|
*/
|
|
55
62
|
refreshTokens(refreshTokenString: string, deviceInfo?: DeviceInfo): Promise<TokenPair | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Opt-in browser-session renewal. Unlike {@link refreshTokens}, this permits
|
|
65
|
+
* a short duplicate presentation to receive the exact committed successor
|
|
66
|
+
* pair. It is deliberately not used by the legacy/public refresh API.
|
|
67
|
+
*/
|
|
68
|
+
renewAutomaticSession(refreshTokenString: string, expectedUserType: string, options?: {
|
|
69
|
+
overlapMs?: number;
|
|
70
|
+
deviceInfo?: DeviceInfo;
|
|
71
|
+
}): Promise<TokenPair | null>;
|
|
72
|
+
private automaticOverlapMs;
|
|
73
|
+
private refreshIdentityMatches;
|
|
74
|
+
private hasSuccessor;
|
|
75
|
+
private isAutomaticOverlapOpen;
|
|
76
|
+
private readAutomaticSuccessor;
|
|
56
77
|
/**
|
|
57
78
|
* Verify a plaintext password against a stored hash.
|
|
58
79
|
*/
|
|
@@ -108,7 +129,7 @@ declare class AuthService {
|
|
|
108
129
|
removeRefreshToken(user: Auth, token: string): Promise<void>;
|
|
109
130
|
/**
|
|
110
131
|
* Revoke every active refresh token for the user and delete their access
|
|
111
|
-
* tokens — "log out of all devices".
|
|
132
|
+
* tokens — "log out of all devices". Each durable family is first marked through its revision coordinator; an
|
|
112
133
|
* event fires per revoked token.
|
|
113
134
|
*/
|
|
114
135
|
revokeAllTokens(user: Auth): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.service.d.mts","names":[],"sources":["../../../../../../../auth/src/services/auth.service.ts"],"mappings":";;;;;;;;
|
|
1
|
+
{"version":3,"file":"auth.service.d.mts","names":[],"sources":["../../../../../../../auth/src/services/auth.service.ts"],"mappings":";;;;;;;;cAiCM,WAAA;;;;AAViD;;;cAiBzC,gBAAA;EAqBgC;;;EAAA,YAdhC,iBAAA;EA6DA;;;EAAA,YAtDA,iBAAA;EAwIG;;;EAjIR,uBAAA,CAAwB,IAAA,EAAM,IAAA,GAAO,MAAA;EA8LU;EArLzC,eAAA,CAAgB,IAAA,EAAM,IAAA,GAAO,OAAA;EAAA,QAI5B,qBAAA;EAAA,QAMA,gBAAA;EA+LH;;;;;EArKE,mBAAA,CACX,IAAA,EAAM,IAAA,EACN,OAAA,GAAU,MAAA,oBACT,OAAA,CAAQ,iBAAA;EAAA,QAMG,iBAAA;EA0asB;;;;;;EAAA,QAjYtB,qBAAA;EAqaM;;;;EArYP,kBAAA,CACX,IAAA,EAAM,IAAA,EACN,UAAA,GAAa,UAAA,GACZ,OAAA,CAAQ,YAAA;EAAA,QAcG,cAAA;EAuXX;;;EAzUU,eAAA,CAAgB,IAAA,EAAM,IAAA,EAAM,UAAA,GAAa,UAAA,GAAa,OAAA,CAAQ,SAAA;EA+VpD;;;;;EA9UV,aAAA,CACX,kBAAA,UACA,UAAA,GAAa,UAAA,GACZ,OAAA,CAAQ,SAAA;EA0YgD;;;;;EAhT9C,qBAAA,CACX,kBAAA,UACA,gBAAA,UACA,OAAA;IAAW,SAAA;IAAoB,UAAA,GAAa,UAAA;EAAA,IAC3C,OAAA,CAAQ,SAAA;EAAA,QAiGH,kBAAA;EAAA,QAMA,sBAAA;EAAA,QAWA,YAAA;EAAA,QAMA,sBAAA;EAAA,QAOM,sBAAA;EA2WsC;;;EAzUvC,cAAA,CAAe,aAAA,UAAuB,cAAA,WAAyB,OAAA;EAoXjE;;;EA7WE,YAAA,CAAa,QAAA,WAAmB,OAAA;EAqYkC;;;;EA7XlE,YAAA,WAAuB,IAAA,EAClC,KAAA,EAAO,UAAA,CAAW,CAAA,GAClB,IAAA,EAAM,eAAA,GACL,OAAA,CAAQ,CAAA;EAne0B;;;;EAmgBxB,KAAA,WAAgB,IAAA,EAC3B,KAAA,EAAO,UAAA,CAAW,CAAA,GAClB,WAAA,EAAa,eAAA,EACb,UAAA,GAAa,UAAA,GACZ,OAAA,CAAQ,WAAA,CAAY,CAAA;EA9fM;;;;;;;;;EAihBhB,aAAA,WAAwB,IAAA,EACnC,IAAA,EAAM,CAAA,EACN,UAAA,GAAa,UAAA,GACZ,OAAA,CAAQ,WAAA,CAAY,CAAA;EA7eZ;EAAA,QAofG,aAAA;EArcA;;;;;;;;;;EAgeD,MAAA,CAAO,IAAA,EAAM,IAAA,EAAM,WAAA,WAAsB,YAAA,YAAwB,OAAA;EAjYjD;;;EA8ZhB,iBAAA,CAAkB,IAAA,EAAM,IAAA,EAAM,KAAA,WAAgB,OAAA;EA9ZgB;;;EAqa9D,qBAAA,CAAsB,IAAA,EAAM,IAAA,GAAO,OAAA;EAlZ9C;;;EAyZW,kBAAA,CAAmB,IAAA,EAAM,IAAA,EAAM,KAAA,WAAgB,OAAA;EA7T1D;;;;;EAsUW,eAAA,CAAgB,IAAA,EAAM,IAAA,GAAO,OAAA;EAnUvC;;;EAkWU,iBAAA,CAAkB,QAAA,WAAmB,OAAA;EAhP1C;;;;;;EAyTK,oBAAA,IAAwB,OAAA;EAnQxB;;;;;;;;;;EA4RA,uBAAA,IAA2B,OAAA;IACtC,YAAA,EAAc,WAAA;IACd,aAAA,EAAe,YAAA;EAAA;EAnPE;;;;;;;;EAmQN,wBAAA,IAA4B,OAAA;IACvC,YAAA;IACA,aAAA;EAAA;EA9OW;;;EAgQA,iBAAA,CAAkB,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,YAAA;EA/PlD;;;;;EAAA,QAwQM,qBAAA;EA/PM;;;;;;;;;;;;;;;;;;;;;EA8RP,aAAA,CACL,QAAA,EAAU,QAAA,EACV,KAAA,WAAgB,iBAAA,EAChB,OAAA,GAAS,oBAAA;EAlNwB;;;;;;;;;;EA0O5B,eAAA,CAAgB,QAAA,EAAU,QAAA,EAAU,OAAA,GAAS,sBAAA;AAAA;AAAA,cAQzC,WAAA,EAAW,WAAoB"}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AccessToken } from "../models/access-token/access-token.model.mjs";
|
|
2
2
|
import "../models/access-token/index.mjs";
|
|
3
|
+
import { AuthTokenFamily } from "../models/auth-token-family/auth-token-family.model.mjs";
|
|
4
|
+
import "../models/auth-token-family/index.mjs";
|
|
3
5
|
import { OneTimeToken } from "../models/one-time-token/one-time-token.model.mjs";
|
|
4
6
|
import "../models/one-time-token/index.mjs";
|
|
5
7
|
import { RefreshToken } from "../models/refresh-token/refresh-token.model.mjs";
|
|
@@ -7,8 +9,10 @@ import "../models/refresh-token/index.mjs";
|
|
|
7
9
|
import { authConfig } from "./auth-config.mjs";
|
|
8
10
|
import { authEvents } from "./auth-events.mjs";
|
|
9
11
|
import { isInvalidCredentialError, jwt } from "./jwt.mjs";
|
|
12
|
+
import { TokenFamilyUnavailableError, afterTokenFamilyOperation, runTokenFamilyOperation } from "./token-family-operation.mjs";
|
|
10
13
|
import { Random } from "@mongez/reinforcements";
|
|
11
14
|
import { ForbiddenError, config, hashPassword, verifyPassword } from "@warlock.js/core";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
12
16
|
|
|
13
17
|
//#region ../auth/src/services/auth.service.ts
|
|
14
18
|
var AuthService = class {
|
|
@@ -50,12 +54,16 @@ var AuthService = class {
|
|
|
50
54
|
async assertCanAuthenticate(user) {
|
|
51
55
|
if (!await this.canAuthenticate(user)) throw new ForbiddenError("This user cannot authenticate.");
|
|
52
56
|
}
|
|
53
|
-
async issueAccessToken(user, payload) {
|
|
54
|
-
const data =
|
|
57
|
+
async issueAccessToken(user, payload, familyId) {
|
|
58
|
+
const data = {
|
|
59
|
+
...payload || this.buildAccessTokenPayload(user),
|
|
60
|
+
jti: randomUUID()
|
|
61
|
+
};
|
|
55
62
|
const expiresIn = authConfig.accessToken.expiresInMs();
|
|
56
63
|
const token = await jwt.generate(data, { expiresIn });
|
|
57
64
|
const expiresAt = new Date(Date.now() + expiresIn);
|
|
58
|
-
await this.accessTokenModel.issue(user, token, expiresAt);
|
|
65
|
+
if (familyId) await this.accessTokenModel.issue(user, token, expiresAt, familyId);
|
|
66
|
+
else await this.accessTokenModel.issue(user, token, expiresAt);
|
|
59
67
|
return {
|
|
60
68
|
token,
|
|
61
69
|
expiresAt: expiresAt.toISOString()
|
|
@@ -74,10 +82,12 @@ var AuthService = class {
|
|
|
74
82
|
if (!authConfig.refreshToken.enabled()) return;
|
|
75
83
|
const expiresIn = authConfig.refreshToken.expiresInMs();
|
|
76
84
|
const familyId = deviceInfo?.familyId || Random.string(32);
|
|
85
|
+
if ((await AuthTokenFamily.ensure(user, familyId)).isRevoked) throw new Error("Cannot issue tokens for a revoked token family");
|
|
77
86
|
const payload = {
|
|
78
87
|
userId: user.id,
|
|
79
88
|
userType: user.userType,
|
|
80
|
-
familyId
|
|
89
|
+
familyId,
|
|
90
|
+
jti: randomUUID()
|
|
81
91
|
};
|
|
82
92
|
const expiresAt = new Date(Date.now() + expiresIn).toISOString();
|
|
83
93
|
await this.refreshTokenModel.enforceMax(user, authConfig.refreshToken.maxPerUser());
|
|
@@ -89,17 +99,41 @@ var AuthService = class {
|
|
|
89
99
|
});
|
|
90
100
|
}
|
|
91
101
|
/**
|
|
102
|
+
* Keep issuance into an explicitly reused family in the same durable
|
|
103
|
+
* transition boundary as rotation and revocation. A new, caller-unspecified
|
|
104
|
+
* family has no concurrent session to protect and retains the lightweight
|
|
105
|
+
* issuance path.
|
|
106
|
+
*/
|
|
107
|
+
async issueInSuppliedFamily(user, familyId, issue) {
|
|
108
|
+
if ((await AuthTokenFamily.ensure(user, familyId)).isRevoked) throw new Error("Cannot issue tokens for a revoked token family");
|
|
109
|
+
try {
|
|
110
|
+
return await runTokenFamilyOperation(familyId, async () => issue());
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof TokenFamilyUnavailableError) throw new Error("Cannot issue tokens for a revoked token family");
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
92
117
|
* Sign + persist a refresh token for the user (enforcing the per-user cap
|
|
93
118
|
* first). Resolves to `undefined` when refresh tokens are disabled in config.
|
|
94
119
|
*/
|
|
95
120
|
async createRefreshToken(user, deviceInfo) {
|
|
96
121
|
if (!authConfig.refreshToken.enabled()) return;
|
|
97
122
|
await this.assertCanAuthenticate(user);
|
|
123
|
+
if (deviceInfo?.familyId) return this.issueInSuppliedFamily(user, deviceInfo.familyId, () => this.issueRefreshToken(user, deviceInfo));
|
|
98
124
|
return this.issueRefreshToken(user, deviceInfo);
|
|
99
125
|
}
|
|
100
126
|
async issueTokenPair(user, deviceInfo) {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
127
|
+
const refreshEnabled = authConfig.refreshToken.enabled();
|
|
128
|
+
const familyId = deviceInfo?.familyId || Random.string(32);
|
|
129
|
+
if (refreshEnabled) {
|
|
130
|
+
if ((await AuthTokenFamily.ensure(user, familyId)).isRevoked) throw new Error("Cannot issue tokens for a revoked token family");
|
|
131
|
+
}
|
|
132
|
+
const accessToken = await this.issueAccessToken(user, deviceInfo?.payload, refreshEnabled ? familyId : void 0);
|
|
133
|
+
const refreshToken = await this.issueRefreshToken(user, refreshEnabled ? {
|
|
134
|
+
...deviceInfo,
|
|
135
|
+
familyId
|
|
136
|
+
} : deviceInfo);
|
|
103
137
|
const tokenPair = {
|
|
104
138
|
accessToken,
|
|
105
139
|
refreshToken: refreshToken ? {
|
|
@@ -107,8 +141,8 @@ var AuthService = class {
|
|
|
107
141
|
expiresAt: refreshToken.get("expires_at")
|
|
108
142
|
} : void 0
|
|
109
143
|
};
|
|
110
|
-
authEvents.emit("token.created", user, tokenPair);
|
|
111
|
-
if (refreshToken) authEvents.emit("session.created", user, refreshToken, deviceInfo);
|
|
144
|
+
afterTokenFamilyOperation(() => authEvents.emit("token.created", user, tokenPair));
|
|
145
|
+
if (refreshToken) afterTokenFamilyOperation(() => authEvents.emit("session.created", user, refreshToken, deviceInfo));
|
|
112
146
|
return tokenPair;
|
|
113
147
|
}
|
|
114
148
|
/**
|
|
@@ -116,6 +150,7 @@ var AuthService = class {
|
|
|
116
150
|
*/
|
|
117
151
|
async createTokenPair(user, deviceInfo) {
|
|
118
152
|
await this.assertCanAuthenticate(user);
|
|
153
|
+
if (authConfig.refreshToken.enabled() && deviceInfo?.familyId) return this.issueInSuppliedFamily(user, deviceInfo.familyId, () => this.issueTokenPair(user, deviceInfo));
|
|
119
154
|
return this.issueTokenPair(user, deviceInfo);
|
|
120
155
|
}
|
|
121
156
|
/**
|
|
@@ -132,28 +167,137 @@ var AuthService = class {
|
|
|
132
167
|
throw error;
|
|
133
168
|
}
|
|
134
169
|
if (!decoded) return null;
|
|
135
|
-
const
|
|
136
|
-
if (!
|
|
137
|
-
|
|
138
|
-
|
|
170
|
+
const presentedToken = await this.refreshTokenModel.findByToken(refreshTokenString);
|
|
171
|
+
if (!presentedToken || !this.refreshIdentityMatches(presentedToken, decoded)) return null;
|
|
172
|
+
await AuthTokenFamily.ensure({
|
|
173
|
+
id: decoded.userId,
|
|
174
|
+
userType: decoded.userType
|
|
175
|
+
}, presentedToken.familyId);
|
|
176
|
+
return runTokenFamilyOperation(presentedToken.familyId, async () => {
|
|
177
|
+
const refreshToken = await this.refreshTokenModel.findByToken(refreshTokenString);
|
|
178
|
+
if (!refreshToken || !this.refreshIdentityMatches(refreshToken, decoded)) return null;
|
|
179
|
+
if (!refreshToken.isValid) {
|
|
180
|
+
if (refreshToken) await this.revokeTokenFamily(refreshToken.familyId);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const UserModel = config.key(`auth.userType.${decoded.userType}`);
|
|
184
|
+
if (!UserModel) throw new Error(`User type ${decoded.userType} is unknown type.`);
|
|
185
|
+
const user = await UserModel.find(decoded.userId);
|
|
186
|
+
if (!user) return null;
|
|
187
|
+
if (!await this.canAuthenticate(user)) return null;
|
|
188
|
+
if (authConfig.refreshToken.rotation()) {
|
|
189
|
+
if (!await refreshToken.revokeIfActive()) {
|
|
190
|
+
await this.revokeTokenFamily(refreshToken.familyId);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
} else await refreshToken.markAsUsed();
|
|
194
|
+
const newTokenPair = await this.issueTokenPair(user, {
|
|
195
|
+
...deviceInfo,
|
|
196
|
+
familyId: refreshToken.familyId
|
|
197
|
+
});
|
|
198
|
+
afterTokenFamilyOperation(() => authEvents.emit("token.refreshed", user, newTokenPair, refreshToken));
|
|
199
|
+
return newTokenPair;
|
|
200
|
+
}).catch((error) => {
|
|
201
|
+
if (error instanceof TokenFamilyUnavailableError) return null;
|
|
202
|
+
throw error;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Opt-in browser-session renewal. Unlike {@link refreshTokens}, this permits
|
|
207
|
+
* a short duplicate presentation to receive the exact committed successor
|
|
208
|
+
* pair. It is deliberately not used by the legacy/public refresh API.
|
|
209
|
+
*/
|
|
210
|
+
async renewAutomaticSession(refreshTokenString, expectedUserType, options = {}) {
|
|
211
|
+
let decoded;
|
|
212
|
+
try {
|
|
213
|
+
decoded = await jwt.verifyRefreshToken(refreshTokenString);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (isInvalidCredentialError(error)) return null;
|
|
216
|
+
throw error;
|
|
139
217
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
218
|
+
if (!decoded || decoded.userType !== expectedUserType) return null;
|
|
219
|
+
const presentedToken = await this.refreshTokenModel.findByToken(refreshTokenString);
|
|
220
|
+
if (!presentedToken || !this.refreshIdentityMatches(presentedToken, decoded)) return null;
|
|
221
|
+
await AuthTokenFamily.ensure({
|
|
222
|
+
id: decoded.userId,
|
|
223
|
+
userType: decoded.userType
|
|
224
|
+
}, presentedToken.familyId);
|
|
225
|
+
return runTokenFamilyOperation(presentedToken.familyId, async () => {
|
|
226
|
+
const oldToken = await this.refreshTokenModel.findByToken(refreshTokenString);
|
|
227
|
+
if (!oldToken || !this.refreshIdentityMatches(oldToken, decoded)) return null;
|
|
228
|
+
if (!oldToken.isValid) {
|
|
229
|
+
const successor = await this.readAutomaticSuccessor(oldToken, decoded, options.overlapMs);
|
|
230
|
+
if (successor) return successor;
|
|
231
|
+
if (this.isAutomaticOverlapOpen(oldToken, options.overlapMs) && this.hasSuccessor(oldToken)) return null;
|
|
232
|
+
await this.revokeTokenFamily(oldToken.familyId);
|
|
148
233
|
return null;
|
|
149
234
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
235
|
+
const UserModel = config.key(`auth.userType.${decoded.userType}`);
|
|
236
|
+
if (!UserModel) throw new Error(`User type ${decoded.userType} is unknown type.`);
|
|
237
|
+
const user = await UserModel.find(decoded.userId);
|
|
238
|
+
if (!user || !await this.canAuthenticate(user)) return null;
|
|
239
|
+
if (!await oldToken.revokeIfActive()) {
|
|
240
|
+
const currentToken = await this.refreshTokenModel.findByToken(refreshTokenString);
|
|
241
|
+
if (!currentToken) return null;
|
|
242
|
+
const successor = await this.readAutomaticSuccessor(currentToken, decoded, options.overlapMs);
|
|
243
|
+
if (successor) return successor;
|
|
244
|
+
if (this.isAutomaticOverlapOpen(currentToken, options.overlapMs) && this.hasSuccessor(currentToken)) return null;
|
|
245
|
+
await this.revokeTokenFamily(oldToken.familyId);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const pair = await this.issueTokenPair(user, {
|
|
249
|
+
...options.deviceInfo,
|
|
250
|
+
familyId: oldToken.familyId
|
|
251
|
+
});
|
|
252
|
+
const successorAccess = await this.accessTokenModel.findByToken(pair.accessToken.token);
|
|
253
|
+
const successorRefresh = pair.refreshToken ? await this.refreshTokenModel.findByToken(pair.refreshToken.token) : null;
|
|
254
|
+
if (!successorAccess || !successorRefresh) throw new Error("Automatic renewal did not persist a complete successor pair");
|
|
255
|
+
if (await this.refreshTokenModel.atomic({ id: oldToken.id }, { $set: {
|
|
256
|
+
successor_access_token_id: successorAccess.id,
|
|
257
|
+
successor_refresh_token_id: successorRefresh.id
|
|
258
|
+
} }) !== 1) throw new Error("Automatic renewal could not link its successor pair");
|
|
259
|
+
afterTokenFamilyOperation(() => authEvents.emit("token.refreshed", user, pair, oldToken));
|
|
260
|
+
return pair;
|
|
261
|
+
}).catch((error) => {
|
|
262
|
+
if (error instanceof TokenFamilyUnavailableError) return null;
|
|
263
|
+
throw error;
|
|
154
264
|
});
|
|
155
|
-
|
|
156
|
-
|
|
265
|
+
}
|
|
266
|
+
automaticOverlapMs(overlapMs) {
|
|
267
|
+
return typeof overlapMs === "number" && Number.isFinite(overlapMs) ? Math.max(0, Math.min(1e4, overlapMs)) : 5e3;
|
|
268
|
+
}
|
|
269
|
+
refreshIdentityMatches(token, decoded) {
|
|
270
|
+
return token.familyId === decoded.familyId && token.get("user_id") === decoded.userId && token.get("user_type") === decoded.userType;
|
|
271
|
+
}
|
|
272
|
+
hasSuccessor(oldToken) {
|
|
273
|
+
return Boolean(oldToken.get("successor_access_token_id") && oldToken.get("successor_refresh_token_id"));
|
|
274
|
+
}
|
|
275
|
+
isAutomaticOverlapOpen(oldToken, overlapMs) {
|
|
276
|
+
const revokedAt = oldToken.get("revoked_at");
|
|
277
|
+
const window = this.automaticOverlapMs(overlapMs);
|
|
278
|
+
const elapsed = revokedAt ? Date.now() - new Date(revokedAt).getTime() : NaN;
|
|
279
|
+
return window > 0 && elapsed >= 0 && elapsed <= window;
|
|
280
|
+
}
|
|
281
|
+
async readAutomaticSuccessor(oldToken, decoded, overlapMs) {
|
|
282
|
+
if (!this.isAutomaticOverlapOpen(oldToken, overlapMs)) return null;
|
|
283
|
+
const accessId = oldToken.get("successor_access_token_id");
|
|
284
|
+
const refreshId = oldToken.get("successor_refresh_token_id");
|
|
285
|
+
if (!accessId || !refreshId) return null;
|
|
286
|
+
const [access, refresh] = await Promise.all([this.accessTokenModel.find(accessId), this.refreshTokenModel.find(refreshId)]);
|
|
287
|
+
if (!access || access.isExpired || !refresh?.isValid) return null;
|
|
288
|
+
if (access.get("family_id") !== decoded.familyId || refresh.get("family_id") !== decoded.familyId || access.get("user_id") !== decoded.userId || refresh.get("user_id") !== decoded.userId || access.get("user_type") !== decoded.userType || refresh.get("user_type") !== decoded.userType) return null;
|
|
289
|
+
const accessExpiry = new Date(access.get("expires_at")).getTime();
|
|
290
|
+
if (!Number.isFinite(accessExpiry) || accessExpiry <= Date.now()) return null;
|
|
291
|
+
return {
|
|
292
|
+
accessToken: {
|
|
293
|
+
token: access.get("token"),
|
|
294
|
+
expiresAt: access.get("expires_at")
|
|
295
|
+
},
|
|
296
|
+
refreshToken: {
|
|
297
|
+
token: refresh.get("token"),
|
|
298
|
+
expiresAt: refresh.get("expires_at")
|
|
299
|
+
}
|
|
300
|
+
};
|
|
157
301
|
}
|
|
158
302
|
/**
|
|
159
303
|
* Verify a plaintext password against a stored hash.
|
|
@@ -239,7 +383,7 @@ var AuthService = class {
|
|
|
239
383
|
if (refreshToken) {
|
|
240
384
|
const token = await this.refreshTokenModel.findForUser(user, refreshToken);
|
|
241
385
|
if (token) {
|
|
242
|
-
await token.
|
|
386
|
+
await this.revokeTokenFamily(token.familyId);
|
|
243
387
|
authEvents.emit("session.destroyed", user, token);
|
|
244
388
|
}
|
|
245
389
|
} else {
|
|
@@ -269,12 +413,16 @@ var AuthService = class {
|
|
|
269
413
|
}
|
|
270
414
|
/**
|
|
271
415
|
* Revoke every active refresh token for the user and delete their access
|
|
272
|
-
* tokens — "log out of all devices".
|
|
416
|
+
* tokens — "log out of all devices". Each durable family is first marked through its revision coordinator; an
|
|
273
417
|
* event fires per revoked token.
|
|
274
418
|
*/
|
|
275
419
|
async revokeAllTokens(user) {
|
|
276
|
-
const
|
|
277
|
-
for (const token of
|
|
420
|
+
const existingRows = await this.refreshTokenModel.familiesForUser(user);
|
|
421
|
+
for (const token of existingRows) await AuthTokenFamily.ensure(user, token.familyId);
|
|
422
|
+
const families = await AuthTokenFamily.activeFor(user);
|
|
423
|
+
for (const family of families) await this.revokeTokenFamily(family.get("family_id"));
|
|
424
|
+
const legacyTokens = await this.refreshTokenModel.revokeAllFor(user);
|
|
425
|
+
for (const token of legacyTokens) authEvents.emit("token.revoked", user, token);
|
|
278
426
|
await this.removeAllAccessTokens(user);
|
|
279
427
|
authEvents.emit("logout.all", user);
|
|
280
428
|
}
|
|
@@ -282,8 +430,44 @@ var AuthService = class {
|
|
|
282
430
|
* Revoke an entire token family — rotation breach containment.
|
|
283
431
|
*/
|
|
284
432
|
async revokeTokenFamily(familyId) {
|
|
285
|
-
|
|
286
|
-
|
|
433
|
+
let family = await AuthTokenFamily.findByFamilyId(familyId);
|
|
434
|
+
if (!family) {
|
|
435
|
+
const legacyToken = await this.refreshTokenModel.findInFamily(familyId);
|
|
436
|
+
if (!legacyToken) return;
|
|
437
|
+
family = await AuthTokenFamily.ensure({
|
|
438
|
+
id: legacyToken.get("user_id"),
|
|
439
|
+
userType: legacyToken.get("user_type")
|
|
440
|
+
}, familyId);
|
|
441
|
+
}
|
|
442
|
+
const user = {
|
|
443
|
+
id: family.get("user_id"),
|
|
444
|
+
userType: family.get("user_type")
|
|
445
|
+
};
|
|
446
|
+
if (family.isRevoked) {
|
|
447
|
+
const revokedTokens = await this.refreshTokenModel.revokeFamily(familyId);
|
|
448
|
+
await this.accessTokenModel.deleteFamilyAndLegacyForUser(user, familyId);
|
|
449
|
+
afterTokenFamilyOperation(() => authEvents.emit("token.familyRevoked", familyId, revokedTokens));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
let revokedTokens;
|
|
453
|
+
try {
|
|
454
|
+
revokedTokens = await runTokenFamilyOperation(familyId, async (currentFamily) => {
|
|
455
|
+
const familyUser = {
|
|
456
|
+
id: currentFamily.get("user_id"),
|
|
457
|
+
userType: currentFamily.get("user_type")
|
|
458
|
+
};
|
|
459
|
+
const revision = currentFamily.get("revision");
|
|
460
|
+
if (await AuthTokenFamily.revoke(familyId, revision) !== 1) throw new Error("Token family revoke lost its revision transition");
|
|
461
|
+
const familyTokens = await this.refreshTokenModel.revokeFamily(familyId);
|
|
462
|
+
await this.accessTokenModel.deleteFamilyAndLegacyForUser(familyUser, familyId);
|
|
463
|
+
return familyTokens;
|
|
464
|
+
});
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (!(error instanceof TokenFamilyUnavailableError)) throw error;
|
|
467
|
+
revokedTokens = await this.refreshTokenModel.revokeFamily(familyId);
|
|
468
|
+
await this.accessTokenModel.deleteFamilyAndLegacyForUser(user, familyId);
|
|
469
|
+
}
|
|
470
|
+
afterTokenFamilyOperation(() => authEvents.emit("token.familyRevoked", familyId, revokedTokens));
|
|
287
471
|
}
|
|
288
472
|
/**
|
|
289
473
|
* Delete expired tokens (refresh + access) and spent one-time tokens
|