@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.
@@ -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
- types_1.RedirectLoginMethodSchema,
153
+ PublicRedirectLoginMethodSchema,
147
154
  PublicWidgetLoginMethodSchema,
148
- types_1.PasskeyLoginMethodSchema,
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 = types_1.RedirectLoginMethodSchema.safeParse(raw);
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
- const parsed = types_1.PasskeyLoginMethodSchema.safeParse(raw);
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
+ }
@@ -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
  }
@@ -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
- /** Search npm for camstack addon packages, optionally filtered by query */
781
- async searchNpm(query) {
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
- return filtered.map((r) => ({
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.574",
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.424",
41
+ "@camstack/addon-admin-ui": "1.2.426",
42
42
  "@camstack/addon-agent-ui": "1.2.233",
43
- "@camstack/addon-auth": "1.2.144",
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.433",
52
- "@camstack/types": "1.2.369",
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",