@rebasepro/server 0.13.0 → 0.13.1-canary.g06dbe5b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
  2. package/dist/admin_block-H86dCPsj.js.map +1 -0
  3. package/dist/api/ast-schema-editor.d.ts +74 -6
  4. package/dist/api/errors.d.ts +0 -6
  5. package/dist/api/openapi-generator.d.ts +14 -0
  6. package/dist/api/rest/api-generator.d.ts +1 -1
  7. package/dist/api/rest/idempotency.d.ts +62 -4
  8. package/dist/api/rest/query-parser.d.ts +15 -1
  9. package/dist/api/rest/write-validation.d.ts +52 -9
  10. package/dist/auth/adapter-middleware.d.ts +3 -1
  11. package/dist/auth/apple-oauth.d.ts +16 -11
  12. package/dist/auth/bitbucket-oauth.d.ts +2 -4
  13. package/dist/auth/builtin-auth-adapter.d.ts +2 -0
  14. package/dist/auth/discord-oauth.d.ts +5 -6
  15. package/dist/auth/facebook-oauth.d.ts +2 -4
  16. package/dist/auth/github-oauth.d.ts +2 -4
  17. package/dist/auth/gitlab-oauth.d.ts +6 -4
  18. package/dist/auth/google-oauth.d.ts +1 -0
  19. package/dist/auth/index.d.ts +8 -1
  20. package/dist/auth/interfaces.d.ts +68 -1
  21. package/dist/auth/jwt.d.ts +40 -0
  22. package/dist/auth/linkedin-oauth.d.ts +2 -4
  23. package/dist/auth/mfa-gate.d.ts +42 -0
  24. package/dist/auth/mfa-routes.d.ts +26 -1
  25. package/dist/auth/mfa.d.ts +16 -0
  26. package/dist/auth/microsoft-oauth.d.ts +19 -4
  27. package/dist/auth/oauth-code-flow.d.ts +66 -0
  28. package/dist/auth/oauth-signin-policy.d.ts +61 -0
  29. package/dist/auth/oidc-id-token.d.ts +61 -0
  30. package/dist/auth/rate-limiter.d.ts +37 -2
  31. package/dist/auth/rls-scope.d.ts +25 -0
  32. package/dist/auth/routes.d.ts +8 -0
  33. package/dist/auth/slack-oauth.d.ts +2 -4
  34. package/dist/auth/spotify-oauth.d.ts +2 -4
  35. package/dist/auth/twitter-oauth.d.ts +2 -5
  36. package/dist/{auth-CuC9M2x6.js → auth-DkAHqvf5.js} +1604 -391
  37. package/dist/auth-DkAHqvf5.js.map +1 -0
  38. package/dist/{backup-CVggVhR2.js → backup-DLluVyuA.js} +2 -2
  39. package/dist/{backup-CVggVhR2.js.map → backup-DLluVyuA.js.map} +1 -1
  40. package/dist/boot/boot.d.ts +19 -0
  41. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  42. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-BB1U05sS.js} +3 -3
  43. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-BB1U05sS.js.map} +1 -1
  44. package/dist/cron/cron-scheduler.d.ts +8 -3
  45. package/dist/cron/define-cron.d.ts +17 -3
  46. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-3U5aGILy.js} +2 -2
  47. package/dist/{cron-loader-B1S2MCSl.js.map → cron-loader-3U5aGILy.js.map} +1 -1
  48. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-rZgwlOz4.js} +2 -2
  49. package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-rZgwlOz4.js.map} +1 -1
  50. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-FJAaCAXm.js} +30 -5
  51. package/dist/cron-scheduler-FJAaCAXm.js.map +1 -0
  52. package/dist/{cron-store-BywZsyfZ.js → cron-store-9NmUDfzL.js} +66 -47
  53. package/dist/cron-store-9NmUDfzL.js.map +1 -0
  54. package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
  55. package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
  56. package/dist/email/html.d.ts +54 -0
  57. package/dist/email/index.d.ts +3 -0
  58. package/dist/email/link-base.d.ts +39 -0
  59. package/dist/email/smtp-email-service.d.ts +7 -1
  60. package/dist/email/templates.d.ts +9 -1
  61. package/dist/email/types.d.ts +11 -1
  62. package/dist/{errors-CgkCzoj7.js → errors-B1WZEdsK.js} +3 -11
  63. package/dist/errors-B1WZEdsK.js.map +1 -0
  64. package/dist/function-loader-D1SwtCa5.js +139 -0
  65. package/dist/function-loader-D1SwtCa5.js.map +1 -0
  66. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  67. package/dist/function-routes-Btcez1T-.js.map +1 -0
  68. package/dist/functions/define-function.d.ts +25 -6
  69. package/dist/functions/function-loader.d.ts +23 -0
  70. package/dist/functions/function-routes.d.ts +7 -1
  71. package/dist/functions/request-timeout.d.ts +34 -0
  72. package/dist/history/history-routes.d.ts +6 -0
  73. package/dist/index.d.ts +2 -1
  74. package/dist/index.es.js +1960 -794
  75. package/dist/index.es.js.map +1 -1
  76. package/dist/init/docs.d.ts +10 -1
  77. package/dist/init/process-safety.d.ts +25 -0
  78. package/dist/init.d.ts +27 -0
  79. package/dist/{jwt-DD6EtpGj.js → jwt-Cuq6MwNy.js} +70 -8
  80. package/dist/jwt-Cuq6MwNy.js.map +1 -0
  81. package/dist/logger-DfvF_8r-.js +190 -0
  82. package/dist/logger-DfvF_8r-.js.map +1 -0
  83. package/dist/openapi-generator-BQxxxDpb.js +865 -0
  84. package/dist/openapi-generator-BQxxxDpb.js.map +1 -0
  85. package/dist/request-timeout-RivJsME0.js +65 -0
  86. package/dist/request-timeout-RivJsME0.js.map +1 -0
  87. package/dist/schema-editor-routes-DZFKGXgq.js +437 -0
  88. package/dist/schema-editor-routes-DZFKGXgq.js.map +1 -0
  89. package/dist/serve-spa.d.ts +5 -4
  90. package/dist/services/outbound-url-guard.d.ts +53 -0
  91. package/dist/services/routed-realtime-service.d.ts +10 -2
  92. package/dist/services/webhook-service.d.ts +76 -1
  93. package/dist/singleton.d.ts +25 -8
  94. package/dist/{src-Cum9kox5.js → src-By48Ffg0.js} +322 -70
  95. package/dist/src-By48Ffg0.js.map +1 -0
  96. package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
  97. package/dist/src-Ca6NhxKs.js.map +1 -0
  98. package/dist/storage/LocalStorageController.d.ts +12 -1
  99. package/dist/storage/image-transform.d.ts +57 -0
  100. package/dist/storage/keys.d.ts +98 -0
  101. package/dist/storage/routes.d.ts +11 -0
  102. package/dist/storage/tus-handler.d.ts +7 -1
  103. package/dist/utils/logger.d.ts +18 -0
  104. package/dist/utils/logging.d.ts +0 -4
  105. package/dist/utils/sql.d.ts +11 -6
  106. package/package.json +7 -6
  107. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  108. package/dist/auth-CuC9M2x6.js.map +0 -1
  109. package/dist/backend-CIxN4FVm.js +0 -15
  110. package/dist/backend-CIxN4FVm.js.map +0 -1
  111. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  112. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  113. package/dist/errors-CgkCzoj7.js.map +0 -1
  114. package/dist/function-loader-B_1fYfUY.js +0 -86
  115. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  116. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  117. package/dist/jwt-DD6EtpGj.js.map +0 -1
  118. package/dist/logger-BYU66ENZ.js +0 -94
  119. package/dist/logger-BYU66ENZ.js.map +0 -1
  120. package/dist/openapi-generator-BEwyiaAr.js +0 -597
  121. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  122. package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
  123. package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
  124. package/dist/src-Cum9kox5.js.map +0 -1
  125. 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
