@blixis-io/auth 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Voeten
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # `@blixis-io/auth`
2
+
3
+ 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.
4
+
5
+ ```bash
6
+ npm install @blixis-io/auth @blixis-io/http @blixis-io/core @blixis-io/di zod
7
+ ```
8
+
9
+ ```ts
10
+ // auth.ts
11
+ import { defineAuthModule } from "@blixis-io/auth";
12
+ import { z } from "zod";
13
+
14
+ const ClaimsSchema = z.object({ sub: z.string(), roles: z.array(z.string()) });
15
+
16
+ export const { AuthModule, JwtAuthGuard, createRolesGuard, getCurrentUser } = defineAuthModule(ClaimsSchema);
17
+ ```
18
+
19
+ ```ts
20
+ @Module({ imports: [AuthModule.forRoot({ secret: process.env.JWT_SECRET! })] })
21
+ class PostsModule {}
22
+ ```
23
+
24
+ ```ts
25
+ @UseGuards(JwtAuthGuard)
26
+ @Get("me")
27
+ me() { /* ... */ }
28
+ ```
29
+
30
+ By default this is verification only. Pass `issuing` to `forRoot()` to also get password sign-in, refresh-token rotation with reuse detection, and sign-out — storage-agnostic, you implement two small interfaces as ordinary DI classes.
31
+
32
+ Part of [Blixis Framework](https://github.com/blixis-io/framework) — full docs: [Authentication](https://blixis-io.github.io/framework/concepts/authentication/) · [API reference](https://blixis-io.github.io/framework/reference/blixis-auth/).
@@ -0,0 +1,131 @@
1
+ import { DynamicModule, ModuleRef } from "@blixis-io/core";
2
+ import { Class, InjectionToken } from "@blixis-io/di";
3
+ import { CanActivate, RequestContext } from "@blixis-io/http";
4
+ import { ZodType, z } from "zod";
5
+ //#region src/issuing.d.ts
6
+ interface CredentialStore<Claims> {
7
+ /** Looks up an account by whatever identifier the app signs in with (email, username, ...). `null`/`undefined` = no such account. */
8
+ findByIdentifier(identifier: string): Promise<{
9
+ subject: string;
10
+ passwordHash: string;
11
+ } | null | undefined>;
12
+ /**
13
+ * The full claims to sign into the access token for `subject` — must
14
+ * satisfy the app's own claims schema. `null`/`undefined` means the
15
+ * account is gone or disabled, which fails closed the same as a wrong
16
+ * password.
17
+ */
18
+ loadClaims(subject: string): Promise<Claims | null | undefined>;
19
+ }
20
+ interface RefreshTokenRecord {
21
+ subject: string;
22
+ expiresAt: Date;
23
+ rotatedAt?: Date | null | undefined;
24
+ revokedAt?: Date | null | undefined;
25
+ }
26
+ interface RefreshTokenStore {
27
+ create(tokenHash: string, record: {
28
+ subject: string;
29
+ expiresAt: Date;
30
+ }): Promise<void>;
31
+ find(tokenHash: string): Promise<RefreshTokenRecord | null | undefined>;
32
+ /**
33
+ * Atomically marks an active (not already rotated or revoked) token as
34
+ * rotated, resolving `true`. Resolves `false` without changing anything
35
+ * if it was already rotated or revoked by the time this runs — the
36
+ * signal `AuthService.refresh()` uses to detect two concurrent refreshes
37
+ * of the same token racing each other, treated the same as reuse.
38
+ */
39
+ markRotated(tokenHash: string): Promise<boolean>;
40
+ /** Must be safe to call on an unknown or already-revoked hash — `AuthService.signOut()` relies on this being a no-op, not a throw. */
41
+ revoke(tokenHash: string): Promise<void>;
42
+ revokeAllForSubject(subject: string): Promise<void>;
43
+ }
44
+ interface TokenPair {
45
+ accessToken: string;
46
+ refreshToken: string;
47
+ accessTokenExpiresAt: Date;
48
+ refreshTokenExpiresAt: Date;
49
+ }
50
+ interface AuthService {
51
+ /** Throws `UnauthorizedException` with one generic message for an unknown identifier, wrong password, or a disabled account (`loadClaims` returning null) — never reveals which. */
52
+ signIn(identifier: string, password: string): Promise<TokenPair>;
53
+ /**
54
+ * Rotates `refreshToken` for a new pair. Throws `UnauthorizedException`
55
+ * if it's unknown, expired, revoked, or **already rotated** — reuse of an
56
+ * already-rotated token revokes every refresh token for that subject,
57
+ * since only the rightful client should ever hold the newest one.
58
+ */
59
+ refresh(refreshToken: string): Promise<TokenPair>;
60
+ /** Revokes one refresh token. Idempotent — never throws for an unknown or already-revoked token. */
61
+ signOut(refreshToken: string): Promise<void>;
62
+ /** Issues a fresh pair for a subject that's already been authenticated some other way (e.g. right after sign-up). */
63
+ issueTokens(subject: string): Promise<TokenPair>;
64
+ /** Revokes every refresh token for `subject` — for a password change or disabling an account. */
65
+ revokeAllSessions(subject: string): Promise<void>;
66
+ }
67
+ //#endregion
68
+ //#region src/module.d.ts
69
+ interface IssuingOptions<Claims> {
70
+ /**
71
+ * Modules to import alongside `AuthModule` itself, so `credentialStore`
72
+ * and `refreshTokenStore` can see whatever they depend on (e.g. a
73
+ * `DATABASE` token exported by a `DbModule`). Without the exporting
74
+ * module listed here, module encapsulation throws `ProviderNotVisibleError`.
75
+ */
76
+ imports?: ModuleRef[] | undefined;
77
+ /** The app's own store implementation — a DI class, so it can inject `DATABASE` or anything else it needs. */
78
+ credentialStore: Class<CredentialStore<Claims>>;
79
+ /** The app's own store implementation for issued refresh tokens. */
80
+ refreshTokenStore: Class<RefreshTokenStore>;
81
+ /** Seconds. Defaults to 900 (15 minutes). */
82
+ accessTokenTtl?: number | undefined;
83
+ /** Seconds. Defaults to 2,592,000 (30 days). */
84
+ refreshTokenTtl?: number | undefined;
85
+ }
86
+ interface AuthModuleOptions<Claims = unknown> {
87
+ /** HMAC secret used to verify the token's signature. */
88
+ secret: string;
89
+ /** Defaults to `"HS256"`. */
90
+ algorithm?: "HS256" | "HS384" | "HS512";
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
+ global?: boolean;
93
+ /** Omit for a verify-only app (the original scope). Set to enable `AUTH_SERVICE` — password sign-in, refresh rotation, sign-out. */
94
+ issuing?: IssuingOptions<Claims> | undefined;
95
+ }
96
+ /**
97
+ * Builds a `JwtAuthGuard` + `createRolesGuard` bound to one claims schema,
98
+ * same factory-closure shape as `@blixis-io/config`'s `defineConfigModule` —
99
+ * the claims shape is app-specific, so there's no single fixed type to
100
+ * validate against.
101
+ */
102
+ export declare function defineAuthModule<Schema extends ZodType>(claimsSchema: Schema): {
103
+ AuthModule: {
104
+ forRoot(options: AuthModuleOptions<z.infer<Schema>>): DynamicModule;
105
+ };
106
+ JwtAuthGuard: Class<CanActivate>;
107
+ createRolesGuard: (...roles: readonly string[]) => Class<CanActivate>;
108
+ getCurrentUser: (ctx: RequestContext) => z.infer<Schema> | undefined;
109
+ /** Resolvable only when `forRoot({ issuing })` was set — otherwise `MissingProviderError` at boot. */
110
+ AUTH_SERVICE: InjectionToken<AuthService>;
111
+ };
112
+ //#endregion
113
+ //#region src/password.d.ts
114
+ /**
115
+ * Hashes `password` with Argon2id, returning a self-describing PHC string
116
+ * (`$argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>`, all base64url without
117
+ * padding). Node's own `crypto.argon2` — no external dependency.
118
+ */
119
+ export declare function hashPassword(password: string): Promise<string>;
120
+ /**
121
+ * Verifies `password` against a PHC string produced by `hashPassword`.
122
+ * Re-derives the hash using the parameters and salt stored *in* `hash`, not
123
+ * the current module constants, so a stored hash always verifies correctly
124
+ * even after `MEMORY_KIB`/`PASSES` change. Throws on a malformed or
125
+ * unrecognized hash — that's a data bug, not "wrong password," and should
126
+ * fail loudly rather than silently report `false`.
127
+ */
128
+ export declare function verifyPassword(password: string, hash: string): Promise<boolean>;
129
+ //#endregion
130
+ export type { AuthModuleOptions, AuthService, CredentialStore, IssuingOptions, RefreshTokenRecord, RefreshTokenStore, TokenPair };
131
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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;;;;UCjDrB,eAAe;;;;;;;EAO9B,UAAU;;EAEV,iBAAiB,MAAM,gBAAgB;;EAEvC,mBAAmB,MAAM;;EAEzB;;EAEA;;UAGe,kBAAkB;;EAEjC;;EAEA;;EAEA;;EAEA,UAAU,eAAe;;;;;;;;wBAmBX,iBAAiB,eAAe,SAC9C,cAAc;EAEd;IAAc,QAAQ,SAAS,kBAAkB,EAAE,MAAM,WAAW;;EACpE,cAAc,MAAM;EACpB,sBAAsB,6BAA6B,MAAM;EACzD,iBAAiB,KAAK,mBAAmB,EAAE,MAAM;;EAEjD,cAAc,eAAe;;;;;;;;;wBCzBT,aAAa,mBAAmB;;;;;;;;;wBAuChC,eAAe,kBAAkB,eAAe"}
package/dist/index.js ADDED
@@ -0,0 +1,330 @@
1
+ import { Module } from "@blixis-io/core";
2
+ import { Inject, Injectable, InjectionToken } from "@blixis-io/di";
3
+ import { RequestContext, UnauthorizedException } from "@blixis-io/http";
4
+ import { SignJWT, jwtVerify } from "jose";
5
+ import { argon2, createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
+ //#region src/password.ts
7
+ const MEMORY_KIB = 19456;
8
+ const PASSES = 2;
9
+ const PARALLELISM = 1;
10
+ const TAG_LENGTH = 32;
11
+ const SALT_LENGTH = 16;
12
+ function argon2id(password, params) {
13
+ return new Promise((resolve, reject) => {
14
+ argon2("argon2id", {
15
+ message: password,
16
+ ...params
17
+ }, (error, tag) => {
18
+ /* v8 ignore next 5 -- @preserve */
19
+ if (error) reject(error);
20
+ else resolve(tag);
21
+ });
22
+ });
23
+ }
24
+ /**
25
+ * Hashes `password` with Argon2id, returning a self-describing PHC string
26
+ * (`$argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>`, all base64url without
27
+ * padding). Node's own `crypto.argon2` — no external dependency.
28
+ */
29
+ async function hashPassword(password) {
30
+ const salt = randomBytes(SALT_LENGTH);
31
+ const tag = await argon2id(password, {
32
+ nonce: salt,
33
+ parallelism: PARALLELISM,
34
+ tagLength: TAG_LENGTH,
35
+ memory: MEMORY_KIB,
36
+ passes: PASSES
37
+ });
38
+ return `$argon2id$v=19$m=${MEMORY_KIB},t=${PASSES},p=${PARALLELISM}$${salt.toString("base64url")}$${tag.toString("base64url")}`;
39
+ }
40
+ const PHC_PATTERN = /^\$argon2id\$v=19\$m=(\d+),t=(\d+),p=(\d+)\$([^$]+)\$([^$]+)$/;
41
+ function requireGroup(match, index) {
42
+ const value = match[index];
43
+ /* v8 ignore start -- @preserve */
44
+ if (value === void 0) throw new Error(`Unrecognized password hash format: expected a $argon2id$... PHC string`);
45
+ /* v8 ignore stop */
46
+ return value;
47
+ }
48
+ /**
49
+ * Verifies `password` against a PHC string produced by `hashPassword`.
50
+ * Re-derives the hash using the parameters and salt stored *in* `hash`, not
51
+ * the current module constants, so a stored hash always verifies correctly
52
+ * even after `MEMORY_KIB`/`PASSES` change. Throws on a malformed or
53
+ * unrecognized hash — that's a data bug, not "wrong password," and should
54
+ * fail loudly rather than silently report `false`.
55
+ */
56
+ async function verifyPassword(password, hash) {
57
+ const match = PHC_PATTERN.exec(hash);
58
+ if (!match) throw new Error(`Unrecognized password hash format: expected a $argon2id$... PHC string`);
59
+ const memory = requireGroup(match, 1);
60
+ const passes = requireGroup(match, 2);
61
+ const parallelism = requireGroup(match, 3);
62
+ const saltB64 = requireGroup(match, 4);
63
+ const tagB64 = requireGroup(match, 5);
64
+ const salt = Buffer.from(saltB64, "base64url");
65
+ const expected = Buffer.from(tagB64, "base64url");
66
+ const actual = await argon2id(password, {
67
+ nonce: salt,
68
+ parallelism: Number(parallelism),
69
+ tagLength: expected.length,
70
+ memory: Number(memory),
71
+ passes: Number(passes)
72
+ });
73
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
74
+ }
75
+ //#endregion
76
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
77
+ function __decorateMetadata(k, v) {
78
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
79
+ }
80
+ //#endregion
81
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateParam.js
82
+ function __decorateParam(paramIndex, decorator) {
83
+ return function(target, key) {
84
+ decorator(target, key, paramIndex);
85
+ };
86
+ }
87
+ //#endregion
88
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
89
+ function __decorate(decorators, target, key, desc) {
90
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
91
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
92
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
93
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
94
+ }
95
+ //#endregion
96
+ //#region src/issuing.ts
97
+ function hashRefreshToken(token) {
98
+ return createHash("sha256").update(token).digest("hex");
99
+ }
100
+ let dummyHash;
101
+ function getDummyHash() {
102
+ dummyHash ??= hashPassword(randomBytes(32).toString("hex"));
103
+ return dummyHash;
104
+ }
105
+ /**
106
+ * Builds the `AuthService` implementation, bound to one
107
+ * `defineAuthModule()` call's tokens — kept out of `module.ts` to keep that
108
+ * file readable. `deps` are all closure-captured `@Inject` targets, same
109
+ * trick `createRolesGuard` already uses for its own per-call guard class.
110
+ * `Claims` only matters internally, for wiring the right `CredentialStore`
111
+ * and validating claims before signing — `AuthService`'s own methods never
112
+ * hand a caller a `Claims` value (they only ever return opaque tokens), so
113
+ * it stays a plain, non-generic interface rather than a phantom parameter.
114
+ */
115
+ function createAuthServiceClass(deps) {
116
+ let AuthServiceImpl = class AuthServiceImpl {
117
+ constructor(authOptions, issuingOptions, credentials, refreshTokens) {
118
+ this.authOptions = authOptions;
119
+ this.issuingOptions = issuingOptions;
120
+ this.credentials = credentials;
121
+ this.refreshTokens = refreshTokens;
122
+ }
123
+ async signIn(identifier, password) {
124
+ const record = await this.credentials.findByIdentifier(identifier);
125
+ const valid = await verifyPassword(password, record?.passwordHash ?? await getDummyHash());
126
+ if (!record || !valid) throw new UnauthorizedException("Invalid credentials");
127
+ const claims = await this.credentials.loadClaims(record.subject);
128
+ if (!claims) throw new UnauthorizedException("Invalid credentials");
129
+ return this.#issuePair(record.subject, claims);
130
+ }
131
+ async refresh(refreshToken) {
132
+ const tokenHash = hashRefreshToken(refreshToken);
133
+ const record = await this.refreshTokens.find(tokenHash);
134
+ if (!record || record.revokedAt || record.expiresAt.getTime() <= Date.now()) throw new UnauthorizedException("Invalid or expired refresh token");
135
+ if (record.rotatedAt) {
136
+ await this.refreshTokens.revokeAllForSubject(record.subject);
137
+ throw new UnauthorizedException("Invalid or expired refresh token");
138
+ }
139
+ if (!await this.refreshTokens.markRotated(tokenHash)) {
140
+ await this.refreshTokens.revokeAllForSubject(record.subject);
141
+ throw new UnauthorizedException("Invalid or expired refresh token");
142
+ }
143
+ const claims = await this.credentials.loadClaims(record.subject);
144
+ if (!claims) {
145
+ await this.refreshTokens.revokeAllForSubject(record.subject);
146
+ throw new UnauthorizedException("Invalid or expired refresh token");
147
+ }
148
+ return this.#issuePair(record.subject, claims);
149
+ }
150
+ async signOut(refreshToken) {
151
+ await this.refreshTokens.revoke(hashRefreshToken(refreshToken));
152
+ }
153
+ async issueTokens(subject) {
154
+ const claims = await this.credentials.loadClaims(subject);
155
+ if (!claims) throw new Error(`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.`);
156
+ return this.#issuePair(subject, claims);
157
+ }
158
+ async revokeAllSessions(subject) {
159
+ await this.refreshTokens.revokeAllForSubject(subject);
160
+ }
161
+ async #issuePair(subject, claims) {
162
+ const parsedClaims = deps.claimsSchema.safeParse(claims);
163
+ if (!parsedClaims.success) throw new Error(`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}`);
164
+ const now = Date.now();
165
+ const accessTokenExpiresAt = new Date(now + this.issuingOptions.accessTokenTtlSeconds * 1e3);
166
+ const accessToken = await new SignJWT(parsedClaims.data).setProtectedHeader({ alg: this.authOptions.algorithm }).setIssuedAt().setExpirationTime(Math.floor(accessTokenExpiresAt.getTime() / 1e3)).sign(this.authOptions.key);
167
+ const refreshToken = randomBytes(32).toString("base64url");
168
+ const refreshTokenExpiresAt = new Date(now + this.issuingOptions.refreshTokenTtlSeconds * 1e3);
169
+ await this.refreshTokens.create(hashRefreshToken(refreshToken), {
170
+ subject,
171
+ expiresAt: refreshTokenExpiresAt
172
+ });
173
+ return {
174
+ accessToken,
175
+ refreshToken,
176
+ accessTokenExpiresAt,
177
+ refreshTokenExpiresAt
178
+ };
179
+ }
180
+ };
181
+ AuthServiceImpl = __decorate([
182
+ Injectable(),
183
+ __decorateParam(0, Inject(deps.authOptionsToken)),
184
+ __decorateParam(1, Inject(deps.issuingOptionsToken)),
185
+ __decorateParam(2, Inject(deps.credentialStoreToken)),
186
+ __decorateParam(3, Inject(deps.refreshTokenStoreToken)),
187
+ __decorateMetadata("design:paramtypes", [
188
+ Object,
189
+ Object,
190
+ Object,
191
+ Object
192
+ ])
193
+ ], AuthServiceImpl);
194
+ return AuthServiceImpl;
195
+ }
196
+ //#endregion
197
+ //#region src/module.ts
198
+ const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
199
+ const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2592e3;
200
+ let authInstanceCounter = 0;
201
+ /**
202
+ * Builds a `JwtAuthGuard` + `createRolesGuard` bound to one claims schema,
203
+ * same factory-closure shape as `@blixis-io/config`'s `defineConfigModule` —
204
+ * the claims shape is app-specific, so there's no single fixed type to
205
+ * validate against.
206
+ */
207
+ function defineAuthModule(claimsSchema) {
208
+ var _AuthModule;
209
+ const AUTH_OPTIONS = new InjectionToken("blixis.auth.options");
210
+ const ISSUING_OPTIONS = new InjectionToken("blixis.auth.issuingOptions");
211
+ const CREDENTIAL_STORE = new InjectionToken("blixis.auth.credentialStore");
212
+ const REFRESH_TOKEN_STORE = new InjectionToken("blixis.auth.refreshTokenStore");
213
+ const AUTH_SERVICE = new InjectionToken("blixis.auth.service");
214
+ const CURRENT_USER_KEY = `blixis.auth.currentUser.${authInstanceCounter++}`;
215
+ /** Reads the claims `JwtAuthGuard` verified for the current request, or `undefined` outside a request (or before the guard has run). */
216
+ function getCurrentUser(ctx) {
217
+ return ctx.get(CURRENT_USER_KEY);
218
+ }
219
+ let JwtAuthGuard = class JwtAuthGuard {
220
+ constructor(options, ctx) {
221
+ this.options = options;
222
+ this.ctx = ctx;
223
+ }
224
+ async canActivate({ request }) {
225
+ const header = request.headers.get("authorization") ?? "";
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);
237
+ return true;
238
+ }
239
+ };
240
+ JwtAuthGuard = __decorate([
241
+ Injectable(),
242
+ __decorateParam(0, Inject(AUTH_OPTIONS)),
243
+ __decorateMetadata("design:paramtypes", [Object, typeof RequestContext === "undefined" ? Object : RequestContext])
244
+ ], JwtAuthGuard);
245
+ /**
246
+ * Builds a guard requiring at least one of `roles` on the current user —
247
+ * a fresh class per call, meant to be assigned to a named export and
248
+ * registered as a provider like any other guard (`export const AdminGuard
249
+ * = createRolesGuard("admin")`). Must run after `JwtAuthGuard` in the
250
+ * same `@UseGuards(...)` list; throws `UnauthorizedException` (not a
251
+ * plain `false`) if no user is in `RequestContext` yet, since that means
252
+ * the guards were ordered wrong, not that this user lacks the role.
253
+ */
254
+ function createRolesGuard(...roles) {
255
+ let RolesGuard = class RolesGuard {
256
+ constructor(ctx) {
257
+ this.ctx = ctx;
258
+ }
259
+ canActivate() {
260
+ const user = getCurrentUser(this.ctx);
261
+ if (!user) throw new UnauthorizedException("No authenticated user in request context — apply the auth guard first");
262
+ const userRoles = user.roles;
263
+ return Array.isArray(userRoles) && roles.some((role) => userRoles.includes(role));
264
+ }
265
+ };
266
+ RolesGuard = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof RequestContext === "undefined" ? Object : RequestContext])], RolesGuard);
267
+ return RolesGuard;
268
+ }
269
+ let AuthModule = _AuthModule = class AuthModule {
270
+ static forRoot(options) {
271
+ const normalized = {
272
+ key: new TextEncoder().encode(options.secret),
273
+ algorithm: options.algorithm ?? "HS256"
274
+ };
275
+ const imports = [];
276
+ const providers = [{
277
+ provide: AUTH_OPTIONS,
278
+ useValue: normalized
279
+ }, JwtAuthGuard];
280
+ const exports = [AUTH_OPTIONS, JwtAuthGuard];
281
+ if (options.issuing) {
282
+ const issuingNormalized = {
283
+ accessTokenTtlSeconds: options.issuing.accessTokenTtl ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS,
284
+ refreshTokenTtlSeconds: options.issuing.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS
285
+ };
286
+ const AuthServiceImpl = createAuthServiceClass({
287
+ claimsSchema,
288
+ authOptionsToken: AUTH_OPTIONS,
289
+ issuingOptionsToken: ISSUING_OPTIONS,
290
+ credentialStoreToken: CREDENTIAL_STORE,
291
+ refreshTokenStoreToken: REFRESH_TOKEN_STORE
292
+ });
293
+ imports.push(...options.issuing.imports ?? []);
294
+ providers.push({
295
+ provide: ISSUING_OPTIONS,
296
+ useValue: issuingNormalized
297
+ }, {
298
+ provide: CREDENTIAL_STORE,
299
+ useClass: options.issuing.credentialStore
300
+ }, {
301
+ provide: REFRESH_TOKEN_STORE,
302
+ useClass: options.issuing.refreshTokenStore
303
+ }, {
304
+ provide: AUTH_SERVICE,
305
+ useClass: AuthServiceImpl
306
+ });
307
+ exports.push(AUTH_SERVICE);
308
+ }
309
+ return {
310
+ module: _AuthModule,
311
+ imports,
312
+ providers,
313
+ exports,
314
+ global: options.global ?? false
315
+ };
316
+ }
317
+ };
318
+ AuthModule = _AuthModule = __decorate([Module()], AuthModule);
319
+ return {
320
+ AuthModule,
321
+ JwtAuthGuard,
322
+ createRolesGuard,
323
+ getCurrentUser,
324
+ AUTH_SERVICE
325
+ };
326
+ }
327
+ //#endregion
328
+ export { defineAuthModule, hashPassword, verifyPassword };
329
+
330
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":";;;;;;AAOA,MAAM,aAAa;AACnB,MAAM,SAAS;AACf,MAAM,cAAc;AACpB,MAAM,aAAa;AACnB,MAAM,cAAc;AAUpB,SAAS,SAAS,UAAkB,QAAyC;CAC3E,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,OAAO,YAAY;GAAE,SAAS;GAAU,GAAG;EAAO,IAAI,OAAO,QAAQ;;GAOnE,IAAI,OACF,OAAO,KAAK;QAEZ,QAAQ,GAAG;EAEf,CAAC;CACH,CAAC;AACH;;;;;;AAOA,eAAsB,aAAa,UAAmC;CACpE,MAAM,OAAO,YAAY,WAAW;CACpC,MAAM,MAAM,MAAM,SAAS,UAAU;EACnC,OAAO;EACP,aAAa;EACb,WAAW;EACX,QAAQ;EACR,QAAQ;CACV,CAAC;CACD,OAAO,oBAAoB,WAAW,KAAK,OAAO,KAAK,YAAY,GAAG,KAAK,SAAS,WAAW,EAAE,GAAG,IAAI,SAAS,WAAW;AAC9H;AAEA,MAAM,cAAc;AAKpB,SAAS,aAAa,OAAwB,OAAuB;CACnE,MAAM,QAAQ,MAAM;;CAMpB,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,wEAAwE;;CAG1F,OAAO;AACT;;;;;;;;;AAUA,eAAsB,eAAe,UAAkB,MAAgC;CACrF,MAAM,QAAQ,YAAY,KAAK,IAAI;CACnC,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,wEAAwE;CAE1F,MAAM,SAAS,aAAa,OAAO,CAAC;CACpC,MAAM,SAAS,aAAa,OAAO,CAAC;CACpC,MAAM,cAAc,aAAa,OAAO,CAAC;CACzC,MAAM,UAAU,aAAa,OAAO,CAAC;CACrC,MAAM,SAAS,aAAa,OAAO,CAAC;CAEpC,MAAM,OAAO,OAAO,KAAK,SAAS,WAAW;CAC7C,MAAM,WAAW,OAAO,KAAK,QAAQ,WAAW;CAChD,MAAM,SAAS,MAAM,SAAS,UAAU;EACtC,OAAO;EACP,aAAa,OAAO,WAAW;EAC/B,WAAW,SAAS;EACpB,QAAQ,OAAO,MAAM;EACrB,QAAQ,OAAO,MAAM;CACvB,CAAC;CAED,OAAO,OAAO,WAAW,SAAS,UAAU,gBAAgB,QAAQ,QAAQ;AAC9E;;;;;;;;;;;;;;;;;;;;;;;AChCA,SAAS,iBAAiB,OAAuB;CAC/C,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;AACxD;AAMA,IAAI;AACJ,SAAS,eAAgC;CACvC,cAAc,aAAa,YAAY,EAAE,CAAC,CAAC,SAAS,KAAK,CAAC;CAC1D,OAAO;AACT;;;;;;;;;;;AA2BA,SAAgB,uBAA+B,MAAqD;CAClG,IACM,kBADN,MACM,gBAAuC;EAC3C,YACE,aACA,gBACA,aACA,eACA;GAJgD,KAAA,cAAA;GACG,KAAA,iBAAA;GACC,KAAA,cAAA;GACE,KAAA,gBAAA;EACrD;EAEH,MAAM,OAAO,YAAoB,UAAsC;GACrE,MAAM,SAAS,MAAM,KAAK,YAAY,iBAAiB,UAAU;GAEjE,MAAM,QAAQ,MAAM,eAAe,UADd,QAAQ,gBAAiB,MAAM,aAAa,CACR;GAEzD,IAAI,CAAC,UAAU,CAAC,OACd,MAAM,IAAI,sBAAsB,qBAAqB;GAGvD,MAAM,SAAS,MAAM,KAAK,YAAY,WAAW,OAAO,OAAO;GAC/D,IAAI,CAAC,QACH,MAAM,IAAI,sBAAsB,qBAAqB;GAGvD,OAAO,KAAK,WAAW,OAAO,SAAS,MAAM;EAC/C;EAEA,MAAM,QAAQ,cAA0C;GACtD,MAAM,YAAY,iBAAiB,YAAY;GAC/C,MAAM,SAAS,MAAM,KAAK,cAAc,KAAK,SAAS;GAEtD,IAAI,CAAC,UAAU,OAAO,aAAa,OAAO,UAAU,QAAQ,KAAK,KAAK,IAAI,GACxE,MAAM,IAAI,sBAAsB,kCAAkC;GAGpE,IAAI,OAAO,WAAW;IACpB,MAAM,KAAK,cAAc,oBAAoB,OAAO,OAAO;IAC3D,MAAM,IAAI,sBAAsB,kCAAkC;GACpE;GAGA,IAAI,CAAC,MADiB,KAAK,cAAc,YAAY,SAAS,GAChD;IAGZ,MAAM,KAAK,cAAc,oBAAoB,OAAO,OAAO;IAC3D,MAAM,IAAI,sBAAsB,kCAAkC;GACpE;GAEA,MAAM,SAAS,MAAM,KAAK,YAAY,WAAW,OAAO,OAAO;GAC/D,IAAI,CAAC,QAAQ;IACX,MAAM,KAAK,cAAc,oBAAoB,OAAO,OAAO;IAC3D,MAAM,IAAI,sBAAsB,kCAAkC;GACpE;GAEA,OAAO,KAAK,WAAW,OAAO,SAAS,MAAM;EAC/C;EAEA,MAAM,QAAQ,cAAqC;GACjD,MAAM,KAAK,cAAc,OAAO,iBAAiB,YAAY,CAAC;EAChE;EAEA,MAAM,YAAY,SAAqC;GACrD,MAAM,SAAS,MAAM,KAAK,YAAY,WAAW,OAAO;GACxD,IAAI,CAAC,QACH,MAAM,IAAI,MACR,yCAAyC,QAAQ,4IACnD;GAEF,OAAO,KAAK,WAAW,SAAS,MAAM;EACxC;EAEA,MAAM,kBAAkB,SAAgC;GACtD,MAAM,KAAK,cAAc,oBAAoB,OAAO;EACtD;EAEA,MAAM,WAAW,SAAiB,QAAoC;GACpE,MAAM,eAAe,KAAK,aAAa,UAAU,MAAM;GACvD,IAAI,CAAC,aAAa,SAChB,MAAM,IAAI,MACR,+BAA+B,QAAQ,4HAA4H,aAAa,MAAM,SACxL;GAGF,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,uBAAuB,IAAI,KAAK,MAAM,KAAK,eAAe,wBAAwB,GAAI;GAM5F,MAAM,cAAc,MAAM,IAAI,QAAQ,aAAa,IAAkB,CAAC,CACnE,mBAAmB,EAAE,KAAK,KAAK,YAAY,UAAU,CAAC,CAAC,CACvD,YAAY,CAAC,CACb,kBAAkB,KAAK,MAAM,qBAAqB,QAAQ,IAAI,GAAI,CAAC,CAAC,CACpE,KAAK,KAAK,YAAY,GAAG;GAE5B,MAAM,eAAe,YAAY,EAAE,CAAC,CAAC,SAAS,WAAW;GACzD,MAAM,wBAAwB,IAAI,KAAK,MAAM,KAAK,eAAe,yBAAyB,GAAI;GAC9F,MAAM,KAAK,cAAc,OAAO,iBAAiB,YAAY,GAAG;IAAE;IAAS,WAAW;GAAsB,CAAC;GAE7G,OAAO;IAAE;IAAa;IAAc;IAAsB;GAAsB;EAClF;CACF;;EArGC,WAAW;EAGP,gBAAA,GAAA,OAAO,KAAK,gBAAgB,CAAA;EAC5B,gBAAA,GAAA,OAAO,KAAK,mBAAmB,CAAA;EAC/B,gBAAA,GAAA,OAAO,KAAK,oBAAoB,CAAA;EAChC,gBAAA,GAAA,OAAO,KAAK,sBAAsB,CAAA;;;;;;;;CAiGvC,OAAO;AACT;;;AC5MA,MAAM,mCAAmC;AACzC,MAAM,oCAAoC;AAuC1C,IAAI,sBAAsB;;;;;;;AAQ1B,SAAgB,iBACd,cAQA;;CAEA,MAAM,eAAe,IAAI,eAAsC,qBAAqB;CACpF,MAAM,kBAAkB,IAAI,eAAyC,4BAA4B;CACjG,MAAM,mBAAmB,IAAI,eAAwC,6BAA6B;CAClG,MAAM,sBAAsB,IAAI,eAAkC,+BAA+B;CACjG,MAAM,eAAe,IAAI,eAA4B,qBAAqB;CAC1E,MAAM,mBAAmB,2BAA2B;;CAGpD,SAAS,eAAe,KAAyC;EAC/D,OAAO,IAAI,IAAY,gBAAgB;CACzC;CAEA,IACM,eADN,MACM,aAAoC;EACxC,YACE,SACA,KACA;GAFuC,KAAA,UAAA;GACtB,KAAA,MAAA;EAChB;EAEH,MAAM,YAAY,EAAE,WAA+C;GACjE,MAAM,SAAS,QAAQ,QAAQ,IAAI,eAAe,KAAK;GACvD,MAAM,QAAQ,OAAO,WAAW,SAAS,IAAI,OAAO,MAAM,CAAgB,IAAI,KAAA;GAC9E,IAAI,CAAC,OACH,MAAM,IAAI,sBAAsB,2CAA2C;GAG7E,IAAI;GACJ,IAAI;IACF,WAAW,MAAM,UAAU,OAAO,KAAK,QAAQ,KAAK,EAAE,YAAY,CAAC,KAAK,QAAQ,SAAS,EAAE,CAAC,EAAA,CAAG;GACjG,QAAQ;IACN,MAAM,IAAI,sBAAsB,0BAA0B;GAC5D;GAEA,MAAM,SAAS,MAAM,aAAa,eAAe,OAAO;GACxD,IAAI,CAAC,OAAO,SACV,MAAM,IAAI,sBAAsB,iCAAiC;GAGnE,KAAK,IAAI,IAAI,kBAAkB,OAAO,IAAI;GAC1C,OAAO;EACT;CACF;;EA7BC,WAAW;EAGP,gBAAA,GAAA,OAAO,YAAY,CAAA;;;;;;;;;;;;CAqCxB,SAAS,iBAAiB,GAAG,OAA8C;EACzE,IACM,aADN,MACM,WAAkC;GACtC,YAAY,KAAsC;IAArB,KAAA,MAAA;GAAsB;GAEnD,cAAuB;IACrB,MAAM,OAAO,eAAe,KAAK,GAAG;IACpC,IAAI,CAAC,MACH,MAAM,IAAI,sBAAsB,uEAAuE;IAEzG,MAAM,YAAa,KAA6B;IAChD,OAAO,MAAM,QAAQ,SAAS,KAAK,MAAM,MAAM,SAAS,UAAU,SAAS,IAAI,CAAC;GAClF;EACF;EAZC,aAAA,WAAA,CAAA,WAAW,GAAA,mBAAA,qBAAA,CAAA,OAAA,mBAAA,cAAA,SAAA,cAAA,CAAA,CAAA,GAAA,UAAA;EAaZ,OAAO;CACT;CAEA,IACM,aAAA,cADN,MACM,WAAW;EACf,OAAO,QAAQ,SAAmD;GAChE,MAAM,aAAoC;IACxC,KAAK,IAAI,YAAY,CAAC,CAAC,OAAO,QAAQ,MAAM;IAC5C,WAAW,QAAQ,aAAa;GAClC;GACA,MAAM,UAAuB,CAAC;GAC9B,MAAM,YAAwB,CAAC;IAAE,SAAS;IAAc,UAAU;GAAW,GAAG,YAAY;GAC5F,MAAM,UAAmB,CAAC,cAAc,YAAY;GAEpD,IAAI,QAAQ,SAAS;IACnB,MAAM,oBAA8C;KAClD,uBAAuB,QAAQ,QAAQ,kBAAkB;KACzD,wBAAwB,QAAQ,QAAQ,mBAAmB;IAC7D;IACA,MAAM,kBAAkB,uBAA+B;KACrD;KACA,kBAAkB;KAClB,qBAAqB;KACrB,sBAAsB;KACtB,wBAAwB;IAC1B,CAAC;IAED,QAAQ,KAAK,GAAI,QAAQ,QAAQ,WAAW,CAAC,CAAE;IAC/C,UAAU,KACR;KAAE,SAAS;KAAiB,UAAU;IAAkB,GACxD;KAAE,SAAS;KAAkB,UAAU,QAAQ,QAAQ;IAAgB,GACvE;KAAE,SAAS;KAAqB,UAAU,QAAQ,QAAQ;IAAkB,GAC5E;KAAE,SAAS;KAAc,UAAU;IAAgB,CACrD;IACA,QAAQ,KAAK,YAAY;GAC3B;GAEA,OAAO;IAAE,QAAA;IAAoB;IAAS;IAAW;IAAS,QAAQ,QAAQ,UAAU;GAAM;EAC5F;CACF;CApCC,aAAA,cAAA,WAAA,CAAA,OAAO,CAAA,GAAA,UAAA;CAsCR,OAAO;EAAE;EAAY;EAAc;EAAkB;EAAgB;CAAa;AACpF"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@blixis-io/auth",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "author": "Michael Voeten <michael@voeten.online>",
7
+ "homepage": "https://blixis-io.github.io/framework/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/blixis-io/framework.git",
11
+ "directory": "packages/auth"
12
+ },
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=24.7.0"
16
+ },
17
+ "keywords": [
18
+ "jwt",
19
+ "authentication",
20
+ "authorization",
21
+ "typescript"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "@blixis-io/source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@blixis-io/core": "0.1.0",
38
+ "@blixis-io/di": "0.1.0",
39
+ "@blixis-io/http": "0.1.0",
40
+ "jose": "^6.2.12",
41
+ "zod": "^4.6.5"
42
+ },
43
+ "scripts": {
44
+ "typecheck": "tsc -p tsconfig.json",
45
+ "build": "rolldown -c"
46
+ }
47
+ }