@camstack/server 1.2.479 → 1.2.481
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/dist/api/core/auth.router.js +188 -192
- package/dist/api/trpc/device-config-secret-redaction.js +111 -13
- package/dist/api/trpc/generated-cap-routers.js +54 -0
- package/dist/api/trpc/trpc.router.js +1 -1
- package/dist/auth/auth-provider-gate.js +19 -0
- package/dist/auth/external-link-intents.js +63 -0
- package/dist/auth/passkey-provider.js +96 -0
- package/dist/auth/second-factors.js +21 -0
- package/dist/auth/session-mint.js +0 -8
- package/dist/auth/sso-finish-deps.js +71 -0
- package/dist/auth/sso-finish.js +124 -38
- package/dist/core/addon/addon-registry.service.js +6 -3
- package/dist/core/addon/materialize-route-path.js +26 -0
- package/dist/main.js +16 -3
- package/package.json +8 -8
|
@@ -21,6 +21,10 @@ const types_1 = require("@camstack/types");
|
|
|
21
21
|
const server_1 = require("@trpc/server");
|
|
22
22
|
const zod_1 = require("zod");
|
|
23
23
|
const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
|
|
24
|
+
const auth_provider_gate_js_1 = require("../../auth/auth-provider-gate.js");
|
|
25
|
+
const external_link_intents_js_1 = require("../../auth/external-link-intents.js");
|
|
26
|
+
const passkey_provider_js_1 = require("../../auth/passkey-provider.js");
|
|
27
|
+
const second_factors_js_1 = require("../../auth/second-factors.js");
|
|
24
28
|
const session_cookie_js_1 = require("../../auth/session-cookie.js");
|
|
25
29
|
const session_mint_js_1 = require("../../auth/session-mint.js");
|
|
26
30
|
const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
|
|
@@ -63,7 +67,7 @@ const ceremonyProcedure = makeRateLimitedProcedure(CEREMONY_LIMITER);
|
|
|
63
67
|
* the `user-passkeys` cap. The client renders a chooser from this list
|
|
64
68
|
* so a future alternate 2FA cap works with zero login-flow changes.
|
|
65
69
|
*/
|
|
66
|
-
|
|
70
|
+
// `SecondFactorSchema` lives in `auth/second-factors.ts` (shared with the SSO finish).
|
|
67
71
|
/**
|
|
68
72
|
* Login response.
|
|
69
73
|
*
|
|
@@ -87,93 +91,8 @@ const LoginResultSchema = zod_1.z.object({
|
|
|
87
91
|
isAdmin: zod_1.z.boolean(),
|
|
88
92
|
}),
|
|
89
93
|
requiresTotp: zod_1.z.boolean().optional(),
|
|
90
|
-
secondFactors: zod_1.z.array(SecondFactorSchema).optional(),
|
|
94
|
+
secondFactors: zod_1.z.array(second_factors_js_1.SecondFactorSchema).optional(),
|
|
91
95
|
});
|
|
92
|
-
// ── Passkey (user-passkeys cap) resolution ───────────────────────────
|
|
93
|
-
//
|
|
94
|
-
// Cap-agnostic: the passkey provider is resolved through the
|
|
95
|
-
// `user-passkeys` CAPABILITY, never by addon id. Any addon registering
|
|
96
|
-
// the cap works. Resolution mirrors `hwaccel.router`:
|
|
97
|
-
// 1. hub-in-process collection provider (a future hub-resident impl),
|
|
98
|
-
// 2. else the hub-local forked child over UDS via the Moleculer cap
|
|
99
|
-
// proxy (`createCapabilityProxy(cap, 'hub')`) — the same routing the
|
|
100
|
-
// generated cap routers use.
|
|
101
|
-
// The proxy is loosely typed (`Record<method, (params)=>Promise<unknown>>`);
|
|
102
|
-
// its outputs are narrowed through the cap's own Zod schemas rather than
|
|
103
|
-
// cast to the provider type (repo rule: no `as` on cross-boundary values).
|
|
104
|
-
const OptionsJsonSchema = zod_1.z.object({ optionsJSON: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) });
|
|
105
|
-
const FinishRegistrationSchema = zod_1.z.object({ success: zod_1.z.literal(true), credentialId: zod_1.z.string() });
|
|
106
|
-
const FinishAuthenticationSchema = zod_1.z.object({ verified: zod_1.z.boolean() });
|
|
107
|
-
const FinishDiscoverableAuthenticationSchema = zod_1.z.object({
|
|
108
|
-
verified: zod_1.z.boolean(),
|
|
109
|
-
userId: zod_1.z.string().nullable(),
|
|
110
|
-
});
|
|
111
|
-
const RemovePasskeySchema = zod_1.z.object({ success: zod_1.z.literal(true) });
|
|
112
|
-
const SecondFactorPreferenceSchema = zod_1.z.object({ enabled: zod_1.z.boolean() });
|
|
113
|
-
const SetSecondFactorPreferenceSchema = zod_1.z.object({ success: zod_1.z.literal(true) });
|
|
114
|
-
function proxyPasskeyProvider(proxy) {
|
|
115
|
-
const call = async (method, params) => {
|
|
116
|
-
const fn = proxy[method];
|
|
117
|
-
if (typeof fn !== 'function')
|
|
118
|
-
throw new Error(`user-passkeys proxy missing method '${method}'`);
|
|
119
|
-
return fn(params);
|
|
120
|
-
};
|
|
121
|
-
return {
|
|
122
|
-
beginRegistration: async (input) => OptionsJsonSchema.parse(await call('beginRegistration', input)),
|
|
123
|
-
finishRegistration: async (input) => FinishRegistrationSchema.parse(await call('finishRegistration', input)),
|
|
124
|
-
beginAuthentication: async (input) => OptionsJsonSchema.parse(await call('beginAuthentication', input)),
|
|
125
|
-
finishAuthentication: async (input) => FinishAuthenticationSchema.parse(await call('finishAuthentication', input)),
|
|
126
|
-
beginDiscoverableAuthentication: async (input) => OptionsJsonSchema.parse(await call('beginDiscoverableAuthentication', input)),
|
|
127
|
-
finishDiscoverableAuthentication: async (input) => FinishDiscoverableAuthenticationSchema.parse(await call('finishDiscoverableAuthentication', input)),
|
|
128
|
-
listPasskeys: async (input) => zod_1.z.array(types_1.PasskeySummarySchema).parse(await call('listPasskeys', input)),
|
|
129
|
-
removePasskey: async (input) => RemovePasskeySchema.parse(await call('removePasskey', input)),
|
|
130
|
-
getSecondFactorPreference: async (input) => SecondFactorPreferenceSchema.parse(await call('getSecondFactorPreference', input)),
|
|
131
|
-
setSecondFactorPreference: async (input) => SetSecondFactorPreferenceSchema.parse(await call('setSecondFactorPreference', input)),
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
function resolvePasskeyProvider(registry, moleculer) {
|
|
135
|
-
const local = registry?.getCollection('user-passkeys')?.[0] ?? null;
|
|
136
|
-
if (local)
|
|
137
|
-
return local;
|
|
138
|
-
const proxy = moleculer?.createCapabilityProxy('user-passkeys', 'hub') ?? null;
|
|
139
|
-
return proxy ? proxyPasskeyProvider(proxy) : null;
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Whether the user has at least one enrolled passkey. Never throws — a
|
|
143
|
-
* missing provider or a transport hiccup degrades to `false` so the
|
|
144
|
-
* login flow is never blocked by passkey introspection.
|
|
145
|
-
*/
|
|
146
|
-
async function userHasPasskey(userId, provider) {
|
|
147
|
-
if (!provider)
|
|
148
|
-
return false;
|
|
149
|
-
try {
|
|
150
|
-
const list = await provider.listPasskeys({ userId });
|
|
151
|
-
return list.length > 0;
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Whether the user OPTED IN to their passkey doubling as a mandatory
|
|
159
|
-
* second factor after a password login. Enrolling a passkey alone only
|
|
160
|
-
* enables passkey-FIRST sign-in — this flag (default OFF, stored by the
|
|
161
|
-
* `user-passkeys` provider) is what re-arms the 2FA gate. Never throws:
|
|
162
|
-
* a missing provider, a provider predating the method, or a transport
|
|
163
|
-
* hiccup all read as `false` so the preference lookup can never lock a
|
|
164
|
-
* user out of the password flow.
|
|
165
|
-
*/
|
|
166
|
-
async function passkeySecondFactorOptedIn(userId, provider) {
|
|
167
|
-
if (!provider || typeof provider.getSecondFactorPreference !== 'function')
|
|
168
|
-
return false;
|
|
169
|
-
try {
|
|
170
|
-
const pref = await provider.getSecondFactorPreference({ userId });
|
|
171
|
-
return pref.enabled;
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
96
|
// ── Login-method aggregation (login-method cap) ──────────────────────
|
|
178
97
|
//
|
|
179
98
|
// The PUBLIC `listLoginMethods` procedure walks the `login-method`
|
|
@@ -197,6 +116,17 @@ const PublicLoginMethodSchema = zod_1.z.discriminatedUnion('kind', [
|
|
|
197
116
|
PublicWidgetLoginMethodSchema,
|
|
198
117
|
types_1.PasskeyLoginMethodSchema,
|
|
199
118
|
]);
|
|
119
|
+
/** One of the caller's linked sign-in identities, as the profile shows it (D782). */
|
|
120
|
+
const OwnExternalIdentitySchema = zod_1.z.object({
|
|
121
|
+
provider: zod_1.z.string(),
|
|
122
|
+
subject: zod_1.z.string(),
|
|
123
|
+
label: zod_1.z.string().optional(),
|
|
124
|
+
linkedAt: zod_1.z.number(),
|
|
125
|
+
/** The provider instance's button label, or `null` when it is gone / disabled. */
|
|
126
|
+
providerLabel: zod_1.z.string().nullable(),
|
|
127
|
+
/** Whether the provider instance is configured and enabled right now. */
|
|
128
|
+
available: zod_1.z.boolean(),
|
|
129
|
+
});
|
|
200
130
|
// ── Share tokens (grid-view standalone share links) ──────────────────
|
|
201
131
|
//
|
|
202
132
|
// Wire schemas for the `csv_*` share-token surface. The summary shape
|
|
@@ -324,7 +254,7 @@ const MeSchema = zod_1.z
|
|
|
324
254
|
scope: EffectiveScopeSchema.optional(),
|
|
325
255
|
})
|
|
326
256
|
.nullable();
|
|
327
|
-
function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService()) {
|
|
257
|
+
function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents()) {
|
|
328
258
|
const requireShareTokens = () => {
|
|
329
259
|
if (!shareTokens) {
|
|
330
260
|
throw new Error('Share tokens unavailable — service not wired on this node');
|
|
@@ -362,39 +292,91 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
362
292
|
throw error;
|
|
363
293
|
}
|
|
364
294
|
};
|
|
295
|
+
/** The second factors a primary login still owes (`auth/second-factors.ts`). */
|
|
296
|
+
const collectSecondFactorsFor = async (userId, primary) => {
|
|
297
|
+
const userMgmt = registry?.getSingleton('user-management');
|
|
298
|
+
return (0, second_factors_js_1.collectSecondFactors)({
|
|
299
|
+
...(userMgmt && typeof userMgmt.getTotpStatus === 'function'
|
|
300
|
+
? { getTotpStatus: (input) => userMgmt.getTotpStatus(input) }
|
|
301
|
+
: {}),
|
|
302
|
+
passkeyProvider: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer),
|
|
303
|
+
}, userId, primary);
|
|
304
|
+
};
|
|
365
305
|
/**
|
|
366
|
-
* The
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* • `totp` — whenever enrolled, regardless of how the user signed in.
|
|
370
|
-
* • `passkey` — ONLY when the user both has an enrolled passkey AND
|
|
371
|
-
* explicitly opted in (`getSecondFactorPreference`, default OFF —
|
|
372
|
-
* enrolling a passkey alone just enables passkey-first sign-in),
|
|
373
|
-
* and NEVER when the primary factor already WAS the passkey
|
|
374
|
-
* (re-proving the factor that just signed in adds nothing).
|
|
375
|
-
*
|
|
376
|
-
* Passkey introspection is resilient (missing provider / transport
|
|
377
|
-
* error ⇒ no passkey factor) so it can never block a login; a TOTP
|
|
378
|
-
* status failure still propagates — fail-open there would silently
|
|
379
|
-
* skip an enrolled factor.
|
|
306
|
+
* The login page's methods: every enabled auth addon's `login-method`
|
|
307
|
+
* contributions (D777), validated and deduplicated. Shared by
|
|
308
|
+
* `listLoginMethods` and the D782 linking procedures.
|
|
380
309
|
*/
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
310
|
+
const aggregateLoginMethods = async () => {
|
|
311
|
+
if (!registry)
|
|
312
|
+
return [];
|
|
313
|
+
const out = [];
|
|
314
|
+
// Defense-in-depth dedupe: a routing bug in a grouped runner once
|
|
315
|
+
// resolved every provider to the same addon (3× identical magic-link
|
|
316
|
+
// buttons) — a duplicated contribution id must never render twice.
|
|
317
|
+
const seenIds = new Set();
|
|
318
|
+
for (const [addonId, provider] of registry.getCollectionEntries('login-method')) {
|
|
319
|
+
// The operator's Enable/Disable on Identity → Authentication is the
|
|
320
|
+
// addon's ONE enable (D777). It toggles `auth-provider` /
|
|
321
|
+
// `user-passkeys`, never `login-method` — so ask the governing caps.
|
|
322
|
+
if ((0, auth_provider_gate_js_1.isAuthAddonDisabled)(registry, addonId))
|
|
323
|
+
continue;
|
|
324
|
+
let contributions;
|
|
325
|
+
try {
|
|
326
|
+
contributions = await provider.getLoginMethods();
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
for (const raw of contributions) {
|
|
332
|
+
if (seenIds.has(raw.id))
|
|
333
|
+
continue;
|
|
334
|
+
if (raw.kind === 'redirect') {
|
|
335
|
+
const parsed = types_1.RedirectLoginMethodSchema.safeParse(raw);
|
|
336
|
+
if (parsed.success) {
|
|
337
|
+
out.push(parsed.data);
|
|
338
|
+
seenIds.add(parsed.data.id);
|
|
339
|
+
}
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (raw.kind === 'passkey') {
|
|
343
|
+
// Native ceremony — passes through untouched, no server-side
|
|
344
|
+
// enrichment (rpId/origin are already stamped by the addon).
|
|
345
|
+
const parsed = types_1.PasskeyLoginMethodSchema.safeParse(raw);
|
|
346
|
+
if (parsed.success) {
|
|
347
|
+
out.push(parsed.data);
|
|
348
|
+
seenIds.add(parsed.data.id);
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// Widget arm — stamp a public bundleUrl with the version in the
|
|
353
|
+
// PATH, not a `?v=` query. Module-Federation bundles import their
|
|
354
|
+
// fixed-name siblings (`./_stub.js`) by RELATIVE url, which never
|
|
355
|
+
// inherit a query — so a `?v=` busts only the entry and a CDN edge
|
|
356
|
+
// keeps serving a stale `_stub.js` (missing the newest widget). A
|
|
357
|
+
// path prefix makes every relative import inherit it → one atomic
|
|
358
|
+
// whole-bundle cache miss per deploy, on any CDN. `AUTH_BUNDLE_VERSION`
|
|
359
|
+
// is stable per hub process (busts on restart/deploy, caches within
|
|
360
|
+
// uptime). The `/api/addon-widgets` route strips the `v<…>/` segment.
|
|
361
|
+
const parsed = PublicWidgetLoginMethodSchema.safeParse({
|
|
362
|
+
...raw,
|
|
363
|
+
bundleUrl: `/api/addon-widgets/${raw.addonId}/v${AUTH_BUNDLE_VERSION}/${raw.bundle}`,
|
|
364
|
+
});
|
|
365
|
+
if (parsed.success) {
|
|
366
|
+
out.push(parsed.data);
|
|
367
|
+
seenIds.add(parsed.data.id);
|
|
368
|
+
}
|
|
394
369
|
}
|
|
395
370
|
}
|
|
396
|
-
return
|
|
371
|
+
return out;
|
|
397
372
|
};
|
|
373
|
+
/**
|
|
374
|
+
* Sign-in providers a signed-in user may link (D782): an enabled provider's
|
|
375
|
+
* redirect method that declares `linkable: true` — the contract, not its id.
|
|
376
|
+
*/
|
|
377
|
+
const linkableProviders = async () => (await aggregateLoginMethods()).flatMap((m) => m.kind === 'redirect' && m.linkable === true
|
|
378
|
+
? [{ provider: m.id, label: m.label, startUrl: m.startUrl }]
|
|
379
|
+
: []);
|
|
398
380
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
399
381
|
login: credentialProcedure
|
|
400
382
|
.input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
|
|
@@ -428,7 +410,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
428
410
|
// protected endpoints (the auth middleware rejects anything
|
|
429
411
|
// without the standard session shape); the same token binds
|
|
430
412
|
// both the TOTP and the passkey second legs (same userId).
|
|
431
|
-
const secondFactors = await
|
|
413
|
+
const secondFactors = await collectSecondFactorsFor(user.id, 'password');
|
|
432
414
|
if (secondFactors.length > 0) {
|
|
433
415
|
const challengeToken = auth.signTotpChallengeToken({
|
|
434
416
|
userId: user.id,
|
|
@@ -487,13 +469,13 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
487
469
|
*/
|
|
488
470
|
loginBeginPasskey: ceremonyProcedure
|
|
489
471
|
.input(zod_1.z.object({ challengeToken: zod_1.z.string() }))
|
|
490
|
-
.output(OptionsJsonSchema)
|
|
472
|
+
.output(passkey_provider_js_1.OptionsJsonSchema)
|
|
491
473
|
.mutation(async ({ input }) => {
|
|
492
474
|
const claims = auth.verifyTotpChallengeToken(input.challengeToken);
|
|
493
475
|
if (!claims) {
|
|
494
476
|
throw new Error('Invalid or expired login challenge — please re-enter your password');
|
|
495
477
|
}
|
|
496
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
478
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
497
479
|
if (!provider)
|
|
498
480
|
throw new Error('Passkey authentication is not available');
|
|
499
481
|
return provider.beginAuthentication({ userId: claims.userId });
|
|
@@ -513,7 +495,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
513
495
|
if (!claims) {
|
|
514
496
|
throw new Error('Invalid or expired login challenge — please re-enter your password');
|
|
515
497
|
}
|
|
516
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
498
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
517
499
|
if (!provider)
|
|
518
500
|
throw new Error('Passkey authentication is not available');
|
|
519
501
|
const result = await provider.finishAuthentication({
|
|
@@ -539,9 +521,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
539
521
|
*/
|
|
540
522
|
passkeyLoginBegin: ceremonyProcedure
|
|
541
523
|
.input(zod_1.z.void())
|
|
542
|
-
.output(OptionsJsonSchema)
|
|
524
|
+
.output(passkey_provider_js_1.OptionsJsonSchema)
|
|
543
525
|
.mutation(async () => {
|
|
544
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
526
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
545
527
|
if (!provider)
|
|
546
528
|
throw new Error('Passkey authentication is not available');
|
|
547
529
|
return provider.beginDiscoverableAuthentication({});
|
|
@@ -565,7 +547,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
565
547
|
.input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
|
|
566
548
|
.output(LoginResultSchema)
|
|
567
549
|
.mutation(async ({ input }) => {
|
|
568
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
550
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
569
551
|
if (!provider)
|
|
570
552
|
throw new Error('Passkey authentication is not available');
|
|
571
553
|
const result = await provider.finishDiscoverableAuthentication({
|
|
@@ -574,7 +556,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
574
556
|
if (!result.verified || !result.userId) {
|
|
575
557
|
throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Passkey verification failed' });
|
|
576
558
|
}
|
|
577
|
-
const secondFactors = await
|
|
559
|
+
const secondFactors = await collectSecondFactorsFor(result.userId, 'passkey');
|
|
578
560
|
if (secondFactors.length > 0) {
|
|
579
561
|
const challenged = await findUserById(result.userId);
|
|
580
562
|
const challengeToken = auth.signTotpChallengeToken({
|
|
@@ -808,22 +790,22 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
808
790
|
// the `user-passkeys` cap, never an addon id.
|
|
809
791
|
beginOwnPasskeyRegistration: trpc_middleware_js_1.protectedProcedure
|
|
810
792
|
.input(zod_1.z.void())
|
|
811
|
-
.output(OptionsJsonSchema)
|
|
793
|
+
.output(passkey_provider_js_1.OptionsJsonSchema)
|
|
812
794
|
.mutation(async ({ ctx }) => {
|
|
813
795
|
if (!ctx.user)
|
|
814
796
|
throw new Error('Not authenticated');
|
|
815
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
797
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
816
798
|
if (!provider)
|
|
817
799
|
throw new Error('Passkey enrollment is not available');
|
|
818
800
|
return provider.beginRegistration({ userId: ctx.user.id, username: ctx.user.username });
|
|
819
801
|
}),
|
|
820
802
|
finishOwnPasskeyRegistration: trpc_middleware_js_1.protectedProcedure
|
|
821
803
|
.input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()), label: zod_1.z.string() }))
|
|
822
|
-
.output(FinishRegistrationSchema)
|
|
804
|
+
.output(passkey_provider_js_1.FinishRegistrationSchema)
|
|
823
805
|
.mutation(async ({ input, ctx }) => {
|
|
824
806
|
if (!ctx.user)
|
|
825
807
|
throw new Error('Not authenticated');
|
|
826
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
808
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
827
809
|
if (!provider)
|
|
828
810
|
throw new Error('Passkey enrollment is not available');
|
|
829
811
|
return provider.finishRegistration({
|
|
@@ -838,7 +820,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
838
820
|
.query(async ({ ctx }) => {
|
|
839
821
|
if (!ctx.user)
|
|
840
822
|
return [];
|
|
841
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
823
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
842
824
|
if (!provider)
|
|
843
825
|
return [];
|
|
844
826
|
try {
|
|
@@ -854,7 +836,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
854
836
|
.mutation(async ({ input, ctx }) => {
|
|
855
837
|
if (!ctx.user)
|
|
856
838
|
throw new Error('Not authenticated');
|
|
857
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
839
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
858
840
|
if (!provider)
|
|
859
841
|
throw new Error('Passkey management is not available');
|
|
860
842
|
return provider.removePasskey({ userId: ctx.user.id, credentialId: input.credentialId });
|
|
@@ -869,11 +851,11 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
869
851
|
*/
|
|
870
852
|
getOwnPasskeySecondFactorPreference: trpc_middleware_js_1.protectedProcedure
|
|
871
853
|
.input(zod_1.z.void())
|
|
872
|
-
.output(SecondFactorPreferenceSchema)
|
|
854
|
+
.output(passkey_provider_js_1.SecondFactorPreferenceSchema)
|
|
873
855
|
.query(async ({ ctx }) => {
|
|
874
856
|
if (!ctx.user)
|
|
875
857
|
return { enabled: false };
|
|
876
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
858
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
877
859
|
if (!provider)
|
|
878
860
|
return { enabled: false };
|
|
879
861
|
try {
|
|
@@ -885,11 +867,11 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
885
867
|
}),
|
|
886
868
|
setOwnPasskeySecondFactorPreference: trpc_middleware_js_1.protectedProcedure
|
|
887
869
|
.input(zod_1.z.object({ enabled: zod_1.z.boolean() }))
|
|
888
|
-
.output(SetSecondFactorPreferenceSchema)
|
|
870
|
+
.output(passkey_provider_js_1.SetSecondFactorPreferenceSchema)
|
|
889
871
|
.mutation(async ({ input, ctx }) => {
|
|
890
872
|
if (!ctx.user)
|
|
891
873
|
throw new Error('Not authenticated');
|
|
892
|
-
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
874
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
|
|
893
875
|
if (!provider)
|
|
894
876
|
throw new Error('Passkey management is not available');
|
|
895
877
|
return provider.setSecondFactorPreference({
|
|
@@ -973,6 +955,74 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
973
955
|
.input(zod_1.z.void())
|
|
974
956
|
.output(zod_1.z.object({ success: zod_1.z.literal(true) }))
|
|
975
957
|
.mutation(() => ({ success: true })),
|
|
958
|
+
// ── External sign-in identities (D782) ────────────────────────
|
|
959
|
+
//
|
|
960
|
+
// A signed-in user links an OIDC identity to THEIR OWN account. The
|
|
961
|
+
// intent is minted for `ctx.user` here and resolved by the SSO finish,
|
|
962
|
+
// which also requires the same user's session cookie — the user id never
|
|
963
|
+
// travels through the browser.
|
|
964
|
+
/** Sign-in providers the caller can link (redirect methods declaring `linkable`). */
|
|
965
|
+
listLinkableProviders: trpc_middleware_js_1.protectedProcedure
|
|
966
|
+
.input(zod_1.z.void())
|
|
967
|
+
.output(zod_1.z.array(zod_1.z.object({ provider: zod_1.z.string(), label: zod_1.z.string() })))
|
|
968
|
+
.query(async () => (await linkableProviders()).map(({ provider, label }) => ({ provider, label }))),
|
|
969
|
+
/** Start linking `provider` to the caller; the client navigates to `url`. */
|
|
970
|
+
beginExternalLink: trpc_middleware_js_1.protectedProcedure
|
|
971
|
+
.input(zod_1.z.object({ provider: zod_1.z.string().min(1) }))
|
|
972
|
+
.output(zod_1.z.object({ url: zod_1.z.string() }))
|
|
973
|
+
.mutation(async ({ ctx, input }) => {
|
|
974
|
+
if (!ctx.user)
|
|
975
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
|
|
976
|
+
assertRealUserSession(ctx.user, 'Linking a sign-in account');
|
|
977
|
+
const target = (await linkableProviders()).find((p) => p.provider === input.provider);
|
|
978
|
+
if (target === undefined) {
|
|
979
|
+
throw new server_1.TRPCError({
|
|
980
|
+
code: 'BAD_REQUEST',
|
|
981
|
+
message: `Sign-in provider "${input.provider}" is not available for linking`,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
const handle = linkIntents.create(ctx.user.id, target.provider);
|
|
985
|
+
const separator = target.startUrl.includes('?') ? '&' : '?';
|
|
986
|
+
return { url: `${target.startUrl}${separator}link=${encodeURIComponent(handle)}` };
|
|
987
|
+
}),
|
|
988
|
+
/** The caller's linked identities; `available: false` when the provider instance is gone or off. */
|
|
989
|
+
listOwnExternalIdentities: trpc_middleware_js_1.protectedProcedure
|
|
990
|
+
.input(zod_1.z.void())
|
|
991
|
+
.output(zod_1.z.array(OwnExternalIdentitySchema))
|
|
992
|
+
.query(async ({ ctx }) => {
|
|
993
|
+
if (!ctx.user)
|
|
994
|
+
return [];
|
|
995
|
+
const userMgmt = registry?.getSingleton('user-management');
|
|
996
|
+
if (!userMgmt || typeof userMgmt.listExternalIdentities !== 'function')
|
|
997
|
+
return [];
|
|
998
|
+
const labels = new Map((await linkableProviders()).map((p) => [p.provider, p.label]));
|
|
999
|
+
const links = await userMgmt.listExternalIdentities({ userId: ctx.user.id });
|
|
1000
|
+
return links.map((link) => ({
|
|
1001
|
+
provider: link.provider,
|
|
1002
|
+
subject: link.subject,
|
|
1003
|
+
...(link.label !== undefined ? { label: link.label } : {}),
|
|
1004
|
+
linkedAt: link.linkedAt,
|
|
1005
|
+
providerLabel: labels.get(link.provider) ?? null,
|
|
1006
|
+
available: labels.has(link.provider),
|
|
1007
|
+
}));
|
|
1008
|
+
}),
|
|
1009
|
+
/** Remove one of the caller's own links. */
|
|
1010
|
+
unlinkOwnExternalIdentity: trpc_middleware_js_1.protectedProcedure
|
|
1011
|
+
.input(zod_1.z.object({ provider: zod_1.z.string().min(1), subject: zod_1.z.string().min(1) }))
|
|
1012
|
+
.output(zod_1.z.object({ success: zod_1.z.boolean() }))
|
|
1013
|
+
.mutation(async ({ ctx, input }) => {
|
|
1014
|
+
if (!ctx.user)
|
|
1015
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
|
|
1016
|
+
assertRealUserSession(ctx.user, 'Unlinking a sign-in account');
|
|
1017
|
+
const userMgmt = registry?.getSingleton('user-management');
|
|
1018
|
+
if (!userMgmt)
|
|
1019
|
+
throw new Error('user-management capability not available');
|
|
1020
|
+
return userMgmt.unlinkExternalIdentity({
|
|
1021
|
+
userId: ctx.user.id,
|
|
1022
|
+
provider: input.provider,
|
|
1023
|
+
subject: input.subject,
|
|
1024
|
+
});
|
|
1025
|
+
}),
|
|
976
1026
|
/**
|
|
977
1027
|
* PUBLIC — the login page's single source of pre-auth login surfaces.
|
|
978
1028
|
* Aggregates the `login-method` collection: every auth addon (OIDC,
|
|
@@ -982,6 +1032,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
982
1032
|
* `widget` methods via `loadRemoteBundle` — a future SSO addon plugs
|
|
983
1033
|
* in with ZERO shell change.
|
|
984
1034
|
*
|
|
1035
|
+
* A provider disabled on Identity → Authentication contributes nothing
|
|
1036
|
+
* (D777: that toggle is an auth addon's only enable).
|
|
1037
|
+
*
|
|
985
1038
|
* A failing / slow provider is skipped independently (mirrors
|
|
986
1039
|
* `listProviders`' old resilience) so one bad addon can't lock every
|
|
987
1040
|
* login method out of the UI.
|
|
@@ -989,63 +1042,6 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
989
1042
|
listLoginMethods: trpc_middleware_js_1.publicProcedure
|
|
990
1043
|
.input(zod_1.z.void())
|
|
991
1044
|
.output(zod_1.z.array(PublicLoginMethodSchema).readonly())
|
|
992
|
-
.query(async () =>
|
|
993
|
-
if (!registry)
|
|
994
|
-
return [];
|
|
995
|
-
const out = [];
|
|
996
|
-
// Defense-in-depth dedupe: a routing bug in a grouped runner once
|
|
997
|
-
// resolved every provider to the same addon (3× identical magic-link
|
|
998
|
-
// buttons) — a duplicated contribution id must never render twice.
|
|
999
|
-
const seenIds = new Set();
|
|
1000
|
-
for (const provider of registry.getCollection('login-method')) {
|
|
1001
|
-
let contributions;
|
|
1002
|
-
try {
|
|
1003
|
-
contributions = await provider.getLoginMethods();
|
|
1004
|
-
}
|
|
1005
|
-
catch {
|
|
1006
|
-
continue;
|
|
1007
|
-
}
|
|
1008
|
-
for (const raw of contributions) {
|
|
1009
|
-
if (seenIds.has(raw.id))
|
|
1010
|
-
continue;
|
|
1011
|
-
if (raw.kind === 'redirect') {
|
|
1012
|
-
const parsed = types_1.RedirectLoginMethodSchema.safeParse(raw);
|
|
1013
|
-
if (parsed.success) {
|
|
1014
|
-
out.push(parsed.data);
|
|
1015
|
-
seenIds.add(parsed.data.id);
|
|
1016
|
-
}
|
|
1017
|
-
continue;
|
|
1018
|
-
}
|
|
1019
|
-
if (raw.kind === 'passkey') {
|
|
1020
|
-
// Native ceremony — passes through untouched, no server-side
|
|
1021
|
-
// enrichment (rpId/origin are already stamped by the addon).
|
|
1022
|
-
const parsed = types_1.PasskeyLoginMethodSchema.safeParse(raw);
|
|
1023
|
-
if (parsed.success) {
|
|
1024
|
-
out.push(parsed.data);
|
|
1025
|
-
seenIds.add(parsed.data.id);
|
|
1026
|
-
}
|
|
1027
|
-
continue;
|
|
1028
|
-
}
|
|
1029
|
-
// Widget arm — stamp a public bundleUrl with the version in the
|
|
1030
|
-
// PATH, not a `?v=` query. Module-Federation bundles import their
|
|
1031
|
-
// fixed-name siblings (`./_stub.js`) by RELATIVE url, which never
|
|
1032
|
-
// inherit a query — so a `?v=` busts only the entry and a CDN edge
|
|
1033
|
-
// keeps serving a stale `_stub.js` (missing the newest widget). A
|
|
1034
|
-
// path prefix makes every relative import inherit it → one atomic
|
|
1035
|
-
// whole-bundle cache miss per deploy, on any CDN. `AUTH_BUNDLE_VERSION`
|
|
1036
|
-
// is stable per hub process (busts on restart/deploy, caches within
|
|
1037
|
-
// uptime). The `/api/addon-widgets` route strips the `v<…>/` segment.
|
|
1038
|
-
const parsed = PublicWidgetLoginMethodSchema.safeParse({
|
|
1039
|
-
...raw,
|
|
1040
|
-
bundleUrl: `/api/addon-widgets/${raw.addonId}/v${AUTH_BUNDLE_VERSION}/${raw.bundle}`,
|
|
1041
|
-
});
|
|
1042
|
-
if (parsed.success) {
|
|
1043
|
-
out.push(parsed.data);
|
|
1044
|
-
seenIds.add(parsed.data.id);
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
return out;
|
|
1049
|
-
}),
|
|
1045
|
+
.query(async () => aggregateLoginMethods()),
|
|
1050
1046
|
});
|
|
1051
1047
|
}
|