@kb-labs/gateway-auth 2.94.0 → 2.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +974 -1
- package/dist/index.js +1503 -13
- package/dist/index.js.map +1 -1
- package/package.json +12 -7
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { ICache } from '@kb-labs/core-platform';
|
|
2
2
|
import { TokenType, JwtPayload, RegisterRequest, RegisterResponse, TokenResponse, AuthContext } from '@kb-labs/gateway-contracts';
|
|
3
|
+
import { IDocumentDatabase, IKVStore } from '@kb-labs/core-platform/adapters';
|
|
4
|
+
import { IdentityUserPort, IdentityCredentialPort, IIdentityProvider, IdentityProviderDeps, IRedirectIdentityProvider, IdentityProviderFactory, IPolicyDecisionPoint } from '@kb-labs/core-contracts';
|
|
5
|
+
export { IPolicyDecisionPoint, Identity, PolicyContext, PolicyDecision, Resource } from '@kb-labs/core-contracts';
|
|
6
|
+
import { z } from 'zod';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* JWT sign / verify / refresh logic.
|
|
@@ -15,6 +19,8 @@ interface SignAccessTokenOptions {
|
|
|
15
19
|
namespaceId: string;
|
|
16
20
|
tier: 'free' | 'pro' | 'enterprise';
|
|
17
21
|
type: TokenType;
|
|
22
|
+
/** Permissions to embed in the JWT payload. Defaults to [] when absent. */
|
|
23
|
+
permissions?: string[];
|
|
18
24
|
}
|
|
19
25
|
declare function signAccessToken(opts: SignAccessTokenOptions, config: JwtConfig): Promise<{
|
|
20
26
|
token: string;
|
|
@@ -25,6 +31,52 @@ declare function verifyAccessToken(token: string, config: JwtConfig): Promise<Jw
|
|
|
25
31
|
declare function verifyRefreshToken(token: string, config: JwtConfig): Promise<{
|
|
26
32
|
hostId: string;
|
|
27
33
|
} | null>;
|
|
34
|
+
interface SignUserTokenOptions {
|
|
35
|
+
userId: string;
|
|
36
|
+
tenantId: string;
|
|
37
|
+
familyId: string;
|
|
38
|
+
/** TTL in seconds. */
|
|
39
|
+
ttlSec: number;
|
|
40
|
+
}
|
|
41
|
+
interface UserAccessPayload {
|
|
42
|
+
userId: string;
|
|
43
|
+
tenantId: string;
|
|
44
|
+
familyId: string;
|
|
45
|
+
jti: string;
|
|
46
|
+
/** Token type discriminator. Access tokens carry 'user'. */
|
|
47
|
+
type: 'user';
|
|
48
|
+
iat: number;
|
|
49
|
+
exp: number;
|
|
50
|
+
}
|
|
51
|
+
interface UserRefreshPayload {
|
|
52
|
+
userId: string;
|
|
53
|
+
tenantId: string;
|
|
54
|
+
familyId: string;
|
|
55
|
+
jti: string;
|
|
56
|
+
type: 'refresh';
|
|
57
|
+
iat: number;
|
|
58
|
+
exp: number;
|
|
59
|
+
}
|
|
60
|
+
interface UserAccessSignResult {
|
|
61
|
+
token: string;
|
|
62
|
+
expiresInSec: number;
|
|
63
|
+
jti: string;
|
|
64
|
+
}
|
|
65
|
+
declare function signUserAccessToken(opts: SignUserTokenOptions, config: JwtConfig): Promise<UserAccessSignResult>;
|
|
66
|
+
/**
|
|
67
|
+
* Sign a user refresh token.
|
|
68
|
+
*
|
|
69
|
+
* When the caller already owns the `jti` (typical: the sessions-store
|
|
70
|
+
* minted it inside `createSession`/`rotateRefresh`), pass it via the
|
|
71
|
+
* second argument so the JWT and the store agree on the identifier.
|
|
72
|
+
* Otherwise the helper generates one — used by tests that don't go
|
|
73
|
+
* through sessions-store.
|
|
74
|
+
*/
|
|
75
|
+
declare function signUserRefreshToken(opts: SignUserTokenOptions & {
|
|
76
|
+
jti?: string;
|
|
77
|
+
}, config: JwtConfig): Promise<string>;
|
|
78
|
+
declare function verifyUserAccessToken(token: string, config: JwtConfig): Promise<UserAccessPayload | null>;
|
|
79
|
+
declare function verifyUserRefreshToken(token: string, config: JwtConfig): Promise<UserRefreshPayload | null>;
|
|
28
80
|
|
|
29
81
|
/**
|
|
30
82
|
* AuthService — orchestrates registration, token issuance, and refresh.
|
|
@@ -40,6 +92,11 @@ declare class AuthService {
|
|
|
40
92
|
}>;
|
|
41
93
|
issueTokens(clientId: string, clientSecret: string): Promise<TokenResponse | null>;
|
|
42
94
|
refreshTokens(refreshToken: string): Promise<TokenResponse | null>;
|
|
95
|
+
me(hostId: string): Promise<{
|
|
96
|
+
hostId: string;
|
|
97
|
+
handle?: string;
|
|
98
|
+
namespaceId: string;
|
|
99
|
+
} | null>;
|
|
43
100
|
verify(token: string): Promise<AuthContext | null>;
|
|
44
101
|
}
|
|
45
102
|
|
|
@@ -50,6 +107,7 @@ declare class AuthService {
|
|
|
50
107
|
* auth:client:{clientId} → ClientRecord (permanent)
|
|
51
108
|
* auth:refresh:{tokenHash} → { hostId } (TTL 30d)
|
|
52
109
|
* auth:publickey:{hostId} → string (base64url X25519 public key)
|
|
110
|
+
* auth:handle:{handle} → clientId (permanent, handle index)
|
|
53
111
|
*/
|
|
54
112
|
|
|
55
113
|
interface ClientRecord {
|
|
@@ -61,22 +119,32 @@ interface ClientRecord {
|
|
|
61
119
|
tier: 'free' | 'pro' | 'enterprise';
|
|
62
120
|
name: string;
|
|
63
121
|
capabilities: string[];
|
|
122
|
+
/** Permissions embedded in issued JWTs. If absent, defaults to ['host:connect']. */
|
|
123
|
+
permissions?: string[];
|
|
64
124
|
publicKey?: string;
|
|
65
125
|
createdAt: number;
|
|
126
|
+
/** Unique human-readable handle for the marketplace (e.g. "kirill"). Immutable once set. */
|
|
127
|
+
handle?: string;
|
|
128
|
+
email?: string;
|
|
66
129
|
}
|
|
67
130
|
declare function generateClientId(): string;
|
|
68
131
|
declare function generateClientSecret(): string;
|
|
69
132
|
declare function generateHostId(): string;
|
|
70
133
|
declare function saveClient(cache: ICache, record: ClientRecord): Promise<void>;
|
|
134
|
+
declare function isHandleTaken(cache: ICache, handle: string): Promise<boolean>;
|
|
135
|
+
declare function getClientByHandle(cache: ICache, handle: string): Promise<ClientRecord | null>;
|
|
71
136
|
declare function getClientByHostId(cache: ICache, hostId: string): Promise<ClientRecord | null>;
|
|
72
137
|
declare function getClient(cache: ICache, clientId: string): Promise<ClientRecord | null>;
|
|
73
138
|
declare function verifyClientSecret(cache: ICache, clientId: string, secret: string): Promise<ClientRecord | null>;
|
|
74
139
|
declare function buildClientRecord(opts: {
|
|
75
140
|
name: string;
|
|
76
141
|
capabilities: string[];
|
|
142
|
+
permissions?: string[];
|
|
77
143
|
publicKey?: string;
|
|
78
144
|
secret: string;
|
|
79
145
|
namespaceId?: string;
|
|
146
|
+
handle?: string;
|
|
147
|
+
email?: string;
|
|
80
148
|
}): ClientRecord;
|
|
81
149
|
declare function saveRefreshToken(cache: ICache, token: string, hostId: string, namespaceId: string): Promise<void>;
|
|
82
150
|
declare function consumeRefreshToken(cache: ICache, token: string): Promise<{
|
|
@@ -86,4 +154,909 @@ declare function consumeRefreshToken(cache: ICache, token: string): Promise<{
|
|
|
86
154
|
declare function savePublicKey(cache: ICache, hostId: string, publicKey: string): Promise<void>;
|
|
87
155
|
declare function getPublicKey(cache: ICache, hostId: string): Promise<string | null>;
|
|
88
156
|
|
|
89
|
-
|
|
157
|
+
/**
|
|
158
|
+
* @module @kb-labs/gateway-auth/users-store
|
|
159
|
+
*
|
|
160
|
+
* Document-backed store for `User` records (ADR-0020, Phase 1.1).
|
|
161
|
+
*
|
|
162
|
+
* Schema invariants:
|
|
163
|
+
* - `(tenantId, email)` is a unique compound — one user per email per
|
|
164
|
+
* tenant. The same email can exist in different tenants.
|
|
165
|
+
* - `email` is **always** stored lowercased + trimmed (CD-4). Callers
|
|
166
|
+
* may pass any casing; the store normalises before write/read.
|
|
167
|
+
* - **No** password hash on this document. Credentials live in the
|
|
168
|
+
* separate `credentials` collection (CD-6) so a future Google/Okta
|
|
169
|
+
* provider can attach to the same `User` without a schema migration.
|
|
170
|
+
*
|
|
171
|
+
* Pattern follows `plugins/gateway/core/src/stores/host-store.ts`:
|
|
172
|
+
* one-shot `ensureSchema()` plus per-method idempotent `ensureCollection`
|
|
173
|
+
* registration.
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
type UserStatus = 'pending' | 'active' | 'disabled';
|
|
177
|
+
interface User {
|
|
178
|
+
userId: string;
|
|
179
|
+
tenantId: string;
|
|
180
|
+
/** Lowercase + trimmed (CD-4). */
|
|
181
|
+
email: string;
|
|
182
|
+
displayName?: string;
|
|
183
|
+
status: UserStatus;
|
|
184
|
+
createdAt: number;
|
|
185
|
+
updatedAt?: number;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Canonical email form used everywhere this store touches.
|
|
189
|
+
*
|
|
190
|
+
* Exported so other modules (the email-password provider, invites-store)
|
|
191
|
+
* normalise identically — having one function prevents drift where one
|
|
192
|
+
* caller forgets to trim and lookups silently miss.
|
|
193
|
+
*/
|
|
194
|
+
declare const canonicalizeEmail: (raw: string) => string;
|
|
195
|
+
interface CreateUserInput {
|
|
196
|
+
userId: string;
|
|
197
|
+
tenantId: string;
|
|
198
|
+
email: string;
|
|
199
|
+
status: UserStatus;
|
|
200
|
+
displayName?: string;
|
|
201
|
+
}
|
|
202
|
+
declare class UsersStore {
|
|
203
|
+
private readonly docs;
|
|
204
|
+
private initialised;
|
|
205
|
+
constructor(docs: IDocumentDatabase);
|
|
206
|
+
private ensureSchema;
|
|
207
|
+
create(input: CreateUserInput): Promise<User>;
|
|
208
|
+
getById(userId: string): Promise<User | null>;
|
|
209
|
+
findByEmailTenant(email: string, tenantId: string): Promise<User | null>;
|
|
210
|
+
setStatus(userId: string, status: UserStatus): Promise<void>;
|
|
211
|
+
/** List all users for a given tenant. */
|
|
212
|
+
listByTenant(tenantId: string): Promise<User[]>;
|
|
213
|
+
delete(userId: string): Promise<void>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @module @kb-labs/gateway-auth/credentials-store
|
|
218
|
+
*
|
|
219
|
+
* Document-backed store for per-provider credentials (ADR-0020, CD-6,
|
|
220
|
+
* Phase 1.2).
|
|
221
|
+
*
|
|
222
|
+
* `User` does not carry a password hash. Credentials live here, keyed by
|
|
223
|
+
* the compound `(userId, providerId)`. Adding a new identity provider
|
|
224
|
+
* (Google, Okta, LDAP, ...) means writing a row with a new `providerId`
|
|
225
|
+
* — no schema migration on the `users` collection, no `passwordHash?:
|
|
226
|
+
* null` quirks.
|
|
227
|
+
*
|
|
228
|
+
* Hash format and algorithm are the provider's concern: this store only
|
|
229
|
+
* persists opaque strings. The `email-password` provider chooses bcrypt
|
|
230
|
+
* with a configured cost.
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
interface Credential {
|
|
234
|
+
userId: string;
|
|
235
|
+
providerId: string;
|
|
236
|
+
/** Opaque to this store — bcrypt hash for email-password, OAuth refresh blob for future providers, etc. */
|
|
237
|
+
hash: string;
|
|
238
|
+
createdAt: number;
|
|
239
|
+
updatedAt?: number;
|
|
240
|
+
}
|
|
241
|
+
interface SetCredentialInput {
|
|
242
|
+
userId: string;
|
|
243
|
+
providerId: string;
|
|
244
|
+
hash: string;
|
|
245
|
+
}
|
|
246
|
+
declare class CredentialsStore {
|
|
247
|
+
private readonly docs;
|
|
248
|
+
private initialised;
|
|
249
|
+
constructor(docs: IDocumentDatabase);
|
|
250
|
+
private ensureSchema;
|
|
251
|
+
setCredential(input: SetCredentialInput): Promise<void>;
|
|
252
|
+
getCredential(userId: string, providerId: string): Promise<Credential | null>;
|
|
253
|
+
deleteCredential(userId: string, providerId: string): Promise<void>;
|
|
254
|
+
/**
|
|
255
|
+
* Cascade removal helper — call from the parent flow when a `User` is
|
|
256
|
+
* deleted so we never leave dangling credentials in the store.
|
|
257
|
+
*/
|
|
258
|
+
deleteAllForUser(userId: string): Promise<void>;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @module @kb-labs/gateway-auth/memberships-store
|
|
263
|
+
*
|
|
264
|
+
* Document-backed store for `Membership` records (ADR-0020, Phase 1.3).
|
|
265
|
+
*
|
|
266
|
+
* A membership is one row per `(userId, tenantId)` with a single
|
|
267
|
+
* `groupId`. Group strings are hardcoded for this iteration —
|
|
268
|
+
* `tenant-admin` and `tenant-member` — and consumed by the stub PDP.
|
|
269
|
+
*
|
|
270
|
+
* When the real RBAC + ReBAC engine lands (ClickUp 869def338) the
|
|
271
|
+
* meaning of `groupId` becomes richer (multiple group memberships,
|
|
272
|
+
* group hierarchies, relations) but the store API stays — the engine
|
|
273
|
+
* sits behind the PDP and reads from here. The "one membership per
|
|
274
|
+
* (userId, tenantId)" invariant may need to flex later; for now we keep
|
|
275
|
+
* it tight so the stub PDP has unambiguous lookups.
|
|
276
|
+
*/
|
|
277
|
+
|
|
278
|
+
/** Hardcoded group ids for the stub PDP iteration. */
|
|
279
|
+
type GroupId = 'tenant-admin' | 'tenant-member';
|
|
280
|
+
interface Membership {
|
|
281
|
+
userId: string;
|
|
282
|
+
tenantId: string;
|
|
283
|
+
groupId: GroupId;
|
|
284
|
+
createdAt: number;
|
|
285
|
+
updatedAt?: number;
|
|
286
|
+
}
|
|
287
|
+
interface AddMembershipInput {
|
|
288
|
+
userId: string;
|
|
289
|
+
tenantId: string;
|
|
290
|
+
groupId: GroupId;
|
|
291
|
+
}
|
|
292
|
+
declare class MembershipsStore {
|
|
293
|
+
private readonly docs;
|
|
294
|
+
private initialised;
|
|
295
|
+
constructor(docs: IDocumentDatabase);
|
|
296
|
+
private ensureSchema;
|
|
297
|
+
addMembership(input: AddMembershipInput): Promise<void>;
|
|
298
|
+
setGroup(userId: string, tenantId: string, groupId: GroupId): Promise<void>;
|
|
299
|
+
listByUser(userId: string): Promise<Membership[]>;
|
|
300
|
+
listByTenant(tenantId: string): Promise<Membership[]>;
|
|
301
|
+
removeMembership(userId: string, tenantId: string): Promise<void>;
|
|
302
|
+
/** Cascade hook — call when a `User` is deleted. */
|
|
303
|
+
removeAllForUser(userId: string): Promise<void>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @module @kb-labs/gateway-auth/invites-store
|
|
308
|
+
*
|
|
309
|
+
* Document-backed store for activation invites (ADR-0020, Phase 1.4).
|
|
310
|
+
*
|
|
311
|
+
* Invites are admin-issued tokens that bootstrap a user account: the
|
|
312
|
+
* recipient activates by POSTing the token along with a password.
|
|
313
|
+
*
|
|
314
|
+
* Security details worth keeping straight:
|
|
315
|
+
*
|
|
316
|
+
* - Token plaintext **never** lands in the database. We store
|
|
317
|
+
* `tokenHash = sha256(plain)` and look up by hash. A snapshot of the
|
|
318
|
+
* `invites` collection is useless without the original tokens.
|
|
319
|
+
* - The unique compound `(tenantId, email, status='active')` is not
|
|
320
|
+
* easy to express across drivers, so uniqueness is enforced in code
|
|
321
|
+
* on top of a non-unique compound index. Concurrent races during
|
|
322
|
+
* `createInvite` are not a real attack vector here (admin UI is
|
|
323
|
+
* single-clicked) and we accept the small theoretical window.
|
|
324
|
+
* - TTL is best-effort on the index; **every read path** explicitly
|
|
325
|
+
* checks `expiresAt > now` (CD-9). Without that, a not-yet-swept row
|
|
326
|
+
* would be returned and the activation flow would accept it.
|
|
327
|
+
* - Emails are canonicalised on the way in (CD-4) so admins can paste
|
|
328
|
+
* "Alice@Example.COM" without breaking lookups.
|
|
329
|
+
*/
|
|
330
|
+
|
|
331
|
+
type InviteStatus = 'active' | 'used' | 'revoked';
|
|
332
|
+
interface InviteRecord {
|
|
333
|
+
inviteId: string;
|
|
334
|
+
email: string;
|
|
335
|
+
tenantId: string;
|
|
336
|
+
groupId: GroupId;
|
|
337
|
+
status: InviteStatus;
|
|
338
|
+
createdBy: string;
|
|
339
|
+
createdAt: number;
|
|
340
|
+
expiresAt: number;
|
|
341
|
+
updatedAt?: number;
|
|
342
|
+
}
|
|
343
|
+
interface CreateInviteInput {
|
|
344
|
+
email: string;
|
|
345
|
+
tenantId: string;
|
|
346
|
+
groupId: GroupId;
|
|
347
|
+
createdBy: string;
|
|
348
|
+
ttlMs: number;
|
|
349
|
+
}
|
|
350
|
+
interface CreateInviteResult {
|
|
351
|
+
inviteId: string;
|
|
352
|
+
activationToken: string;
|
|
353
|
+
expiresAt: number;
|
|
354
|
+
}
|
|
355
|
+
declare class InvitesStore {
|
|
356
|
+
private readonly docs;
|
|
357
|
+
private readonly now;
|
|
358
|
+
private initialised;
|
|
359
|
+
constructor(docs: IDocumentDatabase, now?: () => number);
|
|
360
|
+
private ensureSchema;
|
|
361
|
+
createInvite(input: CreateInviteInput): Promise<CreateInviteResult>;
|
|
362
|
+
findById(inviteId: string): Promise<InviteRecord | null>;
|
|
363
|
+
/**
|
|
364
|
+
* Look up an invite by the plaintext activation token. Returns `null`
|
|
365
|
+
* for missing, expired, used, or revoked invites — callers do not
|
|
366
|
+
* need to filter further.
|
|
367
|
+
*
|
|
368
|
+
* Returns a discriminated result so callers can distinguish:
|
|
369
|
+
* - { kind: 'not_found' } — token hash not in DB (garbage/unknown token)
|
|
370
|
+
* - { kind: 'invalid' } — token exists but expired, used, or revoked
|
|
371
|
+
* - { kind: 'ok', invite } — valid, active invite ready for activation
|
|
372
|
+
*/
|
|
373
|
+
findByToken(plainToken: string): Promise<{
|
|
374
|
+
kind: 'not_found';
|
|
375
|
+
} | {
|
|
376
|
+
kind: 'invalid';
|
|
377
|
+
} | {
|
|
378
|
+
kind: 'ok';
|
|
379
|
+
invite: InviteRecord;
|
|
380
|
+
}>;
|
|
381
|
+
/**
|
|
382
|
+
* Atomically mark an active invite as used.
|
|
383
|
+
*
|
|
384
|
+
* Returns `true` if THIS call flipped the invite from `active` → `used`,
|
|
385
|
+
* `false` if it was already used/revoked (or unknown). The filter pins
|
|
386
|
+
* `status: 'active'`, so a concurrent double-activation of the same token
|
|
387
|
+
* results in exactly one caller seeing `true` — the activation flow relies
|
|
388
|
+
* on this to close the TOCTOU window between `findByToken` and account
|
|
389
|
+
* creation (a second parallel request must NOT create a duplicate account).
|
|
390
|
+
*/
|
|
391
|
+
consume(inviteId: string): Promise<boolean>;
|
|
392
|
+
revoke(inviteId: string): Promise<void>;
|
|
393
|
+
/** List all invites for a tenant (active + used + revoked). */
|
|
394
|
+
listByTenant(tenantId: string): Promise<InviteRecord[]>;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @module @kb-labs/gateway-auth/sessions-store
|
|
399
|
+
*
|
|
400
|
+
* Session families + refresh tokens (ADR-0020, Phase 1.5).
|
|
401
|
+
*
|
|
402
|
+
* This module owns three things:
|
|
403
|
+
*
|
|
404
|
+
* 1. **Session families.** One row per device. The family is the unit
|
|
405
|
+
* of revocation — "log out this device", "kick this user from all
|
|
406
|
+
* devices", "the password just changed, keep the current device".
|
|
407
|
+
* 2. **Refresh tokens.** One-shot rotation: every refresh request
|
|
408
|
+
* consumes the old jti and issues a new one in the same family.
|
|
409
|
+
* 3. **Reuse detection + 5-second grace window (CD-5).** Reusing an
|
|
410
|
+
* already-consumed refresh is either a stolen cookie or a legit
|
|
411
|
+
* multi-tab race. The grace window resolves the ambiguity: if the
|
|
412
|
+
* client presents the same already-consumed jti within 5s, we
|
|
413
|
+
* return the same replacement we issued first time. Beyond 5s, we
|
|
414
|
+
* treat it as theft and kill the entire family.
|
|
415
|
+
*
|
|
416
|
+
* Notes on storage:
|
|
417
|
+
*
|
|
418
|
+
* - Refresh tokens carry an explicit `expiresAt` and we check it
|
|
419
|
+
* manually on every read (CD-9) — the TTL index sweep is best-effort.
|
|
420
|
+
* - `rotateRefresh` runs inside a `transaction(cb)` so a concurrent
|
|
421
|
+
* second caller sees the consumed state and goes through the
|
|
422
|
+
* grace/reuse branch instead of inserting a parallel replacement.
|
|
423
|
+
*/
|
|
424
|
+
|
|
425
|
+
interface SessionFamily {
|
|
426
|
+
familyId: string;
|
|
427
|
+
userId: string;
|
|
428
|
+
tenantId: string;
|
|
429
|
+
createdAt: number;
|
|
430
|
+
lastUsedAt: number;
|
|
431
|
+
userAgent?: string;
|
|
432
|
+
ipFirst?: string;
|
|
433
|
+
}
|
|
434
|
+
declare class RefreshNotFoundError extends Error {
|
|
435
|
+
constructor();
|
|
436
|
+
}
|
|
437
|
+
declare class RefreshExpiredError extends Error {
|
|
438
|
+
constructor();
|
|
439
|
+
}
|
|
440
|
+
declare class RefreshReuseDetectedError extends Error {
|
|
441
|
+
readonly familyId: string;
|
|
442
|
+
constructor(familyId: string);
|
|
443
|
+
}
|
|
444
|
+
interface SessionsStoreOptions {
|
|
445
|
+
/** Override `Date.now` — only used in tests. Defaults to `Date.now`. */
|
|
446
|
+
now?: () => number;
|
|
447
|
+
/** Refresh token TTL in ms. */
|
|
448
|
+
refreshTtlMs: number;
|
|
449
|
+
/** Grace window for legitimate retries (CD-5). Recommended 5_000ms. */
|
|
450
|
+
graceWindowMs: number;
|
|
451
|
+
}
|
|
452
|
+
interface CreateSessionInput {
|
|
453
|
+
userId: string;
|
|
454
|
+
tenantId: string;
|
|
455
|
+
deviceCtx: {
|
|
456
|
+
userAgent?: string;
|
|
457
|
+
ip?: string;
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
interface CreateSessionResult {
|
|
461
|
+
familyId: string;
|
|
462
|
+
refreshJti: string;
|
|
463
|
+
refreshExpiresAt: number;
|
|
464
|
+
}
|
|
465
|
+
interface RotateRefreshResult {
|
|
466
|
+
newJti: string;
|
|
467
|
+
familyId: string;
|
|
468
|
+
newRefreshExpiresAt: number;
|
|
469
|
+
}
|
|
470
|
+
declare class SessionsStore {
|
|
471
|
+
private readonly docs;
|
|
472
|
+
private initialised;
|
|
473
|
+
private readonly now;
|
|
474
|
+
private readonly refreshTtlMs;
|
|
475
|
+
private readonly graceWindowMs;
|
|
476
|
+
constructor(docs: IDocumentDatabase, opts: SessionsStoreOptions);
|
|
477
|
+
private ensureSchema;
|
|
478
|
+
createSession(input: CreateSessionInput): Promise<CreateSessionResult>;
|
|
479
|
+
/**
|
|
480
|
+
* Consume `oldJti` and issue a new refresh in the same family.
|
|
481
|
+
*
|
|
482
|
+
* Throws:
|
|
483
|
+
* - `RefreshNotFoundError` if jti is unknown or its family was revoked.
|
|
484
|
+
* - `RefreshExpiredError` if jti is past `expiresAt` (family stays alive).
|
|
485
|
+
* - `RefreshReuseDetectedError` if jti was already consumed and the
|
|
486
|
+
* grace window has passed — family is killed before throwing.
|
|
487
|
+
*
|
|
488
|
+
* On grace-window retry (same already-consumed jti within
|
|
489
|
+
* `graceWindowMs`) the previously-issued replacement is returned and
|
|
490
|
+
* no new refresh is created.
|
|
491
|
+
*/
|
|
492
|
+
rotateRefresh(oldJti: string): Promise<RotateRefreshResult>;
|
|
493
|
+
revokeFamily(familyId: string): Promise<void>;
|
|
494
|
+
revokeAllUserSessions(userId: string): Promise<void>;
|
|
495
|
+
revokeAllUserSessionsExcept(userId: string, exceptFamilyId: string): Promise<void>;
|
|
496
|
+
listFamiliesByUser(userId: string): Promise<SessionFamily[]>;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* @module @kb-labs/gateway-auth/password-policy
|
|
501
|
+
*
|
|
502
|
+
* Password validation rules (ADR-0020, Phase 1.6).
|
|
503
|
+
*
|
|
504
|
+
* Length: 8..256. No complexity rules — NIST 800-63B explicitly
|
|
505
|
+
* recommends against them.
|
|
506
|
+
*
|
|
507
|
+
* HIBP check via k-anonymity (https://haveibeenpwned.com/API/v3#PwnedPasswords):
|
|
508
|
+
* we send the first 5 hex chars of SHA-1(password) and grep the
|
|
509
|
+
* suffix list locally. The full password never leaves the process.
|
|
510
|
+
*
|
|
511
|
+
* Availability vs. UX trade-off: HIBP outages return `{ ok: true,
|
|
512
|
+
* warning: 'hibp_unavailable' }`. A security check that hard-fails on
|
|
513
|
+
* network glitches breaks more user flows than it protects — we log
|
|
514
|
+
* the warning and let the activation/change-password flow continue.
|
|
515
|
+
*/
|
|
516
|
+
type ValidationResult = {
|
|
517
|
+
ok: true;
|
|
518
|
+
warning?: 'hibp_unavailable';
|
|
519
|
+
} | {
|
|
520
|
+
ok: false;
|
|
521
|
+
reason: 'too_short' | 'too_long' | 'pwned';
|
|
522
|
+
};
|
|
523
|
+
interface PasswordPolicyLogger {
|
|
524
|
+
warn(...args: unknown[]): void;
|
|
525
|
+
info(...args: unknown[]): void;
|
|
526
|
+
error(...args: unknown[]): void;
|
|
527
|
+
}
|
|
528
|
+
interface PasswordPolicyOptions {
|
|
529
|
+
minLength: number;
|
|
530
|
+
maxLength: number;
|
|
531
|
+
hibpEnabled: boolean;
|
|
532
|
+
/** Override for tests. Defaults to global `fetch`. */
|
|
533
|
+
fetch?: typeof fetch;
|
|
534
|
+
/** Override for tests / production logger. Defaults to console-shaped no-op. */
|
|
535
|
+
logger?: PasswordPolicyLogger;
|
|
536
|
+
}
|
|
537
|
+
interface PasswordPolicy {
|
|
538
|
+
validate(plain: string): Promise<ValidationResult>;
|
|
539
|
+
}
|
|
540
|
+
declare const createPasswordPolicy: (opts: PasswordPolicyOptions) => PasswordPolicy;
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* @module @kb-labs/gateway-auth/csrf
|
|
544
|
+
*
|
|
545
|
+
* Double-submit CSRF helpers (ADR-0020, Phase 1.7).
|
|
546
|
+
*
|
|
547
|
+
* The contract is intentionally minimal: a token is just an opaque
|
|
548
|
+
* random string and verification is a constant-time string compare.
|
|
549
|
+
* No server-side state, no rotation logic — the cookie + header pair
|
|
550
|
+
* is the entire protocol.
|
|
551
|
+
*
|
|
552
|
+
* The `Path=/` non-HttpOnly cookie is set in the route layer
|
|
553
|
+
* alongside `kb_access`. Studio JS reads it via `document.cookie` and
|
|
554
|
+
* echoes it via `X-CSRF-Token` on every mutating request.
|
|
555
|
+
*/
|
|
556
|
+
/**
|
|
557
|
+
* Generate a fresh 32-byte token, base64url-encoded.
|
|
558
|
+
*/
|
|
559
|
+
declare const issueCsrfToken: () => string;
|
|
560
|
+
/**
|
|
561
|
+
* Verify the double-submit pair.
|
|
562
|
+
*
|
|
563
|
+
* Returns `true` only when both inputs are non-empty strings of the
|
|
564
|
+
* same length and equal under a constant-time compare. Length
|
|
565
|
+
* mismatches return `false` without involving timingSafeEqual (which
|
|
566
|
+
* throws on length mismatch).
|
|
567
|
+
*/
|
|
568
|
+
declare const verifyCsrfToken: (cookie: string | undefined, header: string | undefined) => boolean;
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* @module @kb-labs/gateway-auth/rate-limit
|
|
572
|
+
*
|
|
573
|
+
* Fixed-window rate limiter on top of `IKVStore` (ADR-0020, Phase 1.8).
|
|
574
|
+
*
|
|
575
|
+
* Used for per-email login limits and activation rate-limiting.
|
|
576
|
+
* Per-IP limits are configured at the gateway layer itself (its
|
|
577
|
+
* existing rate-limit plugin) and are not touched here.
|
|
578
|
+
*
|
|
579
|
+
* Fixed window, not sliding: TTL is applied **once** on the first
|
|
580
|
+
* increment and never extended. Subsequent increments within the
|
|
581
|
+
* window just bump the counter. Once the key expires, the counter
|
|
582
|
+
* starts over.
|
|
583
|
+
*
|
|
584
|
+
* The choice of fixed vs sliding matters: a sliding window would mean
|
|
585
|
+
* "5 attempts in any 60-second window", which an attacker can game by
|
|
586
|
+
* pacing exactly under the limit forever. Fixed window puts a hard
|
|
587
|
+
* ceiling per window and the counter actually resets.
|
|
588
|
+
*/
|
|
589
|
+
|
|
590
|
+
interface RateLimitConfig {
|
|
591
|
+
max: number;
|
|
592
|
+
windowMs: number;
|
|
593
|
+
}
|
|
594
|
+
type RateLimitResult = {
|
|
595
|
+
allowed: true;
|
|
596
|
+
remaining: number;
|
|
597
|
+
} | {
|
|
598
|
+
allowed: false;
|
|
599
|
+
retryAfterSec: number;
|
|
600
|
+
};
|
|
601
|
+
interface RateLimiter {
|
|
602
|
+
/**
|
|
603
|
+
* Increment the counter and report whether this hit is allowed.
|
|
604
|
+
* Use on the event you want to count (e.g. a FAILED login attempt).
|
|
605
|
+
*/
|
|
606
|
+
check(key: string, cfg: RateLimitConfig): Promise<RateLimitResult>;
|
|
607
|
+
/**
|
|
608
|
+
* Read the current counter WITHOUT incrementing and report whether the
|
|
609
|
+
* next `check()` would be allowed.
|
|
610
|
+
*
|
|
611
|
+
* This exists so a caller can reject early — before doing expensive work
|
|
612
|
+
* such as a bcrypt compare — once the window is already exhausted. Without
|
|
613
|
+
* a peek, the only way to know the limit is hit is to `check()` (which
|
|
614
|
+
* increments), forcing the expensive work to run first. peek() lets the
|
|
615
|
+
* login handler gate bcrypt on the existing failure count, closing a
|
|
616
|
+
* CPU-exhaustion DoS where an attacker spams requests past the limit and
|
|
617
|
+
* still pays for a bcrypt round-trip on each.
|
|
618
|
+
*/
|
|
619
|
+
peek(key: string, cfg: RateLimitConfig): Promise<RateLimitResult>;
|
|
620
|
+
}
|
|
621
|
+
declare const createRateLimiter: (kv: IKVStore) => RateLimiter;
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* @module @kb-labs/gateway-auth/tenant-resolver
|
|
625
|
+
*
|
|
626
|
+
* Map `Host`-header → `tenantId` (ADR-0020, Phase 1.9).
|
|
627
|
+
*
|
|
628
|
+
* Pattern is configurable (`{tenant}.kblabs.ru` in cloud) so the same
|
|
629
|
+
* resolver can be used in dev (`{tenant}.localhost`) or for on-prem
|
|
630
|
+
* customers (`{tenant}.acme.example.com`).
|
|
631
|
+
*
|
|
632
|
+
* Returns `null` instead of throwing on bad/reserved hosts — the
|
|
633
|
+
* middleware then renders the right 4xx and avoids surfacing an
|
|
634
|
+
* internal error. Slug rules are strict (`[a-z0-9-]{2,40}`, no
|
|
635
|
+
* leading/trailing dash) so a stray "weird" subdomain doesn't become a
|
|
636
|
+
* valid tenant by accident.
|
|
637
|
+
*/
|
|
638
|
+
declare const RESERVED_SUBDOMAINS: ReadonlySet<string>;
|
|
639
|
+
interface TenantResolverOptions {
|
|
640
|
+
/** Host pattern containing exactly one `{tenant}` placeholder. */
|
|
641
|
+
pattern: string;
|
|
642
|
+
/** Optional override of the reserved set (must remain a superset of the default in production). */
|
|
643
|
+
reserved?: ReadonlySet<string>;
|
|
644
|
+
}
|
|
645
|
+
interface TenantResolver {
|
|
646
|
+
resolve(host: string | undefined): string | null;
|
|
647
|
+
}
|
|
648
|
+
declare const createTenantResolver: (opts: TenantResolverOptions) => TenantResolver;
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* @module @kb-labs/gateway-auth/providers/email-password
|
|
652
|
+
*
|
|
653
|
+
* Built-in identity provider for the local email-password flow
|
|
654
|
+
* (ADR-0020, Phase 1.10). Implements `IIdentityProvider` exactly as a
|
|
655
|
+
* future Google/Okta provider would — `email-password` has no special
|
|
656
|
+
* status beyond being the one we ship.
|
|
657
|
+
*
|
|
658
|
+
* Two design notes worth keeping in mind:
|
|
659
|
+
*
|
|
660
|
+
* - **Constant-time response (CD-8).** Every not-ok path runs a real
|
|
661
|
+
* bcrypt compare against a fixed dummy hash. Without this, a missing
|
|
662
|
+
* user returns in microseconds while a wrong-password attempt takes
|
|
663
|
+
* ~hundreds of milliseconds — a textbook user-enumeration timing
|
|
664
|
+
* channel. The discriminated `reason` is for **logs only**; the HTTP
|
|
665
|
+
* layer collapses it to a single opaque "invalid_credentials".
|
|
666
|
+
*
|
|
667
|
+
* - **No `setCredential` here.** This provider only *reads* credentials.
|
|
668
|
+
* The activation flow writes the hash via the service layer, which
|
|
669
|
+
* chooses the bcrypt cost from config. Keeping write logic out of
|
|
670
|
+
* the provider means future Google/Okta providers don't carry a
|
|
671
|
+
* meaningless `setCredential` method.
|
|
672
|
+
*/
|
|
673
|
+
|
|
674
|
+
interface EmailPasswordProviderOptions {
|
|
675
|
+
/**
|
|
676
|
+
* Narrow read port over users (DD-2). The gateway's `UsersStore`
|
|
677
|
+
* satisfies this structurally; the provider only ever reads.
|
|
678
|
+
*/
|
|
679
|
+
users: IdentityUserPort;
|
|
680
|
+
/** Narrow read port over credentials (DD-2). */
|
|
681
|
+
credentials: IdentityCredentialPort;
|
|
682
|
+
/** The tenant this provider instance authenticates against. */
|
|
683
|
+
tenantId: string;
|
|
684
|
+
/** bcrypt cost used for the dummy compare timing. Should match the
|
|
685
|
+
* cost used by the activation flow so dummy/real timings are similar. */
|
|
686
|
+
bcryptCost: number;
|
|
687
|
+
}
|
|
688
|
+
declare const createEmailPasswordProvider: (opts: EmailPasswordProviderOptions) => IIdentityProvider;
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* @module @kb-labs/gateway-auth/providers/oidc
|
|
692
|
+
*
|
|
693
|
+
* Built-in generic OIDC redirect provider (ADR-0020, Step 5).
|
|
694
|
+
*
|
|
695
|
+
* Implements the authorization-code flow against any spec-compliant OIDC
|
|
696
|
+
* IdP (Google / Okta / Auth0 / Keycloak / Azure AD): `startAuthorization`
|
|
697
|
+
* builds the upstream authorize URL plus a per-attempt `nonce` (and optional
|
|
698
|
+
* PKCE `code_verifier`); `authenticate` exchanges the code at the token
|
|
699
|
+
* endpoint and verifies the returned ID token before returning the canonical
|
|
700
|
+
* identity. The gateway then does email→user→session as for any provider.
|
|
701
|
+
*
|
|
702
|
+
* Security guarantees this provider owns (not "quality of a custom" — these
|
|
703
|
+
* are the floor we promise):
|
|
704
|
+
*
|
|
705
|
+
* - **alg allowlist** — `jwtVerify(..., { algorithms: ['RS256','ES256'] })`.
|
|
706
|
+
* `none` and HS* are rejected (alg-confusion / key-confusion). Same lesson
|
|
707
|
+
* as pinning HS256 in jwt.ts: never let the token choose its own algorithm
|
|
708
|
+
* class.
|
|
709
|
+
* - **iss / aud** — verified against the configured issuer and clientId.
|
|
710
|
+
* - **exp / nonce** — exp enforced by jose; nonce must echo the per-attempt
|
|
711
|
+
* value we stashed in the state session (replay / token-injection guard).
|
|
712
|
+
* - **email_verified === true** — required unless `allowUnverifiedEmail`,
|
|
713
|
+
* else an IdP that federates unverified emails becomes an account-takeover
|
|
714
|
+
* vector.
|
|
715
|
+
* - **issuer https-only** — discovery / JWKS targets are admin-controlled but
|
|
716
|
+
* we still refuse plaintext.
|
|
717
|
+
* - **secret hygiene** — `clientSecret` is read from an env var
|
|
718
|
+
* (`clientSecretEnv`) by default; nothing secret is ever logged (no code /
|
|
719
|
+
* id_token / access_token / client_secret in any log line).
|
|
720
|
+
*
|
|
721
|
+
* `fetch` is injected (DD-8) so the whole flow is testable against a locally
|
|
722
|
+
* signed token with a fake transport — no network, no global mocks. JWKS are
|
|
723
|
+
* fetched via the same injected `fetch` and verified with a local JWK set,
|
|
724
|
+
* with a single refetch on key rotation.
|
|
725
|
+
*/
|
|
726
|
+
|
|
727
|
+
declare const OidcConfigSchema: z.ZodObject<{
|
|
728
|
+
type: z.ZodLiteral<"oidc">;
|
|
729
|
+
id: z.ZodString;
|
|
730
|
+
issuer: z.ZodEffects<z.ZodString, string, string>;
|
|
731
|
+
clientId: z.ZodString;
|
|
732
|
+
/** Direct secret (discouraged — prefer clientSecretEnv). */
|
|
733
|
+
clientSecret: z.ZodOptional<z.ZodString>;
|
|
734
|
+
/** Name of the env var holding the secret (preferred). */
|
|
735
|
+
clientSecretEnv: z.ZodOptional<z.ZodString>;
|
|
736
|
+
/** OAuth scopes; `openid` is always included. Default `['openid','email']`. */
|
|
737
|
+
scopes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
738
|
+
/** Enable PKCE (S256). */
|
|
739
|
+
pkce: z.ZodOptional<z.ZodBoolean>;
|
|
740
|
+
/** Accept tokens whose email_verified is false (default false). */
|
|
741
|
+
allowUnverifiedEmail: z.ZodOptional<z.ZodBoolean>;
|
|
742
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
743
|
+
type: z.ZodLiteral<"oidc">;
|
|
744
|
+
id: z.ZodString;
|
|
745
|
+
issuer: z.ZodEffects<z.ZodString, string, string>;
|
|
746
|
+
clientId: z.ZodString;
|
|
747
|
+
/** Direct secret (discouraged — prefer clientSecretEnv). */
|
|
748
|
+
clientSecret: z.ZodOptional<z.ZodString>;
|
|
749
|
+
/** Name of the env var holding the secret (preferred). */
|
|
750
|
+
clientSecretEnv: z.ZodOptional<z.ZodString>;
|
|
751
|
+
/** OAuth scopes; `openid` is always included. Default `['openid','email']`. */
|
|
752
|
+
scopes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
753
|
+
/** Enable PKCE (S256). */
|
|
754
|
+
pkce: z.ZodOptional<z.ZodBoolean>;
|
|
755
|
+
/** Accept tokens whose email_verified is false (default false). */
|
|
756
|
+
allowUnverifiedEmail: z.ZodOptional<z.ZodBoolean>;
|
|
757
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
758
|
+
type: z.ZodLiteral<"oidc">;
|
|
759
|
+
id: z.ZodString;
|
|
760
|
+
issuer: z.ZodEffects<z.ZodString, string, string>;
|
|
761
|
+
clientId: z.ZodString;
|
|
762
|
+
/** Direct secret (discouraged — prefer clientSecretEnv). */
|
|
763
|
+
clientSecret: z.ZodOptional<z.ZodString>;
|
|
764
|
+
/** Name of the env var holding the secret (preferred). */
|
|
765
|
+
clientSecretEnv: z.ZodOptional<z.ZodString>;
|
|
766
|
+
/** OAuth scopes; `openid` is always included. Default `['openid','email']`. */
|
|
767
|
+
scopes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
768
|
+
/** Enable PKCE (S256). */
|
|
769
|
+
pkce: z.ZodOptional<z.ZodBoolean>;
|
|
770
|
+
/** Accept tokens whose email_verified is false (default false). */
|
|
771
|
+
allowUnverifiedEmail: z.ZodOptional<z.ZodBoolean>;
|
|
772
|
+
}, z.ZodTypeAny, "passthrough">>;
|
|
773
|
+
type OidcProviderConfig = z.infer<typeof OidcConfigSchema>;
|
|
774
|
+
declare function createOidcProvider(rawConfig: unknown, deps: IdentityProviderDeps): IRedirectIdentityProvider;
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* @module @kb-labs/gateway-auth/provider-registry
|
|
778
|
+
*
|
|
779
|
+
* Tiny registry for `IIdentityProvider` instances (ADR-0020, Phase 1.11).
|
|
780
|
+
*
|
|
781
|
+
* Two invariants enforced beyond what a raw Map would give us:
|
|
782
|
+
* - Re-registering the same `id` throws — usually a bootstrap bug,
|
|
783
|
+
* never intentional.
|
|
784
|
+
* - `list()` returns only the public `{ id, kind }` shape so the
|
|
785
|
+
* `GET /auth/providers` route cannot accidentally serialise an
|
|
786
|
+
* `authenticate` function over the wire.
|
|
787
|
+
*/
|
|
788
|
+
|
|
789
|
+
interface ProviderInfo {
|
|
790
|
+
id: string;
|
|
791
|
+
kind: IIdentityProvider['kind'];
|
|
792
|
+
}
|
|
793
|
+
declare class ProviderRegistry {
|
|
794
|
+
private readonly providers;
|
|
795
|
+
register(provider: IIdentityProvider): void;
|
|
796
|
+
get(id: string): IIdentityProvider | undefined;
|
|
797
|
+
has(id: string): boolean;
|
|
798
|
+
list(): ProviderInfo[];
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* @module @kb-labs/gateway-auth/provider-loader
|
|
803
|
+
*
|
|
804
|
+
* Config-driven loader for identity providers (ADR-0020, DD-3).
|
|
805
|
+
*
|
|
806
|
+
* This is the gateway-side analogue of the platform adapter loader
|
|
807
|
+
* (`core/runtime/src/loader.ts` notifier sub-channels): a type→factory
|
|
808
|
+
* map for the built-ins, plus dynamic `import()` of a package for any
|
|
809
|
+
* non-builtin `type`. Two deliberate departures from the platform
|
|
810
|
+
* loader:
|
|
811
|
+
*
|
|
812
|
+
* - **Fail-fast, not warn-skip.** A misconfigured *platform* adapter can
|
|
813
|
+
* degrade gracefully; a misconfigured *auth door* must never boot
|
|
814
|
+
* half-open. If a configured provider cannot be loaded, we throw.
|
|
815
|
+
* - **Gateway-owned (Pattern B).** Providers need gateway-domain ports
|
|
816
|
+
* (users/credentials) and the tenant context, which don't exist until
|
|
817
|
+
* gateway bootstrap — well after platform assembly. So the loader lives
|
|
818
|
+
* here, not in core.
|
|
819
|
+
*
|
|
820
|
+
* The built-in `email-password` provider is the zero-config default: an
|
|
821
|
+
* empty/absent `providers` config registers it and nothing else. It has
|
|
822
|
+
* no special status beyond being the one we ship — it goes through the
|
|
823
|
+
* exact same factory path a third-party provider would.
|
|
824
|
+
*/
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* A single provider entry from `auth.providers` config. `type` is the
|
|
828
|
+
* discriminator (built-in name or package name); everything else is
|
|
829
|
+
* provider-specific and validated by the provider's own zod schema.
|
|
830
|
+
*/
|
|
831
|
+
interface ProviderConfigEntry {
|
|
832
|
+
type: string;
|
|
833
|
+
[key: string]: unknown;
|
|
834
|
+
}
|
|
835
|
+
type ProvidersConfig = Record<string, ProviderConfigEntry> | undefined;
|
|
836
|
+
/**
|
|
837
|
+
* Built-in factories keyed by `type`. Anything not here is resolved as a
|
|
838
|
+
* package name. `oidc` is the generic OIDC redirect provider (Google / Okta /
|
|
839
|
+
* Auth0 / Keycloak / Azure AD); it owns its own zod config schema.
|
|
840
|
+
*/
|
|
841
|
+
declare const BUILTIN_FACTORIES: Record<string, IdentityProviderFactory>;
|
|
842
|
+
/**
|
|
843
|
+
* Build a {@link ProviderRegistry} from the `auth.providers` config.
|
|
844
|
+
*
|
|
845
|
+
* @param config The `auth.providers` map (or undefined).
|
|
846
|
+
* @param deps Shared dependencies handed to every provider factory.
|
|
847
|
+
* @returns A populated registry. Throws (fail-fast) on any load error.
|
|
848
|
+
*/
|
|
849
|
+
declare function loadIdentityProviders(config: ProvidersConfig, deps: IdentityProviderDeps): Promise<ProviderRegistry>;
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* @module @kb-labs/gateway-auth/oauth-state-store
|
|
853
|
+
*
|
|
854
|
+
* Per-attempt OAuth `state` storage (ADR-0020, DD-5).
|
|
855
|
+
*
|
|
856
|
+
* Between `GET /auth/oauth/:id/start` and the IdP callback we must hold a
|
|
857
|
+
* short-lived binding from the opaque `state` token to the flow context
|
|
858
|
+
* (which provider issued it, which tenant, where to return, and the
|
|
859
|
+
* per-attempt secrets like nonce / PKCE verifier). This is a thin,
|
|
860
|
+
* namespaced wrapper over `IKVStore` — a real abstraction, not a stub:
|
|
861
|
+
*
|
|
862
|
+
* - **Namespaced keys** (`oauth:state:{state}`) so it can share the same
|
|
863
|
+
* KV as the rate limiter without collision.
|
|
864
|
+
* - **TTL** so abandoned flows self-clean.
|
|
865
|
+
* - **One-shot `consume`** (get → delete, gated on the delete result) so
|
|
866
|
+
* a replayed callback cannot be processed twice. With a shared Redis KV
|
|
867
|
+
* the gating on `delete()` is what provides the atomic single-winner
|
|
868
|
+
* guarantee under concurrency.
|
|
869
|
+
*
|
|
870
|
+
* NOTE: in a multi-process / HA deployment this MUST be backed by a
|
|
871
|
+
* shared KV (Redis); an in-memory KV is per-process and a callback may
|
|
872
|
+
* land on a different worker than the one that issued the state. The
|
|
873
|
+
* bootstrap warns when a redirect provider is configured against an
|
|
874
|
+
* in-memory KV (see OAuth hardening, Step 4b).
|
|
875
|
+
*/
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* The flow context bound to a single `state` token. `session` carries the
|
|
879
|
+
* provider's per-attempt secrets (OIDC nonce, PKCE verifier) verbatim and
|
|
880
|
+
* is never logged.
|
|
881
|
+
*/
|
|
882
|
+
interface OAuthStateRecord {
|
|
883
|
+
/** The provider instance id that started this flow (mix-up guard). */
|
|
884
|
+
providerId: string;
|
|
885
|
+
/** The tenant the flow was started for (cross-tenant guard). */
|
|
886
|
+
tenantId: string;
|
|
887
|
+
/** Validated relative path to return to after success. */
|
|
888
|
+
returnTo: string;
|
|
889
|
+
/** Opaque per-attempt secrets the provider needs back at callback. */
|
|
890
|
+
session?: Record<string, unknown>;
|
|
891
|
+
/** Issue timestamp (ms). */
|
|
892
|
+
createdAt: number;
|
|
893
|
+
}
|
|
894
|
+
interface OAuthStateStoreOptions {
|
|
895
|
+
/** State TTL in milliseconds. Default 10 minutes. */
|
|
896
|
+
ttlMs?: number;
|
|
897
|
+
}
|
|
898
|
+
declare class OAuthStateStore {
|
|
899
|
+
private readonly kv;
|
|
900
|
+
private readonly ttlMs;
|
|
901
|
+
constructor(kv: IKVStore, opts?: OAuthStateStoreOptions);
|
|
902
|
+
private key;
|
|
903
|
+
/** Persist a state record with the configured TTL. */
|
|
904
|
+
put(state: string, record: OAuthStateRecord): Promise<void>;
|
|
905
|
+
/**
|
|
906
|
+
* One-shot read. Returns the record to the first caller and deletes it;
|
|
907
|
+
* concurrent / subsequent callers get `null`. The `delete` result gates
|
|
908
|
+
* the return so exactly one caller wins even under a shared KV.
|
|
909
|
+
*/
|
|
910
|
+
consume(state: string): Promise<OAuthStateRecord | null>;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* @module @kb-labs/gateway-auth/stub-pdp
|
|
915
|
+
*
|
|
916
|
+
* Stub `IPolicyDecisionPoint` (ADR-0020, Phase 1.12). Mock RBAC over
|
|
917
|
+
* the memberships store: tenant-admin gets every canonical permission,
|
|
918
|
+
* tenant-member gets none. Machine identities are temporarily allowed
|
|
919
|
+
* everything (matches the existing machine-token behaviour pre-RBAC).
|
|
920
|
+
*
|
|
921
|
+
* This module exists so the real handlers can call `policy.check(...)`
|
|
922
|
+
* and `enumeratePermissions(...)` from day one. The real RBAC + ReBAC
|
|
923
|
+
* engine (ClickUp 869def338) replaces this implementation; callers do
|
|
924
|
+
* not change.
|
|
925
|
+
*
|
|
926
|
+
* Closed-world: actions outside the canonical PERMISSIONS enum return
|
|
927
|
+
* `allow: false` even for tenant-admin. This is deliberate — until we
|
|
928
|
+
* have per-plugin permission registration, the central enum is the
|
|
929
|
+
* full universe of known actions.
|
|
930
|
+
*/
|
|
931
|
+
|
|
932
|
+
interface StubPDPOptions {
|
|
933
|
+
memberships: MembershipsStore;
|
|
934
|
+
}
|
|
935
|
+
declare const createStubPDP: (opts: StubPDPOptions) => IPolicyDecisionPoint;
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* @module @kb-labs/gateway-auth/bootstrap-admin
|
|
939
|
+
*
|
|
940
|
+
* First-run admin provisioning (ADR-0020, Phase 1.14).
|
|
941
|
+
*
|
|
942
|
+
* On a fresh deployment the operator can't log in unless someone has
|
|
943
|
+
* already been provisioned — and we deliberately removed the public
|
|
944
|
+
* `/auth/register` for humans. `ensureBootstrapAdmin` reads bootstrap
|
|
945
|
+
* config from env and creates a single tenant-admin User on first
|
|
946
|
+
* start. Idempotent: subsequent runs see the existing User and do
|
|
947
|
+
* nothing.
|
|
948
|
+
*
|
|
949
|
+
* Conflict handling is conservative: if a User with the configured
|
|
950
|
+
* email already exists in the configured tenant but in a different
|
|
951
|
+
* status (e.g. someone manually `disabled` them and forgot the env
|
|
952
|
+
* was still set), we log a warning and **do not touch** the existing
|
|
953
|
+
* record. Silently re-activating or re-passwording is the kind of
|
|
954
|
+
* surprise we want to avoid on a production system.
|
|
955
|
+
*/
|
|
956
|
+
|
|
957
|
+
interface BootstrapConfig {
|
|
958
|
+
adminEmail: string;
|
|
959
|
+
adminPassword: string;
|
|
960
|
+
tenantId: string;
|
|
961
|
+
}
|
|
962
|
+
interface BootstrapAdminLogger {
|
|
963
|
+
warn(...args: unknown[]): void;
|
|
964
|
+
info(...args: unknown[]): void;
|
|
965
|
+
error(...args: unknown[]): void;
|
|
966
|
+
}
|
|
967
|
+
interface EnsureBootstrapAdminOptions {
|
|
968
|
+
bootstrap: BootstrapConfig | undefined;
|
|
969
|
+
users: UsersStore;
|
|
970
|
+
credentials: CredentialsStore;
|
|
971
|
+
memberships: MembershipsStore;
|
|
972
|
+
bcryptCost: number;
|
|
973
|
+
logger: BootstrapAdminLogger;
|
|
974
|
+
}
|
|
975
|
+
declare const ensureBootstrapAdmin: (opts: EnsureBootstrapAdminOptions) => Promise<void>;
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* @module @kb-labs/gateway-auth/user-auth-service
|
|
979
|
+
*
|
|
980
|
+
* Orchestration layer for the user authentication flow (ADR-0020,
|
|
981
|
+
* Phase 1.15).
|
|
982
|
+
*
|
|
983
|
+
* Pulls together every store + provider + jwt + policy and exposes the
|
|
984
|
+
* five operations the HTTP layer calls:
|
|
985
|
+
*
|
|
986
|
+
* - `login`
|
|
987
|
+
* - `refresh` (CD-1 enforced here — disabled user revokes family)
|
|
988
|
+
* - `logout`
|
|
989
|
+
* - `changePassword` (revokes other families, keeps current)
|
|
990
|
+
* - `activate` (consumes invite, auto-logs in)
|
|
991
|
+
*
|
|
992
|
+
* All not-ok paths throw `AuthError(code)`. The HTTP layer collapses
|
|
993
|
+
* most codes to opaque `invalid_credentials` for the caller; `code` is
|
|
994
|
+
* preserved for logs and tests.
|
|
995
|
+
*/
|
|
996
|
+
|
|
997
|
+
type AuthErrorCode = 'invalid_credentials' | 'unknown_provider' | 'invalid_refresh' | 'refresh_reuse' | 'user_disabled' | 'invalid_current_password' | 'weak_password' | 'invalid_invite' | 'unknown_invite';
|
|
998
|
+
declare class AuthError extends Error {
|
|
999
|
+
readonly code: AuthErrorCode;
|
|
1000
|
+
readonly reason?: string | undefined;
|
|
1001
|
+
readonly name = "AuthError";
|
|
1002
|
+
constructor(code: AuthErrorCode, reason?: string | undefined);
|
|
1003
|
+
}
|
|
1004
|
+
interface UserAuthServiceOptions {
|
|
1005
|
+
users: UsersStore;
|
|
1006
|
+
credentials: CredentialsStore;
|
|
1007
|
+
memberships: MembershipsStore;
|
|
1008
|
+
invites: InvitesStore;
|
|
1009
|
+
sessions: SessionsStore;
|
|
1010
|
+
providers: ProviderRegistry;
|
|
1011
|
+
passwordPolicy: PasswordPolicy;
|
|
1012
|
+
jwtConfig: JwtConfig;
|
|
1013
|
+
accessTtlSec: number;
|
|
1014
|
+
refreshTtlSec: number;
|
|
1015
|
+
bcryptCost: number;
|
|
1016
|
+
/** Override for tests. Defaults to Date.now. */
|
|
1017
|
+
now?: () => number;
|
|
1018
|
+
}
|
|
1019
|
+
interface PublicUser {
|
|
1020
|
+
userId: string;
|
|
1021
|
+
email: string;
|
|
1022
|
+
tenantId: string;
|
|
1023
|
+
}
|
|
1024
|
+
interface TokenInfo {
|
|
1025
|
+
token: string;
|
|
1026
|
+
expiresInSec: number;
|
|
1027
|
+
}
|
|
1028
|
+
interface SessionResult {
|
|
1029
|
+
user: PublicUser;
|
|
1030
|
+
access: TokenInfo;
|
|
1031
|
+
refresh: TokenInfo;
|
|
1032
|
+
csrf: string;
|
|
1033
|
+
familyId: string;
|
|
1034
|
+
}
|
|
1035
|
+
interface LoginInput {
|
|
1036
|
+
providerId: string;
|
|
1037
|
+
input: unknown;
|
|
1038
|
+
}
|
|
1039
|
+
interface DeviceCtx {
|
|
1040
|
+
userAgent?: string;
|
|
1041
|
+
ip?: string;
|
|
1042
|
+
}
|
|
1043
|
+
interface ChangePasswordInput {
|
|
1044
|
+
userId: string;
|
|
1045
|
+
currentFamilyId: string;
|
|
1046
|
+
currentPassword: string;
|
|
1047
|
+
newPassword: string;
|
|
1048
|
+
}
|
|
1049
|
+
interface ActivateInput {
|
|
1050
|
+
activationToken: string;
|
|
1051
|
+
password: string;
|
|
1052
|
+
deviceCtx: DeviceCtx;
|
|
1053
|
+
}
|
|
1054
|
+
declare const createUserAuthService: (opts: UserAuthServiceOptions) => {
|
|
1055
|
+
login: (input: LoginInput, tenantId: string, deviceCtx: DeviceCtx) => Promise<SessionResult>;
|
|
1056
|
+
refresh: (refreshToken: string) => Promise<SessionResult>;
|
|
1057
|
+
logout: (refreshToken: string) => Promise<void>;
|
|
1058
|
+
changePassword: (input: ChangePasswordInput) => Promise<void>;
|
|
1059
|
+
activate: (input: ActivateInput) => Promise<SessionResult>;
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
export { type ActivateInput, type AddMembershipInput, AuthError, type AuthErrorCode, AuthService, BUILTIN_FACTORIES, type BootstrapAdminLogger, type BootstrapConfig, type ChangePasswordInput, type ClientRecord, type CreateInviteInput, type CreateInviteResult, type CreateSessionInput, type CreateSessionResult, type CreateUserInput, type Credential, CredentialsStore, type DeviceCtx, type EmailPasswordProviderOptions, type EnsureBootstrapAdminOptions, type GroupId, type InviteRecord, type InviteStatus, InvitesStore, type JwtConfig, type LoginInput, type Membership, MembershipsStore, type OAuthStateRecord, OAuthStateStore, type OAuthStateStoreOptions, type OidcProviderConfig, type PasswordPolicy, type PasswordPolicyLogger, type PasswordPolicyOptions, type ProviderConfigEntry, type ProviderInfo, ProviderRegistry, type ProvidersConfig, type PublicUser, RESERVED_SUBDOMAINS, type RateLimitConfig, type RateLimitResult, type RateLimiter, RefreshExpiredError, RefreshNotFoundError, RefreshReuseDetectedError, type RotateRefreshResult, type SessionFamily, type SessionResult, SessionsStore, type SessionsStoreOptions, type SetCredentialInput, type SignUserTokenOptions, type StubPDPOptions, type TenantResolver, type TenantResolverOptions, type TokenInfo, type User, type UserAccessPayload, type UserAuthServiceOptions, type UserRefreshPayload, type UserStatus, UsersStore, type ValidationResult, buildClientRecord, canonicalizeEmail, consumeRefreshToken, createEmailPasswordProvider, createOidcProvider, createPasswordPolicy, createRateLimiter, createStubPDP, createTenantResolver, createUserAuthService, ensureBootstrapAdmin, generateClientId, generateClientSecret, generateHostId, getClient, getClientByHandle, getClientByHostId, getPublicKey, isHandleTaken, issueCsrfToken, loadIdentityProviders, saveClient, savePublicKey, saveRefreshToken, signAccessToken, signRefreshToken, signUserAccessToken, signUserRefreshToken, verifyAccessToken, verifyClientSecret, verifyCsrfToken, verifyRefreshToken, verifyUserAccessToken, verifyUserRefreshToken };
|