@jskit-ai/auth-provider-supabase-core 0.1.99 → 0.1.100

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,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import * as authProviderSupabase from "../src/server/lib/index.js";
4
+ import { createAccountFlows } from "../src/server/lib/accountFlows.js";
5
+ import { createPasswordSecurityFlows } from "../src/server/lib/passwordSecurityFlows.js";
4
6
 
5
7
  test("auth.provider.supabase exports required symbols", () => {
6
8
  assert.equal(typeof authProviderSupabase.createService, "function");
@@ -52,8 +54,12 @@ test("clearSessionCookies clears root and API path session cookie variants", ()
52
54
  [
53
55
  { name: "sb_access_token", path: "/", maxAge: 0 },
54
56
  { name: "sb_refresh_token", path: "/", maxAge: 0 },
57
+ { name: "sb_recovery_access_token", path: "/", maxAge: 0 },
58
+ { name: "sb_recovery_refresh_token", path: "/", maxAge: 0 },
55
59
  { name: "sb_access_token", path: "/api", maxAge: 0 },
56
- { name: "sb_refresh_token", path: "/api", maxAge: 0 }
60
+ { name: "sb_refresh_token", path: "/api", maxAge: 0 },
61
+ { name: "sb_recovery_access_token", path: "/api", maxAge: 0 },
62
+ { name: "sb_recovery_refresh_token", path: "/api", maxAge: 0 }
57
63
  ]
58
64
  );
59
65
  });
@@ -74,10 +80,114 @@ test("clearSessionCookies preserves secure cookie clearing in production", () =>
74
80
 
75
81
  authService.clearSessionCookies(reply);
76
82
 
77
- assert.equal(clearCalls.length, 4);
83
+ assert.equal(clearCalls.length, 8);
78
84
  assert.equal(clearCalls.every((call) => call.options.secure === true), true);
79
85
  });
80
86
 
87
+ test("supabase account flow updateDisplayName accepts the core command payload shape", async () => {
88
+ let updatePayload = null;
89
+ const flows = createAccountFlows({
90
+ ensureConfigured() {},
91
+ validationError(errors) {
92
+ return new Error(JSON.stringify(errors));
93
+ },
94
+ getSupabaseClient() {
95
+ return {
96
+ auth: {
97
+ async updateUser(payload) {
98
+ updatePayload = payload;
99
+ return {
100
+ data: {
101
+ user: {
102
+ id: "supabase-user-1",
103
+ email: "ada@example.com",
104
+ user_metadata: {
105
+ display_name: payload.data.display_name
106
+ },
107
+ app_metadata: {}
108
+ }
109
+ }
110
+ };
111
+ }
112
+ }
113
+ };
114
+ },
115
+ async setSessionFromRequestCookies() {
116
+ return {
117
+ data: {
118
+ session: {
119
+ access_token: "access"
120
+ }
121
+ }
122
+ };
123
+ },
124
+ async syncProfileFromSupabaseUser(user) {
125
+ return {
126
+ id: "profile-1",
127
+ email: user.email,
128
+ displayName: user.user_metadata.display_name,
129
+ authProvider: "supabase",
130
+ authProviderUserSid: user.id
131
+ };
132
+ },
133
+ mapProfileUpdateError(error) {
134
+ return error || new Error("profile update failed");
135
+ }
136
+ });
137
+
138
+ const result = await flows.updateDisplayName(
139
+ {
140
+ cookies: {}
141
+ },
142
+ {
143
+ displayName: "Ada Lovelace"
144
+ }
145
+ );
146
+
147
+ assert.deepEqual(updatePayload, {
148
+ data: {
149
+ display_name: "Ada Lovelace"
150
+ }
151
+ });
152
+ assert.equal(result.profile.displayName, "Ada Lovelace");
153
+ });
154
+
155
+ test("recovery sessions use recovery cookies and do not authenticate the app", async () => {
156
+ const authService = createServiceFixture();
157
+ const cookies = {};
158
+ const cookieOptions = {};
159
+ const reply = {
160
+ setCookie(name, value, options) {
161
+ cookies[name] = value;
162
+ cookieOptions[name] = options;
163
+ }
164
+ };
165
+
166
+ authService.writeSessionCookies(reply, {
167
+ access_token: "recovery-access",
168
+ refresh_token: "recovery-refresh",
169
+ expires_in: 300,
170
+ purpose: "recovery"
171
+ });
172
+
173
+ assert.deepEqual(cookies, {
174
+ sb_recovery_access_token: "recovery-access",
175
+ sb_recovery_refresh_token: "recovery-refresh"
176
+ });
177
+ assert.equal(cookieOptions.sb_recovery_access_token.maxAge, 300);
178
+ assert.equal(cookieOptions.sb_recovery_refresh_token.maxAge, 300);
179
+ assert.equal(authService.hasAccessTokenCookie({ cookies }), true);
180
+ assert.equal(authService.hasSessionCookie({ cookies }), true);
181
+
182
+ const authResult = await authService.authenticateRequest({ cookies });
183
+ assert.deepEqual(authResult, {
184
+ authenticated: false,
185
+ clearSession: false,
186
+ session: null,
187
+ transientFailure: false
188
+ });
189
+ });
190
+
81
191
  test("logout is local-only when no session cookies are present", async () => {
82
192
  const authService = createServiceFixture();
83
193
 
@@ -106,62 +216,103 @@ test("logout is local-only for dev auth cookies", async () => {
106
216
  });
107
217
  });