- /** Whether the OAuth provider has verified the user's email address */
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
@@ -486,6 +527,32 @@ export interface MfaRepository {
486
527
  * Check if a user has any verified MFA factors
487
528
  */
488
529
  hasVerifiedMfaFactors(uid: string): Promise<boolean>;
530
+ /**
531
+ * Claim a TOTP time step for a factor: succeed once, then never again for
532
+ * that step or any earlier one.
533
+ *
534
+ * One statement, claim-and-act — `UPDATE … WHERE last_used_counter IS NULL
535
+ * OR last_used_counter < $counter RETURNING id`. A read-then-write would
536
+ * let two requests carrying the same code both pass the check before either
537
+ * wrote, which is precisely the replay this exists to stop.
538
+ *
539
+ * Optional so a repository written against an older release still compiles;
540
+ * where it is absent, an accepted code stays replayable for the rest of its
541
+ * window and the route says so in the log.
542
+ *
543
+ * @returns true when the step was claimed by this call, false when it had
544
+ * already been spent.
545
+ */
546
+ claimMfaFactorCounter?(factorId: string, counter: number): Promise<boolean>;
547
+ /**
548
+ * Record a failed verification against a challenge and return the new
549
+ * total. Atomic (`UPDATE … SET attempts = attempts + 1 RETURNING attempts`)
550
+ * so concurrent guesses cannot share one increment.
551
+ *
552
+ * Optional; where it is absent the route falls back to the rate limiters
553
+ * alone.
554
+ */
555
+ recordMfaChallengeAttempt?(challengeId: string): Promise<number>;
489
556
  }
