@camstack/server 1.2.574 → 1.2.576
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 +21 -4
- package/dist/api/core/cap-providers.js +1 -1
- package/dist/auth/app-confirm-page.js +64 -0
- package/dist/auth/app-confirm-routes.js +107 -0
- package/dist/auth/app-confirmations.js +128 -0
- package/dist/auth/app-passkey-page.js +135 -0
- package/dist/auth/app-passkey-routes.js +152 -0
- package/dist/auth/sso-finish-app.js +96 -0
- package/dist/auth/sso-finish.js +6 -0
- package/dist/auth/sso-routes.js +46 -0
- package/dist/core/addon/addon-package.service.js +86 -3
- package/dist/core/auth/handoff-code.service.js +1 -0
- package/dist/main.js +37 -0
- package/package.json +5 -5
|
@@ -21,6 +21,7 @@ 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_network_policy_js_1 = require("../../auth/auth-network-policy.js");
|
|
24
|
+
const app_passkey_routes_js_1 = require("../../auth/app-passkey-routes.js");
|
|
24
25
|
const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
|
|
25
26
|
const auth_provider_gate_js_1 = require("../../auth/auth-provider-gate.js");
|
|
26
27
|
const device_enroll_proofs_js_1 = require("../../auth/device-enroll-proofs.js");
|
|
@@ -142,10 +143,16 @@ const PublicWidgetLoginMethodSchema = types_1.WidgetLoginMethodSchema.extend({
|
|
|
142
143
|
/** Stable per hub PROCESS — busts the login widget bundle URL on each
|
|
143
144
|
* restart/deploy while caching cleanly within the process's uptime. */
|
|
144
145
|
const AUTH_BUNDLE_VERSION = String(Date.now());
|
|
146
|
+
// Every arm of the PUBLIC list says how a native app starts it (D858): the hub
|
|
147
|
+
// always sends `app` (`null` when there is no app path - the viewer hides the
|
|
148
|
+
// method). It stays optional in the TYPE so the admin-ui's hand-mirrored arms,
|
|
149
|
+
// which never read it, keep matching this union.
|
|
150
|
+
const PublicRedirectLoginMethodSchema = types_1.RedirectLoginMethodSchema;
|
|
151
|
+
const PublicPasskeyLoginMethodSchema = types_1.PasskeyLoginMethodSchema;
|
|
145
152
|
const PublicLoginMethodSchema = zod_1.z.discriminatedUnion('kind', [
|
|
146
|
-
|
|
153
|
+
PublicRedirectLoginMethodSchema,
|
|
147
154
|
PublicWidgetLoginMethodSchema,
|
|
148
|
-
|
|
155
|
+
PublicPasskeyLoginMethodSchema,
|
|
149
156
|
types_1.DeviceCertificateLoginMethodSchema,
|
|
150
157
|
]);
|
|
151
158
|
/** One of the caller's linked sign-in identities, as the profile shows it (D782). */
|
|
@@ -477,7 +484,10 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
477
484
|
if (seenIds.has(raw.id))
|
|
478
485
|
continue;
|
|
479
486
|
if (raw.kind === 'redirect') {
|
|
480
|
-
const parsed =
|
|
487
|
+
const parsed = PublicRedirectLoginMethodSchema.safeParse({
|
|
488
|
+
...raw,
|
|
489
|
+
app: raw.app ?? null,
|
|
490
|
+
});
|
|
481
491
|
if (parsed.success) {
|
|
482
492
|
out.push(parsed.data);
|
|
483
493
|
seenIds.add(parsed.data.id);
|
|
@@ -487,7 +497,12 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
487
497
|
if (raw.kind === 'passkey') {
|
|
488
498
|
// Native ceremony — passes through untouched, no server-side
|
|
489
499
|
// enrichment (rpId/origin are already stamped by the addon).
|
|
490
|
-
|
|
500
|
+
// The passkey page is the hub's own (D858): the primary arm runs in the
|
|
501
|
+
// system browser at it; a second-factor ceremony has no app path.
|
|
502
|
+
const parsed = PublicPasskeyLoginMethodSchema.safeParse({
|
|
503
|
+
...raw,
|
|
504
|
+
app: raw.stage === 'primary' ? { kind: 'browser', startPath: app_passkey_routes_js_1.APP_PASSKEY_PATH } : null,
|
|
505
|
+
});
|
|
491
506
|
if (parsed.success) {
|
|
492
507
|
out.push(parsed.data);
|
|
493
508
|
seenIds.add(parsed.data.id);
|
|
@@ -505,6 +520,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
505
520
|
// uptime). The `/api/addon-widgets` route strips the `v<…>/` segment.
|
|
506
521
|
const parsed = PublicWidgetLoginMethodSchema.safeParse({
|
|
507
522
|
...raw,
|
|
523
|
+
app: null,
|
|
508
524
|
bundleUrl: `/api/addon-widgets/${raw.addonId}/v${AUTH_BUNDLE_VERSION}/${raw.bundle}`,
|
|
509
525
|
});
|
|
510
526
|
if (parsed.success) {
|
|
@@ -530,6 +546,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
530
546
|
signature: types_1.DEVICE_CERT_SIGNATURE,
|
|
531
547
|
messageVersion: types_1.DEVICE_SIGNATURE_MESSAGE_VERSION,
|
|
532
548
|
clients: ['native'],
|
|
549
|
+
app: { kind: 'native' },
|
|
533
550
|
});
|
|
534
551
|
return out;
|
|
535
552
|
};
|
|
@@ -1532,7 +1532,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentReg
|
|
|
1532
1532
|
listInstallations: async (input) => (0, addon_uninstall_js_1.listInstallations)(uninstallSeam, input.packageName),
|
|
1533
1533
|
reloadPackages: async () => ps.reloadPackages(),
|
|
1534
1534
|
searchAvailable: async (input) => {
|
|
1535
|
-
const results = await ps.searchNpm(input?.query);
|
|
1535
|
+
const results = await ps.searchNpm(input?.query, input?.capability);
|
|
1536
1536
|
const installedIds = new Set(ps.listInstalled().map((p) => p.name));
|
|
1537
1537
|
return results.map((r) => ({
|
|
1538
1538
|
...r,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.APP_CONFIRM_REFUSAL_CSP = exports.APP_CONFIRM_CSP = void 0;
|
|
4
|
+
exports.appConfirmPage = appConfirmPage;
|
|
5
|
+
exports.appConfirmRefusalPage = appConfirmRefusalPage;
|
|
6
|
+
/**
|
|
7
|
+
* The hub-rendered confirmation page of a native app sign-in (D858):
|
|
8
|
+
* "Sign in the CamStack app as <username>?".
|
|
9
|
+
*
|
|
10
|
+
* Same discipline as the passkey page (`app-passkey-page.ts`): plain HTML, one
|
|
11
|
+
* inline script and one style pinned by SHA-256 in the CSP, same-origin
|
|
12
|
+
* requests only, no framing, no third-party asset. The page carries the
|
|
13
|
+
* one-shot token in a `data-` attribute (never in the script), so the script
|
|
14
|
+
* hash is a constant. Continue POSTs the token and navigates to the
|
|
15
|
+
* `location` the hub answers; Cancel is a plain link to the app with
|
|
16
|
+
* `error=cancelled`.
|
|
17
|
+
*/
|
|
18
|
+
const node_crypto_1 = require("node:crypto");
|
|
19
|
+
const PAGE_SCRIPT = `(function () {
|
|
20
|
+
var btn = document.getElementById('go');
|
|
21
|
+
var msg = document.getElementById('msg');
|
|
22
|
+
var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
|
|
23
|
+
btn.addEventListener('click', function () {
|
|
24
|
+
btn.disabled = true;
|
|
25
|
+
msg.textContent = '';
|
|
26
|
+
fetch(location.pathname, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ confirm: btn.getAttribute('data-confirm') }),
|
|
30
|
+
credentials: 'same-origin',
|
|
31
|
+
cache: 'no-store'
|
|
32
|
+
}).then(function (r) { return r.json(); }).then(function (body) {
|
|
33
|
+
if (typeof body.location !== 'string' || !APP_CALLBACK.test(body.location)) {
|
|
34
|
+
throw new Error(body.error || 'Sign-in failed.');
|
|
35
|
+
}
|
|
36
|
+
location.assign(body.location);
|
|
37
|
+
}).catch(function () {
|
|
38
|
+
msg.textContent = 'This confirmation expired. Start the sign-in again from the app.';
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
})();`;
|
|
42
|
+
const PAGE_STYLE = `body{font-family:system-ui,sans-serif;max-width:26rem;margin:4rem auto;padding:0 1rem;text-align:center}button{font:inherit;padding:.75rem 1.25rem;border-radius:.5rem;border:1px solid #888;cursor:pointer}p#msg{color:#b00020;min-height:1.5em}a{display:inline-block;margin-top:1rem}`;
|
|
43
|
+
const sha256 = (text) => `'sha256-${(0, node_crypto_1.createHash)('sha256').update(text).digest('base64')}'`;
|
|
44
|
+
/** The CSP: the one script and style by hash, same-origin requests, no framing, no form posts. */
|
|
45
|
+
exports.APP_CONFIRM_CSP = [
|
|
46
|
+
"default-src 'none'",
|
|
47
|
+
`script-src ${sha256(PAGE_SCRIPT)}`,
|
|
48
|
+
`style-src ${sha256(PAGE_STYLE)}`,
|
|
49
|
+
"connect-src 'self'",
|
|
50
|
+
"base-uri 'none'",
|
|
51
|
+
"form-action 'none'",
|
|
52
|
+
"frame-ancestors 'none'",
|
|
53
|
+
].join('; ');
|
|
54
|
+
/** CSP of the refusal page: nothing runs, nothing loads. */
|
|
55
|
+
exports.APP_CONFIRM_REFUSAL_CSP = "default-src 'none'; frame-ancestors 'none'";
|
|
56
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
57
|
+
/** The confirmation page for `username`; `cancelHref` is the app callback carrying `error=cancelled`. */
|
|
58
|
+
function appConfirmPage(input) {
|
|
59
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Sign in the CamStack app</title><style>${PAGE_STYLE}</style></head><body><h1>Sign in the CamStack app?</h1><p>The CamStack app on this device will be signed in as <strong>${escapeHtml(input.username)}</strong>.</p><p>Continue only if you just started this from the CamStack app.</p><button id="go" type="button" data-confirm="${escapeHtml(input.token)}">Continue</button><p id="msg" role="alert"></p><a href="${escapeHtml(input.cancelHref)}">Cancel</a><noscript><p>JavaScript is required to continue.</p></noscript><script>${PAGE_SCRIPT}</script></body></html>`;
|
|
60
|
+
}
|
|
61
|
+
/** A confirmation that is gone (expired, used, or opened in another browser): a sentence, nothing runs. */
|
|
62
|
+
function appConfirmRefusalPage() {
|
|
63
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirmation expired</title></head><body><h1>Confirmation expired</h1><p>This sign-in confirmation is no longer valid.</p><p>Close this window and start the sign-in again from the app.</p></body></html>`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.APP_CONFIRM_PARAM = exports.APP_CONFIRM_PATH = void 0;
|
|
4
|
+
exports.appConfirmCookie = appConfirmCookie;
|
|
5
|
+
exports.registerAppConfirmRoutes = registerAppConfirmRoutes;
|
|
6
|
+
/**
|
|
7
|
+
* `/auth/app/confirm` - the last step of a native app sign-in (D858).
|
|
8
|
+
*
|
|
9
|
+
* Both browser legs (the OIDC `finish-app`, the passkey `finish`) end here
|
|
10
|
+
* instead of at the app: they park an {@link AppConfirmations} entry and send
|
|
11
|
+
* the browser to this page. `GET` renders "Sign in the CamStack app as <user>?";
|
|
12
|
+
* `POST` (Continue) spends the one-shot, cookie-bound token and ONLY THEN mints
|
|
13
|
+
* the handoff code and answers `{ location }` at the app's scheme. Nothing is
|
|
14
|
+
* minted without the POST, and the token is useless in any other browser.
|
|
15
|
+
*/
|
|
16
|
+
const types_1 = require("@camstack/types");
|
|
17
|
+
const zod_1 = require("zod");
|
|
18
|
+
const app_confirmations_js_1 = require("./app-confirmations.js");
|
|
19
|
+
const app_confirm_page_js_1 = require("./app-confirm-page.js");
|
|
20
|
+
const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
|
|
21
|
+
const sso_finish_deps_js_1 = require("./sso-finish-deps.js");
|
|
22
|
+
exports.APP_CONFIRM_PATH = '/auth/app/confirm';
|
|
23
|
+
/** The query parameter carrying the token (masked in logged URLs). */
|
|
24
|
+
exports.APP_CONFIRM_PARAM = 'confirm';
|
|
25
|
+
/** `Set-Cookie` for the browser binding: HttpOnly, Lax (the entry may come from a cross-site redirect chain). */
|
|
26
|
+
function appConfirmCookie(value, secure) {
|
|
27
|
+
return [
|
|
28
|
+
`${app_confirmations_js_1.APP_CONFIRM_COOKIE}=${value}`,
|
|
29
|
+
'Path=/',
|
|
30
|
+
'HttpOnly',
|
|
31
|
+
'SameSite=Lax',
|
|
32
|
+
'Max-Age=180',
|
|
33
|
+
...(secure ? ['Secure'] : []),
|
|
34
|
+
].join('; ');
|
|
35
|
+
}
|
|
36
|
+
const ConfirmBodySchema = zod_1.z.object({ confirm: zod_1.z.string().min(1).max(256) });
|
|
37
|
+
function noStore(reply) {
|
|
38
|
+
reply.header('Cache-Control', 'no-store');
|
|
39
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
40
|
+
}
|
|
41
|
+
/** The handoff code, minted for a confirmed person. */
|
|
42
|
+
function mint(deps, confirmation) {
|
|
43
|
+
const { app } = confirmation;
|
|
44
|
+
const minted = deps.handoffCodes.create(confirmation.userId, {
|
|
45
|
+
purpose: app.purpose,
|
|
46
|
+
codeChallenge: app.codeChallenge,
|
|
47
|
+
state: app.state,
|
|
48
|
+
authMethod: confirmation.authMethod,
|
|
49
|
+
});
|
|
50
|
+
return (0, types_1.appFlowLocation)(app.redirect, { code: minted.code }, app.state);
|
|
51
|
+
}
|
|
52
|
+
function registerAppConfirmRoutes(fastify, deps) {
|
|
53
|
+
const limited = (request, reply) => {
|
|
54
|
+
const client = (0, auth_rate_limit_js_1.clientKeyFromRequest)(request, deps.network);
|
|
55
|
+
if (client === null)
|
|
56
|
+
return false;
|
|
57
|
+
const verdict = deps.limiter.check(`app-confirm:${client}`);
|
|
58
|
+
if (verdict.allowed)
|
|
59
|
+
return false;
|
|
60
|
+
reply.header('retry-after', String(verdict.retryAfterSeconds));
|
|
61
|
+
void reply.status(429).send({ error: 'Too many attempts' });
|
|
62
|
+
return true;
|
|
63
|
+
};
|
|
64
|
+
fastify.get(exports.APP_CONFIRM_PATH, async (request, reply) => {
|
|
65
|
+
noStore(reply);
|
|
66
|
+
reply.type('text/html; charset=utf-8');
|
|
67
|
+
if (limited(request, reply))
|
|
68
|
+
return reply;
|
|
69
|
+
const token = request.query[exports.APP_CONFIRM_PARAM];
|
|
70
|
+
const confirmation = token === undefined
|
|
71
|
+
? null
|
|
72
|
+
: deps.confirmations.peek(token, (0, sso_finish_deps_js_1.readCookie)(request.headers.cookie, app_confirmations_js_1.APP_CONFIRM_COOKIE));
|
|
73
|
+
if (token === undefined || confirmation === null) {
|
|
74
|
+
deps.logger.warn('App sign-in confirmation page refused', {
|
|
75
|
+
meta: { reason: 'unknown-expired-or-other-browser' },
|
|
76
|
+
});
|
|
77
|
+
reply.header('Content-Security-Policy', app_confirm_page_js_1.APP_CONFIRM_REFUSAL_CSP);
|
|
78
|
+
return reply.status(400).send((0, app_confirm_page_js_1.appConfirmRefusalPage)());
|
|
79
|
+
}
|
|
80
|
+
reply.header('Content-Security-Policy', app_confirm_page_js_1.APP_CONFIRM_CSP);
|
|
81
|
+
return reply.send((0, app_confirm_page_js_1.appConfirmPage)({
|
|
82
|
+
username: confirmation.username,
|
|
83
|
+
token,
|
|
84
|
+
cancelHref: (0, types_1.appFlowLocation)(confirmation.app.redirect, { error: 'cancelled' }, confirmation.app.state),
|
|
85
|
+
}));
|
|
86
|
+
});
|
|
87
|
+
fastify.post(exports.APP_CONFIRM_PATH, async (request, reply) => {
|
|
88
|
+
noStore(reply);
|
|
89
|
+
if (limited(request, reply))
|
|
90
|
+
return reply;
|
|
91
|
+
const body = ConfirmBodySchema.safeParse(request.body);
|
|
92
|
+
if (!body.success)
|
|
93
|
+
return reply.status(400).send({ error: 'Malformed confirmation.' });
|
|
94
|
+
const confirmation = deps.confirmations.consume(body.data.confirm, (0, sso_finish_deps_js_1.readCookie)(request.headers.cookie, app_confirmations_js_1.APP_CONFIRM_COOKIE));
|
|
95
|
+
if (confirmation === null) {
|
|
96
|
+
deps.logger.warn('App sign-in confirmation refused', {
|
|
97
|
+
meta: { reason: 'unknown-used-expired-or-other-browser' },
|
|
98
|
+
});
|
|
99
|
+
return reply.status(400).send({ error: 'This confirmation expired.' });
|
|
100
|
+
}
|
|
101
|
+
deps.logger.info?.('App sign-in confirmed', {
|
|
102
|
+
tags: { userId: confirmation.userId },
|
|
103
|
+
meta: { method: confirmation.authMethod, purpose: confirmation.app.purpose },
|
|
104
|
+
});
|
|
105
|
+
return reply.send({ location: mint(deps, confirmation) });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AppConfirmations = exports.APP_CONFIRM_COOKIE = exports.APP_CONFIRM_TTL_MS = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* Pending "Sign in the CamStack app as <user>?" confirmations (D858).
|
|
39
|
+
*
|
|
40
|
+
* A custom URL scheme can be claimed by any app on the phone, so a browser leg
|
|
41
|
+
* that ends in `camstack://…?code=…` could hand a code to an app the user never
|
|
42
|
+
* meant to sign in (one that started the flow itself, with its own PKCE
|
|
43
|
+
* challenge, while an IdP session was live). The code is therefore minted only
|
|
44
|
+
* AFTER the signed-in person confirms, on a page the hub renders:
|
|
45
|
+
*
|
|
46
|
+
* - the finished ceremony parks an entry here, keyed by a one-shot token;
|
|
47
|
+
* - the token travels in the URL, but is useless without the browser-bound
|
|
48
|
+
* cookie the entry was created with (the cookie's hash is kept, not the cookie);
|
|
49
|
+
* - it lives {@link APP_CONFIRM_TTL_MS}, is consumed by the first `consume`
|
|
50
|
+
* whatever the outcome, and the store is bounded.
|
|
51
|
+
*
|
|
52
|
+
* In-memory only: a hub restart simply means the person signs in again.
|
|
53
|
+
*/
|
|
54
|
+
const crypto = __importStar(require("node:crypto"));
|
|
55
|
+
exports.APP_CONFIRM_TTL_MS = 120_000;
|
|
56
|
+
const MAX_PENDING = 500;
|
|
57
|
+
/** The cookie that binds a confirmation to the browser that finished the ceremony. */
|
|
58
|
+
exports.APP_CONFIRM_COOKIE = 'camstack_app_confirm';
|
|
59
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
60
|
+
function sameDigest(a, b) {
|
|
61
|
+
const left = Buffer.from(a, 'utf8');
|
|
62
|
+
const right = Buffer.from(b, 'utf8');
|
|
63
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
64
|
+
}
|
|
65
|
+
class AppConfirmations {
|
|
66
|
+
now;
|
|
67
|
+
/** Keyed by SHA-256(token). */
|
|
68
|
+
pending = new Map();
|
|
69
|
+
constructor(now = Date.now) {
|
|
70
|
+
this.now = now;
|
|
71
|
+
}
|
|
72
|
+
/** Park a confirmation. Returns the one-shot token and the cookie value to set. */
|
|
73
|
+
create(confirmation) {
|
|
74
|
+
this.prune();
|
|
75
|
+
if (this.pending.size >= MAX_PENDING)
|
|
76
|
+
throw new Error('Too many pending app confirmations');
|
|
77
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
78
|
+
const cookie = crypto.randomBytes(32).toString('base64url');
|
|
79
|
+
this.pending.set(sha256(token), {
|
|
80
|
+
...confirmation,
|
|
81
|
+
cookieHash: sha256(cookie),
|
|
82
|
+
expiresAt: this.now() + exports.APP_CONFIRM_TTL_MS,
|
|
83
|
+
});
|
|
84
|
+
return { token, cookie };
|
|
85
|
+
}
|
|
86
|
+
/** Read without consuming (to render the page). `null` unless token and cookie both match. */
|
|
87
|
+
peek(token, cookie) {
|
|
88
|
+
this.prune();
|
|
89
|
+
const entry = this.pending.get(sha256(token));
|
|
90
|
+
if (entry === undefined || cookie === null || !sameDigest(sha256(cookie), entry.cookieHash)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return this.view(entry);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Spend the token. Burned on the FIRST attempt whatever the outcome - a wrong
|
|
97
|
+
* cookie included, so a leaked token cannot be probed with guesses.
|
|
98
|
+
*/
|
|
99
|
+
consume(token, cookie) {
|
|
100
|
+
this.prune();
|
|
101
|
+
const key = sha256(token);
|
|
102
|
+
const entry = this.pending.get(key);
|
|
103
|
+
if (entry === undefined)
|
|
104
|
+
return null;
|
|
105
|
+
this.pending.delete(key);
|
|
106
|
+
if (cookie === null || !sameDigest(sha256(cookie), entry.cookieHash))
|
|
107
|
+
return null;
|
|
108
|
+
return this.view(entry);
|
|
109
|
+
}
|
|
110
|
+
get pendingCount() {
|
|
111
|
+
return this.pending.size;
|
|
112
|
+
}
|
|
113
|
+
view(entry) {
|
|
114
|
+
return {
|
|
115
|
+
userId: entry.userId,
|
|
116
|
+
username: entry.username,
|
|
117
|
+
app: entry.app,
|
|
118
|
+
authMethod: entry.authMethod,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
prune() {
|
|
122
|
+
const at = this.now();
|
|
123
|
+
for (const [key, entry] of this.pending)
|
|
124
|
+
if (at > entry.expiresAt)
|
|
125
|
+
this.pending.delete(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
exports.AppConfirmations = AppConfirmations;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.APP_PASSKEY_REFUSAL_CSP = exports.APP_PASSKEY_CSP = void 0;
|
|
4
|
+
exports.appPasskeyPage = appPasskeyPage;
|
|
5
|
+
exports.appPasskeyRefusalPage = appPasskeyRefusalPage;
|
|
6
|
+
/**
|
|
7
|
+
* The standalone passkey page for a native app sign-in (D858).
|
|
8
|
+
*
|
|
9
|
+
* Served by the hub at `/auth/app/passkey`, NOT part of the admin-ui bundle:
|
|
10
|
+
* plain HTML and one small inline script, no third-party asset, no framework.
|
|
11
|
+
* The app opens it in the system browser (`ASWebAuthenticationSession` /
|
|
12
|
+
* Custom Tab); the WebAuthn relying party is the hub's own origin, so the app
|
|
13
|
+
* needs no per-domain entitlement. The page runs the usernameless assertion
|
|
14
|
+
* ceremony against `POST …/begin` and `POST …/finish` (same origin) and
|
|
15
|
+
* navigates to the `location` the hub answers - the app's custom scheme.
|
|
16
|
+
*
|
|
17
|
+
* The CSP pins exactly the one script and one style by hash, forbids every
|
|
18
|
+
* other source, and lets the page talk to its own origin only. Nothing request
|
|
19
|
+
* specific is ever placed in the script; the only server-rendered value is the
|
|
20
|
+
* (allow-listed, HTML-escaped) cancel link.
|
|
21
|
+
*/
|
|
22
|
+
const node_crypto_1 = require("node:crypto");
|
|
23
|
+
const PAGE_SCRIPT = `(function () {
|
|
24
|
+
var q = new URLSearchParams(location.search);
|
|
25
|
+
var base = location.pathname.replace(/\\/+$/, '');
|
|
26
|
+
var btn = document.getElementById('go');
|
|
27
|
+
var msg = document.getElementById('msg');
|
|
28
|
+
var KEYS = ['code_challenge', 'code_challenge_method', 'state', 'purpose', 'redirect'];
|
|
29
|
+
var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
|
|
30
|
+
function fromB64u(s) {
|
|
31
|
+
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
32
|
+
while (s.length % 4) s += '=';
|
|
33
|
+
var b = atob(s), u = new Uint8Array(b.length);
|
|
34
|
+
for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i);
|
|
35
|
+
return u.buffer;
|
|
36
|
+
}
|
|
37
|
+
function toB64u(buf) {
|
|
38
|
+
var u = new Uint8Array(buf), s = '';
|
|
39
|
+
for (var i = 0; i < u.length; i++) s += String.fromCharCode(u[i]);
|
|
40
|
+
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
41
|
+
}
|
|
42
|
+
function flow() {
|
|
43
|
+
var o = {};
|
|
44
|
+
KEYS.forEach(function (k) { var v = q.get(k); if (v !== null) o[k] = v; });
|
|
45
|
+
return o;
|
|
46
|
+
}
|
|
47
|
+
function post(path, body) {
|
|
48
|
+
return fetch(base + path, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
credentials: 'same-origin',
|
|
53
|
+
cache: 'no-store'
|
|
54
|
+
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); });
|
|
55
|
+
}
|
|
56
|
+
function fail(text) { msg.textContent = text; btn.disabled = false; }
|
|
57
|
+
function go() {
|
|
58
|
+
btn.disabled = true;
|
|
59
|
+
msg.textContent = '';
|
|
60
|
+
if (!window.PublicKeyCredential || !navigator.credentials) {
|
|
61
|
+
return fail('This browser cannot use passkeys.');
|
|
62
|
+
}
|
|
63
|
+
post('/begin', flow()).then(function (begin) {
|
|
64
|
+
if (!begin.ok) throw new Error(begin.body.error || 'Passkeys are not available.');
|
|
65
|
+
var opts = begin.body.optionsJSON;
|
|
66
|
+
var pk = {
|
|
67
|
+
challenge: fromB64u(opts.challenge),
|
|
68
|
+
timeout: opts.timeout,
|
|
69
|
+
rpId: opts.rpId,
|
|
70
|
+
userVerification: opts.userVerification,
|
|
71
|
+
allowCredentials: (opts.allowCredentials || []).map(function (c) {
|
|
72
|
+
return { type: c.type, id: fromB64u(c.id), transports: c.transports };
|
|
73
|
+
})
|
|
74
|
+
};
|
|
75
|
+
return navigator.credentials.get({ publicKey: pk });
|
|
76
|
+
}).then(function (cred) {
|
|
77
|
+
var r = cred.response;
|
|
78
|
+
var body = flow();
|
|
79
|
+
body.response = {
|
|
80
|
+
id: cred.id,
|
|
81
|
+
rawId: toB64u(cred.rawId),
|
|
82
|
+
type: cred.type,
|
|
83
|
+
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
|
|
84
|
+
authenticatorAttachment: cred.authenticatorAttachment || undefined,
|
|
85
|
+
response: {
|
|
86
|
+
authenticatorData: toB64u(r.authenticatorData),
|
|
87
|
+
clientDataJSON: toB64u(r.clientDataJSON),
|
|
88
|
+
signature: toB64u(r.signature),
|
|
89
|
+
userHandle: r.userHandle ? toB64u(r.userHandle) : undefined
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return post('/finish', body);
|
|
93
|
+
}).then(function (done) {
|
|
94
|
+
var next = done.body && done.body.confirm;
|
|
95
|
+
if (typeof next === 'string' && /^confirm\\?confirm=[A-Za-z0-9_-]+$/.test(next)) {
|
|
96
|
+
location.assign(next);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
var target = done.body && done.body.location;
|
|
100
|
+
if (typeof target !== 'string' || !APP_CALLBACK.test(target)) {
|
|
101
|
+
throw new Error((done.body && done.body.error) || 'Sign-in failed.');
|
|
102
|
+
}
|
|
103
|
+
location.assign(target);
|
|
104
|
+
}).catch(function (e) {
|
|
105
|
+
fail(e && e.name === 'NotAllowedError' ? 'The passkey request was cancelled.' : 'Sign-in failed. Try again.');
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
btn.addEventListener('click', go);
|
|
109
|
+
})();`;
|
|
110
|
+
const PAGE_STYLE = `body{font-family:system-ui,sans-serif;max-width:26rem;margin:4rem auto;padding:0 1rem;text-align:center}button{font:inherit;padding:.75rem 1.25rem;border-radius:.5rem;border:1px solid #888;cursor:pointer}p#msg{color:#b00020;min-height:1.5em}a{display:inline-block;margin-top:1rem}`;
|
|
111
|
+
const sha256 = (text) => `'sha256-${(0, node_crypto_1.createHash)('sha256').update(text).digest('base64')}'`;
|
|
112
|
+
/**
|
|
113
|
+
* The page's CSP: the one script and one style by hash, same-origin requests
|
|
114
|
+
* only, no framing, no base rewrite, no form posts, no plugins.
|
|
115
|
+
*/
|
|
116
|
+
exports.APP_PASSKEY_CSP = [
|
|
117
|
+
"default-src 'none'",
|
|
118
|
+
`script-src ${sha256(PAGE_SCRIPT)}`,
|
|
119
|
+
`style-src ${sha256(PAGE_STYLE)}`,
|
|
120
|
+
"connect-src 'self'",
|
|
121
|
+
"base-uri 'none'",
|
|
122
|
+
"form-action 'none'",
|
|
123
|
+
"frame-ancestors 'none'",
|
|
124
|
+
].join('; ');
|
|
125
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
126
|
+
/** The page; `cancelHref` is the app callback carrying `error=cancelled`. */
|
|
127
|
+
function appPasskeyPage(cancelHref) {
|
|
128
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Sign in with a passkey</title><style>${PAGE_STYLE}</style></head><body><h1>CamStack</h1><button id="go" type="button">Sign in with a passkey</button><p id="msg" role="alert"></p><a href="${escapeHtml(cancelHref)}">Cancel</a><noscript><p>JavaScript is required to use a passkey.</p></noscript><script>${PAGE_SCRIPT}</script></body></html>`;
|
|
129
|
+
}
|
|
130
|
+
/** The page for a request the hub refuses to serve: a sentence, no script, no assets. */
|
|
131
|
+
function appPasskeyRefusalPage(message) {
|
|
132
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in request refused</title></head><body><h1>Sign-in request refused</h1><p>${escapeHtml(message)}</p><p>Close this window and try again from the app.</p></body></html>`;
|
|
133
|
+
}
|
|
134
|
+
/** CSP of the refusal page: nothing runs, nothing loads. */
|
|
135
|
+
exports.APP_PASSKEY_REFUSAL_CSP = "default-src 'none'; frame-ancestors 'none'";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.APP_PASSKEY_PATH = void 0;
|
|
4
|
+
exports.registerAppPasskeyRoutes = registerAppPasskeyRoutes;
|
|
5
|
+
/**
|
|
6
|
+
* The passkey app sign-in routes (D858):
|
|
7
|
+
*
|
|
8
|
+
* - `GET /auth/app/passkey` - the standalone page (`app-passkey-page.ts`)
|
|
9
|
+
* - `POST /auth/app/passkey/begin` - assertion options (discoverable credentials)
|
|
10
|
+
* - `POST /auth/app/passkey/finish` - the assertion -> a handoff code at the app
|
|
11
|
+
*
|
|
12
|
+
* The finish verifies the assertion through the `user-passkeys` provider (the
|
|
13
|
+
* same ceremony `auth.passkeyLoginFinish` runs), applies the second-factor rule
|
|
14
|
+
* of any primary login, and mints the ONE-SHOT handoff code - PKCE-bound,
|
|
15
|
+
* state-bound, purpose-aware - for the user the passkey belongs to. It opens no
|
|
16
|
+
* session, reads no cookie and honours no `returnTo`: the destination is the
|
|
17
|
+
* allow-listed app callback, nothing else. Every answer is `{ location }` (or an
|
|
18
|
+
* `{ error }` for a request that is not an app flow at all), so the page just
|
|
19
|
+
* navigates. The app's parameters are never logged.
|
|
20
|
+
*/
|
|
21
|
+
const types_1 = require("@camstack/types");
|
|
22
|
+
const zod_1 = require("zod");
|
|
23
|
+
const app_confirm_routes_js_1 = require("./app-confirm-routes.js");
|
|
24
|
+
const app_passkey_page_js_1 = require("./app-passkey-page.js");
|
|
25
|
+
const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
|
|
26
|
+
const session_mint_js_1 = require("./session-mint.js");
|
|
27
|
+
exports.APP_PASSKEY_PATH = '/auth/app/passkey';
|
|
28
|
+
const FinishBodySchema = zod_1.z.object({
|
|
29
|
+
response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
30
|
+
code_challenge: zod_1.z.string().optional(),
|
|
31
|
+
code_challenge_method: zod_1.z.string().optional(),
|
|
32
|
+
state: zod_1.z.string().optional(),
|
|
33
|
+
purpose: zod_1.z.string().optional(),
|
|
34
|
+
redirect: zod_1.z.string().optional(),
|
|
35
|
+
});
|
|
36
|
+
function noStore(reply) {
|
|
37
|
+
reply.header('Cache-Control', 'no-store');
|
|
38
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
39
|
+
}
|
|
40
|
+
function registerAppPasskeyRoutes(fastify, deps) {
|
|
41
|
+
const limited = (bucket, request, reply) => {
|
|
42
|
+
const client = (0, auth_rate_limit_js_1.clientKeyFromRequest)(request, deps.network);
|
|
43
|
+
if (client === null)
|
|
44
|
+
return false;
|
|
45
|
+
const verdict = deps.limiter.check(`${bucket}:${client}`);
|
|
46
|
+
if (verdict.allowed)
|
|
47
|
+
return false;
|
|
48
|
+
reply.header('retry-after', String(verdict.retryAfterSeconds));
|
|
49
|
+
void reply.status(429).send({ error: 'Too many attempts' });
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
fastify.get(exports.APP_PASSKEY_PATH, async (request, reply) => {
|
|
53
|
+
noStore(reply);
|
|
54
|
+
reply.type('text/html; charset=utf-8');
|
|
55
|
+
const parsed = (0, types_1.parseAppFlowRequest)(request.query);
|
|
56
|
+
if (!parsed.ok) {
|
|
57
|
+
deps.logger.warn('Passkey app sign-in refused at the page', {
|
|
58
|
+
meta: { reason: parsed.reason },
|
|
59
|
+
});
|
|
60
|
+
reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_REFUSAL_CSP);
|
|
61
|
+
return reply
|
|
62
|
+
.status(400)
|
|
63
|
+
.send((0, app_passkey_page_js_1.appPasskeyRefusalPage)(types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason]));
|
|
64
|
+
}
|
|
65
|
+
reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_CSP);
|
|
66
|
+
const cancel = (0, types_1.appFlowLocation)(parsed.request.redirect, { error: 'cancelled' }, parsed.request.state);
|
|
67
|
+
return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel));
|
|
68
|
+
});
|
|
69
|
+
fastify.post(`${exports.APP_PASSKEY_PATH}/begin`, async (request, reply) => {
|
|
70
|
+
noStore(reply);
|
|
71
|
+
if (limited('app-passkey-begin', request, reply))
|
|
72
|
+
return reply;
|
|
73
|
+
const body = zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).safeParse(request.body);
|
|
74
|
+
const query = body.success
|
|
75
|
+
? Object.fromEntries(Object.entries(body.data).flatMap(([k, v]) => (typeof v === 'string' ? [[k, v]] : [])))
|
|
76
|
+
: {};
|
|
77
|
+
const parsed = (0, types_1.parseAppFlowRequest)(query);
|
|
78
|
+
if (!parsed.ok)
|
|
79
|
+
return reply.status(400).send({ error: types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason] });
|
|
80
|
+
const provider = deps.passkeys();
|
|
81
|
+
if (provider === null)
|
|
82
|
+
return reply.status(503).send({ error: 'Passkeys are not available.' });
|
|
83
|
+
try {
|
|
84
|
+
return await reply.send(await provider.beginDiscoverableAuthentication({}));
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
deps.logger.error('Passkey app sign-in begin failed', {
|
|
88
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
89
|
+
});
|
|
90
|
+
return reply.status(500).send({ error: 'Passkeys are not available.' });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
fastify.post(`${exports.APP_PASSKEY_PATH}/finish`, async (request, reply) => {
|
|
94
|
+
noStore(reply);
|
|
95
|
+
if (limited('app-passkey-finish', request, reply))
|
|
96
|
+
return reply;
|
|
97
|
+
const body = FinishBodySchema.safeParse(request.body);
|
|
98
|
+
if (!body.success)
|
|
99
|
+
return reply.status(400).send({ error: 'Malformed sign-in request.' });
|
|
100
|
+
const parsed = (0, types_1.parseAppFlowRequest)({
|
|
101
|
+
code_challenge: body.data.code_challenge,
|
|
102
|
+
code_challenge_method: body.data.code_challenge_method,
|
|
103
|
+
state: body.data.state,
|
|
104
|
+
purpose: body.data.purpose,
|
|
105
|
+
redirect: body.data.redirect,
|
|
106
|
+
});
|
|
107
|
+
if (!parsed.ok)
|
|
108
|
+
return reply.status(400).send({ error: types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason] });
|
|
109
|
+
const app = parsed.request;
|
|
110
|
+
const deny = (error) => {
|
|
111
|
+
deps.logger.warn('Passkey app sign-in refused', { meta: { error } });
|
|
112
|
+
return reply.send({ location: (0, types_1.appFlowLocation)(app.redirect, { error }, app.state) });
|
|
113
|
+
};
|
|
114
|
+
const provider = deps.passkeys();
|
|
115
|
+
if (provider === null)
|
|
116
|
+
return deny('passkey-unavailable');
|
|
117
|
+
try {
|
|
118
|
+
const result = await provider.finishDiscoverableAuthentication({
|
|
119
|
+
response: body.data.response,
|
|
120
|
+
});
|
|
121
|
+
if (!result.verified || result.userId === null)
|
|
122
|
+
return deny('passkey-failed');
|
|
123
|
+
const record = await deps.minter.findUser(result.userId);
|
|
124
|
+
// The rule of any primary login: owed second factors are not skippable
|
|
125
|
+
// here, and a browser page cannot ask for them - the app uses another method.
|
|
126
|
+
if ((await deps.secondFactors(record.id)).length > 0)
|
|
127
|
+
return deny('second-factor-required');
|
|
128
|
+
// The assertion verified; before ANY code exists the person confirms that
|
|
129
|
+
// THIS app is the one to sign in (a custom scheme can be claimed by
|
|
130
|
+
// another app, D858). The page goes to the sibling confirmation page; its
|
|
131
|
+
// cookie binds the token to this browser.
|
|
132
|
+
const parked = deps.confirmations.create({
|
|
133
|
+
userId: record.id,
|
|
134
|
+
username: record.username,
|
|
135
|
+
app,
|
|
136
|
+
authMethod: 'passkey',
|
|
137
|
+
});
|
|
138
|
+
reply.header('Set-Cookie', (0, app_confirm_routes_js_1.appConfirmCookie)(parked.cookie, request.protocol === 'https'));
|
|
139
|
+
return reply.send({
|
|
140
|
+
confirm: `confirm?${app_confirm_routes_js_1.APP_CONFIRM_PARAM}=${encodeURIComponent(parked.token)}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof session_mint_js_1.SessionUserGoneError)
|
|
145
|
+
return deny('user-gone');
|
|
146
|
+
deps.logger.error('Passkey app sign-in failed', {
|
|
147
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
148
|
+
});
|
|
149
|
+
return deny('failed');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.finishSsoApp = finishSsoApp;
|
|
4
|
+
/**
|
|
5
|
+
* `/api/auth/sso/finish-app` - the native-app leg of an OIDC sign-in (D858).
|
|
6
|
+
*
|
|
7
|
+
* The viewer starts OIDC at `/addon/auth-oidc/<id>/start?app=1&code_challenge=…`
|
|
8
|
+
* and the system browser must come back to the APP, not to the admin UI. The
|
|
9
|
+
* addon parks the app's PKCE challenge, `state`, purpose and return URL in its
|
|
10
|
+
* server-side pending state and signs them into the login bridge (`claims.app`);
|
|
11
|
+
* the callback then lands here instead of `/sso/finish`.
|
|
12
|
+
*
|
|
13
|
+
* Same trust as the web finish (D782, D810): the bridge must verify, be a LOGIN
|
|
14
|
+
* bridge of an enabled provider, be finished in the browser that started it
|
|
15
|
+
* (state-cookie binding) and only once (`jti`); the identity must be LINKED to
|
|
16
|
+
* a user. The difference is the product: NO web session and NO cookie. The hub
|
|
17
|
+
* mints the existing one-shot handoff code - PKCE-bound, state-bound - and
|
|
18
|
+
* redirects the browser to the app's custom scheme:
|
|
19
|
+
*
|
|
20
|
+
* - `new-session`: the app redeems it at `auth.redeemHandoffCode` for a normal
|
|
21
|
+
* session;
|
|
22
|
+
* - `step-up`: the app redeems it at `auth.deviceEnrollProofFromHandoff` for a
|
|
23
|
+
* device-enroll proof (D855). An IdP login has JUST happened in this request
|
|
24
|
+
* chain - that is the fresh authentication - and nothing here reads a web
|
|
25
|
+
* session cookie, so an old session can never launder a proof.
|
|
26
|
+
*
|
|
27
|
+
* Every refusal is a 302 to the app with `error=<code>` (and the app's `state`
|
|
28
|
+
* once the bridge is trusted): the app shows the message, the user is never left
|
|
29
|
+
* on a hub error page. The app's parameters are never logged.
|
|
30
|
+
*
|
|
31
|
+
* Pure: the Fastify route in `sso-routes.ts` wires the deps and maps the outcome.
|
|
32
|
+
*/
|
|
33
|
+
const types_1 = require("@camstack/types");
|
|
34
|
+
const auth_provider_gate_js_1 = require("./auth-provider-gate.js");
|
|
35
|
+
const session_mint_js_1 = require("./session-mint.js");
|
|
36
|
+
const sso_finish_js_1 = require("./sso-finish.js");
|
|
37
|
+
function refused(error, deps, redirect, state) {
|
|
38
|
+
deps.logRefusal?.(error);
|
|
39
|
+
return { kind: 'app', location: (0, types_1.appFlowLocation)(redirect, { error }, state) };
|
|
40
|
+
}
|
|
41
|
+
/** Exchange a verified app login bridge for a handoff code at the app's callback. */
|
|
42
|
+
async function finishSsoApp(bridge, deps) {
|
|
43
|
+
const fallback = types_1.APP_FLOW_DEFAULT_REDIRECT;
|
|
44
|
+
if (!bridge)
|
|
45
|
+
return refused('invalid-bridge', deps, fallback, undefined);
|
|
46
|
+
const claims = deps.verifyBridge(bridge);
|
|
47
|
+
if (claims === null)
|
|
48
|
+
return refused('invalid-bridge', deps, fallback, undefined);
|
|
49
|
+
const app = claims.app;
|
|
50
|
+
if (app === undefined || !(0, sso_finish_js_1.isLoginBridge)(claims)) {
|
|
51
|
+
return refused('not-app-flow', deps, fallback, undefined);
|
|
52
|
+
}
|
|
53
|
+
// The signed redirect is checked against the allow-list again: the finish
|
|
54
|
+
// never trusts a stored URL to be one it would accept.
|
|
55
|
+
const redirect = (0, types_1.parseAppAuthCallback)(app.redirect);
|
|
56
|
+
if (redirect === null)
|
|
57
|
+
return refused('not-app-flow', deps, fallback, undefined);
|
|
58
|
+
const deny = (error) => refused(error, deps, redirect, app.state);
|
|
59
|
+
if (claims.linkIntent !== undefined)
|
|
60
|
+
return deny('link-not-supported');
|
|
61
|
+
if (deps.isProviderDisabled((0, auth_provider_gate_js_1.bridgeProviderAddonId)(claims.provider))) {
|
|
62
|
+
return deny('provider-disabled');
|
|
63
|
+
}
|
|
64
|
+
if (!(0, sso_finish_js_1.bridgeIsFirstUseHere)(claims, deps))
|
|
65
|
+
return deny('sso-state');
|
|
66
|
+
try {
|
|
67
|
+
const identity = (0, sso_finish_js_1.oidcIdentityOf)(claims);
|
|
68
|
+
if (identity === null)
|
|
69
|
+
return deny('invalid-bridge');
|
|
70
|
+
const link = await deps.identities.findUser(identity.provider, identity.subject);
|
|
71
|
+
// A link made under another issuer is not this identity (D782).
|
|
72
|
+
if (link === null || link.issuer !== identity.issuer)
|
|
73
|
+
return deny('sso-not-linked');
|
|
74
|
+
const record = await deps.minter.findUser(link.userId);
|
|
75
|
+
// Same rule as a password login: the second factors owed are not skippable
|
|
76
|
+
// here, and a browser leg cannot ask for them - the app uses another method.
|
|
77
|
+
if ((await deps.secondFactors(record.id)).length > 0)
|
|
78
|
+
return deny('second-factor-required');
|
|
79
|
+
// The person has just authenticated; before ANY code exists they confirm
|
|
80
|
+
// that THIS app is the one to sign in (a custom scheme can be claimed by
|
|
81
|
+
// another app, D858).
|
|
82
|
+
const parked = deps.confirmations.create({
|
|
83
|
+
userId: record.id,
|
|
84
|
+
username: record.username,
|
|
85
|
+
app: { codeChallenge: app.codeChallenge, state: app.state, purpose: app.purpose, redirect },
|
|
86
|
+
authMethod: 'sso',
|
|
87
|
+
});
|
|
88
|
+
return { kind: 'confirm', ...parked };
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof session_mint_js_1.SessionUserGoneError)
|
|
92
|
+
return deny('user-gone');
|
|
93
|
+
deps.logFailure?.(error);
|
|
94
|
+
return deny('failed');
|
|
95
|
+
}
|
|
96
|
+
}
|
package/dist/auth/sso-finish.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SSO_POPUP_COMPLETE_PATH = exports.SSO_LOGIN_PATH = exports.SSO_PROFILE_PATH = exports.OIDC_IDENTITY_PREFIX = exports.OIDC_BRIDGE_PROVIDER_PREFIX = void 0;
|
|
4
4
|
exports.popupCompletionLocation = popupCompletionLocation;
|
|
5
|
+
exports.oidcIdentityOf = oidcIdentityOf;
|
|
5
6
|
exports.isLoginBridge = isLoginBridge;
|
|
6
7
|
exports.appCallbackLocation = appCallbackLocation;
|
|
7
8
|
exports.sessionFragmentParams = sessionFragmentParams;
|
|
8
9
|
exports.finishSsoLogin = finishSsoLogin;
|
|
10
|
+
exports.bridgeIsFirstUseHere = bridgeIsFirstUseHere;
|
|
9
11
|
/**
|
|
10
12
|
* `/api/auth/sso/finish` — swaps a verified login bridge for a session (D712),
|
|
11
13
|
* or completes an explicit OIDC link (D782).
|
|
@@ -287,6 +289,10 @@ async function finishSsoReturn(bridge, deps) {
|
|
|
287
289
|
return unauthorized('Invalid or expired bridge token');
|
|
288
290
|
if (!isLoginBridge(claims))
|
|
289
291
|
return unauthorized('Not a login bridge token');
|
|
292
|
+
// A bridge minted for a native app (D858) is finished only at `finish-app`,
|
|
293
|
+
// which hands the app a code; it must never become a web session here.
|
|
294
|
+
if (claims.app !== undefined)
|
|
295
|
+
return unauthorized('Not a web login bridge token');
|
|
290
296
|
if (deps.isProviderDisabled((0, auth_provider_gate_js_1.bridgeProviderAddonId)(claims.provider))) {
|
|
291
297
|
return unauthorized('Login provider is disabled');
|
|
292
298
|
}
|
package/dist/auth/sso-routes.js
CHANGED
|
@@ -8,6 +8,9 @@ exports.registerSsoRoutes = registerSsoRoutes;
|
|
|
8
8
|
* browser-bound login bridge for a session / challenge / link
|
|
9
9
|
* (`sso-finish.ts` holds the rules). Limited per REAL client address.
|
|
10
10
|
* Every answer clears the `SSO_STATE_COOKIE`: it binds exactly one finish.
|
|
11
|
+
* - `GET /api/auth/sso/finish-app?bridge=…` - the same bridge rules for a
|
|
12
|
+
* NATIVE APP sign-in (D858): no web session, a handoff code at the app's
|
|
13
|
+
* custom scheme (`sso-finish-app.ts`).
|
|
11
14
|
* - `GET /api/auth/browser-handoff?code=…` — a one-time code from
|
|
12
15
|
* `auth.createBrowserHandoff` opens the account page signed in (D801).
|
|
13
16
|
*/
|
|
@@ -16,6 +19,8 @@ const spa_mount_js_1 = require("../api/static/spa-mount.js");
|
|
|
16
19
|
const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
|
|
17
20
|
const native_return_js_1 = require("./native-return.js");
|
|
18
21
|
const sso_finish_js_1 = require("./sso-finish.js");
|
|
22
|
+
const app_confirm_routes_js_1 = require("./app-confirm-routes.js");
|
|
23
|
+
const sso_finish_app_js_1 = require("./sso-finish-app.js");
|
|
19
24
|
const sso_finish_deps_js_1 = require("./sso-finish-deps.js");
|
|
20
25
|
/** A User-Agent as a session's client label. */
|
|
21
26
|
function clientOf(ua) {
|
|
@@ -61,6 +66,47 @@ function registerSsoRoutes(fastify, deps) {
|
|
|
61
66
|
reply.header('Location', (0, native_return_js_1.ssoReturnLocation)(mount, outcome.location));
|
|
62
67
|
return reply.send('');
|
|
63
68
|
});
|
|
69
|
+
// The native-app leg (D858): same bridge rules, a handoff code instead of a
|
|
70
|
+
// session. Every outcome - success or refusal - is a 302 to the app.
|
|
71
|
+
fastify.get('/api/auth/sso/finish-app', async (request, reply) => {
|
|
72
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
73
|
+
reply.header('Cache-Control', 'no-store');
|
|
74
|
+
const clearState = clearSsoStateCookie();
|
|
75
|
+
reply.header('Set-Cookie', clearState);
|
|
76
|
+
const client = (0, auth_rate_limit_js_1.clientKeyFromRequest)(request, deps.network);
|
|
77
|
+
if (client !== null) {
|
|
78
|
+
const verdict = deps.limiter.check(`sso-finish:${client}`);
|
|
79
|
+
if (!verdict.allowed) {
|
|
80
|
+
reply.header('retry-after', String(verdict.retryAfterSeconds));
|
|
81
|
+
return reply.status(429).send({ error: 'Too many attempts' });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const outcome = await (0, sso_finish_app_js_1.finishSsoApp)(request.query.bridge, {
|
|
85
|
+
...(0, sso_finish_deps_js_1.createSsoFinishDeps)({
|
|
86
|
+
...deps.wiring,
|
|
87
|
+
logFailure: (error) => deps.logger.error('SSO app finish failed', {
|
|
88
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
89
|
+
}),
|
|
90
|
+
}, request.headers.cookie),
|
|
91
|
+
minter: deps.minter,
|
|
92
|
+
confirmations: deps.confirmations,
|
|
93
|
+
logRefusal: (error) => deps.logger.warn('SSO app finish refused', { meta: { error } }),
|
|
94
|
+
});
|
|
95
|
+
reply.code(302);
|
|
96
|
+
if (outcome.kind === 'app') {
|
|
97
|
+
reply.header('Location', outcome.location);
|
|
98
|
+
return reply.send('');
|
|
99
|
+
}
|
|
100
|
+
// The confirmation page, on this hub (mount-aware); its cookie binds the
|
|
101
|
+
// token to this browser. The SSO state cookie is spent either way.
|
|
102
|
+
const mount = (0, spa_mount_js_1.parseForwardedPrefix)(request.headers['x-forwarded-prefix']) ?? '';
|
|
103
|
+
reply.header('Set-Cookie', [
|
|
104
|
+
clearState,
|
|
105
|
+
(0, app_confirm_routes_js_1.appConfirmCookie)(outcome.cookie, request.protocol === 'https'),
|
|
106
|
+
]);
|
|
107
|
+
reply.header('Location', `${mount}${app_confirm_routes_js_1.APP_CONFIRM_PATH}?${app_confirm_routes_js_1.APP_CONFIRM_PARAM}=${encodeURIComponent(outcome.token)}`);
|
|
108
|
+
return reply.send('');
|
|
109
|
+
});
|
|
64
110
|
// A one-time code minted by `auth.createBrowserHandoff` opens the account
|
|
65
111
|
// page signed in (D801): the session rides the URL FRAGMENT (never a server
|
|
66
112
|
// log), and the only destination is the account page.
|
|
@@ -192,6 +192,7 @@ class AddonPackageService {
|
|
|
192
192
|
cachedUpdates = null;
|
|
193
193
|
searchCache = null;
|
|
194
194
|
versionCache = new Map();
|
|
195
|
+
capabilityCache = new Map();
|
|
195
196
|
updateAvailability;
|
|
196
197
|
// -- Auto-update state ----------------------------------------------------
|
|
197
198
|
autoUpdateConfig = {
|
|
@@ -217,6 +218,8 @@ class AddonPackageService {
|
|
|
217
218
|
static UPDATE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
218
219
|
static SEARCH_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
219
220
|
static VERSION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
221
|
+
static CAPABILITY_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
222
|
+
static CAPABILITY_LOOKUP_CONCURRENCY = 6;
|
|
220
223
|
static NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
221
224
|
static REGISTRY_TIMEOUT_MS = 10_000;
|
|
222
225
|
constructor(loggingService, eventBusService, configService, addonRegistry, notificationService, toastService,
|
|
@@ -777,8 +780,15 @@ class AddonPackageService {
|
|
|
777
780
|
// =========================================================================
|
|
778
781
|
// npm search
|
|
779
782
|
// =========================================================================
|
|
780
|
-
/**
|
|
781
|
-
|
|
783
|
+
/**
|
|
784
|
+
* Search npm for camstack addon packages, optionally filtered by query and
|
|
785
|
+
* by a capability the package must declare. The capabilities come from each
|
|
786
|
+
* package's own `/latest` manifest (`camstack.addons[].capabilities`) — the
|
|
787
|
+
* single source of truth (D4), no hand-kept keywords. A package whose
|
|
788
|
+
* manifest could not be read carries `capabilities: null` and is excluded
|
|
789
|
+
* from a capability-filtered result (unknown is not a match).
|
|
790
|
+
*/
|
|
791
|
+
async searchNpm(query, capability) {
|
|
782
792
|
const npmResults = await this.fetchSearchFromNpm();
|
|
783
793
|
let filtered = npmResults;
|
|
784
794
|
if (query) {
|
|
@@ -787,7 +797,14 @@ class AddonPackageService {
|
|
|
787
797
|
r.description?.toLowerCase().includes(q) ||
|
|
788
798
|
r.keywords?.some((k) => k.toLowerCase().includes(q)));
|
|
789
799
|
}
|
|
790
|
-
|
|
800
|
+
const capabilitiesByName = await this.lookupCapabilities(filtered.map((r) => r.name));
|
|
801
|
+
const failed = filtered.filter((r) => capabilitiesByName.get(r.name) === null).length;
|
|
802
|
+
if (failed > 0) {
|
|
803
|
+
this.logger.warn('npm capability lookup failed for some packages', {
|
|
804
|
+
meta: { failed, total: filtered.length, capability: capability ?? null },
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
const withCapabilities = filtered.map((r) => ({
|
|
791
808
|
name: r.name,
|
|
792
809
|
version: r.version,
|
|
793
810
|
description: r.description ?? '',
|
|
@@ -796,7 +813,73 @@ class AddonPackageService {
|
|
|
796
813
|
author: r.publisher?.username ?? '',
|
|
797
814
|
installed: false, // caller will enrich
|
|
798
815
|
installedVersion: undefined,
|
|
816
|
+
capabilities: capabilitiesByName.get(r.name) ?? null,
|
|
799
817
|
}));
|
|
818
|
+
if (capability === undefined)
|
|
819
|
+
return withCapabilities;
|
|
820
|
+
return withCapabilities.filter((r) => r.capabilities?.includes(capability) === true);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Declared capabilities per package, through a bounded worker pool and the
|
|
824
|
+
* 10-minute cache. `null` = the lookup failed (not cached, so the next
|
|
825
|
+
* search retries).
|
|
826
|
+
*/
|
|
827
|
+
async lookupCapabilities(names) {
|
|
828
|
+
const result = new Map();
|
|
829
|
+
const now = Date.now();
|
|
830
|
+
const pending = [];
|
|
831
|
+
for (const name of names) {
|
|
832
|
+
const cached = this.capabilityCache.get(name);
|
|
833
|
+
if (cached !== undefined && cached.expiresAt > now)
|
|
834
|
+
result.set(name, cached.capabilities);
|
|
835
|
+
else
|
|
836
|
+
pending.push(name);
|
|
837
|
+
}
|
|
838
|
+
let next = 0;
|
|
839
|
+
const worker = async () => {
|
|
840
|
+
while (next < pending.length) {
|
|
841
|
+
const name = pending[next++];
|
|
842
|
+
if (name === undefined)
|
|
843
|
+
return;
|
|
844
|
+
const capabilities = await this.fetchPackageCapabilities(name);
|
|
845
|
+
result.set(name, capabilities);
|
|
846
|
+
if (capabilities !== null) {
|
|
847
|
+
this.capabilityCache.set(name, {
|
|
848
|
+
capabilities,
|
|
849
|
+
expiresAt: Date.now() + AddonPackageService.CAPABILITY_CACHE_TTL_MS,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
const poolSize = Math.min(AddonPackageService.CAPABILITY_LOOKUP_CONCURRENCY, pending.length);
|
|
855
|
+
await Promise.all(Array.from({ length: poolSize }, worker));
|
|
856
|
+
return result;
|
|
857
|
+
}
|
|
858
|
+
/** Read `camstack.addons[].capabilities` from a package's `/latest`; null on any failure. */
|
|
859
|
+
async fetchPackageCapabilities(packageName) {
|
|
860
|
+
try {
|
|
861
|
+
const url = `${this.resolveRegistryBase()}/${packageName.replace('/', '%2F')}/latest`;
|
|
862
|
+
const response = await fetch(url, {
|
|
863
|
+
signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
|
|
864
|
+
});
|
|
865
|
+
if (!response.ok)
|
|
866
|
+
return null;
|
|
867
|
+
const data = await fetchJsonObject(response);
|
|
868
|
+
const addons = asRecord(data['camstack'])['addons'];
|
|
869
|
+
const names = new Set();
|
|
870
|
+
for (const addon of Array.isArray(addons) ? addons : []) {
|
|
871
|
+
const caps = asRecord(addon)['capabilities'];
|
|
872
|
+
for (const cap of Array.isArray(caps) ? caps : []) {
|
|
873
|
+
const name = typeof cap === 'string' ? cap : asString(asRecord(cap)['name']);
|
|
874
|
+
if (name)
|
|
875
|
+
names.add(name);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return [...names];
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
800
883
|
}
|
|
801
884
|
// =========================================================================
|
|
802
885
|
// Package versions
|
|
@@ -98,6 +98,7 @@ class HandoffCodeService {
|
|
|
98
98
|
...(options.codeChallenge !== undefined ? { codeChallenge: options.codeChallenge } : {}),
|
|
99
99
|
...(options.state !== undefined ? { state: options.state } : {}),
|
|
100
100
|
...(options.credentialId !== undefined ? { credentialId: options.credentialId } : {}),
|
|
101
|
+
...(options.authMethod !== undefined ? { authMethod: options.authMethod } : {}),
|
|
101
102
|
expiresAt,
|
|
102
103
|
});
|
|
103
104
|
return { code, expiresAt };
|
package/dist/main.js
CHANGED
|
@@ -96,6 +96,11 @@ const external_link_intents_js_1 = require("./auth/external-link-intents.js");
|
|
|
96
96
|
const session_cookie_js_1 = require("./auth/session-cookie.js");
|
|
97
97
|
const session_mint_js_1 = require("./auth/session-mint.js");
|
|
98
98
|
const sso_finish_deps_js_1 = require("./auth/sso-finish-deps.js");
|
|
99
|
+
const app_confirmations_js_1 = require("./auth/app-confirmations.js");
|
|
100
|
+
const app_confirm_routes_js_1 = require("./auth/app-confirm-routes.js");
|
|
101
|
+
const app_passkey_routes_js_1 = require("./auth/app-passkey-routes.js");
|
|
102
|
+
const passkey_provider_js_1 = require("./auth/passkey-provider.js");
|
|
103
|
+
const second_factors_js_1 = require("./auth/second-factors.js");
|
|
99
104
|
const sso_routes_js_1 = require("./auth/sso-routes.js");
|
|
100
105
|
const used_bridge_ids_js_1 = require("./auth/used-bridge-ids.js");
|
|
101
106
|
const native_link_completions_js_1 = require("./auth/native-link-completions.js");
|
|
@@ -605,6 +610,8 @@ async function bootstrap() {
|
|
|
605
610
|
// `jti`s of login bridges already finished — a bridge is single-use.
|
|
606
611
|
const usedBridgeIds = new used_bridge_ids_js_1.UsedBridgeIds();
|
|
607
612
|
const browserHandoffCodes = new handoff_code_service_1.HandoffCodeService();
|
|
613
|
+
// Parked "sign the app in?" confirmations of the two native app flows (D858).
|
|
614
|
+
const appConfirmations = new app_confirmations_js_1.AppConfirmations();
|
|
608
615
|
const nativeLinkMinter = (0, session_mint_js_1.createSessionMinter)({
|
|
609
616
|
signer: app.get(auth_service_1.AuthService),
|
|
610
617
|
directory: () => capabilityRegistry.getSingleton('user-management'),
|
|
@@ -950,9 +957,39 @@ async function bootstrap() {
|
|
|
950
957
|
network: authNetwork,
|
|
951
958
|
limiter: authRouteLimiter,
|
|
952
959
|
browserHandoffCodes,
|
|
960
|
+
confirmations: appConfirmations,
|
|
953
961
|
minter: ssoMinter,
|
|
954
962
|
logger: app.get(logging_service_1.LoggingService).createLogger('sso-finish'),
|
|
955
963
|
});
|
|
964
|
+
// ── Passkey sign-in for the native app (D858) ───────────────────
|
|
965
|
+
// A standalone page + two same-origin legs; the assertion ends in a handoff
|
|
966
|
+
// code at the app's custom scheme, never in a web session.
|
|
967
|
+
(0, app_passkey_routes_js_1.registerAppPasskeyRoutes)(fastify, {
|
|
968
|
+
passkeys: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(capabilityRegistry, app.get(moleculer_service_1.MoleculerService)),
|
|
969
|
+
secondFactors: (userId) => {
|
|
970
|
+
const um = capabilityRegistry.getSingleton('user-management');
|
|
971
|
+
return (0, second_factors_js_1.collectSecondFactors)({
|
|
972
|
+
...(um && typeof um.getTotpStatus === 'function'
|
|
973
|
+
? { getTotpStatus: (input) => um.getTotpStatus(input) }
|
|
974
|
+
: {}),
|
|
975
|
+
passkeyProvider: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(capabilityRegistry, app.get(moleculer_service_1.MoleculerService)),
|
|
976
|
+
}, userId, 'passkey');
|
|
977
|
+
},
|
|
978
|
+
minter: ssoMinter,
|
|
979
|
+
confirmations: appConfirmations,
|
|
980
|
+
network: authNetwork,
|
|
981
|
+
limiter: authRouteLimiter,
|
|
982
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('app-passkey'),
|
|
983
|
+
});
|
|
984
|
+
// The last step of both app flows: "Sign in the app as <user>?" - the
|
|
985
|
+
// handoff code is minted only when the person presses Continue (D858).
|
|
986
|
+
(0, app_confirm_routes_js_1.registerAppConfirmRoutes)(fastify, {
|
|
987
|
+
confirmations: appConfirmations,
|
|
988
|
+
handoffCodes: handoffCodeService,
|
|
989
|
+
network: authNetwork,
|
|
990
|
+
limiter: authRouteLimiter,
|
|
991
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('app-confirm'),
|
|
992
|
+
});
|
|
956
993
|
// ── Backup archive download ─────────────────────────────────────
|
|
957
994
|
// Streams an archive through the storage cap's chunked-download
|
|
958
995
|
// protocol at the pace the client reads it (D447). The route, its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.576",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
]
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
41
|
+
"@camstack/addon-admin-ui": "1.2.426",
|
|
42
42
|
"@camstack/addon-agent-ui": "1.2.233",
|
|
43
|
-
"@camstack/addon-auth": "1.2.
|
|
43
|
+
"@camstack/addon-auth": "1.2.145",
|
|
44
44
|
"@camstack/addon-decoder-nodeav": "1.2.123",
|
|
45
45
|
"@camstack/addon-notifiers": "1.2.134",
|
|
46
46
|
"@camstack/addon-pipeline": "1.2.369",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@camstack/addon-post-analysis": "1.2.369",
|
|
49
49
|
"@camstack/develop": "0.1.10",
|
|
50
50
|
"@camstack/sdk": "1.2.131",
|
|
51
|
-
"@camstack/system": "1.2.
|
|
52
|
-
"@camstack/types": "1.2.
|
|
51
|
+
"@camstack/system": "1.2.435",
|
|
52
|
+
"@camstack/types": "1.2.371",
|
|
53
53
|
"@camstack/ui-library": "1.2.289",
|
|
54
54
|
"@fastify/compress": "^9.0.0",
|
|
55
55
|
"@fastify/cookie": "^11.0.2",
|