@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,868 @@
|
|
|
1
|
+
import { createClient } from "@supabase/supabase-js";
|
|
2
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
3
|
+
import {
|
|
4
|
+
AUTH_METHOD_PASSWORD_ID,
|
|
5
|
+
AUTH_METHOD_PASSWORD_PROVIDER,
|
|
6
|
+
buildOAuthMethodId
|
|
7
|
+
} from "@jskit-ai/auth-core/shared/authMethods";
|
|
8
|
+
import { normalizeEmail } from "@jskit-ai/auth-core/server/utils";
|
|
9
|
+
import { validators } from "@jskit-ai/auth-core/server/validators";
|
|
10
|
+
import {
|
|
11
|
+
isTransientAuthMessage,
|
|
12
|
+
isTransientSupabaseError,
|
|
13
|
+
mapAuthError,
|
|
14
|
+
validationError,
|
|
15
|
+
isUserNotFoundLikeAuthError,
|
|
16
|
+
mapRecoveryError,
|
|
17
|
+
mapPasswordUpdateError,
|
|
18
|
+
mapOtpVerifyError,
|
|
19
|
+
mapProfileUpdateError,
|
|
20
|
+
mapCurrentPasswordError
|
|
21
|
+
} from "./authErrorMappers.js";
|
|
22
|
+
import { displayNameFromEmail, resolveDisplayName, resolveDisplayNameFromClaims } from "./authProfileNames.js";
|
|
23
|
+
import {
|
|
24
|
+
normalizeOAuthProviderInput as normalizeOAuthProviderInputFromCatalog,
|
|
25
|
+
validatePasswordRecoveryPayload,
|
|
26
|
+
parseOAuthCompletePayload as parseOAuthCompletePayloadFromCatalog,
|
|
27
|
+
parseOtpLoginVerifyPayload,
|
|
28
|
+
mapOAuthCallbackError
|
|
29
|
+
} from "./authInputParsers.js";
|
|
30
|
+
import {
|
|
31
|
+
parseHttpUrl,
|
|
32
|
+
buildPasswordResetRedirectUrl,
|
|
33
|
+
buildOtpLoginRedirectUrl,
|
|
34
|
+
normalizeOAuthIntent,
|
|
35
|
+
normalizeReturnToPath,
|
|
36
|
+
buildOAuthRedirectUrl,
|
|
37
|
+
buildOAuthLoginRedirectUrl,
|
|
38
|
+
buildOAuthLinkRedirectUrl
|
|
39
|
+
} from "./authRedirectUrls.js";
|
|
40
|
+
import {
|
|
41
|
+
normalizeIdentityProviderId,
|
|
42
|
+
collectProviderIdsFromSupabaseUser,
|
|
43
|
+
buildAuthMethodsStatusFromProviderIds,
|
|
44
|
+
buildAuthMethodsStatusFromSupabaseUser,
|
|
45
|
+
buildSecurityStatusFromAuthMethodsStatus,
|
|
46
|
+
findAuthMethodById,
|
|
47
|
+
findLinkedIdentityByProvider
|
|
48
|
+
} from "./authMethodStatus.js";
|
|
49
|
+
import { safeRequestCookies, cookieOptions } from "./authCookies.js";
|
|
50
|
+
import { loadJose, isExpiredJwtError, classifyJwtVerifyError } from "./authJwt.js";
|
|
51
|
+
import { buildDisabledPasswordSecret } from "./authSecrets.js";
|
|
52
|
+
import { createAccountFlows } from "./accountFlows.js";
|
|
53
|
+
import { createOauthFlows } from "./oauthFlows.js";
|
|
54
|
+
import { createPasswordSecurityFlows } from "./passwordSecurityFlows.js";
|
|
55
|
+
import { USER_PROFILE_EMAIL_CONFLICT_CODE } from "./standaloneProfileSyncService.js";
|
|
56
|
+
import {
|
|
57
|
+
buildOAuthProviderCatalogResponse,
|
|
58
|
+
resolveOAuthProviderQueryParams,
|
|
59
|
+
resolveSupabaseOAuthProviderCatalog
|
|
60
|
+
} from "./oauthProviderCatalog.js";
|
|
61
|
+
|
|
62
|
+
const ACCESS_TOKEN_COOKIE = "sb_access_token";
|
|
63
|
+
const REFRESH_TOKEN_COOKIE = "sb_refresh_token";
|
|
64
|
+
const DEFAULT_AUDIENCE = "authenticated";
|
|
65
|
+
const DEFAULT_AUTH_PROVIDER_ID = "supabase";
|
|
66
|
+
const AUTH_PROVIDER_ID_PATTERN = /^[a-z][a-z0-9_-]{1,63}$/;
|
|
67
|
+
const PERSISTENT_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 400;
|
|
68
|
+
|
|
69
|
+
function normalizeAuthProviderId(value, { fallback = DEFAULT_AUTH_PROVIDER_ID } = {}) {
|
|
70
|
+
const normalized = String(value || "")
|
|
71
|
+
.trim()
|
|
72
|
+
.toLowerCase();
|
|
73
|
+
if (AUTH_PROVIDER_ID_PATTERN.test(normalized)) {
|
|
74
|
+
return normalized;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fallbackNormalized = String(fallback || "")
|
|
78
|
+
.trim()
|
|
79
|
+
.toLowerCase();
|
|
80
|
+
if (AUTH_PROVIDER_ID_PATTERN.test(fallbackNormalized)) {
|
|
81
|
+
return fallbackNormalized;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return DEFAULT_AUTH_PROVIDER_ID;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeProviderUserId(value) {
|
|
88
|
+
return String(value || "").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createService(options) {
|
|
92
|
+
const authProvider = options.authProvider && typeof options.authProvider === "object" ? options.authProvider : null;
|
|
93
|
+
if (!authProvider) {
|
|
94
|
+
throw new Error("authProvider is required.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const authProviderId = normalizeAuthProviderId(authProvider.id);
|
|
98
|
+
if (authProviderId !== DEFAULT_AUTH_PROVIDER_ID) {
|
|
99
|
+
throw new Error(`Unsupported auth provider "${authProviderId}".`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const supabaseUrl = String(authProvider.supabaseUrl || "").trim();
|
|
103
|
+
const supabasePublishableKey = String(authProvider.supabasePublishableKey || "").trim();
|
|
104
|
+
const userSettingsRepository = options.userSettingsRepository || null;
|
|
105
|
+
const userProfileSyncService = options.userProfileSyncService;
|
|
106
|
+
if (
|
|
107
|
+
!userProfileSyncService ||
|
|
108
|
+
typeof userProfileSyncService.syncIdentityProfile !== "function" ||
|
|
109
|
+
typeof userProfileSyncService.findByIdentity !== "function"
|
|
110
|
+
) {
|
|
111
|
+
throw new Error("userProfileSyncService with syncIdentityProfile() and findByIdentity() is required.");
|
|
112
|
+
}
|
|
113
|
+
const isProduction = options.nodeEnv === "production";
|
|
114
|
+
const jwtAudience = String(authProvider.jwtAudience || DEFAULT_AUDIENCE).trim();
|
|
115
|
+
const settingsProfileAuthInfo = Object.freeze({
|
|
116
|
+
emailManagedBy: normalizeAuthProviderId(authProvider.emailManagedBy || authProviderId, { fallback: authProviderId }),
|
|
117
|
+
emailChangeFlow: normalizeAuthProviderId(authProvider.emailChangeFlow || authProviderId, { fallback: authProviderId })
|
|
118
|
+
});
|
|
119
|
+
const passwordResetRedirectUrl = buildPasswordResetRedirectUrl({
|
|
120
|
+
appPublicUrl: options.appPublicUrl
|
|
121
|
+
});
|
|
122
|
+
const otpLoginRedirectUrl = buildOtpLoginRedirectUrl({
|
|
123
|
+
appPublicUrl: options.appPublicUrl
|
|
124
|
+
});
|
|
125
|
+
const appPublicUrl = String(options.appPublicUrl || "");
|
|
126
|
+
const authAllowedReturnToOrigins = Array.isArray(options.authAllowedReturnToOrigins)
|
|
127
|
+
? options.authAllowedReturnToOrigins
|
|
128
|
+
: [];
|
|
129
|
+
const authOAuthCatalog = resolveSupabaseOAuthProviderCatalog({
|
|
130
|
+
oauthProviderCatalog: authProvider.oauthProviderCatalog || options.authOAuthProviderCatalog,
|
|
131
|
+
oauthProviders: authProvider.oauthProviders ?? options.authOAuthProviders,
|
|
132
|
+
oauthDefaultProvider: authProvider.oauthDefaultProvider ?? options.authOAuthDefaultProvider,
|
|
133
|
+
oauthProviderLabels: authProvider.oauthProviderLabels || options.authOAuthProviderLabels,
|
|
134
|
+
oauthProviderQueryParams: authProvider.oauthProviderQueryParams || options.authOAuthProviderQueryParams
|
|
135
|
+
});
|
|
136
|
+
const authOAuthProviders = authOAuthCatalog.providers;
|
|
137
|
+
const authOAuthProviderIds = authOAuthCatalog.providerIds;
|
|
138
|
+
const authOAuthDefaultProvider = authOAuthCatalog.defaultProvider;
|
|
139
|
+
const authOAuthCatalogResponse = Object.freeze(buildOAuthProviderCatalogResponse(authOAuthCatalog));
|
|
140
|
+
|
|
141
|
+
function normalizeOAuthProviderInput(value) {
|
|
142
|
+
return normalizeOAuthProviderInputFromCatalog(value, {
|
|
143
|
+
providerIds: authOAuthProviderIds,
|
|
144
|
+
defaultProvider: authOAuthDefaultProvider
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseOAuthCompletePayload(payload) {
|
|
149
|
+
return parseOAuthCompletePayloadFromCatalog(payload, {
|
|
150
|
+
providerIds: authOAuthProviderIds,
|
|
151
|
+
defaultProvider: authOAuthDefaultProvider
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildOAuthLoginRedirectUrlWithCatalog(payload) {
|
|
156
|
+
return buildOAuthLoginRedirectUrl({
|
|
157
|
+
...payload,
|
|
158
|
+
providerIds: authOAuthProviderIds
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildOAuthLinkRedirectUrlWithCatalog(payload) {
|
|
163
|
+
return buildOAuthLinkRedirectUrl({
|
|
164
|
+
...payload,
|
|
165
|
+
providerIds: authOAuthProviderIds
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveOAuthProviderQueryParamsForProvider(providerId) {
|
|
170
|
+
return resolveOAuthProviderQueryParams(providerId, {
|
|
171
|
+
providerQueryParamsById: authOAuthCatalog.providerQueryParamsById
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeAuthReturnToTarget(value, { fallback = "/" } = {}) {
|
|
176
|
+
return normalizeReturnToPath(value, {
|
|
177
|
+
fallback,
|
|
178
|
+
allowedOrigins: authAllowedReturnToOrigins
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const issuerUrl = supabaseUrl ? new URL("/auth/v1", supabaseUrl).toString().replace(/\/$/, "") : "";
|
|
183
|
+
const jwksUrl = issuerUrl ? `${issuerUrl}/.well-known/jwks.json` : "";
|
|
184
|
+
|
|
185
|
+
let jwksResolver = null;
|
|
186
|
+
|
|
187
|
+
function ensureConfigured() {
|
|
188
|
+
if (!supabaseUrl || !supabasePublishableKey) {
|
|
189
|
+
throw new AppError(500, "Auth provider is not configured.");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function createSupabaseClient() {
|
|
194
|
+
ensureConfigured();
|
|
195
|
+
return createClient(supabaseUrl, supabasePublishableKey, {
|
|
196
|
+
auth: {
|
|
197
|
+
autoRefreshToken: false,
|
|
198
|
+
persistSession: false
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getSupabaseClient() {
|
|
204
|
+
return createSupabaseClient();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createStatelessSupabaseClient() {
|
|
208
|
+
return createSupabaseClient();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function getJwksResolver() {
|
|
212
|
+
if (jwksResolver) {
|
|
213
|
+
return jwksResolver;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const jose = await loadJose();
|
|
217
|
+
jwksResolver = jose.createRemoteJWKSet(new URL(jwksUrl));
|
|
218
|
+
return jwksResolver;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function verifyAccessToken(accessToken) {
|
|
222
|
+
const jose = await loadJose();
|
|
223
|
+
const jwks = await getJwksResolver();
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const { payload } = await jose.jwtVerify(accessToken, jwks, {
|
|
227
|
+
issuer: issuerUrl,
|
|
228
|
+
audience: jwtAudience,
|
|
229
|
+
clockTolerance: 5
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
status: "valid",
|
|
234
|
+
payload
|
|
235
|
+
};
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return {
|
|
238
|
+
status: classifyJwtVerifyError(error)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function verifyAccessTokenViaSupabase(accessToken) {
|
|
244
|
+
const supabase = getSupabaseClient();
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const response = await supabase.auth.getUser(accessToken);
|
|
248
|
+
if (response.error) {
|
|
249
|
+
if (isTransientSupabaseError(response.error)) {
|
|
250
|
+
return { status: "transient" };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { status: "invalid" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!response.data?.user) {
|
|
257
|
+
return { status: "invalid" };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const profile = await syncProfileFromSupabaseUser(response.data.user, response.data.user.email);
|
|
261
|
+
return {
|
|
262
|
+
status: "valid",
|
|
263
|
+
profile
|
|
264
|
+
};
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (isTransientSupabaseError(error)) {
|
|
267
|
+
return { status: "transient" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { status: "invalid" };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function setSessionFromRequestCookies(request, options = {}) {
|
|
275
|
+
const cookies = safeRequestCookies(request);
|
|
276
|
+
const accessToken = String(cookies[ACCESS_TOKEN_COOKIE] || "").trim();
|
|
277
|
+
const refreshToken = String(cookies[REFRESH_TOKEN_COOKIE] || "").trim();
|
|
278
|
+
|
|
279
|
+
if (!accessToken || !refreshToken) {
|
|
280
|
+
throw new AppError(401, "Authentication required.");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const supabase = options.supabaseClient || getSupabaseClient();
|
|
284
|
+
let sessionResponse;
|
|
285
|
+
try {
|
|
286
|
+
sessionResponse = await supabase.auth.setSession({
|
|
287
|
+
access_token: accessToken,
|
|
288
|
+
refresh_token: refreshToken
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw mapRecoveryError(error);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const session = sessionResponse.data?.session || null;
|
|
295
|
+
const user = sessionResponse.data?.user || null;
|
|
296
|
+
|
|
297
|
+
if (sessionResponse.error || !session || !user) {
|
|
298
|
+
throw mapRecoveryError(sessionResponse.error);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
...sessionResponse,
|
|
303
|
+
data: {
|
|
304
|
+
...sessionResponse.data,
|
|
305
|
+
session,
|
|
306
|
+
user
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function resolveCurrentSupabaseUser(request, options = {}) {
|
|
312
|
+
const supabase = options.supabaseClient || getSupabaseClient();
|
|
313
|
+
const cookies = safeRequestCookies(request);
|
|
314
|
+
const accessToken = String(cookies[ACCESS_TOKEN_COOKIE] || "").trim();
|
|
315
|
+
let user = null;
|
|
316
|
+
let session = null;
|
|
317
|
+
|
|
318
|
+
async function getUserByAccessToken(token) {
|
|
319
|
+
if (!token) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const userResponse = await supabase.auth.getUser(token);
|
|
325
|
+
if (!userResponse.error && userResponse.data?.user) {
|
|
326
|
+
return userResponse.data.user;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (userResponse.error && isTransientSupabaseError(userResponse.error)) {
|
|
330
|
+
throw mapAuthError(userResponse.error, 503);
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (isTransientSupabaseError(error)) {
|
|
334
|
+
throw mapAuthError(error, 503);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (accessToken) {
|
|
342
|
+
user = await getUserByAccessToken(accessToken);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!user) {
|
|
346
|
+
const sessionResponse = await setSessionFromRequestCookies(request, {
|
|
347
|
+
supabaseClient: supabase
|
|
348
|
+
});
|
|
349
|
+
user = sessionResponse.data.user || null;
|
|
350
|
+
session = sessionResponse.data.session || null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const currentAccessToken = String(session?.access_token || accessToken || "").trim();
|
|
354
|
+
const explicitUser = await getUserByAccessToken(currentAccessToken);
|
|
355
|
+
if (explicitUser) {
|
|
356
|
+
user = explicitUser;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!user) {
|
|
360
|
+
throw new AppError(401, "Authentication required.");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
session,
|
|
365
|
+
user
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function writeSessionCookies(reply, session) {
|
|
370
|
+
const accessToken = String(session?.access_token || "");
|
|
371
|
+
const refreshToken = String(session?.refresh_token || "");
|
|
372
|
+
if (!accessToken || !refreshToken) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const sessionAccessMaxAge = Number.isFinite(Number(session?.expires_in)) ? Number(session.expires_in) : 3600;
|
|
377
|
+
const persistentCookieMaxAge = Math.max(Math.floor(sessionAccessMaxAge), PERSISTENT_SESSION_COOKIE_MAX_AGE_SECONDS);
|
|
378
|
+
|
|
379
|
+
reply.setCookie(ACCESS_TOKEN_COOKIE, accessToken, cookieOptions(isProduction, persistentCookieMaxAge));
|
|
380
|
+
reply.setCookie(REFRESH_TOKEN_COOKIE, refreshToken, cookieOptions(isProduction, persistentCookieMaxAge));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function clearSessionCookies(reply) {
|
|
384
|
+
const clearOptions = cookieOptions(isProduction, 0);
|
|
385
|
+
reply.clearCookie(ACCESS_TOKEN_COOKIE, clearOptions);
|
|
386
|
+
reply.clearCookie(REFRESH_TOKEN_COOKIE, clearOptions);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function requireSynchronizedProfile(profile) {
|
|
390
|
+
if (profile && Number.isFinite(Number(profile.id)) && String(profile.displayName || "").trim()) {
|
|
391
|
+
return profile;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
throw new AppError(500, "Authentication profile synchronization failed. Please retry.");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildNormalizedIdentityKey(identityLike) {
|
|
398
|
+
const source = identityLike && typeof identityLike === "object" ? identityLike : {};
|
|
399
|
+
const authProvider = normalizeAuthProviderId(source.authProvider || authProviderId, {
|
|
400
|
+
fallback: authProviderId
|
|
401
|
+
});
|
|
402
|
+
const authProviderUserId = normalizeProviderUserId(source.authProviderUserId);
|
|
403
|
+
|
|
404
|
+
if (!authProviderUserId) {
|
|
405
|
+
throw new TypeError("Profile identity is missing required fields.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
authProvider,
|
|
410
|
+
authProviderUserId
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function buildNormalizedIdentityProfile(profileLike) {
|
|
415
|
+
const source = profileLike && typeof profileLike === "object" ? profileLike : {};
|
|
416
|
+
const identity = buildNormalizedIdentityKey(source);
|
|
417
|
+
const email = normalizeEmail(source.email || "");
|
|
418
|
+
const displayName = String(source.displayName || "").trim();
|
|
419
|
+
|
|
420
|
+
if (!email || !displayName) {
|
|
421
|
+
throw new TypeError("Profile identity is missing required fields.");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
authProvider: identity.authProvider,
|
|
426
|
+
authProviderUserId: identity.authProviderUserId,
|
|
427
|
+
email,
|
|
428
|
+
displayName
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function findProfileByIdentity(identityProfile, options = {}) {
|
|
433
|
+
const normalized = buildNormalizedIdentityKey(identityProfile);
|
|
434
|
+
return userProfileSyncService.findByIdentity(
|
|
435
|
+
{
|
|
436
|
+
authProvider: normalized.authProvider,
|
|
437
|
+
authProviderUserId: normalized.authProviderUserId
|
|
438
|
+
},
|
|
439
|
+
options
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getSettingsProfileAuthInfo() {
|
|
444
|
+
return {
|
|
445
|
+
emailManagedBy: settingsProfileAuthInfo.emailManagedBy,
|
|
446
|
+
emailChangeFlow: settingsProfileAuthInfo.emailChangeFlow
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function syncProfileMirror(nextProfile) {
|
|
451
|
+
try {
|
|
452
|
+
const normalized = buildNormalizedIdentityProfile(nextProfile);
|
|
453
|
+
const synchronizedProfile = await userProfileSyncService.syncIdentityProfile(normalized);
|
|
454
|
+
return requireSynchronizedProfile(synchronizedProfile);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (String(error?.code || "") === USER_PROFILE_EMAIL_CONFLICT_CODE) {
|
|
457
|
+
throw new AppError(
|
|
458
|
+
409,
|
|
459
|
+
"This email is already registered with another sign-in method. Sign in with that method, then link this provider in Settings > Security."
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function syncProfileFromSupabaseUser(supabaseUser, fallbackEmail) {
|
|
468
|
+
const supabaseUserId = normalizeProviderUserId(supabaseUser?.id);
|
|
469
|
+
const email = normalizeEmail(supabaseUser?.email || fallbackEmail);
|
|
470
|
+
|
|
471
|
+
if (!supabaseUserId || !email) {
|
|
472
|
+
throw new AppError(500, "Auth provider user payload is missing required fields.");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return syncProfileMirror({
|
|
476
|
+
authProvider: authProviderId,
|
|
477
|
+
authProviderUserId: supabaseUserId,
|
|
478
|
+
email,
|
|
479
|
+
displayName: resolveDisplayName(supabaseUser, email)
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function syncProfileFromJwtClaims(claims) {
|
|
484
|
+
const supabaseUserId = normalizeProviderUserId(claims?.sub);
|
|
485
|
+
if (!supabaseUserId) {
|
|
486
|
+
throw new AppError(401, "Token is missing subject claim.");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const existing = await findProfileByIdentity({
|
|
490
|
+
authProvider: authProviderId,
|
|
491
|
+
authProviderUserId: supabaseUserId
|
|
492
|
+
});
|
|
493
|
+
const emailFromToken = normalizeEmail(claims?.email || "");
|
|
494
|
+
|
|
495
|
+
if (!emailFromToken) {
|
|
496
|
+
if (existing) {
|
|
497
|
+
return existing;
|
|
498
|
+
}
|
|
499
|
+
throw new AppError(401, "Token is missing email claim.");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return syncProfileMirror({
|
|
503
|
+
authProvider: authProviderId,
|
|
504
|
+
authProviderUserId: supabaseUserId,
|
|
505
|
+
email: emailFromToken,
|
|
506
|
+
displayName: resolveDisplayNameFromClaims(claims, emailFromToken)
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function resolvePasswordSignInPolicyForUserId(userId) {
|
|
511
|
+
if (!userSettingsRepository || typeof userSettingsRepository.ensureForUserId !== "function") {
|
|
512
|
+
return {
|
|
513
|
+
passwordSignInEnabled: true,
|
|
514
|
+
passwordSetupRequired: false
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const settings = await userSettingsRepository.ensureForUserId(userId);
|
|
519
|
+
return {
|
|
520
|
+
passwordSignInEnabled: settings?.passwordSignInEnabled !== false,
|
|
521
|
+
passwordSetupRequired: settings?.passwordSetupRequired === true
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function setPasswordSignInEnabledForUserId(userId, enabled, options = {}) {
|
|
526
|
+
if (!userSettingsRepository || typeof userSettingsRepository.updatePasswordSignInEnabled !== "function") {
|
|
527
|
+
throw new AppError(500, "Password sign-in settings repository is not configured.");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const updated = await userSettingsRepository.updatePasswordSignInEnabled(userId, enabled, options);
|
|
531
|
+
return {
|
|
532
|
+
passwordSignInEnabled: updated.passwordSignInEnabled !== false,
|
|
533
|
+
passwordSetupRequired: updated.passwordSetupRequired === true
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function setPasswordSetupRequiredForUserId(userId, required) {
|
|
538
|
+
if (!userSettingsRepository || typeof userSettingsRepository.updatePasswordSetupRequired !== "function") {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
await userSettingsRepository.updatePasswordSetupRequired(userId, required);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function resolveCurrentAuthContext(request, options = {}) {
|
|
546
|
+
const current = await resolveCurrentSupabaseUser(request, options);
|
|
547
|
+
const profile = await syncProfileFromSupabaseUser(current.user, current.user?.email || "");
|
|
548
|
+
const passwordSignInPolicy = await resolvePasswordSignInPolicyForUserId(profile.id);
|
|
549
|
+
const authMethodsStatus = buildAuthMethodsStatusFromSupabaseUser(current.user, {
|
|
550
|
+
...passwordSignInPolicy,
|
|
551
|
+
oauthProviders: authOAuthProviders
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
...current,
|
|
556
|
+
profile,
|
|
557
|
+
passwordSignInEnabled: passwordSignInPolicy.passwordSignInEnabled,
|
|
558
|
+
passwordSetupRequired: passwordSignInPolicy.passwordSetupRequired,
|
|
559
|
+
authMethodsStatus
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const { register, login, requestOtpLogin, verifyOtpLogin, updateDisplayName } = createAccountFlows({
|
|
564
|
+
ensureConfigured,
|
|
565
|
+
validators,
|
|
566
|
+
validationError,
|
|
567
|
+
getSupabaseClient,
|
|
568
|
+
displayNameFromEmail,
|
|
569
|
+
mapAuthError,
|
|
570
|
+
syncProfileFromSupabaseUser,
|
|
571
|
+
resolvePasswordSignInPolicyForUserId,
|
|
572
|
+
otpLoginRedirectUrl,
|
|
573
|
+
buildOtpLoginRedirectUrl,
|
|
574
|
+
appPublicUrl,
|
|
575
|
+
isTransientSupabaseError,
|
|
576
|
+
isUserNotFoundLikeAuthError,
|
|
577
|
+
parseOtpLoginVerifyPayload,
|
|
578
|
+
mapOtpVerifyError,
|
|
579
|
+
setSessionFromRequestCookies,
|
|
580
|
+
mapProfileUpdateError,
|
|
581
|
+
normalizeReturnToPath: normalizeAuthReturnToTarget
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const { oauthStart, startProviderLink, oauthComplete, unlinkProvider } = createOauthFlows({
|
|
585
|
+
ensureConfigured,
|
|
586
|
+
normalizeOAuthProviderInput,
|
|
587
|
+
normalizeReturnToPath: normalizeAuthReturnToTarget,
|
|
588
|
+
buildOAuthLoginRedirectUrl: buildOAuthLoginRedirectUrlWithCatalog,
|
|
589
|
+
appPublicUrl,
|
|
590
|
+
authOAuthDefaultProvider,
|
|
591
|
+
resolveOAuthProviderQueryParams: resolveOAuthProviderQueryParamsForProvider,
|
|
592
|
+
getSupabaseClient,
|
|
593
|
+
mapAuthError,
|
|
594
|
+
setSessionFromRequestCookies,
|
|
595
|
+
buildOAuthLinkRedirectUrl: buildOAuthLinkRedirectUrlWithCatalog,
|
|
596
|
+
parseOAuthCompletePayload,
|
|
597
|
+
validationError,
|
|
598
|
+
mapOAuthCallbackError,
|
|
599
|
+
mapRecoveryError,
|
|
600
|
+
syncProfileFromSupabaseUser,
|
|
601
|
+
resolveCurrentAuthContext,
|
|
602
|
+
buildOAuthMethodId,
|
|
603
|
+
findAuthMethodById,
|
|
604
|
+
findLinkedIdentityByProvider,
|
|
605
|
+
buildSecurityStatusFromAuthMethodsStatus
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const {
|
|
609
|
+
requestPasswordReset,
|
|
610
|
+
completePasswordRecovery,
|
|
611
|
+
resetPassword,
|
|
612
|
+
changePassword,
|
|
613
|
+
setPasswordSignInEnabled,
|
|
614
|
+
signOutOtherSessions,
|
|
615
|
+
getSecurityStatus
|
|
616
|
+
} = createPasswordSecurityFlows({
|
|
617
|
+
ensureConfigured,
|
|
618
|
+
validators,
|
|
619
|
+
validationError,
|
|
620
|
+
getSupabaseClient,
|
|
621
|
+
passwordResetRedirectUrl,
|
|
622
|
+
mapAuthError,
|
|
623
|
+
validatePasswordRecoveryPayload,
|
|
624
|
+
mapRecoveryError,
|
|
625
|
+
syncProfileFromSupabaseUser,
|
|
626
|
+
setSessionFromRequestCookies,
|
|
627
|
+
resolvePasswordSignInPolicyForUserId,
|
|
628
|
+
mapPasswordUpdateError,
|
|
629
|
+
setPasswordSetupRequiredForUserId,
|
|
630
|
+
normalizeEmail,
|
|
631
|
+
createStatelessSupabaseClient,
|
|
632
|
+
mapCurrentPasswordError,
|
|
633
|
+
resolveCurrentAuthContext,
|
|
634
|
+
findAuthMethodById,
|
|
635
|
+
authMethodPasswordId: AUTH_METHOD_PASSWORD_ID,
|
|
636
|
+
buildDisabledPasswordSecret,
|
|
637
|
+
setPasswordSignInEnabledForUserId,
|
|
638
|
+
buildAuthMethodsStatusFromSupabaseUser: (user, statusOptions = {}) =>
|
|
639
|
+
buildAuthMethodsStatusFromSupabaseUser(user, {
|
|
640
|
+
...statusOptions,
|
|
641
|
+
oauthProviders: authOAuthProviders
|
|
642
|
+
}),
|
|
643
|
+
buildSecurityStatusFromAuthMethodsStatus,
|
|
644
|
+
authMethodPasswordProvider: AUTH_METHOD_PASSWORD_PROVIDER,
|
|
645
|
+
buildAuthMethodsStatusFromProviderIds: (providerIds, statusOptions = {}) =>
|
|
646
|
+
buildAuthMethodsStatusFromProviderIds(providerIds, {
|
|
647
|
+
...statusOptions,
|
|
648
|
+
oauthProviders: authOAuthProviders
|
|
649
|
+
})
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
async function authenticateRequest(request) {
|
|
653
|
+
ensureConfigured();
|
|
654
|
+
|
|
655
|
+
const cookies = safeRequestCookies(request);
|
|
656
|
+
const accessToken = String(cookies[ACCESS_TOKEN_COOKIE] || "");
|
|
657
|
+
const refreshToken = String(cookies[REFRESH_TOKEN_COOKIE] || "");
|
|
658
|
+
|
|
659
|
+
if (!accessToken && !refreshToken) {
|
|
660
|
+
return {
|
|
661
|
+
authenticated: false,
|
|
662
|
+
clearSession: false,
|
|
663
|
+
session: null,
|
|
664
|
+
transientFailure: false
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (accessToken) {
|
|
669
|
+
const verification = await verifyAccessToken(accessToken);
|
|
670
|
+
|
|
671
|
+
if (verification.status === "valid") {
|
|
672
|
+
const profile = await syncProfileFromJwtClaims(verification.payload);
|
|
673
|
+
return {
|
|
674
|
+
authenticated: true,
|
|
675
|
+
profile,
|
|
676
|
+
clearSession: false,
|
|
677
|
+
session: null,
|
|
678
|
+
transientFailure: false
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (verification.status === "transient") {
|
|
683
|
+
return {
|
|
684
|
+
authenticated: false,
|
|
685
|
+
clearSession: false,
|
|
686
|
+
session: null,
|
|
687
|
+
transientFailure: true
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (verification.status === "invalid") {
|
|
692
|
+
const supabaseVerification = await verifyAccessTokenViaSupabase(accessToken);
|
|
693
|
+
if (supabaseVerification.status === "valid") {
|
|
694
|
+
return {
|
|
695
|
+
authenticated: true,
|
|
696
|
+
profile: supabaseVerification.profile,
|
|
697
|
+
clearSession: false,
|
|
698
|
+
session: null,
|
|
699
|
+
transientFailure: false
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (supabaseVerification.status === "transient") {
|
|
704
|
+
return {
|
|
705
|
+
authenticated: false,
|
|
706
|
+
clearSession: false,
|
|
707
|
+
session: null,
|
|
708
|
+
transientFailure: true
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!refreshToken) {
|
|
715
|
+
return {
|
|
716
|
+
authenticated: false,
|
|
717
|
+
clearSession: true,
|
|
718
|
+
session: null,
|
|
719
|
+
transientFailure: false
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const supabase = getSupabaseClient();
|
|
724
|
+
let refreshResponse;
|
|
725
|
+
try {
|
|
726
|
+
refreshResponse = await supabase.auth.refreshSession({ refresh_token: refreshToken });
|
|
727
|
+
/* c8 ignore next 17 -- defensive: refreshSession usually resolves with { error } for auth/transport issues. */
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (isTransientSupabaseError(error)) {
|
|
730
|
+
return {
|
|
731
|
+
authenticated: false,
|
|
732
|
+
clearSession: false,
|
|
733
|
+
session: null,
|
|
734
|
+
transientFailure: true
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
authenticated: false,
|
|
740
|
+
clearSession: true,
|
|
741
|
+
session: null,
|
|
742
|
+
transientFailure: false
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (refreshResponse.error) {
|
|
747
|
+
if (isTransientSupabaseError(refreshResponse.error)) {
|
|
748
|
+
return {
|
|
749
|
+
authenticated: false,
|
|
750
|
+
clearSession: false,
|
|
751
|
+
session: null,
|
|
752
|
+
transientFailure: true
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
authenticated: false,
|
|
758
|
+
clearSession: true,
|
|
759
|
+
session: null,
|
|
760
|
+
transientFailure: false
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (!refreshResponse.data?.session || !refreshResponse.data?.user) {
|
|
765
|
+
return {
|
|
766
|
+
authenticated: false,
|
|
767
|
+
clearSession: true,
|
|
768
|
+
session: null,
|
|
769
|
+
transientFailure: false
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const profile = await syncProfileFromSupabaseUser(refreshResponse.data.user, refreshResponse.data.user.email);
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
authenticated: true,
|
|
777
|
+
profile,
|
|
778
|
+
clearSession: false,
|
|
779
|
+
session: refreshResponse.data.session,
|
|
780
|
+
transientFailure: false
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function hasAccessTokenCookie(request) {
|
|
785
|
+
const cookies = safeRequestCookies(request);
|
|
786
|
+
return Boolean(cookies[ACCESS_TOKEN_COOKIE]);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function hasSessionCookie(request) {
|
|
790
|
+
const cookies = safeRequestCookies(request);
|
|
791
|
+
return Boolean(cookies[ACCESS_TOKEN_COOKIE] || cookies[REFRESH_TOKEN_COOKIE]);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function getOAuthProviderCatalog() {
|
|
795
|
+
return authOAuthCatalogResponse;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return {
|
|
799
|
+
register,
|
|
800
|
+
login,
|
|
801
|
+
requestOtpLogin,
|
|
802
|
+
verifyOtpLogin,
|
|
803
|
+
oauthStart,
|
|
804
|
+
oauthComplete,
|
|
805
|
+
startProviderLink,
|
|
806
|
+
requestPasswordReset,
|
|
807
|
+
completePasswordRecovery,
|
|
808
|
+
resetPassword,
|
|
809
|
+
updateDisplayName,
|
|
810
|
+
changePassword,
|
|
811
|
+
setPasswordSignInEnabled,
|
|
812
|
+
unlinkProvider,
|
|
813
|
+
signOutOtherSessions,
|
|
814
|
+
getSecurityStatus,
|
|
815
|
+
getSettingsProfileAuthInfo,
|
|
816
|
+
getOAuthProviderCatalog,
|
|
817
|
+
authenticateRequest,
|
|
818
|
+
hasAccessTokenCookie,
|
|
819
|
+
hasSessionCookie,
|
|
820
|
+
writeSessionCookies,
|
|
821
|
+
clearSessionCookies
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const __testables = {
|
|
826
|
+
validatePasswordRecoveryPayload,
|
|
827
|
+
displayNameFromEmail,
|
|
828
|
+
resolveDisplayName,
|
|
829
|
+
resolveDisplayNameFromClaims,
|
|
830
|
+
isTransientAuthMessage,
|
|
831
|
+
isTransientSupabaseError,
|
|
832
|
+
mapAuthError,
|
|
833
|
+
validationError,
|
|
834
|
+
isUserNotFoundLikeAuthError,
|
|
835
|
+
mapRecoveryError,
|
|
836
|
+
mapPasswordUpdateError,
|
|
837
|
+
mapOtpVerifyError,
|
|
838
|
+
mapProfileUpdateError,
|
|
839
|
+
mapCurrentPasswordError,
|
|
840
|
+
parseHttpUrl,
|
|
841
|
+
buildPasswordResetRedirectUrl,
|
|
842
|
+
buildOtpLoginRedirectUrl,
|
|
843
|
+
normalizeOAuthIntent,
|
|
844
|
+
normalizeReturnToPath,
|
|
845
|
+
buildOAuthRedirectUrl,
|
|
846
|
+
buildOAuthLoginRedirectUrl,
|
|
847
|
+
buildOAuthLinkRedirectUrl,
|
|
848
|
+
normalizeOAuthProviderInput: normalizeOAuthProviderInputFromCatalog,
|
|
849
|
+
parseOAuthCompletePayload: parseOAuthCompletePayloadFromCatalog,
|
|
850
|
+
parseOtpLoginVerifyPayload,
|
|
851
|
+
mapOAuthCallbackError,
|
|
852
|
+
resolveSupabaseOAuthProviderCatalog,
|
|
853
|
+
resolveOAuthProviderQueryParams,
|
|
854
|
+
buildOAuthProviderCatalogResponse,
|
|
855
|
+
normalizeIdentityProviderId,
|
|
856
|
+
collectProviderIdsFromSupabaseUser,
|
|
857
|
+
buildAuthMethodsStatusFromProviderIds,
|
|
858
|
+
buildAuthMethodsStatusFromSupabaseUser,
|
|
859
|
+
buildSecurityStatusFromAuthMethodsStatus,
|
|
860
|
+
findAuthMethodById,
|
|
861
|
+
findLinkedIdentityByProvider,
|
|
862
|
+
safeRequestCookies,
|
|
863
|
+
cookieOptions,
|
|
864
|
+
isExpiredJwtError,
|
|
865
|
+
classifyJwtVerifyError
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
export { createService, __testables };
|