@jskit-ai/auth-provider-supabase-core 0.1.4

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.
@@ -0,0 +1,306 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+
3
+ function createPasswordSecurityFlows(deps) {
4
+ const {
5
+ ensureConfigured,
6
+ validators,
7
+ validationError,
8
+ getSupabaseClient,
9
+ passwordResetRedirectUrl,
10
+ mapAuthError,
11
+ validatePasswordRecoveryPayload,
12
+ mapRecoveryError,
13
+ syncProfileFromSupabaseUser,
14
+ setSessionFromRequestCookies,
15
+ resolvePasswordSignInPolicyForUserId,
16
+ mapPasswordUpdateError,
17
+ setPasswordSetupRequiredForUserId,
18
+ normalizeEmail,
19
+ createStatelessSupabaseClient,
20
+ mapCurrentPasswordError,
21
+ resolveCurrentAuthContext,
22
+ findAuthMethodById,
23
+ authMethodPasswordId,
24
+ buildDisabledPasswordSecret,
25
+ setPasswordSignInEnabledForUserId,
26
+ buildAuthMethodsStatusFromSupabaseUser,
27
+ buildSecurityStatusFromAuthMethodsStatus,
28
+ authMethodPasswordProvider,
29
+ buildAuthMethodsStatusFromProviderIds
30
+ } = deps;
31
+
32
+ async function requestPasswordReset(payload) {
33
+ ensureConfigured();
34
+
35
+ const parsed = validators.forgotPasswordInput(payload);
36
+ if (Object.keys(parsed.fieldErrors).length > 0) {
37
+ throw validationError(parsed.fieldErrors);
38
+ }
39
+
40
+ const supabase = getSupabaseClient();
41
+ const options = { redirectTo: passwordResetRedirectUrl };
42
+ let response;
43
+ try {
44
+ response = await supabase.auth.resetPasswordForEmail(parsed.email, options);
45
+ /* c8 ignore next 4 -- supabase-js returns auth/transport failures as response.error;
46
+ * this catch exists only for unexpected non-Auth throws from SDK/runtime internals. */
47
+ } catch (error) {
48
+ throw mapAuthError(error, 500);
49
+ }
50
+
51
+ if (response.error) {
52
+ throw mapAuthError(response.error, 400);
53
+ }
54
+
55
+ return {
56
+ ok: true,
57
+ message: "If an account exists for that email, a password reset link has been sent."
58
+ };
59
+ }
60
+
61
+ async function completePasswordRecovery(payload) {
62
+ ensureConfigured();
63
+
64
+ const parsed = validatePasswordRecoveryPayload(payload);
65
+ if (Object.keys(parsed.fieldErrors).length > 0) {
66
+ throw validationError(parsed.fieldErrors);
67
+ }
68
+
69
+ const supabase = getSupabaseClient();
70
+ let response;
71
+ try {
72
+ if (parsed.hasCode) {
73
+ response = await supabase.auth.exchangeCodeForSession(parsed.code);
74
+ } else if (parsed.hasTokenHash) {
75
+ response = await supabase.auth.verifyOtp({
76
+ type: "recovery",
77
+ token_hash: parsed.tokenHash
78
+ });
79
+ } else {
80
+ response = await supabase.auth.setSession({
81
+ access_token: parsed.accessToken,
82
+ refresh_token: parsed.refreshToken
83
+ });
84
+ }
85
+ /* c8 ignore next 3 -- defensive: supabase-js usually surfaces failures via response.error. */
86
+ } catch (error) {
87
+ throw mapRecoveryError(error);
88
+ }
89
+
90
+ if (response.error) {
91
+ throw mapRecoveryError(response.error);
92
+ }
93
+
94
+ /* c8 ignore next 3 -- defensive against malformed SDK responses without explicit error payload. */
95
+ if (!response.data?.session || !response.data?.user) {
96
+ throw new AppError(401, "Recovery link is invalid or has expired.");
97
+ }
98
+
99
+ const profile = await syncProfileFromSupabaseUser(response.data.user, response.data.user.email);
100
+
101
+ return {
102
+ profile,
103
+ session: response.data.session
104
+ };
105
+ }
106
+
107
+ async function resetPassword(request, payload) {
108
+ ensureConfigured();
109
+
110
+ const parsed = validators.resetPasswordInput(payload);
111
+ if (Object.keys(parsed.fieldErrors).length > 0) {
112
+ throw validationError(parsed.fieldErrors);
113
+ }
114
+
115
+ const supabase = getSupabaseClient();
116
+ const sessionResponse = await setSessionFromRequestCookies(request, {
117
+ supabaseClient: supabase
118
+ });
119
+ const profile = await syncProfileFromSupabaseUser(
120
+ sessionResponse.data.user,
121
+ sessionResponse.data.user?.email || ""
122
+ );
123
+ const passwordSignInPolicy = await resolvePasswordSignInPolicyForUserId(profile.id);
124
+ if (!passwordSignInPolicy.passwordSignInEnabled) {
125
+ throw new AppError(409, "Password sign-in is disabled for this account.");
126
+ }
127
+
128
+ let updateResponse;
129
+ try {
130
+ updateResponse = await supabase.auth.updateUser({
131
+ password: parsed.password
132
+ });
133
+ } catch (error) {
134
+ throw mapPasswordUpdateError(error);
135
+ }
136
+
137
+ if (updateResponse.error || !updateResponse.data?.user) {
138
+ throw mapPasswordUpdateError(updateResponse.error);
139
+ }
140
+
141
+ const updatedProfile = await syncProfileFromSupabaseUser(updateResponse.data.user, updateResponse.data.user.email);
142
+ await setPasswordSetupRequiredForUserId(updatedProfile.id, false);
143
+ }
144
+
145
+ async function changePassword(request, payload) {
146
+ ensureConfigured();
147
+
148
+ const currentPassword = String(payload?.currentPassword || "");
149
+ const newPassword = String(payload?.newPassword || "");
150
+ const requireCurrentPassword = payload?.requireCurrentPassword !== false;
151
+ const supabase = getSupabaseClient();
152
+ const sessionResponse = await setSessionFromRequestCookies(request, {
153
+ supabaseClient: supabase
154
+ });
155
+
156
+ const email = normalizeEmail(sessionResponse.data.user?.email || "");
157
+ if (!email) {
158
+ throw new AppError(500, "Authenticated user email could not be resolved.");
159
+ }
160
+
161
+ if (requireCurrentPassword) {
162
+ const verificationClient = createStatelessSupabaseClient();
163
+ let verifyResponse;
164
+ try {
165
+ verifyResponse = await verificationClient.auth.signInWithPassword({
166
+ email,
167
+ password: currentPassword
168
+ });
169
+ } catch (error) {
170
+ throw mapCurrentPasswordError(error);
171
+ }
172
+
173
+ if (verifyResponse.error || !verifyResponse.data?.session) {
174
+ throw mapCurrentPasswordError(verifyResponse.error);
175
+ }
176
+ }
177
+
178
+ let updateResponse;
179
+ try {
180
+ updateResponse = await supabase.auth.updateUser({
181
+ password: newPassword
182
+ });
183
+ } catch (error) {
184
+ throw mapPasswordUpdateError(error);
185
+ }
186
+
187
+ if (updateResponse.error || !updateResponse.data?.user) {
188
+ throw mapPasswordUpdateError(updateResponse.error);
189
+ }
190
+
191
+ const profile = await syncProfileFromSupabaseUser(updateResponse.data.user, updateResponse.data.user.email);
192
+ await setPasswordSetupRequiredForUserId(profile.id, false);
193
+
194
+ return {
195
+ profile,
196
+ session: sessionResponse.data.session
197
+ };
198
+ }
199
+
200
+ async function setPasswordSignInEnabled(request, payload = {}) {
201
+ ensureConfigured();
202
+
203
+ if (typeof payload?.enabled !== "boolean") {
204
+ throw validationError({
205
+ enabled: "Enabled must be a boolean."
206
+ });
207
+ }
208
+
209
+ const supabase = getSupabaseClient();
210
+ await setSessionFromRequestCookies(request, {
211
+ supabaseClient: supabase
212
+ });
213
+ const current = await resolveCurrentAuthContext(request, {
214
+ supabaseClient: supabase
215
+ });
216
+ const passwordMethod = findAuthMethodById(current.authMethodsStatus, authMethodPasswordId);
217
+
218
+ if (!passwordMethod) {
219
+ throw new AppError(500, "Password method configuration could not be resolved.");
220
+ }
221
+
222
+ if (payload.enabled && !passwordMethod.configured) {
223
+ throw validationError({
224
+ enabled: "Set a password before enabling password sign-in."
225
+ });
226
+ }
227
+
228
+ if (!payload.enabled && !passwordMethod.canDisable) {
229
+ throw new AppError(409, "At least one sign-in method must remain enabled.");
230
+ }
231
+
232
+ if (!payload.enabled && passwordMethod.configured) {
233
+ let updateResponse = null;
234
+ try {
235
+ updateResponse = await supabase.auth.updateUser({
236
+ // Supabase does not support null password removal; rotate to high-entropy unknown secret.
237
+ password: buildDisabledPasswordSecret()
238
+ });
239
+ } catch {
240
+ // Some Supabase projects require re-authenticated password updates.
241
+ // Treat secret rotation as best-effort and still disable app-level password sign-in.
242
+ updateResponse = null;
243
+ }
244
+
245
+ if (!updateResponse || updateResponse.error || !updateResponse.data?.user) {
246
+ updateResponse = null;
247
+ }
248
+
249
+ if (updateResponse?.data?.user) {
250
+ await syncProfileFromSupabaseUser(updateResponse.data.user, updateResponse.data.user.email);
251
+ }
252
+ }
253
+
254
+ const passwordSignInOptions = !payload.enabled && passwordMethod.configured ? { passwordSetupRequired: true } : {};
255
+ const nextPasswordSignInPolicy = await setPasswordSignInEnabledForUserId(
256
+ current.profile.id,
257
+ payload.enabled,
258
+ passwordSignInOptions
259
+ );
260
+ const nextAuthMethodsStatus = buildAuthMethodsStatusFromSupabaseUser(current.user, nextPasswordSignInPolicy);
261
+
262
+ return {
263
+ securityStatus: buildSecurityStatusFromAuthMethodsStatus(nextAuthMethodsStatus)
264
+ };
265
+ }
266
+
267
+ async function signOutOtherSessions(request) {
268
+ ensureConfigured();
269
+ const supabase = getSupabaseClient();
270
+ await setSessionFromRequestCookies(request, {
271
+ supabaseClient: supabase
272
+ });
273
+ const response = await supabase.auth.signOut({
274
+ scope: "others"
275
+ });
276
+
277
+ if (response.error) {
278
+ throw mapAuthError(response.error, Number(response.error?.status || 400));
279
+ }
280
+ }
281
+
282
+ async function getSecurityStatus(request) {
283
+ if (!request) {
284
+ const authMethodsStatus = buildAuthMethodsStatusFromProviderIds([authMethodPasswordProvider], {
285
+ passwordSignInEnabled: true,
286
+ passwordSetupRequired: false
287
+ });
288
+ return buildSecurityStatusFromAuthMethodsStatus(authMethodsStatus);
289
+ }
290
+
291
+ const current = await resolveCurrentAuthContext(request);
292
+ return buildSecurityStatusFromAuthMethodsStatus(current.authMethodsStatus);
293
+ }
294
+
295
+ return {
296
+ requestPasswordReset,
297
+ completePasswordRecovery,
298
+ resetPassword,
299
+ changePassword,
300
+ setPasswordSignInEnabled,
301
+ signOutOtherSessions,
302
+ getSecurityStatus
303
+ };
304
+ }
305
+
306
+ export { createPasswordSecurityFlows };