@rebasepro/server 0.13.1-canary.gef9608c → 0.14.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/{admin_block-EmIHae6X.js → admin_block-H86dCPsj.js} +61 -2
- package/dist/admin_block-H86dCPsj.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +74 -6
- package/dist/api/errors.d.ts +12 -0
- package/dist/api/rest/api-generator.d.ts +26 -1
- package/dist/api/rest/idempotency.d.ts +25 -4
- package/dist/api/rest/query-parser.d.ts +15 -1
- package/dist/api/rest/write-validation.d.ts +40 -4
- package/dist/auth/adapter-middleware.d.ts +3 -1
- package/dist/auth/admin-roles.d.ts +24 -0
- package/dist/auth/apple-oauth.d.ts +16 -11
- package/dist/auth/bitbucket-oauth.d.ts +2 -4
- package/dist/auth/builtin-auth-adapter.d.ts +11 -0
- package/dist/auth/discord-oauth.d.ts +5 -6
- package/dist/auth/facebook-oauth.d.ts +2 -4
- package/dist/auth/github-oauth.d.ts +2 -4
- package/dist/auth/gitlab-oauth.d.ts +6 -4
- package/dist/auth/google-oauth.d.ts +1 -0
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/interfaces.d.ts +81 -1
- package/dist/auth/jwt.d.ts +40 -0
- package/dist/auth/linkedin-oauth.d.ts +2 -4
- package/dist/auth/mfa-crypto.d.ts +54 -3
- package/dist/auth/mfa-gate.d.ts +42 -0
- package/dist/auth/mfa-routes.d.ts +26 -1
- package/dist/auth/mfa.d.ts +16 -0
- package/dist/auth/microsoft-oauth.d.ts +19 -4
- package/dist/auth/middleware.d.ts +26 -0
- package/dist/auth/oauth-code-flow.d.ts +66 -0
- package/dist/auth/oauth-signin-policy.d.ts +61 -0
- package/dist/auth/oidc-id-token.d.ts +61 -0
- package/dist/auth/rate-limiter.d.ts +62 -5
- package/dist/auth/registration-policy.d.ts +31 -0
- package/dist/auth/rls-scope.d.ts +25 -0
- package/dist/auth/routes.d.ts +15 -0
- package/dist/auth/slack-oauth.d.ts +2 -4
- package/dist/auth/spotify-oauth.d.ts +2 -4
- package/dist/auth/token-revocation.d.ts +38 -0
- package/dist/auth/twitter-oauth.d.ts +2 -5
- package/dist/{auth-DU4ShERo.js → auth-CYoPVf-E.js} +1985 -395
- package/dist/auth-CYoPVf-E.js.map +1 -0
- package/dist/{backup-BJiY_mM-.js → backup-C6ljYVTp.js} +2 -2
- package/dist/{backup-BJiY_mM-.js.map → backup-C6ljYVTp.js.map} +1 -1
- package/dist/boot/ddl-bootstrap.d.ts +70 -0
- package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-Bet-eCNJ.js} +3 -3
- package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
- package/dist/cron/cron-loader.d.ts +26 -1
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +13 -3
- package/dist/cron/define-cron.d.ts +17 -3
- package/dist/cron/index.d.ts +2 -2
- package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
- package/dist/cron-loader-YhhQeVBM.js.map +1 -0
- package/dist/{cron-routes-D5a9v1HY.js → cron-routes-maM_RlUu.js} +10 -4
- package/dist/cron-routes-maM_RlUu.js.map +1 -0
- package/dist/{cron-scheduler-BhFZWR0T.js → cron-scheduler-DIpYBmZP.js} +20 -8
- package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
- package/dist/{cron-store-Cu4sOseb.js → cron-store-Dvr4Y1sZ.js} +60 -51
- package/dist/cron-store-Dvr4Y1sZ.js.map +1 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
- package/dist/email/html.d.ts +54 -0
- package/dist/email/index.d.ts +3 -0
- package/dist/email/link-base.d.ts +39 -0
- package/dist/email/smtp-email-service.d.ts +7 -1
- package/dist/email/templates.d.ts +9 -1
- package/dist/email/types.d.ts +11 -1
- package/dist/env.d.ts +5 -0
- package/dist/{errors-BpudWAVU.js → errors-EBYiaJ2E.js} +14 -2
- package/dist/errors-EBYiaJ2E.js.map +1 -0
- package/dist/function-loader-DDS1v7YX.js +139 -0
- package/dist/function-loader-DDS1v7YX.js.map +1 -0
- package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
- package/dist/function-routes-Btcez1T-.js.map +1 -0
- package/dist/functions/define-function.d.ts +25 -6
- package/dist/functions/function-loader.d.ts +23 -0
- package/dist/functions/function-routes.d.ts +7 -1
- package/dist/functions/request-timeout.d.ts +34 -0
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.es.js +1893 -714
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +10 -1
- package/dist/init/process-safety.d.ts +25 -0
- package/dist/init.d.ts +38 -0
- package/dist/{jwt-CzeviDcB.js → jwt-_IFqfTOg.js} +57 -9
- package/dist/jwt-_IFqfTOg.js.map +1 -0
- package/dist/logger-DfvF_8r-.js +190 -0
- package/dist/logger-DfvF_8r-.js.map +1 -0
- package/dist/{openapi-generator-D5xsfVJY.js → openapi-generator-DPKtUC9X.js} +326 -55
- package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
- package/dist/request-timeout-RivJsME0.js +65 -0
- package/dist/request-timeout-RivJsME0.js.map +1 -0
- package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
- package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
- package/dist/serve-spa.d.ts +5 -4
- package/dist/services/outbound-url-guard.d.ts +53 -0
- package/dist/services/routed-realtime-service.d.ts +10 -2
- package/dist/services/webhook-service.d.ts +76 -1
- package/dist/singleton.d.ts +25 -8
- package/dist/{src-CKOZBpeJ.js → src-C7rkDGxA.js} +619 -70
- package/dist/src-C7rkDGxA.js.map +1 -0
- package/dist/{src-_qQ3RNCK.js → src-Cz9nMgUR.js} +93 -3
- package/dist/src-Cz9nMgUR.js.map +1 -0
- package/dist/storage/LocalStorageController.d.ts +12 -1
- package/dist/storage/image-transform.d.ts +57 -0
- package/dist/storage/keys.d.ts +98 -0
- package/dist/storage/routes.d.ts +11 -0
- package/dist/storage/tus-handler.d.ts +22 -2
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/sql.d.ts +11 -6
- package/package.json +6 -5
- package/dist/admin_block-EmIHae6X.js.map +0 -1
- package/dist/auth-DU4ShERo.js.map +0 -1
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/cron-loader-B1S2MCSl.js.map +0 -1
- package/dist/cron-routes-D5a9v1HY.js.map +0 -1
- package/dist/cron-scheduler-BhFZWR0T.js.map +0 -1
- package/dist/cron-store-Cu4sOseb.js.map +0 -1
- package/dist/errors-BpudWAVU.js.map +0 -1
- package/dist/function-loader-B_1fYfUY.js +0 -86
- package/dist/function-loader-B_1fYfUY.js.map +0 -1
- package/dist/function-routes-C0cLIy3N.js.map +0 -1
- package/dist/jwt-CzeviDcB.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/openapi-generator-D5xsfVJY.js.map +0 -1
- package/dist/schema-editor-routes-C1DxDnDR.js +0 -248
- package/dist/schema-editor-routes-C1DxDnDR.js.map +0 -1
- package/dist/src-CKOZBpeJ.js.map +0 -1
- package/dist/src-_qQ3RNCK.js.map +0 -1
|
@@ -55,7 +55,21 @@ export interface OAuthProviderProfile {
|
|
|
55
55
|
email: string;
|
|
56
56
|
displayName?: string | null;
|
|
57
57
|
photoUrl?: string | null;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Whether the provider reported that it verified this email address.
|
|
60
|
+
*
|
|
61
|
+
* This is not a display field. It is the sole authorization input to
|
|
62
|
+
* account linking: a `true` here lets the sign-in route attach the
|
|
63
|
+
* incoming identity to a pre-existing account that shares the address.
|
|
64
|
+
* Set it `true` **only** from a verification signal actually present in
|
|
65
|
+
* the provider's response — pass that signal through
|
|
66
|
+
* `providerVerifiedEmail()` rather than writing a literal, so a provider
|
|
67
|
+
* that reports nothing comes back `false`.
|
|
68
|
+
*
|
|
69
|
+
* `false` is not a failure: the user signs in and, if they already have an
|
|
70
|
+
* account, links through `POST /auth/link/<provider>`, which proves
|
|
71
|
+
* ownership with a live session instead of an unverified address.
|
|
72
|
+
*/
|
|
59
73
|
emailVerified?: boolean;
|
|
60
74
|
}
|
|
61
75
|
/**
|
|
@@ -138,6 +152,15 @@ export interface RefreshTokenInfo {
|
|
|
138
152
|
revoked?: boolean;
|
|
139
153
|
/** When the sign-in happened; carried across rotations, unlike createdAt. */
|
|
140
154
|
sessionStartedAt?: Date;
|
|
155
|
+
/**
|
|
156
|
+
* The assurance level this session was established at. See
|
|
157
|
+
* {@link RefreshTokenSession.aal} — refresh reads it from here and mints
|
|
158
|
+
* the replacement access token at the same level.
|
|
159
|
+
*
|
|
160
|
+
* Absent on rows written before this column existed, and on repositories
|
|
161
|
+
* that do not store it; both read as `aal1`, the restrictive value.
|
|
162
|
+
*/
|
|
163
|
+
aal?: "aal1" | "aal2";
|
|
141
164
|
}
|
|
142
165
|
/**
|
|
143
166
|
* Identity of the sign-in a refresh token belongs to, threaded through
|
|
@@ -146,6 +169,16 @@ export interface RefreshTokenInfo {
|
|
|
146
169
|
export interface RefreshTokenSession {
|
|
147
170
|
id: string;
|
|
148
171
|
startedAt: Date;
|
|
172
|
+
/**
|
|
173
|
+
* How this session was established: `aal2` only where a second factor was
|
|
174
|
+
* actually presented.
|
|
175
|
+
*
|
|
176
|
+
* Persisted because refresh has nothing else to go on. Deriving it at
|
|
177
|
+
* refresh time from "does this user have a factor?" would promote every
|
|
178
|
+
* pre-existing password-only session of a user who later enrolled — the
|
|
179
|
+
* assurance level is a property of the *sign-in*, not of the account.
|
|
180
|
+
*/
|
|
181
|
+
aal?: "aal1" | "aal2";
|
|
149
182
|
}
|
|
150
183
|
/**
|
|
151
184
|
* Password reset token info
|
|
@@ -408,6 +441,12 @@ export interface MfaFactor {
|
|
|
408
441
|
verified: boolean;
|
|
409
442
|
createdAt: Date;
|
|
410
443
|
updatedAt: Date;
|
|
444
|
+
/**
|
|
445
|
+
* The highest TOTP time step ever accepted for this factor. A code whose
|
|
446
|
+
* step is at or below it has already been spent — see
|
|
447
|
+
* {@link MfaRepository.claimMfaFactorCounter}.
|
|
448
|
+
*/
|
|
449
|
+
lastUsedCounter?: number | null;
|
|
411
450
|
}
|
|
412
451
|
/**
|
|
413
452
|
* MFA challenge information
|
|
@@ -418,6 +457,8 @@ export interface MfaChallengeInfo {
|
|
|
418
457
|
createdAt: Date;
|
|
419
458
|
verifiedAt?: Date;
|
|
420
459
|
ipAddress?: string;
|
|
460
|
+
/** Failed verifications recorded against this challenge so far. */
|
|
461
|
+
attempts?: number;
|
|
421
462
|
}
|
|
422
463
|
/**
|
|
423
464
|
* Recovery code data structure
|
|
@@ -450,6 +491,19 @@ export interface MfaRepository {
|
|
|
450
491
|
* Mark an MFA factor as verified
|
|
451
492
|
*/
|
|
452
493
|
verifyMfaFactor(factorId: string): Promise<void>;
|
|
494
|
+
/**
|
|
495
|
+
* Replace a factor's stored ciphertext with an equivalent one.
|
|
496
|
+
*
|
|
497
|
+
* Used only to re-wrap a secret under the current `MFA_ENCRYPTION_KEY`
|
|
498
|
+
* after it opened with an older one, so that a key rotation completes as
|
|
499
|
+
* users sign in rather than needing a migration. The plaintext secret is
|
|
500
|
+
* unchanged, so a repository that does not implement this is not broken —
|
|
501
|
+
* its factors simply stay on the key that wrote them, and that key has to
|
|
502
|
+
* remain in `MFA_ENCRYPTION_KEY_PREVIOUS`.
|
|
503
|
+
*
|
|
504
|
+
* Optional for that reason: callers must check before calling.
|
|
505
|
+
*/
|
|
506
|
+
updateMfaFactorSecret?(factorId: string, secretEncrypted: string): Promise<void>;
|
|
453
507
|
/**
|
|
454
508
|
* Delete an MFA factor
|
|
455
509
|
*/
|
|
@@ -486,6 +540,32 @@ export interface MfaRepository {
|
|
|
486
540
|
* Check if a user has any verified MFA factors
|
|
487
541
|
*/
|
|
488
542
|
hasVerifiedMfaFactors(uid: string): Promise<boolean>;
|
|
543
|
+
/**
|
|
544
|
+
* Claim a TOTP time step for a factor: succeed once, then never again for
|
|
545
|
+
* that step or any earlier one.
|
|
546
|
+
*
|
|
547
|
+
* One statement, claim-and-act — `UPDATE … WHERE last_used_counter IS NULL
|
|
548
|
+
* OR last_used_counter < $counter RETURNING id`. A read-then-write would
|
|
549
|
+
* let two requests carrying the same code both pass the check before either
|
|
550
|
+
* wrote, which is precisely the replay this exists to stop.
|
|
551
|
+
*
|
|
552
|
+
* Optional so a repository written against an older release still compiles;
|
|
553
|
+
* where it is absent, an accepted code stays replayable for the rest of its
|
|
554
|
+
* window and the route says so in the log.
|
|
555
|
+
*
|
|
556
|
+
* @returns true when the step was claimed by this call, false when it had
|
|
557
|
+
* already been spent.
|
|
558
|
+
*/
|
|
559
|
+
claimMfaFactorCounter?(factorId: string, counter: number): Promise<boolean>;
|
|
560
|
+
/**
|
|
561
|
+
* Record a failed verification against a challenge and return the new
|
|
562
|
+
* total. Atomic (`UPDATE … SET attempts = attempts + 1 RETURNING attempts`)
|
|
563
|
+
* so concurrent guesses cannot share one increment.
|
|
564
|
+
*
|
|
565
|
+
* Optional; where it is absent the route falls back to the rate limiters
|
|
566
|
+
* alone.
|
|
567
|
+
*/
|
|
568
|
+
recordMfaChallengeAttempt?(challengeId: string): Promise<number>;
|
|
489
569
|
}
|
|
490
570
|
/**
|
|
491
571
|
* Combined auth repository interface for convenience
|
package/dist/auth/jwt.d.ts
CHANGED
|
@@ -14,6 +14,18 @@ export interface AccessTokenPayload {
|
|
|
14
14
|
roles: string[];
|
|
15
15
|
/** Authentication Assurance Level: aal1 = password/oauth, aal2 = MFA verified */
|
|
16
16
|
aal?: "aal1" | "aal2";
|
|
17
|
+
/**
|
|
18
|
+
* When the token was issued, in seconds since the epoch — the standard JWT
|
|
19
|
+
* `iat` claim, which `jsonwebtoken` sets on every token it signs.
|
|
20
|
+
*
|
|
21
|
+
* Carried through verification because revocation needs it: `logout`,
|
|
22
|
+
* `change-password`, `reset-password` and `DELETE /auth/sessions` all stamp
|
|
23
|
+
* a `tokensValidAfter` watermark on the user, and a token is void if it was
|
|
24
|
+
* issued before that mark. `verifyAccessToken` used to rebuild the payload
|
|
25
|
+
* from three claims and drop this one, so nothing downstream could make the
|
|
26
|
+
* comparison and the watermark was read on exactly one path — refresh.
|
|
27
|
+
*/
|
|
28
|
+
iat?: number;
|
|
17
29
|
/** Email claim from the JWT, if present */
|
|
18
30
|
email?: string;
|
|
19
31
|
/** Display name claim from the JWT, if present */
|
|
@@ -98,6 +110,34 @@ export declare function getRefreshTokenTtlMs(): number;
|
|
|
98
110
|
* Calculate refresh token expiration date
|
|
99
111
|
*/
|
|
100
112
|
export declare function getRefreshTokenExpiry(): Date;
|
|
113
|
+
/**
|
|
114
|
+
* The `purpose` claim carried by a credential that stands between "first factor
|
|
115
|
+
* accepted" and "session issued".
|
|
116
|
+
*
|
|
117
|
+
* A pre-auth token is NOT a session and must never be usable as one:
|
|
118
|
+
* {@link verifyAccessToken} refuses any token carrying a `purpose`, so this
|
|
119
|
+
* value cannot authenticate a request no matter which route it is presented to.
|
|
120
|
+
* The only thing that reads it is the MFA challenge pair, which exchanges it —
|
|
121
|
+
* plus a second factor — for a real session.
|
|
122
|
+
*/
|
|
123
|
+
export declare const MFA_PENDING_PURPOSE = "mfa-pending";
|
|
124
|
+
/**
|
|
125
|
+
* Mint the short-lived credential handed back with an `MFA_REQUIRED` response.
|
|
126
|
+
*
|
|
127
|
+
* Short-lived on purpose: it is the window in which a caller who has proven the
|
|
128
|
+
* first factor may present the second, not a session to be carried around. Five
|
|
129
|
+
* minutes matches the challenge TTL.
|
|
130
|
+
*/
|
|
131
|
+
export declare function generateMfaPendingToken(uid: string, expiresInSeconds?: number): string;
|
|
132
|
+
/**
|
|
133
|
+
* Verify a pre-auth token and return the user it was minted for.
|
|
134
|
+
*
|
|
135
|
+
* Returns `null` for anything else — including a perfectly valid *access*
|
|
136
|
+
* token, which must not be interchangeable with this one in either direction.
|
|
137
|
+
*/
|
|
138
|
+
export declare function verifyMfaPendingToken(token: string): {
|
|
139
|
+
uid: string;
|
|
140
|
+
} | null;
|
|
101
141
|
export interface DownloadTokenPayload {
|
|
102
142
|
purpose: "file-read";
|
|
103
143
|
path: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OAuthProvider } from "./interfaces";
|
|
2
|
+
import { type OAuthCodeFlowPayload } from "./oauth-code-flow";
|
|
2
3
|
export interface LinkedinUserInfo {
|
|
3
4
|
linkedinId: string;
|
|
4
5
|
email: string;
|
|
@@ -12,7 +13,4 @@ export interface LinkedinUserInfo {
|
|
|
12
13
|
export declare function createLinkedinProvider(config: {
|
|
13
14
|
clientId: string;
|
|
14
15
|
clientSecret: string;
|
|
15
|
-
}): OAuthProvider<
|
|
16
|
-
code: string;
|
|
17
|
-
redirectUri: string;
|
|
18
|
-
}>;
|
|
16
|
+
}): OAuthProvider<OAuthCodeFlowPayload>;
|
|
@@ -3,7 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - Derives a 32-byte key via SHA-256 from `MFA_ENCRYPTION_KEY` or `JWT_SECRET`.
|
|
5
5
|
* - Generates a random 12-byte IV per encryption call.
|
|
6
|
-
* -
|
|
6
|
+
* - Stamps the key that made the ciphertext, so the key can be changed without
|
|
7
|
+
* locking every enrolled user out — see "Rotating the key" below.
|
|
8
|
+
*
|
|
9
|
+
* ## Rotating the key
|
|
10
|
+
*
|
|
11
|
+
* A ciphertext used to be `iv:authTag:ciphertext` and recorded nothing about
|
|
12
|
+
* which key produced it, while the key was re-derived from the environment on
|
|
13
|
+
* every call. Setting `MFA_ENCRYPTION_KEY` on a deployment that had been
|
|
14
|
+
* falling back to `JWT_SECRET` therefore made every stored factor undecryptable
|
|
15
|
+
* — AES-GCM authenticates, so it did not return a wrong secret, it threw, and
|
|
16
|
+
* every MFA sign-in failed until the variable was removed again.
|
|
17
|
+
*
|
|
18
|
+
* Now a ciphertext carries `v1.<keyId>`, where `keyId` is derived from the key
|
|
19
|
+
* itself (no key names to keep in sync), and decryption runs against a list of
|
|
20
|
+
* candidates: `MFA_ENCRYPTION_KEY`, then each comma-separated entry in
|
|
21
|
+
* `MFA_ENCRYPTION_KEY_PREVIOUS`, then `JWT_SECRET`. To rotate:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Deploy this code first.** It reads both formats. Doing it the other way
|
|
24
|
+
* round — setting the key before the readers understand the old ciphertexts
|
|
25
|
+
* — is the outage this exists to prevent.
|
|
26
|
+
* 2. Move the old value into `MFA_ENCRYPTION_KEY_PREVIOUS` and set the new one
|
|
27
|
+
* as `MFA_ENCRYPTION_KEY`.
|
|
28
|
+
* 3. Each factor is re-wrapped with the current key the next time its owner
|
|
29
|
+
* authenticates (`openTotpSecret` reports it via `rewrapped`).
|
|
30
|
+
* 4. Once every enrolled user has signed in, drop `MFA_ENCRYPTION_KEY_PREVIOUS`.
|
|
31
|
+
* Any factor still on the old key then fails loudly, naming the key id,
|
|
32
|
+
* rather than being silently unreadable.
|
|
33
|
+
*
|
|
34
|
+
* Trial decryption across candidates is safe here: GCM's auth tag is a 128-bit
|
|
35
|
+
* MAC, so a wrong key throws instead of yielding a plausible-looking secret.
|
|
7
36
|
*
|
|
8
37
|
* @module
|
|
9
38
|
*/
|
|
@@ -11,13 +40,35 @@
|
|
|
11
40
|
* Encrypt a plaintext TOTP secret.
|
|
12
41
|
*
|
|
13
42
|
* @param plaintext - The Base32 TOTP secret to encrypt.
|
|
14
|
-
* @returns A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
|
|
43
|
+
* @returns A string in the format `v1.<keyId>:iv_hex:authTag_hex:ciphertext_hex`.
|
|
15
44
|
*/
|
|
16
45
|
export declare function encryptTotpSecret(plaintext: string): string;
|
|
46
|
+
/** What a factor's stored secret opened to, and whether it should be re-stored. */
|
|
47
|
+
export interface OpenedTotpSecret {
|
|
48
|
+
/** The Base32 TOTP secret. */
|
|
49
|
+
secret: string;
|
|
50
|
+
/**
|
|
51
|
+
* Set when the secret opened with something other than the current key —
|
|
52
|
+
* the same secret, re-encrypted under it. Persist this and the factor
|
|
53
|
+
* stops depending on the old key. `null` when it is already current.
|
|
54
|
+
*/
|
|
55
|
+
rewrapped: string | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Decrypt a stored TOTP secret, reporting whether it wants re-wrapping.
|
|
59
|
+
*
|
|
60
|
+
* Accepts both the key-stamped format and the original unstamped
|
|
61
|
+
* `iv:authTag:ciphertext`, which is tried against each candidate in turn.
|
|
62
|
+
*/
|
|
63
|
+
export declare function openTotpSecret(ciphertext: string): OpenedTotpSecret;
|
|
17
64
|
/**
|
|
18
65
|
* Decrypt a previously encrypted TOTP secret.
|
|
19
66
|
*
|
|
20
|
-
* @
|
|
67
|
+
* Prefer {@link openTotpSecret} on any path that can persist the result —
|
|
68
|
+
* this wrapper discards the re-wrapped form, so a factor read through it stays
|
|
69
|
+
* on whichever key it was written with.
|
|
70
|
+
*
|
|
71
|
+
* @param ciphertext - A stored TOTP secret, in either format.
|
|
21
72
|
* @returns The original Base32 TOTP secret.
|
|
22
73
|
*/
|
|
23
74
|
export declare function decryptTotpSecret(ciphertext: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate between "first factor accepted" and "session issued".
|
|
3
|
+
*
|
|
4
|
+
* MFA used to be modelled as an optional step-up that no resource required: a
|
|
5
|
+
* user could enrol TOTP, print recovery codes, and change nothing about what a
|
|
6
|
+
* stolen password bought, because every session-minting route issued a full
|
|
7
|
+
* `aal1` token without ever asking whether the account had a second factor.
|
|
8
|
+
* This module is the ask. It lives apart from the routes so that there is one
|
|
9
|
+
* decision, made once, that `createSessionAndTokens` cannot be extended past —
|
|
10
|
+
* a new sign-in route inherits the gate by construction rather than by the
|
|
11
|
+
* author remembering it.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { AuthRepository } from "./interfaces";
|
|
16
|
+
/** Error code a client watches for to switch a sign-in into its second step. */
|
|
17
|
+
export declare const MFA_REQUIRED_CODE = "MFA_REQUIRED";
|
|
18
|
+
/** Shape carried in `error.details` of an {@link MFA_REQUIRED_CODE} response. */
|
|
19
|
+
export interface MfaRequiredDetails {
|
|
20
|
+
/**
|
|
21
|
+
* Short-lived, purpose-scoped credential proving the first factor was
|
|
22
|
+
* accepted. It is refused as a session everywhere (`verifyAccessToken`
|
|
23
|
+
* rejects purpose-scoped tokens); `POST /auth/mfa/challenge` and
|
|
24
|
+
* `POST /auth/mfa/challenge/verify` are the only routes that accept it.
|
|
25
|
+
*/
|
|
26
|
+
mfaToken: string;
|
|
27
|
+
/** The verified factors the caller may answer the challenge with. */
|
|
28
|
+
factors: Array<{
|
|
29
|
+
id: string;
|
|
30
|
+
factorType: string;
|
|
31
|
+
friendlyName?: string;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Refuse to mint a session for an account whose second factor has not been
|
|
36
|
+
* presented, handing back what the client needs to present it.
|
|
37
|
+
*
|
|
38
|
+
* A 401 rather than a 200 with a different body: a client that has not been
|
|
39
|
+
* taught about MFA must fail, not proceed. The credential in `details` is
|
|
40
|
+
* useless for anything except the challenge routes.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertMfaSatisfied(authRepo: AuthRepository, uid: string): Promise<void>;
|
|
@@ -4,4 +4,29 @@ import { HonoEnv } from "../api/types";
|
|
|
4
4
|
import type { AuthModuleConfig } from "./routes";
|
|
5
5
|
import { resolveAuthHooks } from "./auth-hooks";
|
|
6
6
|
import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
|
|
7
|
-
|
|
7
|
+
interface MfaRoutesConfig {
|
|
8
|
+
router: Hono<HonoEnv>;
|
|
9
|
+
config: AuthModuleConfig;
|
|
10
|
+
ops: ReturnType<typeof resolveAuthHooks>;
|
|
11
|
+
parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T;
|
|
12
|
+
buildAuthResponse: (user: {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
displayName?: string | null;
|
|
16
|
+
photoUrl?: string | null;
|
|
17
|
+
emailVerified?: boolean;
|
|
18
|
+
isAnonymous?: boolean;
|
|
19
|
+
metadata?: Record<string, unknown> | null;
|
|
20
|
+
}, roleIds: string[], accessToken: string, refreshToken: string, providerId: string) => unknown;
|
|
21
|
+
createSessionAndTokens: (uid: string, userAgent: string, ipAddress: string, options?: {
|
|
22
|
+
skipMfaGate?: boolean;
|
|
23
|
+
aal?: "aal1" | "aal2";
|
|
24
|
+
}) => Promise<{
|
|
25
|
+
roleIds: string[];
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken: string;
|
|
28
|
+
}>;
|
|
29
|
+
applyTransformHook?: (response: AuthResponsePayload, method: TransformAuthResponseContext["method"], request: Request, uid: string) => Promise<AuthResponsePayload>;
|
|
30
|
+
}
|
|
31
|
+
export declare function mountMfaRoutes(opts: MfaRoutesConfig): void;
|
|
32
|
+
export {};
|
package/dist/auth/mfa.d.ts
CHANGED
|
@@ -25,6 +25,22 @@ export declare function generateTotp(secret: Buffer, timeStep?: number): string;
|
|
|
25
25
|
* @returns true if the token is valid within the window
|
|
26
26
|
*/
|
|
27
27
|
export declare function verifyTotp(secret: Buffer, token: string, window?: number): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Verify a TOTP token and report *which* time step matched.
|
|
30
|
+
*
|
|
31
|
+
* RFC 6238 §5.2 requires that an accepted OTP not be accepted a second time,
|
|
32
|
+
* and a boolean cannot express what was accepted: with `window = 1` the same
|
|
33
|
+
* six digits stay valid for up to 90 seconds, so a code observed once — by a
|
|
34
|
+
* phishing relay, over a shoulder, in a pasted support message — can be
|
|
35
|
+
* presented again for the rest of that window. The caller records the returned
|
|
36
|
+
* counter against the factor and refuses anything at or below it.
|
|
37
|
+
*
|
|
38
|
+
* @param secret - The shared secret as a Buffer
|
|
39
|
+
* @param token - The 6-digit TOTP code to verify
|
|
40
|
+
* @param window - Number of time steps to check on each side (default: 1)
|
|
41
|
+
* @returns The matched time step, or `null` when no step in the window matches
|
|
42
|
+
*/
|
|
43
|
+
export declare function verifyTotpCounter(secret: Buffer, token: string, window?: number): number | null;
|
|
28
44
|
/**
|
|
29
45
|
* Generate a new TOTP secret and return the setup information
|
|
30
46
|
*
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import type { OAuthProvider } from "./interfaces";
|
|
2
|
+
import { type OAuthCodeFlowPayload } from "./oauth-code-flow";
|
|
2
3
|
/**
|
|
3
4
|
* Creates a Microsoft / Entra ID (Azure AD) OAuth Provider integration.
|
|
4
5
|
*
|
|
5
6
|
* Supports both personal Microsoft accounts and work/school (Azure AD) accounts
|
|
6
7
|
* via the "common" tenant endpoint. Uses the authorization code flow.
|
|
8
|
+
*
|
|
9
|
+
* ## On `emailVerified`
|
|
10
|
+
*
|
|
11
|
+
* Microsoft Graph exposes no email-verification field, and `mail` is *not* a
|
|
12
|
+
* substitute: it is a directory attribute a tenant administrator sets to any
|
|
13
|
+
* string they like, through Graph or AAD Connect sync. With the default
|
|
14
|
+
* `tenantId: "common"` every Entra tenant in the world is an accepted issuer,
|
|
15
|
+
* so "there is a `mail` value" would mean "anybody who can create a free
|
|
16
|
+
* tenant can nominate any address" — including one that already has an account
|
|
17
|
+
* here.
|
|
18
|
+
*
|
|
19
|
+
* The only signal Microsoft offers is the `xms_edov` ("email domain owner
|
|
20
|
+
* verified") optional claim on the id_token, so that is what this provider
|
|
21
|
+
* reads, off a signature-verified token. If the app registration does not emit
|
|
22
|
+
* `xms_edov`, sign-in still works and the address comes back unverified —
|
|
23
|
+
* which routes the user to `POST /auth/link/microsoft` rather than silently
|
|
24
|
+
* handing them somebody else's account.
|
|
7
25
|
*/
|
|
8
26
|
export declare function createMicrosoftProvider(config: {
|
|
9
27
|
clientId: string;
|
|
10
28
|
clientSecret: string;
|
|
11
29
|
/** Tenant ID. Defaults to "common" which allows both personal and organizational accounts. */
|
|
12
30
|
tenantId?: string;
|
|
13
|
-
}): OAuthProvider<
|
|
14
|
-
code: string;
|
|
15
|
-
redirectUri: string;
|
|
16
|
-
}>;
|
|
31
|
+
}): OAuthProvider<OAuthCodeFlowPayload>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MiddlewareHandler, Context } from "hono";
|
|
2
|
+
import type { AuthRepository } from "./interfaces";
|
|
2
3
|
import { DataDriver } from "@rebasepro/types";
|
|
3
4
|
import { AccessTokenPayload } from "./jwt";
|
|
4
5
|
import type { HonoEnv } from "../api/types";
|
|
@@ -85,6 +86,31 @@ export declare const requireAuth: MiddlewareHandler<HonoEnv>;
|
|
|
85
86
|
*/
|
|
86
87
|
export declare function createRequireAuth(options?: {
|
|
87
88
|
serviceKey?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Read this user's roles from the database, replacing whatever the token
|
|
91
|
+
* claims.
|
|
92
|
+
*
|
|
93
|
+
* Without it, `requireAdmin` downstream trusts the `roles` array inside the
|
|
94
|
+
* access token — so demoting an administrator does nothing until that token
|
|
95
|
+
* expires, and in the meantime the demoted admin can call
|
|
96
|
+
* `PUT /api/admin/users/<self>` and put the role back for good. The data
|
|
97
|
+
* plane already re-reads roles per request (`builtin-auth-adapter`); the
|
|
98
|
+
* admin routes, which are the ones that can grant roles, did not.
|
|
99
|
+
*
|
|
100
|
+
* Optional because two call sites have no repository in scope. A route that
|
|
101
|
+
* can supply one should: the cost is one indexed lookup on requests that
|
|
102
|
+
* are already administrative.
|
|
103
|
+
*/
|
|
104
|
+
resolveRoles?: (uid: string) => Promise<string[]>;
|
|
105
|
+
/**
|
|
106
|
+
* Repository used to check the token-revocation watermark.
|
|
107
|
+
*
|
|
108
|
+
* Separate from {@link resolveRoles} only because that one is a narrow
|
|
109
|
+
* function; a caller with a repository should pass it here so a token
|
|
110
|
+
* invalidated by `logout` or a password reset stops working on admin routes
|
|
111
|
+
* too, not just on the data plane.
|
|
112
|
+
*/
|
|
113
|
+
revocationRepo?: Pick<AuthRepository, "getTokensValidAfter">;
|
|
88
114
|
}): MiddlewareHandler<HonoEnv>;
|
|
89
115
|
/**
|
|
90
116
|
* Middleware that requires the user to have an admin or schema-admin role.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The authorization-code request shape every OAuth provider accepts, and the
|
|
4
|
+
* two derivations of it that were previously copy-pasted twelve times.
|
|
5
|
+
*
|
|
6
|
+
* Twelve providers declared their own `z.object({ code, redirectUri })` and
|
|
7
|
+
* each decided independently whether to accept a PKCE verifier (one did) and
|
|
8
|
+
* how to read a verification signal off the provider's profile response (five
|
|
9
|
+
* skipped the question and hardcoded `true`). One predicate, twelve
|
|
10
|
+
* implementations — so the controls below live here and every provider calls
|
|
11
|
+
* them, which is what makes it impossible for a thirteenth provider to ship
|
|
12
|
+
* without them.
|
|
13
|
+
*/
|
|
14
|
+
/** The request body every authorization-code provider consumes. */
|
|
15
|
+
export interface OAuthCodeFlowPayload {
|
|
16
|
+
/** Authorization code returned to the client by the provider. */
|
|
17
|
+
code: string;
|
|
18
|
+
/**
|
|
19
|
+
* Redirect URI the code was issued against. Echoed to the token endpoint,
|
|
20
|
+
* which is what binds the code to the client that started the flow — and
|
|
21
|
+
* why the route allowlists it before `verify` ever runs
|
|
22
|
+
* (see `isRedirectUriAllowed`).
|
|
23
|
+
*/
|
|
24
|
+
redirectUri: string;
|
|
25
|
+
/**
|
|
26
|
+
* PKCE verifier, when the client started the flow with a
|
|
27
|
+
* `code_challenge`. Forwarded to the token endpoint verbatim.
|
|
28
|
+
*/
|
|
29
|
+
codeVerifier?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the request schema for an authorization-code provider.
|
|
33
|
+
*
|
|
34
|
+
* `pkce` defaults to `"optional"`: the client decides whether it sent a
|
|
35
|
+
* `code_challenge`, and providers that do not implement PKCE simply never see
|
|
36
|
+
* a verifier because their clients never generate one. `"required"` is for
|
|
37
|
+
* providers that mandate PKCE (Twitter/X).
|
|
38
|
+
*/
|
|
39
|
+
export declare function oauthCodeFlowSchema(opts?: {
|
|
40
|
+
pkce?: "required" | "optional";
|
|
41
|
+
}): z.ZodObject<{
|
|
42
|
+
code: z.ZodString;
|
|
43
|
+
redirectUri: z.ZodString;
|
|
44
|
+
codeVerifier: z.ZodString | z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
/**
|
|
47
|
+
* The `code_verifier` token-endpoint parameter, or nothing.
|
|
48
|
+
*
|
|
49
|
+
* Returned as a spreadable object so a provider adds PKCE with one `...` in
|
|
50
|
+
* its existing body literal, rather than branching.
|
|
51
|
+
*/
|
|
52
|
+
export declare function pkceTokenParams(codeVerifier?: string): Record<string, string>;
|
|
53
|
+
/**
|
|
54
|
+
* Normalise a provider's email-verification signal into the boolean that
|
|
55
|
+
* `OAuthProviderProfile.emailVerified` promises.
|
|
56
|
+
*
|
|
57
|
+
* The whole point is the default: anything that is not an affirmative
|
|
58
|
+
* verification signal — `undefined`, `null`, a missing field, an empty string,
|
|
59
|
+
* `false` — becomes `false`. A provider that reports nothing therefore cannot
|
|
60
|
+
* accidentally assert that it verified the address, which is exactly the
|
|
61
|
+
* mistake five providers had made.
|
|
62
|
+
*
|
|
63
|
+
* Accepts the string forms because OIDC issuers are inconsistent about whether
|
|
64
|
+
* `email_verified` is a JSON boolean or a string.
|
|
65
|
+
*/
|
|
66
|
+
export declare function providerVerifiedEmail(signal: unknown): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two decisions the OAuth sign-in route makes before it will hand a caller
|
|
3
|
+
* an existing account, extracted so they are stated once for all twelve
|
|
4
|
+
* providers and can be tested without a provider, a network or a database.
|
|
5
|
+
*/
|
|
6
|
+
/** Why an incoming OAuth identity may not be auto-attached to an existing account. */
|
|
7
|
+
export type AutoLinkRefusal =
|
|
8
|
+
/** The provider did not report that it verified the address. */
|
|
9
|
+
"provider-email-unverified"
|
|
10
|
+
/**
|
|
11
|
+
* The local account holds a password nobody ever proved they own: it was
|
|
12
|
+
* created through `POST /auth/register`, which does not verify the
|
|
13
|
+
* address. Attaching a provider identity to it would hand the session to
|
|
14
|
+
* whoever registered the address first — the classic pre-hijack.
|
|
15
|
+
*/
|
|
16
|
+
| "local-account-unverified";
|
|
17
|
+
export type AutoLinkDecision = {
|
|
18
|
+
allowed: true;
|
|
19
|
+
} | {
|
|
20
|
+
allowed: false;
|
|
21
|
+
reason: AutoLinkRefusal;
|
|
22
|
+
};
|
|
23
|
+
/** The part of an existing user row the decision depends on. */
|
|
24
|
+
export interface AutoLinkExistingUser {
|
|
25
|
+
emailVerified: boolean;
|
|
26
|
+
passwordHash?: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* May an OAuth identity be attached to a pre-existing account found *by email*?
|
|
30
|
+
*
|
|
31
|
+
* Both sides have to be trustworthy, and only one of them used to be checked:
|
|
32
|
+
*
|
|
33
|
+
* - the **provider** must have verified the address, or the caller has not
|
|
34
|
+
* shown they control it;
|
|
35
|
+
* - the **local account** must itself be trustworthy — either its address was
|
|
36
|
+
* verified, or it has no password at all (it was created by an OAuth
|
|
37
|
+
* sign-in or an invitation, so there is no credential an attacker could
|
|
38
|
+
* have planted in advance).
|
|
39
|
+
*
|
|
40
|
+
* A refusal is not a dead end: `POST /auth/link/<provider>` attaches the
|
|
41
|
+
* identity once the caller proves ownership by holding a session.
|
|
42
|
+
*/
|
|
43
|
+
export declare function decideOAuthAutoLink(args: {
|
|
44
|
+
providerEmailVerified: boolean | undefined;
|
|
45
|
+
existingUser: AutoLinkExistingUser;
|
|
46
|
+
}): AutoLinkDecision;
|
|
47
|
+
/**
|
|
48
|
+
* Is `redirectUri` one the operator authorised?
|
|
49
|
+
*
|
|
50
|
+
* The provider's own registered-URI match is a real control but a coarse one:
|
|
51
|
+
* it authorises *every* URI registered on that OAuth client, so a `localhost`
|
|
52
|
+
* entry kept for development, or a second product sharing the client id, can
|
|
53
|
+
* mint codes this backend accepts. An empty or absent allowlist keeps the old
|
|
54
|
+
* behaviour (the provider's check is the only one) so existing deployments are
|
|
55
|
+
* unaffected; setting one narrows it to the origins this backend serves.
|
|
56
|
+
*
|
|
57
|
+
* Comparison is on origin plus path, with the origin lowercased and a trailing
|
|
58
|
+
* slash ignored — query strings and fragments are not part of the identity of a
|
|
59
|
+
* redirect URI, and neither is the case of the host.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isRedirectUriAllowed(redirectUri: string, allowlist?: string[]): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal JWKS-backed id_token verification, shared by the OIDC providers.
|
|
4
|
+
*
|
|
5
|
+
* Apple's id_token used to be `split(".")` + `JSON.parse`, justified by the
|
|
6
|
+
* comment "we only need the payload". That skips `aud` — nothing confirmed the
|
|
7
|
+
* token was minted for *this* Services ID rather than another one under the
|
|
8
|
+
* same Apple team — along with `iss`, `exp` and the signature. Microsoft did
|
|
9
|
+
* not request an id_token at all and inferred verification from a directory
|
|
10
|
+
* attribute instead.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately hand-rolled rather than pulling in a JWKS client: the only
|
|
13
|
+
* cryptography here is `jsonwebtoken`'s (already a dependency) and Node's own
|
|
14
|
+
* JWK→KeyObject import. What this file adds is key discovery and a cache.
|
|
15
|
+
*/
|
|
16
|
+
export interface OidcIdTokenClaims {
|
|
17
|
+
sub: string;
|
|
18
|
+
iss: string;
|
|
19
|
+
aud: string | string[];
|
|
20
|
+
exp: number;
|
|
21
|
+
email?: string;
|
|
22
|
+
email_verified?: boolean | string;
|
|
23
|
+
nonce?: string;
|
|
24
|
+
[claim: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface VerifyOidcIdTokenOptions {
|
|
27
|
+
/** The compact JWS to verify. */
|
|
28
|
+
idToken: string;
|
|
29
|
+
/** JWKS endpoint of the issuer. */
|
|
30
|
+
jwksUri: string;
|
|
31
|
+
/**
|
|
32
|
+
* Expected `iss`. A `RegExp` is for multi-tenant issuers whose tenant id is
|
|
33
|
+
* part of the issuer URL (Entra ID with `tenantId: "common"`); everything
|
|
34
|
+
* else passes the exact string.
|
|
35
|
+
*/
|
|
36
|
+
issuer: string | RegExp;
|
|
37
|
+
/** Expected `aud` — the client/services id this backend is configured with. */
|
|
38
|
+
audience: string;
|
|
39
|
+
/** Permitted signing algorithms. Defaults to RS256, which is what Apple and Entra use. */
|
|
40
|
+
algorithms?: jwt.Algorithm[];
|
|
41
|
+
/** Seconds of clock skew tolerated on `exp`/`iat`. Defaults to 60. */
|
|
42
|
+
clockToleranceSec?: number;
|
|
43
|
+
/** Expected `nonce`, when the client bound one to the authorization request. */
|
|
44
|
+
nonce?: string;
|
|
45
|
+
/** Injection point for tests; defaults to global `fetch`. */
|
|
46
|
+
fetchImpl?: typeof fetch;
|
|
47
|
+
}
|
|
48
|
+
/** Drops the JWKS cache. Exported for tests; nothing in the runtime calls it. */
|
|
49
|
+
export declare function resetJwksCache(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Verify an id_token's signature, `aud`, `iss` and `exp`, and return its claims.
|
|
52
|
+
*
|
|
53
|
+
* Throws on any failure — callers treat a throw as "reject this sign-in",
|
|
54
|
+
* never as "continue without the claims".
|
|
55
|
+
*/
|
|
56
|
+
export declare function verifyOidcIdToken(options: VerifyOidcIdTokenOptions): Promise<OidcIdTokenClaims>;
|
|
57
|
+
/**
|
|
58
|
+
* `verifyOidcIdToken` that reports failure as `null` and logs it, for the one
|
|
59
|
+
* call site that has to decide between "reject" and "degrade".
|
|
60
|
+
*/
|
|
61
|
+
export declare function tryVerifyOidcIdToken(providerId: string, options: VerifyOidcIdTokenOptions): Promise<OidcIdTokenClaims | null>;
|