@nage-api/auth 1.0.0-beta.2

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.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +176 -0
  3. package/dist/api-key.service.d.ts +50 -0
  4. package/dist/api-key.service.js +110 -0
  5. package/dist/auth.controller.d.ts +48 -0
  6. package/dist/auth.controller.js +185 -0
  7. package/dist/auth.dto.d.ts +37 -0
  8. package/dist/auth.dto.js +117 -0
  9. package/dist/auth.guard.d.ts +29 -0
  10. package/dist/auth.guard.js +122 -0
  11. package/dist/auth.module.d.ts +61 -0
  12. package/dist/auth.module.js +226 -0
  13. package/dist/auth.service.d.ts +82 -0
  14. package/dist/auth.service.js +269 -0
  15. package/dist/authorization.guard.d.ts +24 -0
  16. package/dist/authorization.guard.js +107 -0
  17. package/dist/config.d.ts +71 -0
  18. package/dist/config.js +151 -0
  19. package/dist/decorators.d.ts +51 -0
  20. package/dist/decorators.js +70 -0
  21. package/dist/index.d.ts +30 -0
  22. package/dist/index.js +96 -0
  23. package/dist/jwt.d.ts +50 -0
  24. package/dist/jwt.js +163 -0
  25. package/dist/lockout.service.d.ts +43 -0
  26. package/dist/lockout.service.js +94 -0
  27. package/dist/memory-stores.d.ts +84 -0
  28. package/dist/memory-stores.js +246 -0
  29. package/dist/otp.service.d.ts +47 -0
  30. package/dist/otp.service.js +137 -0
  31. package/dist/password.d.ts +51 -0
  32. package/dist/password.js +122 -0
  33. package/dist/policy.d.ts +44 -0
  34. package/dist/policy.js +61 -0
  35. package/dist/ports.d.ts +175 -0
  36. package/dist/ports.js +17 -0
  37. package/dist/principal.resolver.d.ts +52 -0
  38. package/dist/principal.resolver.js +125 -0
  39. package/dist/session.service.d.ts +71 -0
  40. package/dist/session.service.js +175 -0
  41. package/dist/tokens.d.ts +22 -0
  42. package/dist/tokens.js +23 -0
  43. package/package.json +66 -0
