@jskit-ai/auth-provider-supabase-core 0.1.53 → 0.1.55

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.53",
4
+ "version": "0.1.55",
5
5
  "kind": "runtime",
6
6
  "options": {
7
7
  "auth-supabase-url": {
@@ -83,8 +83,8 @@ export default Object.freeze({
83
83
  "mutations": {
84
84
  "dependencies": {
85
85
  "runtime": {
86
- "@jskit-ai/auth-core": "0.1.53",
87
- "@jskit-ai/kernel": "0.1.54",
86
+ "@jskit-ai/auth-core": "0.1.55",
87
+ "@jskit-ai/kernel": "0.1.56",
88
88
  "dotenv": "^16.4.5",
89
89
  "@supabase/supabase-js": "^2.57.4",
90
90
  "jose": "^6.1.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-provider-supabase-core",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -12,8 +12,9 @@
12
12
  "./client": "./src/client/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/auth-core": "0.1.53",
16
- "@jskit-ai/kernel": "0.1.54",
15
+ "@jskit-ai/auth-core": "0.1.55",
16
+ "@jskit-ai/kernel": "0.1.56",
17
+ "json-rest-schema": "1.x.x",
17
18
  "jose": "^6.1.0",
18
19
  "@supabase/supabase-js": "^2.57.4"
19
20
  }
@@ -1,8 +1,12 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { authRegisterBodyValidator } from "@jskit-ai/auth-core/shared/commands/authRegisterCommand";
3
+ import { authRegisterConfirmationResendBodyValidator } from "@jskit-ai/auth-core/shared/commands/authRegisterConfirmationResendCommand";
4
+ import { authLoginPasswordBodyValidator } from "@jskit-ai/auth-core/shared/commands/authLoginPasswordCommand";
5
+ import { authLoginOtpRequestBodyValidator } from "@jskit-ai/auth-core/shared/commands/authLoginOtpRequestCommand";
6
+ import { authLoginOtpVerifyBodyValidator } from "@jskit-ai/auth-core/shared/commands/authLoginOtpVerifyCommand";
2
7
  import {
3
8
  requireAuthUser,
4
- requireAuthUserSession,
5
- requireNoFieldErrors
9
+ requireAuthUserSession
6
10
  } from "./flowGuards.js";
7
11
 
8
12
  function normalizeLocalReturnToPath(value, { fallback = "" } = {}) {
@@ -21,7 +25,6 @@ function normalizeLocalReturnToPath(value, { fallback = "" } = {}) {
21
25
  function createAccountFlows(deps) {
22
26
  const {
23
27
  ensureConfigured,
24
- validators,
25
28
  validationError,
26
29
  getSupabaseClient,
27
30
  displayNameFromEmail,
@@ -33,7 +36,6 @@ function createAccountFlows(deps) {
33
36
  appPublicUrl,
34
37
  isTransientSupabaseError,
35
38
  isUserNotFoundLikeAuthError,
36
- parseOtpLoginVerifyPayload,
37
39
  mapOtpVerifyError,
38
40
  setSessionFromRequestCookies,
39
41
  mapProfileUpdateError,
@@ -82,8 +84,11 @@ function createAccountFlows(deps) {
82
84
  async function register(payload) {
83
85
  ensureConfigured();
84
86
 
85
- const parsed = validators.registerInput(payload);
86
- requireNoFieldErrors(parsed, validationError);
87
+ const result = authRegisterBodyValidator.schema.create(payload);
88
+ if (Object.keys(result.errors).length > 0) {
89
+ throw validationError(result.errors);
90
+ }
91
+ const parsed = result.validatedObject;
87
92
 
88
93
  const supabase = getSupabaseClient();
89
94
  const response = await supabase.auth.signUp({
@@ -126,8 +131,11 @@ function createAccountFlows(deps) {
126
131
  async function resendRegisterConfirmation(payload) {
127
132
  ensureConfigured();
128
133
 
129
- const parsed = validators.forgotPasswordInput(payload);
130
- requireNoFieldErrors(parsed, validationError);
134
+ const result = authRegisterConfirmationResendBodyValidator.schema.create(payload);
135
+ if (Object.keys(result.errors).length > 0) {
136
+ throw validationError(result.errors);
137
+ }
138
+ const parsed = result.validatedObject;
131
139
 
132
140
  const supabase = getSupabaseClient();
133
141
  const emailRedirectTo = resolveRegisterEmailRedirectTo();
@@ -175,8 +183,11 @@ function createAccountFlows(deps) {
175
183
  async function login(payload) {
176
184
  ensureConfigured();
177
185
 
178
- const parsed = validators.loginInput(payload);
179
- requireNoFieldErrors(parsed, validationError);
186
+ const result = authLoginPasswordBodyValidator.schema.create(payload);
187
+ if (Object.keys(result.errors).length > 0) {
188
+ throw validationError(result.errors);
189
+ }
190
+ const parsed = result.validatedObject;
180
191
 
181
192
  const supabase = getSupabaseClient();
182
193
  const response = await supabase.auth.signInWithPassword({
@@ -201,10 +212,13 @@ function createAccountFlows(deps) {
201
212
  async function requestOtpLogin(payload) {
202
213
  ensureConfigured();
203
214
 
204
- const parsed = validators.forgotPasswordInput(payload);
205
- requireNoFieldErrors(parsed, validationError);
215
+ const result = authLoginOtpRequestBodyValidator.schema.create(payload);
216
+ if (Object.keys(result.errors).length > 0) {
217
+ throw validationError(result.errors);
218
+ }
219
+ const parsed = result.validatedObject;
206
220
 
207
- const emailRedirectTo = resolveOtpEmailRedirectTo(payload?.returnTo);
221
+ const emailRedirectTo = resolveOtpEmailRedirectTo(parsed.returnTo);
208
222
  const supabase = getSupabaseClient();
209
223
  let response;
210
224
  try {
@@ -250,22 +264,41 @@ function createAccountFlows(deps) {
250
264
  async function verifyOtpLogin(payload) {
251
265
  ensureConfigured();
252
266
 
253
- const parsed = parseOtpLoginVerifyPayload(payload);
254
- requireNoFieldErrors(parsed, validationError);
267
+ const result = authLoginOtpVerifyBodyValidator.schema.patch(payload);
268
+ if (Object.keys(result.errors).length > 0) {
269
+ throw validationError(result.errors);
270
+ }
271
+ const parsed = result.validatedObject;
272
+ const fieldErrors = {};
273
+ const token = String(parsed.token || "").trim();
274
+ const tokenHash = String(parsed.tokenHash || "").trim();
275
+ const type = String(parsed.type || "email").trim().toLowerCase();
276
+
277
+ if (!token && !tokenHash) {
278
+ fieldErrors.token = "One-time code is required.";
279
+ }
280
+
281
+ if (!tokenHash && !parsed.email) {
282
+ fieldErrors.email = "Email is required.";
283
+ }
284
+
285
+ if (Object.keys(fieldErrors).length > 0) {
286
+ throw validationError(fieldErrors);
287
+ }
255
288
 
256
289
  const supabase = getSupabaseClient();
257
290
  let response;
258
291
  try {
259
- if (parsed.tokenHash) {
292
+ if (tokenHash) {
260
293
  response = await supabase.auth.verifyOtp({
261
- token_hash: parsed.tokenHash,
262
- type: parsed.type
294
+ token_hash: tokenHash,
295
+ type
263
296
  });
264
297
  } else {
265
298
  response = await supabase.auth.verifyOtp({
266
299
  email: parsed.email,
267
- token: parsed.token,
268
- type: parsed.type
300
+ token,
301
+ type
269
302
  });
270
303
  }
271
304
  } catch (error) {
@@ -1,6 +1,11 @@
1
+ import { createSchema } from "json-rest-schema";
1
2
  import {
2
- EMPTY_INPUT_VALIDATOR
3
+ emptyInputValidator
3
4
  } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
5
+ import {
6
+ composeSchemaDefinitions
7
+ } from "@jskit-ai/kernel/shared/validators";
8
+ import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
4
9
  import {
5
10
  authRegisterCommand,
6
11
  authRegisterConfirmationResendCommand,
@@ -15,6 +20,22 @@ import {
15
20
  authPasswordResetCommand
16
21
  } from "@jskit-ai/auth-core/shared/commands";
17
22
 
23
+ const authLoginOAuthStartInput = composeSchemaDefinitions([
24
+ authLoginOAuthStartCommand.operation.params,
25
+ authLoginOAuthStartCommand.operation.query
26
+ ], {
27
+ mode: "patch",
28
+ context: "authContributor.authLoginOAuthStartInput"
29
+ });
30
+
31
+ const authLogoutOutput = deepFreeze({
32
+ schema: createSchema({
33
+ ok: { type: "boolean", required: true },
34
+ clearSession: { type: "boolean", required: true }
35
+ }),
36
+ mode: "replace"
37
+ });
38
+
18
39
  function requireRequestContext(context, actionId) {
19
40
  const request = context?.requestMeta?.request || null;
20
41
  if (request) {
@@ -30,7 +51,7 @@ const devLoginAsAction = Object.freeze({
30
51
  kind: "command",
31
52
  channels: ["api", "internal"],
32
53
  surfacesFrom: "enabled",
33
- inputValidator: authDevLoginAsCommand.operation.bodyValidator,
54
+ input: authDevLoginAsCommand.operation.body,
34
55
  idempotency: "none",
35
56
  audit: {
36
57
  actionName: "auth.dev.loginAs"
@@ -48,7 +69,7 @@ const authActionsBeforeDevLogin = Object.freeze([
48
69
  kind: "command",
49
70
  channels: ["api", "internal"],
50
71
  surfacesFrom: "enabled",
51
- inputValidator: authRegisterCommand.operation.bodyValidator,
72
+ input: authRegisterCommand.operation.body,
52
73
  idempotency: "none",
53
74
  audit: {
54
75
  actionName: "auth.register"
@@ -64,7 +85,7 @@ const authActionsBeforeDevLogin = Object.freeze([
64
85
  kind: "command",
65
86
  channels: ["api", "internal"],
66
87
  surfacesFrom: "enabled",
67
- inputValidator: authRegisterConfirmationResendCommand.operation.bodyValidator,
88
+ input: authRegisterConfirmationResendCommand.operation.body,
68
89
  idempotency: "none",
69
90
  audit: {
70
91
  actionName: "auth.register.confirmation.resend"
@@ -80,7 +101,7 @@ const authActionsBeforeDevLogin = Object.freeze([
80
101
  kind: "command",
81
102
  channels: ["api", "internal"],
82
103
  surfacesFrom: "enabled",
83
- inputValidator: authLoginPasswordCommand.operation.bodyValidator,
104
+ input: authLoginPasswordCommand.operation.body,
84
105
  idempotency: "none",
85
106
  audit: {
86
107
  actionName: "auth.login.password"
@@ -96,7 +117,7 @@ const authActionsBeforeDevLogin = Object.freeze([
96
117
  kind: "command",
97
118
  channels: ["api", "internal"],
98
119
  surfacesFrom: "enabled",
99
- inputValidator: authLoginOtpRequestCommand.operation.bodyValidator,
120
+ input: authLoginOtpRequestCommand.operation.body,
100
121
  idempotency: "none",
101
122
  audit: {
102
123
  actionName: "auth.login.otp.request"
@@ -112,7 +133,7 @@ const authActionsBeforeDevLogin = Object.freeze([
112
133
  kind: "command",
113
134
  channels: ["api", "internal"],
114
135
  surfacesFrom: "enabled",
115
- inputValidator: authLoginOtpVerifyCommand.operation.bodyValidator,
136
+ input: authLoginOtpVerifyCommand.operation.body,
116
137
  idempotency: "none",
117
138
  audit: {
118
139
  actionName: "auth.login.otp.verify"
@@ -128,7 +149,7 @@ const authActionsBeforeDevLogin = Object.freeze([
128
149
  kind: "command",
129
150
  channels: ["api", "internal"],
130
151
  surfacesFrom: "enabled",
131
- inputValidator: [authLoginOAuthStartCommand.operation.paramsValidator, authLoginOAuthStartCommand.operation.queryValidator],
152
+ input: authLoginOAuthStartInput,
132
153
  idempotency: "none",
133
154
  audit: {
134
155
  actionName: "auth.login.oauth.start"
@@ -144,7 +165,7 @@ const authActionsBeforeDevLogin = Object.freeze([
144
165
  kind: "command",
145
166
  channels: ["api", "internal"],
146
167
  surfacesFrom: "enabled",
147
- inputValidator: authLoginOAuthCompleteCommand.operation.bodyValidator,
168
+ input: authLoginOAuthCompleteCommand.operation.body,
148
169
  idempotency: "none",
149
170
  audit: {
150
171
  actionName: "auth.login.oauth.complete"
@@ -163,7 +184,7 @@ const authActionsAfterDevLogin = Object.freeze([
163
184
  kind: "command",
164
185
  channels: ["api", "internal"],
165
186
  surfacesFrom: "enabled",
166
- inputValidator: authPasswordResetRequestCommand.operation.bodyValidator,
187
+ input: authPasswordResetRequestCommand.operation.body,
167
188
  idempotency: "none",
168
189
  audit: {
169
190
  actionName: "auth.password.reset.request"
@@ -179,7 +200,7 @@ const authActionsAfterDevLogin = Object.freeze([
179
200
  kind: "command",
180
201
  channels: ["api", "internal"],
181
202
  surfacesFrom: "enabled",
182
- inputValidator: authPasswordRecoveryCompleteCommand.operation.bodyValidator,
203
+ input: authPasswordRecoveryCompleteCommand.operation.body,
183
204
  idempotency: "none",
184
205
  audit: {
185
206
  actionName: "auth.password.recovery.complete"
@@ -195,7 +216,7 @@ const authActionsAfterDevLogin = Object.freeze([
195
216
  kind: "command",
196
217
  channels: ["api", "internal"],
197
218
  surfacesFrom: "enabled",
198
- inputValidator: authPasswordResetCommand.operation.bodyValidator,
219
+ input: authPasswordResetCommand.operation.body,
199
220
  idempotency: "none",
200
221
  audit: {
201
222
  actionName: "auth.password.reset"
@@ -211,22 +232,8 @@ const authActionsAfterDevLogin = Object.freeze([
211
232
  kind: "command",
212
233
  channels: ["api", "automation", "internal"],
213
234
  surfacesFrom: "enabled",
214
- inputValidator: EMPTY_INPUT_VALIDATOR,
215
- outputValidator: {
216
- schema: {
217
- type: "object",
218
- properties: {
219
- ok: {
220
- type: "boolean"
221
- },
222
- clearSession: {
223
- type: "boolean"
224
- }
225
- },
226
- required: ["ok", "clearSession"],
227
- additionalProperties: false
228
- }
229
- },
235
+ input: emptyInputValidator,
236
+ output: authLogoutOutput,
230
237
  idempotency: "none",
231
238
  audit: {
232
239
  actionName: "auth.logout"
@@ -250,7 +257,7 @@ const authActionsAfterDevLogin = Object.freeze([
250
257
  kind: "query",
251
258
  channels: ["api", "internal"],
252
259
  surfacesFrom: "enabled",
253
- inputValidator: EMPTY_INPUT_VALIDATOR,
260
+ input: emptyInputValidator,
254
261
  idempotency: "none",
255
262
  audit: {
256
263
  actionName: "auth.session.read"
@@ -1,31 +1,8 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
- import {
3
- AUTH_ACCESS_TOKEN_MAX_LENGTH,
4
- AUTH_RECOVERY_TOKEN_MAX_LENGTH,
5
- AUTH_REFRESH_TOKEN_MAX_LENGTH
6
- } from "@jskit-ai/auth-core/shared/authConstraints";
7
2
  import { normalizeOAuthProviderList } from "@jskit-ai/auth-core/shared/oauthProviders";
8
- import { validators } from "@jskit-ai/auth-core/server/validators";
9
3
  import { normalizeOAuthProviderFromCatalog } from "./oauthProviderCatalog.js";
10
4
  import { validationError } from "./authErrorMappers.js";
11
5
 
12
- const OTP_VERIFY_TYPE = "email";
13
- const SESSION_PAIR_REQUIRED_ERRORS = Object.freeze({
14
- accessToken: "Access token is required when a refresh token is provided.",
15
- refreshToken: "Refresh token is required when an access token is provided."
16
- });
17
-
18
- function applySessionPairValidation(accessToken, refreshToken, fieldErrors) {
19
- if ((accessToken && !refreshToken) || (!accessToken && refreshToken)) {
20
- if (!accessToken) {
21
- fieldErrors.accessToken = SESSION_PAIR_REQUIRED_ERRORS.accessToken;
22
- }
23
- if (!refreshToken) {
24
- fieldErrors.refreshToken = SESSION_PAIR_REQUIRED_ERRORS.refreshToken;
25
- }
26
- }
27
- }
28
-
29
6
  function resolveConfiguredOAuthProviders(options = {}) {
30
7
  return normalizeOAuthProviderList(options.providerIds, { fallback: [] });
31
8
  }
@@ -51,154 +28,6 @@ function normalizeOAuthProviderInput(value, options = {}) {
51
28
  });
52
29
  }
53
30
 
54
- function validatePasswordRecoveryPayload(payload) {
55
- const code = String(payload?.code || "").trim();
56
- const tokenHash = String(payload?.tokenHash || "").trim();
57
- const type = String(payload?.type || "recovery")
58
- .trim()
59
- .toLowerCase();
60
- const accessToken = String(payload?.accessToken || "").trim();
61
- const refreshToken = String(payload?.refreshToken || "").trim();
62
-
63
- const fieldErrors = {};
64
-
65
- if (type !== "recovery") {
66
- fieldErrors.type = "Only recovery password reset links are supported.";
67
- }
68
-
69
- if (code.length > AUTH_RECOVERY_TOKEN_MAX_LENGTH) {
70
- fieldErrors.code = "Recovery code is too long.";
71
- }
72
-
73
- if (tokenHash.length > AUTH_RECOVERY_TOKEN_MAX_LENGTH) {
74
- fieldErrors.tokenHash = "Recovery token is too long.";
75
- }
76
-
77
- if (accessToken.length > AUTH_ACCESS_TOKEN_MAX_LENGTH) {
78
- fieldErrors.accessToken = "Access token is too long.";
79
- }
80
-
81
- if (refreshToken.length > AUTH_REFRESH_TOKEN_MAX_LENGTH) {
82
- fieldErrors.refreshToken = "Refresh token is too long.";
83
- }
84
-
85
- applySessionPairValidation(accessToken, refreshToken, fieldErrors);
86
-
87
- const hasCode = Boolean(code);
88
- const hasTokenHash = Boolean(tokenHash);
89
- const hasSessionPair = Boolean(accessToken && refreshToken);
90
-
91
- if (!hasCode && !hasTokenHash && !hasSessionPair) {
92
- fieldErrors.recovery = "Recovery token is required.";
93
- }
94
-
95
- return {
96
- code,
97
- tokenHash,
98
- type,
99
- accessToken,
100
- refreshToken,
101
- hasCode,
102
- hasTokenHash,
103
- hasSessionPair,
104
- fieldErrors
105
- };
106
- }
107
-
108
- function parseOAuthCompletePayload(payload = {}, options = {}) {
109
- const code = String(payload.code || "").trim();
110
- const accessToken = String(payload.accessToken || "").trim();
111
- const refreshToken = String(payload.refreshToken || "").trim();
112
- const errorCode = String(payload.error || payload.error_code || "").trim();
113
- const errorDescription = String(payload.errorDescription || payload.error_description || "").trim();
114
- const fieldErrors = {};
115
-
116
- if (code.length > AUTH_RECOVERY_TOKEN_MAX_LENGTH) {
117
- fieldErrors.code = "OAuth code is too long.";
118
- }
119
-
120
- if (errorCode.length > 128) {
121
- fieldErrors.error = "OAuth error code is too long.";
122
- }
123
-
124
- if (errorDescription.length > 1024) {
125
- fieldErrors.errorDescription = "OAuth error description is too long.";
126
- }
127
-
128
- if (accessToken.length > AUTH_ACCESS_TOKEN_MAX_LENGTH) {
129
- fieldErrors.accessToken = "Access token is too long.";
130
- }
131
-
132
- if (refreshToken.length > AUTH_REFRESH_TOKEN_MAX_LENGTH) {
133
- fieldErrors.refreshToken = "Refresh token is too long.";
134
- }
135
-
136
- applySessionPairValidation(accessToken, refreshToken, fieldErrors);
137
-
138
- const hasSessionPair = Boolean(accessToken && refreshToken);
139
- const provider =
140
- !hasSessionPair || code || errorCode
141
- ? normalizeOAuthProviderInput(payload.provider || options.defaultProvider, options)
142
- : null;
143
-
144
- if (!code && !errorCode && !hasSessionPair) {
145
- fieldErrors.code = "OAuth code is required when access/refresh tokens are not provided.";
146
- }
147
-
148
- return {
149
- provider,
150
- code,
151
- accessToken,
152
- refreshToken,
153
- hasSessionPair,
154
- errorCode,
155
- errorDescription,
156
- fieldErrors
157
- };
158
- }
159
-
160
- function parseOtpLoginVerifyPayload(payload = {}) {
161
- const parsedEmail = validators.forgotPasswordInput(payload);
162
- const token = String(payload?.token || "").trim();
163
- const tokenHash = String(payload?.tokenHash || "").trim();
164
- const type = String(payload?.type || OTP_VERIFY_TYPE)
165
- .trim()
166
- .toLowerCase();
167
- const fieldErrors = {
168
- ...parsedEmail.fieldErrors
169
- };
170
-
171
- if (type !== OTP_VERIFY_TYPE) {
172
- fieldErrors.type = "Only email OTP verification is supported.";
173
- }
174
-
175
- if (!token && !tokenHash) {
176
- fieldErrors.token = "One-time code is required.";
177
- }
178
-
179
- if (token && token.length > AUTH_RECOVERY_TOKEN_MAX_LENGTH) {
180
- fieldErrors.token = "One-time code is too long.";
181
- }
182
-
183
- if (tokenHash && tokenHash.length > AUTH_RECOVERY_TOKEN_MAX_LENGTH) {
184
- fieldErrors.tokenHash = "One-time token hash is too long.";
185
- }
186
-
187
- if (token && parsedEmail.fieldErrors.email) {
188
- fieldErrors.email = parsedEmail.fieldErrors.email;
189
- } else if (tokenHash) {
190
- delete fieldErrors.email;
191
- }
192
-
193
- return {
194
- email: parsedEmail.email,
195
- token,
196
- tokenHash,
197
- type,
198
- fieldErrors
199
- };
200
- }
201
-
202
31
  function mapOAuthCallbackError(errorCode) {
203
32
  const normalizedCode = String(errorCode || "")
204
33
  .trim()
@@ -213,8 +42,5 @@ function mapOAuthCallbackError(errorCode) {
213
42
 
214
43
  export {
215
44
  normalizeOAuthProviderInput,
216
- validatePasswordRecoveryPayload,
217
- parseOAuthCompletePayload,
218
- parseOtpLoginVerifyPayload,
219
45
  mapOAuthCallbackError
220
46
  };
@@ -0,0 +1,78 @@
1
+ import { createSchema } from "json-rest-schema";
2
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
3
+ import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
4
+
5
+ const authenticatedProfileSchema = createSchema({
6
+ id: {
7
+ type: "string",
8
+ required: true,
9
+ minLength: 1,
10
+ pattern: RECORD_ID_PATTERN
11
+ },
12
+ email: {
13
+ type: "string",
14
+ required: true,
15
+ minLength: 1,
16
+ lowercase: true
17
+ },
18
+ username: {
19
+ type: "string",
20
+ required: false,
21
+ lowercase: true
22
+ },
23
+ displayName: {
24
+ type: "string",
25
+ required: true,
26
+ minLength: 1
27
+ },
28
+ authProvider: {
29
+ type: "string",
30
+ required: true,
31
+ minLength: 1,
32
+ lowercase: true
33
+ },
34
+ authProviderUserSid: {
35
+ type: "string",
36
+ required: true,
37
+ minLength: 1
38
+ },
39
+ avatarStorageKey: {
40
+ type: "string",
41
+ required: false,
42
+ nullable: true
43
+ },
44
+ avatarVersion: {
45
+ type: "string",
46
+ required: false,
47
+ nullable: true
48
+ }
49
+ });
50
+
51
+ function requireAuthenticatedProfile(profileLike, { context = "authenticated profile" } = {}) {
52
+ const source = profileLike && typeof profileLike === "object" && !Array.isArray(profileLike) ? profileLike : {};
53
+ const candidate = {
54
+ id: source.id,
55
+ email: source.email,
56
+ displayName: source.displayName,
57
+ authProvider: source.authProvider,
58
+ authProviderUserSid: source.authProviderUserSid
59
+ };
60
+ if (Object.hasOwn(source, "username")) {
61
+ candidate.username = source.username;
62
+ }
63
+ if (Object.hasOwn(source, "avatarStorageKey")) {
64
+ candidate.avatarStorageKey = source.avatarStorageKey;
65
+ }
66
+ if (Object.hasOwn(source, "avatarVersion")) {
67
+ candidate.avatarVersion = source.avatarVersion;
68
+ }
69
+
70
+ const { validatedObject, errors } = authenticatedProfileSchema.create(candidate);
71
+ if (Object.keys(errors).length > 0) {
72
+ throw new AppError(500, `${context} is invalid.`);
73
+ }
74
+
75
+ return validatedObject;
76
+ }
77
+
78
+ export { requireAuthenticatedProfile };
@@ -1,4 +1,6 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { authLoginOAuthStartParamsValidator, authLoginOAuthStartQueryValidator } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthStartCommand";
3
+ import { authLoginOAuthCompleteBodyValidator } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthCompleteCommand";
2
4
 
3
5
  function createOauthFlows(deps) {
4
6
  const {
@@ -13,7 +15,6 @@ function createOauthFlows(deps) {
13
15
  mapAuthError,
14
16
  setSessionFromRequestCookies,
15
17
  buildOAuthLinkRedirectUrl,
16
- parseOAuthCompletePayload,
17
18
  validationError,
18
19
  mapOAuthCallbackError,
19
20
  mapRecoveryError,
@@ -50,8 +51,22 @@ function createOauthFlows(deps) {
50
51
  async function oauthStart(payload = {}) {
51
52
  ensureConfigured();
52
53
 
53
- const provider = normalizeOAuthProviderInput(payload.provider || authOAuthDefaultProvider);
54
- const returnTo = normalizeReturnToPath(payload.returnTo, { fallback: "/" });
54
+ const paramsResult = authLoginOAuthStartParamsValidator.schema.patch({
55
+ provider: payload.provider || authOAuthDefaultProvider
56
+ });
57
+ if (Object.keys(paramsResult.errors).length > 0) {
58
+ throw validationError(paramsResult.errors);
59
+ }
60
+
61
+ const queryResult = authLoginOAuthStartQueryValidator.schema.patch({
62
+ returnTo: payload.returnTo
63
+ });
64
+ if (Object.keys(queryResult.errors).length > 0) {
65
+ throw validationError(queryResult.errors);
66
+ }
67
+
68
+ const provider = normalizeOAuthProviderInput(paramsResult.validatedObject.provider);
69
+ const returnTo = normalizeReturnToPath(queryResult.validatedObject.returnTo, { fallback: "/" });
55
70
  const redirectTo = buildOAuthLoginRedirectUrl({
56
71
  appPublicUrl,
57
72
  provider,
@@ -72,9 +87,23 @@ function createOauthFlows(deps) {
72
87
  async function startProviderLink(request, payload = {}) {
73
88
  ensureConfigured();
74
89
 
90
+ const paramsResult = authLoginOAuthStartParamsValidator.schema.patch({
91
+ provider: payload.provider || authOAuthDefaultProvider
92
+ });
93
+ if (Object.keys(paramsResult.errors).length > 0) {
94
+ throw validationError(paramsResult.errors);
95
+ }
96
+
97
+ const queryResult = authLoginOAuthStartQueryValidator.schema.patch({
98
+ returnTo: payload.returnTo
99
+ });
100
+ if (Object.keys(queryResult.errors).length > 0) {
101
+ throw validationError(queryResult.errors);
102
+ }
103
+
104
+ const provider = normalizeOAuthProviderInput(paramsResult.validatedObject.provider);
105
+ const returnTo = normalizeReturnToPath(queryResult.validatedObject.returnTo, { fallback: "/" });
75
106
  const supabase = getSupabaseClient();
76
- const provider = normalizeOAuthProviderInput(payload.provider || authOAuthDefaultProvider);
77
- const returnTo = normalizeReturnToPath(payload.returnTo, { fallback: "/" });
78
107
  await setSessionFromRequestCookies(request, {
79
108
  supabaseClient: supabase
80
109
  });
@@ -102,25 +131,55 @@ function createOauthFlows(deps) {
102
131
  async function oauthComplete(payload = {}) {
103
132
  ensureConfigured();
104
133
 
105
- const parsed = parseOAuthCompletePayload(payload);
106
- if (Object.keys(parsed.fieldErrors).length > 0) {
107
- throw validationError(parsed.fieldErrors);
134
+ const result = authLoginOAuthCompleteBodyValidator.schema.patch(payload);
135
+ if (Object.keys(result.errors).length > 0) {
136
+ throw validationError(result.errors);
137
+ }
138
+ const parsed = result.validatedObject;
139
+ const code = String(parsed.code || "").trim();
140
+ const accessToken = String(parsed.accessToken || "").trim();
141
+ const refreshToken = String(parsed.refreshToken || "").trim();
142
+ const errorCode = String(parsed.error || parsed.error_code || "").trim();
143
+ const errorDescription = String(parsed.errorDescription || parsed.error_description || "").trim();
144
+ const hasSessionPair = Boolean(accessToken && refreshToken);
145
+ const fieldErrors = {};
146
+
147
+ if ((accessToken && !refreshToken) || (!accessToken && refreshToken)) {
148
+ if (!accessToken) {
149
+ fieldErrors.accessToken = "Access token is required when a refresh token is provided.";
150
+ }
151
+ if (!refreshToken) {
152
+ fieldErrors.refreshToken = "Refresh token is required when an access token is provided.";
153
+ }
108
154
  }
109
155
 
110
- if (parsed.errorCode) {
111
- throw mapOAuthCallbackError(parsed.errorCode, parsed.errorDescription);
156
+ if (!code && !errorCode && !hasSessionPair) {
157
+ fieldErrors.code = "OAuth code is required when access/refresh tokens are not provided.";
158
+ }
159
+
160
+ if (Object.keys(fieldErrors).length > 0) {
161
+ throw validationError(fieldErrors);
162
+ }
163
+
164
+ const provider =
165
+ !hasSessionPair || code || errorCode
166
+ ? normalizeOAuthProviderInput(parsed.provider || authOAuthDefaultProvider)
167
+ : null;
168
+
169
+ if (errorCode) {
170
+ throw mapOAuthCallbackError(errorCode, errorDescription);
112
171
  }
113
172
 
114
173
  const supabase = getSupabaseClient();
115
174
  let response;
116
175
  try {
117
- if (parsed.hasSessionPair) {
176
+ if (hasSessionPair) {
118
177
  response = await supabase.auth.setSession({
119
- access_token: parsed.accessToken,
120
- refresh_token: parsed.refreshToken
178
+ access_token: accessToken,
179
+ refresh_token: refreshToken
121
180
  });
122
181
  } else {
123
- response = await supabase.auth.exchangeCodeForSession(parsed.code);
182
+ response = await supabase.auth.exchangeCodeForSession(code);
124
183
  }
125
184
  } catch (error) {
126
185
  throw mapRecoveryError(error);
@@ -133,7 +192,7 @@ function createOauthFlows(deps) {
133
192
  const profile = await syncProfileFromSupabaseUser(response.data.user, response.data.user.email);
134
193
 
135
194
  return {
136
- provider: parsed.provider,
195
+ provider,
137
196
  profile,
138
197
  session: response.data.session
139
198
  };
@@ -1,20 +1,20 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { authPasswordResetRequestBodyValidator } from "@jskit-ai/auth-core/shared/commands/authPasswordResetRequestCommand";
3
+ import { authPasswordRecoveryCompleteBodyValidator } from "@jskit-ai/auth-core/shared/commands/authPasswordRecoveryCompleteCommand";
4
+ import { authPasswordResetBodyValidator } from "@jskit-ai/auth-core/shared/commands/authPasswordResetCommand";
2
5
  import {
3
6
  requireAuthUser,
4
7
  requireAuthSession,
5
- requireAuthUserSession,
6
- requireNoFieldErrors
8
+ requireAuthUserSession
7
9
  } from "./flowGuards.js";
8
10
 
9
11
  function createPasswordSecurityFlows(deps) {
10
12
  const {
11
13
  ensureConfigured,
12
- validators,
13
14
  validationError,
14
15
  getSupabaseClient,
15
16
  passwordResetRedirectUrl,
16
17
  mapAuthError,
17
- validatePasswordRecoveryPayload,
18
18
  mapRecoveryError,
19
19
  syncProfileFromSupabaseUser,
20
20
  setSessionFromRequestCookies,
@@ -38,8 +38,11 @@ function createPasswordSecurityFlows(deps) {
38
38
  async function requestPasswordReset(payload) {
39
39
  ensureConfigured();
40
40
 
41
- const parsed = validators.forgotPasswordInput(payload);
42
- requireNoFieldErrors(parsed, validationError);
41
+ const result = authPasswordResetRequestBodyValidator.schema.create(payload);
42
+ if (Object.keys(result.errors).length > 0) {
43
+ throw validationError(result.errors);
44
+ }
45
+ const parsed = result.validatedObject;
43
46
 
44
47
  const supabase = getSupabaseClient();
45
48
  const options = { redirectTo: passwordResetRedirectUrl };
@@ -65,23 +68,51 @@ function createPasswordSecurityFlows(deps) {
65
68
  async function completePasswordRecovery(payload) {
66
69
  ensureConfigured();
67
70
 
68
- const parsed = validatePasswordRecoveryPayload(payload);
69
- requireNoFieldErrors(parsed, validationError);
71
+ const result = authPasswordRecoveryCompleteBodyValidator.schema.patch(payload);
72
+ if (Object.keys(result.errors).length > 0) {
73
+ throw validationError(result.errors);
74
+ }
75
+ const parsed = result.validatedObject;
76
+ const code = String(parsed.code || "").trim();
77
+ const tokenHash = String(parsed.tokenHash || "").trim();
78
+ const accessToken = String(parsed.accessToken || "").trim();
79
+ const refreshToken = String(parsed.refreshToken || "").trim();
80
+ const hasCode = Boolean(code);
81
+ const hasTokenHash = Boolean(tokenHash);
82
+ const hasSessionPair = Boolean(accessToken && refreshToken);
83
+ const fieldErrors = {};
84
+
85
+ if ((accessToken && !refreshToken) || (!accessToken && refreshToken)) {
86
+ if (!accessToken) {
87
+ fieldErrors.accessToken = "Access token is required when a refresh token is provided.";
88
+ }
89
+ if (!refreshToken) {
90
+ fieldErrors.refreshToken = "Refresh token is required when an access token is provided.";
91
+ }
92
+ }
93
+
94
+ if (!hasCode && !hasTokenHash && !hasSessionPair) {
95
+ fieldErrors.recovery = "Recovery token is required.";
96
+ }
97
+
98
+ if (Object.keys(fieldErrors).length > 0) {
99
+ throw validationError(fieldErrors);
100
+ }
70
101
 
71
102
  const supabase = getSupabaseClient();
72
103
  let response;
73
104
  try {
74
- if (parsed.hasCode) {
75
- response = await supabase.auth.exchangeCodeForSession(parsed.code);
76
- } else if (parsed.hasTokenHash) {
105
+ if (hasCode) {
106
+ response = await supabase.auth.exchangeCodeForSession(code);
107
+ } else if (hasTokenHash) {
77
108
  response = await supabase.auth.verifyOtp({
78
109
  type: "recovery",
79
- token_hash: parsed.tokenHash
110
+ token_hash: tokenHash
80
111
  });
81
112
  } else {
82
113
  response = await supabase.auth.setSession({
83
- access_token: parsed.accessToken,
84
- refresh_token: parsed.refreshToken
114
+ access_token: accessToken,
115
+ refresh_token: refreshToken
85
116
  });
86
117
  }
87
118
  /* c8 ignore next 3 -- defensive: supabase-js usually surfaces failures via response.error. */
@@ -105,8 +136,11 @@ function createPasswordSecurityFlows(deps) {
105
136
  async function resetPassword(request, payload) {
106
137
  ensureConfigured();
107
138
 
108
- const parsed = validators.resetPasswordInput(payload);
109
- requireNoFieldErrors(parsed, validationError);
139
+ const result = authPasswordResetBodyValidator.schema.create(payload);
140
+ if (Object.keys(result.errors).length > 0) {
141
+ throw validationError(result.errors);
142
+ }
143
+ const parsed = result.validatedObject;
110
144
 
111
145
  const supabase = getSupabaseClient();
112
146
  const sessionResponse = await setSessionFromRequestCookies(request, {
@@ -267,6 +301,10 @@ function createPasswordSecurityFlows(deps) {
267
301
  if (response.error) {
268
302
  throw mapAuthError(response.error, Number(response.error?.status || 400));
269
303
  }
304
+
305
+ return {
306
+ ok: true
307
+ };
270
308
  }
271
309
 
272
310
  async function getSecurityStatus(request) {
@@ -6,8 +6,6 @@ import {
6
6
  buildOAuthMethodId
7
7
  } from "@jskit-ai/auth-core/shared/authMethods";
8
8
  import { normalizeEmail } from "@jskit-ai/auth-core/server/utils";
9
- import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
10
- import { validators } from "@jskit-ai/auth-core/server/validators";
11
9
  import {
12
10
  isTransientAuthMessage,
13
11
  isTransientSupabaseError,
@@ -23,9 +21,6 @@ import {
23
21
  import { displayNameFromEmail, resolveDisplayName, resolveDisplayNameFromClaims } from "./authProfileNames.js";
24
22
  import {
25
23
  normalizeOAuthProviderInput as normalizeOAuthProviderInputFromCatalog,
26
- validatePasswordRecoveryPayload,
27
- parseOAuthCompletePayload as parseOAuthCompletePayloadFromCatalog,
28
- parseOtpLoginVerifyPayload,
29
24
  mapOAuthCallbackError
30
25
  } from "./authInputParsers.js";
31
26
  import {
@@ -62,6 +57,7 @@ import {
62
57
  resolveDevAuthConfig,
63
58
  resolveDevAuthProfile
64
59
  } from "./devAuthBootstrap.js";
60
+ import { requireAuthenticatedProfile } from "./authenticatedProfile.js";
65
61
  import {
66
62
  buildOAuthProviderCatalogResponse,
67
63
  resolveOAuthProviderQueryParams,
@@ -166,13 +162,6 @@ function createService(options) {
166
162
  });
167
163
  }
168
164
 
169
- function parseOAuthCompletePayload(payload) {
170
- return parseOAuthCompletePayloadFromCatalog(payload, {
171
- providerIds: authOAuthProviderIds,
172
- defaultProvider: authOAuthDefaultProvider
173
- });
174
- }
175
-
176
165
  function buildOAuthLoginRedirectUrlWithCatalog(payload) {
177
166
  return buildOAuthLoginRedirectUrl({
178
167
  ...payload,
@@ -408,11 +397,16 @@ function createService(options) {
408
397
  }
409
398
 
410
399
  function requireSynchronizedProfile(profile) {
411
- if (profile && normalizeRecordId(profile.id, { fallback: null }) && String(profile.displayName || "").trim()) {
412
- return profile;
400
+ try {
401
+ return requireAuthenticatedProfile(profile, {
402
+ context: "authentication profile"
403
+ });
404
+ } catch (error) {
405
+ if (error instanceof AppError) {
406
+ throw new AppError(500, "Authentication profile synchronization failed. Please retry.");
407
+ }
408
+ throw error;
413
409
  }
414
-
415
- throw new AppError(500, "Authentication profile synchronization failed. Please retry.");
416
410
  }
417
411
 
418
412
  function buildNormalizedIdentityKey(identityLike) {
@@ -583,7 +577,6 @@ function createService(options) {
583
577
 
584
578
  const { register, resendRegisterConfirmation, login, requestOtpLogin, verifyOtpLogin, updateDisplayName } = createAccountFlows({
585
579
  ensureConfigured,
586
- validators,
587
580
  validationError,
588
581
  getSupabaseClient,
589
582
  displayNameFromEmail,
@@ -595,7 +588,6 @@ function createService(options) {
595
588
  appPublicUrl,
596
589
  isTransientSupabaseError,
597
590
  isUserNotFoundLikeAuthError,
598
- parseOtpLoginVerifyPayload,
599
591
  mapOtpVerifyError,
600
592
  setSessionFromRequestCookies,
601
593
  mapProfileUpdateError,
@@ -614,7 +606,6 @@ function createService(options) {
614
606
  mapAuthError,
615
607
  setSessionFromRequestCookies,
616
608
  buildOAuthLinkRedirectUrl: buildOAuthLinkRedirectUrlWithCatalog,
617
- parseOAuthCompletePayload,
618
609
  validationError,
619
610
  mapOAuthCallbackError,
620
611
  mapRecoveryError,
@@ -636,12 +627,10 @@ function createService(options) {
636
627
  getSecurityStatus
637
628
  } = createPasswordSecurityFlows({
638
629
  ensureConfigured,
639
- validators,
640
630
  validationError,
641
631
  getSupabaseClient,
642
632
  passwordResetRedirectUrl,
643
633
  mapAuthError,
644
- validatePasswordRecoveryPayload,
645
634
  mapRecoveryError,
646
635
  syncProfileFromSupabaseUser,
647
636
  setSessionFromRequestCookies,
@@ -687,6 +676,14 @@ function createService(options) {
687
676
  }
688
677
  );
689
678
  if (devAuthResult) {
679
+ if (devAuthResult.authenticated === true) {
680
+ return {
681
+ ...devAuthResult,
682
+ profile: requireAuthenticatedProfile(devAuthResult.profile, {
683
+ context: "dev auth profile"
684
+ })
685
+ };
686
+ }
690
687
  return devAuthResult;
691
688
  }
692
689
 
@@ -837,10 +834,13 @@ function createService(options) {
837
834
 
838
835
  async function devLoginAs(request, input = {}) {
839
836
  ensureDevAuthBootstrapAvailable(devAuthConfig, request);
840
- const profile = await resolveDevAuthProfile(input, {
837
+ const rawProfile = await resolveDevAuthProfile(input, {
841
838
  userProfilesRepository,
842
839
  validationError
843
840
  });
841
+ const profile = requireAuthenticatedProfile(rawProfile, {
842
+ context: "dev auth profile"
843
+ });
844
844
  return {
845
845
  profile,
846
846
  session: await createDevAuthSession(profile, devAuthConfig)
@@ -878,7 +878,6 @@ function createService(options) {
878
878
  }
879
879
 
880
880
  const __testables = {
881
- validatePasswordRecoveryPayload,
882
881
  displayNameFromEmail,
883
882
  resolveDisplayName,
884
883
  resolveDisplayNameFromClaims,
@@ -901,8 +900,6 @@ const __testables = {
901
900
  buildOAuthLoginRedirectUrl,
902
901
  buildOAuthLinkRedirectUrl,
903
902
  normalizeOAuthProviderInput: normalizeOAuthProviderInputFromCatalog,
904
- parseOAuthCompletePayload: parseOAuthCompletePayloadFromCatalog,
905
- parseOtpLoginVerifyPayload,
906
903
  mapOAuthCallbackError,
907
904
  resolveSupabaseOAuthProviderCatalog,
908
905
  resolveOAuthProviderQueryParams,
@@ -26,10 +26,7 @@ export {
26
26
 
27
27
  export {
28
28
  normalizeOAuthProviderInput,
29
- parseOAuthCompletePayload,
30
- parseOtpLoginVerifyPayload,
31
- mapOAuthCallbackError,
32
- validatePasswordRecoveryPayload
29
+ mapOAuthCallbackError
33
30
  } from "./authInputParsers.js";
34
31
 
35
32
  export {
@@ -1,36 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { parseOAuthCompletePayload } from "../src/server/lib/authInputParsers.js";
3
+ import { normalizeOAuthProviderInput } from "../src/server/lib/authInputParsers.js";
4
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
- );
5
+ test("normalizeOAuthProviderInput uses the configured default provider", () => {
6
+ const provider = normalizeOAuthProviderInput("", {
7
+ providerIds: ["github", "google"],
8
+ defaultProvider: "github"
9
+ });
16
10
 
17
- assert.equal(parsed.hasSessionPair, true);
18
- assert.equal(parsed.provider, null);
19
- assert.deepEqual(parsed.fieldErrors, {});
11
+ assert.equal(provider, "github");
20
12
  });
21
13
 
22
- test("parseOAuthCompletePayload still requires OAuth provider for code exchanges", () => {
14
+ test("normalizeOAuthProviderInput rejects oauth sign-in when no providers are configured", () => {
23
15
  assert.throws(
24
16
  () =>
25
- parseOAuthCompletePayload(
26
- {
27
- code: "oauth-code"
28
- },
29
- {
30
- providerIds: [],
31
- defaultProvider: null
32
- }
33
- ),
17
+ normalizeOAuthProviderInput("github", {
18
+ providerIds: [],
19
+ defaultProvider: null
20
+ }),
34
21
  (error) => {
35
22
  assert.equal(error?.status, 400);
36
23
  assert.equal(String(error?.details?.fieldErrors?.provider || "").length > 0, true);
@@ -120,6 +120,38 @@ test("dev auth bootstrap supports email lookup", async () => {
120
120
  assert.equal(result.profile.email, "ada@example.com");
121
121
  });
122
122
 
123
+ test("dev auth bootstrap canonicalizes producer profiles before returning them", async () => {
124
+ const rawProfile = createProfile({
125
+ email: " ADA@EXAMPLE.COM ",
126
+ username: " Ada ",
127
+ displayName: " Ada Example ",
128
+ authProvider: " SUPABASE ",
129
+ authProviderUserSid: " supabase-user-7 ",
130
+ avatarStorageKey: " avatars/7.png ",
131
+ avatarVersion: 7,
132
+ avatarUpdatedAt: "2026-01-01T00:00:00.000Z",
133
+ createdAt: "2026-01-01T00:00:00.000Z"
134
+ });
135
+ const authService = createServiceFixture({
136
+ userProfilesRepository: createUserProfilesRepository(rawProfile)
137
+ });
138
+
139
+ const loginResult = await authService.devLoginAs(createLocalRequest(), {
140
+ userId: "7"
141
+ });
142
+
143
+ assert.deepEqual(loginResult.profile, {
144
+ id: "7",
145
+ email: "ada@example.com",
146
+ username: "ada",
147
+ displayName: "Ada Example",
148
+ authProvider: "supabase",
149
+ authProviderUserSid: "supabase-user-7",
150
+ avatarStorageKey: "avatars/7.png",
151
+ avatarVersion: "7"
152
+ });
153
+ });
154
+
123
155
  test("dev auth bootstrap rejects non-local requests and clears leaked dev sessions", async () => {
124
156
  const authService = createServiceFixture();
125
157
  const issued = await authService.devLoginAs(createLocalRequest(), {