@jskit-ai/auth-provider-supabase-core 0.1.45 → 0.1.47

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.45",
4
+ "version": "0.1.47",
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.45",
87
- "@jskit-ai/kernel": "0.1.46",
86
+ "@jskit-ai/auth-core": "0.1.47",
87
+ "@jskit-ai/kernel": "0.1.48",
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.45",
3
+ "version": "0.1.47",
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.45",
16
- "@jskit-ai/kernel": "0.1.46",
15
+ "@jskit-ai/auth-core": "0.1.47",
16
+ "@jskit-ai/kernel": "0.1.48",
17
17
  "jose": "^6.1.0",
18
18
  "@supabase/supabase-js": "^2.57.4"
19
19
  }
@@ -9,6 +9,7 @@ import {
9
9
  authLoginOtpVerifyCommand,
10
10
  authLoginOAuthStartCommand,
11
11
  authLoginOAuthCompleteCommand,
12
+ authDevLoginAsCommand,
12
13
  authPasswordResetRequestCommand,
13
14
  authPasswordRecoveryCompleteCommand,
14
15
  authPasswordResetCommand
@@ -23,7 +24,24 @@ function requireRequestContext(context, actionId) {
23
24
  throw new Error(`${actionId} requires request context.`);
24
25
  }
25
26
 
26
- const authActions = Object.freeze([
27
+ const devLoginAsAction = Object.freeze({
28
+ id: "auth.dev.loginAs",
29
+ version: 1,
30
+ kind: "command",
31
+ channels: ["api", "internal"],
32
+ surfacesFrom: "enabled",
33
+ inputValidator: authDevLoginAsCommand.operation.bodyValidator,
34
+ idempotency: "none",
35
+ audit: {
36
+ actionName: "auth.dev.loginAs"
37
+ },
38
+ observability: {},
39
+ async execute(input, context, deps) {
40
+ return deps.authService.devLoginAs(requireRequestContext(context, "auth.dev.loginAs"), input);
41
+ }
42
+ });
43
+
44
+ const authActionsBeforeDevLogin = Object.freeze([
27
45
  {
28
46
  id: "auth.register",
29
47
  version: 1,
@@ -135,7 +153,10 @@ const authActions = Object.freeze([
135
153
  async execute(input, _context, deps) {
136
154
  return deps.authService.oauthComplete(input);
137
155
  }
138
- },
156
+ }
157
+ ]);
158
+
159
+ const authActionsAfterDevLogin = Object.freeze([
139
160
  {
140
161
  id: "auth.password.reset.request",
141
162
  version: 1,
@@ -241,4 +262,21 @@ const authActions = Object.freeze([
241
262
  }
242
263
  ]);
243
264
 
244
- export { authActions };
265
+ const baseAuthActions = Object.freeze([
266
+ ...authActionsBeforeDevLogin,
267
+ ...authActionsAfterDevLogin
268
+ ]);
269
+
270
+ function buildAuthActions({ includeDevLoginAs = false } = {}) {
271
+ if (!includeDevLoginAs) {
272
+ return baseAuthActions;
273
+ }
274
+
275
+ return Object.freeze([
276
+ ...authActionsBeforeDevLogin,
277
+ devLoginAsAction,
278
+ ...authActionsAfterDevLogin
279
+ ]);
280
+ }
281
+
282
+ export { baseAuthActions, buildAuthActions, devLoginAsAction };
@@ -0,0 +1,398 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { normalizeEmail } from "@jskit-ai/auth-core/server/utils";
4
+ import { loadJose, isExpiredJwtError } from "./authJwt.js";
5
+
6
+ const DEV_AUTH_TOKEN_PREFIX = "jskit-dev.";
7
+ const DEV_AUTH_ISSUER = "jskit:dev-auth";
8
+ const DEFAULT_DEV_AUTH_ACCESS_TTL_SECONDS = 60 * 60 * 12;
9
+ const DEFAULT_DEV_AUTH_REFRESH_TTL_SECONDS = 60 * 60 * 24 * 30;
10
+ const encoder = new TextEncoder();
11
+
12
+ function parseBoolean(value, fallback = false) {
13
+ const raw = String(value || "").trim().toLowerCase();
14
+ if (!raw) {
15
+ return fallback;
16
+ }
17
+ if (["1", "true", "yes", "on"].includes(raw)) {
18
+ return true;
19
+ }
20
+ if (["0", "false", "no", "off"].includes(raw)) {
21
+ return false;
22
+ }
23
+ return fallback;
24
+ }
25
+
26
+ function normalizePositiveInteger(value, fallback) {
27
+ const parsed = Number.parseInt(String(value || "").trim(), 10);
28
+ if (Number.isInteger(parsed) && parsed > 0) {
29
+ return parsed;
30
+ }
31
+ return fallback;
32
+ }
33
+
34
+ function normalizeRequestHostname(request) {
35
+ const hostHeader = String(request?.headers?.host || "").trim();
36
+ if (!hostHeader) {
37
+ return "";
38
+ }
39
+
40
+ const firstHost = hostHeader.split(",")[0]?.trim();
41
+ if (!firstHost) {
42
+ return "";
43
+ }
44
+
45
+ try {
46
+ return new URL(`http://${firstHost}`).hostname.trim().toLowerCase();
47
+ } catch {
48
+ return firstHost
49
+ .replace(/^\[/, "")
50
+ .replace(/\]$/, "")
51
+ .split(":")[0]
52
+ .trim()
53
+ .toLowerCase();
54
+ }
55
+ }
56
+
57
+ function resolveDirectRemoteAddress(request) {
58
+ return String(request?.socket?.remoteAddress || request?.raw?.socket?.remoteAddress || "").trim();
59
+ }
60
+
61
+ function normalizeLoopbackIp(value) {
62
+ return String(value || "")
63
+ .trim()
64
+ .toLowerCase()
65
+ .replace(/^\[|\]$/g, "")
66
+ .replace(/^::ffff:/, "");
67
+ }
68
+
69
+ function isLoopbackIp(value) {
70
+ const normalized = normalizeLoopbackIp(value);
71
+ return normalized === "::1" || normalized === "127.0.0.1" || normalized.startsWith("127.");
72
+ }
73
+
74
+ function isLoopbackHostname(value) {
75
+ const normalized = String(value || "")
76
+ .trim()
77
+ .toLowerCase()
78
+ .replace(/^\[|\]$/g, "");
79
+ return (
80
+ normalized === "localhost" ||
81
+ normalized.endsWith(".localhost") ||
82
+ normalized === "::1" ||
83
+ normalized === "127.0.0.1"
84
+ );
85
+ }
86
+
87
+ function isLocalDevAuthRequest(request) {
88
+ return (
89
+ isLoopbackIp(resolveDirectRemoteAddress(request)) &&
90
+ isLoopbackHostname(normalizeRequestHostname(request))
91
+ );
92
+ }
93
+
94
+ function isDevAuthToken(token) {
95
+ return String(token || "").trim().startsWith(DEV_AUTH_TOKEN_PREFIX);
96
+ }
97
+
98
+ function stripDevAuthTokenPrefix(token) {
99
+ return String(token || "").trim().slice(DEV_AUTH_TOKEN_PREFIX.length);
100
+ }
101
+
102
+ function resolveDevAuthConfig({
103
+ enabled = false,
104
+ secret = "",
105
+ nodeEnv = "development",
106
+ jwtAudience = "authenticated",
107
+ accessTtlSeconds = DEFAULT_DEV_AUTH_ACCESS_TTL_SECONDS,
108
+ refreshTtlSeconds = DEFAULT_DEV_AUTH_REFRESH_TTL_SECONDS
109
+ } = {}) {
110
+ return Object.freeze({
111
+ enabled: parseBoolean(enabled, false),
112
+ secret: String(secret || "").trim(),
113
+ isProduction: String(nodeEnv || "development").trim().toLowerCase() === "production",
114
+ jwtAudience: String(jwtAudience || "authenticated").trim() || "authenticated",
115
+ accessTtlSeconds: normalizePositiveInteger(accessTtlSeconds, DEFAULT_DEV_AUTH_ACCESS_TTL_SECONDS),
116
+ refreshTtlSeconds: normalizePositiveInteger(refreshTtlSeconds, DEFAULT_DEV_AUTH_REFRESH_TTL_SECONDS)
117
+ });
118
+ }
119
+
120
+ function assertDevAuthBootstrapConfig(config, { userProfilesRepository = null } = {}) {
121
+ if (!config?.enabled) {
122
+ return;
123
+ }
124
+
125
+ if (config.isProduction) {
126
+ throw new Error("AUTH_DEV_BYPASS_ENABLED must not be enabled in production.");
127
+ }
128
+ if (!config.secret) {
129
+ throw new Error("AUTH_DEV_BYPASS_SECRET is required when AUTH_DEV_BYPASS_ENABLED=true.");
130
+ }
131
+ if (
132
+ !userProfilesRepository ||
133
+ typeof userProfilesRepository.findById !== "function" ||
134
+ typeof userProfilesRepository.findByEmail !== "function"
135
+ ) {
136
+ throw new Error(
137
+ "Dev auth bootstrap requires internal.repository.user-profiles with findById() and findByEmail() when AUTH_DEV_BYPASS_ENABLED=true."
138
+ );
139
+ }
140
+ }
141
+
142
+ function ensureDevAuthBootstrapAvailable(config, request) {
143
+ if (!config?.enabled || config?.isProduction) {
144
+ throw new AppError(404, "Not found.");
145
+ }
146
+ if (!config.secret) {
147
+ throw new AppError(500, "AUTH_DEV_BYPASS_SECRET is required when AUTH_DEV_BYPASS_ENABLED=true.");
148
+ }
149
+ if (!isLocalDevAuthRequest(request)) {
150
+ throw new AppError(403, "Dev auth bootstrap is only available from localhost.");
151
+ }
152
+ }
153
+
154
+ function buildProfileFromTokenClaims(payload) {
155
+ const id = normalizeRecordId(payload?.sub, { fallback: null });
156
+ const email = normalizeEmail(payload?.email || "");
157
+ if (!id || !email) {
158
+ return null;
159
+ }
160
+
161
+ const displayName = String(payload?.displayName || "").trim();
162
+ const username = String(payload?.username || "")
163
+ .trim()
164
+ .toLowerCase();
165
+ const authProvider = String(payload?.authProvider || "dev")
166
+ .trim()
167
+ .toLowerCase();
168
+ const authProviderUserSid = String(payload?.authProviderUserSid || payload?.sub || "").trim();
169
+
170
+ return {
171
+ id,
172
+ email,
173
+ username,
174
+ displayName: displayName || email || `User ${id}`,
175
+ authProvider,
176
+ authProviderUserSid,
177
+ avatarStorageKey: null,
178
+ avatarVersion: null
179
+ };
180
+ }
181
+
182
+ async function resolveProfileFromTokenClaims(payload, { userProfilesRepository = null } = {}) {
183
+ const userId = normalizeRecordId(payload?.sub, { fallback: null });
184
+ if (userId && userProfilesRepository && typeof userProfilesRepository.findById === "function") {
185
+ const latest = await userProfilesRepository.findById(userId);
186
+ if (latest?.id) {
187
+ return latest;
188
+ }
189
+ }
190
+
191
+ const profile = buildProfileFromTokenClaims(payload);
192
+ if (profile) {
193
+ return profile;
194
+ }
195
+
196
+ throw new AppError(401, "Dev session is invalid.");
197
+ }
198
+
199
+ async function signDevAuthToken(kind, profile, config) {
200
+ const jose = await loadJose();
201
+ const nowSeconds = Math.floor(Date.now() / 1000);
202
+ const ttlSeconds = kind === "refresh" ? config.refreshTtlSeconds : config.accessTtlSeconds;
203
+ const token = await new jose.SignJWT({
204
+ kind,
205
+ email: String(profile?.email || "").trim().toLowerCase(),
206
+ displayName: String(profile?.displayName || "").trim(),
207
+ username: String(profile?.username || "")
208
+ .trim()
209
+ .toLowerCase(),
210
+ authProvider: String(profile?.authProvider || "dev")
211
+ .trim()
212
+ .toLowerCase(),
213
+ authProviderUserSid: String(profile?.authProviderUserSid || profile?.id || "").trim()
214
+ })
215
+ .setProtectedHeader({ alg: "HS256", typ: "JWT" })
216
+ .setIssuer(DEV_AUTH_ISSUER)
217
+ .setAudience(config.jwtAudience)
218
+ .setSubject(String(profile?.id || "").trim())
219
+ .setIssuedAt(nowSeconds)
220
+ .setExpirationTime(nowSeconds + ttlSeconds)
221
+ .sign(encoder.encode(config.secret));
222
+
223
+ return `${DEV_AUTH_TOKEN_PREFIX}${token}`;
224
+ }
225
+
226
+ async function verifyDevAuthToken(token, kind, config) {
227
+ if (!isDevAuthToken(token) || !config?.secret) {
228
+ return {
229
+ status: "invalid",
230
+ payload: null
231
+ };
232
+ }
233
+
234
+ const jose = await loadJose();
235
+ try {
236
+ const { payload } = await jose.jwtVerify(stripDevAuthTokenPrefix(token), encoder.encode(config.secret), {
237
+ issuer: DEV_AUTH_ISSUER,
238
+ audience: config.jwtAudience
239
+ });
240
+
241
+ if (String(payload?.kind || "").trim() !== kind) {
242
+ return {
243
+ status: "invalid",
244
+ payload: null
245
+ };
246
+ }
247
+
248
+ return {
249
+ status: "valid",
250
+ payload
251
+ };
252
+ } catch (error) {
253
+ if (isExpiredJwtError(error)) {
254
+ return {
255
+ status: "expired",
256
+ payload: null
257
+ };
258
+ }
259
+
260
+ return {
261
+ status: "invalid",
262
+ payload: null
263
+ };
264
+ }
265
+ }
266
+
267
+ async function createDevAuthSession(profile, config) {
268
+ return {
269
+ access_token: await signDevAuthToken("access", profile, config),
270
+ refresh_token: await signDevAuthToken("refresh", profile, config),
271
+ expires_in: config.accessTtlSeconds,
272
+ token_type: "bearer"
273
+ };
274
+ }
275
+
276
+ async function resolveDevAuthProfile(input = {}, { userProfilesRepository = null, validationError } = {}) {
277
+ if (!userProfilesRepository || typeof userProfilesRepository.findById !== "function") {
278
+ throw new AppError(500, "Dev auth bootstrap requires internal.repository.user-profiles.findById().");
279
+ }
280
+
281
+ const normalizedUserId = normalizeRecordId(input?.userId, { fallback: null });
282
+ const normalizedEmail = normalizeEmail(input?.email || "");
283
+ if (!normalizedUserId && !normalizedEmail) {
284
+ throw validationError({
285
+ userId: "Provide a user id or email.",
286
+ email: "Provide a user id or email."
287
+ });
288
+ }
289
+
290
+ const fieldErrors = {};
291
+
292
+ if (normalizedUserId) {
293
+ const byId = await userProfilesRepository.findById(normalizedUserId);
294
+ if (byId?.id) {
295
+ return byId;
296
+ }
297
+ fieldErrors.userId = "User not found.";
298
+ }
299
+
300
+ if (normalizedEmail) {
301
+ if (typeof userProfilesRepository.findByEmail !== "function") {
302
+ throw new AppError(500, "Dev auth bootstrap requires internal.repository.user-profiles.findByEmail() for email lookup.");
303
+ }
304
+
305
+ const byEmail = await userProfilesRepository.findByEmail(normalizedEmail);
306
+ if (byEmail?.id) {
307
+ return byEmail;
308
+ }
309
+ fieldErrors.email = "User not found.";
310
+ }
311
+
312
+ throw validationError(fieldErrors);
313
+ }
314
+
315
+ async function authenticateDevAuthRequest(
316
+ { request, accessToken = "", refreshToken = "" },
317
+ { config, userProfilesRepository = null } = {}
318
+ ) {
319
+ const hasDevAccessToken = isDevAuthToken(accessToken);
320
+ const hasDevRefreshToken = isDevAuthToken(refreshToken);
321
+ if (!hasDevAccessToken && !hasDevRefreshToken) {
322
+ return null;
323
+ }
324
+
325
+ if (!config?.enabled || config?.isProduction || !config?.secret || !isLocalDevAuthRequest(request)) {
326
+ return {
327
+ authenticated: false,
328
+ clearSession: true,
329
+ session: null,
330
+ transientFailure: false
331
+ };
332
+ }
333
+
334
+ if (hasDevAccessToken) {
335
+ const accessVerification = await verifyDevAuthToken(accessToken, "access", config);
336
+ if (accessVerification.status === "valid") {
337
+ const profile = await resolveProfileFromTokenClaims(accessVerification.payload, {
338
+ userProfilesRepository
339
+ });
340
+ return {
341
+ authenticated: true,
342
+ profile,
343
+ clearSession: false,
344
+ session: null,
345
+ transientFailure: false
346
+ };
347
+ }
348
+
349
+ if (accessVerification.status === "invalid" && !hasDevRefreshToken) {
350
+ return {
351
+ authenticated: false,
352
+ clearSession: true,
353
+ session: null,
354
+ transientFailure: false
355
+ };
356
+ }
357
+ }
358
+
359
+ if (!hasDevRefreshToken) {
360
+ return {
361
+ authenticated: false,
362
+ clearSession: true,
363
+ session: null,
364
+ transientFailure: false
365
+ };
366
+ }
367
+
368
+ const refreshVerification = await verifyDevAuthToken(refreshToken, "refresh", config);
369
+ if (refreshVerification.status !== "valid") {
370
+ return {
371
+ authenticated: false,
372
+ clearSession: true,
373
+ session: null,
374
+ transientFailure: false
375
+ };
376
+ }
377
+
378
+ const profile = await resolveProfileFromTokenClaims(refreshVerification.payload, {
379
+ userProfilesRepository
380
+ });
381
+ return {
382
+ authenticated: true,
383
+ profile,
384
+ clearSession: false,
385
+ session: await createDevAuthSession(profile, config),
386
+ transientFailure: false
387
+ };
388
+ }
389
+
390
+ export {
391
+ assertDevAuthBootstrapConfig,
392
+ authenticateDevAuthRequest,
393
+ createDevAuthSession,
394
+ ensureDevAuthBootstrapAvailable,
395
+ isDevAuthToken,
396
+ resolveDevAuthConfig,
397
+ resolveDevAuthProfile
398
+ };
@@ -1,2 +1,2 @@
1
1
  export { createService, __testables } from "./service.js";
2
- export { authActions } from "./actions/auth.contributor.js";
2
+ export { baseAuthActions, buildAuthActions, devLoginAsAction } from "./actions/auth.contributor.js";
@@ -54,6 +54,14 @@ import { createAccountFlows } from "./accountFlows.js";
54
54
  import { createOauthFlows } from "./oauthFlows.js";
55
55
  import { createPasswordSecurityFlows } from "./passwordSecurityFlows.js";
56
56
  import { USER_PROFILE_EMAIL_CONFLICT_CODE } from "./standaloneProfileSyncService.js";
57
+ import {
58
+ assertDevAuthBootstrapConfig,
59
+ authenticateDevAuthRequest,
60
+ createDevAuthSession,
61
+ ensureDevAuthBootstrapAvailable,
62
+ resolveDevAuthConfig,
63
+ resolveDevAuthProfile
64
+ } from "./devAuthBootstrap.js";
57
65
  import {
58
66
  buildOAuthProviderCatalogResponse,
59
67
  resolveOAuthProviderQueryParams,
@@ -103,6 +111,7 @@ function createService(options) {
103
111
  const supabaseUrl = String(authProvider.supabaseUrl || "").trim();
104
112
  const supabasePublishableKey = String(authProvider.supabasePublishableKey || "").trim();
105
113
  const userSettingsRepository = options.userSettingsRepository || null;
114
+ const userProfilesRepository = options.userProfilesRepository || null;
106
115
  const userProfileSyncService = options.userProfileSyncService;
107
116
  if (
108
117
  !userProfileSyncService ||
@@ -113,6 +122,17 @@ function createService(options) {
113
122
  }
114
123
  const isProduction = options.nodeEnv === "production";
115
124
  const jwtAudience = String(authProvider.jwtAudience || DEFAULT_AUDIENCE).trim();
125
+ const devAuthConfig = resolveDevAuthConfig({
126
+ enabled: options.devAuthBypassEnabled,
127
+ secret: options.devAuthBypassSecret,
128
+ nodeEnv: options.nodeEnv,
129
+ jwtAudience,
130
+ accessTtlSeconds: options.devAuthAccessTtlSeconds,
131
+ refreshTtlSeconds: options.devAuthRefreshTtlSeconds
132
+ });
133
+ assertDevAuthBootstrapConfig(devAuthConfig, {
134
+ userProfilesRepository
135
+ });
116
136
  const settingsProfileAuthInfo = Object.freeze({
117
137
  emailManagedBy: normalizeAuthProviderId(authProvider.emailManagedBy || authProviderId, { fallback: authProviderId }),
118
138
  emailChangeFlow: normalizeAuthProviderId(authProvider.emailChangeFlow || authProviderId, { fallback: authProviderId })
@@ -651,12 +671,25 @@ function createService(options) {
651
671
  });
652
672
 
653
673
  async function authenticateRequest(request) {
654
- ensureConfigured();
655
-
656
674
  const cookies = safeRequestCookies(request);
657
675
  const accessToken = String(cookies[ACCESS_TOKEN_COOKIE] || "");
658
676
  const refreshToken = String(cookies[REFRESH_TOKEN_COOKIE] || "");
659
677
 
678
+ const devAuthResult = await authenticateDevAuthRequest(
679
+ {
680
+ request,
681
+ accessToken,
682
+ refreshToken
683
+ },
684
+ {
685
+ config: devAuthConfig,
686
+ userProfilesRepository
687
+ }
688
+ );
689
+ if (devAuthResult) {
690
+ return devAuthResult;
691
+ }
692
+
660
693
  if (!accessToken && !refreshToken) {
661
694
  return {
662
695
  authenticated: false,
@@ -666,6 +699,8 @@ function createService(options) {
666
699
  };
667
700
  }
668
701
 
702
+ ensureConfigured();
703
+
669
704
  if (accessToken) {
670
705
  const verification = await verifyAccessToken(accessToken);
671
706
 
@@ -796,6 +831,22 @@ function createService(options) {
796
831
  return authOAuthCatalogResponse;
797
832
  }
798
833
 
834
+ function isDevAuthBootstrapEnabled() {
835
+ return devAuthConfig.enabled === true;
836
+ }
837
+
838
+ async function devLoginAs(request, input = {}) {
839
+ ensureDevAuthBootstrapAvailable(devAuthConfig, request);
840
+ const profile = await resolveDevAuthProfile(input, {
841
+ userProfilesRepository,
842
+ validationError
843
+ });
844
+ return {
845
+ profile,
846
+ session: await createDevAuthSession(profile, devAuthConfig)
847
+ };
848
+ }
849
+
799
850
  return {
800
851
  register,
801
852
  resendRegisterConfirmation,
@@ -816,6 +867,8 @@ function createService(options) {
816
867
  getSecurityStatus,
817
868
  getSettingsProfileAuthInfo,
818
869
  getOAuthProviderCatalog,
870
+ isDevAuthBootstrapEnabled,
871
+ devLoginAs,
819
872
  authenticateRequest,
820
873
  hasAccessTokenCookie,
821
874
  hasSessionCookie,
@@ -4,7 +4,7 @@ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import { createService } from "../lib/service.js";
5
5
  import { createStandaloneProfileSyncService } from "../lib/standaloneProfileSyncService.js";
6
6
  import { createAuthSessionEventsService } from "../lib/authSessionEventsService.js";
7
- import { authActions } from "../lib/actions/auth.contributor.js";
7
+ import { buildAuthActions } from "../lib/actions/auth.contributor.js";
8
8
  const AUTH_PROFILE_MODE_STANDALONE = "standalone";
9
9
  const AUTH_PROFILE_MODE_USERS = "users";
10
10
  const SUPPORTED_AUTH_PROFILE_MODES = Object.freeze([AUTH_PROFILE_MODE_STANDALONE, AUTH_PROFILE_MODE_USERS]);
@@ -16,6 +16,20 @@ function splitCsv(value) {
16
16
  .filter(Boolean);
17
17
  }
18
18
 
19
+ function parseBoolean(value, fallback = false) {
20
+ const raw = String(value || "").trim().toLowerCase();
21
+ if (!raw) {
22
+ return fallback;
23
+ }
24
+ if (["1", "true", "yes", "on"].includes(raw)) {
25
+ return true;
26
+ }
27
+ if (["0", "false", "no", "off"].includes(raw)) {
28
+ return false;
29
+ }
30
+ return fallback;
31
+ }
32
+
19
33
  function normalizeRecord(value) {
20
34
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
21
35
  }
@@ -88,6 +102,18 @@ function resolveAuthProfileMode(env) {
88
102
  );
89
103
  }
90
104
 
105
+ function isDevAuthBypassEnabledForRegistration(env) {
106
+ if (!isDevAuthBypassRequested(env)) {
107
+ return false;
108
+ }
109
+
110
+ return String(env?.NODE_ENV || "development").trim().toLowerCase() !== "production";
111
+ }
112
+
113
+ function isDevAuthBypassRequested(env) {
114
+ return parseBoolean(env?.AUTH_DEV_BYPASS_ENABLED, false);
115
+ }
116
+
91
117
  function createInMemoryUserSettingsRepository() {
92
118
  const settingsByUserId = new Map();
93
119
 
@@ -137,10 +163,24 @@ function resolveCommonDependencies(scope) {
137
163
  return dependencies;
138
164
  }
139
165
 
166
+ function resolveRuntimeEnv(scope) {
167
+ const dependencies = resolveCommonDependencies(scope);
168
+ const envFromDependencies =
169
+ dependencies?.env && typeof dependencies.env === "object" ? dependencies.env : {};
170
+
171
+ return {
172
+ ...process.env,
173
+ ...envFromDependencies
174
+ };
175
+ }
176
+
140
177
  function resolveOptionalRepositories(scope) {
141
178
  const repositories = {};
142
- if (scope.has("userSettingsRepository")) {
143
- repositories.userSettingsRepository = scope.make("userSettingsRepository");
179
+ if (scope.has("internal.repository.user-settings")) {
180
+ repositories.userSettingsRepository = scope.make("internal.repository.user-settings");
181
+ }
182
+ if (scope.has("internal.repository.user-profiles")) {
183
+ repositories.userProfilesRepository = scope.make("internal.repository.user-profiles");
144
184
  }
145
185
  return repositories;
146
186
  }
@@ -163,19 +203,16 @@ class AuthSupabaseServiceProvider {
163
203
 
164
204
  if (!app.has("authService")) {
165
205
  app.singleton("authService", (scope) => {
166
- const dependencies = resolveCommonDependencies(scope);
167
- const envFromDependencies =
168
- dependencies?.env && typeof dependencies.env === "object" ? dependencies.env : {};
169
- const env = {
170
- ...process.env,
171
- ...envFromDependencies
172
- };
206
+ const env = resolveRuntimeEnv(scope);
173
207
  const appConfig = scope.has("appConfig") ? scope.make("appConfig") : {};
174
208
  const authProvider = resolveAuthProviderConfig(env, appConfig);
175
209
  const repositories = resolveOptionalRepositories(scope);
176
210
  const userSettingsRepository = repositories.userSettingsRepository || fallbackUserSettingsRepository;
211
+ const devAuthBypassEnabled = parseBoolean(env.AUTH_DEV_BYPASS_ENABLED, false);
177
212
  if (!authProvider.supabaseUrl || !authProvider.supabasePublishableKey) {
178
- return null;
213
+ if (!devAuthBypassEnabled) {
214
+ return null;
215
+ }
179
216
  }
180
217
  const authProfileMode = resolveAuthProfileMode(env);
181
218
  let userProfileSyncService = fallbackStandaloneProfileSyncService;
@@ -197,7 +234,12 @@ class AuthSupabaseServiceProvider {
197
234
  }),
198
235
  nodeEnv: String(env.NODE_ENV || "development").trim() || "development",
199
236
  userSettingsRepository,
200
- userProfileSyncService
237
+ userProfileSyncService,
238
+ userProfilesRepository: repositories.userProfilesRepository || null,
239
+ devAuthBypassEnabled,
240
+ devAuthBypassSecret: String(env.AUTH_DEV_BYPASS_SECRET || "").trim(),
241
+ devAuthAccessTtlSeconds: env.AUTH_DEV_ACCESS_TTL_SECONDS,
242
+ devAuthRefreshTtlSeconds: env.AUTH_DEV_REFRESH_TTL_SECONDS
201
243
  });
202
244
  });
203
245
  }
@@ -236,7 +278,9 @@ class AuthSupabaseServiceProvider {
236
278
  );
237
279
 
238
280
  app.actions(
239
- withActionDefaults(authActions, {
281
+ withActionDefaults(buildAuthActions({
282
+ includeDevLoginAs: isDevAuthBypassEnabledForRegistration(resolveRuntimeEnv(app))
283
+ }), {
240
284
  domain: "auth",
241
285
  dependencies: {
242
286
  authService: "authService",
@@ -245,6 +289,18 @@ class AuthSupabaseServiceProvider {
245
289
  })
246
290
  );
247
291
  }
292
+
293
+ boot(app) {
294
+ if (!app || typeof app.make !== "function") {
295
+ throw new Error("AuthSupabaseServiceProvider requires application make().");
296
+ }
297
+
298
+ if (!isDevAuthBypassRequested(resolveRuntimeEnv(app))) {
299
+ return;
300
+ }
301
+
302
+ app.make("authService");
303
+ }
248
304
  }
249
305
 
250
306
  export { AuthSupabaseServiceProvider };
@@ -0,0 +1,182 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createService } from "../src/server/lib/index.js";
4
+
5
+ function createProfile(overrides = {}) {
6
+ return {
7
+ id: "7",
8
+ email: "ada@example.com",
9
+ username: "ada",
10
+ displayName: "Ada Example",
11
+ authProvider: "supabase",
12
+ authProviderUserSid: "supabase-user-7",
13
+ avatarStorageKey: null,
14
+ avatarVersion: null,
15
+ ...overrides
16
+ };
17
+ }
18
+
19
+ function createUserProfilesRepository(profile = createProfile()) {
20
+ return {
21
+ async findById(userId) {
22
+ return String(userId || "") === String(profile.id) ? profile : null;
23
+ },
24
+ async findByEmail(email) {
25
+ return String(email || "").trim().toLowerCase() === String(profile.email || "").toLowerCase() ? profile : null;
26
+ }
27
+ };
28
+ }
29
+
30
+ function createUserProfileSyncService() {
31
+ return {
32
+ async findByIdentity() {
33
+ return null;
34
+ },
35
+ async syncIdentityProfile(profile) {
36
+ return createProfile({
37
+ authProvider: String(profile?.authProvider || "supabase"),
38
+ authProviderUserSid: String(profile?.authProviderUserSid || "supabase-user-7"),
39
+ email: String(profile?.email || "ada@example.com").toLowerCase(),
40
+ displayName: String(profile?.displayName || "Ada Example")
41
+ });
42
+ }
43
+ };
44
+ }
45
+
46
+ function createLocalRequest(overrides = {}) {
47
+ const remoteAddress = String(overrides?.socket?.remoteAddress || overrides?.raw?.socket?.remoteAddress || "127.0.0.1");
48
+ return {
49
+ ip: "127.0.0.1",
50
+ hostname: "localhost",
51
+ headers: {
52
+ host: "localhost:3000"
53
+ },
54
+ cookies: {},
55
+ socket: {
56
+ remoteAddress
57
+ },
58
+ raw: {
59
+ socket: {
60
+ remoteAddress
61
+ }
62
+ },
63
+ ...overrides
64
+ };
65
+ }
66
+
67
+ function createServiceFixture(overrides = {}) {
68
+ return createService({
69
+ authProvider: {
70
+ id: "supabase",
71
+ supabaseUrl: "",
72
+ supabasePublishableKey: "",
73
+ jwtAudience: "authenticated"
74
+ },
75
+ appPublicUrl: "http://localhost:5173",
76
+ nodeEnv: "development",
77
+ devAuthBypassEnabled: true,
78
+ devAuthBypassSecret: "dev-bootstrap-secret",
79
+ userProfilesRepository: createUserProfilesRepository(),
80
+ userProfileSyncService: createUserProfileSyncService(),
81
+ ...overrides
82
+ });
83
+ }
84
+
85
+ test("dev auth bootstrap can issue and authenticate a local session without Supabase", async () => {
86
+ const authService = createServiceFixture();
87
+ const loginRequest = createLocalRequest();
88
+
89
+ const loginResult = await authService.devLoginAs(loginRequest, {
90
+ userId: "7"
91
+ });
92
+
93
+ assert.equal(loginResult.profile.id, "7");
94
+ assert.match(loginResult.session.access_token, /^jskit-dev\./);
95
+ assert.match(loginResult.session.refresh_token, /^jskit-dev\./);
96
+
97
+ const authResult = await authService.authenticateRequest(
98
+ createLocalRequest({
99
+ cookies: {
100
+ sb_access_token: loginResult.session.access_token,
101
+ sb_refresh_token: loginResult.session.refresh_token
102
+ }
103
+ })
104
+ );
105
+
106
+ assert.equal(authResult.authenticated, true);
107
+ assert.equal(authResult.profile.id, "7");
108
+ assert.equal(authResult.profile.email, "ada@example.com");
109
+ assert.equal(authResult.session, null);
110
+ });
111
+
112
+ test("dev auth bootstrap supports email lookup", async () => {
113
+ const authService = createServiceFixture();
114
+
115
+ const result = await authService.devLoginAs(createLocalRequest(), {
116
+ email: "ADA@EXAMPLE.COM"
117
+ });
118
+
119
+ assert.equal(result.profile.id, "7");
120
+ assert.equal(result.profile.email, "ada@example.com");
121
+ });
122
+
123
+ test("dev auth bootstrap rejects non-local requests and clears leaked dev sessions", async () => {
124
+ const authService = createServiceFixture();
125
+ const issued = await authService.devLoginAs(createLocalRequest(), {
126
+ userId: "7"
127
+ });
128
+
129
+ await assert.rejects(
130
+ () =>
131
+ authService.devLoginAs(
132
+ createLocalRequest({
133
+ ip: "203.0.113.10",
134
+ hostname: "example.com",
135
+ headers: { host: "example.com" }
136
+ }),
137
+ { userId: "7" }
138
+ ),
139
+ /localhost/
140
+ );
141
+
142
+ const authResult = await authService.authenticateRequest({
143
+ ip: "203.0.113.10",
144
+ hostname: "example.com",
145
+ headers: { host: "example.com" },
146
+ cookies: {
147
+ sb_access_token: issued.session.access_token,
148
+ sb_refresh_token: issued.session.refresh_token
149
+ }
150
+ });
151
+
152
+ assert.equal(authResult.authenticated, false);
153
+ assert.equal(authResult.clearSession, true);
154
+ });
155
+
156
+ test("dev auth bootstrap does not trust forwarded localhost headers", async () => {
157
+ const authService = createServiceFixture();
158
+
159
+ await assert.rejects(
160
+ () =>
161
+ authService.devLoginAs(
162
+ createLocalRequest({
163
+ ip: "203.0.113.10",
164
+ socket: {
165
+ remoteAddress: "203.0.113.10"
166
+ },
167
+ raw: {
168
+ socket: {
169
+ remoteAddress: "203.0.113.10"
170
+ }
171
+ },
172
+ headers: {
173
+ host: "localhost:3000",
174
+ "x-forwarded-for": "127.0.0.1",
175
+ "x-forwarded-host": "localhost"
176
+ }
177
+ }),
178
+ { userId: "7" }
179
+ ),
180
+ /localhost/
181
+ );
182
+ });
@@ -21,6 +21,14 @@ function createAppConfigFixture() {
21
21
  };
22
22
  }
23
23
 
24
+ function isBootFailureWithCause(error, pattern) {
25
+ return (
26
+ error instanceof Error &&
27
+ /failed during boot\(\)/.test(String(error.message || "")) &&
28
+ pattern.test(String(error.details?.cause?.message || ""))
29
+ );
30
+ }
31
+
24
32
  test("auth supabase provider registers authService and contributes auth actions in users mode", async () => {
25
33
  const app = createApplication();
26
34
  app.instance("appConfig", createAppConfigFixture());
@@ -69,6 +77,7 @@ test("auth supabase provider registers authService and contributes auth actions
69
77
  assert.equal(Array.isArray(definitions), true);
70
78
  assert.equal(definitions.some((definition) => definition.id === "auth.login.password"), true);
71
79
  assert.equal(definitions.some((definition) => definition.id === "auth.register.confirmation.resend"), true);
80
+ assert.equal(definitions.some((definition) => definition.id === "auth.dev.loginAs"), false);
72
81
  const sessionRead = definitions.find((definition) => definition.id === "auth.session.read");
73
82
  assert.deepEqual(sessionRead?.surfaces, ["home", "console"]);
74
83
  });
@@ -155,6 +164,206 @@ test("auth supabase provider rejects unsupported AUTH_PROFILE_MODE values", asyn
155
164
  assert.throws(() => app.make("authService"), /Unsupported AUTH_PROFILE_MODE/);
156
165
  });
157
166
 
167
+ test("auth supabase provider can boot dev auth without Supabase credentials", async () => {
168
+ const app = createApplication();
169
+ app.instance("appConfig", createAppConfigFixture());
170
+ app.instance("jskit.env", {
171
+ AUTH_DEV_BYPASS_ENABLED: "true",
172
+ AUTH_DEV_BYPASS_SECRET: "dev-bootstrap-secret",
173
+ AUTH_PROFILE_MODE: "users",
174
+ APP_PUBLIC_URL: "http://localhost:5173",
175
+ NODE_ENV: "development"
176
+ });
177
+ app.instance("jskit.logger", {
178
+ info() {},
179
+ warn() {},
180
+ error() {},
181
+ debug() {}
182
+ });
183
+ app.instance("domainEvents", {
184
+ async publish() {}
185
+ });
186
+ app.instance("users.profile.sync.service", {
187
+ async findByIdentity() {
188
+ return null;
189
+ },
190
+ async syncIdentityProfile(profile) {
191
+ return {
192
+ id: 1,
193
+ authProvider: String(profile?.authProvider || "supabase"),
194
+ authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
195
+ email: String(profile?.email || "test@example.com"),
196
+ displayName: String(profile?.displayName || "Test User")
197
+ };
198
+ }
199
+ });
200
+ app.instance("internal.repository.user-profiles", {
201
+ async findById() {
202
+ return null;
203
+ },
204
+ async findByEmail() {
205
+ return null;
206
+ }
207
+ });
208
+
209
+ await app.start({
210
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
211
+ });
212
+
213
+ const authService = app.make("authService");
214
+ assert.equal(typeof authService?.devLoginAs, "function");
215
+ assert.equal(typeof authService?.isDevAuthBootstrapEnabled, "function");
216
+ assert.equal(authService.isDevAuthBootstrapEnabled(), true);
217
+
218
+ const actionExecutor = app.make("actionExecutor");
219
+ const definitions = actionExecutor.listDefinitions();
220
+ assert.equal(definitions.some((definition) => definition.id === "auth.dev.loginAs"), true);
221
+ });
222
+
223
+ test("auth supabase provider rejects dev auth bypass in production", async () => {
224
+ const app = createApplication();
225
+ app.instance("appConfig", createAppConfigFixture());
226
+ app.instance("jskit.env", {
227
+ AUTH_DEV_BYPASS_ENABLED: "true",
228
+ AUTH_DEV_BYPASS_SECRET: "dev-bootstrap-secret",
229
+ AUTH_PROFILE_MODE: "users",
230
+ APP_PUBLIC_URL: "https://example.com",
231
+ NODE_ENV: "production"
232
+ });
233
+ app.instance("jskit.logger", {
234
+ info() {},
235
+ warn() {},
236
+ error() {},
237
+ debug() {}
238
+ });
239
+ app.instance("domainEvents", {
240
+ async publish() {}
241
+ });
242
+ app.instance("users.profile.sync.service", {
243
+ async findByIdentity() {
244
+ return null;
245
+ },
246
+ async syncIdentityProfile(profile) {
247
+ return {
248
+ id: 1,
249
+ authProvider: String(profile?.authProvider || "supabase"),
250
+ authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
251
+ email: String(profile?.email || "test@example.com"),
252
+ displayName: String(profile?.displayName || "Test User")
253
+ };
254
+ }
255
+ });
256
+ app.instance("internal.repository.user-profiles", {
257
+ async findById() {
258
+ return null;
259
+ },
260
+ async findByEmail() {
261
+ return null;
262
+ }
263
+ });
264
+
265
+ await assert.rejects(
266
+ () =>
267
+ app.start({
268
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
269
+ }),
270
+ (error) => isBootFailureWithCause(error, /must not be enabled in production/)
271
+ );
272
+ });
273
+
274
+ test("auth supabase provider rejects dev auth bypass without a secret during boot", async () => {
275
+ const app = createApplication();
276
+ app.instance("appConfig", createAppConfigFixture());
277
+ app.instance("jskit.env", {
278
+ AUTH_DEV_BYPASS_ENABLED: "true",
279
+ AUTH_PROFILE_MODE: "users",
280
+ APP_PUBLIC_URL: "http://localhost:5173",
281
+ NODE_ENV: "development"
282
+ });
283
+ app.instance("jskit.logger", {
284
+ info() {},
285
+ warn() {},
286
+ error() {},
287
+ debug() {}
288
+ });
289
+ app.instance("domainEvents", {
290
+ async publish() {}
291
+ });
292
+ app.instance("users.profile.sync.service", {
293
+ async findByIdentity() {
294
+ return null;
295
+ },
296
+ async syncIdentityProfile(profile) {
297
+ return {
298
+ id: 1,
299
+ authProvider: String(profile?.authProvider || "supabase"),
300
+ authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
301
+ email: String(profile?.email || "test@example.com"),
302
+ displayName: String(profile?.displayName || "Test User")
303
+ };
304
+ }
305
+ });
306
+ app.instance("internal.repository.user-profiles", {
307
+ async findById() {
308
+ return null;
309
+ },
310
+ async findByEmail() {
311
+ return null;
312
+ }
313
+ });
314
+
315
+ await assert.rejects(
316
+ () =>
317
+ app.start({
318
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
319
+ }),
320
+ (error) => isBootFailureWithCause(error, /AUTH_DEV_BYPASS_SECRET is required/)
321
+ );
322
+ });
323
+
324
+ test("auth supabase provider rejects dev auth bypass without internal.repository.user-profiles during boot", async () => {
325
+ const app = createApplication();
326
+ app.instance("appConfig", createAppConfigFixture());
327
+ app.instance("jskit.env", {
328
+ AUTH_DEV_BYPASS_ENABLED: "true",
329
+ AUTH_DEV_BYPASS_SECRET: "dev-bootstrap-secret",
330
+ AUTH_PROFILE_MODE: "users",
331
+ APP_PUBLIC_URL: "http://localhost:5173",
332
+ NODE_ENV: "development"
333
+ });
334
+ app.instance("jskit.logger", {
335
+ info() {},
336
+ warn() {},
337
+ error() {},
338
+ debug() {}
339
+ });
340
+ app.instance("domainEvents", {
341
+ async publish() {}
342
+ });
343
+ app.instance("users.profile.sync.service", {
344
+ async findByIdentity() {
345
+ return null;
346
+ },
347
+ async syncIdentityProfile(profile) {
348
+ return {
349
+ id: 1,
350
+ authProvider: String(profile?.authProvider || "supabase"),
351
+ authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
352
+ email: String(profile?.email || "test@example.com"),
353
+ displayName: String(profile?.displayName || "Test User")
354
+ };
355
+ }
356
+ });
357
+
358
+ await assert.rejects(
359
+ () =>
360
+ app.start({
361
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
362
+ }),
363
+ (error) => isBootFailureWithCause(error, /requires internal\.repository\.user-profiles with findById\(\) and findByEmail\(\)/)
364
+ );
365
+ });
366
+
158
367
  test("auth supabase provider reads oauth providers from appConfig.auth.oauth", async () => {
159
368
  const app = createApplication();
160
369
  app.instance("appConfig", {