@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.
- package/package.descriptor.mjs +138 -0
- package/package.json +20 -0
- package/src/client/index.js +1 -0
- package/src/server/lib/accountFlows.js +276 -0
- package/src/server/lib/actions/auth.contributor.js +225 -0
- package/src/server/lib/authCookies.js +24 -0
- package/src/server/lib/authErrorMappers.js +194 -0
- package/src/server/lib/authInputParsers.js +217 -0
- package/src/server/lib/authJwt.js +49 -0
- package/src/server/lib/authMethodStatus.js +233 -0
- package/src/server/lib/authProfileNames.js +24 -0
- package/src/server/lib/authRedirectUrls.js +135 -0
- package/src/server/lib/authSecrets.js +9 -0
- package/src/server/lib/authSessionEventsService.js +20 -0
- package/src/server/lib/index.js +2 -0
- package/src/server/lib/oauthFlows.js +201 -0
- package/src/server/lib/oauthProviderCatalog.js +272 -0
- package/src/server/lib/passwordSecurityFlows.js +306 -0
- package/src/server/lib/service.js +868 -0
- package/src/server/lib/standaloneProfileSyncService.js +92 -0
- package/src/server/lib/test-utils.js +47 -0
- package/src/server/providers/AuthProviderServiceProvider.js +9 -0
- package/src/server/providers/AuthSupabaseServiceProvider.js +217 -0
- package/test/auth.provider.supabase.test.js +7 -0
- package/test/authRedirectUrls.test.js +47 -0
- package/test/entrypoints.boundary.test.js +20 -0
- package/test/index.test.js +7 -0
- package/test/providerRuntime.test.js +156 -0
|
@@ -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 };
|