490
557
  /**
491
558
  * Combined auth repository interface for convenience
@@ -30,6 +30,18 @@ export interface AccessTokenPayload {
30
30
  * Validates the secret strength to prevent deployment with default/weak secrets.
31
31
  */
32
32
  export declare function configureJwt(config: JwtConfig): void;
33
+ /**
34
+ * Has this server been given a JWT secret?
35
+ *
36
+ * False on every backend that authenticates through an adapter — Firebase,
37
+ * Clerk, anything with its own tokens — because nothing calls
38
+ * {@link configureJwt} there. Signing paths still throw when it is false, since
39
+ * asking for a token from a server that cannot mint one is a mistake worth
40
+ * hearing about. Paths whose contract is *"tolerate the absence of auth"* must
41
+ * ask first: `optionalAuth` crashing a request because this backend does not do
42
+ * JWT is a 500 on a route that had already decided anonymous was acceptable.
43
+ */
44
+ export declare function isJwtConfigured(): boolean;
33
45
  /**
34
46
  * Generate an access token (short-lived, 1 hour by default)
35
47
  */
@@ -86,6 +98,34 @@ export declare function getRefreshTokenTtlMs(): number;
86
98
  * Calculate refresh token expiration date
87
99
  */
88
100
  export declare function getRefreshTokenExpiry(): Date;
