@jskit-ai/auth-provider-supabase-core 0.1.9 → 0.1.11

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  "packageVersion": 1,
3
3
  "packageId": "@jskit-ai/auth-provider-supabase-core",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
5
5
  "options": {
6
6
  "auth-supabase-url": {
7
7
  "required": true,
@@ -83,8 +83,8 @@ export default Object.freeze({
83
83
  "mutations": {
84
84
  "dependencies": {
85
85
  "runtime": {
86
- "@jskit-ai/auth-core": "0.1.9",
87
- "@jskit-ai/kernel": "0.1.9",
86
+ "@jskit-ai/auth-core": "0.1.11",
87
+ "@jskit-ai/kernel": "0.1.11",
88
88
  "dotenv": "^16.4.5",
89
89
  "@supabase/supabase-js": "^2.57.4",
90
90
  "jose": "^6.1.0"
@@ -132,6 +132,16 @@ export default Object.freeze({
132
132
  "reason": "Configure application public URL for auth redirect flows.",
133
133
  "category": "runtime-config",
134
134
  "id": "auth-app-public-url"
135
+ },
136
+ {
137
+ "op": "append-text",
138
+ "file": "config/server.js",
139
+ "position": "bottom",
140
+ "skipIfContains": "config.auth = {",
141
+ "value": "\nconfig.auth = {\n oauth: {\n providers: [],\n defaultProvider: \"\"\n }\n};\n",
142
+ "reason": "Append app-owned OAuth provider visibility config for stock auth screens.",
143
+ "category": "runtime-config",
144
+ "id": "auth-oauth-app-config"
135
145
  }
136
146
  ]
137
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-provider-supabase-core",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -12,8 +12,8 @@
12
12
  "./client": "./src/client/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/auth-core": "0.1.9",
16
- "@jskit-ai/kernel": "0.1.9",
15
+ "@jskit-ai/auth-core": "0.1.11",
16
+ "@jskit-ai/kernel": "0.1.11",
17
17
  "jose": "^6.1.0",
18
18
  "@supabase/supabase-js": "^2.57.4"
19
19
  }
@@ -1,4 +1,9 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import {
3
+ requireAuthUser,
4
+ requireAuthUserSession,
5
+ requireNoFieldErrors
6
+ } from "./flowGuards.js";
2
7
 
3
8
  function normalizeLocalReturnToPath(value, { fallback = "" } = {}) {
4
9
  const normalized = String(value || "").trim();
@@ -61,19 +66,31 @@ function createAccountFlows(deps) {
61
66
  }
62
67
  }
63
68
 
69
+ function resolveRegisterEmailRedirectTo() {
70
+ if (typeof buildOtpLoginRedirectUrl === "function") {
71
+ try {
72
+ return buildOtpLoginRedirectUrl({
73
+ appPublicUrl
74
+ });
75
+ } catch {
76
+ // Fall through to the pre-built URL fallback below.
77
+ }
78
+ }
79
+ return otpLoginRedirectUrl;
80
+ }
81
+
64
82
  async function register(payload) {
65
83
  ensureConfigured();
66
84
 
67
85
  const parsed = validators.registerInput(payload);
68
- if (Object.keys(parsed.fieldErrors).length > 0) {
69
- throw validationError(parsed.fieldErrors);
70
- }
86
+ requireNoFieldErrors(parsed, validationError);
71
87
 
72
88
  const supabase = getSupabaseClient();
73
89
  const response = await supabase.auth.signUp({
74
90
  email: parsed.email,
75
91
  password: parsed.password,
76
92
  options: {
93
+ emailRedirectTo: resolveRegisterEmailRedirectTo(),
77
94
  data: {
78
95
  display_name: displayNameFromEmail(parsed.email)
79
96
  }
@@ -81,7 +98,7 @@ function createAccountFlows(deps) {
81
98
  });
82
99
 
83
100
  if (response.error) {
84
- throw mapAuthError(response.error, 400);
101
+ throw mapAuthError(response.error, Number(response.error?.status || 400));
85
102
  }
86
103
 
87
104
  if (!response.data?.user) {
@@ -106,13 +123,60 @@ function createAccountFlows(deps) {
106
123
  };
107
124
  }
108
125
 
126
+ async function resendRegisterConfirmation(payload) {
127
+ ensureConfigured();
128
+
129
+ const parsed = validators.forgotPasswordInput(payload);
130
+ requireNoFieldErrors(parsed, validationError);
131
+
132
+ const supabase = getSupabaseClient();
133
+ const emailRedirectTo = resolveRegisterEmailRedirectTo();
134
+ let response;
135
+ try {
136
+ response = await supabase.auth.resend({
137
+ type: "signup",
138
+ email: parsed.email,
139
+ options: {
140
+ emailRedirectTo
141
+ }
142
+ });
143
+ } catch (error) {
144
+ if (isTransientSupabaseError(error)) {
145
+ throw mapAuthError(error, 503);
146
+ }
147
+
148
+ return {
149
+ ok: true,
150
+ message: "If an account exists for that email, a confirmation email has been sent."
151
+ };
152
+ }
153
+
154
+ if (response.error) {
155
+ if (isTransientSupabaseError(response.error)) {
156
+ throw mapAuthError(response.error, 503);
157
+ }
158
+
159
+ if (isUserNotFoundLikeAuthError(response.error)) {
160
+ return {
161
+ ok: true,
162
+ message: "If an account exists for that email, a confirmation email has been sent."
163
+ };
164
+ }
165
+
166
+ throw mapAuthError(response.error, Number(response.error?.status || 400));
167
+ }
168
+
169
+ return {
170
+ ok: true,
171
+ message: "If an account exists for that email, a confirmation email has been sent."
172
+ };
173
+ }
174
+
109
175
  async function login(payload) {
110
176
  ensureConfigured();
111
177
 
112
178
  const parsed = validators.loginInput(payload);
113
- if (Object.keys(parsed.fieldErrors).length > 0) {
114
- throw validationError(parsed.fieldErrors);
115
- }
179
+ requireNoFieldErrors(parsed, validationError);
116
180
 
117
181
  const supabase = getSupabaseClient();
118
182
  const response = await supabase.auth.signInWithPassword({
@@ -120,11 +184,9 @@ function createAccountFlows(deps) {
120
184
  password: parsed.password
121
185
  });
122
186
 
123
- if (response.error || !response.data?.user || !response.data?.session) {
124
- throw mapAuthError(response.error, 401);
125
- }
187
+ const { user, session } = requireAuthUserSession(response, mapAuthError, 401);
126
188
 
127
- const profile = await syncProfileFromSupabaseUser(response.data.user, parsed.email);
189
+ const profile = await syncProfileFromSupabaseUser(user, parsed.email);
128
190
  const passwordSignInPolicy = await resolvePasswordSignInPolicyForUserId(profile.id);
129
191
  if (!passwordSignInPolicy.passwordSignInEnabled) {
130
192
  throw new AppError(401, "Invalid email or password.");
@@ -132,7 +194,7 @@ function createAccountFlows(deps) {
132
194
 
133
195
  return {
134
196
  profile,
135
- session: response.data.session
197
+ session
136
198
  };
137
199
  }
138
200
 
@@ -140,9 +202,7 @@ function createAccountFlows(deps) {
140
202
  ensureConfigured();
141
203
 
142
204
  const parsed = validators.forgotPasswordInput(payload);
143
- if (Object.keys(parsed.fieldErrors).length > 0) {
144
- throw validationError(parsed.fieldErrors);
145
- }
205
+ requireNoFieldErrors(parsed, validationError);
146
206
 
147
207
  const emailRedirectTo = resolveOtpEmailRedirectTo(payload?.returnTo);
148
208
  const supabase = getSupabaseClient();
@@ -191,9 +251,7 @@ function createAccountFlows(deps) {
191
251
  ensureConfigured();
192
252
 
193
253
  const parsed = parseOtpLoginVerifyPayload(payload);
194
- if (Object.keys(parsed.fieldErrors).length > 0) {
195
- throw validationError(parsed.fieldErrors);
196
- }
254
+ requireNoFieldErrors(parsed, validationError);
197
255
 
198
256
  const supabase = getSupabaseClient();
199
257
  let response;
@@ -214,15 +272,13 @@ function createAccountFlows(deps) {
214
272
  throw mapOtpVerifyError(error);
215
273
  }
216
274
 
217
- if (response.error || !response.data?.session || !response.data?.user) {
218
- throw mapOtpVerifyError(response.error);
219
- }
275
+ const { user, session } = requireAuthUserSession(response, mapOtpVerifyError);
220
276
 
221
- const profile = await syncProfileFromSupabaseUser(response.data.user, response.data.user.email || parsed.email);
277
+ const profile = await syncProfileFromSupabaseUser(user, user.email || parsed.email);
222
278
 
223
279
  return {
224
280
  profile,
225
- session: response.data.session
281
+ session
226
282
  };
227
283
  }
228
284
 
@@ -252,11 +308,9 @@ function createAccountFlows(deps) {
252
308
  throw mapProfileUpdateError(error);
253
309
  }
254
310
 
255
- if (updateResponse.error || !updateResponse.data?.user) {
256
- throw mapProfileUpdateError(updateResponse.error);
257
- }
311
+ const { user } = requireAuthUser(updateResponse, mapProfileUpdateError);
258
312
 
259
- const profile = await syncProfileFromSupabaseUser(updateResponse.data.user, updateResponse.data.user.email);
313
+ const profile = await syncProfileFromSupabaseUser(user, user.email);
260
314
 
261
315
  return {
262
316
  profile,
@@ -266,6 +320,7 @@ function createAccountFlows(deps) {
266
320
 
267
321
  return {
268
322
  register,
323
+ resendRegisterConfirmation,
269
324
  login,
270
325
  requestOtpLogin,
271
326
  verifyOtpLogin,
@@ -1,15 +1,18 @@
1
1
  import {
2
2
  EMPTY_INPUT_VALIDATOR
3
3
  } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
4
- import { authRegisterCommand } from "@jskit-ai/auth-core/shared/commands/authRegisterCommand";
5
- import { authLoginPasswordCommand } from "@jskit-ai/auth-core/shared/commands/authLoginPasswordCommand";
6
- import { authLoginOtpRequestCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOtpRequestCommand";
7
- import { authLoginOtpVerifyCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOtpVerifyCommand";
8
- import { authLoginOAuthStartCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthStartCommand";
9
- import { authLoginOAuthCompleteCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthCompleteCommand";
10
- import { authPasswordResetRequestCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetRequestCommand";
11
- import { authPasswordRecoveryCompleteCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordRecoveryCompleteCommand";
12
- import { authPasswordResetCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetCommand";
4
+ import {
5
+ authRegisterCommand,
6
+ authRegisterConfirmationResendCommand,
7
+ authLoginPasswordCommand,
8
+ authLoginOtpRequestCommand,
9
+ authLoginOtpVerifyCommand,
10
+ authLoginOAuthStartCommand,
11
+ authLoginOAuthCompleteCommand,
12
+ authPasswordResetRequestCommand,
13
+ authPasswordRecoveryCompleteCommand,
14
+ authPasswordResetCommand
15
+ } from "@jskit-ai/auth-core/shared/commands";
13
16
 
14
17
  function requireRequestContext(context, actionId) {
15
18
  const request = context?.requestMeta?.request || null;
@@ -37,6 +40,22 @@ const authActions = Object.freeze([
37
40
  return deps.authService.register(input);
38
41
  }
39
42
  },
43
+ {
44
+ id: "auth.register.confirmation.resend",
45
+ version: 1,
46
+ kind: "command",
47
+ channels: ["api", "internal"],
48
+ surfacesFrom: "enabled",
49
+ inputValidator: authRegisterConfirmationResendCommand.operation.bodyValidator,
50
+ idempotency: "none",
51
+ audit: {
52
+ actionName: "auth.register.confirmation.resend"
53
+ },
54
+ observability: {},
55
+ async execute(input, _context, deps) {
56
+ return deps.authService.resendRegisterConfirmation(input);
57
+ }
58
+ },
40
59
  {
41
60
  id: "auth.login.password",
42
61
  version: 1,
@@ -47,9 +47,14 @@ function mapAuthError(error, fallbackStatus) {
47
47
  return new AppError(503, "Authentication service temporarily unavailable. Please retry.");
48
48
  }
49
49
 
50
+ const statusFromError = Number(error?.status || error?.statusCode);
50
51
  const message = sanitizeAuthMessage(error?.message, "Authentication failed.");
51
52
  const lower = message.toLowerCase();
52
53
 
54
+ if (statusFromError === 429 || lower.includes("rate limit")) {
55
+ return new AppError(429, "Too many authentication attempts. Please wait and try again.");
56
+ }
57
+
53
58
  if (lower.includes("already registered") || lower.includes("already been registered")) {
54
59
  return new AppError(409, "Email is already registered.");
55
60
  }
@@ -89,7 +94,11 @@ function mapAuthError(error, fallbackStatus) {
89
94
  return new AppError(409, "This sign-in method is not currently linked.");
90
95
  }
91
96
 
92
- const status = Number.isInteger(Number(fallbackStatus)) ? Number(fallbackStatus) : 400;
97
+ const status = Number.isInteger(statusFromError)
98
+ ? statusFromError
99
+ : Number.isInteger(Number(fallbackStatus))
100
+ ? Number(fallbackStatus)
101
+ : 400;
93
102
  if (status >= 500) {
94
103
  return new AppError(503, "Authentication service temporarily unavailable. Please retry.");
95
104
  }
@@ -106,7 +106,6 @@ function validatePasswordRecoveryPayload(payload) {
106
106
  }
107
107
 
108
108
  function parseOAuthCompletePayload(payload = {}, options = {}) {
109
- const provider = normalizeOAuthProviderInput(payload.provider || options.defaultProvider, options);
110
109
  const code = String(payload.code || "").trim();
111
110
  const accessToken = String(payload.accessToken || "").trim();
112
111
  const refreshToken = String(payload.refreshToken || "").trim();
@@ -137,6 +136,10 @@ function parseOAuthCompletePayload(payload = {}, options = {}) {
137
136
  applySessionPairValidation(accessToken, refreshToken, fieldErrors);
138
137
 
139
138
  const hasSessionPair = Boolean(accessToken && refreshToken);
139
+ const provider =
140
+ !hasSessionPair || code || errorCode
141
+ ? normalizeOAuthProviderInput(payload.provider || options.defaultProvider, options)
142
+ : null;
140
143
 
141
144
  if (!code && !errorCode && !hasSessionPair) {
142
145
  fieldErrors.code = "OAuth code is required when access/refresh tokens are not provided.";
@@ -27,6 +27,16 @@ function parseHttpUrl(rawValue, variableName) {
27
27
  return parsedUrl;
28
28
  }
29
29
 
30
+ function createAppBaseUrl(appPublicUrl) {
31
+ const baseUrl = parseHttpUrl(String(appPublicUrl || "").trim(), "APP_PUBLIC_URL");
32
+ if (!baseUrl.pathname.endsWith("/")) {
33
+ baseUrl.pathname = `${baseUrl.pathname}/`;
34
+ }
35
+ baseUrl.search = "";
36
+ baseUrl.hash = "";
37
+ return baseUrl;
38
+ }
39
+
30
40
  function buildPasswordResetRedirectUrl(options) {
31
41
  const appPublicUrl = String(options.appPublicUrl || "").trim();
32
42
 
@@ -34,13 +44,7 @@ function buildPasswordResetRedirectUrl(options) {
34
44
  throw new Error("APP_PUBLIC_URL is required to build password reset links.");
35
45
  }
36
46
 
37
- const baseUrl = parseHttpUrl(appPublicUrl, "APP_PUBLIC_URL");
38
- if (!baseUrl.pathname.endsWith("/")) {
39
- baseUrl.pathname = `${baseUrl.pathname}/`;
40
- }
41
- baseUrl.search = "";
42
- baseUrl.hash = "";
43
- return new URL(PASSWORD_RESET_PATH, baseUrl).toString();
47
+ return new URL(PASSWORD_RESET_PATH, createAppBaseUrl(appPublicUrl)).toString();
44
48
  }
45
49
 
46
50
  function buildOtpLoginRedirectUrl(options) {
@@ -51,13 +55,7 @@ function buildOtpLoginRedirectUrl(options) {
51
55
  throw new Error("APP_PUBLIC_URL is required to build OTP login redirects.");
52
56
  }
53
57
 
54
- const baseUrl = parseHttpUrl(appPublicUrl, "APP_PUBLIC_URL");
55
- if (!baseUrl.pathname.endsWith("/")) {
56
- baseUrl.pathname = `${baseUrl.pathname}/`;
57
- }
58
- baseUrl.search = "";
59
- baseUrl.hash = "";
60
- const redirectUrl = new URL(OAUTH_LOGIN_PATH, baseUrl);
58
+ const redirectUrl = new URL(OAUTH_LOGIN_PATH, createAppBaseUrl(appPublicUrl));
61
59
  if (returnTo) {
62
60
  redirectUrl.searchParams.set("returnTo", returnTo);
63
61
  }
@@ -91,14 +89,7 @@ function buildOAuthRedirectUrl(options) {
91
89
  throw new Error("OAuth callback path is required.");
92
90
  }
93
91
 
94
- const baseUrl = parseHttpUrl(appPublicUrl, "APP_PUBLIC_URL");
95
- if (!baseUrl.pathname.endsWith("/")) {
96
- baseUrl.pathname = `${baseUrl.pathname}/`;
97
- }
98
- baseUrl.search = "";
99
- baseUrl.hash = "";
100
-
101
- const redirectUrl = new URL(callbackPath, baseUrl);
92
+ const redirectUrl = new URL(callbackPath, createAppBaseUrl(appPublicUrl));
102
93
  redirectUrl.searchParams.set(OAUTH_QUERY_PARAM_PROVIDER, provider);
103
94
  redirectUrl.searchParams.set(OAUTH_QUERY_PARAM_INTENT, intent);
104
95
  if (returnTo) {
@@ -125,6 +116,7 @@ function buildOAuthLinkRedirectUrl(options) {
125
116
 
126
117
  export {
127
118
  parseHttpUrl,
119
+ createAppBaseUrl,
128
120
  buildPasswordResetRedirectUrl,
129
121
  buildOtpLoginRedirectUrl,
130
122
  normalizeOAuthIntent,
@@ -0,0 +1,58 @@
1
+ function requireNoFieldErrors(parsed, validationError) {
2
+ if (typeof validationError !== "function") {
3
+ throw new TypeError("requireNoFieldErrors requires validationError.");
4
+ }
5
+
6
+ const fieldErrors =
7
+ parsed && typeof parsed === "object" && parsed.fieldErrors && typeof parsed.fieldErrors === "object"
8
+ ? parsed.fieldErrors
9
+ : {};
10
+ if (Object.keys(fieldErrors).length > 0) {
11
+ throw validationError(fieldErrors);
12
+ }
13
+ }
14
+
15
+ function requireAuthUserSession(response, mapError, status = 401) {
16
+ if (typeof mapError !== "function") {
17
+ throw new TypeError("requireAuthUserSession requires mapError.");
18
+ }
19
+
20
+ if (response?.error || !response?.data?.user || !response?.data?.session) {
21
+ throw mapError(response?.error, status);
22
+ }
23
+
24
+ return {
25
+ user: response.data.user,
26
+ session: response.data.session
27
+ };
28
+ }
29
+
30
+ function requireAuthUser(response, mapError, status = 400) {
31
+ if (typeof mapError !== "function") {
32
+ throw new TypeError("requireAuthUser requires mapError.");
33
+ }
34
+
35
+ if (response?.error || !response?.data?.user) {
36
+ throw mapError(response?.error, status);
37
+ }
38
+
39
+ return {
40
+ user: response.data.user
41
+ };
42
+ }
43
+
44
+ function requireAuthSession(response, mapError, status = 401) {
45
+ if (typeof mapError !== "function") {
46
+ throw new TypeError("requireAuthSession requires mapError.");
47
+ }
48
+
49
+ if (response?.error || !response?.data?.session) {
50
+ throw mapError(response?.error, status);
51
+ }
52
+
53
+ return {
54
+ session: response.data.session
55
+ };
56
+ }
57
+
58
+ export { requireNoFieldErrors, requireAuthUserSession, requireAuthUser, requireAuthSession };
@@ -25,7 +25,7 @@ const SUPABASE_OAUTH_PROVIDER_METADATA = Object.freeze({
25
25
  zoom: Object.freeze({ id: "zoom", label: "Zoom" })
26
26
  });
27
27
 
28
- const DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS = Object.freeze(["google"]);
28
+ const DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS = Object.freeze([]);
29
29
 
30
30
  function normalizeProviderLabel(value, fallback) {
31
31
  const normalized = String(value || "").trim();
@@ -1,4 +1,10 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import {
3
+ requireAuthUser,
4
+ requireAuthSession,
5
+ requireAuthUserSession,
6
+ requireNoFieldErrors
7
+ } from "./flowGuards.js";
2
8
 
3
9
  function createPasswordSecurityFlows(deps) {
4
10
  const {
@@ -33,9 +39,7 @@ function createPasswordSecurityFlows(deps) {
33
39
  ensureConfigured();
34
40
 
35
41
  const parsed = validators.forgotPasswordInput(payload);
36
- if (Object.keys(parsed.fieldErrors).length > 0) {
37
- throw validationError(parsed.fieldErrors);
38
- }
42
+ requireNoFieldErrors(parsed, validationError);
39
43
 
40
44
  const supabase = getSupabaseClient();
41
45
  const options = { redirectTo: passwordResetRedirectUrl };
@@ -62,9 +66,7 @@ function createPasswordSecurityFlows(deps) {
62
66
  ensureConfigured();
63
67
 
64
68
  const parsed = validatePasswordRecoveryPayload(payload);
65
- if (Object.keys(parsed.fieldErrors).length > 0) {
66
- throw validationError(parsed.fieldErrors);
67
- }
69
+ requireNoFieldErrors(parsed, validationError);
68
70
 
69
71
  const supabase = getSupabaseClient();
70
72
  let response;
@@ -91,16 +93,12 @@ function createPasswordSecurityFlows(deps) {
91
93
  throw mapRecoveryError(response.error);
92
94
  }
93
95
 
94
- /* c8 ignore next 3 -- defensive against malformed SDK responses without explicit error payload. */
95
- if (!response.data?.session || !response.data?.user) {
96
- throw new AppError(401, "Recovery link is invalid or has expired.");
97
- }
98
-
99
- const profile = await syncProfileFromSupabaseUser(response.data.user, response.data.user.email);
96
+ const { user, session } = requireAuthUserSession(response, () => new AppError(401, "Recovery link is invalid or has expired."));
97
+ const profile = await syncProfileFromSupabaseUser(user, user.email);
100
98
 
101
99
  return {
102
100
  profile,
103
- session: response.data.session
101
+ session
104
102
  };
105
103
  }
106
104
 
@@ -108,9 +106,7 @@ function createPasswordSecurityFlows(deps) {
108
106
  ensureConfigured();
109
107
 
110
108
  const parsed = validators.resetPasswordInput(payload);
111
- if (Object.keys(parsed.fieldErrors).length > 0) {
112
- throw validationError(parsed.fieldErrors);
113
- }
109
+ requireNoFieldErrors(parsed, validationError);
114
110
 
115
111
  const supabase = getSupabaseClient();
116
112
  const sessionResponse = await setSessionFromRequestCookies(request, {
@@ -134,11 +130,9 @@ function createPasswordSecurityFlows(deps) {
134
130
  throw mapPasswordUpdateError(error);
135
131
  }
136
132
 
137
- if (updateResponse.error || !updateResponse.data?.user) {
138
- throw mapPasswordUpdateError(updateResponse.error);
139
- }
133
+ const { user } = requireAuthUser(updateResponse, mapPasswordUpdateError);
140
134
 
141
- const updatedProfile = await syncProfileFromSupabaseUser(updateResponse.data.user, updateResponse.data.user.email);
135
+ const updatedProfile = await syncProfileFromSupabaseUser(user, user.email);
142
136
  await setPasswordSetupRequiredForUserId(updatedProfile.id, false);
143
137
  }
144
138
 
@@ -170,9 +164,7 @@ function createPasswordSecurityFlows(deps) {
170
164
  throw mapCurrentPasswordError(error);
171
165
  }
172
166
 
173
- if (verifyResponse.error || !verifyResponse.data?.session) {
174
- throw mapCurrentPasswordError(verifyResponse.error);
175
- }
167
+ requireAuthSession(verifyResponse, mapCurrentPasswordError);
176
168
  }
177
169
 
178
170
  let updateResponse;
@@ -184,11 +176,9 @@ function createPasswordSecurityFlows(deps) {
184
176
  throw mapPasswordUpdateError(error);
185
177
  }
186
178
 
187
- if (updateResponse.error || !updateResponse.data?.user) {
188
- throw mapPasswordUpdateError(updateResponse.error);
189
- }
179
+ const { user } = requireAuthUser(updateResponse, mapPasswordUpdateError);
190
180
 
191
- const profile = await syncProfileFromSupabaseUser(updateResponse.data.user, updateResponse.data.user.email);
181
+ const profile = await syncProfileFromSupabaseUser(user, user.email);
192
182
  await setPasswordSetupRequiredForUserId(profile.id, false);
193
183
 
194
184
  return {
@@ -560,7 +560,7 @@ function createService(options) {
560
560
  };
561
561
  }
562
562
 
563
- const { register, login, requestOtpLogin, verifyOtpLogin, updateDisplayName } = createAccountFlows({
563
+ const { register, resendRegisterConfirmation, login, requestOtpLogin, verifyOtpLogin, updateDisplayName } = createAccountFlows({
564
564
  ensureConfigured,
565
565
  validators,
566
566
  validationError,
@@ -797,6 +797,7 @@ function createService(options) {
797
797
 
798
798
  return {
799
799
  register,
800
+ resendRegisterConfirmation,
800
801
  login,
801
802
  requestOtpLogin,
802
803
  verifyOtpLogin,
@@ -20,6 +20,35 @@ function splitCsv(value) {
20
20
  .filter(Boolean);
21
21
  }
22
22
 
23
+ function normalizeRecord(value) {
24
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
25
+ }
26
+
27
+ function normalizeOAuthProviderConfigList(value) {
28
+ if (Array.isArray(value)) {
29
+ return value
30
+ .map((entry) => String(entry || "").trim())
31
+ .filter(Boolean);
32
+ }
33
+
34
+ if (typeof value === "string") {
35
+ return splitCsv(value);
36
+ }
37
+
38
+ return [];
39
+ }
40
+
41
+ function resolveOAuthConfigFromAppConfig(appConfig) {
42
+ const source = normalizeRecord(appConfig);
43
+ const auth = normalizeRecord(source.auth);
44
+ const oauth = normalizeRecord(auth.oauth);
45
+
46
+ return {
47
+ oauthProviders: normalizeOAuthProviderConfigList(oauth.providers),
48
+ oauthDefaultProvider: String(oauth.defaultProvider || "").trim()
49
+ };
50
+ }
51
+
23
52
  function resolveAllowedReturnToOrigins({ appConfig = {}, appPublicUrl = "" } = {}) {
24
53
  const surfaceDefinitions =
25
54
  appConfig && typeof appConfig === "object" && appConfig.surfaceDefinitions && typeof appConfig.surfaceDefinitions === "object"
@@ -31,8 +60,12 @@ function resolveAllowedReturnToOrigins({ appConfig = {}, appPublicUrl = "" } = {
31
60
  });
32
61
  }
33
62
 
34
- function resolveAuthProviderConfig(env) {
63
+ function resolveAuthProviderConfig(env, appConfig = {}) {
35
64
  const source = env && typeof env === "object" ? env : {};
65
+ const oauthConfigFromApp = resolveOAuthConfigFromAppConfig(appConfig);
66
+ const oauthProvidersFromEnv = splitCsv(source.AUTH_OAUTH_PROVIDERS);
67
+ const oauthDefaultProviderFromEnv = String(source.AUTH_OAUTH_DEFAULT_PROVIDER || "").trim();
68
+
36
69
  return {
37
70
  id: "supabase",
38
71
  supabaseUrl: String(source.AUTH_SUPABASE_URL || source.SUPABASE_URL || "").trim(),
@@ -40,8 +73,9 @@ function resolveAuthProviderConfig(env) {
40
73
  source.AUTH_SUPABASE_PUBLISHABLE_KEY || source.SUPABASE_PUBLISHABLE_KEY || ""
41
74
  ).trim(),
42
75
  jwtAudience: String(source.AUTH_JWT_AUDIENCE || "authenticated").trim(),
43
- oauthProviders: splitCsv(source.AUTH_OAUTH_PROVIDERS),
44
- oauthDefaultProvider: String(source.AUTH_OAUTH_DEFAULT_PROVIDER || "").trim()
76
+ oauthProviders:
77
+ oauthProvidersFromEnv.length > 0 ? oauthProvidersFromEnv : oauthConfigFromApp.oauthProviders,
78
+ oauthDefaultProvider: oauthDefaultProviderFromEnv || oauthConfigFromApp.oauthDefaultProvider
45
79
  };
46
80
  }
47
81
 
@@ -138,7 +172,7 @@ class AuthSupabaseServiceProvider {
138
172
  ...envFromDependencies
139
173
  };
140
174
  const appConfig = scope.has("appConfig") ? scope.make("appConfig") : {};
141
- const authProvider = resolveAuthProviderConfig(env);
175
+ const authProvider = resolveAuthProviderConfig(env, appConfig);
142
176
  const repositories = resolveOptionalRepositories(scope);
143
177
  const userSettingsRepository = repositories.userSettingsRepository || fallbackUserSettingsRepository;
144
178
  if (!authProvider.supabaseUrl || !authProvider.supabasePublishableKey) {
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mapAuthError } from "../src/server/lib/authErrorMappers.js";
4
+
5
+ test("mapAuthError preserves rate-limit errors as 429 with actionable message", () => {
6
+ const mapped = mapAuthError(
7
+ {
8
+ status: 429,
9
+ message: "email rate limit exceeded"
10
+ },
11
+ 400
12
+ );
13
+
14
+ assert.equal(mapped.status, 429);
15
+ assert.equal(mapped.message, "Too many authentication attempts. Please wait and try again.");
16
+ });
17
+
18
+ test("mapAuthError honors upstream status codes for unknown client errors", () => {
19
+ const mapped = mapAuthError(
20
+ {
21
+ status: 422,
22
+ message: "unexpected auth validation issue"
23
+ },
24
+ 400
25
+ );
26
+
27
+ assert.equal(mapped.status, 422);
28
+ assert.equal(mapped.message, "Authentication request could not be processed.");
29
+ });
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { parseOAuthCompletePayload } from "../src/server/lib/authInputParsers.js";
4
+
5
+ test("parseOAuthCompletePayload accepts provider-less session pairs", () => {
6
+ const parsed = parseOAuthCompletePayload(
7
+ {
8
+ accessToken: "access-token",
9
+ refreshToken: "refresh-token"
10
+ },
11
+ {
12
+ providerIds: [],
13
+ defaultProvider: null
14
+ }
15
+ );
16
+
17
+ assert.equal(parsed.hasSessionPair, true);
18
+ assert.equal(parsed.provider, null);
19
+ assert.deepEqual(parsed.fieldErrors, {});
20
+ });
21
+
22
+ test("parseOAuthCompletePayload still requires OAuth provider for code exchanges", () => {
23
+ assert.throws(
24
+ () =>
25
+ parseOAuthCompletePayload(
26
+ {
27
+ code: "oauth-code"
28
+ },
29
+ {
30
+ providerIds: [],
31
+ defaultProvider: null
32
+ }
33
+ ),
34
+ (error) => {
35
+ assert.equal(error?.status, 400);
36
+ assert.equal(String(error?.details?.fieldErrors?.provider || "").length > 0, true);
37
+ return true;
38
+ }
39
+ );
40
+ });
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS,
5
+ resolveSupabaseOAuthProviderCatalog
6
+ } from "../src/server/lib/oauthProviderCatalog.js";
7
+
8
+ test("oauth provider catalog defaults to no providers", () => {
9
+ assert.deepEqual(DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS, []);
10
+
11
+ const catalog = resolveSupabaseOAuthProviderCatalog();
12
+ assert.deepEqual(catalog.providers, []);
13
+ assert.deepEqual(catalog.providerIds, []);
14
+ assert.equal(catalog.defaultProvider, null);
15
+ });
16
+
17
+ test("oauth provider catalog uses explicit provider configuration", () => {
18
+ const catalog = resolveSupabaseOAuthProviderCatalog({
19
+ oauthProviders: ["github", "google"],
20
+ oauthDefaultProvider: "google"
21
+ });
22
+
23
+ assert.deepEqual(
24
+ catalog.providers.map((provider) => provider.id),
25
+ ["github", "google"]
26
+ );
27
+ assert.equal(catalog.defaultProvider, "google");
28
+ });
@@ -69,6 +69,7 @@ test("auth supabase provider registers authService and contributes auth actions
69
69
  const definitions = actionExecutor.listDefinitions();
70
70
  assert.equal(Array.isArray(definitions), true);
71
71
  assert.equal(definitions.some((definition) => definition.id === "auth.login.password"), true);
72
+ assert.equal(definitions.some((definition) => definition.id === "auth.register.confirmation.resend"), true);
72
73
  const sessionRead = definitions.find((definition) => definition.id === "auth.session.read");
73
74
  assert.deepEqual(sessionRead?.surfaces, ["home", "console"]);
74
75
  });
@@ -154,3 +155,81 @@ test("auth supabase provider rejects unsupported AUTH_PROFILE_MODE values", asyn
154
155
 
155
156
  assert.throws(() => app.make("authService"), /Unsupported AUTH_PROFILE_MODE/);
156
157
  });
158
+
159
+ test("auth supabase provider reads oauth providers from appConfig.auth.oauth", async () => {
160
+ const app = createApplication();
161
+ app.instance("appConfig", {
162
+ ...createAppConfigFixture(),
163
+ auth: {
164
+ oauth: {
165
+ providers: ["github"],
166
+ defaultProvider: "github"
167
+ }
168
+ }
169
+ });
170
+ app.instance(KERNEL_TOKENS.Env, {
171
+ AUTH_SUPABASE_URL: "https://example.supabase.co",
172
+ AUTH_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test_key",
173
+ AUTH_PROFILE_MODE: "standalone",
174
+ APP_PUBLIC_URL: "http://localhost:5173",
175
+ NODE_ENV: "test"
176
+ });
177
+ app.instance(KERNEL_TOKENS.Logger, {
178
+ info() {},
179
+ warn() {},
180
+ error() {},
181
+ debug() {}
182
+ });
183
+ app.instance("domainEvents", {
184
+ async publish() {}
185
+ });
186
+
187
+ await app.start({
188
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
189
+ });
190
+
191
+ const authService = app.make("authService");
192
+ const catalog = authService.getOAuthProviderCatalog();
193
+ assert.deepEqual(catalog.providers.map((provider) => provider.id), ["github"]);
194
+ assert.equal(catalog.defaultProvider, "github");
195
+ });
196
+
197
+ test("auth supabase provider lets env oauth settings override appConfig.auth.oauth", async () => {
198
+ const app = createApplication();
199
+ app.instance("appConfig", {
200
+ ...createAppConfigFixture(),
201
+ auth: {
202
+ oauth: {
203
+ providers: ["github"],
204
+ defaultProvider: "github"
205
+ }
206
+ }
207
+ });
208
+ app.instance(KERNEL_TOKENS.Env, {
209
+ AUTH_SUPABASE_URL: "https://example.supabase.co",
210
+ AUTH_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test_key",
211
+ AUTH_OAUTH_PROVIDERS: "google",
212
+ AUTH_OAUTH_DEFAULT_PROVIDER: "google",
213
+ AUTH_PROFILE_MODE: "standalone",
214
+ APP_PUBLIC_URL: "http://localhost:5173",
215
+ NODE_ENV: "test"
216
+ });
217
+ app.instance(KERNEL_TOKENS.Logger, {
218
+ info() {},
219
+ warn() {},
220
+ error() {},
221
+ debug() {}
222
+ });
223
+ app.instance("domainEvents", {
224
+ async publish() {}
225
+ });
226
+
227
+ await app.start({
228
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
229
+ });
230
+
231
+ const authService = app.make("authService");
232
+ const catalog = authService.getOAuthProviderCatalog();
233
+ assert.deepEqual(catalog.providers.map((provider) => provider.id), ["google"]);
234
+ assert.equal(catalog.defaultProvider, "google");
235
+ });