@jskit-ai/auth-provider-supabase-core 0.1.45 → 0.1.47
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 +3 -3
- package/package.json +3 -3
- package/src/server/lib/actions/auth.contributor.js +41 -3
- package/src/server/lib/devAuthBootstrap.js +398 -0
- package/src/server/lib/index.js +1 -1
- package/src/server/lib/service.js +55 -2
- package/src/server/providers/AuthSupabaseServiceProvider.js +69 -13
- package/test/devAuthBootstrap.test.js +182 -0
- package/test/providerRuntime.test.js +209 -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.47",
|
|
5
5
|
"kind": "runtime",
|
|
6
6
|
"options": {
|
|
7
7
|
"auth-supabase-url": {
|
|
@@ -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.47",
|
|
87
|
+
"@jskit-ai/kernel": "0.1.48",
|
|
88
88
|
"dotenv": "^16.4.5",
|
|
89
89
|
"@supabase/supabase-js": "^2.57.4",
|
|
90
90
|
"jose": "^6.1.0"
|
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.47",
|
|
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.47",
|
|
16
|
+
"@jskit-ai/kernel": "0.1.48",
|
|
17
17
|
"jose": "^6.1.0",
|
|
18
18
|
"@supabase/supabase-js": "^2.57.4"
|
|
19
19
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
authLoginOtpVerifyCommand,
|
|
10
10
|
authLoginOAuthStartCommand,
|
|
11
11
|
authLoginOAuthCompleteCommand,
|
|
12
|
+
authDevLoginAsCommand,
|
|
12
13
|
authPasswordResetRequestCommand,
|
|
13
14
|
authPasswordRecoveryCompleteCommand,
|
|
14
15
|
authPasswordResetCommand
|
|
@@ -23,7 +24,24 @@ function requireRequestContext(context, actionId) {
|
|
|
23
24
|
throw new Error(`${actionId} requires request context.`);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const
|
|
27
|
+
const devLoginAsAction = Object.freeze({
|
|
28
|
+
id: "auth.dev.loginAs",
|
|
29
|
+
version: 1,
|
|
30
|
+
kind: "command",
|
|
31
|
+
channels: ["api", "internal"],
|
|
32
|
+
surfacesFrom: "enabled",
|
|
33
|
+
inputValidator: authDevLoginAsCommand.operation.bodyValidator,
|
|
34
|
+
idempotency: "none",
|
|
35
|
+
audit: {
|
|
36
|
+
actionName: "auth.dev.loginAs"
|
|
37
|
+
},
|
|
38
|
+
observability: {},
|
|
39
|
+
async execute(input, context, deps) {
|
|
40
|
+
return deps.authService.devLoginAs(requireRequestContext(context, "auth.dev.loginAs"), input);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const authActionsBeforeDevLogin = Object.freeze([
|
|
27
45
|
{
|
|
28
46
|
id: "auth.register",
|
|
29
47
|
version: 1,
|
|
@@ -135,7 +153,10 @@ const authActions = Object.freeze([
|
|
|
135
153
|
async execute(input, _context, deps) {
|
|
136
154
|
return deps.authService.oauthComplete(input);
|
|
137
155
|
}
|
|
138
|
-
}
|
|
156
|
+
}
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const authActionsAfterDevLogin = Object.freeze([
|
|
139
160
|
{
|
|
140
161
|
id: "auth.password.reset.request",
|
|
141
162
|
version: 1,
|
|
@@ -241,4 +262,21 @@ const authActions = Object.freeze([
|
|
|
241
262
|
}
|
|
242
263
|
]);
|
|
243
264
|
|
|
244
|
-
|
|
265
|
+
const baseAuthActions = Object.freeze([
|
|
266
|
+
...authActionsBeforeDevLogin,
|
|
267
|
+
...authActionsAfterDevLogin
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
function buildAuthActions({ includeDevLoginAs = false } = {}) {
|
|
271
|
+
if (!includeDevLoginAs) {
|
|
272
|
+
return baseAuthActions;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return Object.freeze([
|
|
276
|
+
...authActionsBeforeDevLogin,
|
|
277
|
+
devLoginAsAction,
|
|
278
|
+
...authActionsAfterDevLogin
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export { baseAuthActions, buildAuthActions, devLoginAsAction };
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
import { normalizeEmail } from "@jskit-ai/auth-core/server/utils";
|
|
4
|
+
import { loadJose, isExpiredJwtError } from "./authJwt.js";
|
|
5
|
+
|
|
6
|
+
const DEV_AUTH_TOKEN_PREFIX = "jskit-dev.";
|
|
7
|
+
const DEV_AUTH_ISSUER = "jskit:dev-auth";
|
|
8
|
+
const DEFAULT_DEV_AUTH_ACCESS_TTL_SECONDS = 60 * 60 * 12;
|
|
9
|
+
const DEFAULT_DEV_AUTH_REFRESH_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
10
|
+
const encoder = new TextEncoder();
|
|
11
|
+
|
|
12
|
+
function parseBoolean(value, fallback = false) {
|
|
13
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
14
|
+
if (!raw) {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
if (["1", "true", "yes", "on"].includes(raw)) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
if (["0", "false", "no", "off"].includes(raw)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizePositiveInteger(value, fallback) {
|
|
27
|
+
const parsed = Number.parseInt(String(value || "").trim(), 10);
|
|
28
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeRequestHostname(request) {
|
|
35
|
+
const hostHeader = String(request?.headers?.host || "").trim();
|
|
36
|
+
if (!hostHeader) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const firstHost = hostHeader.split(",")[0]?.trim();
|
|
41
|
+
if (!firstHost) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return new URL(`http://${firstHost}`).hostname.trim().toLowerCase();
|
|
47
|
+
} catch {
|
|
48
|
+
return firstHost
|
|
49
|
+
.replace(/^\[/, "")
|
|
50
|
+
.replace(/\]$/, "")
|
|
51
|
+
.split(":")[0]
|
|
52
|
+
.trim()
|
|
53
|
+
.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveDirectRemoteAddress(request) {
|
|
58
|
+
return String(request?.socket?.remoteAddress || request?.raw?.socket?.remoteAddress || "").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeLoopbackIp(value) {
|
|
62
|
+
return String(value || "")
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/^\[|\]$/g, "")
|
|
66
|
+
.replace(/^::ffff:/, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isLoopbackIp(value) {
|
|
70
|
+
const normalized = normalizeLoopbackIp(value);
|
|
71
|
+
return normalized === "::1" || normalized === "127.0.0.1" || normalized.startsWith("127.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isLoopbackHostname(value) {
|
|
75
|
+
const normalized = String(value || "")
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/^\[|\]$/g, "");
|
|
79
|
+
return (
|
|
80
|
+
normalized === "localhost" ||
|
|
81
|
+
normalized.endsWith(".localhost") ||
|
|
82
|
+
normalized === "::1" ||
|
|
83
|
+
normalized === "127.0.0.1"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isLocalDevAuthRequest(request) {
|
|
88
|
+
return (
|
|
89
|
+
isLoopbackIp(resolveDirectRemoteAddress(request)) &&
|
|
90
|
+
isLoopbackHostname(normalizeRequestHostname(request))
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isDevAuthToken(token) {
|
|
95
|
+
return String(token || "").trim().startsWith(DEV_AUTH_TOKEN_PREFIX);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stripDevAuthTokenPrefix(token) {
|
|
99
|
+
return String(token || "").trim().slice(DEV_AUTH_TOKEN_PREFIX.length);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveDevAuthConfig({
|
|
103
|
+
enabled = false,
|
|
104
|
+
secret = "",
|
|
105
|
+
nodeEnv = "development",
|
|
106
|
+
jwtAudience = "authenticated",
|
|
107
|
+
accessTtlSeconds = DEFAULT_DEV_AUTH_ACCESS_TTL_SECONDS,
|
|
108
|
+
refreshTtlSeconds = DEFAULT_DEV_AUTH_REFRESH_TTL_SECONDS
|
|
109
|
+
} = {}) {
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
enabled: parseBoolean(enabled, false),
|
|
112
|
+
secret: String(secret || "").trim(),
|
|
113
|
+
isProduction: String(nodeEnv || "development").trim().toLowerCase() === "production",
|
|
114
|
+
jwtAudience: String(jwtAudience || "authenticated").trim() || "authenticated",
|
|
115
|
+
accessTtlSeconds: normalizePositiveInteger(accessTtlSeconds, DEFAULT_DEV_AUTH_ACCESS_TTL_SECONDS),
|
|
116
|
+
refreshTtlSeconds: normalizePositiveInteger(refreshTtlSeconds, DEFAULT_DEV_AUTH_REFRESH_TTL_SECONDS)
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assertDevAuthBootstrapConfig(config, { userProfilesRepository = null } = {}) {
|
|
121
|
+
if (!config?.enabled) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (config.isProduction) {
|
|
126
|
+
throw new Error("AUTH_DEV_BYPASS_ENABLED must not be enabled in production.");
|
|
127
|
+
}
|
|
128
|
+
if (!config.secret) {
|
|
129
|
+
throw new Error("AUTH_DEV_BYPASS_SECRET is required when AUTH_DEV_BYPASS_ENABLED=true.");
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
!userProfilesRepository ||
|
|
133
|
+
typeof userProfilesRepository.findById !== "function" ||
|
|
134
|
+
typeof userProfilesRepository.findByEmail !== "function"
|
|
135
|
+
) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"Dev auth bootstrap requires internal.repository.user-profiles with findById() and findByEmail() when AUTH_DEV_BYPASS_ENABLED=true."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ensureDevAuthBootstrapAvailable(config, request) {
|
|
143
|
+
if (!config?.enabled || config?.isProduction) {
|
|
144
|
+
throw new AppError(404, "Not found.");
|
|
145
|
+
}
|
|
146
|
+
if (!config.secret) {
|
|
147
|
+
throw new AppError(500, "AUTH_DEV_BYPASS_SECRET is required when AUTH_DEV_BYPASS_ENABLED=true.");
|
|
148
|
+
}
|
|
149
|
+
if (!isLocalDevAuthRequest(request)) {
|
|
150
|
+
throw new AppError(403, "Dev auth bootstrap is only available from localhost.");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildProfileFromTokenClaims(payload) {
|
|
155
|
+
const id = normalizeRecordId(payload?.sub, { fallback: null });
|
|
156
|
+
const email = normalizeEmail(payload?.email || "");
|
|
157
|
+
if (!id || !email) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const displayName = String(payload?.displayName || "").trim();
|
|
162
|
+
const username = String(payload?.username || "")
|
|
163
|
+
.trim()
|
|
164
|
+
.toLowerCase();
|
|
165
|
+
const authProvider = String(payload?.authProvider || "dev")
|
|
166
|
+
.trim()
|
|
167
|
+
.toLowerCase();
|
|
168
|
+
const authProviderUserSid = String(payload?.authProviderUserSid || payload?.sub || "").trim();
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
id,
|
|
172
|
+
email,
|
|
173
|
+
username,
|
|
174
|
+
displayName: displayName || email || `User ${id}`,
|
|
175
|
+
authProvider,
|
|
176
|
+
authProviderUserSid,
|
|
177
|
+
avatarStorageKey: null,
|
|
178
|
+
avatarVersion: null
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function resolveProfileFromTokenClaims(payload, { userProfilesRepository = null } = {}) {
|
|
183
|
+
const userId = normalizeRecordId(payload?.sub, { fallback: null });
|
|
184
|
+
if (userId && userProfilesRepository && typeof userProfilesRepository.findById === "function") {
|
|
185
|
+
const latest = await userProfilesRepository.findById(userId);
|
|
186
|
+
if (latest?.id) {
|
|
187
|
+
return latest;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const profile = buildProfileFromTokenClaims(payload);
|
|
192
|
+
if (profile) {
|
|
193
|
+
return profile;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new AppError(401, "Dev session is invalid.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function signDevAuthToken(kind, profile, config) {
|
|
200
|
+
const jose = await loadJose();
|
|
201
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
202
|
+
const ttlSeconds = kind === "refresh" ? config.refreshTtlSeconds : config.accessTtlSeconds;
|
|
203
|
+
const token = await new jose.SignJWT({
|
|
204
|
+
kind,
|
|
205
|
+
email: String(profile?.email || "").trim().toLowerCase(),
|
|
206
|
+
displayName: String(profile?.displayName || "").trim(),
|
|
207
|
+
username: String(profile?.username || "")
|
|
208
|
+
.trim()
|
|
209
|
+
.toLowerCase(),
|
|
210
|
+
authProvider: String(profile?.authProvider || "dev")
|
|
211
|
+
.trim()
|
|
212
|
+
.toLowerCase(),
|
|
213
|
+
authProviderUserSid: String(profile?.authProviderUserSid || profile?.id || "").trim()
|
|
214
|
+
})
|
|
215
|
+
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
|
216
|
+
.setIssuer(DEV_AUTH_ISSUER)
|
|
217
|
+
.setAudience(config.jwtAudience)
|
|
218
|
+
.setSubject(String(profile?.id || "").trim())
|
|
219
|
+
.setIssuedAt(nowSeconds)
|
|
220
|
+
.setExpirationTime(nowSeconds + ttlSeconds)
|
|
221
|
+
.sign(encoder.encode(config.secret));
|
|
222
|
+
|
|
223
|
+
return `${DEV_AUTH_TOKEN_PREFIX}${token}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function verifyDevAuthToken(token, kind, config) {
|
|
227
|
+
if (!isDevAuthToken(token) || !config?.secret) {
|
|
228
|
+
return {
|
|
229
|
+
status: "invalid",
|
|
230
|
+
payload: null
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const jose = await loadJose();
|
|
235
|
+
try {
|
|
236
|
+
const { payload } = await jose.jwtVerify(stripDevAuthTokenPrefix(token), encoder.encode(config.secret), {
|
|
237
|
+
issuer: DEV_AUTH_ISSUER,
|
|
238
|
+
audience: config.jwtAudience
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (String(payload?.kind || "").trim() !== kind) {
|
|
242
|
+
return {
|
|
243
|
+
status: "invalid",
|
|
244
|
+
payload: null
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
status: "valid",
|
|
250
|
+
payload
|
|
251
|
+
};
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (isExpiredJwtError(error)) {
|
|
254
|
+
return {
|
|
255
|
+
status: "expired",
|
|
256
|
+
payload: null
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
status: "invalid",
|
|
262
|
+
payload: null
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function createDevAuthSession(profile, config) {
|
|
268
|
+
return {
|
|
269
|
+
access_token: await signDevAuthToken("access", profile, config),
|
|
270
|
+
refresh_token: await signDevAuthToken("refresh", profile, config),
|
|
271
|
+
expires_in: config.accessTtlSeconds,
|
|
272
|
+
token_type: "bearer"
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function resolveDevAuthProfile(input = {}, { userProfilesRepository = null, validationError } = {}) {
|
|
277
|
+
if (!userProfilesRepository || typeof userProfilesRepository.findById !== "function") {
|
|
278
|
+
throw new AppError(500, "Dev auth bootstrap requires internal.repository.user-profiles.findById().");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const normalizedUserId = normalizeRecordId(input?.userId, { fallback: null });
|
|
282
|
+
const normalizedEmail = normalizeEmail(input?.email || "");
|
|
283
|
+
if (!normalizedUserId && !normalizedEmail) {
|
|
284
|
+
throw validationError({
|
|
285
|
+
userId: "Provide a user id or email.",
|
|
286
|
+
email: "Provide a user id or email."
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const fieldErrors = {};
|
|
291
|
+
|
|
292
|
+
if (normalizedUserId) {
|
|
293
|
+
const byId = await userProfilesRepository.findById(normalizedUserId);
|
|
294
|
+
if (byId?.id) {
|
|
295
|
+
return byId;
|
|
296
|
+
}
|
|
297
|
+
fieldErrors.userId = "User not found.";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (normalizedEmail) {
|
|
301
|
+
if (typeof userProfilesRepository.findByEmail !== "function") {
|
|
302
|
+
throw new AppError(500, "Dev auth bootstrap requires internal.repository.user-profiles.findByEmail() for email lookup.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const byEmail = await userProfilesRepository.findByEmail(normalizedEmail);
|
|
306
|
+
if (byEmail?.id) {
|
|
307
|
+
return byEmail;
|
|
308
|
+
}
|
|
309
|
+
fieldErrors.email = "User not found.";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
throw validationError(fieldErrors);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function authenticateDevAuthRequest(
|
|
316
|
+
{ request, accessToken = "", refreshToken = "" },
|
|
317
|
+
{ config, userProfilesRepository = null } = {}
|
|
318
|
+
) {
|
|
319
|
+
const hasDevAccessToken = isDevAuthToken(accessToken);
|
|
320
|
+
const hasDevRefreshToken = isDevAuthToken(refreshToken);
|
|
321
|
+
if (!hasDevAccessToken && !hasDevRefreshToken) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!config?.enabled || config?.isProduction || !config?.secret || !isLocalDevAuthRequest(request)) {
|
|
326
|
+
return {
|
|
327
|
+
authenticated: false,
|
|
328
|
+
clearSession: true,
|
|
329
|
+
session: null,
|
|
330
|
+
transientFailure: false
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (hasDevAccessToken) {
|
|
335
|
+
const accessVerification = await verifyDevAuthToken(accessToken, "access", config);
|
|
336
|
+
if (accessVerification.status === "valid") {
|
|
337
|
+
const profile = await resolveProfileFromTokenClaims(accessVerification.payload, {
|
|
338
|
+
userProfilesRepository
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
authenticated: true,
|
|
342
|
+
profile,
|
|
343
|
+
clearSession: false,
|
|
344
|
+
session: null,
|
|
345
|
+
transientFailure: false
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (accessVerification.status === "invalid" && !hasDevRefreshToken) {
|
|
350
|
+
return {
|
|
351
|
+
authenticated: false,
|
|
352
|
+
clearSession: true,
|
|
353
|
+
session: null,
|
|
354
|
+
transientFailure: false
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!hasDevRefreshToken) {
|
|
360
|
+
return {
|
|
361
|
+
authenticated: false,
|
|
362
|
+
clearSession: true,
|
|
363
|
+
session: null,
|
|
364
|
+
transientFailure: false
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const refreshVerification = await verifyDevAuthToken(refreshToken, "refresh", config);
|
|
369
|
+
if (refreshVerification.status !== "valid") {
|
|
370
|
+
return {
|
|
371
|
+
authenticated: false,
|
|
372
|
+
clearSession: true,
|
|
373
|
+
session: null,
|
|
374
|
+
transientFailure: false
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const profile = await resolveProfileFromTokenClaims(refreshVerification.payload, {
|
|
379
|
+
userProfilesRepository
|
|
380
|
+
});
|
|
381
|
+
return {
|
|
382
|
+
authenticated: true,
|
|
383
|
+
profile,
|
|
384
|
+
clearSession: false,
|
|
385
|
+
session: await createDevAuthSession(profile, config),
|
|
386
|
+
transientFailure: false
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export {
|
|
391
|
+
assertDevAuthBootstrapConfig,
|
|
392
|
+
authenticateDevAuthRequest,
|
|
393
|
+
createDevAuthSession,
|
|
394
|
+
ensureDevAuthBootstrapAvailable,
|
|
395
|
+
isDevAuthToken,
|
|
396
|
+
resolveDevAuthConfig,
|
|
397
|
+
resolveDevAuthProfile
|
|
398
|
+
};
|
package/src/server/lib/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createService, __testables } from "./service.js";
|
|
2
|
-
export {
|
|
2
|
+
export { baseAuthActions, buildAuthActions, devLoginAsAction } from "./actions/auth.contributor.js";
|
|
@@ -54,6 +54,14 @@ import { createAccountFlows } from "./accountFlows.js";
|
|
|
54
54
|
import { createOauthFlows } from "./oauthFlows.js";
|
|
55
55
|
import { createPasswordSecurityFlows } from "./passwordSecurityFlows.js";
|
|
56
56
|
import { USER_PROFILE_EMAIL_CONFLICT_CODE } from "./standaloneProfileSyncService.js";
|
|
57
|
+
import {
|
|
58
|
+
assertDevAuthBootstrapConfig,
|
|
59
|
+
authenticateDevAuthRequest,
|
|
60
|
+
createDevAuthSession,
|
|
61
|
+
ensureDevAuthBootstrapAvailable,
|
|
62
|
+
resolveDevAuthConfig,
|
|
63
|
+
resolveDevAuthProfile
|
|
64
|
+
} from "./devAuthBootstrap.js";
|
|
57
65
|
import {
|
|
58
66
|
buildOAuthProviderCatalogResponse,
|
|
59
67
|
resolveOAuthProviderQueryParams,
|
|
@@ -103,6 +111,7 @@ function createService(options) {
|
|
|
103
111
|
const supabaseUrl = String(authProvider.supabaseUrl || "").trim();
|
|
104
112
|
const supabasePublishableKey = String(authProvider.supabasePublishableKey || "").trim();
|
|
105
113
|
const userSettingsRepository = options.userSettingsRepository || null;
|
|
114
|
+
const userProfilesRepository = options.userProfilesRepository || null;
|
|
106
115
|
const userProfileSyncService = options.userProfileSyncService;
|
|
107
116
|
if (
|
|
108
117
|
!userProfileSyncService ||
|
|
@@ -113,6 +122,17 @@ function createService(options) {
|
|
|
113
122
|
}
|
|
114
123
|
const isProduction = options.nodeEnv === "production";
|
|
115
124
|
const jwtAudience = String(authProvider.jwtAudience || DEFAULT_AUDIENCE).trim();
|
|
125
|
+
const devAuthConfig = resolveDevAuthConfig({
|
|
126
|
+
enabled: options.devAuthBypassEnabled,
|
|
127
|
+
secret: options.devAuthBypassSecret,
|
|
128
|
+
nodeEnv: options.nodeEnv,
|
|
129
|
+
jwtAudience,
|
|
130
|
+
accessTtlSeconds: options.devAuthAccessTtlSeconds,
|
|
131
|
+
refreshTtlSeconds: options.devAuthRefreshTtlSeconds
|
|
132
|
+
});
|
|
133
|
+
assertDevAuthBootstrapConfig(devAuthConfig, {
|
|
134
|
+
userProfilesRepository
|
|
135
|
+
});
|
|
116
136
|
const settingsProfileAuthInfo = Object.freeze({
|
|
117
137
|
emailManagedBy: normalizeAuthProviderId(authProvider.emailManagedBy || authProviderId, { fallback: authProviderId }),
|
|
118
138
|
emailChangeFlow: normalizeAuthProviderId(authProvider.emailChangeFlow || authProviderId, { fallback: authProviderId })
|
|
@@ -651,12 +671,25 @@ function createService(options) {
|
|
|
651
671
|
});
|
|
652
672
|
|
|
653
673
|
async function authenticateRequest(request) {
|
|
654
|
-
ensureConfigured();
|
|
655
|
-
|
|
656
674
|
const cookies = safeRequestCookies(request);
|
|
657
675
|
const accessToken = String(cookies[ACCESS_TOKEN_COOKIE] || "");
|
|
658
676
|
const refreshToken = String(cookies[REFRESH_TOKEN_COOKIE] || "");
|
|
659
677
|
|
|
678
|
+
const devAuthResult = await authenticateDevAuthRequest(
|
|
679
|
+
{
|
|
680
|
+
request,
|
|
681
|
+
accessToken,
|
|
682
|
+
refreshToken
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
config: devAuthConfig,
|
|
686
|
+
userProfilesRepository
|
|
687
|
+
}
|
|
688
|
+
);
|
|
689
|
+
if (devAuthResult) {
|
|
690
|
+
return devAuthResult;
|
|
691
|
+
}
|
|
692
|
+
|
|
660
693
|
if (!accessToken && !refreshToken) {
|
|
661
694
|
return {
|
|
662
695
|
authenticated: false,
|
|
@@ -666,6 +699,8 @@ function createService(options) {
|
|
|
666
699
|
};
|
|
667
700
|
}
|
|
668
701
|
|
|
702
|
+
ensureConfigured();
|
|
703
|
+
|
|
669
704
|
if (accessToken) {
|
|
670
705
|
const verification = await verifyAccessToken(accessToken);
|
|
671
706
|
|
|
@@ -796,6 +831,22 @@ function createService(options) {
|
|
|
796
831
|
return authOAuthCatalogResponse;
|
|
797
832
|
}
|
|
798
833
|
|
|
834
|
+
function isDevAuthBootstrapEnabled() {
|
|
835
|
+
return devAuthConfig.enabled === true;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function devLoginAs(request, input = {}) {
|
|
839
|
+
ensureDevAuthBootstrapAvailable(devAuthConfig, request);
|
|
840
|
+
const profile = await resolveDevAuthProfile(input, {
|
|
841
|
+
userProfilesRepository,
|
|
842
|
+
validationError
|
|
843
|
+
});
|
|
844
|
+
return {
|
|
845
|
+
profile,
|
|
846
|
+
session: await createDevAuthSession(profile, devAuthConfig)
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
799
850
|
return {
|
|
800
851
|
register,
|
|
801
852
|
resendRegisterConfirmation,
|
|
@@ -816,6 +867,8 @@ function createService(options) {
|
|
|
816
867
|
getSecurityStatus,
|
|
817
868
|
getSettingsProfileAuthInfo,
|
|
818
869
|
getOAuthProviderCatalog,
|
|
870
|
+
isDevAuthBootstrapEnabled,
|
|
871
|
+
devLoginAs,
|
|
819
872
|
authenticateRequest,
|
|
820
873
|
hasAccessTokenCookie,
|
|
821
874
|
hasSessionCookie,
|
|
@@ -4,7 +4,7 @@ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
|
4
4
|
import { createService } from "../lib/service.js";
|
|
5
5
|
import { createStandaloneProfileSyncService } from "../lib/standaloneProfileSyncService.js";
|
|
6
6
|
import { createAuthSessionEventsService } from "../lib/authSessionEventsService.js";
|
|
7
|
-
import {
|
|
7
|
+
import { buildAuthActions } from "../lib/actions/auth.contributor.js";
|
|
8
8
|
const AUTH_PROFILE_MODE_STANDALONE = "standalone";
|
|
9
9
|
const AUTH_PROFILE_MODE_USERS = "users";
|
|
10
10
|
const SUPPORTED_AUTH_PROFILE_MODES = Object.freeze([AUTH_PROFILE_MODE_STANDALONE, AUTH_PROFILE_MODE_USERS]);
|
|
@@ -16,6 +16,20 @@ function splitCsv(value) {
|
|
|
16
16
|
.filter(Boolean);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function parseBoolean(value, fallback = false) {
|
|
20
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
if (["1", "true", "yes", "on"].includes(raw)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (["0", "false", "no", "off"].includes(raw)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
function normalizeRecord(value) {
|
|
20
34
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
21
35
|
}
|
|
@@ -88,6 +102,18 @@ function resolveAuthProfileMode(env) {
|
|
|
88
102
|
);
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
function isDevAuthBypassEnabledForRegistration(env) {
|
|
106
|
+
if (!isDevAuthBypassRequested(env)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return String(env?.NODE_ENV || "development").trim().toLowerCase() !== "production";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isDevAuthBypassRequested(env) {
|
|
114
|
+
return parseBoolean(env?.AUTH_DEV_BYPASS_ENABLED, false);
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
function createInMemoryUserSettingsRepository() {
|
|
92
118
|
const settingsByUserId = new Map();
|
|
93
119
|
|
|
@@ -137,10 +163,24 @@ function resolveCommonDependencies(scope) {
|
|
|
137
163
|
return dependencies;
|
|
138
164
|
}
|
|
139
165
|
|
|
166
|
+
function resolveRuntimeEnv(scope) {
|
|
167
|
+
const dependencies = resolveCommonDependencies(scope);
|
|
168
|
+
const envFromDependencies =
|
|
169
|
+
dependencies?.env && typeof dependencies.env === "object" ? dependencies.env : {};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
...process.env,
|
|
173
|
+
...envFromDependencies
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
140
177
|
function resolveOptionalRepositories(scope) {
|
|
141
178
|
const repositories = {};
|
|
142
|
-
if (scope.has("
|
|
143
|
-
repositories.userSettingsRepository = scope.make("
|
|
179
|
+
if (scope.has("internal.repository.user-settings")) {
|
|
180
|
+
repositories.userSettingsRepository = scope.make("internal.repository.user-settings");
|
|
181
|
+
}
|
|
182
|
+
if (scope.has("internal.repository.user-profiles")) {
|
|
183
|
+
repositories.userProfilesRepository = scope.make("internal.repository.user-profiles");
|
|
144
184
|
}
|
|
145
185
|
return repositories;
|
|
146
186
|
}
|
|
@@ -163,19 +203,16 @@ class AuthSupabaseServiceProvider {
|
|
|
163
203
|
|
|
164
204
|
if (!app.has("authService")) {
|
|
165
205
|
app.singleton("authService", (scope) => {
|
|
166
|
-
const
|
|
167
|
-
const envFromDependencies =
|
|
168
|
-
dependencies?.env && typeof dependencies.env === "object" ? dependencies.env : {};
|
|
169
|
-
const env = {
|
|
170
|
-
...process.env,
|
|
171
|
-
...envFromDependencies
|
|
172
|
-
};
|
|
206
|
+
const env = resolveRuntimeEnv(scope);
|
|
173
207
|
const appConfig = scope.has("appConfig") ? scope.make("appConfig") : {};
|
|
174
208
|
const authProvider = resolveAuthProviderConfig(env, appConfig);
|
|
175
209
|
const repositories = resolveOptionalRepositories(scope);
|
|
176
210
|
const userSettingsRepository = repositories.userSettingsRepository || fallbackUserSettingsRepository;
|
|
211
|
+
const devAuthBypassEnabled = parseBoolean(env.AUTH_DEV_BYPASS_ENABLED, false);
|
|
177
212
|
if (!authProvider.supabaseUrl || !authProvider.supabasePublishableKey) {
|
|
178
|
-
|
|
213
|
+
if (!devAuthBypassEnabled) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
179
216
|
}
|
|
180
217
|
const authProfileMode = resolveAuthProfileMode(env);
|
|
181
218
|
let userProfileSyncService = fallbackStandaloneProfileSyncService;
|
|
@@ -197,7 +234,12 @@ class AuthSupabaseServiceProvider {
|
|
|
197
234
|
}),
|
|
198
235
|
nodeEnv: String(env.NODE_ENV || "development").trim() || "development",
|
|
199
236
|
userSettingsRepository,
|
|
200
|
-
userProfileSyncService
|
|
237
|
+
userProfileSyncService,
|
|
238
|
+
userProfilesRepository: repositories.userProfilesRepository || null,
|
|
239
|
+
devAuthBypassEnabled,
|
|
240
|
+
devAuthBypassSecret: String(env.AUTH_DEV_BYPASS_SECRET || "").trim(),
|
|
241
|
+
devAuthAccessTtlSeconds: env.AUTH_DEV_ACCESS_TTL_SECONDS,
|
|
242
|
+
devAuthRefreshTtlSeconds: env.AUTH_DEV_REFRESH_TTL_SECONDS
|
|
201
243
|
});
|
|
202
244
|
});
|
|
203
245
|
}
|
|
@@ -236,7 +278,9 @@ class AuthSupabaseServiceProvider {
|
|
|
236
278
|
);
|
|
237
279
|
|
|
238
280
|
app.actions(
|
|
239
|
-
withActionDefaults(
|
|
281
|
+
withActionDefaults(buildAuthActions({
|
|
282
|
+
includeDevLoginAs: isDevAuthBypassEnabledForRegistration(resolveRuntimeEnv(app))
|
|
283
|
+
}), {
|
|
240
284
|
domain: "auth",
|
|
241
285
|
dependencies: {
|
|
242
286
|
authService: "authService",
|
|
@@ -245,6 +289,18 @@ class AuthSupabaseServiceProvider {
|
|
|
245
289
|
})
|
|
246
290
|
);
|
|
247
291
|
}
|
|
292
|
+
|
|
293
|
+
boot(app) {
|
|
294
|
+
if (!app || typeof app.make !== "function") {
|
|
295
|
+
throw new Error("AuthSupabaseServiceProvider requires application make().");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!isDevAuthBypassRequested(resolveRuntimeEnv(app))) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
app.make("authService");
|
|
303
|
+
}
|
|
248
304
|
}
|
|
249
305
|
|
|
250
306
|
export { AuthSupabaseServiceProvider };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createService } from "../src/server/lib/index.js";
|
|
4
|
+
|
|
5
|
+
function createProfile(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
id: "7",
|
|
8
|
+
email: "ada@example.com",
|
|
9
|
+
username: "ada",
|
|
10
|
+
displayName: "Ada Example",
|
|
11
|
+
authProvider: "supabase",
|
|
12
|
+
authProviderUserSid: "supabase-user-7",
|
|
13
|
+
avatarStorageKey: null,
|
|
14
|
+
avatarVersion: null,
|
|
15
|
+
...overrides
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createUserProfilesRepository(profile = createProfile()) {
|
|
20
|
+
return {
|
|
21
|
+
async findById(userId) {
|
|
22
|
+
return String(userId || "") === String(profile.id) ? profile : null;
|
|
23
|
+
},
|
|
24
|
+
async findByEmail(email) {
|
|
25
|
+
return String(email || "").trim().toLowerCase() === String(profile.email || "").toLowerCase() ? profile : null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createUserProfileSyncService() {
|
|
31
|
+
return {
|
|
32
|
+
async findByIdentity() {
|
|
33
|
+
return null;
|
|
34
|
+
},
|
|
35
|
+
async syncIdentityProfile(profile) {
|
|
36
|
+
return createProfile({
|
|
37
|
+
authProvider: String(profile?.authProvider || "supabase"),
|
|
38
|
+
authProviderUserSid: String(profile?.authProviderUserSid || "supabase-user-7"),
|
|
39
|
+
email: String(profile?.email || "ada@example.com").toLowerCase(),
|
|
40
|
+
displayName: String(profile?.displayName || "Ada Example")
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createLocalRequest(overrides = {}) {
|
|
47
|
+
const remoteAddress = String(overrides?.socket?.remoteAddress || overrides?.raw?.socket?.remoteAddress || "127.0.0.1");
|
|
48
|
+
return {
|
|
49
|
+
ip: "127.0.0.1",
|
|
50
|
+
hostname: "localhost",
|
|
51
|
+
headers: {
|
|
52
|
+
host: "localhost:3000"
|
|
53
|
+
},
|
|
54
|
+
cookies: {},
|
|
55
|
+
socket: {
|
|
56
|
+
remoteAddress
|
|
57
|
+
},
|
|
58
|
+
raw: {
|
|
59
|
+
socket: {
|
|
60
|
+
remoteAddress
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
...overrides
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createServiceFixture(overrides = {}) {
|
|
68
|
+
return createService({
|
|
69
|
+
authProvider: {
|
|
70
|
+
id: "supabase",
|
|
71
|
+
supabaseUrl: "",
|
|
72
|
+
supabasePublishableKey: "",
|
|
73
|
+
jwtAudience: "authenticated"
|
|
74
|
+
},
|
|
75
|
+
appPublicUrl: "http://localhost:5173",
|
|
76
|
+
nodeEnv: "development",
|
|
77
|
+
devAuthBypassEnabled: true,
|
|
78
|
+
devAuthBypassSecret: "dev-bootstrap-secret",
|
|
79
|
+
userProfilesRepository: createUserProfilesRepository(),
|
|
80
|
+
userProfileSyncService: createUserProfileSyncService(),
|
|
81
|
+
...overrides
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("dev auth bootstrap can issue and authenticate a local session without Supabase", async () => {
|
|
86
|
+
const authService = createServiceFixture();
|
|
87
|
+
const loginRequest = createLocalRequest();
|
|
88
|
+
|
|
89
|
+
const loginResult = await authService.devLoginAs(loginRequest, {
|
|
90
|
+
userId: "7"
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.equal(loginResult.profile.id, "7");
|
|
94
|
+
assert.match(loginResult.session.access_token, /^jskit-dev\./);
|
|
95
|
+
assert.match(loginResult.session.refresh_token, /^jskit-dev\./);
|
|
96
|
+
|
|
97
|
+
const authResult = await authService.authenticateRequest(
|
|
98
|
+
createLocalRequest({
|
|
99
|
+
cookies: {
|
|
100
|
+
sb_access_token: loginResult.session.access_token,
|
|
101
|
+
sb_refresh_token: loginResult.session.refresh_token
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
assert.equal(authResult.authenticated, true);
|
|
107
|
+
assert.equal(authResult.profile.id, "7");
|
|
108
|
+
assert.equal(authResult.profile.email, "ada@example.com");
|
|
109
|
+
assert.equal(authResult.session, null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("dev auth bootstrap supports email lookup", async () => {
|
|
113
|
+
const authService = createServiceFixture();
|
|
114
|
+
|
|
115
|
+
const result = await authService.devLoginAs(createLocalRequest(), {
|
|
116
|
+
email: "ADA@EXAMPLE.COM"
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.equal(result.profile.id, "7");
|
|
120
|
+
assert.equal(result.profile.email, "ada@example.com");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("dev auth bootstrap rejects non-local requests and clears leaked dev sessions", async () => {
|
|
124
|
+
const authService = createServiceFixture();
|
|
125
|
+
const issued = await authService.devLoginAs(createLocalRequest(), {
|
|
126
|
+
userId: "7"
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
() =>
|
|
131
|
+
authService.devLoginAs(
|
|
132
|
+
createLocalRequest({
|
|
133
|
+
ip: "203.0.113.10",
|
|
134
|
+
hostname: "example.com",
|
|
135
|
+
headers: { host: "example.com" }
|
|
136
|
+
}),
|
|
137
|
+
{ userId: "7" }
|
|
138
|
+
),
|
|
139
|
+
/localhost/
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const authResult = await authService.authenticateRequest({
|
|
143
|
+
ip: "203.0.113.10",
|
|
144
|
+
hostname: "example.com",
|
|
145
|
+
headers: { host: "example.com" },
|
|
146
|
+
cookies: {
|
|
147
|
+
sb_access_token: issued.session.access_token,
|
|
148
|
+
sb_refresh_token: issued.session.refresh_token
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
assert.equal(authResult.authenticated, false);
|
|
153
|
+
assert.equal(authResult.clearSession, true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("dev auth bootstrap does not trust forwarded localhost headers", async () => {
|
|
157
|
+
const authService = createServiceFixture();
|
|
158
|
+
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() =>
|
|
161
|
+
authService.devLoginAs(
|
|
162
|
+
createLocalRequest({
|
|
163
|
+
ip: "203.0.113.10",
|
|
164
|
+
socket: {
|
|
165
|
+
remoteAddress: "203.0.113.10"
|
|
166
|
+
},
|
|
167
|
+
raw: {
|
|
168
|
+
socket: {
|
|
169
|
+
remoteAddress: "203.0.113.10"
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
headers: {
|
|
173
|
+
host: "localhost:3000",
|
|
174
|
+
"x-forwarded-for": "127.0.0.1",
|
|
175
|
+
"x-forwarded-host": "localhost"
|
|
176
|
+
}
|
|
177
|
+
}),
|
|
178
|
+
{ userId: "7" }
|
|
179
|
+
),
|
|
180
|
+
/localhost/
|
|
181
|
+
);
|
|
182
|
+
});
|
|
@@ -21,6 +21,14 @@ function createAppConfigFixture() {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function isBootFailureWithCause(error, pattern) {
|
|
25
|
+
return (
|
|
26
|
+
error instanceof Error &&
|
|
27
|
+
/failed during boot\(\)/.test(String(error.message || "")) &&
|
|
28
|
+
pattern.test(String(error.details?.cause?.message || ""))
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
test("auth supabase provider registers authService and contributes auth actions in users mode", async () => {
|
|
25
33
|
const app = createApplication();
|
|
26
34
|
app.instance("appConfig", createAppConfigFixture());
|
|
@@ -69,6 +77,7 @@ test("auth supabase provider registers authService and contributes auth actions
|
|
|
69
77
|
assert.equal(Array.isArray(definitions), true);
|
|
70
78
|
assert.equal(definitions.some((definition) => definition.id === "auth.login.password"), true);
|
|
71
79
|
assert.equal(definitions.some((definition) => definition.id === "auth.register.confirmation.resend"), true);
|
|
80
|
+
assert.equal(definitions.some((definition) => definition.id === "auth.dev.loginAs"), false);
|
|
72
81
|
const sessionRead = definitions.find((definition) => definition.id === "auth.session.read");
|
|
73
82
|
assert.deepEqual(sessionRead?.surfaces, ["home", "console"]);
|
|
74
83
|
});
|
|
@@ -155,6 +164,206 @@ test("auth supabase provider rejects unsupported AUTH_PROFILE_MODE values", asyn
|
|
|
155
164
|
assert.throws(() => app.make("authService"), /Unsupported AUTH_PROFILE_MODE/);
|
|
156
165
|
});
|
|
157
166
|
|
|
167
|
+
test("auth supabase provider can boot dev auth without Supabase credentials", async () => {
|
|
168
|
+
const app = createApplication();
|
|
169
|
+
app.instance("appConfig", createAppConfigFixture());
|
|
170
|
+
app.instance("jskit.env", {
|
|
171
|
+
AUTH_DEV_BYPASS_ENABLED: "true",
|
|
172
|
+
AUTH_DEV_BYPASS_SECRET: "dev-bootstrap-secret",
|
|
173
|
+
AUTH_PROFILE_MODE: "users",
|
|
174
|
+
APP_PUBLIC_URL: "http://localhost:5173",
|
|
175
|
+
NODE_ENV: "development"
|
|
176
|
+
});
|
|
177
|
+
app.instance("jskit.logger", {
|
|
178
|
+
info() {},
|
|
179
|
+
warn() {},
|
|
180
|
+
error() {},
|
|
181
|
+
debug() {}
|
|
182
|
+
});
|
|
183
|
+
app.instance("domainEvents", {
|
|
184
|
+
async publish() {}
|
|
185
|
+
});
|
|
186
|
+
app.instance("users.profile.sync.service", {
|
|
187
|
+
async findByIdentity() {
|
|
188
|
+
return null;
|
|
189
|
+
},
|
|
190
|
+
async syncIdentityProfile(profile) {
|
|
191
|
+
return {
|
|
192
|
+
id: 1,
|
|
193
|
+
authProvider: String(profile?.authProvider || "supabase"),
|
|
194
|
+
authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
|
|
195
|
+
email: String(profile?.email || "test@example.com"),
|
|
196
|
+
displayName: String(profile?.displayName || "Test User")
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
app.instance("internal.repository.user-profiles", {
|
|
201
|
+
async findById() {
|
|
202
|
+
return null;
|
|
203
|
+
},
|
|
204
|
+
async findByEmail() {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await app.start({
|
|
210
|
+
providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const authService = app.make("authService");
|
|
214
|
+
assert.equal(typeof authService?.devLoginAs, "function");
|
|
215
|
+
assert.equal(typeof authService?.isDevAuthBootstrapEnabled, "function");
|
|
216
|
+
assert.equal(authService.isDevAuthBootstrapEnabled(), true);
|
|
217
|
+
|
|
218
|
+
const actionExecutor = app.make("actionExecutor");
|
|
219
|
+
const definitions = actionExecutor.listDefinitions();
|
|
220
|
+
assert.equal(definitions.some((definition) => definition.id === "auth.dev.loginAs"), true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("auth supabase provider rejects dev auth bypass in production", async () => {
|
|
224
|
+
const app = createApplication();
|
|
225
|
+
app.instance("appConfig", createAppConfigFixture());
|
|
226
|
+
app.instance("jskit.env", {
|
|
227
|
+
AUTH_DEV_BYPASS_ENABLED: "true",
|
|
228
|
+
AUTH_DEV_BYPASS_SECRET: "dev-bootstrap-secret",
|
|
229
|
+
AUTH_PROFILE_MODE: "users",
|
|
230
|
+
APP_PUBLIC_URL: "https://example.com",
|
|
231
|
+
NODE_ENV: "production"
|
|
232
|
+
});
|
|
233
|
+
app.instance("jskit.logger", {
|
|
234
|
+
info() {},
|
|
235
|
+
warn() {},
|
|
236
|
+
error() {},
|
|
237
|
+
debug() {}
|
|
238
|
+
});
|
|
239
|
+
app.instance("domainEvents", {
|
|
240
|
+
async publish() {}
|
|
241
|
+
});
|
|
242
|
+
app.instance("users.profile.sync.service", {
|
|
243
|
+
async findByIdentity() {
|
|
244
|
+
return null;
|
|
245
|
+
},
|
|
246
|
+
async syncIdentityProfile(profile) {
|
|
247
|
+
return {
|
|
248
|
+
id: 1,
|
|
249
|
+
authProvider: String(profile?.authProvider || "supabase"),
|
|
250
|
+
authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
|
|
251
|
+
email: String(profile?.email || "test@example.com"),
|
|
252
|
+
displayName: String(profile?.displayName || "Test User")
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
app.instance("internal.repository.user-profiles", {
|
|
257
|
+
async findById() {
|
|
258
|
+
return null;
|
|
259
|
+
},
|
|
260
|
+
async findByEmail() {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await assert.rejects(
|
|
266
|
+
() =>
|
|
267
|
+
app.start({
|
|
268
|
+
providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
|
|
269
|
+
}),
|
|
270
|
+
(error) => isBootFailureWithCause(error, /must not be enabled in production/)
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("auth supabase provider rejects dev auth bypass without a secret during boot", async () => {
|
|
275
|
+
const app = createApplication();
|
|
276
|
+
app.instance("appConfig", createAppConfigFixture());
|
|
277
|
+
app.instance("jskit.env", {
|
|
278
|
+
AUTH_DEV_BYPASS_ENABLED: "true",
|
|
279
|
+
AUTH_PROFILE_MODE: "users",
|
|
280
|
+
APP_PUBLIC_URL: "http://localhost:5173",
|
|
281
|
+
NODE_ENV: "development"
|
|
282
|
+
});
|
|
283
|
+
app.instance("jskit.logger", {
|
|
284
|
+
info() {},
|
|
285
|
+
warn() {},
|
|
286
|
+
error() {},
|
|
287
|
+
debug() {}
|
|
288
|
+
});
|
|
289
|
+
app.instance("domainEvents", {
|
|
290
|
+
async publish() {}
|
|
291
|
+
});
|
|
292
|
+
app.instance("users.profile.sync.service", {
|
|
293
|
+
async findByIdentity() {
|
|
294
|
+
return null;
|
|
295
|
+
},
|
|
296
|
+
async syncIdentityProfile(profile) {
|
|
297
|
+
return {
|
|
298
|
+
id: 1,
|
|
299
|
+
authProvider: String(profile?.authProvider || "supabase"),
|
|
300
|
+
authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
|
|
301
|
+
email: String(profile?.email || "test@example.com"),
|
|
302
|
+
displayName: String(profile?.displayName || "Test User")
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
app.instance("internal.repository.user-profiles", {
|
|
307
|
+
async findById() {
|
|
308
|
+
return null;
|
|
309
|
+
},
|
|
310
|
+
async findByEmail() {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await assert.rejects(
|
|
316
|
+
() =>
|
|
317
|
+
app.start({
|
|
318
|
+
providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
|
|
319
|
+
}),
|
|
320
|
+
(error) => isBootFailureWithCause(error, /AUTH_DEV_BYPASS_SECRET is required/)
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("auth supabase provider rejects dev auth bypass without internal.repository.user-profiles during boot", async () => {
|
|
325
|
+
const app = createApplication();
|
|
326
|
+
app.instance("appConfig", createAppConfigFixture());
|
|
327
|
+
app.instance("jskit.env", {
|
|
328
|
+
AUTH_DEV_BYPASS_ENABLED: "true",
|
|
329
|
+
AUTH_DEV_BYPASS_SECRET: "dev-bootstrap-secret",
|
|
330
|
+
AUTH_PROFILE_MODE: "users",
|
|
331
|
+
APP_PUBLIC_URL: "http://localhost:5173",
|
|
332
|
+
NODE_ENV: "development"
|
|
333
|
+
});
|
|
334
|
+
app.instance("jskit.logger", {
|
|
335
|
+
info() {},
|
|
336
|
+
warn() {},
|
|
337
|
+
error() {},
|
|
338
|
+
debug() {}
|
|
339
|
+
});
|
|
340
|
+
app.instance("domainEvents", {
|
|
341
|
+
async publish() {}
|
|
342
|
+
});
|
|
343
|
+
app.instance("users.profile.sync.service", {
|
|
344
|
+
async findByIdentity() {
|
|
345
|
+
return null;
|
|
346
|
+
},
|
|
347
|
+
async syncIdentityProfile(profile) {
|
|
348
|
+
return {
|
|
349
|
+
id: 1,
|
|
350
|
+
authProvider: String(profile?.authProvider || "supabase"),
|
|
351
|
+
authProviderUserSid: String(profile?.authProviderUserSid || "user-1"),
|
|
352
|
+
email: String(profile?.email || "test@example.com"),
|
|
353
|
+
displayName: String(profile?.displayName || "Test User")
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await assert.rejects(
|
|
359
|
+
() =>
|
|
360
|
+
app.start({
|
|
361
|
+
providers: [ActionRuntimeServiceProvider, AuthSupabaseServiceProvider]
|
|
362
|
+
}),
|
|
363
|
+
(error) => isBootFailureWithCause(error, /requires internal\.repository\.user-profiles with findById\(\) and findByEmail\(\)/)
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
158
367
|
test("auth supabase provider reads oauth providers from appConfig.auth.oauth", async () => {
|
|
159
368
|
const app = createApplication();
|
|
160
369
|
app.instance("appConfig", {
|