tinbase 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +10 -9
  2. package/dist/admin/api.d.ts +62 -1
  3. package/dist/admin/api.js +221 -10
  4. package/dist/admin/api.js.map +1 -1
  5. package/dist/admin/ui.d.ts +1 -1
  6. package/dist/admin/ui.js +1548 -16
  7. package/dist/admin/ui.js.map +1 -1
  8. package/dist/auth/handler.d.ts +52 -5
  9. package/dist/auth/handler.js +147 -36
  10. package/dist/auth/handler.js.map +1 -1
  11. package/dist/auth/inbox.d.ts +4 -2
  12. package/dist/auth/inbox.js +1 -0
  13. package/dist/auth/inbox.js.map +1 -1
  14. package/dist/auth/oauth.d.ts +30 -4
  15. package/dist/auth/oauth.js +22 -6
  16. package/dist/auth/oauth.js.map +1 -1
  17. package/dist/auth/password.d.ts +5 -0
  18. package/dist/auth/password.js +7 -0
  19. package/dist/auth/password.js.map +1 -1
  20. package/dist/auth/qr.d.ts +1 -1
  21. package/dist/auth/qr.js +1 -1
  22. package/dist/auth/rate-limit.d.ts +33 -0
  23. package/dist/auth/rate-limit.js +63 -0
  24. package/dist/auth/rate-limit.js.map +1 -0
  25. package/dist/auth/redirect.d.ts +20 -0
  26. package/dist/auth/redirect.js +79 -0
  27. package/dist/auth/redirect.js.map +1 -0
  28. package/dist/auth/settings.d.ts +55 -0
  29. package/dist/auth/settings.js +116 -0
  30. package/dist/auth/settings.js.map +1 -0
  31. package/dist/auth/totp.js +1 -1
  32. package/dist/cli.js +43 -21
  33. package/dist/cli.js.map +1 -1
  34. package/dist/cron/service.d.ts +15 -2
  35. package/dist/cron/service.js +19 -2
  36. package/dist/cron/service.js.map +1 -1
  37. package/dist/db/bootstrap.d.ts +13 -6
  38. package/dist/db/bootstrap.js +26 -5
  39. package/dist/db/bootstrap.js.map +1 -1
  40. package/dist/db/database.d.ts +61 -3
  41. package/dist/db/database.js +72 -9
  42. package/dist/db/database.js.map +1 -1
  43. package/dist/db/emulated.d.ts +39 -2
  44. package/dist/db/emulated.js +39 -23
  45. package/dist/db/emulated.js.map +1 -1
  46. package/dist/db/engine.d.ts +19 -5
  47. package/dist/db/engine.js +4 -2
  48. package/dist/db/engine.js.map +1 -1
  49. package/dist/db/inspect.d.ts +6 -1
  50. package/dist/db/inspect.js +2 -1
  51. package/dist/db/inspect.js.map +1 -1
  52. package/dist/db/pglite-engine.d.ts +7 -1
  53. package/dist/db/pglite-engine.js +7 -1
  54. package/dist/db/pglite-engine.js.map +1 -1
  55. package/dist/db/pgmem-engine.d.ts +9 -3
  56. package/dist/db/pgmem-engine.js +13 -7
  57. package/dist/db/pgmem-engine.js.map +1 -1
  58. package/dist/db/schema-diff.d.ts +9 -1
  59. package/dist/db/schema-diff.js +19 -5
  60. package/dist/db/schema-diff.js.map +1 -1
  61. package/dist/db/sql-compat.d.ts +2 -2
  62. package/dist/db/sql-compat.js +2 -2
  63. package/dist/functions/deno-shim.d.ts +6 -17
  64. package/dist/functions/deno-shim.js +42 -10
  65. package/dist/functions/deno-shim.js.map +1 -1
  66. package/dist/functions/handler.d.ts +9 -4
  67. package/dist/functions/handler.js +9 -1
  68. package/dist/functions/handler.js.map +1 -1
  69. package/dist/gen-types.d.ts +6 -1
  70. package/dist/gen-types.js +53 -41
  71. package/dist/gen-types.js.map +1 -1
  72. package/dist/index.d.ts +33 -3
  73. package/dist/index.js +158 -28
  74. package/dist/index.js.map +1 -1
  75. package/dist/jwt.d.ts +4 -1
  76. package/dist/jwt.js +4 -2
  77. package/dist/jwt.js.map +1 -1
  78. package/dist/log-buffer.d.ts +7 -1
  79. package/dist/log-buffer.js +3 -0
  80. package/dist/log-buffer.js.map +1 -1
  81. package/dist/net/service.d.ts +35 -6
  82. package/dist/net/service.js +72 -17
  83. package/dist/net/service.js.map +1 -1
  84. package/dist/node/bun-server.d.ts +2 -1
  85. package/dist/node/bun-server.js +11 -1
  86. package/dist/node/bun-server.js.map +1 -1
  87. package/dist/node/bundle-function.d.ts +1 -1
  88. package/dist/node/bundle-function.js +1 -1
  89. package/dist/node/config-toml.d.ts +31 -0
  90. package/dist/node/config-toml.js +186 -0
  91. package/dist/node/config-toml.js.map +1 -0
  92. package/dist/node/db-diff.d.ts +13 -1
  93. package/dist/node/db-diff.js +3 -1
  94. package/dist/node/db-diff.js.map +1 -1
  95. package/dist/node/fs-driver.d.ts +16 -1
  96. package/dist/node/fs-driver.js +20 -1
  97. package/dist/node/fs-driver.js.map +1 -1
  98. package/dist/node/index.d.ts +2 -2
  99. package/dist/node/index.js +1 -1
  100. package/dist/node/index.js.map +1 -1
  101. package/dist/node/load-config.d.ts +80 -0
  102. package/dist/node/load-config.js +230 -0
  103. package/dist/node/load-config.js.map +1 -0
  104. package/dist/node/load-functions.d.ts +17 -2
  105. package/dist/node/load-functions.js +17 -4
  106. package/dist/node/load-functions.js.map +1 -1
  107. package/dist/node/native/engine.d.ts +7 -0
  108. package/dist/node/native/engine.js +58 -7
  109. package/dist/node/native/engine.js.map +1 -1
  110. package/dist/node/native/wire.d.ts +15 -1
  111. package/dist/node/native/wire.js +32 -10
  112. package/dist/node/native/wire.js.map +1 -1
  113. package/dist/node/project.d.ts +15 -1
  114. package/dist/node/project.js +19 -7
  115. package/dist/node/project.js.map +1 -1
  116. package/dist/node/server-shared.d.ts +4 -1
  117. package/dist/node/server.d.ts +11 -0
  118. package/dist/node/server.js +11 -0
  119. package/dist/node/server.js.map +1 -1
  120. package/dist/node/ws.d.ts +26 -1
  121. package/dist/node/ws.js +26 -3
  122. package/dist/node/ws.js.map +1 -1
  123. package/dist/realtime/engine.d.ts +6 -0
  124. package/dist/realtime/engine.js +45 -34
  125. package/dist/realtime/engine.js.map +1 -1
  126. package/dist/rest/build.d.ts +37 -1
  127. package/dist/rest/build.js +48 -8
  128. package/dist/rest/build.js.map +1 -1
  129. package/dist/rest/errors.d.ts +8 -0
  130. package/dist/rest/errors.js +12 -1
  131. package/dist/rest/errors.js.map +1 -1
  132. package/dist/rest/handler.d.ts +17 -1
  133. package/dist/rest/handler.js +48 -5
  134. package/dist/rest/handler.js.map +1 -1
  135. package/dist/rest/parse.d.ts +24 -1
  136. package/dist/rest/parse.js +7 -1
  137. package/dist/rest/parse.js.map +1 -1
  138. package/dist/retention/service.d.ts +3 -0
  139. package/dist/retention/service.js +3 -1
  140. package/dist/retention/service.js.map +1 -1
  141. package/dist/security.d.ts +22 -0
  142. package/dist/security.js +47 -0
  143. package/dist/security.js.map +1 -0
  144. package/dist/storage/driver.d.ts +8 -1
  145. package/dist/storage/driver.js +4 -1
  146. package/dist/storage/driver.js.map +1 -1
  147. package/dist/storage/handler.d.ts +10 -1
  148. package/dist/storage/handler.js +93 -12
  149. package/dist/storage/handler.js.map +1 -1
  150. package/dist/types.d.ts +77 -1
  151. package/dist/types.js +10 -0
  152. package/dist/types.js.map +1 -1
  153. package/dist/webhooks/service.d.ts +25 -2
  154. package/dist/webhooks/service.js +21 -3
  155. package/dist/webhooks/service.js.map +1 -1
  156. package/package.json +5 -3
