@jskit-ai/auth-provider-supabase-core 0.1.9 → 0.1.11
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 +13 -3
- package/package.json +3 -3
- package/src/server/lib/accountFlows.js +82 -27
- package/src/server/lib/actions/auth.contributor.js +28 -9
- package/src/server/lib/authErrorMappers.js +10 -1
- package/src/server/lib/authInputParsers.js +4 -1
- package/src/server/lib/authRedirectUrls.js +14 -22
- package/src/server/lib/flowGuards.js +58 -0
- package/src/server/lib/oauthProviderCatalog.js +1 -1
- package/src/server/lib/passwordSecurityFlows.js +17 -27
- package/src/server/lib/service.js +2 -1
- package/src/server/providers/AuthSupabaseServiceProvider.js +38 -4
- package/test/authErrorMappers.test.js +29 -0
- package/test/authInputParsers.test.js +40 -0
- package/test/oauthProviderCatalog.test.js +28 -0
- package/test/providerRuntime.test.js +79 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
"packageVersion": 1,
|
|
3
3
|
"packageId": "@jskit-ai/auth-provider-supabase-core",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.11",
|
|
5
5
|
"options": {
|
|
6
6
|
"auth-supabase-url": {
|
|
7
7
|
"required": true,
|
|
@@ -83,8 +83,8 @@ export default Object.freeze({
|
|
|
83
83
|
"mutations": {
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"runtime": {
|
|
86
|
-
"@jskit-ai/auth-core": "0.1.
|
|
87
|
-
"@jskit-ai/kernel": "0.1.
|
|
86
|
+
"@jskit-ai/auth-core": "0.1.11",
|
|
87
|
+
"@jskit-ai/kernel": "0.1.11",
|
|
88
88
|
"dotenv": "^16.4.5",
|
|
89
89
|
"@supabase/supabase-js": "^2.57.4",
|
|
90
90
|
"jose": "^6.1.0"
|
|
@@ -132,6 +132,16 @@ export default Object.freeze({
|
|
|
132
132
|
"reason": "Configure application public URL for auth redirect flows.",
|
|
133
133
|
"category": "runtime-config",
|
|
134
134
|
"id": "auth-app-public-url"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"op": "append-text",
|
|
138
|
+
"file": "config/server.js",
|
|
139
|
+
"position": "bottom",
|
|
140
|
+
"skipIfContains": "config.auth = {",
|
|
141
|
+
"value": "\nconfig.auth = {\n oauth: {\n providers: [],\n defaultProvider: \"\"\n }\n};\n",
|
|
142
|
+
"reason": "Append app-owned OAuth provider visibility config for stock auth screens.",
|
|
143
|
+
"category": "runtime-config",
|
|
144
|
+
"id": "auth-oauth-app-config"
|
|
135
145
|
}
|
|
136
146
|
]
|
|
137
147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/auth-provider-supabase-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"./client": "./src/client/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@jskit-ai/auth-core": "0.1.
|
|
16
|
-
"@jskit-ai/kernel": "0.1.
|
|
15
|
+
"@jskit-ai/auth-core": "0.1.11",
|
|
16
|
+
"@jskit-ai/kernel": "0.1.11",
|
|
17
17
|
"jose": "^6.1.0",
|
|
18
18
|
"@supabase/supabase-js": "^2.57.4"
|
|
19
19
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import {
|
|
3
|
+
requireAuthUser,
|
|
4
|
+
requireAuthUserSession,
|
|
5
|
+
requireNoFieldErrors
|
|
6
|
+
} from "./flowGuards.js";
|
|
2
7
|
|
|
3
8
|
function normalizeLocalReturnToPath(value, { fallback = "" } = {}) {
|
|
4
9
|
const normalized = String(value || "").trim();
|
|
@@ -61,19 +66,31 @@ function createAccountFlows(deps) {
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
function resolveRegisterEmailRedirectTo() {
|
|
70
|
+
if (typeof buildOtpLoginRedirectUrl === "function") {
|
|
71
|
+
try {
|
|
72
|
+
return buildOtpLoginRedirectUrl({
|
|
73
|
+
appPublicUrl
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
// Fall through to the pre-built URL fallback below.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return otpLoginRedirectUrl;
|
|
80
|
+
}
|
|
81
|
+
|
|
64
82
|
async function register(payload) {
|
|
65
83
|
ensureConfigured();
|
|
66
84
|
|
|
67
85
|
const parsed = validators.registerInput(payload);
|
|
68
|
-
|
|
69
|
-
throw validationError(parsed.fieldErrors);
|
|
70
|
-
}
|
|
86
|
+
requireNoFieldErrors(parsed, validationError);
|
|
71
87
|
|
|
72
88
|
const supabase = getSupabaseClient();
|
|
73
89
|
const response = await supabase.auth.signUp({
|
|
74
90
|
email: parsed.email,
|
|
75
91
|
password: parsed.password,
|
|
76
92
|
options: {
|
|
93
|
+
emailRedirectTo: resolveRegisterEmailRedirectTo(),
|
|
77
94
|
data: {
|
|
78
95
|
display_name: displayNameFromEmail(parsed.email)
|
|
79
96
|
}
|
|
@@ -81,7 +98,7 @@ function createAccountFlows(deps) {
|
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
if (response.error) {
|
|
84
|
-
throw mapAuthError(response.error, 400);
|
|
101
|
+
throw mapAuthError(response.error, Number(response.error?.status || 400));
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
if (!response.data?.user) {
|
|
@@ -106,13 +123,60 @@ function createAccountFlows(deps) {
|
|
|
106
123
|
};
|
|
107
124
|
}
|
|
108
125
|
|
|
126
|
+
async function resendRegisterConfirmation(payload) {
|
|
127
|
+
ensureConfigured();
|
|
128
|
+
|
|
129
|
+
const parsed = validators.forgotPasswordInput(payload);
|
|
130
|
+
requireNoFieldErrors(parsed, validationError);
|
|
131
|
+
|
|
132
|
+
const supabase = getSupabaseClient();
|
|
133
|
+
const emailRedirectTo = resolveRegisterEmailRedirectTo();
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
response = await supabase.auth.resend({
|
|
137
|
+
type: "signup",
|
|
138
|
+
email: parsed.email,
|
|
139
|
+
options: {
|
|
140
|
+
emailRedirectTo
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (isTransientSupabaseError(error)) {
|
|
145
|
+
throw mapAuthError(error, 503);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
message: "If an account exists for that email, a confirmation email has been sent."
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (response.error) {
|
|
155
|
+
if (isTransientSupabaseError(response.error)) {
|
|
156
|
+
throw mapAuthError(response.error, 503);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (isUserNotFoundLikeAuthError(response.error)) {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
message: "If an account exists for that email, a confirmation email has been sent."
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
throw mapAuthError(response.error, Number(response.error?.status || 400));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
message: "If an account exists for that email, a confirmation email has been sent."
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
109
175
|
async function login(payload) {
|
|
110
176
|
ensureConfigured();
|
|
111
177
|
|
|
112
178
|
const parsed = validators.loginInput(payload);
|
|
113
|
-
|
|
114
|
-
throw validationError(parsed.fieldErrors);
|
|
115
|
-
}
|
|
179
|
+
requireNoFieldErrors(parsed, validationError);
|
|
116
180
|
|
|
117
181
|
const supabase = getSupabaseClient();
|
|
118
182
|
const response = await supabase.auth.signInWithPassword({
|
|
@@ -120,11 +184,9 @@ function createAccountFlows(deps) {
|
|
|
120
184
|
password: parsed.password
|
|
121
185
|
});
|
|
122
186
|
|
|
123
|
-
|
|
124
|
-
throw mapAuthError(response.error, 401);
|
|
125
|
-
}
|
|
187
|
+
const { user, session } = requireAuthUserSession(response, mapAuthError, 401);
|
|
126
188
|
|
|
127
|
-
const profile = await syncProfileFromSupabaseUser(
|
|
189
|
+
const profile = await syncProfileFromSupabaseUser(user, parsed.email);
|
|
128
190
|
const passwordSignInPolicy = await resolvePasswordSignInPolicyForUserId(profile.id);
|
|
129
191
|
if (!passwordSignInPolicy.passwordSignInEnabled) {
|
|
130
192
|
throw new AppError(401, "Invalid email or password.");
|
|
@@ -132,7 +194,7 @@ function createAccountFlows(deps) {
|
|
|
132
194
|
|
|
133
195
|
return {
|
|
134
196
|
profile,
|
|
135
|
-
session
|
|
197
|
+
session
|
|
136
198
|
};
|
|
137
199
|
}
|
|
138
200
|
|
|
@@ -140,9 +202,7 @@ function createAccountFlows(deps) {
|
|
|
140
202
|
ensureConfigured();
|
|
141
203
|
|
|
142
204
|
const parsed = validators.forgotPasswordInput(payload);
|
|
143
|
-
|
|
144
|
-
throw validationError(parsed.fieldErrors);
|
|
145
|
-
}
|
|
205
|
+
requireNoFieldErrors(parsed, validationError);
|
|
146
206
|
|
|
147
207
|
const emailRedirectTo = resolveOtpEmailRedirectTo(payload?.returnTo);
|
|
148
208
|
const supabase = getSupabaseClient();
|
|
@@ -191,9 +251,7 @@ function createAccountFlows(deps) {
|
|
|
191
251
|
ensureConfigured();
|
|
192
252
|
|
|
193
253
|
const parsed = parseOtpLoginVerifyPayload(payload);
|
|
194
|
-
|
|
195
|
-
throw validationError(parsed.fieldErrors);
|
|
196
|
-
}
|
|
254
|
+
requireNoFieldErrors(parsed, validationError);
|
|
197
255
|
|
|
198
256
|
const supabase = getSupabaseClient();
|
|
199
257
|
let response;
|
|
@@ -214,15 +272,13 @@ function createAccountFlows(deps) {
|
|
|
214
272
|
throw mapOtpVerifyError(error);
|
|
215
273
|
}
|
|
216
274
|
|
|
217
|
-
|
|
218
|
-
throw mapOtpVerifyError(response.error);
|
|
219
|
-
}
|
|
275
|
+
const { user, session } = requireAuthUserSession(response, mapOtpVerifyError);
|
|
220
276
|
|
|
221
|
-
const profile = await syncProfileFromSupabaseUser(
|
|
277
|
+
const profile = await syncProfileFromSupabaseUser(user, user.email || parsed.email);
|
|
222
278
|
|
|
223
279
|
return {
|
|
224
280
|
profile,
|
|
225
|
-
session
|
|
281
|
+
session
|
|
226
282
|
};
|
|
227
283
|
}
|
|
228
284
|
|
|
@@ -252,11 +308,9 @@ function createAccountFlows(deps) {
|
|
|
252
308
|
throw mapProfileUpdateError(error);
|
|
253
309
|
}
|
|
254
310
|
|
|
255
|
-
|
|
256
|
-
throw mapProfileUpdateError(updateResponse.error);
|
|
257
|
-
}
|
|
311
|
+
const { user } = requireAuthUser(updateResponse, mapProfileUpdateError);
|
|
258
312
|
|
|
259
|
-
const profile = await syncProfileFromSupabaseUser(
|
|
313
|
+
const profile = await syncProfileFromSupabaseUser(user, user.email);
|
|
260
314
|
|
|
261
315
|
return {
|
|
262
316
|
profile,
|
|
@@ -266,6 +320,7 @@ function createAccountFlows(deps) {
|
|
|
266
320
|
|
|
267
321
|
return {
|
|
268
322
|
register,
|
|
323
|
+
resendRegisterConfirmation,
|
|
269
324
|
login,
|
|
270
325
|
requestOtpLogin,
|
|
271
326
|
verifyOtpLogin,
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EMPTY_INPUT_VALIDATOR
|
|
3
3
|
} from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
import {
|
|
5
|
+
authRegisterCommand,
|
|
6
|
+
authRegisterConfirmationResendCommand,
|
|
7
|
+
authLoginPasswordCommand,
|
|
8
|
+
authLoginOtpRequestCommand,
|
|
9
|
+
authLoginOtpVerifyCommand,
|
|
10
|
+
authLoginOAuthStartCommand,
|
|
11
|
+
authLoginOAuthCompleteCommand,
|
|
12
|
+
authPasswordResetRequestCommand,
|
|
13
|
+
authPasswordRecoveryCompleteCommand,
|
|
14
|
+
authPasswordResetCommand
|
|
15
|
+
} from "@jskit-ai/auth-core/shared/commands";
|
|
13
16
|
|
|
14
17
|
function requireRequestContext(context, actionId) {
|
|
15
18
|
const request = context?.requestMeta?.request || null;
|
|
@@ -37,6 +40,22 @@ const authActions = Object.freeze([
|
|
|
37
40
|
return deps.authService.register(input);
|
|
38
41
|
}
|
|
39
42
|
},
|
|
43
|
+
{
|
|
44
|
+
id: "auth.register.confirmation.resend",
|
|
45
|
+
version: 1,
|
|
46
|
+
kind: "command",
|
|
47
|
+
channels: ["api", "internal"],
|
|
48
|
+
surfacesFrom: "enabled",
|
|
49
|
+
inputValidator: authRegisterConfirmationResendCommand.operation.bodyValidator,
|
|
50
|
+
idempotency: "none",
|
|
51
|
+
audit: {
|
|
52
|
+
actionName: "auth.register.confirmation.resend"
|
|
53
|
+
},
|
|
54
|
+
observability: {},
|
|
55
|
+
async execute(input, _context, deps) {
|
|
56
|
+
return deps.authService.resendRegisterConfirmation(input);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
40
59
|
{
|
|
41
60
|
id: "auth.login.password",
|
|
42
61
|
version: 1,
|
|
@@ -47,9 +47,14 @@ function mapAuthError(error, fallbackStatus) {
|
|
|
47
47
|
return new AppError(503, "Authentication service temporarily unavailable. Please retry.");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
const statusFromError = Number(error?.status || error?.statusCode);
|
|
50
51
|
const message = sanitizeAuthMessage(error?.message, "Authentication failed.");
|
|
51
52
|
const lower = message.toLowerCase();
|
|
52
53
|
|
|
54
|
+
if (statusFromError === 429 || lower.includes("rate limit")) {
|
|
55
|
+
return new AppError(429, "Too many authentication attempts. Please wait and try again.");
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
if (lower.includes("already registered") || lower.includes("already been registered")) {
|
|
54
59
|
return new AppError(409, "Email is already registered.");
|
|
55
60
|
}
|
|
@@ -89,7 +94,11 @@ function mapAuthError(error, fallbackStatus) {
|
|
|
89
94
|
return new AppError(409, "This sign-in method is not currently linked.");
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
const status = Number.isInteger(
|
|
97
|
+
const status = Number.isInteger(statusFromError)
|
|
98
|
+
? statusFromError
|
|
99
|
+
: Number.isInteger(Number(fallbackStatus))
|
|
100
|
+
? Number(fallbackStatus)
|
|
101
|
+
: 400;
|
|
93
102
|
if (status >= 500) {
|
|
94
103
|
return new AppError(503, "Authentication service temporarily unavailable. Please retry.");
|
|
95
104
|
}
|
|
@@ -106,7 +106,6 @@ function validatePasswordRecoveryPayload(payload) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
function parseOAuthCompletePayload(payload = {}, options = {}) {
|
|
109
|
-
const provider = normalizeOAuthProviderInput(payload.provider || options.defaultProvider, options);
|
|
110
109
|
const code = String(payload.code || "").trim();
|
|
111
110
|
const accessToken = String(payload.accessToken || "").trim();
|
|
112
111
|
const refreshToken = String(payload.refreshToken || "").trim();
|
|
@@ -137,6 +136,10 @@ function parseOAuthCompletePayload(payload = {}, options = {}) {
|
|
|
137
136
|
applySessionPairValidation(accessToken, refreshToken, fieldErrors);
|
|
138
137
|
|
|
139
138
|
const hasSessionPair = Boolean(accessToken && refreshToken);
|
|
139
|
+
const provider =
|
|
140
|
+
!hasSessionPair || code || errorCode
|
|
141
|
+
? normalizeOAuthProviderInput(payload.provider || options.defaultProvider, options)
|
|
142
|
+
: null;
|
|
140
143
|
|
|
141
144
|
if (!code && !errorCode && !hasSessionPair) {
|
|
142
145
|
fieldErrors.code = "OAuth code is required when access/refresh tokens are not provided.";
|
|
@@ -27,6 +27,16 @@ function parseHttpUrl(rawValue, variableName) {
|
|
|
27
27
|
return parsedUrl;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function createAppBaseUrl(appPublicUrl) {
|
|
31
|
+
const baseUrl = parseHttpUrl(String(appPublicUrl || "").trim(), "APP_PUBLIC_URL");
|
|
32
|
+
if (!baseUrl.pathname.endsWith("/")) {
|
|
33
|
+
baseUrl.pathname = `${baseUrl.pathname}/`;
|
|
34
|
+
}
|
|
35
|
+
baseUrl.search = "";
|
|
36
|
+
baseUrl.hash = "";
|
|
37
|
+
return baseUrl;
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
function buildPasswordResetRedirectUrl(options) {
|
|
31
41
|
const appPublicUrl = String(options.appPublicUrl || "").trim();
|
|
32
42
|
|
|
@@ -34,13 +44,7 @@ function buildPasswordResetRedirectUrl(options) {
|
|
|
34
44
|
throw new Error("APP_PUBLIC_URL is required to build password reset links.");
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
if (!baseUrl.pathname.endsWith("/")) {
|
|
39
|
-
baseUrl.pathname = `${baseUrl.pathname}/`;
|
|
40
|
-
}
|
|
41
|
-
baseUrl.search = "";
|
|
42
|
-
baseUrl.hash = "";
|
|
43
|
-
return new URL(PASSWORD_RESET_PATH, baseUrl).toString();
|
|
47
|
+
return new URL(PASSWORD_RESET_PATH, createAppBaseUrl(appPublicUrl)).toString();
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
function buildOtpLoginRedirectUrl(options) {
|
|
@@ -51,13 +55,7 @@ function buildOtpLoginRedirectUrl(options) {
|
|
|
51
55
|
throw new Error("APP_PUBLIC_URL is required to build OTP login redirects.");
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
const
|
|
55
|
-
if (!baseUrl.pathname.endsWith("/")) {
|
|
56
|
-
baseUrl.pathname = `${baseUrl.pathname}/`;
|
|
57
|
-
}
|
|
58
|
-
baseUrl.search = "";
|
|
59
|
-
baseUrl.hash = "";
|
|
60
|
-
const redirectUrl = new URL(OAUTH_LOGIN_PATH, baseUrl);
|
|
58
|
+
const redirectUrl = new URL(OAUTH_LOGIN_PATH, createAppBaseUrl(appPublicUrl));
|
|
61
59
|
if (returnTo) {
|
|
62
60
|
redirectUrl.searchParams.set("returnTo", returnTo);
|
|
63
61
|
}
|
|
@@ -91,14 +89,7 @@ function buildOAuthRedirectUrl(options) {
|
|
|
91
89
|
throw new Error("OAuth callback path is required.");
|
|
92
90
|
}
|
|
93
91
|
|
|
94
|
-
const
|
|
95
|
-
if (!baseUrl.pathname.endsWith("/")) {
|
|
96
|
-
baseUrl.pathname = `${baseUrl.pathname}/`;
|
|
97
|
-
}
|
|
98
|
-
baseUrl.search = "";
|
|
99
|
-
baseUrl.hash = "";
|
|
100
|
-
|
|
101
|
-
const redirectUrl = new URL(callbackPath, baseUrl);
|
|
92
|
+
const redirectUrl = new URL(callbackPath, createAppBaseUrl(appPublicUrl));
|
|
102
93
|
redirectUrl.searchParams.set(OAUTH_QUERY_PARAM_PROVIDER, provider);
|
|
103
94
|
redirectUrl.searchParams.set(OAUTH_QUERY_PARAM_INTENT, intent);
|
|
104
95
|
if (returnTo) {
|
|
@@ -125,6 +116,7 @@ function buildOAuthLinkRedirectUrl(options) {
|
|
|
125
116
|
|
|
126
117
|
export {
|
|
127
118
|
parseHttpUrl,
|
|
119
|
+
createAppBaseUrl,
|
|
128
120
|
buildPasswordResetRedirectUrl,
|
|
129
121
|
buildOtpLoginRedirectUrl,
|
|
130
122
|
normalizeOAuthIntent,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function requireNoFieldErrors(parsed, validationError) {
|
|
2
|
+
if (typeof validationError !== "function") {
|
|
3
|
+
throw new TypeError("requireNoFieldErrors requires validationError.");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const fieldErrors =
|
|
7
|
+
parsed && typeof parsed === "object" && parsed.fieldErrors && typeof parsed.fieldErrors === "object"
|
|
8
|
+
? parsed.fieldErrors
|
|
9
|
+
: {};
|
|
10
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
11
|
+
throw validationError(fieldErrors);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function requireAuthUserSession(response, mapError, status = 401) {
|
|
16
|
+
if (typeof mapError !== "function") {
|
|
17
|
+
throw new TypeError("requireAuthUserSession requires mapError.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (response?.error || !response?.data?.user || !response?.data?.session) {
|
|
21
|
+
throw mapError(response?.error, status);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
user: response.data.user,
|
|
26
|
+
session: response.data.session
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireAuthUser(response, mapError, status = 400) {
|
|
31
|
+
if (typeof mapError !== "function") {
|
|
32
|
+
throw new TypeError("requireAuthUser requires mapError.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (response?.error || !response?.data?.user) {
|
|
36
|
+
throw mapError(response?.error, status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
user: response.data.user
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireAuthSession(response, mapError, status = 401) {
|
|
45
|
+
if (typeof mapError !== "function") {
|
|
46
|
+
throw new TypeError("requireAuthSession requires mapError.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (response?.error || !response?.data?.session) {
|
|
50
|
+
throw mapError(response?.error, status);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
session: response.data.session
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { requireNoFieldErrors, requireAuthUserSession, requireAuthUser, requireAuthSession };
|
|
@@ -25,7 +25,7 @@ const SUPABASE_OAUTH_PROVIDER_METADATA = Object.freeze({
|
|
|
25
25
|
zoom: Object.freeze({ id: "zoom", label: "Zoom" })
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
const DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS = Object.freeze([
|
|
28
|
+
const DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS = Object.freeze([]);
|
|
29
29
|
|
|
30
30
|
function normalizeProviderLabel(value, fallback) {
|
|
31
31
|
const normalized = String(value || "").trim();
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import {
|
|
3
|
+
requireAuthUser,
|
|
4
|
+
requireAuthSession,
|
|
5
|
+
requireAuthUserSession,
|
|
6
|
+
requireNoFieldErrors
|
|
7
|
+
} from "./flowGuards.js";
|
|
2
8
|
|
|
3
9
|
function createPasswordSecurityFlows(deps) {
|
|
4
10
|
const {
|
|
@@ -33,9 +39,7 @@ function createPasswordSecurityFlows(deps) {
|
|
|
33
39
|
ensureConfigured();
|
|
34
40
|
|
|
35
41
|
const parsed = validators.forgotPasswordInput(payload);
|
|
36
|
-
|
|
37
|
-
throw validationError(parsed.fieldErrors);
|
|
38
|
-
}
|
|
42
|
+
requireNoFieldErrors(parsed, validationError);
|
|
39
43
|
|
|
40
44
|
const supabase = getSupabaseClient();
|
|
41
45
|
const options = { redirectTo: passwordResetRedirectUrl };
|
|
@@ -62,9 +66,7 @@ function createPasswordSecurityFlows(deps) {
|
|
|
62
66
|
ensureConfigured();
|
|
63
67
|
|
|
64
68
|
const parsed = validatePasswordRecoveryPayload(payload);
|
|
65
|
-
|
|
66
|
-
throw validationError(parsed.fieldErrors);
|
|
67
|
-
}
|
|
69
|
+
requireNoFieldErrors(parsed, validationError);
|
|
68
70
|
|
|
69
71
|
const supabase = getSupabaseClient();
|
|
70
72
|
let response;
|
|
@@ -91,16 +93,12 @@ function createPasswordSecurityFlows(deps) {
|
|
|
91
93
|
throw mapRecoveryError(response.error);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
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);
|
|
96
|
+
const { user, session } = requireAuthUserSession(response, () => new AppError(401, "Recovery link is invalid or has expired."));
|
|
97
|
+
const profile = await syncProfileFromSupabaseUser(user, user.email);
|
|
100
98
|
|
|
101
99
|
return {
|
|
102
100
|
profile,
|
|
103
|
-
session
|
|
101
|
+
session
|
|
104
102
|
};
|
|
105
103
|
}
|
|
106
104
|
|
|
@@ -108,9 +106,7 @@ function createPasswordSecurityFlows(deps) {
|
|
|
108
106
|
ensureConfigured();
|
|
109
107
|
|
|
110
108
|
const parsed = validators.resetPasswordInput(payload);
|
|
111
|
-
|
|
112
|
-
throw validationError(parsed.fieldErrors);
|
|
113
|
-
}
|
|
109
|
+
requireNoFieldErrors(parsed, validationError);
|
|
114
110
|
|
|
115
111
|
const supabase = getSupabaseClient();
|
|
116
112
|
const sessionResponse = await setSessionFromRequestCookies(request, {
|
|
@@ -134,11 +130,9 @@ function createPasswordSecurityFlows(deps) {
|
|
|
134
130
|
throw mapPasswordUpdateError(error);
|
|
135
131
|
}
|
|
136
132
|
|
|
137
|
-
|
|
138
|
-
throw mapPasswordUpdateError(updateResponse.error);
|
|
139
|
-
}
|
|
133
|
+
const { user } = requireAuthUser(updateResponse, mapPasswordUpdateError);
|
|
140
134
|
|
|
141
|
-
const updatedProfile = await syncProfileFromSupabaseUser(
|
|
135
|
+
const updatedProfile = await syncProfileFromSupabaseUser(user, user.email);
|
|
142
136
|
await setPasswordSetupRequiredForUserId(updatedProfile.id, false);
|
|
143
137
|
}
|
|
144
138
|
|
|
@@ -170,9 +164,7 @@ function createPasswordSecurityFlows(deps) {
|
|
|
170
164
|
throw mapCurrentPasswordError(error);
|
|
171
165
|
}
|
|
172
166
|
|
|
173
|
-
|
|
174
|
-
throw mapCurrentPasswordError(verifyResponse.error);
|
|
175
|
-
}
|
|
167
|
+
requireAuthSession(verifyResponse, mapCurrentPasswordError);
|
|
176
168
|
}
|
|
177
169
|
|
|
178
170
|
let updateResponse;
|
|
@@ -184,11 +176,9 @@ function createPasswordSecurityFlows(deps) {
|
|
|
184
176
|
throw mapPasswordUpdateError(error);
|
|
185
177
|
}
|
|
186
178
|
|
|
187
|
-
|
|
188
|
-
throw mapPasswordUpdateError(updateResponse.error);
|
|
189
|
-
}
|
|
179
|
+
const { user } = requireAuthUser(updateResponse, mapPasswordUpdateError);
|
|
190
180
|
|
|
191
|
-
const profile = await syncProfileFromSupabaseUser(
|
|
181
|
+
const profile = await syncProfileFromSupabaseUser(user, user.email);
|
|
192
182
|
await setPasswordSetupRequiredForUserId(profile.id, false);
|
|
193
183
|
|
|
194
184
|
return {
|
|
@@ -560,7 +560,7 @@ function createService(options) {
|
|
|
560
560
|
};
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
-
const { register, login, requestOtpLogin, verifyOtpLogin, updateDisplayName } = createAccountFlows({
|
|
563
|
+
const { register, resendRegisterConfirmation, login, requestOtpLogin, verifyOtpLogin, updateDisplayName } = createAccountFlows({
|
|
564
564
|
ensureConfigured,
|
|
565
565
|
validators,
|
|
566
566
|
validationError,
|
|
@@ -797,6 +797,7 @@ function createService(options) {
|
|
|
797
797
|
|
|
798
798
|
return {
|
|
799
799
|
register,
|
|
800
|
+
resendRegisterConfirmation,
|
|
800
801
|
login,
|
|
801
802
|
requestOtpLogin,
|
|
802
803
|
verifyOtpLogin,
|
|
@@ -20,6 +20,35 @@ function splitCsv(value) {
|
|
|
20
20
|
.filter(Boolean);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
function normalizeRecord(value) {
|
|
24
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeOAuthProviderConfigList(value) {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value
|
|
30
|
+
.map((entry) => String(entry || "").trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
return splitCsv(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveOAuthConfigFromAppConfig(appConfig) {
|
|
42
|
+
const source = normalizeRecord(appConfig);
|
|
43
|
+
const auth = normalizeRecord(source.auth);
|
|
44
|
+
const oauth = normalizeRecord(auth.oauth);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
oauthProviders: normalizeOAuthProviderConfigList(oauth.providers),
|
|
48
|
+
oauthDefaultProvider: String(oauth.defaultProvider || "").trim()
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
function resolveAllowedReturnToOrigins({ appConfig = {}, appPublicUrl = "" } = {}) {
|
|
24
53
|
const surfaceDefinitions =
|
|
25
54
|
appConfig && typeof appConfig === "object" && appConfig.surfaceDefinitions && typeof appConfig.surfaceDefinitions === "object"
|
|
@@ -31,8 +60,12 @@ function resolveAllowedReturnToOrigins({ appConfig = {}, appPublicUrl = "" } = {
|
|
|
31
60
|
});
|
|
32
61
|
}
|
|
33
62
|
|
|
34
|
-
function resolveAuthProviderConfig(env) {
|
|
63
|
+
function resolveAuthProviderConfig(env, appConfig = {}) {
|
|
35
64
|
const source = env && typeof env === "object" ? env : {};
|
|
65
|
+
const oauthConfigFromApp = resolveOAuthConfigFromAppConfig(appConfig);
|
|
66
|
+
const oauthProvidersFromEnv = splitCsv(source.AUTH_OAUTH_PROVIDERS);
|
|
67
|
+
const oauthDefaultProviderFromEnv = String(source.AUTH_OAUTH_DEFAULT_PROVIDER || "").trim();
|
|
68
|
+
|
|
36
69
|
return {
|
|
37
70
|
id: "supabase",
|
|
38
71
|
supabaseUrl: String(source.AUTH_SUPABASE_URL || source.SUPABASE_URL || "").trim(),
|
|
@@ -40,8 +73,9 @@ function resolveAuthProviderConfig(env) {
|
|
|
40
73
|
source.AUTH_SUPABASE_PUBLISHABLE_KEY || source.SUPABASE_PUBLISHABLE_KEY || ""
|
|
41
74
|
).trim(),
|
|
42
75
|
jwtAudience: String(source.AUTH_JWT_AUDIENCE || "authenticated").trim(),
|
|
43
|
-
oauthProviders:
|
|
44
|
-
|
|
76
|
+
oauthProviders:
|
|
77
|
+
oauthProvidersFromEnv.length > 0 ? oauthProvidersFromEnv : oauthConfigFromApp.oauthProviders,
|
|
78
|
+
oauthDefaultProvider: oauthDefaultProviderFromEnv || oauthConfigFromApp.oauthDefaultProvider
|
|
45
79
|
};
|
|
46
80
|
}
|
|
47
81
|
|
|
@@ -138,7 +172,7 @@ class AuthSupabaseServiceProvider {
|
|
|
138
172
|
...envFromDependencies
|
|
139
173
|
};
|
|
140
174
|
const appConfig = scope.has("appConfig") ? scope.make("appConfig") : {};
|
|
141
|
-
const authProvider = resolveAuthProviderConfig(env);
|
|
175
|
+
const authProvider = resolveAuthProviderConfig(env, appConfig);
|
|
142
176
|
const repositories = resolveOptionalRepositories(scope);
|
|
143
177
|
const userSettingsRepository = repositories.userSettingsRepository || fallbackUserSettingsRepository;
|
|
144
178
|
if (!authProvider.supabaseUrl || !authProvider.supabasePublishableKey) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mapAuthError } from "../src/server/lib/authErrorMappers.js";
|
|
4
|
+
|
|
5
|
+
test("mapAuthError preserves rate-limit errors as 429 with actionable message", () => {
|
|
6
|
+
const mapped = mapAuthError(
|
|
7
|
+
{
|
|
8
|
+
status: 429,
|
|
9
|
+
message: "email rate limit exceeded"
|
|
10
|
+
},
|
|
11
|
+
400
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
assert.equal(mapped.status, 429);
|
|
15
|
+
assert.equal(mapped.message, "Too many authentication attempts. Please wait and try again.");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("mapAuthError honors upstream status codes for unknown client errors", () => {
|
|
19
|
+
const mapped = mapAuthError(
|
|
20
|
+
{
|
|
21
|
+
status: 422,
|
|
22
|
+
message: "unexpected auth validation issue"
|
|
23
|
+
},
|
|
24
|
+
400
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
assert.equal(mapped.status, 422);
|
|
28
|
+
assert.equal(mapped.message, "Authentication request could not be processed.");
|
|
29
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parseOAuthCompletePayload } from "../src/server/lib/authInputParsers.js";
|
|
4
|
+
|
|
5
|
+
test("parseOAuthCompletePayload accepts provider-less session pairs", () => {
|
|
6
|
+
const parsed = parseOAuthCompletePayload(
|
|
7
|
+
{
|
|
8
|
+
accessToken: "access-token",
|
|
9
|
+
refreshToken: "refresh-token"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
providerIds: [],
|
|
13
|
+
defaultProvider: null
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
assert.equal(parsed.hasSessionPair, true);
|
|
18
|
+
assert.equal(parsed.provider, null);
|
|
19
|
+
assert.deepEqual(parsed.fieldErrors, {});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parseOAuthCompletePayload still requires OAuth provider for code exchanges", () => {
|
|
23
|
+
assert.throws(
|
|
24
|
+
() =>
|
|
25
|
+
parseOAuthCompletePayload(
|
|
26
|
+
{
|
|
27
|
+
code: "oauth-code"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
providerIds: [],
|
|
31
|
+
defaultProvider: null
|
|
32
|
+
}
|
|
33
|
+
),
|
|
34
|
+
(error) => {
|
|
35
|
+
assert.equal(error?.status, 400);
|
|
36
|
+
assert.equal(String(error?.details?.fieldErrors?.provider || "").length > 0, true);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS,
|
|
5
|
+
resolveSupabaseOAuthProviderCatalog
|
|
6
|
+
} from "../src/server/lib/oauthProviderCatalog.js";
|
|
7
|
+
|
|
8
|
+
test("oauth provider catalog defaults to no providers", () => {
|
|
9
|
+
assert.deepEqual(DEFAULT_SUPABASE_OAUTH_PROVIDER_IDS, []);
|
|
10
|
+
|
|
11
|
+
const catalog = resolveSupabaseOAuthProviderCatalog();
|
|
12
|
+
assert.deepEqual(catalog.providers, []);
|
|
13
|
+
assert.deepEqual(catalog.providerIds, []);
|
|
14
|
+
assert.equal(catalog.defaultProvider, null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("oauth provider catalog uses explicit provider configuration", () => {
|
|
18
|
+
const catalog = resolveSupabaseOAuthProviderCatalog({
|
|
19
|
+
oauthProviders: ["github", "google"],
|
|
20
|
+
oauthDefaultProvider: "google"
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
catalog.providers.map((provider) => provider.id),
|
|
25
|
+
["github", "google"]
|
|
26
|
+
);
|
|
27
|
+
assert.equal(catalog.defaultProvider, "google");
|
|
28
|
+
});
|
|
@@ -69,6 +69,7 @@ test("auth supabase provider registers authService and contributes auth actions
|
|
|
69
69
|
const definitions = actionExecutor.listDefinitions();
|
|
70
70
|
assert.equal(Array.isArray(definitions), true);
|
|
71
71
|
assert.equal(definitions.some((definition) => definition.id === "auth.login.password"), true);
|
|
72
|
+
assert.equal(definitions.some((definition) => definition.id === "auth.register.confirmation.resend"), true);
|
|
72
73
|
const sessionRead = definitions.find((definition) => definition.id === "auth.session.read");
|
|
73
74
|
assert.deepEqual(sessionRead?.surfaces, ["home", "console"]);
|
|
74
75
|
});
|
|
@@ -154,3 +155,81 @@ test("auth supabase provider rejects unsupported AUTH_PROFILE_MODE values", asyn
|
|
|
154
155
|
|
|
155
156
|
assert.throws(() => app.make("authService"), /Unsupported AUTH_PROFILE_MODE/);
|
|
156
157
|
});
|
|
158
|
+
|
|
159
|
+
test("auth supabase provider reads oauth providers from appConfig.auth.oauth", async () => {
|
|
160
|
+
const app = createApplication();
|
|
161
|
+
app.instance("appConfig", {
|
|
162
|
+
...createAppConfigFixture(),
|
|
163
|
+
auth: {
|
|
164
|
+
oauth: {
|
|
165
|
+
providers: ["github"],
|
|
166
|
+
defaultProvider: "github"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
app.instance(KERNEL_TOKENS.Env, {
|
|
171
|
+
AUTH_SUPABASE_URL: "https://example.supabase.co",
|
|
172
|
+
AUTH_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test_key",
|
|
173
|
+
AUTH_PROFILE_MODE: "standalone",
|
|
174
|
+
APP_PUBLIC_URL: "http://localhost:5173",
|
|
175
|
+
NODE_ENV: "test"
|
|
176
|
+
});
|
|
177
|
+
app.instance(KERNEL_TOKENS.Logger, {
|
|
178
|
+
info() {},
|
|
179
|
+
warn() {},
|
|
180
|
+
error() {},
|
|
181
|
+
debug() {}
|
|
182
|
+
});
|
|
183
|
+
app.instance("domainEvents", {
|
|
184
|
+
async publish() {}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await app.start({
|
|
188
|
+
providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const authService = app.make("authService");
|
|
192
|
+
const catalog = authService.getOAuthProviderCatalog();
|
|
193
|
+
assert.deepEqual(catalog.providers.map((provider) => provider.id), ["github"]);
|
|
194
|
+
assert.equal(catalog.defaultProvider, "github");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("auth supabase provider lets env oauth settings override appConfig.auth.oauth", async () => {
|
|
198
|
+
const app = createApplication();
|
|
199
|
+
app.instance("appConfig", {
|
|
200
|
+
...createAppConfigFixture(),
|
|
201
|
+
auth: {
|
|
202
|
+
oauth: {
|
|
203
|
+
providers: ["github"],
|
|
204
|
+
defaultProvider: "github"
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
app.instance(KERNEL_TOKENS.Env, {
|
|
209
|
+
AUTH_SUPABASE_URL: "https://example.supabase.co",
|
|
210
|
+
AUTH_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test_key",
|
|
211
|
+
AUTH_OAUTH_PROVIDERS: "google",
|
|
212
|
+
AUTH_OAUTH_DEFAULT_PROVIDER: "google",
|
|
213
|
+
AUTH_PROFILE_MODE: "standalone",
|
|
214
|
+
APP_PUBLIC_URL: "http://localhost:5173",
|
|
215
|
+
NODE_ENV: "test"
|
|
216
|
+
});
|
|
217
|
+
app.instance(KERNEL_TOKENS.Logger, {
|
|
218
|
+
info() {},
|
|
219
|
+
warn() {},
|
|
220
|
+
error() {},
|
|
221
|
+
debug() {}
|
|
222
|
+
});
|
|
223
|
+
app.instance("domainEvents", {
|
|
224
|
+
async publish() {}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await app.start({
|
|
228
|
+
providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const authService = app.make("authService");
|
|
232
|
+
const catalog = authService.getOAuthProviderCatalog();
|
|
233
|
+
assert.deepEqual(catalog.providers.map((provider) => provider.id), ["google"]);
|
|
234
|
+
assert.equal(catalog.defaultProvider, "google");
|
|
235
|
+
});
|