@jskit-ai/auth-provider-local-core 0.1.15 → 0.1.17

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-local-core",
4
- "version": "0.1.15",
4
+ "version": "0.1.17",
5
5
  "kind": "runtime",
6
6
  "description": "Local auth provider with a file backend default and no database requirement.",
7
7
  "dependsOn": [
@@ -54,11 +54,18 @@ export default Object.freeze({
54
54
  }
55
55
  }
56
56
  },
57
+ "ci": {
58
+ "environment": {
59
+ "AUTH_PROVIDER": "local"
60
+ },
61
+ "services": [],
62
+ "steps": []
63
+ },
57
64
  "mutations": {
58
65
  "dependencies": {
59
66
  "runtime": {
60
- "@jskit-ai/auth-core": "0.1.114",
61
- "@jskit-ai/kernel": "0.1.116",
67
+ "@jskit-ai/auth-core": "0.1.115",
68
+ "@jskit-ai/kernel": "0.1.118",
62
69
  "nodemailer": "^7.0.10",
63
70
  "dotenv": "^16.4.5"
64
71
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-provider-local-core",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,8 +11,8 @@
11
11
  "./server/lib/index": "./src/server/lib/index.js"
12
12
  },
13
13
  "dependencies": {
14
- "@jskit-ai/auth-core": "0.1.114",
15
- "@jskit-ai/kernel": "0.1.116",
14
+ "@jskit-ai/auth-core": "0.1.115",
15
+ "@jskit-ai/kernel": "0.1.118",
16
16
  "nodemailer": "^7.0.10"
17
17
  }
18
18
  }
@@ -6,6 +6,12 @@ import {
6
6
  import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
7
7
  import { buildSecurityStatusFromAuthMethodsStatus } from "@jskit-ai/auth-core/shared/authSecurityStatus";
8
8
  import { normalizeAuthActor, normalizeAuthResult } from "@jskit-ai/auth-core/server/authActor";
9
+ import {
10
+ assertDevAuthPolicy,
11
+ ensureDevAuthExchangeAvailable,
12
+ ensureDevAuthRuntimeAvailable,
13
+ resolveDevAuthPolicy
14
+ } from "@jskit-ai/auth-core/server/devAuth";
9
15
  import { normalizeEmail } from "@jskit-ai/auth-core/server/utils";
10
16
  import { throwUnsupportedAuthOperation } from "@jskit-ai/auth-core/server/unsupportedOperation";
11
17
  import { normalizePasswordStrategy } from "./passwords.js";
@@ -15,6 +21,7 @@ const ACCESS_TOKEN_COOKIE = "jskit_local_access_token";
15
21
  const REFRESH_TOKEN_COOKIE = "jskit_local_refresh_token";
16
22
  const RECOVERY_TOKEN_COOKIE = "jskit_local_recovery_token";
17
23
  const PROVIDER_ID = "local";
24
+ const DEV_AUTH_SESSION_PURPOSE = "dev-auth";
18
25
  const ACCESS_TTL_SECONDS = 15 * 60;
19
26
  const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 30;
20
27
  const RECOVERY_SESSION_TTL_SECONDS = 15 * 60;
@@ -36,6 +43,14 @@ function isNormalSession(session) {
36
43
  return (session?.purpose || "normal") === "normal";
37
44
  }
38
45
 
46
+ function isDevAuthSession(session) {
47
+ return session?.purpose === DEV_AUTH_SESSION_PURPOSE;
48
+ }
49
+
50
+ function isAuthenticatingSession(session) {
51
+ return isNormalSession(session) || isDevAuthSession(session);
52
+ }
53
+
39
54
  function normalizeDisplayName(value, email) {
40
55
  const displayName = String(value || "").trim();
41
56
  if (displayName) {
@@ -52,6 +67,15 @@ function safeRequestCookies(request) {
52
67
  return request && request.cookies && typeof request.cookies === "object" ? request.cookies : {};
53
68
  }
54
69
 
70
+ function unauthenticatedAuthResult(clearSession = false) {
71
+ return {
72
+ authenticated: false,
73
+ clearSession: Boolean(clearSession),
74
+ session: null,
75
+ transientFailure: false
76
+ };
77
+ }
78
+
55
79
  function cookieOptions(isProduction, maxAge) {
56
80
  return {
57
81
  path: "/",
@@ -92,21 +116,59 @@ function buildActor(user, profile = null) {
92
116
  });
93
117
  }
94
118
 
95
- async function buildAuthPayload({ user, session, profileProjector, profileOptions = {} }) {
96
- let profile = buildProfile(user);
97
- if (profileProjector && typeof profileProjector.syncIdentityProfile === "function") {
98
- profile = await profileProjector.syncIdentityProfile(profile, profileOptions);
99
- if (!profile || typeof profile !== "object") {
100
- throw new Error("auth.profile.projector.syncIdentityProfile() must return a profile object.");
101
- }
102
- }
119
+ function buildAuthResult({ user, session, appProfile = null }) {
103
120
  return normalizeAuthResult({
104
- profile,
105
- actor: buildActor(user, profileProjector ? profile : null),
121
+ profile: appProfile || buildProfile(user),
122
+ actor: buildActor(user, appProfile),
106
123
  session
107
124
  });
108
125
  }
109
126
 
127
+ function requireProfileResult(profile, methodName) {
128
+ if (!profile || typeof profile !== "object") {
129
+ throw new Error(`auth.profile.projector.${methodName}() must return a profile object.`);
130
+ }
131
+ return profile;
132
+ }
133
+
134
+ async function buildAuthPayload({ user, session, profileProjector, profileOptions = {} }) {
135
+ const appProfile = profileProjector
136
+ ? requireProfileResult(
137
+ await profileProjector.syncIdentityProfile(buildProfile(user), profileOptions),
138
+ "syncIdentityProfile"
139
+ )
140
+ : null;
141
+ return buildAuthResult({ user, session, appProfile });
142
+ }
143
+
144
+ async function findExistingAppProfile(user, profileProjector) {
145
+ if (!profileProjector) {
146
+ return null;
147
+ }
148
+ if (typeof profileProjector.findByIdentity !== "function") {
149
+ throw new Error(
150
+ "auth.profile.projector.findByIdentity() is required for dev auth impersonation."
151
+ );
152
+ }
153
+ const profile = await profileProjector.findByIdentity(buildProfile(user));
154
+ return profile ? requireProfileResult(profile, "findByIdentity") : null;
155
+ }
156
+
157
+ async function buildSessionAuthPayload({ devAuth, profileProjector, request, session, user }) {
158
+ if (!isDevAuthSession(session)) {
159
+ return buildAuthPayload({ user, session: null, profileProjector });
160
+ }
161
+ try {
162
+ ensureDevAuthRuntimeAvailable(devAuth, request);
163
+ } catch {
164
+ return null;
165
+ }
166
+ const appProfile = await findExistingAppProfile(user, profileProjector);
167
+ return profileProjector && !appProfile
168
+ ? null
169
+ : buildAuthResult({ user, session: null, appProfile });
170
+ }
171
+
110
172
  function buildAccessToken({ user, session, secret, ttlSeconds = ACCESS_TTL_SECONDS }) {
111
173
  const issuedAt = nowSeconds();
112
174
  return signToken(
@@ -166,6 +228,22 @@ function validateEmailInput(email) {
166
228
  return normalized;
167
229
  }
168
230
 
231
+ function devLoginAsValidationError(fieldErrors = {}) {
232
+ const messages = Object.values(fieldErrors).filter(Boolean);
233
+ return new AppError(400, messages[0] || "Provide a user id or email.", {
234
+ details: {
235
+ fieldErrors
236
+ }
237
+ });
238
+ }
239
+
240
+ function devLoginAsUserNotFound({ email = "", userId = "" } = {}) {
241
+ return devLoginAsValidationError({
242
+ ...(userId ? { userId: "User not found." } : {}),
243
+ ...(email ? { email: "User not found." } : {})
244
+ });
245
+ }
246
+
169
247
  function normalizeInvitationInput(value = null) {
170
248
  if (!value || typeof value !== "object" || Array.isArray(value)) {
171
249
  return null;
@@ -221,6 +299,10 @@ function createLocalAuthService({
221
299
 
222
300
  const passwords = normalizePasswordStrategy(passwordStrategy);
223
301
  const isProduction = config.nodeEnv === "production";
302
+ const devAuth = config.devAuth || resolveDevAuthPolicy({
303
+ nodeEnv: config.nodeEnv
304
+ });
305
+ assertDevAuthPolicy(devAuth);
224
306
  const profileProjectionEnabled = typeof profileProjector?.syncIdentityProfile === "function";
225
307
  const recoveryDelivery = config.smtpConfigured
226
308
  ? "smtp"
@@ -262,7 +344,8 @@ function createLocalAuthService({
262
344
  },
263
345
  securityStatus: true,
264
346
  signOutOtherSessions: true,
265
- appProfileProjection: profileProjectionEnabled
347
+ appProfileProjection: profileProjectionEnabled,
348
+ devLoginAs: devAuth.enabled === true && devAuth.isProduction !== true
266
349
  }
267
350
  });
268
351
 
@@ -367,6 +450,40 @@ function createLocalAuthService({
367
450
  });
368
451
  }
369
452
 
453
+ function isDevAuthBootstrapEnabled() {
454
+ return devAuth.enabled === true && devAuth.isProduction !== true;
455
+ }
456
+
457
+ async function devLoginAs(request, input = {}) {
458
+ ensureDevAuthExchangeAvailable(devAuth, request);
459
+ const userId = String(input?.userId || "").trim();
460
+ const email = normalizeEmail(input?.email || "");
461
+ if (!userId && !email) {
462
+ throw devLoginAsValidationError({
463
+ userId: "Provide a user id or email.",
464
+ email: "Provide a user id or email."
465
+ });
466
+ }
467
+
468
+ return backend.withTransaction(async (tx) => {
469
+ let user = userId ? await tx.users.findById(userId) : null;
470
+ if (!user && email) {
471
+ user = await tx.users.findByEmail(email);
472
+ }
473
+ if (!user || user.disabled) {
474
+ throw devLoginAsUserNotFound({ email, userId });
475
+ }
476
+ const appProfile = await findExistingAppProfile(user, profileProjector);
477
+ if (profileProjector && !appProfile) {
478
+ throw devLoginAsUserNotFound({ email, userId });
479
+ }
480
+ const session = await createSessionForUser(tx, user, {
481
+ purpose: DEV_AUTH_SESSION_PURPOSE
482
+ });
483
+ return buildAuthResult({ user, session, appProfile });
484
+ });
485
+ }
486
+
370
487
  async function authenticateRequest(request) {
371
488
  const cookies = safeRequestCookies(request);
372
489
  const accessToken = String(cookies[ACCESS_TOKEN_COOKIE] || "").trim();
@@ -386,10 +503,20 @@ function createLocalAuthService({
386
503
  }
387
504
  return { user, session };
388
505
  });
389
- if (resolved && isNormalSession(resolved.session)) {
506
+ if (resolved && isAuthenticatingSession(resolved.session)) {
507
+ const authPayload = await buildSessionAuthPayload({
508
+ devAuth,
509
+ profileProjector,
510
+ request,
511
+ session: resolved.session,
512
+ user: resolved.user
513
+ });
514
+ if (!authPayload) {
515
+ return unauthenticatedAuthResult(true);
516
+ }
390
517
  return {
391
518
  authenticated: true,
392
- ...(await buildAuthPayload({ user: resolved.user, session: null, profileProjector })),
519
+ ...authPayload,
393
520
  session: null,
394
521
  sessionPurpose: resolved.session.purpose || "normal",
395
522
  clearSession: false,
@@ -400,36 +527,32 @@ function createLocalAuthService({
400
527
  }
401
528
 
402
529
  if (!refreshToken) {
403
- return {
404
- authenticated: false,
405
- clearSession: Boolean(accessToken),
406
- session: null,
407
- transientFailure: false
408
- };
530
+ return unauthenticatedAuthResult(Boolean(accessToken));
409
531
  }
410
532
 
411
533
  const resolved = await resolveValidSessionByRefreshToken(refreshToken);
412
534
  if (!resolved) {
413
- return {
414
- authenticated: false,
415
- clearSession: true,
416
- session: null,
417
- transientFailure: false
418
- };
535
+ return unauthenticatedAuthResult(true);
419
536
  }
420
537
 
421
- if (!isNormalSession(resolved.session)) {
422
- return {
423
- authenticated: false,
424
- clearSession: false,
425
- session: null,
426
- transientFailure: false
427
- };
538
+ if (!isAuthenticatingSession(resolved.session)) {
539
+ return unauthenticatedAuthResult();
540
+ }
541
+
542
+ const authPayload = await buildSessionAuthPayload({
543
+ devAuth,
544
+ profileProjector,
545
+ request,
546
+ session: resolved.session,
547
+ user: resolved.user
548
+ });
549
+ if (!authPayload) {
550
+ return unauthenticatedAuthResult(true);
428
551
  }
429
552
 
430
553
  return {
431
554
  authenticated: true,
432
- ...(await buildAuthPayload({ user: resolved.user, session: null, profileProjector })),
555
+ ...authPayload,
433
556
  session: buildSessionPayload({
434
557
  user: resolved.user,
435
558
  session: resolved.session,
@@ -686,6 +809,8 @@ function createLocalAuthService({
686
809
 
687
810
  return Object.freeze({
688
811
  getCapabilities,
812
+ isDevAuthBootstrapEnabled,
813
+ devLoginAs,
689
814
  authenticateRequest,
690
815
  hasAccessTokenCookie(request) {
691
816
  return Boolean(safeRequestCookies(request)[ACCESS_TOKEN_COOKIE] || safeRequestCookies(request)[RECOVERY_TOKEN_COOKIE]);
@@ -1,4 +1,9 @@
1
1
  import { applyAuthServiceDecorators } from "@jskit-ai/auth-core/server/authServiceDecoratorRegistry";
2
+ import { parseBooleanFlag } from "@jskit-ai/auth-core/server/booleanFlag";
3
+ import {
4
+ assertDevAuthPolicy,
5
+ resolveDevAuthPolicyFromEnv
6
+ } from "@jskit-ai/auth-core/server/devAuth";
2
7
  import fs from "node:fs";
3
8
  import path from "node:path";
4
9
  import { randomBytes } from "node:crypto";
@@ -12,20 +17,6 @@ function resolveLocalBackendMode(scope) {
12
17
  return String(env.AUTH_LOCAL_BACKEND || "file").trim().toLowerCase() || "file";
13
18
  }
14
19
 
15
- function parseBoolean(value, fallback = false) {
16
- const raw = String(value || "").trim().toLowerCase();
17
- if (!raw) {
18
- return fallback;
19
- }
20
- if (["1", "true", "yes", "on"].includes(raw)) {
21
- return true;
22
- }
23
- if (["0", "false", "no", "off"].includes(raw)) {
24
- return false;
25
- }
26
- return fallback;
27
- }
28
-
29
20
  function resolveRuntimeEnv(scope) {
30
21
  const env = scope && typeof scope.has === "function" && scope.has("jskit.env") ? scope.make("jskit.env") : {};
31
22
  return {
@@ -91,7 +82,7 @@ function resolveSmtpConfig(env) {
91
82
  smtp: {
92
83
  host,
93
84
  port: Number(env.AUTH_LOCAL_SMTP_PORT || 587),
94
- secure: parseBoolean(env.AUTH_LOCAL_SMTP_SECURE, false),
85
+ secure: parseBooleanFlag(env.AUTH_LOCAL_SMTP_SECURE, false),
95
86
  user: String(env.AUTH_LOCAL_SMTP_USER || "").trim(),
96
87
  password: String(env.AUTH_LOCAL_SMTP_PASSWORD || ""),
97
88
  from,
@@ -130,10 +121,12 @@ function resolveConfig(scope) {
130
121
  const isProduction = nodeEnv === "production";
131
122
  const backend = String(env.AUTH_LOCAL_BACKEND || "file").trim().toLowerCase() || "file";
132
123
  const storeDir = resolveStoreDir(env);
133
- if (backend === "file" && isProduction && !parseBoolean(env.AUTH_LOCAL_FILE_PRODUCTION_ACK, false)) {
124
+ if (backend === "file" && isProduction && !parseBooleanFlag(env.AUTH_LOCAL_FILE_PRODUCTION_ACK, false)) {
134
125
  throw new Error("AUTH_LOCAL_FILE_PRODUCTION_ACK is required to use the local file auth backend in production.");
135
126
  }
136
127
  const smtp = resolveSmtpConfig(env);
128
+ const devAuth = resolveDevAuthPolicyFromEnv(env);
129
+ assertDevAuthPolicy(devAuth);
137
130
  return {
138
131
  backend,
139
132
  storeDir,
@@ -145,10 +138,32 @@ function resolveConfig(scope) {
145
138
  isProduction
146
139
  }),
147
140
  recoveryDevOutput: String(env.AUTH_LOCAL_RECOVERY_DEV_OUTPUT || "log").trim().toLowerCase() || "log",
141
+ devAuth,
148
142
  ...smtp
149
143
  };
150
144
  }
151
145
 
146
+ function createLazyProfileProjector(scope) {
147
+ if (!scope.has("auth.profile.projector")) {
148
+ return null;
149
+ }
150
+ const resolveMethod = (methodName) => {
151
+ const projector = scope.make("auth.profile.projector");
152
+ if (!projector || typeof projector[methodName] !== "function") {
153
+ throw new Error(`auth.profile.projector.${methodName}() must be a function.`);
154
+ }
155
+ return projector[methodName].bind(projector);
156
+ };
157
+ return {
158
+ findByIdentity(identity, options = {}) {
159
+ return resolveMethod("findByIdentity")(identity, options);
160
+ },
161
+ syncIdentityProfile(profile, options = {}) {
162
+ return resolveMethod("syncIdentityProfile")(profile, options);
163
+ }
164
+ };
165
+ }
166
+
152
167
  class AuthLocalServiceProvider {
153
168
  static id = "auth.provider.local";
154
169
 
@@ -178,17 +193,7 @@ class AuthLocalServiceProvider {
178
193
  throw new Error(`AUTH_LOCAL_BACKEND="${config.backend}" requires a package or app provider that registers auth.local.backend.`);
179
194
  }
180
195
  const backend = scope.make("auth.local.backend");
181
- const profileProjector = scope.has("auth.profile.projector")
182
- ? {
183
- async syncIdentityProfile(profile, options = {}) {
184
- const projector = scope.make("auth.profile.projector");
185
- if (!projector || typeof projector.syncIdentityProfile !== "function") {
186
- throw new Error("auth.profile.projector.syncIdentityProfile() must be a function.");
187
- }
188
- return projector.syncIdentityProfile(profile, options);
189
- }
190
- }
191
- : null;
196
+ const profileProjector = createLazyProfileProjector(scope);
192
197
  const invitationContextResolver = scope.has("auth.invitationContextResolver")
193
198
  ? {
194
199
  async resolveInvitationContext(invitation, options = {}) {
@@ -0,0 +1,13 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import descriptor from "../package.descriptor.mjs";
4
+
5
+ test("local auth contributes only its provider selection to CI", () => {
6
+ assert.deepEqual(descriptor.ci, {
7
+ environment: {
8
+ AUTH_PROVIDER: "local"
9
+ },
10
+ services: [],
11
+ steps: []
12
+ });
13
+ });
@@ -6,6 +6,7 @@ import test from "node:test";
6
6
  import { createApplication } from "@jskit-ai/kernel/_testable";
7
7
  import { ActionRuntimeServiceProvider } from "@jskit-ai/kernel/server/actions";
8
8
  import { registerAuthServiceDecorator } from "@jskit-ai/auth-core/server/authServiceDecoratorRegistry";
9
+ import { DEV_AUTH_SECRET_HEADER } from "@jskit-ai/auth-core/server/devAuth";
9
10
  import { AuthActionsServiceProvider } from "@jskit-ai/auth-core/server/providers/AuthActionsServiceProvider";
10
11
  import { AuthLocalServiceProvider } from "../src/server/providers/AuthLocalServiceProvider.js";
11
12
  import { AuthProviderServiceProvider } from "../src/server/providers/AuthProviderServiceProvider.js";
@@ -18,6 +19,8 @@ import {
18
19
  verifyPassword
19
20
  } from "../src/server/lib/index.js";
20
21
 
22
+ const DEV_AUTH_SECRET = "local-preview-exchange-secret";
23
+
21
24
  function createAppConfigFixture() {
22
25
  return {
23
26
  surfaceModeAll: "all",
@@ -52,7 +55,44 @@ function createReplyFixture() {
52
55
  };
53
56
  }
54
57
 
58
+ function createLocalAuthRequest({
59
+ cookies = {},
60
+ headers = {},
61
+ host = "localhost:4100",
62
+ remoteAddress = "127.0.0.1"
63
+ } = {}) {
64
+ return {
65
+ cookies,
66
+ headers: {
67
+ host,
68
+ ...headers
69
+ },
70
+ socket: {
71
+ remoteAddress
72
+ }
73
+ };
74
+ }
75
+
76
+ function createDevAuthExchangeRequest(options = {}) {
77
+ const request = createLocalAuthRequest(options);
78
+ return {
79
+ ...request,
80
+ headers: {
81
+ ...request.headers,
82
+ [DEV_AUTH_SECRET_HEADER]: DEV_AUTH_SECRET
83
+ }
84
+ };
85
+ }
86
+
87
+ function devAuthEnv() {
88
+ return {
89
+ AUTH_DEV_BYPASS_ENABLED: "true",
90
+ AUTH_DEV_BYPASS_SECRET: DEV_AUTH_SECRET
91
+ };
92
+ }
93
+
55
94
  async function createStartedApp({
95
+ env = {},
56
96
  profileProjector = null,
57
97
  profileProjectorFactory = null,
58
98
  passwordStrategy = null,
@@ -68,7 +108,8 @@ async function createStartedApp({
68
108
  AUTH_LOCAL_STORE_DIR: storeDir,
69
109
  AUTH_LOCAL_RECOVERY_DEV_OUTPUT: "response",
70
110
  APP_PUBLIC_URL: "http://localhost:5173",
71
- NODE_ENV: "test"
111
+ NODE_ENV: "test",
112
+ ...env
72
113
  });
73
114
  app.instance(
74
115
  "jskit.logger",
@@ -162,6 +203,179 @@ test("local auth provider registers, logs in, reads session, and logs out with f
162
203
  assert.match(usersFile, /^user:v1:/);
163
204
  });
164
205
 
206
+ test("local file auth login-as issues native cookies for an existing user", async () => {
207
+ const { app } = await createStartedApp({
208
+ env: devAuthEnv()
209
+ });
210
+ const authService = app.make("authService");
211
+ await authService.register({
212
+ displayName: "Ada",
213
+ email: "ada@example.com",
214
+ password: "correct horse battery staple"
215
+ });
216
+
217
+ const impersonated = await authService.devLoginAs(createDevAuthExchangeRequest(), {
218
+ email: "ADA@EXAMPLE.COM"
219
+ });
220
+ assert.equal(impersonated.profile.email, "ada@example.com");
221
+ assert.equal(impersonated.session.purpose, "dev-auth");
222
+ assert.equal(authService.getCapabilities().features.devLoginAs, true);
223
+
224
+ const reply = createReplyFixture();
225
+ authService.writeSessionCookies(reply, impersonated.session);
226
+ assert.equal(Boolean(reply.cookies.jskit_local_access_token), true);
227
+ assert.equal(Boolean(reply.cookies.jskit_local_refresh_token), true);
228
+
229
+ const authenticated = await authService.authenticateRequest(createLocalAuthRequest({
230
+ cookies: reply.cookies
231
+ }));
232
+ assert.equal(authenticated.authenticated, true);
233
+ assert.equal(authenticated.actor.email, "ada@example.com");
234
+ assert.equal(authenticated.sessionPurpose, "dev-auth");
235
+ });
236
+
237
+ test("local login-as never invokes the mutating profile projector", async () => {
238
+ const projectedProfile = {
239
+ id: "app-user-ada",
240
+ authProvider: "local",
241
+ authProviderUserSid: "provider-user-ada",
242
+ displayName: "Ada",
243
+ email: "ada@example.com"
244
+ };
245
+ let findCalls = 0;
246
+ let syncCalls = 0;
247
+ const { app } = await createStartedApp({
248
+ env: devAuthEnv(),
249
+ profileProjector: {
250
+ async findByIdentity() {
251
+ findCalls += 1;
252
+ return projectedProfile;
253
+ },
254
+ async syncIdentityProfile(profile) {
255
+ syncCalls += 1;
256
+ return {
257
+ ...projectedProfile,
258
+ authProviderUserSid: profile.authProviderUserSid
259
+ };
260
+ }
261
+ }
262
+ });
263
+ const authService = app.make("authService");
264
+ const registered = await authService.register({
265
+ displayName: "Ada",
266
+ email: "ada@example.com",
267
+ password: "correct horse battery staple"
268
+ });
269
+ projectedProfile.authProviderUserSid = registered.actor.providerUserId;
270
+ findCalls = 0;
271
+ syncCalls = 0;
272
+
273
+ const impersonated = await authService.devLoginAs(createDevAuthExchangeRequest(), {
274
+ email: "ada@example.com"
275
+ });
276
+ const reply = createReplyFixture();
277
+ authService.writeSessionCookies(reply, impersonated.session);
278
+ const authenticated = await authService.authenticateRequest(createLocalAuthRequest({
279
+ cookies: reply.cookies
280
+ }));
281
+
282
+ assert.equal(impersonated.profile.id, "app-user-ada");
283
+ assert.equal(authenticated.profile.id, "app-user-ada");
284
+ assert.equal(findCalls, 2);
285
+ assert.equal(syncCalls, 0);
286
+ });
287
+
288
+ test("local login-as rejects missing, disabled, and unprojected users", async () => {
289
+ const { app } = await createStartedApp({
290
+ env: devAuthEnv(),
291
+ profileProjector: {
292
+ async findByIdentity() {
293
+ return null;
294
+ },
295
+ async syncIdentityProfile() {
296
+ throw new Error("preview login must not synchronize profiles");
297
+ }
298
+ }
299
+ });
300
+ const backend = app.make("auth.local.backend");
301
+ const password = await hashPassword("stored password value");
302
+ await backend.withTransaction(async (tx) => {
303
+ await tx.users.create({
304
+ disabled: false,
305
+ displayName: "Unprojected",
306
+ email: "unprojected@example.com",
307
+ id: "usr_unprojected",
308
+ password
309
+ });
310
+ await tx.users.create({
311
+ disabled: true,
312
+ displayName: "Disabled",
313
+ email: "disabled@example.com",
314
+ id: "usr_disabled",
315
+ password
316
+ });
317
+ });
318
+ const authService = app.make("authService");
319
+
320
+ for (const email of [
321
+ "missing@example.com",
322
+ "disabled@example.com",
323
+ "unprojected@example.com"
324
+ ]) {
325
+ await assert.rejects(
326
+ () => authService.devLoginAs(createDevAuthExchangeRequest(), { email }),
327
+ /User not found/
328
+ );
329
+ }
330
+ });
331
+
332
+ test("local login-as rejects missing, incorrect, and non-loopback exchange authorization", async () => {
333
+ const { app } = await createStartedApp({
334
+ env: devAuthEnv()
335
+ });
336
+ const authService = app.make("authService");
337
+ await authService.register({
338
+ displayName: "Ada",
339
+ email: "ada@example.com",
340
+ password: "correct horse battery staple"
341
+ });
342
+
343
+ await assert.rejects(
344
+ () => authService.devLoginAs(createLocalAuthRequest(), { email: "ada@example.com" }),
345
+ /not authorized/
346
+ );
347
+ await assert.rejects(
348
+ () => authService.devLoginAs(createLocalAuthRequest({
349
+ headers: {
350
+ [DEV_AUTH_SECRET_HEADER]: "wrong-secret"
351
+ }
352
+ }), { email: "ada@example.com" }),
353
+ /not authorized/
354
+ );
355
+ await assert.rejects(
356
+ () => authService.devLoginAs(createDevAuthExchangeRequest({
357
+ remoteAddress: "203.0.113.5"
358
+ }), { email: "ada@example.com" }),
359
+ /localhost/
360
+ );
361
+ });
362
+
363
+ test("local auth provider rejects dev login-as enablement in production", async () => {
364
+ await assert.rejects(
365
+ () => createStartedApp({
366
+ env: {
367
+ ...devAuthEnv(),
368
+ AUTH_LOCAL_FILE_PRODUCTION_ACK: "true",
369
+ AUTH_LOCAL_SESSION_SECRET: "production-session-secret",
370
+ NODE_ENV: "production"
371
+ }
372
+ }),
373
+ (error) => /must not be enabled in production/.test(
374
+ String(error.details?.cause?.message || error.message || "")
375
+ )
376
+ );
377
+ });
378
+
165
379
  test("local auth provider applies auth service decorators for blocking and non-blocking hooks", async () => {
166
380
  const calls = [];
167
381
  const errors = [];