101
+ /**
102
+ * The `purpose` claim carried by a credential that stands between "first factor
103
+ * accepted" and "session issued".
104
+ *
105
+ * A pre-auth token is NOT a session and must never be usable as one:
106
+ * {@link verifyAccessToken} refuses any token carrying a `purpose`, so this
107
+ * value cannot authenticate a request no matter which route it is presented to.
108
+ * The only thing that reads it is the MFA challenge pair, which exchanges it —
109
+ * plus a second factor — for a real session.
110
+ */
111
+ export declare const MFA_PENDING_PURPOSE = "mfa-pending";
112
+ /**
113
+ * Mint the short-lived credential handed back with an `MFA_REQUIRED` response.
114
+ *
115
+ * Short-lived on purpose: it is the window in which a caller who has proven the
116
+ * first factor may present the second, not a session to be carried around. Five
117
+ * minutes matches the challenge TTL.
118
+ */
119
+ export declare function generateMfaPendingToken(uid: string, expiresInSeconds?: number): string;
120
+ /**
121
+ * Verify a pre-auth token and return the user it was minted for.
122
+ *
123
+ * Returns `null` for anything else — including a perfectly valid *access*
124
+ * token, which must not be interchangeable with this one in either direction.
125
+ */
126
+ export declare function verifyMfaPendingToken(token: string): {
127
+ uid: string;
128
+ } | null;
89
129
  export interface DownloadTokenPayload {
90
130
  purpose: "file-read";
91
131
  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>;
@@ -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
- export declare function mountMfaRoutes(router: Hono<HonoEnv>, config: AuthModuleConfig, ops: ReturnType<typeof resolveAuthHooks>, parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T, applyTransformHook?: (response: AuthResponsePayload, method: TransformAuthResponseContext["method"], request: Request, uid: string) => Promise<AuthResponsePayload>): void;
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 {};
@@ -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>;
@@ -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>;
@@ -54,6 +54,26 @@ export declare const defaultAuthLimiter: MiddlewareHandler<HonoEnv>;
54
54
  * 50 requests per 15 minutes per IP.
55
55
  */
56
56
  export declare const strictAuthLimiter: MiddlewareHandler<HonoEnv>;
57
+ /**
58
+ * Limiter for `POST /auth/send-verification`, keyed by the authenticated user.
59
+ *
60
+ * That route had no limiter at all while every one of its email-sending
61
+ * siblings had one, and being authenticated is not the protection it looks
62
+ * like: registration does not verify the address it is given, so an attacker
63
+ * registers a victim's address, signs in to the account they just made, and
64
+ * loops the route. Each call mints a token and mails the victim.
65
+ *
66
+ * An IP limiter cannot express what is wanted here — the recipient is the
67
+ * quantity being protected, not the caller — so this one keys on the uid, which
68
+ * on this route is one-to-one with the recipient address (emails are unique per
69
+ * account). `strictAuthLimiter` still runs in front of it to bound the caller by
70
+ * IP; this bounds what any single address can be sent.
71
+ *
72
+ * Five per 15 minutes is generous for "I didn't get the email, resend it" and
73
+ * useless as a mail bomb. Unauthenticated requests fall back to the IP bucket so
74
+ * the limiter is never a no-op if it is ever mounted before the auth middleware.
75
+ */
76
+ export declare const verificationEmailLimiter: MiddlewareHandler<HonoEnv>;
57
77
  /**
58
78
  * Key generator for API-key-based rate limiting.
59
79
  *
@@ -73,13 +93,28 @@ export interface DataRateLimitConfig {
73
93
  user?: number;
74
94
  /**
75
95
  * Per IP, for requests with no principal at all. Default 300.
76
- * `null` disables the anonymous bucket entirely — used for routers whose
77
- * anonymous traffic must not be throttled (public webhook functions).
96
+ * `null` disables the anonymous bucket entirely.
78
97
  */
79
98
  anonymous?: number | null;
99
+ /**
100
+ * Per IP, for anonymous requests to `/api/functions/*`. Default 3000 —
101
+ * deliberately far looser than {@link anonymous}, because the functions
102
+ * router is public by default for webhook receivers (Stripe, GitHub) whose
103
+ * bursts arrive from a handful of provider IPs and would trip the data
104
+ * API's ceiling.
105
+ *
106
+ * The looseness bounds the *value*, not the existence of the limit: this
107
+ * router is the one that invites anonymous callers, so it is the last one
108
+ * that should have no ceiling at all. `null` disables it — an explicit
109
+ * choice, which is what the hardcoded `anonymous: null` here used to be
110
+ * without any way to say otherwise.
111
+ */
112
+ anonymousFunctions?: number | null;
80
113
  /** Share counts across replicas. Defaults to this process's memory. */
81
114
  store?: RateLimitStore;
82
115
  }
116
+ /** @see DataRateLimitConfig.anonymousFunctions */
117
+ export declare const DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3000;
83
118
  /**
84
119
  * Rate limiting for the data API.
85
120
  *
@@ -12,6 +12,31 @@
12
12
  * @module
13
13
  */
14
14
  import type { DataDriver } from "@rebasepro/types";
