@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
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ /**
3
+ * One-time codes (PLAN.md §12, §15.1).
4
+ *
5
+ * Codes are drawn from the CSPRNG (`randomOtp`, which rejects and re-draws
6
+ * rather than folding a range, so no digit is more likely than another), stored
7
+ * as an HMAC, compared in constant time, and consumed on first correct use.
8
+ *
9
+ * Three limits make a six-digit code defensible: a short TTL, a per-code
10
+ * attempt ceiling, and a resend cooldown. Without the cooldown, an attacker
11
+ * requests a fresh code every time the attempt counter runs out and the
12
+ * ceiling stops mattering.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.OtpService = void 0;
16
+ const node_crypto_1 = require("node:crypto");
17
+ const core_1 = require("@nage-api/core");
18
+ const MIN_PEPPER_LENGTH = 16;
19
+ class OtpService {
20
+ #store;
21
+ #channel;
22
+ #config;
23
+ #pepper;
24
+ #clock;
25
+ #audit;
26
+ constructor(options) {
27
+ if (options.pepper.length < MIN_PEPPER_LENGTH) {
28
+ throw new core_1.ConfigurationError({
29
+ detail: `The OTP pepper must be at least ${String(MIN_PEPPER_LENGTH)} characters`,
30
+ meta: { setting: 'OTP_PEPPER' },
31
+ });
32
+ }
33
+ this.#store = options.store;
34
+ this.#channel = options.channel;
35
+ this.#config = options.config;
36
+ this.#pepper = options.pepper;
37
+ this.#clock = options.clock ?? { now: () => Date.now() };
38
+ this.#audit = options.audit;
39
+ }
40
+ /**
41
+ * Issue a code and hand it to the channel.
42
+ *
43
+ * Returns `issued: false` while the cooldown is in force rather than
44
+ * throwing: the caller replies the same way either way, because telling an
45
+ * unauthenticated client "you already have a code" confirms the account
46
+ * exists.
47
+ */
48
+ async issue(subject, purpose) {
49
+ const now = this.#clock.now();
50
+ const active = await this.#store.findActive(subject, purpose);
51
+ if (active !== undefined) {
52
+ const elapsed = now - active.issuedAt;
53
+ const cooldown = this.#config.resendCooldownSeconds * 1000;
54
+ if (elapsed < cooldown) {
55
+ return { issued: false, retryAfterSeconds: Math.ceil((cooldown - elapsed) / 1000) };
56
+ }
57
+ }
58
+ // Only one code may be live per subject and purpose; leaving the old one
59
+ // valid would double the guessing surface for every resend.
60
+ await this.#store.invalidate(subject, purpose);
61
+ const code = (0, core_1.randomOtp)(this.#config.length);
62
+ const record = {
63
+ id: (0, core_1.randomId)(),
64
+ subject,
65
+ purpose,
66
+ hashedCode: this.#hash(subject, purpose, code),
67
+ issuedAt: now,
68
+ expiresAt: now + this.#config.ttlSeconds * 1000,
69
+ attempts: 0,
70
+ };
71
+ await this.#store.create(record);
72
+ await this.#channel.send({ subject, code, purpose });
73
+ await this.#audit?.record({ name: 'otp.sent', at: now, meta: { purpose } });
74
+ return { issued: true };
75
+ }
76
+ /**
77
+ * Verify and consume a code.
78
+ *
79
+ * @throws AuthenticationError `AUTH_OTP_INVALID` or `AUTH_OTP_EXPIRED`
80
+ */
81
+ async verify(subject, purpose, code) {
82
+ const now = this.#clock.now();
83
+ const record = await this.#store.findActive(subject, purpose);
84
+ if (record === undefined) {
85
+ await this.#audit?.record({
86
+ name: 'otp.failed',
87
+ at: now,
88
+ meta: { purpose, reason: 'no-code' },
89
+ });
90
+ throw new core_1.AuthenticationError('AUTH_OTP_INVALID', {
91
+ detail: `No active ${purpose} code for this subject`,
92
+ });
93
+ }
94
+ if (record.expiresAt <= now) {
95
+ await this.#store.consume(record.id, now);
96
+ throw new core_1.AuthenticationError('AUTH_OTP_EXPIRED', {
97
+ detail: `The ${purpose} code expired at ${new Date(record.expiresAt).toISOString()}`,
98
+ });
99
+ }
100
+ if (record.attempts >= this.#config.maxAttempts) {
101
+ // Burn it: an exhausted code must not become guessable again by waiting.
102
+ await this.#store.consume(record.id, now);
103
+ await this.#audit?.record({
104
+ name: 'otp.failed',
105
+ at: now,
106
+ meta: { purpose, reason: 'attempts-exhausted' },
107
+ });
108
+ throw new core_1.AuthenticationError('AUTH_OTP_INVALID', {
109
+ detail: `The ${purpose} code was tried ${String(record.attempts)} times and is now void`,
110
+ });
111
+ }
112
+ if (!(0, core_1.secureCompare)(record.hashedCode, this.#hash(subject, purpose, code))) {
113
+ await this.#store.recordAttempt(record.id, record.attempts + 1);
114
+ await this.#audit?.record({
115
+ name: 'otp.failed',
116
+ at: now,
117
+ meta: { purpose, reason: 'mismatch' },
118
+ });
119
+ throw new core_1.AuthenticationError('AUTH_OTP_INVALID', {
120
+ detail: `Incorrect ${purpose} code (attempt ${String(record.attempts + 1)})`,
121
+ });
122
+ }
123
+ await this.#store.consume(record.id, now);
124
+ }
125
+ /**
126
+ * The subject and purpose are bound into the HMAC, so a code issued for a
127
+ * password reset cannot be presented as a login code, and a code for one
128
+ * mailbox cannot be replayed against another.
129
+ */
130
+ #hash(subject, purpose, code) {
131
+ return (0, node_crypto_1.createHmac)('sha256', this.#pepper)
132
+ .update(`${purpose}:${subject}:${code}`, 'utf8')
133
+ .digest('base64url');
134
+ }
135
+ }
136
+ exports.OtpService = OtpService;
137
+ //# sourceMappingURL=otp.service.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Password hashing and policy (PLAN.md §12, §15.1).
3
+ *
4
+ * argon2id with the OWASP-recommended parameters (19 MiB, t=2, p=1), plus a
5
+ * **pepper**: the password is HMAC'd with a secret held outside the database
6
+ * before it is hashed. A dumped user table is then useless on its own, because
7
+ * the attacker is missing a key that never lived in it.
8
+ *
9
+ * The pepper is applied by HMAC rather than concatenation so that a password
10
+ * longer than the hash block size cannot dilute it, and so rotating to a second
11
+ * pepper is a matter of re-HMAC'ing at next login rather than a schema change.
12
+ */
13
+ import type { PasswordHasher } from './ports.js';
14
+ import type { ResolvedAuthConfig } from './config.js';
15
+ /** OWASP's second-choice argon2id profile: 19 MiB, two passes, one lane. */
16
+ export declare const ARGON2_PARAMETERS: {
17
+ readonly memoryCost: 19456;
18
+ readonly timeCost: 2;
19
+ readonly parallelism: 1;
20
+ };
21
+ export interface Argon2PasswordHasherOptions {
22
+ /** Secret held outside the database. Required — a missing one is a config bug. */
23
+ readonly pepper: string;
24
+ readonly memoryCost?: number;
25
+ readonly timeCost?: number;
26
+ readonly parallelism?: number;
27
+ }
28
+ export declare class Argon2PasswordHasher implements PasswordHasher {
29
+ #private;
30
+ constructor(options: Argon2PasswordHasherOptions);
31
+ hash(password: string): Promise<string>;
32
+ verify(hashed: string, password: string): Promise<boolean>;
33
+ needsRehash(hashed: string): boolean;
34
+ }
35
+ export interface Argon2Parameters {
36
+ readonly algorithm: string;
37
+ readonly memoryCost: number;
38
+ readonly timeCost: number;
39
+ readonly parallelism: number;
40
+ }
41
+ /** Read the cost parameters back out of an encoded hash. */
42
+ export declare function parseArgon2Parameters(hashed: string): Argon2Parameters | undefined;
43
+ /**
44
+ * Check a candidate password against the configured policy.
45
+ *
46
+ * Reported as field-level validation details rather than one opaque failure —
47
+ * "must contain a number" is actionable; "password is invalid" makes people
48
+ * pick `Password1!` and move on.
49
+ */
50
+ export declare function assertPasswordPolicy(password: string, policy: ResolvedAuthConfig['password'], field?: string): void;
51
+ //# sourceMappingURL=password.d.ts.map
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ /**
3
+ * Password hashing and policy (PLAN.md §12, §15.1).
4
+ *
5
+ * argon2id with the OWASP-recommended parameters (19 MiB, t=2, p=1), plus a
6
+ * **pepper**: the password is HMAC'd with a secret held outside the database
7
+ * before it is hashed. A dumped user table is then useless on its own, because
8
+ * the attacker is missing a key that never lived in it.
9
+ *
10
+ * The pepper is applied by HMAC rather than concatenation so that a password
11
+ * longer than the hash block size cannot dilute it, and so rotating to a second
12
+ * pepper is a matter of re-HMAC'ing at next login rather than a schema change.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.Argon2PasswordHasher = exports.ARGON2_PARAMETERS = void 0;
16
+ exports.parseArgon2Parameters = parseArgon2Parameters;
17
+ exports.assertPasswordPolicy = assertPasswordPolicy;
18
+ const node_crypto_1 = require("node:crypto");
19
+ const argon2_1 = require("@node-rs/argon2");
20
+ const core_1 = require("@nage-api/core");
21
+ /**
22
+ * `Algorithm.Argon2id` from `@node-rs/argon2` is an ambient `const enum`, which
23
+ * `isolatedModules` cannot read across a module boundary. The numeric value is
24
+ * part of the library's published API, so it is spelled out here rather than
25
+ * relaxing a compiler flag for the whole package.
26
+ */
27
+ const ARGON2ID = 2;
28
+ /** OWASP's second-choice argon2id profile: 19 MiB, two passes, one lane. */
29
+ exports.ARGON2_PARAMETERS = {
30
+ memoryCost: 19_456,
31
+ timeCost: 2,
32
+ parallelism: 1,
33
+ };
34
+ /** Anything shorter than this cannot carry enough entropy to be a pepper. */
35
+ const MIN_PEPPER_LENGTH = 16;
36
+ class Argon2PasswordHasher {
37
+ #pepper;
38
+ #memoryCost;
39
+ #timeCost;
40
+ #parallelism;
41
+ constructor(options) {
42
+ if (options.pepper.length < MIN_PEPPER_LENGTH) {
43
+ throw new core_1.ConfigurationError({
44
+ detail: `The password pepper must be at least ${String(MIN_PEPPER_LENGTH)} characters; it is the secret that makes a stolen user table useless`,
45
+ meta: { setting: 'PASSWORD_PEPPER', length: options.pepper.length },
46
+ });
47
+ }
48
+ this.#pepper = options.pepper;
49
+ this.#memoryCost = options.memoryCost ?? exports.ARGON2_PARAMETERS.memoryCost;
50
+ this.#timeCost = options.timeCost ?? exports.ARGON2_PARAMETERS.timeCost;
51
+ this.#parallelism = options.parallelism ?? exports.ARGON2_PARAMETERS.parallelism;
52
+ }
53
+ async hash(password) {
54
+ return (0, argon2_1.hash)(this.#peppered(password), {
55
+ algorithm: ARGON2ID,
56
+ memoryCost: this.#memoryCost,
57
+ timeCost: this.#timeCost,
58
+ parallelism: this.#parallelism,
59
+ });
60
+ }
61
+ async verify(hashed, password) {
62
+ try {
63
+ return await (0, argon2_1.verify)(hashed, this.#peppered(password));
64
+ }
65
+ catch {
66
+ // A malformed or foreign hash is a failed verification, not a 500: it is
67
+ // what a row written by another system looks like.
68
+ return false;
69
+ }
70
+ }
71
+ needsRehash(hashed) {
72
+ const parameters = parseArgon2Parameters(hashed);
73
+ if (parameters === undefined)
74
+ return true;
75
+ return (parameters.algorithm !== 'argon2id' ||
76
+ parameters.memoryCost < this.#memoryCost ||
77
+ parameters.timeCost < this.#timeCost);
78
+ }
79
+ #peppered(password) {
80
+ return (0, node_crypto_1.createHmac)('sha256', this.#pepper).update(password, 'utf8').digest('base64');
81
+ }
82
+ }
83
+ exports.Argon2PasswordHasher = Argon2PasswordHasher;
84
+ /** Read the cost parameters back out of an encoded hash. */
85
+ function parseArgon2Parameters(hashed) {
86
+ const match = /^\$(argon2(?:id|i|d))\$v=\d+\$m=(\d+),t=(\d+),p=(\d+)\$/.exec(hashed);
87
+ if (match === null)
88
+ return undefined;
89
+ return {
90
+ algorithm: match[1] ?? '',
91
+ memoryCost: Number(match[2]),
92
+ timeCost: Number(match[3]),
93
+ parallelism: Number(match[4]),
94
+ };
95
+ }
96
+ /**
97
+ * Check a candidate password against the configured policy.
98
+ *
99
+ * Reported as field-level validation details rather than one opaque failure —
100
+ * "must contain a number" is actionable; "password is invalid" makes people
101
+ * pick `Password1!` and move on.
102
+ */
103
+ function assertPasswordPolicy(password, policy, field = 'password') {
104
+ const failures = {};
105
+ if (password.length < policy.minLength) {
106
+ failures['minLength'] = `Must be at least ${String(policy.minLength)} characters.`;
107
+ }
108
+ if (policy.requireMixedCase && !(/[a-z]/.test(password) && /[A-Z]/.test(password))) {
109
+ failures['mixedCase'] = 'Must contain both upper and lower case letters.';
110
+ }
111
+ if (policy.requireNumber && !/\d/.test(password)) {
112
+ failures['number'] = 'Must contain a number.';
113
+ }
114
+ if (policy.requireSymbol && !/[^\p{L}\p{N}]/u.test(password)) {
115
+ failures['symbol'] = 'Must contain a symbol.';
116
+ }
117
+ if (Object.keys(failures).length > 0) {
118
+ const details = [{ field, constraints: failures }];
119
+ throw new core_1.ValidationError({ message: 'The password does not meet the policy.', details });
120
+ }
121
+ }
122
+ //# sourceMappingURL=password.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Policies — the opt-in ABAC layer over RBAC (PLAN.md §15.2).
3
+ *
4
+ * RBAC answers "may an editor publish?"; a policy answers "may *this* editor
5
+ * publish *this* article?". Rather than pulling in a rules engine, a policy is
6
+ * a named function of the principal and the request, registered at module
7
+ * setup. That keeps the decision auditable — every policy has a name that
8
+ * appears in the denial's log line — and typed.
9
+ */
10
+ import type { AuthUser } from '@nage-api/contracts';
11
+ import type { PolicyDecision } from '@nage-api/contracts';
12
+ /** What a policy is given. Deliberately not the raw Nest request object. */
13
+ export interface PolicyContext {
14
+ readonly user: AuthUser;
15
+ readonly method: string;
16
+ readonly path: string;
17
+ readonly params: Readonly<Record<string, string>>;
18
+ readonly query: Readonly<Record<string, unknown>>;
19
+ readonly body: unknown;
20
+ }
21
+ export type PolicyFunction = (context: PolicyContext) => PolicyDecision | Promise<PolicyDecision>;
22
+ /**
23
+ * The registered policies.
24
+ *
25
+ * An unknown policy name **denies**. A typo in `@Policy('article:pubish')`
26
+ * must not open a route; failing closed is the only safe reading of "I could
27
+ * not find the rule".
28
+ */
29
+ export declare class PolicyRegistry {
30
+ #private;
31
+ constructor(policies?: Readonly<Record<string, PolicyFunction>>);
32
+ register(name: string, policy: PolicyFunction): this;
33
+ has(name: string): boolean;
34
+ evaluate(name: string, context: PolicyContext): Promise<PolicyDecision>;
35
+ }
36
+ /**
37
+ * A policy that allows a request only when the principal owns the resource.
38
+ *
39
+ * The retained ownership-scoping pattern from `docs/service.md`, expressed as a
40
+ * first-class helper: `ownershipPolicy('id')` compares the route parameter with
41
+ * the caller's own id.
42
+ */
43
+ export declare function ownershipPolicy(parameter?: string): PolicyFunction;
44
+ //# sourceMappingURL=policy.d.ts.map
package/dist/policy.js ADDED
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ /**
3
+ * Policies — the opt-in ABAC layer over RBAC (PLAN.md §15.2).
4
+ *
5
+ * RBAC answers "may an editor publish?"; a policy answers "may *this* editor
6
+ * publish *this* article?". Rather than pulling in a rules engine, a policy is
7
+ * a named function of the principal and the request, registered at module
8
+ * setup. That keeps the decision auditable — every policy has a name that
9
+ * appears in the denial's log line — and typed.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.PolicyRegistry = void 0;
13
+ exports.ownershipPolicy = ownershipPolicy;
14
+ /**
15
+ * The registered policies.
16
+ *
17
+ * An unknown policy name **denies**. A typo in `@Policy('article:pubish')`
18
+ * must not open a route; failing closed is the only safe reading of "I could
19
+ * not find the rule".
20
+ */
21
+ class PolicyRegistry {
22
+ #policies = new Map();
23
+ constructor(policies = {}) {
24
+ for (const [name, policy] of Object.entries(policies))
25
+ this.register(name, policy);
26
+ }
27
+ register(name, policy) {
28
+ this.#policies.set(name, policy);
29
+ return this;
30
+ }
31
+ has(name) {
32
+ return this.#policies.has(name);
33
+ }
34
+ async evaluate(name, context) {
35
+ const policy = this.#policies.get(name);
36
+ if (policy === undefined) {
37
+ return { allowed: false, reason: `No policy named "${name}" is registered` };
38
+ }
39
+ return policy(context);
40
+ }
41
+ }
42
+ exports.PolicyRegistry = PolicyRegistry;
43
+ /**
44
+ * A policy that allows a request only when the principal owns the resource.
45
+ *
46
+ * The retained ownership-scoping pattern from `docs/service.md`, expressed as a
47
+ * first-class helper: `ownershipPolicy('id')` compares the route parameter with
48
+ * the caller's own id.
49
+ */
50
+ function ownershipPolicy(parameter = 'id') {
51
+ return ({ user, params }) => {
52
+ const target = params[parameter];
53
+ if (target === undefined) {
54
+ return { allowed: false, reason: `Route has no :${parameter} to check ownership against` };
55
+ }
56
+ return target === String(user.id)
57
+ ? { allowed: true }
58
+ : { allowed: false, reason: 'The resource belongs to another user' };
59
+ };
60
+ }
61
+ //# sourceMappingURL=policy.js.map
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The ports `@nage-api/auth` needs and does not implement (PLAN.md §15.1).
3
+ *
4
+ * A feature package may not import another feature package (§7.2), so auth
5
+ * cannot reach for `@nage-api/data` to persist sessions. Instead it declares the
6
+ * narrow stores it needs; a generated app binds them to its own driver in one
7
+ * `forRoot` call, and the in-memory reference implementations in this package
8
+ * make that binding testable before any database exists.
9
+ *
10
+ * Every port is deliberately small: a store that only knows how to find, insert
11
+ * and revoke cannot be the place a subtle authorization bug hides.
12
+ */
13
+ import type { AuthUser, Id, Permission, RoleName, SessionRecord } from '@nage-api/contracts';
14
+ /** What the framework needs to know about a user in order to authenticate one. */
15
+ export interface AuthUserRecord<TRole extends string = RoleName> {
16
+ readonly id: Id;
17
+ readonly email: string;
18
+ /** Encoded hash, including its parameters. Never a plaintext password. */
19
+ readonly passwordHash?: string;
20
+ readonly roles: readonly TRole[];
21
+ /** Granted directly, on top of whatever the roles imply. */
22
+ readonly permissions?: readonly Permission[];
23
+ /** A disabled account authenticates as nobody, whatever its credentials. */
24
+ readonly disabled?: boolean;
25
+ readonly tenantId?: Id;
26
+ }
27
+ /**
28
+ * Lookup of the principal. Read-only except for the password, because auth owns
29
+ * credentials and the application owns everything else about a user.
30
+ */
31
+ export interface AuthUserStore<TRole extends string = RoleName> {
32
+ findById(id: Id): Promise<AuthUserRecord<TRole> | undefined>;
33
+ /** Case-insensitive by convention; the caller normalises before storing. */
34
+ findByEmail(email: string): Promise<AuthUserRecord<TRole> | undefined>;
35
+ setPasswordHash(id: Id, hash: string): Promise<void>;
36
+ }
37
+ /**
38
+ * Refresh-session persistence.
39
+ *
40
+ * Sessions are looked up **by token hash**, never by a client-supplied id: the
41
+ * hash is the only thing that proves possession, and a lookup by id would let a
42
+ * caller name a session they do not hold.
43
+ */
44
+ export interface SessionStore {
45
+ create(session: SessionRecord): Promise<void>;
46
+ findByHashedToken(hashedToken: string): Promise<SessionRecord | undefined>;
47
+ /** Mark a session rotated and point at its successor. */
48
+ markRotated(id: string, rotatedAt: number): Promise<void>;
49
+ revoke(id: string, revokedAt: number): Promise<void>;
50
+ /** Revoke every session in a family — the reuse-detection response. */
51
+ revokeFamily(familyId: string, revokedAt: number): Promise<number>;
52
+ /**
53
+ * Whether a family has been revoked.
54
+ *
55
+ * The access token carries the **family** id, so this is what makes a logout
56
+ * take effect immediately instead of at the end of the access token's life.
57
+ */
58
+ isFamilyRevoked(familyId: string): Promise<boolean>;
59
+ /** Revoke everything a user holds, e.g. after a password reset. */
60
+ revokeAllForUser(userId: Id, revokedAt: number): Promise<number>;
61
+ /** Housekeeping; expired rows are useless and grow forever without it. */
62
+ deleteExpired(now: number): Promise<number>;
63
+ }
64
+ /** A one-time code, stored hashed and consumed on first correct use. */
65
+ export interface OtpRecord {
66
+ readonly id: string;
67
+ readonly subject: string;
68
+ readonly purpose: OtpPurpose;
69
+ readonly hashedCode: string;
70
+ readonly issuedAt: number;
71
+ readonly expiresAt: number;
72
+ readonly attempts: number;
73
+ readonly consumedAt?: number;
74
+ }
75
+ export type OtpPurpose = 'login' | 'password-reset' | 'verify-contact';
76
+ export interface OtpStore {
77
+ create(record: OtpRecord): Promise<void>;
78
+ /** The newest unconsumed code for this subject and purpose. */
79
+ findActive(subject: string, purpose: OtpPurpose): Promise<OtpRecord | undefined>;
80
+ recordAttempt(id: string, attempts: number): Promise<void>;
81
+ consume(id: string, consumedAt: number): Promise<void>;
82
+ /** Invalidate outstanding codes when a new one is issued. */
83
+ invalidate(subject: string, purpose: OtpPurpose): Promise<number>;
84
+ }
85
+ /** A hashed, scoped machine credential (PLAN.md §15.1). */
86
+ export interface ApiKeyRecord {
87
+ readonly id: string;
88
+ /** Public, non-secret prefix used to find the row before verifying it. */
89
+ readonly prefix: string;
90
+ readonly hashedKey: string;
91
+ readonly name: string;
92
+ /** Acts as this user; a key with no user is a service principal. */
93
+ readonly userId?: Id;
94
+ readonly roles: readonly string[];
95
+ readonly permissions: readonly Permission[];
96
+ readonly createdAt: number;
97
+ readonly expiresAt?: number;
98
+ readonly revokedAt?: number;
99
+ readonly lastUsedAt?: number;
100
+ }
101
+ export interface ApiKeyStore {
102
+ create(record: ApiKeyRecord): Promise<void>;
103
+ findByPrefix(prefix: string): Promise<ApiKeyRecord | undefined>;
104
+ touch(id: string, usedAt: number): Promise<void>;
105
+ revoke(id: string, revokedAt: number): Promise<void>;
106
+ }
107
+ /** Failed-attempt counting behind login and OTP lockout. */
108
+ export interface LockoutRecord {
109
+ readonly key: string;
110
+ readonly failures: number;
111
+ readonly firstFailureAt: number;
112
+ readonly lockedUntil?: number;
113
+ }
114
+ export interface LockoutStore {
115
+ get(key: string): Promise<LockoutRecord | undefined>;
116
+ set(record: LockoutRecord): Promise<void>;
117
+ clear(key: string): Promise<void>;
118
+ }
119
+ /**
120
+ * Where OTPs actually go.
121
+ *
122
+ * Auth generates and verifies codes; it does not know how to send an email.
123
+ * `@nage-api/notify` binds this in Phase 8; until then an application supplies its
124
+ * own, and the in-memory one makes the flow testable.
125
+ */
126
+ export interface OtpChannel {
127
+ send(input: {
128
+ subject: string;
129
+ code: string;
130
+ purpose: OtpPurpose;
131
+ }): Promise<void>;
132
+ }
133
+ /** Password hashing, so the algorithm is a swap rather than a rewrite. */
134
+ export interface PasswordHasher {
135
+ hash(password: string): Promise<string>;
136
+ /** Constant-time by contract; implementations must not short-circuit. */
137
+ verify(hash: string, password: string): Promise<boolean>;
138
+ /** True when the stored hash used weaker parameters than the current policy. */
139
+ needsRehash(hash: string): boolean;
140
+ }
141
+ /** Signing and verification of access tokens. */
142
+ export interface TokenSigner {
143
+ sign(claims: Record<string, unknown>, options: {
144
+ expiresInSeconds: number;
145
+ }): Promise<string>;
146
+ /** Rejects on expiry, bad signature, wrong issuer or wrong audience. */
147
+ verify(token: string): Promise<Record<string, unknown>>;
148
+ }
149
+ /**
150
+ * Auth events worth keeping (PLAN.md §15.1).
151
+ *
152
+ * A sink rather than a logger call: a login failure is a security event that
153
+ * many deployments route to a SIEM, and it should not be discoverable only by
154
+ * grepping stdout.
155
+ */
156
+ export type AuthEventName = 'login.succeeded' | 'login.failed' | 'login.locked' | 'token.refreshed' | 'token.reuse-detected' | 'session.revoked' | 'password.reset' | 'otp.sent' | 'otp.failed' | 'api-key.used' | 'impersonation.started';
157
+ export interface AuthEvent {
158
+ readonly name: AuthEventName;
159
+ readonly at: number;
160
+ readonly userId?: Id;
161
+ readonly sessionId?: string;
162
+ /** Never contains a credential, a token or a code. */
163
+ readonly meta?: Readonly<Record<string, unknown>>;
164
+ }
165
+ export interface AuthAuditSink {
166
+ record(event: AuthEvent): void | Promise<void>;
167
+ }
168
+ /** Injected so token expiry and lockout windows are testable without waiting. */
169
+ export interface Clock {
170
+ now(): number;
171
+ }
172
+ export declare const systemClock: Clock;
173
+ /** Resolved principal, as the guards put it into the request context. */
174
+ export type ResolvedPrincipal<TRole extends string = RoleName> = AuthUser<TRole>;
175
+ //# sourceMappingURL=ports.d.ts.map
package/dist/ports.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ /**
3
+ * The ports `@nage-api/auth` needs and does not implement (PLAN.md §15.1).
4
+ *
5
+ * A feature package may not import another feature package (§7.2), so auth
6
+ * cannot reach for `@nage-api/data` to persist sessions. Instead it declares the
7
+ * narrow stores it needs; a generated app binds them to its own driver in one
8
+ * `forRoot` call, and the in-memory reference implementations in this package
9
+ * make that binding testable before any database exists.
10
+ *
11
+ * Every port is deliberately small: a store that only knows how to find, insert
12
+ * and revoke cannot be the place a subtle authorization bug hides.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.systemClock = void 0;
16
+ exports.systemClock = { now: () => Date.now() };
17
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Turning a verified token into the principal the request runs as
3
+ * (PLAN.md §15.1).
4
+ *
5
+ * Two decisions worth stating, because both are places auth packages commonly
6
+ * go wrong:
7
+ *
8
+ * **Roles come from the store, not from the token.** A signed token proves who
9
+ * the caller is; it does not prove what they may still do. Trusting its `roles`
10
+ * claim means a demotion or a disabled account keeps working until the token
11
+ * expires. Claims are carried for observability and compared, not obeyed.
12
+ *
13
+ * **A revoked session stops working at once.** The token carries the session
14
+ * *family* id, and a revoked family is refused — so logging out actually logs
15
+ * you out, rather than leaving a 15-minute window in which the access token
16
+ * still works.
17
+ *
18
+ * Both cost a lookup per request, so both are memoised behind a short TTL. The
19
+ * TTL is the tunable: a few seconds of staleness in exchange for not hitting
20
+ * the database on every call.
21
+ */
22
+ import type { AuthUser, Permission, RoleMatrix } from '@nage-api/contracts';
23
+ import type { AuthUserStore, Clock, SessionStore } from './ports.js';
24
+ export interface PrincipalResolverOptions {
25
+ readonly users: AuthUserStore;
26
+ readonly sessions: SessionStore;
27
+ readonly roles: RoleMatrix;
28
+ /** How long a resolved principal may be reused. 0 disables caching. */
29
+ readonly cacheTtlMs?: number;
30
+ /** Bound on the cache, so a token flood cannot grow it without limit. */
31
+ readonly cacheMaxEntries?: number;
32
+ readonly clock?: Clock;
33
+ }
34
+ export interface ResolveInput {
35
+ readonly userId: string;
36
+ /** Session family id, taken from the token's `sid` claim. */
37
+ readonly sessionId: string;
38
+ readonly claimedRoles: readonly string[];
39
+ readonly claimedPermissions: readonly Permission[];
40
+ readonly tenantId?: string;
41
+ readonly impersonatedBy?: string;
42
+ }
43
+ export declare class PrincipalResolver {
44
+ #private;
45
+ constructor(options: PrincipalResolverOptions);
46
+ resolve(input: ResolveInput): Promise<AuthUser>;
47
+ /** Drop a user's cached record — call after a role or status change. */
48
+ invalidate(userId: string): void;
49
+ /** Drop everything; used by tests and by an administrative refresh. */
50
+ clear(): void;
51
+ }
52
+ //# sourceMappingURL=principal.resolver.d.ts.map