package/dist/jwt.js ADDED
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ /**
3
+ * Access-token signing and verification (PLAN.md §12, §15.1).
4
+ *
5
+ * RS256 by default: verifiers hold only the public key, so a compromised API
6
+ * gateway or a downstream service cannot mint tokens. `jose` does the crypto;
7
+ * this file is about the parts that get security reviews wrong — that the
8
+ * algorithm is pinned rather than read from the token header, that issuer and
9
+ * audience are actually checked, and that a verification failure is mapped to a
10
+ * typed error instead of leaking a library message to the caller.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.JoseTokenSigner = void 0;
14
+ exports.buildClaims = buildClaims;
15
+ exports.parseClaims = parseClaims;
16
+ exports.actorId = actorId;
17
+ const node_crypto_1 = require("node:crypto");
18
+ const jose_1 = require("jose");
19
+ const core_1 = require("@nage-api/core");
20
+ class JoseTokenSigner {
21
+ #config;
22
+ #signingKey;
23
+ #verificationKey;
24
+ constructor(options) {
25
+ const { config } = options;
26
+ this.#config = config;
27
+ if (options.privateKey.trim() === '') {
28
+ throw new core_1.ConfigurationError({
29
+ detail: 'A JWT signing key is required; set JWT_PRIVATE_KEY',
30
+ meta: { setting: 'auth.jwt.privateKey' },
31
+ });
32
+ }
33
+ if (config.jwt.algorithm === 'HS256') {
34
+ const secret = (0, node_crypto_1.createSecretKey)(Buffer.from(options.privateKey, 'utf8'));
35
+ this.#signingKey = secret;
36
+ this.#verificationKey = secret;
37
+ return;
38
+ }
39
+ if (options.publicKey === undefined || options.publicKey.trim() === '') {
40
+ // Deriving the public key from the private one would work, but requiring
41
+ // it makes the deployment state what verifiers are expected to hold.
42
+ throw new core_1.ConfigurationError({
43
+ detail: `A public key is required to verify ${config.jwt.algorithm} tokens; set JWT_PUBLIC_KEY`,
44
+ meta: { setting: 'auth.jwt.publicKey', algorithm: config.jwt.algorithm },
45
+ });
46
+ }
47
+ try {
48
+ this.#signingKey = (0, node_crypto_1.createPrivateKey)(options.privateKey);
49
+ this.#verificationKey = (0, node_crypto_1.createPublicKey)(options.publicKey);
50
+ }
51
+ catch (error) {
52
+ throw new core_1.ConfigurationError({
53
+ detail: 'The configured JWT key pair could not be parsed; expected PEM encoding',
54
+ cause: error,
55
+ meta: { setting: 'auth.jwt', algorithm: config.jwt.algorithm },
56
+ });
57
+ }
58
+ }
59
+ async sign(claims, options) {
60
+ const issuedAt = Math.floor(Date.now() / 1000);
61
+ const header = { alg: this.#config.jwt.algorithm };
62
+ if (this.#config.jwt.keyId !== undefined)
63
+ header.kid = this.#config.jwt.keyId;
64
+ return new jose_1.SignJWT(claims)
65
+ .setProtectedHeader(header)
66
+ .setIssuedAt(issuedAt)
67
+ .setExpirationTime(issuedAt + options.expiresInSeconds)
68
+ .setIssuer(this.#config.jwt.issuer)
69
+ .setAudience(audienceFor(this.#config.jwt.audience))
70
+ .sign(this.#signingKey);
71
+ }
72
+ async verify(token) {
73
+ try {
74
+ const { payload } = await (0, jose_1.jwtVerify)(token, this.#verificationKey, {
75
+ // Pinned: without this, `jose` would accept whatever the token's own
76
+ // header asks for, which is how algorithm-confusion attacks work.
77
+ algorithms: [this.#config.jwt.algorithm],
78
+ issuer: this.#config.jwt.issuer,
79
+ audience: audienceFor(this.#config.jwt.audience),
80
+ clockTolerance: this.#config.jwt.clockToleranceSeconds,
81
+ });
82
+ return payload;
83
+ }
84
+ catch (error) {
85
+ // Expiry is worth distinguishing — a client should refresh rather than
86
+ // send the user back to a login form.
87
+ const expired = error instanceof Error &&
88
+ (error.name === 'JWTExpired' ||
89
+ error.message.includes('"exp" claim timestamp check failed'));
90
+ throw new core_1.AuthenticationError(expired ? 'AUTH_TOKEN_EXPIRED' : 'AUTH_TOKEN_INVALID', {
91
+ detail: `Access token rejected: ${error instanceof Error ? error.message : String(error)}`,
92
+ cause: error,
93
+ });
94
+ }
95
+ }
96
+ }
97
+ exports.JoseTokenSigner = JoseTokenSigner;
98
+ /** The claim set, kept in one place so signing and parsing cannot disagree. */
99
+ function buildClaims(input) {
100
+ return {
101
+ sub: input.userId,
102
+ sid: input.sessionId,
103
+ roles: input.roles,
104
+ permissions: input.permissions,
105
+ ...(input.tenantId === undefined ? {} : { tenantId: input.tenantId }),
106
+ ...(input.impersonatedBy === undefined ? {} : { act: { sub: input.impersonatedBy } }),
107
+ };
108
+ }
109
+ /**
110
+ * Read a verified payload back into typed claims.
111
+ *
112
+ * The payload is verified but still **untrusted in shape**: a token minted by
113
+ * an older version of this service, or by a deliberately odd one, can carry a
114
+ * `roles` that is not an array. Anything malformed is dropped rather than cast.
115
+ */
116
+ function parseClaims(payload) {
117
+ const subject = payload['sub'];
118
+ const sessionId = payload['sid'];
119
+ if (typeof subject !== 'string' || typeof sessionId !== 'string') {
120
+ throw new core_1.AuthenticationError('AUTH_TOKEN_INVALID', {
121
+ detail: 'Access token is missing its sub or sid claim',
122
+ });
123
+ }
124
+ return {
125
+ sub: subject,
126
+ sid: sessionId,
127
+ iss: typeof payload['iss'] === 'string' ? payload['iss'] : '',
128
+ aud: readAudience(payload['aud']),
129
+ iat: typeof payload['iat'] === 'number' ? payload['iat'] : 0,
130
+ exp: typeof payload['exp'] === 'number' ? payload['exp'] : 0,
131
+ roles: stringArray(payload['roles']),
132
+ permissions: stringArray(payload['permissions']),
133
+ ...(typeof payload['tenantId'] === 'string' ? { tenantId: payload['tenantId'] } : {}),
134
+ };
135
+ }
136
+ /** The impersonator's id, when the token carries an `act` claim (RFC 8693). */
137
+ function actorId(payload) {
138
+ const actor = payload['act'];
139
+ if (typeof actor !== 'object' || actor === null)
140
+ return undefined;
141
+ const subject = actor['sub'];
142
+ return typeof subject === 'string' ? subject : undefined;
143
+ }
144
+ /**
145
+ * `jose` takes a mutable array; the config holds a readonly one. A single
146
+ * audience stays a bare string, which is the conventional encoding and what
147
+ * other verifiers expect to see on the wire.
148
+ */
149
+ function audienceFor(audience) {
150
+ return typeof audience === 'string' ? audience : [...audience];
151
+ }
152
+ /** `aud` is a string or an array of them, per RFC 7519. */
153
+ function readAudience(value) {
154
+ if (typeof value === 'string')
155
+ return value;
156
+ return stringArray(value);
157
+ }
158
+ function stringArray(value) {
159
+ return Array.isArray(value)
160
+ ? value.filter((entry) => typeof entry === 'string')
161
+ : [];
162
+ }
163
+ //# sourceMappingURL=jwt.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Failed-attempt lockout (PLAN.md §15.1).
3
+ *
4
+ * Distinct from the global rate limit in `@nage-api/core`: that one bounds request
5
+ * volume, this one bounds *guesses against a specific account*. An attacker
6
+ * spreading a credential-stuffing run across a botnet stays under any per-IP
7
+ * limit while hammering one mailbox, so the counter is keyed by identity as
8
+ * well as by address.
9
+ *
10
+ * A lockout is temporary by design. Permanent lockout on failed passwords hands
11
+ * anyone who knows an email address a denial-of-service button.
12
+ */
13
+ import type { AuthAuditSink, Clock, LockoutStore } from './ports.js';
14
+ import type { ResolvedAuthConfig } from './config.js';
15
+ export interface LockoutServiceOptions {
16
+ readonly store: LockoutStore;
17
+ readonly config: ResolvedAuthConfig['lockout'];
18
+ readonly clock?: Clock;
19
+ readonly audit?: AuthAuditSink;
20
+ }
21
+ export interface LockoutStatus {
22
+ readonly locked: boolean;
23
+ readonly failures: number;
24
+ readonly retryAfterSeconds?: number;
25
+ }
26
+ export declare class LockoutService {
27
+ #private;
28
+ constructor(options: LockoutServiceOptions);
29
+ /** Compose a key; callers should include both the identity and the source. */
30
+ static key(scope: string, ...parts: readonly (string | undefined)[]): string;
31
+ status(key: string): Promise<LockoutStatus>;
32
+ /** Throw if the key is locked. Call **before** verifying a credential. */
33
+ assertNotLocked(key: string): Promise<void>;
34
+ /**
35
+ * Count a failure and lock once the threshold is crossed.
36
+ *
37
+ * @returns the status after recording, so the caller can audit a fresh lock.
38
+ */
39
+ recordFailure(key: string, meta?: Readonly<Record<string, unknown>>): Promise<LockoutStatus>;
40
+ /** Clear the counter after a success. */
41
+ recordSuccess(key: string): Promise<void>;
42
+ }
43
+ //# sourceMappingURL=lockout.service.d.ts.map
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ /**
3
+ * Failed-attempt lockout (PLAN.md §15.1).
4
+ *
5
+ * Distinct from the global rate limit in `@nage-api/core`: that one bounds request
6
+ * volume, this one bounds *guesses against a specific account*. An attacker
7
+ * spreading a credential-stuffing run across a botnet stays under any per-IP
8
+ * limit while hammering one mailbox, so the counter is keyed by identity as
9
+ * well as by address.
10
+ *
11
+ * A lockout is temporary by design. Permanent lockout on failed passwords hands
12
+ * anyone who knows an email address a denial-of-service button.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.LockoutService = void 0;
16
+ const core_1 = require("@nage-api/core");
17
+ class LockoutService {
18
+ #store;
19
+ #config;
20
+ #clock;
21
+ #audit;
22
+ constructor(options) {
23
+ this.#store = options.store;
24
+ this.#config = options.config;
25
+ this.#clock = options.clock ?? { now: () => Date.now() };
26
+ this.#audit = options.audit;
27
+ }
28
+ /** Compose a key; callers should include both the identity and the source. */
29
+ static key(scope, ...parts) {
30
+ return [scope, ...parts.filter((part) => part !== undefined && part !== '')].join(':');
31
+ }
32
+ async status(key) {
33
+ const record = await this.#store.get(key);
34
+ if (record === undefined)
35
+ return { locked: false, failures: 0 };
36
+ const now = this.#clock.now();
37
+ if (record.lockedUntil !== undefined && record.lockedUntil > now) {
38
+ return {
39
+ locked: true,
40
+ failures: record.failures,
41
+ retryAfterSeconds: Math.ceil((record.lockedUntil - now) / 1000),
42
+ };
43
+ }
44
+ // The counting window has passed with no lock: the slate is clean.
45
+ if (now - record.firstFailureAt > this.#config.windowSeconds * 1000) {
46
+ return { locked: false, failures: 0 };
47
+ }
48
+ return { locked: false, failures: record.failures };
49
+ }
50
+ /** Throw if the key is locked. Call **before** verifying a credential. */
51
+ async assertNotLocked(key) {
52
+ const status = await this.status(key);
53
+ if (!status.locked)
54
+ return;
55
+ throw new core_1.AuthenticationError('AUTH_ACCOUNT_LOCKED', {
56
+ detail: `${key} is locked for another ${String(status.retryAfterSeconds ?? 0)}s after ${String(status.failures)} failed attempts`,
57
+ meta: { retryAfterSeconds: status.retryAfterSeconds, failures: status.failures },
58
+ });
59
+ }
60
+ /**
61
+ * Count a failure and lock once the threshold is crossed.
62
+ *
63
+ * @returns the status after recording, so the caller can audit a fresh lock.
64
+ */
65
+ async recordFailure(key, meta = {}) {
66
+ const now = this.#clock.now();
67
+ const existing = await this.#store.get(key);
68
+ const withinWindow = existing !== undefined && now - existing.firstFailureAt <= this.#config.windowSeconds * 1000;
69
+ const failures = (withinWindow ? existing.failures : 0) + 1;
70
+ const firstFailureAt = withinWindow ? existing.firstFailureAt : now;
71
+ const locked = failures >= this.#config.maxAttempts;
72
+ const lockedUntil = locked ? now + this.#config.durationSeconds * 1000 : undefined;
73
+ await this.#store.set({
74
+ key,
75
+ failures,
76
+ firstFailureAt,
77
+ ...(lockedUntil === undefined ? {} : { lockedUntil }),
78
+ });
79
+ if (locked) {
80
+ await this.#audit?.record({ name: 'login.locked', at: now, meta: { ...meta, failures } });
81
+ }
82
+ return {
83
+ locked,
84
+ failures,
85
+ ...(lockedUntil === undefined ? {} : { retryAfterSeconds: this.#config.durationSeconds }),
86
+ };
87
+ }
88
+ /** Clear the counter after a success. */
89
+ async recordSuccess(key) {
90
+ await this.#store.clear(key);
91
+ }
92
+ }
93
+ exports.LockoutService = LockoutService;
94
+ //# sourceMappingURL=lockout.service.js.map
@@ -0,0 +1,84 @@
1
+ /**
2
+ * In-memory reference implementations of the auth ports (PLAN.md §19).
3
+ *
4
+ * Not mocks — real implementations with the same semantics, which is what makes
5
+ * them useful. Every rule the guards depend on (a rotated session stays
6
+ * rotated, revoking a family revokes siblings, an OTP is consumed once) is
7
+ * enforced here, so a test that passes against these is exercising the service
8
+ * logic rather than a stub that agrees with it.
9
+ *
10
+ * They are also the ports' executable specification: a SQL-backed
11
+ * `SessionStore` should behave exactly like `MemorySessionStore`.
12
+ */
13
+ import type { Id, SessionRecord } from '@nage-api/contracts';
14
+ import type { ApiKeyRecord, ApiKeyStore, AuthAuditSink, AuthEvent, AuthUserRecord, AuthUserStore, LockoutRecord, LockoutStore, OtpChannel, OtpPurpose, OtpRecord, OtpStore, SessionStore } from './ports.js';
15
+ export declare class MemoryAuthUserStore implements AuthUserStore {
16
+ #private;
17
+ constructor(users?: readonly AuthUserRecord[]);
18
+ add(user: AuthUserRecord): this;
19
+ findById(id: Id): Promise<AuthUserRecord | undefined>;
20
+ findByEmail(email: string): Promise<AuthUserRecord | undefined>;
21
+ setPasswordHash(id: Id, hash: string): Promise<void>;
22
+ }
23
+ export declare class MemorySessionStore implements SessionStore {
24
+ #private;
25
+ create(session: SessionRecord): Promise<void>;
26
+ findByHashedToken(hashedToken: string): Promise<SessionRecord | undefined>;
27
+ markRotated(id: string, rotatedAt: number): Promise<void>;
28
+ revoke(id: string, revokedAt: number): Promise<void>;
29
+ revokeFamily(familyId: string, revokedAt: number): Promise<number>;
30
+ isFamilyRevoked(familyId: string): Promise<boolean>;
31
+ revokeAllForUser(userId: Id, revokedAt: number): Promise<number>;
32
+ deleteExpired(now: number): Promise<number>;
33
+ /** Test helper: every stored session, in insertion order. */
34
+ get all(): readonly SessionRecord[];
35
+ }
36
+ export declare class MemoryOtpStore implements OtpStore {
37
+ #private;
38
+ create(record: OtpRecord): Promise<void>;
39
+ findActive(subject: string, purpose: OtpPurpose): Promise<OtpRecord | undefined>;
40
+ recordAttempt(id: string, attempts: number): Promise<void>;
41
+ consume(id: string, consumedAt: number): Promise<void>;
42
+ invalidate(subject: string, purpose: OtpPurpose): Promise<number>;
43
+ get all(): readonly OtpRecord[];
44
+ }
45
+ export declare class MemoryApiKeyStore implements ApiKeyStore {
46
+ #private;
47
+ create(record: ApiKeyRecord): Promise<void>;
48
+ findByPrefix(prefix: string): Promise<ApiKeyRecord | undefined>;
49
+ touch(id: string, usedAt: number): Promise<void>;
50
+ revoke(id: string, revokedAt: number): Promise<void>;
51
+ }
52
+ export declare class MemoryLockoutStore implements LockoutStore {
53
+ #private;
54
+ get(key: string): Promise<LockoutRecord | undefined>;
55
+ set(record: LockoutRecord): Promise<void>;
56
+ clear(key: string): Promise<void>;
57
+ }
58
+ /**
59
+ * Collects the codes that would have been sent.
60
+ *
61
+ * The default channel in tests and in `nage create` output, so a developer can
62
+ * exercise the OTP flow before any email provider exists.
63
+ */
64
+ export declare class MemoryOtpChannel implements OtpChannel {
65
+ readonly sent: {
66
+ subject: string;
67
+ code: string;
68
+ purpose: OtpPurpose;
69
+ }[];
70
+ send(input: {
71
+ subject: string;
72
+ code: string;
73
+ purpose: OtpPurpose;
74
+ }): Promise<void>;
75
+ /** The most recent code for a subject, which is what a test wants to assert. */
76
+ latest(subject: string): string | undefined;
77
+ }
78
+ /** Keeps auth events in memory so tests can assert on what was recorded. */
79
+ export declare class MemoryAuthAuditSink implements AuthAuditSink {
80
+ readonly events: AuthEvent[];
81
+ record(event: AuthEvent): void;
82
+ names(): readonly string[];
83
+ }
84
+ //# sourceMappingURL=memory-stores.d.ts.map
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+ /**
3
+ * In-memory reference implementations of the auth ports (PLAN.md §19).
4
+ *
5
+ * Not mocks — real implementations with the same semantics, which is what makes
6
+ * them useful. Every rule the guards depend on (a rotated session stays
7
+ * rotated, revoking a family revokes siblings, an OTP is consumed once) is
8
+ * enforced here, so a test that passes against these is exercising the service
9
+ * logic rather than a stub that agrees with it.
10
+ *
11
+ * They are also the ports' executable specification: a SQL-backed
12
+ * `SessionStore` should behave exactly like `MemorySessionStore`.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.MemoryAuthAuditSink = exports.MemoryOtpChannel = exports.MemoryLockoutStore = exports.MemoryApiKeyStore = exports.MemoryOtpStore = exports.MemorySessionStore = exports.MemoryAuthUserStore = void 0;
16
+ class MemoryAuthUserStore {
17
+ #byId = new Map();
18
+ constructor(users = []) {
19
+ for (const user of users)
20
+ this.add(user);
21
+ }
22
+ add(user) {
23
+ this.#byId.set(String(user.id), user);
24
+ return this;
25
+ }
26
+ async findById(id) {
27
+ await Promise.resolve();
28
+ return this.#byId.get(String(id));
29
+ }
30
+ async findByEmail(email) {
31
+ await Promise.resolve();
32
+ const wanted = email.trim().toLowerCase();
33
+ for (const user of this.#byId.values()) {
34
+ if (user.email.toLowerCase() === wanted)
35
+ return user;
36
+ }
37
+ return undefined;
38
+ }
39
+ async setPasswordHash(id, hash) {
40
+ await Promise.resolve();
41
+ const user = this.#byId.get(String(id));
42
+ if (user !== undefined)
43
+ this.#byId.set(String(id), { ...user, passwordHash: hash });
44
+ }
45
+ }
46
+ exports.MemoryAuthUserStore = MemoryAuthUserStore;
47
+ class MemorySessionStore {
48
+ #byId = new Map();
49
+ #byHash = new Map();
50
+ async create(session) {
51
+ await Promise.resolve();
52
+ this.#byId.set(session.id, session);
53
+ this.#byHash.set(session.hashedToken, session.id);
54
+ }
55
+ async findByHashedToken(hashedToken) {
56
+ await Promise.resolve();
57
+ const id = this.#byHash.get(hashedToken);
58
+ return id === undefined ? undefined : this.#byId.get(id);
59
+ }
60
+ async markRotated(id, rotatedAt) {
61
+ await Promise.resolve();
62
+ const session = this.#byId.get(id);
63
+ if (session !== undefined)
64
+ this.#byId.set(id, { ...session, rotatedAt });
65
+ }
66
+ async revoke(id, revokedAt) {
67
+ await Promise.resolve();
68
+ const session = this.#byId.get(id);
69
+ if (session !== undefined)
70
+ this.#byId.set(id, { ...session, revokedAt });
71
+ }
72
+ async revokeFamily(familyId, revokedAt) {
73
+ await Promise.resolve();
74
+ let revoked = 0;
75
+ for (const [id, session] of this.#byId) {
76
+ if (session.familyId !== familyId || session.revokedAt !== undefined)
77
+ continue;
78
+ this.#byId.set(id, { ...session, revokedAt });
79
+ revoked += 1;
80
+ }
81
+ return revoked;
82
+ }
83
+ async isFamilyRevoked(familyId) {
84
+ await Promise.resolve();
85
+ for (const session of this.#byId.values()) {
86
+ if (session.familyId === familyId && session.revokedAt !== undefined)
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ async revokeAllForUser(userId, revokedAt) {
92
+ await Promise.resolve();
93
+ let revoked = 0;
94
+ for (const [id, session] of this.#byId) {
95
+ if (String(session.userId) !== String(userId) || session.revokedAt !== undefined)
96
+ continue;
97
+ this.#byId.set(id, { ...session, revokedAt });
98
+ revoked += 1;
99
+ }
100
+ return revoked;
101
+ }
102
+ async deleteExpired(now) {
103
+ await Promise.resolve();
104
+ let deleted = 0;
105
+ for (const [id, session] of this.#byId) {
106
+ if (session.expiresAt > now)
107
+ continue;
108
+ this.#byId.delete(id);
109
+ this.#byHash.delete(session.hashedToken);
110
+ deleted += 1;
111
+ }
112
+ return deleted;
113
+ }
114
+ /** Test helper: every stored session, in insertion order. */
115
+ get all() {
116
+ return [...this.#byId.values()];
117
+ }
118
+ }
119
+ exports.MemorySessionStore = MemorySessionStore;
120
+ class MemoryOtpStore {
121
+ #records = new Map();
122
+ async create(record) {
123
+ await Promise.resolve();
124
+ this.#records.set(record.id, record);
125
+ }
126
+ async findActive(subject, purpose) {
127
+ await Promise.resolve();
128
+ let newest;
129
+ for (const record of this.#records.values()) {
130
+ if (record.subject !== subject || record.purpose !== purpose)
131
+ continue;
132
+ if (record.consumedAt !== undefined)
133
+ continue;
134
+ if (newest === undefined || record.issuedAt > newest.issuedAt)
135
+ newest = record;
136
+ }
137
+ return newest;
138
+ }
139
+ async recordAttempt(id, attempts) {
140
+ await Promise.resolve();
141
+ const record = this.#records.get(id);
142
+ if (record !== undefined)
143
+ this.#records.set(id, { ...record, attempts });
144
+ }
145
+ async consume(id, consumedAt) {
146
+ await Promise.resolve();
147
+ const record = this.#records.get(id);
148
+ if (record !== undefined)
149
+ this.#records.set(id, { ...record, consumedAt });
150
+ }
151
+ async invalidate(subject, purpose) {
152
+ await Promise.resolve();
153
+ let invalidated = 0;
154
+ for (const [id, record] of this.#records) {
155
+ if (record.subject !== subject || record.purpose !== purpose)
156
+ continue;
157
+ if (record.consumedAt !== undefined)
158
+ continue;
159
+ this.#records.set(id, { ...record, consumedAt: record.issuedAt });
160
+ invalidated += 1;
161
+ }
162
+ return invalidated;
163
+ }
164
+ get all() {
165
+ return [...this.#records.values()];
166
+ }
167
+ }
168
+ exports.MemoryOtpStore = MemoryOtpStore;
169
+ class MemoryApiKeyStore {
170
+ #byId = new Map();
171
+ async create(record) {
172
+ await Promise.resolve();
173
+ this.#byId.set(record.id, record);
174
+ }
175
+ async findByPrefix(prefix) {
176
+ await Promise.resolve();
177
+ for (const record of this.#byId.values()) {
178
+ if (record.prefix === prefix)
179
+ return record;
180
+ }
181
+ return undefined;
182
+ }
183
+ async touch(id, usedAt) {
184
+ await Promise.resolve();
185
+ const record = this.#byId.get(id);
186
+ if (record !== undefined)
187
+ this.#byId.set(id, { ...record, lastUsedAt: usedAt });
188
+ }
189
+ async revoke(id, revokedAt) {
190
+ await Promise.resolve();
191
+ const record = this.#byId.get(id);
192
+ if (record !== undefined)
193
+ this.#byId.set(id, { ...record, revokedAt });
194
+ }
195
+ }
196
+ exports.MemoryApiKeyStore = MemoryApiKeyStore;
197
+ class MemoryLockoutStore {
198
+ #records = new Map();
199
+ async get(key) {
200
+ await Promise.resolve();
201
+ return this.#records.get(key);
202
+ }
203
+ async set(record) {
204
+ await Promise.resolve();
205
+ this.#records.set(record.key, record);
206
+ }
207
+ async clear(key) {
208
+ await Promise.resolve();
209
+ this.#records.delete(key);
210
+ }
211
+ }
212
+ exports.MemoryLockoutStore = MemoryLockoutStore;
213
+ /**
214
+ * Collects the codes that would have been sent.
215
+ *
216
+ * The default channel in tests and in `nage create` output, so a developer can
217
+ * exercise the OTP flow before any email provider exists.
218
+ */
219
+ class MemoryOtpChannel {
220
+ sent = [];
221
+ async send(input) {
222
+ await Promise.resolve();
223
+ this.sent.push(input);
224
+ }
225
+ /** The most recent code for a subject, which is what a test wants to assert. */
226
+ latest(subject) {
227
+ for (let index = this.sent.length - 1; index >= 0; index -= 1) {
228
+ if (this.sent[index]?.subject === subject)
229
+ return this.sent[index]?.code;
230
+ }
231
+ return undefined;
232
+ }
233
+ }
234
+ exports.MemoryOtpChannel = MemoryOtpChannel;
235
+ /** Keeps auth events in memory so tests can assert on what was recorded. */
236
+ class MemoryAuthAuditSink {
237
+ events = [];
238
+ record(event) {
239
+ this.events.push(event);
240
+ }
241
+ names() {
242
+ return this.events.map((event) => event.name);
243
+ }
244
+ }
245
+ exports.MemoryAuthAuditSink = MemoryAuthAuditSink;
246
+ //# sourceMappingURL=memory-stores.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * One-time codes (PLAN.md §12, §15.1).
3
+ *
4
+ * Codes are drawn from the CSPRNG (`randomOtp`, which rejects and re-draws
5
+ * rather than folding a range, so no digit is more likely than another), stored
6
+ * as an HMAC, compared in constant time, and consumed on first correct use.
7
+ *
8
+ * Three limits make a six-digit code defensible: a short TTL, a per-code
9
+ * attempt ceiling, and a resend cooldown. Without the cooldown, an attacker
10
+ * requests a fresh code every time the attempt counter runs out and the
11
+ * ceiling stops mattering.
12
+ */
13
+ import type { AuthAuditSink, Clock, OtpChannel, OtpPurpose, OtpStore } from './ports.js';
14
+ import type { ResolvedAuthConfig } from './config.js';
15
+ export interface OtpServiceOptions {
16
+ readonly store: OtpStore;
17
+ readonly channel: OtpChannel;
18
+ readonly config: ResolvedAuthConfig['otp'];
19
+ readonly pepper: string;
20
+ readonly clock?: Clock;
21
+ readonly audit?: AuthAuditSink;
22
+ }
23
+ export interface OtpIssueResult {
24
+ readonly issued: boolean;
25
+ /** Set when a resend was refused; seconds until one is allowed. */
26
+ readonly retryAfterSeconds?: number;
27
+ }
28
+ export declare class OtpService {
29
+ #private;
30
+ constructor(options: OtpServiceOptions);
31
+ /**
32
+ * Issue a code and hand it to the channel.
33
+ *
34
+ * Returns `issued: false` while the cooldown is in force rather than
35
+ * throwing: the caller replies the same way either way, because telling an
36
+ * unauthenticated client "you already have a code" confirms the account
37
+ * exists.
38
+ */
39
+ issue(subject: string, purpose: OtpPurpose): Promise<OtpIssueResult>;
40
+ /**
41
+ * Verify and consume a code.
42
+ *
43
+ * @throws AuthenticationError `AUTH_OTP_INVALID` or `AUTH_OTP_EXPIRED`
44
+ */
45
+ verify(subject: string, purpose: OtpPurpose, code: string): Promise<void>;
46
+ }
47
+ //# sourceMappingURL=otp.service.d.ts.map