15
+ /**
16
+ * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
17
+ * caller presenting the service key.
18
+ *
19
+ * Naming it makes the privilege legible, because it is narrower than it reads.
20
+ * This is **not** an RLS bypass — a driver scoped with it is an
21
+ * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
22
+ * transaction that has done `SET LOCAL ROLE rebase_user` with
23
+ * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
24
+ * knowing before you write a policy:
25
+ *
26
+ * - It passes the default policies through their `rolesOverlap(['admin'])`
27
+ * arm — the same arm an application user holding the `admin` role passes.
28
+ * - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
29
+ * **false** for it. A collection with `disableDefaultPolicies: true` whose
30
+ * only rule is `serverContext()` denies these writes (42501) and returns
31
+ * zero rows for these reads.
32
+ *
33
+ * The true bypass is `rebase.sql()`, which runs on the owner connection and
34
+ * never goes near `withAuth`.
35
+ */
36
+ export declare const SERVICE_IDENTITY: {
37
+ uid: string;
38
+ roles: string[];
39
+ };
15
40
  /**
16
41
  * Scope a DataDriver via `withAuth()` for RLS.
17
42
  *
@@ -18,6 +18,14 @@ export interface AuthModuleConfig {
18
18
  defaultRole?: string;
19
19
  /** Optional array of OAuth providers */
20
20
  oauthProviders?: OAuthProvider<unknown>[];
21
+ /**
22
+ * Redirect URIs the OAuth routes will accept, for every provider.
23
+ *
24
+ * Left unset, the only check is the provider's own registered-URI match,
25
+ * which authorises every URI registered on that OAuth client. Compared on
26
+ * origin plus path; query and fragment are ignored, as is a trailing slash.
27
+ */
28
+ allowedRedirectUris?: string[];
21
29
  /** When true, blocks all self-registration regardless of `allowRegistration`. */
22
30
  disableSelfRegistration?: boolean;
23
31
  /**
@@ -1,4 +1,5 @@
1
1
  import type { OAuthProvider } from "./interfaces";
2
+ import { type OAuthCodeFlowPayload } from "./oauth-code-flow";
2
3
  /**
3
4
  * Creates a Slack OAuth Provider integration (OAuth 2.0 / "Sign in with Slack").
4
5
  * Uses the OpenID Connect flow with the "openid,email,profile" scopes.
@@ -6,7 +7,4 @@ import type { OAuthProvider } from "./interfaces";
6
7
  export declare function createSlackProvider(config: {
7
8
  clientId: string;
8
9
  clientSecret: string;
9
- }): OAuthProvider<{
10
- code: string;
11
- redirectUri: string;
12
- }>;
10
+ }): OAuthProvider<OAuthCodeFlowPayload>;
@@ -1,4 +1,5 @@
1
1
  import type { OAuthProvider } from "./interfaces";
2
+ import { type OAuthCodeFlowPayload } from "./oauth-code-flow";
2
3
  /**
3
4
  * Creates a Spotify OAuth Provider integration.
4
5
  * Uses the authorization code flow with the "user-read-email" scope.
@@ -6,7 +7,4 @@ import type { OAuthProvider } from "./interfaces";
6
7
  export declare function createSpotifyProvider(config: {
7
8
  clientId: string;
8
9
  clientSecret: string;
9
- }): OAuthProvider<{
10
- code: string;
11
- redirectUri: string;
12
- }>;
10
+ }): OAuthProvider<OAuthCodeFlowPayload>;
@@ -1,4 +1,5 @@
1
1
  import type { OAuthProvider } from "./interfaces";
2
+ import { type OAuthCodeFlowPayload } from "./oauth-code-flow";
2
3
  /**
3
4
  * Creates a Twitter/X OAuth 2.0 Provider integration.
4
5
  *
@@ -11,8 +12,4 @@ import type { OAuthProvider } from "./interfaces";
11
12
  export declare function createTwitterProvider(config: {
12
13
  clientId: string;
13
14
  clientSecret: string;
14
- }): OAuthProvider<{
15
- code: string;
16
- redirectUri: string;
17
- codeVerifier: string;
18
- }>;
15
+ }): OAuthProvider<OAuthCodeFlowPayload>;