108
218
 
109
- test("auth logout action delegates to provider logout and notifies session changes", async () => {
110
- const action = authProviderSupabase
111
- .buildAuthActions()
112
- .find((definition) => definition.id === "auth.logout");
113
- const request = {
114
- id: "request-1"
115
- };
116
- const calls = [];
117
-
118
- const result = await action.execute(
119
- {},
120
- {
121
- requestMeta: {
122
- request
123
- }
219
+ test("supabase password reset consumes the recovery session reader", async () => {
220
+ let normalSessionReads = 0;
221
+ let recoverySessionReads = 0;
222
+ let signOutCalls = 0;
223
+ const flows = createPasswordSecurityFlows({
224
+ ensureConfigured() {},
225
+ validationError(errors) {
226
+ const error = new Error("Validation failed.");
227
+ error.errors = errors;
228
+ return error;
124
229
  },
125
- {
126
- authService: {
127
- async logout(receivedRequest) {
128
- calls.push({
129
- type: "logout",
130
- request: receivedRequest
131
- });
132
- return {
133
- ok: true,
134
- clearSession: true
135
- };
230
+ getSupabaseClient() {
231
+ return {
232
+ auth: {
233
+ async updateUser(input) {
234
+ assert.deepEqual(input, { password: "new password value" });
235
+ return {
236
+ data: {
237
+ user: {
238
+ id: "supabase-user-1",
239
+ email: "reset@example.com"
240
+ }
241
+ },
242
+ error: null
243
+ };
244
+ },
245
+ async signOut(options) {
246
+ signOutCalls += 1;
247
+ assert.deepEqual(options, {
248
+ scope: "local"
249
+ });
250
+ return {
251
+ error: null
252
+ };
253
+ }
136
254
  }
137
- },
138
- authSessionEventsService: {
139
- async notifySessionChanged(payload) {
140
- calls.push({
141
- type: "notify",
142
- context: payload.context
143
- });
255
+ };
256
+ },
257
+ async setSessionFromRequestCookies() {
258
+ normalSessionReads += 1;
259
+ throw new Error("normal session reader should not be used for reset.");
260
+ },
261
+ async setRecoverySessionFromRequestCookies(_request, { supabaseClient }) {
262
+ recoverySessionReads += 1;
263
+ assert.equal(typeof supabaseClient.auth.updateUser, "function");
264
+ return {
265
+ data: {
266
+ user: {
267
+ id: "supabase-user-1",
268
+ email: "reset@example.com"
269
+ },
270
+ session: {
271
+ access_token: "recovery-access",
272
+ refresh_token: "recovery-refresh"
273
+ }
144
274
  }
145
- }
275
+ };
276
+ },
277
+ async syncProfileFromSupabaseUser(user) {
278
+ return {
279
+ id: "1",
280
+ email: user.email,
281
+ displayName: "Reset Example",
282
+ authProvider: "supabase",
283
+ authProviderUserSid: user.id
284
+ };
285
+ },
286
+ async resolvePasswordSignInPolicyForUserId(userId) {
287
+ assert.equal(userId, "1");
288
+ return {
289
+ passwordSignInEnabled: true
290
+ };
291
+ },
292
+ async setPasswordSetupRequiredForUserId(userId, required) {
293
+ assert.equal(userId, "1");
294
+ assert.equal(required, false);
295
+ },
296
+ mapPasswordUpdateError(error) {
297
+ return error instanceof Error ? error : new Error("Password update failed.");
146
298
  }
147
- );
148
-
149
- assert.deepEqual(result, {
150
- ok: true,
151
- clearSession: true
152
299
  });
153
- assert.deepEqual(calls, [
300
+
301
+ await flows.resetPassword(
154
302
  {
155
- type: "logout",
156
- request
303
+ cookies: {
304
+ sb_access_token: "normal-access",
305
+ sb_refresh_token: "normal-refresh",
306
+ sb_recovery_access_token: "recovery-access",
307
+ sb_recovery_refresh_token: "recovery-refresh"
308
+ }
157
309
  },
158
310
  {
159
- type: "notify",
160
- context: {
161
- requestMeta: {
162
- request
163
- }
164
- }
311
+ password: "new password value"
165
312
  }
166
- ]);
313
+ );
314
+
315
+ assert.equal(recoverySessionReads, 1);
316
+ assert.equal(normalSessionReads, 0);
317
+ assert.equal(signOutCalls, 1);
167
318
  });
@@ -1,9 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createApplication } from "@jskit-ai/kernel/_testable";
4
+ import { AuthActionsServiceProvider } from "@jskit-ai/auth-core/server/providers/AuthActionsServiceProvider";
4
5
  import { registerAuthServiceDecorator } from "@jskit-ai/auth-core/server/authServiceDecoratorRegistry";
5
6
  import { ActionRuntimeServiceProvider } from "@jskit-ai/kernel/server/actions";
6
7
  import { createProviderClass } from "../../kernel/shared/runtime/application.js";
8
+ import { AuthProviderServiceProvider } from "../src/server/providers/AuthProviderServiceProvider.js";
7
9
  import { AuthSupabaseServiceProvider } from "../src/server/providers/AuthSupabaseServiceProvider.js";
8
10
 
9
11
  function createAppConfigFixture({ auth = null } = {}) {
@@ -35,7 +37,7 @@ function isBootFailureWithCause(error, pattern) {
35
37
  );
36
38
  }
37
39
 
38
- test("auth supabase provider defaults to users mode and contributes auth actions", async () => {
40
+ test("auth supabase provider defaults to provider profile mode and contributes auth actions", async () => {
39
41
  const app = createApplication();
40
42
  app.instance("appConfig", createAppConfigFixture());
41
43
  app.instance("jskit.env", {
@@ -69,11 +71,53 @@ test("auth supabase provider defaults to users mode and contributes auth actions
69
71
  });
70
72
 
71
73
  await app.start({
72
- providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
74
+ providers: [
75
+ ActionRuntimeServiceProvider,
76
+ AuthSupabaseServiceProvider,
77
+ AuthProviderServiceProvider,
78
+ AuthActionsServiceProvider
79
+ ]
73
80
  });
74
81
 
75
82
  const authService = app.make("authService");
76
83
  assert.equal(typeof authService?.login, "function");
84
+ assert.deepEqual(authService.getCapabilities(), {
85
+ provider: {
86
+ id: "supabase",
87
+ label: "Supabase"
88
+ },
89
+ features: {
90
+ password: {
91
+ login: true,
92
+ register: true,
93
+ change: true,
94
+ methodToggle: false
95
+ },
96
+ passwordRecovery: {
97
+ request: true,
98
+ complete: true,
99
+ delivery: "smtp"
100
+ },
101
+ otp: {
102
+ login: true
103
+ },
104
+ oauthLogin: {
105
+ enabled: false,
106
+ providers: [],
107
+ defaultProvider: null
108
+ },
109
+ emailConfirmation: true,
110
+ profileUpdate: true,
111
+ providerLinking: {
112
+ start: false,
113
+ unlink: false
114
+ },
115
+ securityStatus: true,
116
+ signOutOtherSessions: true,
117
+ appProfileProjection: false,
118
+ devLoginAs: false
119
+ }
120
+ });
77
121
 
78
122
  const actionExecutor = app.make("actionExecutor");
79
123
  assert.equal(typeof actionExecutor?.execute, "function");
@@ -111,14 +155,22 @@ test("auth supabase provider registers authService in standalone mode without us
111
155
  });
112
156
 
113
157
  await app.start({
114
- providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
158
+ providers: [
159
+ ActionRuntimeServiceProvider,
160
+ AuthSupabaseServiceProvider,
161
+ AuthProviderServiceProvider,
162
+ AuthActionsServiceProvider
163
+ ]
115
164
  });
116
165
 
117
166
  const authService = app.make("authService");
118
167
  assert.equal(typeof authService?.login, "function");
168
+ const status = await authService.getSecurityStatus();
169
+ assert.equal(status.actions.changePassword, true);
170
+ assert.equal(status.actions.setPasswordEnabled, false);
119
171
  });
120
172
 
121
- test("auth supabase provider requires users.profile.sync.service in default users mode", async () => {
173
+ test("auth supabase provider reports password method toggle only with persistent settings repository", async () => {
122
174
  const app = createApplication();
123
175
  app.instance("appConfig", createAppConfigFixture());
124
176
  app.instance("jskit.env", {
@@ -136,12 +188,61 @@ test("auth supabase provider requires users.profile.sync.service in default user
136
188
  app.instance("domainEvents", {
137
189
  async publish() {}
138
190
  });
191
+ app.instance("internal.repository.user-settings", {
192
+ async ensureForUserId() {
193
+ return {
194
+ passwordSignInEnabled: true,
195
+ passwordSetupRequired: false
196
+ };
197
+ },
198
+ async updatePasswordSignInEnabled() {
199
+ return {
200
+ passwordSignInEnabled: true,
201
+ passwordSetupRequired: false
202
+ };
203
+ }
204
+ });
139
205
 
140
206
  await app.start({
141
207
  providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
142
208
  });
143
209
 
144
- assert.throws(() => app.make("authService"), /config\.auth\.profileMode is "users"/);
210
+ const authService = app.make("authService");
211
+ assert.equal(authService.getCapabilities().features.password.methodToggle, true);
212
+ const status = await authService.getSecurityStatus();
213
+ assert.equal(status.actions.setPasswordEnabled, true);
214
+ });
215
+
216
+ test("auth supabase provider requires users.profile.sync.service in explicit users mode", async () => {
217
+ const app = createApplication();
218
+ app.instance("appConfig", createAppConfigFixture({
219
+ auth: {
220
+ profileMode: "users"
221
+ }
222
+ }));
223
+ app.instance("jskit.env", {
224
+ AUTH_SUPABASE_URL: "https://example.supabase.co",
225
+ AUTH_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test_key",
226
+ APP_PUBLIC_URL: "http://localhost:5173",
227
+ NODE_ENV: "test"
228
+ });
229
+ app.instance("jskit.logger", {
230
+ info() {},
231
+ warn() {},
232
+ error() {},
233
+ debug() {}
234
+ });
235
+ app.instance("domainEvents", {
236
+ async publish() {}
237
+ });
238
+
239
+ await assert.rejects(
240
+ () =>
241
+ app.start({
242
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
243
+ }),
244
+ (error) => isBootFailureWithCause(error, /config\.auth\.profileMode is "users"/)
245
+ );
145
246
  });
146
247
 
147
248
  test("auth supabase provider rejects unsupported config.auth.profileMode values", async () => {
@@ -167,11 +268,42 @@ test("auth supabase provider rejects unsupported config.auth.profileMode values"
167
268
  async publish() {}
168
269
  });
169
270
 
170
- await app.start({
171
- providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
271
+ await assert.rejects(
272
+ () =>
273
+ app.start({
274
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
275
+ }),
276
+ (error) => isBootFailureWithCause(error, /Unsupported config\.auth\.profileMode/)
277
+ );
278
+ });
279
+
280
+ test("auth supabase provider rejects AUTH_PROVIDER mismatches", async () => {
281
+ const app = createApplication();
282
+ app.instance("appConfig", createAppConfigFixture());
283
+ app.instance("jskit.env", {
284
+ AUTH_PROVIDER: "local",
285
+ AUTH_SUPABASE_URL: "https://example.supabase.co",
286
+ AUTH_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test_key",
287
+ APP_PUBLIC_URL: "http://localhost:5173",
288
+ NODE_ENV: "test"
289
+ });
290
+ app.instance("jskit.logger", {
291
+ info() {},
292
+ warn() {},
293
+ error() {},
294
+ debug() {}
295
+ });
296
+ app.instance("domainEvents", {
297
+ async publish() {}
172
298
  });
173
299
 
174
- assert.throws(() => app.make("authService"), /Unsupported config\.auth\.profileMode/);
300
+ await assert.rejects(
301
+ () =>
302
+ app.start({
303
+ providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
304
+ }),
305
+ (error) => /AUTH_PROVIDER is "local"/.test(String(error.details?.cause?.message || error.message || ""))
306
+ );
175
307
  });
176
308
 
177
309
  test("auth supabase provider can boot dev auth without Supabase credentials", async () => {
@@ -223,6 +355,12 @@ test("auth supabase provider can boot dev auth without Supabase credentials", as
223
355
  assert.equal(typeof authService?.devLoginAs, "function");
224
356
  assert.equal(typeof authService?.isDevAuthBootstrapEnabled, "function");
225
357
  assert.equal(authService.isDevAuthBootstrapEnabled(), true);
358
+ const capabilities = authService.getCapabilities();
359
+ assert.equal(capabilities.features.password.login, false);
360
+ assert.equal(capabilities.features.passwordRecovery.request, false);
361
+ assert.equal(capabilities.features.otp.login, false);
362
+ assert.equal(capabilities.features.signOutOtherSessions, false);
363
+ assert.equal(capabilities.features.devLoginAs, true);
226
364
 
227
365
  const actionExecutor = app.make("actionExecutor");
228
366
  const definitions = actionExecutor.listDefinitions();
@@ -1,20 +0,0 @@
1
- import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
2
-
3
- function createAuthSessionEventsService() {
4
- async function notifySessionChanged(options = {}) {
5
- const actorId = normalizeRecordId(options?.context?.actor?.id, { fallback: null });
6
- if (!actorId) {
7
- return null;
8
- }
9
-
10
- return {
11
- id: actorId
12
- };
13
- }
14
-
15
- return Object.freeze({
16
- notifySessionChanged
17
- });
18
- }
19
-
20
- export { createAuthSessionEventsService };