@@ -1 +1 @@
1
- {"version":3,"file":"ui.js","sourceRoot":"","sources":["../../src/admin/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgEzB,CAAA"}
1
+ {"version":3,"file":"ui.js","sourceRoot":"","sources":["../../src/admin/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4jDzB,CAAA"}
@@ -1,18 +1,50 @@
1
1
  /**
2
- * GoTrue-compatible auth endpoints (/auth/v1/*) — the subset supabase-js
2
+ * GoTrue-compatible auth endpoints (/auth/v1/*) - the subset supabase-js
3
3
  * uses for email/password auth, sessions, and admin user management.
4
4
  */
5
5
  import type { Database } from '../db/database.js';
6
- import type { Mailer, RequestContext } from '../types.js';
6
+ import { type Mailer, type RequestContext } from '../types.js';
7
7
  import { type OAuthProviderConfig } from './oauth.js';
8
+ import { type AuthSettings } from './settings.js';
9
+ import { RateLimiter } from './rate-limit.js';
10
+ /** Construction-time config for {@link AuthHandler}. */
8
11
  export interface AuthConfig {
12
+ /** HS256 secret used to sign and verify access tokens */
9
13
  jwtSecret: string;
14
+ /** public base URL of this instance; used as issuer and default redirect target */
10
15
  siteUrl: string;
16
+ /** Access-token lifetime in seconds. */
11
17
  jwtExpiry: number;
18
+ /** Force sign-out after this many seconds (config.toml auth.sessions.timebox). Caps session lifetime. */
19
+ sessionTimeboxSeconds?: number;
20
+ /** sends outgoing auth email (magic links, OTP codes, recovery) */
12
21
  mailer: Mailer;
22
+ /** OAuth providers to enable, keyed by provider name (google, github, …) */
13
23
  oauthProviders?: Record<string, OAuthProviderConfig>;
14
24
  /** injectable fetch for the OAuth provider calls (tests use a mock provider) */
15
25
  oauthFetch?: typeof fetch;
26
+ /**
27
+ * Additional redirect targets allowed beyond the site URL's own origin
28
+ * (GoTrue's URI_ALLOW_LIST). Entries may use `*`/`**` globs. A `redirect_to`
29
+ * that matches neither the site origin nor an entry falls back to the site URL.
30
+ */
31
+ uriAllowList?: string[];
32
+ /**
33
+ * Enforce the redirect allowlist strictly. Off for local dev (any well-formed
34
+ * URL is honored, like `supabase start`); the backend turns it on when
35
+ * network-exposed so redirects can't leave the allowed origins.
36
+ */
37
+ enforceRedirectAllowList?: boolean;
38
+ /**
39
+ * Runtime-mutable toggles (signups, anonymous users, autoconfirm…). The
40
+ * admin API mutates this same object in place, so changes apply instantly.
41
+ */
42
+ settings?: AuthSettings;
43
+ /**
44
+ * Rate limiter for login/signup/OTP/recovery. Defaults to a fresh in-memory
45
+ * limiter with GoTrue-shaped windows; pass `null` to disable (e.g. tests).
46
+ */
47
+ rateLimiter?: RateLimiter | null;
16
48
  }
17
49
  interface UserRow {
18
50
  id: string;
@@ -30,11 +62,23 @@ interface UserRow {
30
62
  phone_confirmed_at: Date | string | null;
31
63
  is_anonymous: boolean | null;
32
64
  }
65
+ /** Routes and services the GoTrue-compatible `/auth/v1/*` endpoints. */
33
66
  export declare class AuthHandler {
34
67
  private db;
35
68
  private config;
36
69
  private oauth;
70
+ /** Shared, runtime-mutable settings - read on every request, never copied. */
71
+ private settings;
72
+ private rateLimiter;
37
73
  constructor(db: Database, config: AuthConfig);
74
+ /**
75
+ * Enforce the rate limit for `action`, keyed by client address. Returns a 429
76
+ * (GoTrue's `over_request_rate_limit`) when exceeded, else null to proceed.
77
+ */
78
+ private limit;
79
+ /** Stop background timers (rate-limiter sweep). Called on backend close. */
80
+ stop(): void;
81
+ /** Dispatch one `/auth/v1/*` request. Any thrown error becomes a 500 `unexpected_failure`. */
38
82
  handle(req: Request, ctx: RequestContext, url: URL): Promise<Response>;
39
83
  private signup;
40
84
  private token;
@@ -58,7 +102,7 @@ export declare class AuthHandler {
58
102
  /**
59
103
  * Export everything held about one user across the auth schema, for a GDPR
60
104
  * right-of-access / portability request. Credentials (password hash, MFA
61
- * secrets, raw token values) are deliberately omitted — they are not personal
105
+ * secrets, raw token values) are deliberately omitted - they are not personal
62
106
  * data to hand back and exporting them would leak secrets.
63
107
  */
64
108
  private exportUser;
@@ -69,7 +113,7 @@ export declare class AuthHandler {
69
113
  * doesn't exist and a summary of what was erased.
70
114
  *
71
115
  * Note: storage.objects.owner has no FK to auth.users, so object rows/bytes
72
- * owned by the user are not removed here — see COMPLIANCE.md for the
116
+ * owned by the user are not removed here - see COMPLIANCE.md for the
73
117
  * storage-erasure step the operator must run.
74
118
  */
75
119
  private eraseUser;
@@ -78,8 +122,11 @@ export declare class AuthHandler {
78
122
  private verifyFactor;
79
123
  private unenrollFactor;
80
124
  private getUserFactors;
125
+ /** GoTrue-shaped identities for a user (linked providers), from auth.identities. */
126
+ private getUserIdentities;
81
127
  private userFromBearer;
82
- userJson(u: UserRow, factors?: Record<string, unknown>[]): Record<string, unknown>;
128
+ /** Shape a user row into the GoTrue user object supabase-js expects. */
129
+ userJson(u: UserRow, factors?: Record<string, unknown>[], identities?: Record<string, unknown>[]): Record<string, unknown>;
83
130
  /** Session tokens for a bare user id (used by the OAuth implicit callback). */
84
131
  private sessionTokensFor;
85
132
  private sessionFor;
@@ -1,11 +1,25 @@
1
1
  import { randomToken, signJwt, verifyJwt } from '../jwt.js';
2
+ import { TINBASE_VERSION } from '../types.js';
2
3
  import { OAuthService } from './oauth.js';
3
4
  import { hashPassword, verifyPassword } from './password.js';
4
5
  import { qrSvgDataUri } from './qr.js';
6
+ import { DEFAULT_AUTH_SETTINGS } from './settings.js';
7
+ import { resolveRedirect } from './redirect.js';
8
+ import { RateLimiter } from './rate-limit.js';
5
9
  import { generateTotpSecret, otpauthUri, verifyTotp } from './totp.js';
6
10
  function authError(status, errorCode, msg) {
7
11
  return json(status, { code: status, error_code: errorCode, msg });
8
12
  }
13
+ /** A cryptographically-random numeric OTP of `length` digits (6-10). */
14
+ function randomOtp(length) {
15
+ const n = Math.max(6, Math.min(10, Math.floor(length)));
16
+ const buf = new Uint32Array(n);
17
+ crypto.getRandomValues(buf);
18
+ let code = '';
19
+ for (let i = 0; i < n; i++)
20
+ code += String(buf[i] % 10);
21
+ return code;
22
+ }
9
23
  function json(status, body) {
10
24
  return new Response(status === 204 ? null : JSON.stringify(body), {
11
25
  status,
@@ -17,33 +31,64 @@ function iso(v) {
17
31
  return null;
18
32
  return v instanceof Date ? v.toISOString() : new Date(v).toISOString();
19
33
  }
34
+ /** Routes and services the GoTrue-compatible `/auth/v1/*` endpoints. */
20
35
  export class AuthHandler {
21
36
  db;
22
37
  config;
23
38
  oauth;
39
+ /** Shared, runtime-mutable settings - read on every request, never copied. */
40
+ settings;
41
+ rateLimiter;
24
42
  constructor(db, config) {
25
43
  this.db = db;
26
44
  this.config = config;
27
- this.oauth = new OAuthService(db, config.siteUrl, config.oauthProviders ?? {}, config.oauthFetch ?? fetch);
45
+ this.oauth = new OAuthService(db, config.siteUrl, config.oauthProviders ?? {}, config.oauthFetch ?? fetch, config.uriAllowList, config.enforceRedirectAllowList);
46
+ this.settings = config.settings ?? { ...DEFAULT_AUTH_SETTINGS };
47
+ this.rateLimiter = config.rateLimiter === undefined ? new RateLimiter() : config.rateLimiter;
28
48
  }
49
+ /**
50
+ * Enforce the rate limit for `action`, keyed by client address. Returns a 429
51
+ * (GoTrue's `over_request_rate_limit`) when exceeded, else null to proceed.
52
+ */
53
+ limit(action, req) {
54
+ if (!this.rateLimiter)
55
+ return null;
56
+ const client = req.headers.get('x-tinbase-remote-addr') ?? 'local';
57
+ const retryAfter = this.rateLimiter.check(action, client);
58
+ if (retryAfter === null)
59
+ return null;
60
+ return new Response(JSON.stringify({ code: 429, error_code: 'over_request_rate_limit', msg: 'Request rate limit reached' }), { status: 429, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': String(retryAfter) } });
61
+ }
62
+ /** Stop background timers (rate-limiter sweep). Called on backend close. */
63
+ stop() {
64
+ this.rateLimiter?.stop();
65
+ }
66
+ /** Dispatch one `/auth/v1/*` request. Any thrown error becomes a 500 `unexpected_failure`. */
29
67
  async handle(req, ctx, url) {
30
68
  const path = url.pathname.replace(/^\/auth\/v1\/?/, '').replace(/\/+$/, '');
31
69
  const method = req.method.toUpperCase();
32
70
  try {
33
71
  if (path === 'health')
34
- return json(200, { name: 'tinbase-auth', version: '0.1.0', description: 'GoTrue-compatible auth' });
72
+ return json(200, { name: 'tinbase-auth', version: TINBASE_VERSION, description: 'GoTrue-compatible auth' });
35
73
  if (path === 'settings') {
74
+ const providers = Object.keys(this.config.oauthProviders ?? {});
36
75
  return json(200, {
37
- external: { email: true, phone: false, anonymous_users: true },
38
- disable_signup: false,
39
- autoconfirm: true,
40
- mailer_autoconfirm: true,
76
+ external: {
77
+ email: true,
78
+ phone: false,
79
+ anonymous_users: this.settings.anonymousUsers,
80
+ ...Object.fromEntries(providers.map((p) => [p, !this.settings.disabledProviders.includes(p)])),
81
+ },
82
+ disable_signup: this.settings.disableSignup,
83
+ autoconfirm: this.settings.autoconfirm,
84
+ mailer_autoconfirm: this.settings.autoconfirm,
85
+ minimum_password_length: this.settings.minPasswordLength,
41
86
  });
42
87
  }
43
88
  if (path === 'signup' && method === 'POST')
44
- return await this.signup(req);
89
+ return this.limit('signup', req) ?? (await this.signup(req));
45
90
  if (path === 'token' && method === 'POST')
46
- return await this.token(req, url);
91
+ return this.limit('token', req) ?? (await this.token(req, url));
47
92
  if (path === 'user' && method === 'GET')
48
93
  return await this.getUser(req);
49
94
  if (path === 'user' && method === 'PUT')
@@ -51,11 +96,11 @@ export class AuthHandler {
51
96
  if (path === 'logout' && method === 'POST')
52
97
  return await this.logout(req);
53
98
  if (path === 'otp' && method === 'POST')
54
- return await this.sendOtp(req);
99
+ return this.limit('otp', req) ?? (await this.sendOtp(req));
55
100
  if (path === 'recover' && method === 'POST')
56
- return await this.sendRecovery(req);
101
+ return this.limit('recover', req) ?? (await this.sendRecovery(req));
57
102
  if (['magiclink', 'resend'].includes(path) && method === 'POST')
58
- return await this.sendOtp(req);
103
+ return this.limit('otp', req) ?? (await this.sendOtp(req));
59
104
  if (path === 'verify' && method === 'POST')
60
105
  return await this.verifyToken(req);
61
106
  if (path === 'verify' && method === 'GET')
@@ -68,8 +113,13 @@ export class AuthHandler {
68
113
  return await this.verifyFactor(req, path.split('/')[1]);
69
114
  if (/^factors\/[^/]+$/.test(path) && method === 'DELETE')
70
115
  return await this.unenrollFactor(req, path.split('/')[1]);
71
- if (path === 'authorize' && method === 'GET')
116
+ if (path === 'authorize' && method === 'GET') {
117
+ const provider = url.searchParams.get('provider') ?? '';
118
+ if (provider && this.settings.disabledProviders.includes(provider)) {
119
+ return authError(422, 'provider_disabled', `Sign-ins with ${provider} are disabled`);
120
+ }
72
121
  return await this.oauth.authorize(url);
122
+ }
73
123
  if (path === 'callback' && (method === 'GET' || method === 'POST')) {
74
124
  return await this.oauth.callback(url, (userId) => this.sessionTokensFor(userId));
75
125
  }
@@ -87,16 +137,22 @@ export class AuthHandler {
87
137
  const body = (await req.json().catch(() => ({})));
88
138
  if (!body.email && !body.password) {
89
139
  // supabase.auth.signInAnonymously()
140
+ if (!this.settings.anonymousUsers) {
141
+ return authError(422, 'anonymous_provider_disabled', 'Anonymous sign-ins are disabled');
142
+ }
90
143
  const res = await this.db.query(`insert into auth.users (aud, role, raw_app_meta_data, raw_user_meta_data, is_anonymous, last_sign_in_at)
91
144
  values ('authenticated', 'authenticated', '{}', $1, true, now())
92
145
  returning *`, [JSON.stringify(body.data ?? {})]);
93
146
  return json(200, await this.sessionFor(res.rows[0]));
94
147
  }
148
+ if (this.settings.disableSignup) {
149
+ return authError(422, 'signup_disabled', 'Signups not allowed for this instance');
150
+ }
95
151
  if (!body.email || !body.password) {
96
152
  return authError(400, 'validation_failed', 'Signup requires a valid email and password');
97
153
  }
98
- if (body.password.length < 6) {
99
- return authError(422, 'weak_password', 'Password should be at least 6 characters.');
154
+ if (body.password.length < this.settings.minPasswordLength) {
155
+ return authError(422, 'weak_password', `Password should be at least ${this.settings.minPasswordLength} characters.`);
100
156
  }
101
157
  const email = body.email.toLowerCase().trim();
102
158
  const existing = await this.db.query(`select id from auth.users where email = $1`, [email]);
@@ -104,14 +160,26 @@ export class AuthHandler {
104
160
  return authError(422, 'user_already_exists', 'User already registered');
105
161
  }
106
162
  const hashed = await hashPassword(body.password);
163
+ const autoconfirm = this.settings.autoconfirm;
107
164
  const res = await this.db.query(`insert into auth.users
108
165
  (aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
109
166
  raw_app_meta_data, raw_user_meta_data)
110
- values ('authenticated', 'authenticated', $1, $2, now(), now(),
167
+ values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end, now(),
111
168
  '{"provider":"email","providers":["email"]}', $3)
112
- returning *`, [email, hashed, JSON.stringify(body.data ?? {})]);
169
+ returning *`, [email, hashed, JSON.stringify(body.data ?? {}), autoconfirm]);
113
170
  const newUser = res.rows[0];
171
+ // GoTrue records an `email` identity at signup, so user.identities reflects
172
+ // the email provider (getUser returned [] before this). Mirrors the shape
173
+ // written on anonymous-to-email upgrade.
174
+ await this.db.query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
175
+ values ($1, 'email', $2, $3)
176
+ on conflict (provider, provider_id) do nothing`, [newUser.id, newUser.id, JSON.stringify({ sub: newUser.id, email: newUser.email })]);
114
177
  await this.audit('user_signedup', { actorId: newUser.id, actorEmail: email });
178
+ if (!autoconfirm) {
179
+ // confirmation required: email a verification link/code; no session yet
180
+ await this.issueToken(email, 'otp', false, 'confirm');
181
+ return json(200, this.userJson(newUser));
182
+ }
115
183
  return json(200, await this.sessionFor(newUser));
116
184
  }
117
185
  async token(req, url) {
@@ -125,6 +193,10 @@ export class AuthHandler {
125
193
  await this.audit('login_failed', { actorEmail: email, traits: { grant_type: 'password' } });
126
194
  return authError(400, 'invalid_credentials', 'Invalid login credentials');
127
195
  }
196
+ // when confirmation is required, unverified accounts cannot sign in yet
197
+ if (!this.settings.autoconfirm && !user.email_confirmed_at) {
198
+ return authError(400, 'email_not_confirmed', 'Email not confirmed');
199
+ }
128
200
  await this.db.query(`update auth.users set last_sign_in_at = now() where id = $1`, [user.id]);
129
201
  await this.audit('login', { actorId: user.id, actorEmail: user.email, traits: { grant_type: 'password' } });
130
202
  return json(200, await this.sessionFor(user));
@@ -161,7 +233,7 @@ export class AuthHandler {
161
233
  const user = await this.userFromBearer(req);
162
234
  if (!user)
163
235
  return authError(401, 'no_authorization', 'Invalid or expired token');
164
- return json(200, this.userJson(user, await this.getUserFactors(user.id)));
236
+ return json(200, this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id)));
165
237
  }
166
238
  async updateUser(req) {
167
239
  const user = await this.userFromBearer(req);
@@ -172,7 +244,7 @@ export class AuthHandler {
172
244
  const params = [];
173
245
  // Upgrading an anonymous user to a permanent one: adding an email (and
174
246
  // usually a password) keeps the same id + data, flips is_anonymous off, and
175
- // records an email identity — matching supabase.auth.updateUser({ email }).
247
+ // records an email identity - matching supabase.auth.updateUser({ email }).
176
248
  const upgradingAnon = (user.is_anonymous ?? false) && !!body.email;
177
249
  if (body.email) {
178
250
  const email = body.email.toLowerCase().trim();
@@ -184,8 +256,8 @@ export class AuthHandler {
184
256
  sets.push(`email = $${params.length}, email_confirmed_at = now()`);
185
257
  }
186
258
  if (body.password) {
187
- if (body.password.length < 6) {
188
- return authError(422, 'weak_password', 'Password should be at least 6 characters.');
259
+ if (body.password.length < this.settings.minPasswordLength) {
260
+ return authError(422, 'weak_password', `Password should be at least ${this.settings.minPasswordLength} characters.`);
189
261
  }
190
262
  params.push(await hashPassword(body.password));
191
263
  sets.push(`encrypted_password = $${params.length}`);
@@ -220,31 +292,36 @@ export class AuthHandler {
220
292
  return new Response(null, { status: 204 });
221
293
  }
222
294
  // ── OTP / magic links / recovery ──────────────────────────────────────
223
- async issueToken(email, tokenType, createUser) {
295
+ async issueToken(email, tokenType, createUser, flavor = 'login') {
224
296
  const normalized = email.toLowerCase().trim();
225
297
  let res = await this.db.query(`select * from auth.users where email = $1`, [normalized]);
226
298
  let user = res.rows[0];
227
299
  if (!user) {
228
300
  if (!createUser)
229
301
  return authError(422, 'otp_disabled', 'Signups not allowed for otp');
302
+ if (this.settings.disableSignup)
303
+ return authError(422, 'signup_disabled', 'Signups not allowed for this instance');
230
304
  res = await this.db.query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
231
305
  values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
232
306
  returning *`, [normalized]);
233
307
  user = res.rows[0];
234
308
  }
235
- const code = String(Math.floor(100000 + Math.random() * 900000));
309
+ const code = randomOtp(this.settings.otpLength);
236
310
  const linkToken = randomToken(24);
311
+ const expiry = `${this.settings.otpExpirySeconds} seconds`;
237
312
  await this.db.query(`delete from auth.one_time_tokens where email = $1 and token_type = $2`, [normalized, tokenType]);
238
313
  await this.db.query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
239
- values ($1, $2, $3, $4, now() + interval '15 minutes'), ($1, $2, $5, $6, now() + interval '15 minutes')`, [user.id, normalized, tokenType, code, tokenType === 'otp' ? 'magiclink' : tokenType, linkToken]);
314
+ values ($1, $2, $3, $4, now() + $7::interval), ($1, $2, $5, $6, now() + $7::interval)`, [user.id, normalized, tokenType, code, tokenType === 'otp' ? 'magiclink' : tokenType, linkToken, expiry]);
240
315
  const kind = tokenType === 'otp' ? 'magiclink' : tokenType;
241
316
  const link = `${this.config.siteUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
242
317
  await this.config.mailer.send({
243
318
  to: normalized,
244
- subject: tokenType === 'otp' ? 'Your login code' : 'Reset your password',
245
- text: tokenType === 'otp'
246
- ? `Your one-time code is ${code}\n\nOr sign in with this link: ${link}`
247
- : `Reset your password with this link: ${link}\n\nOr use code ${code}`,
319
+ subject: tokenType === 'recovery' ? 'Reset your password' : flavor === 'confirm' ? 'Confirm your email' : 'Your login code',
320
+ text: tokenType === 'recovery'
321
+ ? `Reset your password with this link: ${link}\n\nOr use code ${code}`
322
+ : flavor === 'confirm'
323
+ ? `Confirm your email address with this link: ${link}\n\nOr enter the code ${code}`
324
+ : `Your one-time code is ${code}\n\nOr sign in with this link: ${link}`,
248
325
  });
249
326
  return json(200, {});
250
327
  }
@@ -291,7 +368,7 @@ export class AuthHandler {
291
368
  if (!body.token)
292
369
  return authError(400, 'validation_failed', 'token is required');
293
370
  // A recovery (password-reset) token must be redeemed with type=recovery
294
- // explicitly — never fold it into the default set, or a guessed login OTP
371
+ // explicitly - never fold it into the default set, or a guessed login OTP
295
372
  // could mint a recovery session.
296
373
  const types = body.type === 'recovery' ? ['recovery'] : body.type === 'magiclink' ? ['magiclink'] : ['otp', 'magiclink'];
297
374
  const user = await this.redeem(body.token, types, body.email);
@@ -302,7 +379,10 @@ export class AuthHandler {
302
379
  async verifyLink(url) {
303
380
  const token = url.searchParams.get('token') ?? '';
304
381
  const type = url.searchParams.get('type') ?? 'magiclink';
305
- const redirectTo = url.searchParams.get('redirect_to') ?? this.config.siteUrl;
382
+ // Never redirect (with the freshly minted session tokens) to an origin the
383
+ // operator hasn't allowed - a crafted magic-link would otherwise exfiltrate
384
+ // the session. Unknown targets fall back to the site URL.
385
+ const redirectTo = resolveRedirect(url.searchParams.get('redirect_to'), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
306
386
  const user = await this.redeem(token, [type]);
307
387
  if (!user) {
308
388
  return new Response(null, { status: 303, headers: { location: `${redirectTo}#error=access_denied&error_code=otp_expired` } });
@@ -413,7 +493,7 @@ export class AuthHandler {
413
493
  /**
414
494
  * Export everything held about one user across the auth schema, for a GDPR
415
495
  * right-of-access / portability request. Credentials (password hash, MFA
416
- * secrets, raw token values) are deliberately omitted — they are not personal
496
+ * secrets, raw token values) are deliberately omitted - they are not personal
417
497
  * data to hand back and exporting them would leak secrets.
418
498
  */
419
499
  async exportUser(userId) {
@@ -455,7 +535,7 @@ export class AuthHandler {
455
535
  * doesn't exist and a summary of what was erased.
456
536
  *
457
537
  * Note: storage.objects.owner has no FK to auth.users, so object rows/bytes
458
- * owned by the user are not removed here — see COMPLIANCE.md for the
538
+ * owned by the user are not removed here - see COMPLIANCE.md for the
459
539
  * storage-erasure step the operator must run.
460
540
  */
461
541
  async eraseUser(userId) {
@@ -484,6 +564,13 @@ export class AuthHandler {
484
564
  if (factorType !== 'totp') {
485
565
  return authError(422, 'validation_failed', 'Only the totp factor type is supported');
486
566
  }
567
+ if (!this.settings.totpEnrollEnabled) {
568
+ return authError(422, 'mfa_totp_enroll_disabled', 'TOTP enrollment is disabled');
569
+ }
570
+ const enrolled = await this.db.query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [user.id]);
571
+ if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
572
+ return authError(422, 'too_many_enrolled_mfa_factors', 'Maximum number of enrolled MFA factors reached');
573
+ }
487
574
  const friendlyName = body.friendly_name ?? null;
488
575
  if (friendlyName) {
489
576
  const dup = await this.db.query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
@@ -517,6 +604,9 @@ export class AuthHandler {
517
604
  const user = await this.userFromBearer(req);
518
605
  if (!user)
519
606
  return authError(401, 'no_authorization', 'This endpoint requires a Bearer token');
607
+ if (!this.settings.totpVerifyEnabled) {
608
+ return authError(422, 'mfa_totp_verify_disabled', 'TOTP verification is disabled');
609
+ }
520
610
  const fr = await this.db.query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
521
611
  if (fr.rows.length === 0)
522
612
  return authError(404, 'mfa_factor_not_found', 'MFA factor not found');
@@ -587,6 +677,21 @@ export class AuthHandler {
587
677
  updated_at: iso(f.updated_at),
588
678
  }));
589
679
  }
680
+ /** GoTrue-shaped identities for a user (linked providers), from auth.identities. */
681
+ async getUserIdentities(userId) {
682
+ const res = await this.db.query(`select id, provider_id, user_id, identity_data, provider, created_at, updated_at, last_sign_in_at
683
+ from auth.identities where user_id = $1 order by created_at`, [userId]);
684
+ return res.rows.map((r) => ({
685
+ identity_id: r.id,
686
+ id: r.provider_id,
687
+ user_id: r.user_id,
688
+ identity_data: r.identity_data ?? {},
689
+ provider: r.provider,
690
+ last_sign_in_at: iso(r.last_sign_in_at),
691
+ created_at: iso(r.created_at),
692
+ updated_at: iso(r.updated_at),
693
+ }));
694
+ }
590
695
  // ── helpers ───────────────────────────────────────────────────────────
591
696
  async userFromBearer(req) {
592
697
  const authz = req.headers.get('authorization') ?? '';
@@ -598,7 +703,8 @@ export class AuthHandler {
598
703
  const res = await this.db.query(`select * from auth.users where id = $1`, [claims.sub]);
599
704
  return res.rows[0] ?? null;
600
705
  }
601
- userJson(u, factors = []) {
706
+ /** Shape a user row into the GoTrue user object supabase-js expects. */
707
+ userJson(u, factors = [], identities = []) {
602
708
  return {
603
709
  id: u.id,
604
710
  aud: u.aud ?? 'authenticated',
@@ -610,7 +716,7 @@ export class AuthHandler {
610
716
  last_sign_in_at: iso(u.last_sign_in_at),
611
717
  app_metadata: u.raw_app_meta_data ?? {},
612
718
  user_metadata: u.raw_user_meta_data ?? {},
613
- identities: [],
719
+ identities,
614
720
  factors,
615
721
  created_at: iso(u.created_at),
616
722
  updated_at: iso(u.updated_at),
@@ -625,7 +731,12 @@ export class AuthHandler {
625
731
  }
626
732
  async sessionFor(user, parentToken, opts) {
627
733
  const now = Math.floor(Date.now() / 1000);
628
- const expiresAt = now + this.config.jwtExpiry;
734
+ // Cap the access-token lifetime at the session timebox so a timeboxed
735
+ // session can't outlive its absolute deadline (config.toml auth.sessions.timebox).
736
+ const lifetime = this.config.sessionTimeboxSeconds
737
+ ? Math.min(this.config.jwtExpiry, this.config.sessionTimeboxSeconds)
738
+ : this.config.jwtExpiry;
739
+ const expiresAt = now + lifetime;
629
740
  const sessionId = crypto.randomUUID();
630
741
  const claims = {
631
742
  iss: `${this.config.siteUrl}/auth/v1`,
@@ -649,10 +760,10 @@ export class AuthHandler {
649
760
  return {
650
761
  access_token: accessToken,
651
762
  token_type: 'bearer',
652
- expires_in: this.config.jwtExpiry,
763
+ expires_in: lifetime,
653
764
  expires_at: expiresAt,
654
765
  refresh_token: refreshToken,
655
- user: this.userJson(user, await this.getUserFactors(user.id)),
766
+ user: this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id)),
656
767
  };
657
768
  }
658
769
  }