@blixis-io/auth 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +84 -19
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -90,9 +90,24 @@ interface AuthModuleOptions<Claims = unknown> {
|
|
|
90
90
|
algorithm?: "HS256" | "HS384" | "HS512";
|
|
91
91
|
/** Makes `JwtAuthGuard` (and `AUTH_SERVICE`, if `issuing` is set) visible to every module without each one importing this one directly. Defaults to `false`. */
|
|
92
92
|
global?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Require authentication on **every** route unless it is marked `@Public()`, and enforce `@Roles(...)`
|
|
95
|
+
* wherever it is used. Defaults to `false`: nothing changes until you opt in, and then protection is on by
|
|
96
|
+
* default. Without it, add `@UseGuards(AuthGuard)` to the controllers or routes you want protected.
|
|
97
|
+
*/
|
|
98
|
+
protectAllRoutes?: boolean;
|
|
93
99
|
/** Omit for a verify-only app (the original scope). Set to enable `AUTH_SERVICE` — password sign-in, refresh rotation, sign-out. */
|
|
94
100
|
issuing?: IssuingOptions<Claims> | undefined;
|
|
95
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Requires the authenticated user to hold at least one of `roles` (read from the token's `roles` claim).
|
|
104
|
+
* On a controller it covers every route in it; on a route it replaces the controller's. Only takes effect
|
|
105
|
+
* where `AuthGuard` runs: everywhere with `protectAllRoutes: true`, otherwise on routes that carry
|
|
106
|
+
* `@UseGuards(AuthGuard)`. An unauthenticated request is a 401, a missing role a 403.
|
|
107
|
+
*/
|
|
108
|
+
export declare function Roles(...roles: readonly string[]): ClassDecorator & MethodDecorator;
|
|
109
|
+
/** Skips authentication for a route (or every route in a controller): the right place for a login or health endpoint when `protectAllRoutes` is on. */
|
|
110
|
+
export declare function Public(): ClassDecorator & MethodDecorator;
|
|
96
111
|
/**
|
|
97
112
|
* Builds a `JwtAuthGuard` + `createRolesGuard` bound to one claims schema,
|
|
98
113
|
* same factory-closure shape as `@blixis-io/config`'s `defineConfigModule` —
|
|
@@ -104,6 +119,8 @@ export declare function defineAuthModule<Schema extends ZodType>(claimsSchema: S
|
|
|
104
119
|
forRoot(options: AuthModuleOptions<z.infer<Schema>>): DynamicModule;
|
|
105
120
|
};
|
|
106
121
|
JwtAuthGuard: Class<CanActivate>;
|
|
122
|
+
/** Authenticates the request unless the route is `@Public()`, then enforces `@Roles(...)`. Use it with `@UseGuards(AuthGuard)`, or let `protectAllRoutes` apply it everywhere. */
|
|
123
|
+
AuthGuard: Class<CanActivate>;
|
|
107
124
|
createRolesGuard: (...roles: readonly string[]) => Class<CanActivate>;
|
|
108
125
|
getCurrentUser: (ctx: RequestContext) => z.infer<Schema> | undefined;
|
|
109
126
|
/** Resolvable only when `forRoot({ issuing })` was set — otherwise `MissingProviderError` at boot. */
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/issuing.ts","../src/module.ts","../src/password.ts"],"mappings":";;;;;UAQiB,gBAAgB;;EAE/B,iBAAiB,qBAAqB;IAAU;IAAiB;;;;;;;;EAOjE,WAAW,kBAAkB,QAAQ;;UAGtB;EACf;EACA,WAAW;EACX,YAAY;EACZ,YAAY;;UAGG;EACf,OAAO,mBAAmB;IAAU;IAAiB,WAAW;MAAS;EACzE,KAAK,oBAAoB,QAAQ;;;;;;;;EAQjC,YAAY,oBAAoB;;EAEhC,OAAO,oBAAoB;EAC3B,oBAAoB,kBAAkB;;UAGvB;EACf;EACA;EACA,sBAAsB;EACtB,uBAAuB;;UAGR;;EAEf,OAAO,oBAAoB,mBAAmB,QAAQ;;;;;;;EAOtD,QAAQ,uBAAuB,QAAQ;;EAEvC,QAAQ,uBAAuB;;EAE/B,YAAY,kBAAkB,QAAQ;;EAEtC,kBAAkB,kBAAkB;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/issuing.ts","../src/module.ts","../src/password.ts"],"mappings":";;;;;UAQiB,gBAAgB;;EAE/B,iBAAiB,qBAAqB;IAAU;IAAiB;;;;;;;;EAOjE,WAAW,kBAAkB,QAAQ;;UAGtB;EACf;EACA,WAAW;EACX,YAAY;EACZ,YAAY;;UAGG;EACf,OAAO,mBAAmB;IAAU;IAAiB,WAAW;MAAS;EACzE,KAAK,oBAAoB,QAAQ;;;;;;;;EAQjC,YAAY,oBAAoB;;EAEhC,OAAO,oBAAoB;EAC3B,oBAAoB,kBAAkB;;UAGvB;EACf;EACA;EACA,sBAAsB;EACtB,uBAAuB;;UAGR;;EAEf,OAAO,oBAAoB,mBAAmB,QAAQ;;;;;;;EAOtD,QAAQ,uBAAuB,QAAQ;;EAEvC,QAAQ,uBAAuB;;EAE/B,YAAY,kBAAkB,QAAQ;;EAEtC,kBAAkB,kBAAkB;;;;UCzCrB,eAAe;;;;;;;EAO9B,UAAU;;EAEV,iBAAiB,MAAM,gBAAgB;;EAEvC,mBAAmB,MAAM;;EAEzB;;EAEA;;UAGe,kBAAkB;;EAEjC;;EAEA;;EAEA;;;;;;EAMA;;EAEA,UAAU,eAAe;;;;;;;;wBAaX,SAAS,2BAA2B,iBAAiB;;wBAKrD,UAAU,iBAAiB;;;;;;;wBA6B3B,iBAAiB,eAAe,SAC9C,cAAc;EAEd;IAAc,QAAQ,SAAS,kBAAkB,EAAE,MAAM,WAAW;;EACpE,cAAc,MAAM;;EAEpB,WAAW,MAAM;EACjB,sBAAsB,6BAA6B,MAAM;EACzD,iBAAiB,KAAK,mBAAmB,EAAE,MAAM;;EAEjD,cAAc,eAAe;;;;;;;;;wBCrET,aAAa,mBAAmB;;;;;;;;;wBAuChC,eAAe,kBAAkB,eAAe"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Module } from "@blixis-io/core";
|
|
2
2
|
import { Inject, Injectable, InjectionToken } from "@blixis-io/di";
|
|
3
|
-
import { RequestContext, UnauthorizedException } from "@blixis-io/http";
|
|
3
|
+
import { GlobalGuard, RequestContext, SetRouteMetadata, UnauthorizedException, getRouteMetadata } from "@blixis-io/http";
|
|
4
4
|
import { SignJWT, jwtVerify } from "jose";
|
|
5
5
|
import { argon2, createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
6
|
//#region src/password.ts
|
|
@@ -197,6 +197,28 @@ function createAuthServiceClass(deps) {
|
|
|
197
197
|
//#region src/module.ts
|
|
198
198
|
const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
|
|
199
199
|
const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2592e3;
|
|
200
|
+
const ROLES_METADATA = Symbol.for("blixis:auth:roles");
|
|
201
|
+
const PUBLIC_METADATA = Symbol.for("blixis:auth:public");
|
|
202
|
+
/**
|
|
203
|
+
* Requires the authenticated user to hold at least one of `roles` (read from the token's `roles` claim).
|
|
204
|
+
* On a controller it covers every route in it; on a route it replaces the controller's. Only takes effect
|
|
205
|
+
* where `AuthGuard` runs: everywhere with `protectAllRoutes: true`, otherwise on routes that carry
|
|
206
|
+
* `@UseGuards(AuthGuard)`. An unauthenticated request is a 401, a missing role a 403.
|
|
207
|
+
*/
|
|
208
|
+
function Roles(...roles) {
|
|
209
|
+
return SetRouteMetadata(ROLES_METADATA, [...roles]);
|
|
210
|
+
}
|
|
211
|
+
/** Skips authentication for a route (or every route in a controller): the right place for a login or health endpoint when `protectAllRoutes` is on. */
|
|
212
|
+
function Public() {
|
|
213
|
+
return SetRouteMetadata(PUBLIC_METADATA, true);
|
|
214
|
+
}
|
|
215
|
+
/** The `roles` claim of a verified user, whatever shape the claims schema gave it. */
|
|
216
|
+
function rolesOf(user) {
|
|
217
|
+
return typeof user === "object" && user !== null ? Reflect.get(user, "roles") : void 0;
|
|
218
|
+
}
|
|
219
|
+
function isStringArray(value) {
|
|
220
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
221
|
+
}
|
|
200
222
|
let authInstanceCounter = 0;
|
|
201
223
|
/**
|
|
202
224
|
* Builds a `JwtAuthGuard` + `createRolesGuard` bound to one claims schema,
|
|
@@ -216,24 +238,29 @@ function defineAuthModule(claimsSchema) {
|
|
|
216
238
|
function getCurrentUser(ctx) {
|
|
217
239
|
return ctx.get(CURRENT_USER_KEY);
|
|
218
240
|
}
|
|
241
|
+
/** Verifies the bearer token, validates its claims, and stores them as the current user. Throws `UnauthorizedException`. */
|
|
242
|
+
async function authenticate(request, options, ctx) {
|
|
243
|
+
const header = request.headers.get("authorization") ?? "";
|
|
244
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : void 0;
|
|
245
|
+
if (!token) throw new UnauthorizedException("Missing or malformed Authorization header");
|
|
246
|
+
let payload;
|
|
247
|
+
try {
|
|
248
|
+
payload = (await jwtVerify(token, options.key, { algorithms: [options.algorithm] })).payload;
|
|
249
|
+
} catch {
|
|
250
|
+
throw new UnauthorizedException("Invalid or expired token");
|
|
251
|
+
}
|
|
252
|
+
const parsed = await claimsSchema.safeParseAsync(payload);
|
|
253
|
+
if (!parsed.success) throw new UnauthorizedException("Token payload failed validation");
|
|
254
|
+
ctx.set(CURRENT_USER_KEY, parsed.data);
|
|
255
|
+
return parsed.data;
|
|
256
|
+
}
|
|
219
257
|
let JwtAuthGuard = class JwtAuthGuard {
|
|
220
258
|
constructor(options, ctx) {
|
|
221
259
|
this.options = options;
|
|
222
260
|
this.ctx = ctx;
|
|
223
261
|
}
|
|
224
262
|
async canActivate({ request }) {
|
|
225
|
-
|
|
226
|
-
const token = header.startsWith("Bearer ") ? header.slice(7) : void 0;
|
|
227
|
-
if (!token) throw new UnauthorizedException("Missing or malformed Authorization header");
|
|
228
|
-
let payload;
|
|
229
|
-
try {
|
|
230
|
-
payload = (await jwtVerify(token, this.options.key, { algorithms: [this.options.algorithm] })).payload;
|
|
231
|
-
} catch {
|
|
232
|
-
throw new UnauthorizedException("Invalid or expired token");
|
|
233
|
-
}
|
|
234
|
-
const parsed = await claimsSchema.safeParseAsync(payload);
|
|
235
|
-
if (!parsed.success) throw new UnauthorizedException("Token payload failed validation");
|
|
236
|
-
this.ctx.set(CURRENT_USER_KEY, parsed.data);
|
|
263
|
+
await authenticate(request, this.options, this.ctx);
|
|
237
264
|
return true;
|
|
238
265
|
}
|
|
239
266
|
};
|
|
@@ -242,6 +269,34 @@ function defineAuthModule(claimsSchema) {
|
|
|
242
269
|
__decorateParam(0, Inject(AUTH_OPTIONS)),
|
|
243
270
|
__decorateMetadata("design:paramtypes", [Object, typeof RequestContext === "undefined" ? Object : RequestContext])
|
|
244
271
|
], JwtAuthGuard);
|
|
272
|
+
/** A fresh guard class per call: the global one has to be a different class from the one used with `@UseGuards`, since only it is marked `@GlobalGuard()`. */
|
|
273
|
+
function createAuthGuard() {
|
|
274
|
+
let AuthGuardImpl = class AuthGuardImpl {
|
|
275
|
+
constructor(options, ctx) {
|
|
276
|
+
this.options = options;
|
|
277
|
+
this.ctx = ctx;
|
|
278
|
+
}
|
|
279
|
+
async canActivate(context) {
|
|
280
|
+
if (getRouteMetadata(PUBLIC_METADATA, context) === true) return true;
|
|
281
|
+
const user = await authenticate(context.request, this.options, this.ctx);
|
|
282
|
+
const required = getRouteMetadata(ROLES_METADATA, context);
|
|
283
|
+
if (isStringArray(required) && required.length > 0) {
|
|
284
|
+
const held = rolesOf(user);
|
|
285
|
+
return isStringArray(held) && required.some((role) => held.includes(role));
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
AuthGuardImpl = __decorate([
|
|
291
|
+
Injectable(),
|
|
292
|
+
__decorateParam(0, Inject(AUTH_OPTIONS)),
|
|
293
|
+
__decorateMetadata("design:paramtypes", [Object, typeof RequestContext === "undefined" ? Object : RequestContext])
|
|
294
|
+
], AuthGuardImpl);
|
|
295
|
+
return AuthGuardImpl;
|
|
296
|
+
}
|
|
297
|
+
const AuthGuard = createAuthGuard();
|
|
298
|
+
const GlobalAuthGuard = createAuthGuard();
|
|
299
|
+
GlobalGuard()(GlobalAuthGuard);
|
|
245
300
|
/**
|
|
246
301
|
* Builds a guard requiring at least one of `roles` on the current user —
|
|
247
302
|
* a fresh class per call, meant to be assigned to a named export and
|
|
@@ -273,11 +328,20 @@ function defineAuthModule(claimsSchema) {
|
|
|
273
328
|
algorithm: options.algorithm ?? "HS256"
|
|
274
329
|
};
|
|
275
330
|
const imports = [];
|
|
276
|
-
const providers = [
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
331
|
+
const providers = [
|
|
332
|
+
{
|
|
333
|
+
provide: AUTH_OPTIONS,
|
|
334
|
+
useValue: normalized
|
|
335
|
+
},
|
|
336
|
+
JwtAuthGuard,
|
|
337
|
+
AuthGuard
|
|
338
|
+
];
|
|
339
|
+
const exports = [
|
|
340
|
+
AUTH_OPTIONS,
|
|
341
|
+
JwtAuthGuard,
|
|
342
|
+
AuthGuard
|
|
343
|
+
];
|
|
344
|
+
if (options.protectAllRoutes) providers.push(GlobalAuthGuard);
|
|
281
345
|
if (options.issuing) {
|
|
282
346
|
const issuingNormalized = {
|
|
283
347
|
accessTokenTtlSeconds: options.issuing.accessTokenTtl ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS,
|
|
@@ -319,12 +383,13 @@ function defineAuthModule(claimsSchema) {
|
|
|
319
383
|
return {
|
|
320
384
|
AuthModule,
|
|
321
385
|
JwtAuthGuard,
|
|
386
|
+
AuthGuard,
|
|
322
387
|
createRolesGuard,
|
|
323
388
|
getCurrentUser,
|
|
324
389
|
AUTH_SERVICE
|
|
325
390
|
};
|
|
326
391
|
}
|
|
327
392
|
//#endregion
|
|
328
|
-
export { defineAuthModule, hashPassword, verifyPassword };
|
|
393
|
+
export { Public, Roles, defineAuthModule, hashPassword, verifyPassword };
|
|
329
394
|
|
|
330
395
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/password.ts","../src/issuing.ts","../src/module.ts"],"sourcesContent":["import { argon2, randomBytes, timingSafeEqual } from \"node:crypto\";\n\n// OWASP-minimum Argon2id parameters (m=19456 KiB, t=2, p=1) — ~20ms per hash\n// on typical hardware. Stored alongside the hash in the PHC string, so a\n// future bump to these constants still verifies hashes minted under the old\n// ones; verifyPassword always re-derives from what's actually stored, never\n// from these constants directly.\nconst MEMORY_KIB = 19_456;\nconst PASSES = 2;\nconst PARALLELISM = 1;\nconst TAG_LENGTH = 32;\nconst SALT_LENGTH = 16;\n\ninterface Argon2idParams {\n nonce: Uint8Array;\n parallelism: number;\n tagLength: number;\n memory: number;\n passes: number;\n}\n\nfunction argon2id(password: string, params: Argon2idParams): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n argon2(\"argon2id\", { message: password, ...params }, (error, tag) => {\n // Every invalid-parameter case (bad memory/parallelism/tagLength)\n // throws synchronously from `argon2()` itself before this callback\n // ever runs — caught by this Promise's own executor, not here. The\n // `error` arm exists for a genuine internal crypto failure, which\n // isn't something a test can trigger deterministically.\n /* v8 ignore next 5 -- @preserve */\n if (error) {\n reject(error);\n } else {\n resolve(tag);\n }\n });\n });\n}\n\n/**\n * Hashes `password` with Argon2id, returning a self-describing PHC string\n * (`$argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>`, all base64url without\n * padding). Node's own `crypto.argon2` — no external dependency.\n */\nexport async function hashPassword(password: string): Promise<string> {\n const salt = randomBytes(SALT_LENGTH);\n const tag = await argon2id(password, {\n nonce: salt,\n parallelism: PARALLELISM,\n tagLength: TAG_LENGTH,\n memory: MEMORY_KIB,\n passes: PASSES,\n });\n return `$argon2id$v=19$m=${MEMORY_KIB},t=${PASSES},p=${PARALLELISM}$${salt.toString(\"base64url\")}$${tag.toString(\"base64url\")}`;\n}\n\nconst PHC_PATTERN = /^\\$argon2id\\$v=19\\$m=(\\d+),t=(\\d+),p=(\\d+)\\$([^$]+)\\$([^$]+)$/;\n\n// A successful PHC_PATTERN match always has all 5 capture groups present —\n// `noUncheckedIndexedAccess` can't see that, so this makes it explicit\n// instead of asserting past the type checker.\nfunction requireGroup(match: RegExpExecArray, index: number): string {\n const value = match[index];\n // PHC_PATTERN has exactly 5 capture groups and only ever calls this with\n // index 1-5, so a successful match always has a defined value here — this\n // is unreachable through the public API, not a real \"somehow still\n // undefined\" case.\n /* v8 ignore start -- @preserve */\n if (value === undefined) {\n throw new Error(`Unrecognized password hash format: expected a $argon2id$... PHC string`);\n }\n /* v8 ignore stop */\n return value;\n}\n\n/**\n * Verifies `password` against a PHC string produced by `hashPassword`.\n * Re-derives the hash using the parameters and salt stored *in* `hash`, not\n * the current module constants, so a stored hash always verifies correctly\n * even after `MEMORY_KIB`/`PASSES` change. Throws on a malformed or\n * unrecognized hash — that's a data bug, not \"wrong password,\" and should\n * fail loudly rather than silently report `false`.\n */\nexport async function verifyPassword(password: string, hash: string): Promise<boolean> {\n const match = PHC_PATTERN.exec(hash);\n if (!match) {\n throw new Error(`Unrecognized password hash format: expected a $argon2id$... PHC string`);\n }\n const memory = requireGroup(match, 1);\n const passes = requireGroup(match, 2);\n const parallelism = requireGroup(match, 3);\n const saltB64 = requireGroup(match, 4);\n const tagB64 = requireGroup(match, 5);\n\n const salt = Buffer.from(saltB64, \"base64url\");\n const expected = Buffer.from(tagB64, \"base64url\");\n const actual = await argon2id(password, {\n nonce: salt,\n parallelism: Number(parallelism),\n tagLength: expected.length,\n memory: Number(memory),\n passes: Number(passes),\n });\n\n return actual.length === expected.length && timingSafeEqual(actual, expected);\n}\n","import { createHash, randomBytes } from \"node:crypto\";\nimport { Inject, Injectable, type Class, type Token } from \"@blixis-io/di\";\nimport { UnauthorizedException } from \"@blixis-io/http\";\nimport { SignJWT, type JWTPayload } from \"jose\";\nimport type { ZodType } from \"zod\";\nimport { hashPassword, verifyPassword } from \"./password.js\";\nimport type { NormalizedAuthOptions } from \"./module.js\";\n\nexport interface CredentialStore<Claims> {\n /** Looks up an account by whatever identifier the app signs in with (email, username, ...). `null`/`undefined` = no such account. */\n findByIdentifier(identifier: string): Promise<{ subject: string; passwordHash: string } | null | undefined>;\n /**\n * The full claims to sign into the access token for `subject` — must\n * satisfy the app's own claims schema. `null`/`undefined` means the\n * account is gone or disabled, which fails closed the same as a wrong\n * password.\n */\n loadClaims(subject: string): Promise<Claims | null | undefined>;\n}\n\nexport interface RefreshTokenRecord {\n subject: string;\n expiresAt: Date;\n rotatedAt?: Date | null | undefined;\n revokedAt?: Date | null | undefined;\n}\n\nexport interface RefreshTokenStore {\n create(tokenHash: string, record: { subject: string; expiresAt: Date }): Promise<void>;\n find(tokenHash: string): Promise<RefreshTokenRecord | null | undefined>;\n /**\n * Atomically marks an active (not already rotated or revoked) token as\n * rotated, resolving `true`. Resolves `false` without changing anything\n * if it was already rotated or revoked by the time this runs — the\n * signal `AuthService.refresh()` uses to detect two concurrent refreshes\n * of the same token racing each other, treated the same as reuse.\n */\n markRotated(tokenHash: string): Promise<boolean>;\n /** Must be safe to call on an unknown or already-revoked hash — `AuthService.signOut()` relies on this being a no-op, not a throw. */\n revoke(tokenHash: string): Promise<void>;\n revokeAllForSubject(subject: string): Promise<void>;\n}\n\nexport interface TokenPair {\n accessToken: string;\n refreshToken: string;\n accessTokenExpiresAt: Date;\n refreshTokenExpiresAt: Date;\n}\n\nexport interface AuthService {\n /** Throws `UnauthorizedException` with one generic message for an unknown identifier, wrong password, or a disabled account (`loadClaims` returning null) — never reveals which. */\n signIn(identifier: string, password: string): Promise<TokenPair>;\n /**\n * Rotates `refreshToken` for a new pair. Throws `UnauthorizedException`\n * if it's unknown, expired, revoked, or **already rotated** — reuse of an\n * already-rotated token revokes every refresh token for that subject,\n * since only the rightful client should ever hold the newest one.\n */\n refresh(refreshToken: string): Promise<TokenPair>;\n /** Revokes one refresh token. Idempotent — never throws for an unknown or already-revoked token. */\n signOut(refreshToken: string): Promise<void>;\n /** Issues a fresh pair for a subject that's already been authenticated some other way (e.g. right after sign-up). */\n issueTokens(subject: string): Promise<TokenPair>;\n /** Revokes every refresh token for `subject` — for a password change or disabling an account. */\n revokeAllSessions(subject: string): Promise<void>;\n}\n\nexport interface NormalizedIssuingOptions {\n accessTokenTtlSeconds: number;\n refreshTokenTtlSeconds: number;\n}\n\nfunction hashRefreshToken(token: string): string {\n return createHash(\"sha256\").update(token).digest(\"hex\");\n}\n\n// Computed once, lazily, and reused for every signIn() against an unknown\n// identifier — a valid-looking PHC string to verify against so an unknown\n// account costs the same one Argon2id verification as a real one, instead\n// of returning early and leaking \"this identifier doesn't exist\" via timing.\nlet dummyHash: Promise<string> | undefined;\nfunction getDummyHash(): Promise<string> {\n dummyHash ??= hashPassword(randomBytes(32).toString(\"hex\"));\n return dummyHash;\n}\n\ninterface AuthServiceTokens<Claims> {\n // Typed as the bare `ZodType`, not `ZodType<Claims>` — the concrete\n // `Schema extends ZodType` generic parameter callers actually have\n // doesn't structurally satisfy `ZodType<Claims>` under\n // `exactOptionalPropertyTypes`, even though `Claims` is defined as\n // `z.infer<Schema>` at every call site. `.safeParse()`'s result is\n // narrowed with `as JWTPayload` right where it's used below regardless,\n // so nothing downstream actually needs the precise type here.\n claimsSchema: ZodType;\n authOptionsToken: Token<NormalizedAuthOptions>;\n issuingOptionsToken: Token<NormalizedIssuingOptions>;\n credentialStoreToken: Token<CredentialStore<Claims>>;\n refreshTokenStoreToken: Token<RefreshTokenStore>;\n}\n\n/**\n * Builds the `AuthService` implementation, bound to one\n * `defineAuthModule()` call's tokens — kept out of `module.ts` to keep that\n * file readable. `deps` are all closure-captured `@Inject` targets, same\n * trick `createRolesGuard` already uses for its own per-call guard class.\n * `Claims` only matters internally, for wiring the right `CredentialStore`\n * and validating claims before signing — `AuthService`'s own methods never\n * hand a caller a `Claims` value (they only ever return opaque tokens), so\n * it stays a plain, non-generic interface rather than a phantom parameter.\n */\nexport function createAuthServiceClass<Claims>(deps: AuthServiceTokens<Claims>): Class<AuthService> {\n @Injectable()\n class AuthServiceImpl implements AuthService {\n constructor(\n @Inject(deps.authOptionsToken) private readonly authOptions: NormalizedAuthOptions,\n @Inject(deps.issuingOptionsToken) private readonly issuingOptions: NormalizedIssuingOptions,\n @Inject(deps.credentialStoreToken) private readonly credentials: CredentialStore<Claims>,\n @Inject(deps.refreshTokenStoreToken) private readonly refreshTokens: RefreshTokenStore,\n ) {}\n\n async signIn(identifier: string, password: string): Promise<TokenPair> {\n const record = await this.credentials.findByIdentifier(identifier);\n const passwordHash = record?.passwordHash ?? (await getDummyHash());\n const valid = await verifyPassword(password, passwordHash);\n\n if (!record || !valid) {\n throw new UnauthorizedException(\"Invalid credentials\");\n }\n\n const claims = await this.credentials.loadClaims(record.subject);\n if (!claims) {\n throw new UnauthorizedException(\"Invalid credentials\");\n }\n\n return this.#issuePair(record.subject, claims);\n }\n\n async refresh(refreshToken: string): Promise<TokenPair> {\n const tokenHash = hashRefreshToken(refreshToken);\n const record = await this.refreshTokens.find(tokenHash);\n\n if (!record || record.revokedAt || record.expiresAt.getTime() <= Date.now()) {\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n if (record.rotatedAt) {\n await this.refreshTokens.revokeAllForSubject(record.subject);\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n const rotated = await this.refreshTokens.markRotated(tokenHash);\n if (!rotated) {\n // Lost a race with a concurrent refresh of this exact token — same\n // reuse-detected handling as an already-rotated token above.\n await this.refreshTokens.revokeAllForSubject(record.subject);\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n const claims = await this.credentials.loadClaims(record.subject);\n if (!claims) {\n await this.refreshTokens.revokeAllForSubject(record.subject);\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n return this.#issuePair(record.subject, claims);\n }\n\n async signOut(refreshToken: string): Promise<void> {\n await this.refreshTokens.revoke(hashRefreshToken(refreshToken));\n }\n\n async issueTokens(subject: string): Promise<TokenPair> {\n const claims = await this.credentials.loadClaims(subject);\n if (!claims) {\n throw new Error(\n `issueTokens() was called for subject \"${subject}\", but loadClaims() returned nothing for it — the caller must ensure the account exists (and is visible to loadClaims) before calling this.`,\n );\n }\n return this.#issuePair(subject, claims);\n }\n\n async revokeAllSessions(subject: string): Promise<void> {\n await this.refreshTokens.revokeAllForSubject(subject);\n }\n\n async #issuePair(subject: string, claims: Claims): Promise<TokenPair> {\n const parsedClaims = deps.claimsSchema.safeParse(claims);\n if (!parsedClaims.success) {\n throw new Error(\n `CredentialStore.loadClaims(\"${subject}\") returned claims that fail this app's own claims schema — the guard that later verifies this token would reject it too: ${parsedClaims.error.message}`,\n );\n }\n\n const now = Date.now();\n const accessTokenExpiresAt = new Date(now + this.issuingOptions.accessTokenTtlSeconds * 1000);\n // The claims shape is fully app-defined (any object satisfying the\n // app's own Zod schema) — jose's JWTPayload type can't express that\n // structurally, so this is the one place that trusts the schema just\n // validated it, same category of boundary cast as @blixis-io/events'\n // `payload as never`.\n const accessToken = await new SignJWT(parsedClaims.data as JWTPayload)\n .setProtectedHeader({ alg: this.authOptions.algorithm })\n .setIssuedAt()\n .setExpirationTime(Math.floor(accessTokenExpiresAt.getTime() / 1000))\n .sign(this.authOptions.key);\n\n const refreshToken = randomBytes(32).toString(\"base64url\");\n const refreshTokenExpiresAt = new Date(now + this.issuingOptions.refreshTokenTtlSeconds * 1000);\n await this.refreshTokens.create(hashRefreshToken(refreshToken), { subject, expiresAt: refreshTokenExpiresAt });\n\n return { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt };\n }\n }\n\n return AuthServiceImpl;\n}\n","import { Module, type DynamicModule, type ModuleRef } from \"@blixis-io/core\";\nimport { Inject, Injectable, InjectionToken, type Class, type Provider, type Token } from \"@blixis-io/di\";\nimport { RequestContext, UnauthorizedException, type CanActivate, type ExecutionContext } from \"@blixis-io/http\";\nimport { jwtVerify } from \"jose\";\nimport type { ZodType, z } from \"zod\";\nimport {\n createAuthServiceClass,\n type AuthService,\n type CredentialStore,\n type NormalizedIssuingOptions,\n type RefreshTokenStore,\n} from \"./issuing.js\";\n\nconst DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900; // 15 minutes\nconst DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2_592_000; // 30 days\n\nexport interface IssuingOptions<Claims> {\n /**\n * Modules to import alongside `AuthModule` itself, so `credentialStore`\n * and `refreshTokenStore` can see whatever they depend on (e.g. a\n * `DATABASE` token exported by a `DbModule`). Without the exporting\n * module listed here, module encapsulation throws `ProviderNotVisibleError`.\n */\n imports?: ModuleRef[] | undefined;\n /** The app's own store implementation — a DI class, so it can inject `DATABASE` or anything else it needs. */\n credentialStore: Class<CredentialStore<Claims>>;\n /** The app's own store implementation for issued refresh tokens. */\n refreshTokenStore: Class<RefreshTokenStore>;\n /** Seconds. Defaults to 900 (15 minutes). */\n accessTokenTtl?: number | undefined;\n /** Seconds. Defaults to 2,592,000 (30 days). */\n refreshTokenTtl?: number | undefined;\n}\n\nexport interface AuthModuleOptions<Claims = unknown> {\n /** HMAC secret used to verify the token's signature. */\n secret: string;\n /** Defaults to `\"HS256\"`. */\n algorithm?: \"HS256\" | \"HS384\" | \"HS512\";\n /** Makes `JwtAuthGuard` (and `AUTH_SERVICE`, if `issuing` is set) visible to every module without each one importing this one directly. Defaults to `false`. */\n global?: boolean;\n /** Omit for a verify-only app (the original scope). Set to enable `AUTH_SERVICE` — password sign-in, refresh rotation, sign-out. */\n issuing?: IssuingOptions<Claims> | undefined;\n}\n\nexport interface NormalizedAuthOptions {\n key: Uint8Array;\n algorithm: string;\n}\n\n// One `RequestContext` key per `defineAuthModule()` call, not a fixed\n// literal — otherwise two calls in the same app (two different claims\n// shapes) would read/write the same slot and clobber each other.\nlet authInstanceCounter = 0;\n\n/**\n * Builds a `JwtAuthGuard` + `createRolesGuard` bound to one claims schema,\n * same factory-closure shape as `@blixis-io/config`'s `defineConfigModule` —\n * the claims shape is app-specific, so there's no single fixed type to\n * validate against.\n */\nexport function defineAuthModule<Schema extends ZodType>(\n claimsSchema: Schema,\n): {\n AuthModule: { forRoot(options: AuthModuleOptions<z.infer<Schema>>): DynamicModule };\n JwtAuthGuard: Class<CanActivate>;\n createRolesGuard: (...roles: readonly string[]) => Class<CanActivate>;\n getCurrentUser: (ctx: RequestContext) => z.infer<Schema> | undefined;\n /** Resolvable only when `forRoot({ issuing })` was set — otherwise `MissingProviderError` at boot. */\n AUTH_SERVICE: InjectionToken<AuthService>;\n} {\n type Claims = z.infer<Schema>;\n const AUTH_OPTIONS = new InjectionToken<NormalizedAuthOptions>(\"blixis.auth.options\");\n const ISSUING_OPTIONS = new InjectionToken<NormalizedIssuingOptions>(\"blixis.auth.issuingOptions\");\n const CREDENTIAL_STORE = new InjectionToken<CredentialStore<Claims>>(\"blixis.auth.credentialStore\");\n const REFRESH_TOKEN_STORE = new InjectionToken<RefreshTokenStore>(\"blixis.auth.refreshTokenStore\");\n const AUTH_SERVICE = new InjectionToken<AuthService>(\"blixis.auth.service\");\n const CURRENT_USER_KEY = `blixis.auth.currentUser.${authInstanceCounter++}`;\n\n /** Reads the claims `JwtAuthGuard` verified for the current request, or `undefined` outside a request (or before the guard has run). */\n function getCurrentUser(ctx: RequestContext): Claims | undefined {\n return ctx.get<Claims>(CURRENT_USER_KEY);\n }\n\n @Injectable()\n class JwtAuthGuard implements CanActivate {\n constructor(\n @Inject(AUTH_OPTIONS) private readonly options: NormalizedAuthOptions,\n private readonly ctx: RequestContext,\n ) {}\n\n async canActivate({ request }: ExecutionContext): Promise<boolean> {\n const header = request.headers.get(\"authorization\") ?? \"\";\n const token = header.startsWith(\"Bearer \") ? header.slice(\"Bearer \".length) : undefined;\n if (!token) {\n throw new UnauthorizedException(\"Missing or malformed Authorization header\");\n }\n\n let payload: unknown;\n try {\n payload = (await jwtVerify(token, this.options.key, { algorithms: [this.options.algorithm] })).payload;\n } catch {\n throw new UnauthorizedException(\"Invalid or expired token\");\n }\n\n const parsed = await claimsSchema.safeParseAsync(payload);\n if (!parsed.success) {\n throw new UnauthorizedException(\"Token payload failed validation\");\n }\n\n this.ctx.set(CURRENT_USER_KEY, parsed.data);\n return true;\n }\n }\n\n /**\n * Builds a guard requiring at least one of `roles` on the current user —\n * a fresh class per call, meant to be assigned to a named export and\n * registered as a provider like any other guard (`export const AdminGuard\n * = createRolesGuard(\"admin\")`). Must run after `JwtAuthGuard` in the\n * same `@UseGuards(...)` list; throws `UnauthorizedException` (not a\n * plain `false`) if no user is in `RequestContext` yet, since that means\n * the guards were ordered wrong, not that this user lacks the role.\n */\n function createRolesGuard(...roles: readonly string[]): Class<CanActivate> {\n @Injectable()\n class RolesGuard implements CanActivate {\n constructor(private readonly ctx: RequestContext) {}\n\n canActivate(): boolean {\n const user = getCurrentUser(this.ctx);\n if (!user) {\n throw new UnauthorizedException(\"No authenticated user in request context — apply the auth guard first\");\n }\n const userRoles = (user as { roles?: unknown }).roles;\n return Array.isArray(userRoles) && roles.some((role) => userRoles.includes(role));\n }\n }\n return RolesGuard;\n }\n\n @Module()\n class AuthModule {\n static forRoot(options: AuthModuleOptions<Claims>): DynamicModule {\n const normalized: NormalizedAuthOptions = {\n key: new TextEncoder().encode(options.secret),\n algorithm: options.algorithm ?? \"HS256\",\n };\n const imports: ModuleRef[] = [];\n const providers: Provider[] = [{ provide: AUTH_OPTIONS, useValue: normalized }, JwtAuthGuard];\n const exports: Token[] = [AUTH_OPTIONS, JwtAuthGuard];\n\n if (options.issuing) {\n const issuingNormalized: NormalizedIssuingOptions = {\n accessTokenTtlSeconds: options.issuing.accessTokenTtl ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS,\n refreshTokenTtlSeconds: options.issuing.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS,\n };\n const AuthServiceImpl = createAuthServiceClass<Claims>({\n claimsSchema,\n authOptionsToken: AUTH_OPTIONS,\n issuingOptionsToken: ISSUING_OPTIONS,\n credentialStoreToken: CREDENTIAL_STORE,\n refreshTokenStoreToken: REFRESH_TOKEN_STORE,\n });\n\n imports.push(...(options.issuing.imports ?? []));\n providers.push(\n { provide: ISSUING_OPTIONS, useValue: issuingNormalized },\n { provide: CREDENTIAL_STORE, useClass: options.issuing.credentialStore },\n { provide: REFRESH_TOKEN_STORE, useClass: options.issuing.refreshTokenStore },\n { provide: AUTH_SERVICE, useClass: AuthServiceImpl },\n );\n exports.push(AUTH_SERVICE);\n }\n\n return { module: AuthModule, imports, providers, exports, global: options.global ?? false };\n }\n }\n\n return { AuthModule, JwtAuthGuard, createRolesGuard, getCurrentUser, AUTH_SERVICE };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/password.ts","../src/issuing.ts","../src/module.ts"],"sourcesContent":["import { argon2, randomBytes, timingSafeEqual } from \"node:crypto\";\n\n// OWASP-minimum Argon2id parameters (m=19456 KiB, t=2, p=1) — ~20ms per hash\n// on typical hardware. Stored alongside the hash in the PHC string, so a\n// future bump to these constants still verifies hashes minted under the old\n// ones; verifyPassword always re-derives from what's actually stored, never\n// from these constants directly.\nconst MEMORY_KIB = 19_456;\nconst PASSES = 2;\nconst PARALLELISM = 1;\nconst TAG_LENGTH = 32;\nconst SALT_LENGTH = 16;\n\ninterface Argon2idParams {\n nonce: Uint8Array;\n parallelism: number;\n tagLength: number;\n memory: number;\n passes: number;\n}\n\nfunction argon2id(password: string, params: Argon2idParams): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n argon2(\"argon2id\", { message: password, ...params }, (error, tag) => {\n // Every invalid-parameter case (bad memory/parallelism/tagLength)\n // throws synchronously from `argon2()` itself before this callback\n // ever runs — caught by this Promise's own executor, not here. The\n // `error` arm exists for a genuine internal crypto failure, which\n // isn't something a test can trigger deterministically.\n /* v8 ignore next 5 -- @preserve */\n if (error) {\n reject(error);\n } else {\n resolve(tag);\n }\n });\n });\n}\n\n/**\n * Hashes `password` with Argon2id, returning a self-describing PHC string\n * (`$argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>`, all base64url without\n * padding). Node's own `crypto.argon2` — no external dependency.\n */\nexport async function hashPassword(password: string): Promise<string> {\n const salt = randomBytes(SALT_LENGTH);\n const tag = await argon2id(password, {\n nonce: salt,\n parallelism: PARALLELISM,\n tagLength: TAG_LENGTH,\n memory: MEMORY_KIB,\n passes: PASSES,\n });\n return `$argon2id$v=19$m=${MEMORY_KIB},t=${PASSES},p=${PARALLELISM}$${salt.toString(\"base64url\")}$${tag.toString(\"base64url\")}`;\n}\n\nconst PHC_PATTERN = /^\\$argon2id\\$v=19\\$m=(\\d+),t=(\\d+),p=(\\d+)\\$([^$]+)\\$([^$]+)$/;\n\n// A successful PHC_PATTERN match always has all 5 capture groups present —\n// `noUncheckedIndexedAccess` can't see that, so this makes it explicit\n// instead of asserting past the type checker.\nfunction requireGroup(match: RegExpExecArray, index: number): string {\n const value = match[index];\n // PHC_PATTERN has exactly 5 capture groups and only ever calls this with\n // index 1-5, so a successful match always has a defined value here — this\n // is unreachable through the public API, not a real \"somehow still\n // undefined\" case.\n /* v8 ignore start -- @preserve */\n if (value === undefined) {\n throw new Error(`Unrecognized password hash format: expected a $argon2id$... PHC string`);\n }\n /* v8 ignore stop */\n return value;\n}\n\n/**\n * Verifies `password` against a PHC string produced by `hashPassword`.\n * Re-derives the hash using the parameters and salt stored *in* `hash`, not\n * the current module constants, so a stored hash always verifies correctly\n * even after `MEMORY_KIB`/`PASSES` change. Throws on a malformed or\n * unrecognized hash — that's a data bug, not \"wrong password,\" and should\n * fail loudly rather than silently report `false`.\n */\nexport async function verifyPassword(password: string, hash: string): Promise<boolean> {\n const match = PHC_PATTERN.exec(hash);\n if (!match) {\n throw new Error(`Unrecognized password hash format: expected a $argon2id$... PHC string`);\n }\n const memory = requireGroup(match, 1);\n const passes = requireGroup(match, 2);\n const parallelism = requireGroup(match, 3);\n const saltB64 = requireGroup(match, 4);\n const tagB64 = requireGroup(match, 5);\n\n const salt = Buffer.from(saltB64, \"base64url\");\n const expected = Buffer.from(tagB64, \"base64url\");\n const actual = await argon2id(password, {\n nonce: salt,\n parallelism: Number(parallelism),\n tagLength: expected.length,\n memory: Number(memory),\n passes: Number(passes),\n });\n\n return actual.length === expected.length && timingSafeEqual(actual, expected);\n}\n","import { createHash, randomBytes } from \"node:crypto\";\nimport { Inject, Injectable, type Class, type Token } from \"@blixis-io/di\";\nimport { UnauthorizedException } from \"@blixis-io/http\";\nimport { SignJWT, type JWTPayload } from \"jose\";\nimport type { ZodType } from \"zod\";\nimport { hashPassword, verifyPassword } from \"./password.js\";\nimport type { NormalizedAuthOptions } from \"./module.js\";\n\nexport interface CredentialStore<Claims> {\n /** Looks up an account by whatever identifier the app signs in with (email, username, ...). `null`/`undefined` = no such account. */\n findByIdentifier(identifier: string): Promise<{ subject: string; passwordHash: string } | null | undefined>;\n /**\n * The full claims to sign into the access token for `subject` — must\n * satisfy the app's own claims schema. `null`/`undefined` means the\n * account is gone or disabled, which fails closed the same as a wrong\n * password.\n */\n loadClaims(subject: string): Promise<Claims | null | undefined>;\n}\n\nexport interface RefreshTokenRecord {\n subject: string;\n expiresAt: Date;\n rotatedAt?: Date | null | undefined;\n revokedAt?: Date | null | undefined;\n}\n\nexport interface RefreshTokenStore {\n create(tokenHash: string, record: { subject: string; expiresAt: Date }): Promise<void>;\n find(tokenHash: string): Promise<RefreshTokenRecord | null | undefined>;\n /**\n * Atomically marks an active (not already rotated or revoked) token as\n * rotated, resolving `true`. Resolves `false` without changing anything\n * if it was already rotated or revoked by the time this runs — the\n * signal `AuthService.refresh()` uses to detect two concurrent refreshes\n * of the same token racing each other, treated the same as reuse.\n */\n markRotated(tokenHash: string): Promise<boolean>;\n /** Must be safe to call on an unknown or already-revoked hash — `AuthService.signOut()` relies on this being a no-op, not a throw. */\n revoke(tokenHash: string): Promise<void>;\n revokeAllForSubject(subject: string): Promise<void>;\n}\n\nexport interface TokenPair {\n accessToken: string;\n refreshToken: string;\n accessTokenExpiresAt: Date;\n refreshTokenExpiresAt: Date;\n}\n\nexport interface AuthService {\n /** Throws `UnauthorizedException` with one generic message for an unknown identifier, wrong password, or a disabled account (`loadClaims` returning null) — never reveals which. */\n signIn(identifier: string, password: string): Promise<TokenPair>;\n /**\n * Rotates `refreshToken` for a new pair. Throws `UnauthorizedException`\n * if it's unknown, expired, revoked, or **already rotated** — reuse of an\n * already-rotated token revokes every refresh token for that subject,\n * since only the rightful client should ever hold the newest one.\n */\n refresh(refreshToken: string): Promise<TokenPair>;\n /** Revokes one refresh token. Idempotent — never throws for an unknown or already-revoked token. */\n signOut(refreshToken: string): Promise<void>;\n /** Issues a fresh pair for a subject that's already been authenticated some other way (e.g. right after sign-up). */\n issueTokens(subject: string): Promise<TokenPair>;\n /** Revokes every refresh token for `subject` — for a password change or disabling an account. */\n revokeAllSessions(subject: string): Promise<void>;\n}\n\nexport interface NormalizedIssuingOptions {\n accessTokenTtlSeconds: number;\n refreshTokenTtlSeconds: number;\n}\n\nfunction hashRefreshToken(token: string): string {\n return createHash(\"sha256\").update(token).digest(\"hex\");\n}\n\n// Computed once, lazily, and reused for every signIn() against an unknown\n// identifier — a valid-looking PHC string to verify against so an unknown\n// account costs the same one Argon2id verification as a real one, instead\n// of returning early and leaking \"this identifier doesn't exist\" via timing.\nlet dummyHash: Promise<string> | undefined;\nfunction getDummyHash(): Promise<string> {\n dummyHash ??= hashPassword(randomBytes(32).toString(\"hex\"));\n return dummyHash;\n}\n\ninterface AuthServiceTokens<Claims> {\n // Typed as the bare `ZodType`, not `ZodType<Claims>` — the concrete\n // `Schema extends ZodType` generic parameter callers actually have\n // doesn't structurally satisfy `ZodType<Claims>` under\n // `exactOptionalPropertyTypes`, even though `Claims` is defined as\n // `z.infer<Schema>` at every call site. `.safeParse()`'s result is\n // narrowed with `as JWTPayload` right where it's used below regardless,\n // so nothing downstream actually needs the precise type here.\n claimsSchema: ZodType;\n authOptionsToken: Token<NormalizedAuthOptions>;\n issuingOptionsToken: Token<NormalizedIssuingOptions>;\n credentialStoreToken: Token<CredentialStore<Claims>>;\n refreshTokenStoreToken: Token<RefreshTokenStore>;\n}\n\n/**\n * Builds the `AuthService` implementation, bound to one\n * `defineAuthModule()` call's tokens — kept out of `module.ts` to keep that\n * file readable. `deps` are all closure-captured `@Inject` targets, same\n * trick `createRolesGuard` already uses for its own per-call guard class.\n * `Claims` only matters internally, for wiring the right `CredentialStore`\n * and validating claims before signing — `AuthService`'s own methods never\n * hand a caller a `Claims` value (they only ever return opaque tokens), so\n * it stays a plain, non-generic interface rather than a phantom parameter.\n */\nexport function createAuthServiceClass<Claims>(deps: AuthServiceTokens<Claims>): Class<AuthService> {\n @Injectable()\n class AuthServiceImpl implements AuthService {\n constructor(\n @Inject(deps.authOptionsToken) private readonly authOptions: NormalizedAuthOptions,\n @Inject(deps.issuingOptionsToken) private readonly issuingOptions: NormalizedIssuingOptions,\n @Inject(deps.credentialStoreToken) private readonly credentials: CredentialStore<Claims>,\n @Inject(deps.refreshTokenStoreToken) private readonly refreshTokens: RefreshTokenStore,\n ) {}\n\n async signIn(identifier: string, password: string): Promise<TokenPair> {\n const record = await this.credentials.findByIdentifier(identifier);\n const passwordHash = record?.passwordHash ?? (await getDummyHash());\n const valid = await verifyPassword(password, passwordHash);\n\n if (!record || !valid) {\n throw new UnauthorizedException(\"Invalid credentials\");\n }\n\n const claims = await this.credentials.loadClaims(record.subject);\n if (!claims) {\n throw new UnauthorizedException(\"Invalid credentials\");\n }\n\n return this.#issuePair(record.subject, claims);\n }\n\n async refresh(refreshToken: string): Promise<TokenPair> {\n const tokenHash = hashRefreshToken(refreshToken);\n const record = await this.refreshTokens.find(tokenHash);\n\n if (!record || record.revokedAt || record.expiresAt.getTime() <= Date.now()) {\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n if (record.rotatedAt) {\n await this.refreshTokens.revokeAllForSubject(record.subject);\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n const rotated = await this.refreshTokens.markRotated(tokenHash);\n if (!rotated) {\n // Lost a race with a concurrent refresh of this exact token — same\n // reuse-detected handling as an already-rotated token above.\n await this.refreshTokens.revokeAllForSubject(record.subject);\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n const claims = await this.credentials.loadClaims(record.subject);\n if (!claims) {\n await this.refreshTokens.revokeAllForSubject(record.subject);\n throw new UnauthorizedException(\"Invalid or expired refresh token\");\n }\n\n return this.#issuePair(record.subject, claims);\n }\n\n async signOut(refreshToken: string): Promise<void> {\n await this.refreshTokens.revoke(hashRefreshToken(refreshToken));\n }\n\n async issueTokens(subject: string): Promise<TokenPair> {\n const claims = await this.credentials.loadClaims(subject);\n if (!claims) {\n throw new Error(\n `issueTokens() was called for subject \"${subject}\", but loadClaims() returned nothing for it — the caller must ensure the account exists (and is visible to loadClaims) before calling this.`,\n );\n }\n return this.#issuePair(subject, claims);\n }\n\n async revokeAllSessions(subject: string): Promise<void> {\n await this.refreshTokens.revokeAllForSubject(subject);\n }\n\n async #issuePair(subject: string, claims: Claims): Promise<TokenPair> {\n const parsedClaims = deps.claimsSchema.safeParse(claims);\n if (!parsedClaims.success) {\n throw new Error(\n `CredentialStore.loadClaims(\"${subject}\") returned claims that fail this app's own claims schema — the guard that later verifies this token would reject it too: ${parsedClaims.error.message}`,\n );\n }\n\n const now = Date.now();\n const accessTokenExpiresAt = new Date(now + this.issuingOptions.accessTokenTtlSeconds * 1000);\n // The claims shape is fully app-defined (any object satisfying the\n // app's own Zod schema) — jose's JWTPayload type can't express that\n // structurally, so this is the one place that trusts the schema just\n // validated it, same category of boundary cast as @blixis-io/events'\n // `payload as never`.\n const accessToken = await new SignJWT(parsedClaims.data as JWTPayload)\n .setProtectedHeader({ alg: this.authOptions.algorithm })\n .setIssuedAt()\n .setExpirationTime(Math.floor(accessTokenExpiresAt.getTime() / 1000))\n .sign(this.authOptions.key);\n\n const refreshToken = randomBytes(32).toString(\"base64url\");\n const refreshTokenExpiresAt = new Date(now + this.issuingOptions.refreshTokenTtlSeconds * 1000);\n await this.refreshTokens.create(hashRefreshToken(refreshToken), { subject, expiresAt: refreshTokenExpiresAt });\n\n return { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt };\n }\n }\n\n return AuthServiceImpl;\n}\n","import { Module, type DynamicModule, type ModuleRef } from \"@blixis-io/core\";\nimport { Inject, Injectable, InjectionToken, type Class, type Provider, type Token } from \"@blixis-io/di\";\nimport {\n getRouteMetadata,\n GlobalGuard,\n RequestContext,\n SetRouteMetadata,\n UnauthorizedException,\n type CanActivate,\n type ExecutionContext,\n} from \"@blixis-io/http\";\nimport { jwtVerify } from \"jose\";\nimport type { ZodType, z } from \"zod\";\nimport {\n createAuthServiceClass,\n type AuthService,\n type CredentialStore,\n type NormalizedIssuingOptions,\n type RefreshTokenStore,\n} from \"./issuing.js\";\n\nconst DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900; // 15 minutes\nconst DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2_592_000; // 30 days\n\nexport interface IssuingOptions<Claims> {\n /**\n * Modules to import alongside `AuthModule` itself, so `credentialStore`\n * and `refreshTokenStore` can see whatever they depend on (e.g. a\n * `DATABASE` token exported by a `DbModule`). Without the exporting\n * module listed here, module encapsulation throws `ProviderNotVisibleError`.\n */\n imports?: ModuleRef[] | undefined;\n /** The app's own store implementation — a DI class, so it can inject `DATABASE` or anything else it needs. */\n credentialStore: Class<CredentialStore<Claims>>;\n /** The app's own store implementation for issued refresh tokens. */\n refreshTokenStore: Class<RefreshTokenStore>;\n /** Seconds. Defaults to 900 (15 minutes). */\n accessTokenTtl?: number | undefined;\n /** Seconds. Defaults to 2,592,000 (30 days). */\n refreshTokenTtl?: number | undefined;\n}\n\nexport interface AuthModuleOptions<Claims = unknown> {\n /** HMAC secret used to verify the token's signature. */\n secret: string;\n /** Defaults to `\"HS256\"`. */\n algorithm?: \"HS256\" | \"HS384\" | \"HS512\";\n /** Makes `JwtAuthGuard` (and `AUTH_SERVICE`, if `issuing` is set) visible to every module without each one importing this one directly. Defaults to `false`. */\n global?: boolean;\n /**\n * Require authentication on **every** route unless it is marked `@Public()`, and enforce `@Roles(...)`\n * wherever it is used. Defaults to `false`: nothing changes until you opt in, and then protection is on by\n * default. Without it, add `@UseGuards(AuthGuard)` to the controllers or routes you want protected.\n */\n protectAllRoutes?: boolean;\n /** Omit for a verify-only app (the original scope). Set to enable `AUTH_SERVICE` — password sign-in, refresh rotation, sign-out. */\n issuing?: IssuingOptions<Claims> | undefined;\n}\n\n// `Symbol.for`, so metadata set with one copy of this package is read by another.\nconst ROLES_METADATA = Symbol.for(\"blixis:auth:roles\");\nconst PUBLIC_METADATA = Symbol.for(\"blixis:auth:public\");\n\n/**\n * Requires the authenticated user to hold at least one of `roles` (read from the token's `roles` claim).\n * On a controller it covers every route in it; on a route it replaces the controller's. Only takes effect\n * where `AuthGuard` runs: everywhere with `protectAllRoutes: true`, otherwise on routes that carry\n * `@UseGuards(AuthGuard)`. An unauthenticated request is a 401, a missing role a 403.\n */\nexport function Roles(...roles: readonly string[]): ClassDecorator & MethodDecorator {\n return SetRouteMetadata(ROLES_METADATA, [...roles]);\n}\n\n/** Skips authentication for a route (or every route in a controller): the right place for a login or health endpoint when `protectAllRoutes` is on. */\nexport function Public(): ClassDecorator & MethodDecorator {\n return SetRouteMetadata(PUBLIC_METADATA, true);\n}\n\n/** The `roles` claim of a verified user, whatever shape the claims schema gave it. */\nfunction rolesOf(user: unknown): unknown {\n return typeof user === \"object\" && user !== null ? Reflect.get(user, \"roles\") : undefined;\n}\n\nfunction isStringArray(value: unknown): value is string[] {\n return Array.isArray(value) && value.every((item) => typeof item === \"string\");\n}\n\nexport interface NormalizedAuthOptions {\n key: Uint8Array;\n algorithm: string;\n}\n\n// One `RequestContext` key per `defineAuthModule()` call, not a fixed\n// literal — otherwise two calls in the same app (two different claims\n// shapes) would read/write the same slot and clobber each other.\nlet authInstanceCounter = 0;\n\n/**\n * Builds a `JwtAuthGuard` + `createRolesGuard` bound to one claims schema,\n * same factory-closure shape as `@blixis-io/config`'s `defineConfigModule` —\n * the claims shape is app-specific, so there's no single fixed type to\n * validate against.\n */\nexport function defineAuthModule<Schema extends ZodType>(\n claimsSchema: Schema,\n): {\n AuthModule: { forRoot(options: AuthModuleOptions<z.infer<Schema>>): DynamicModule };\n JwtAuthGuard: Class<CanActivate>;\n /** Authenticates the request unless the route is `@Public()`, then enforces `@Roles(...)`. Use it with `@UseGuards(AuthGuard)`, or let `protectAllRoutes` apply it everywhere. */\n AuthGuard: Class<CanActivate>;\n createRolesGuard: (...roles: readonly string[]) => Class<CanActivate>;\n getCurrentUser: (ctx: RequestContext) => z.infer<Schema> | undefined;\n /** Resolvable only when `forRoot({ issuing })` was set — otherwise `MissingProviderError` at boot. */\n AUTH_SERVICE: InjectionToken<AuthService>;\n} {\n type Claims = z.infer<Schema>;\n const AUTH_OPTIONS = new InjectionToken<NormalizedAuthOptions>(\"blixis.auth.options\");\n const ISSUING_OPTIONS = new InjectionToken<NormalizedIssuingOptions>(\"blixis.auth.issuingOptions\");\n const CREDENTIAL_STORE = new InjectionToken<CredentialStore<Claims>>(\"blixis.auth.credentialStore\");\n const REFRESH_TOKEN_STORE = new InjectionToken<RefreshTokenStore>(\"blixis.auth.refreshTokenStore\");\n const AUTH_SERVICE = new InjectionToken<AuthService>(\"blixis.auth.service\");\n const CURRENT_USER_KEY = `blixis.auth.currentUser.${authInstanceCounter++}`;\n\n /** Reads the claims `JwtAuthGuard` verified for the current request, or `undefined` outside a request (or before the guard has run). */\n function getCurrentUser(ctx: RequestContext): Claims | undefined {\n return ctx.get<Claims>(CURRENT_USER_KEY);\n }\n\n /** Verifies the bearer token, validates its claims, and stores them as the current user. Throws `UnauthorizedException`. */\n async function authenticate(request: Request, options: NormalizedAuthOptions, ctx: RequestContext): Promise<Claims> {\n const header = request.headers.get(\"authorization\") ?? \"\";\n const token = header.startsWith(\"Bearer \") ? header.slice(\"Bearer \".length) : undefined;\n if (!token) {\n throw new UnauthorizedException(\"Missing or malformed Authorization header\");\n }\n\n let payload: unknown;\n try {\n payload = (await jwtVerify(token, options.key, { algorithms: [options.algorithm] })).payload;\n } catch {\n throw new UnauthorizedException(\"Invalid or expired token\");\n }\n\n const parsed = await claimsSchema.safeParseAsync(payload);\n if (!parsed.success) {\n throw new UnauthorizedException(\"Token payload failed validation\");\n }\n\n ctx.set(CURRENT_USER_KEY, parsed.data);\n return parsed.data;\n }\n\n @Injectable()\n class JwtAuthGuard implements CanActivate {\n constructor(\n @Inject(AUTH_OPTIONS) private readonly options: NormalizedAuthOptions,\n private readonly ctx: RequestContext,\n ) {}\n\n async canActivate({ request }: ExecutionContext): Promise<boolean> {\n await authenticate(request, this.options, this.ctx);\n return true;\n }\n }\n\n /** A fresh guard class per call: the global one has to be a different class from the one used with `@UseGuards`, since only it is marked `@GlobalGuard()`. */\n function createAuthGuard(): Class<CanActivate> {\n @Injectable()\n class AuthGuardImpl implements CanActivate {\n constructor(\n @Inject(AUTH_OPTIONS) private readonly options: NormalizedAuthOptions,\n private readonly ctx: RequestContext,\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n if (getRouteMetadata(PUBLIC_METADATA, context) === true) {\n return true;\n }\n\n const user = await authenticate(context.request, this.options, this.ctx);\n\n const required = getRouteMetadata(ROLES_METADATA, context);\n if (isStringArray(required) && required.length > 0) {\n const held = rolesOf(user);\n return isStringArray(held) && required.some((role) => held.includes(role));\n }\n return true;\n }\n }\n return AuthGuardImpl;\n }\n\n const AuthGuard = createAuthGuard();\n const GlobalAuthGuard = createAuthGuard();\n GlobalGuard()(GlobalAuthGuard);\n\n /**\n * Builds a guard requiring at least one of `roles` on the current user —\n * a fresh class per call, meant to be assigned to a named export and\n * registered as a provider like any other guard (`export const AdminGuard\n * = createRolesGuard(\"admin\")`). Must run after `JwtAuthGuard` in the\n * same `@UseGuards(...)` list; throws `UnauthorizedException` (not a\n * plain `false`) if no user is in `RequestContext` yet, since that means\n * the guards were ordered wrong, not that this user lacks the role.\n */\n function createRolesGuard(...roles: readonly string[]): Class<CanActivate> {\n @Injectable()\n class RolesGuard implements CanActivate {\n constructor(private readonly ctx: RequestContext) {}\n\n canActivate(): boolean {\n const user = getCurrentUser(this.ctx);\n if (!user) {\n throw new UnauthorizedException(\"No authenticated user in request context — apply the auth guard first\");\n }\n const userRoles = (user as { roles?: unknown }).roles;\n return Array.isArray(userRoles) && roles.some((role) => userRoles.includes(role));\n }\n }\n return RolesGuard;\n }\n\n @Module()\n class AuthModule {\n static forRoot(options: AuthModuleOptions<Claims>): DynamicModule {\n const normalized: NormalizedAuthOptions = {\n key: new TextEncoder().encode(options.secret),\n algorithm: options.algorithm ?? \"HS256\",\n };\n const imports: ModuleRef[] = [];\n const providers: Provider[] = [{ provide: AUTH_OPTIONS, useValue: normalized }, JwtAuthGuard, AuthGuard];\n const exports: Token[] = [AUTH_OPTIONS, JwtAuthGuard, AuthGuard];\n\n if (options.protectAllRoutes) {\n providers.push(GlobalAuthGuard);\n }\n\n if (options.issuing) {\n const issuingNormalized: NormalizedIssuingOptions = {\n accessTokenTtlSeconds: options.issuing.accessTokenTtl ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS,\n refreshTokenTtlSeconds: options.issuing.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS,\n };\n const AuthServiceImpl = createAuthServiceClass<Claims>({\n claimsSchema,\n authOptionsToken: AUTH_OPTIONS,\n issuingOptionsToken: ISSUING_OPTIONS,\n credentialStoreToken: CREDENTIAL_STORE,\n refreshTokenStoreToken: REFRESH_TOKEN_STORE,\n });\n\n imports.push(...(options.issuing.imports ?? []));\n providers.push(\n { provide: ISSUING_OPTIONS, useValue: issuingNormalized },\n { provide: CREDENTIAL_STORE, useClass: options.issuing.credentialStore },\n { provide: REFRESH_TOKEN_STORE, useClass: options.issuing.refreshTokenStore },\n { provide: AUTH_SERVICE, useClass: AuthServiceImpl },\n );\n exports.push(AUTH_SERVICE);\n }\n\n return { module: AuthModule, imports, providers, exports, global: options.global ?? false };\n }\n }\n\n return { AuthModule, JwtAuthGuard, AuthGuard, createRolesGuard, getCurrentUser, AUTH_SERVICE };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blixis-io/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "JWT verification and role checks, plus optional password sign-in and refresh-token rotation. Built on @blixis-io/http's guard primitive; app-layer, not a framework dependency.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Michael Voeten <michael@voeten.online>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@blixis-io/core": "0.3.1",
|
|
38
38
|
"@blixis-io/di": "0.1.2",
|
|
39
|
-
"@blixis-io/http": "0.
|
|
39
|
+
"@blixis-io/http": "0.4.0",
|
|
40
40
|
"jose": "^6.2.12",
|
|
41
41
|
"zod": "^4.6.5"
|
|
42
42
|
},
|