@camstack/server 1.2.579 → 1.2.580
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 +11 -0
- package/dist/api/core/cap-providers.js +3 -1
- package/dist/api/core/server-name.js +64 -0
- package/dist/api/oauth2/consent-page.js +9 -6
- package/dist/api/oauth2/oauth2-routes.js +5 -0
- package/dist/api/trpc/trpc.context.js +1 -0
- package/dist/api/trpc/trpc.router.js +29 -2
- package/dist/auth/app-confirm-page.js +20 -14
- package/dist/auth/app-confirm-routes.js +8 -1
- package/dist/auth/app-page-shell.js +4 -1
- package/dist/auth/app-page-strings.js +157 -0
- package/dist/auth/app-passkey-page.js +32 -16
- package/dist/auth/app-passkey-routes.js +9 -2
- package/dist/auth/app-refused-route.js +15 -9
- package/dist/auth/session-connections.js +136 -0
- package/dist/auth/sso-finish-app.js +7 -1
- package/dist/main.js +31 -0
- package/package.json +8 -8
|
@@ -41,6 +41,7 @@ const auth_revoke_credentials_procedures_js_1 = require("./auth-revoke-credentia
|
|
|
41
41
|
const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
|
|
42
42
|
const auth_device_cert_procedures_js_1 = require("./auth-device-cert.procedures.js");
|
|
43
43
|
const auth_sessions_procedures_js_1 = require("./auth-sessions.procedures.js");
|
|
44
|
+
const server_name_js_1 = require("./server-name.js");
|
|
44
45
|
/**
|
|
45
46
|
* A public procedure limited per (procedure, REAL client address) — see
|
|
46
47
|
* `auth-limits.ts` for the tiers and why the address is the forwarded client.
|
|
@@ -459,6 +460,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
459
460
|
return refuse('this sign-in already started a step-up');
|
|
460
461
|
}
|
|
461
462
|
};
|
|
463
|
+
const serverNameReader = (0, server_name_js_1.serverNameReaderFor)(registry);
|
|
462
464
|
const aggregateLoginMethods = async () => {
|
|
463
465
|
if (!registry)
|
|
464
466
|
return [];
|
|
@@ -1504,5 +1506,14 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
1504
1506
|
.input(zod_1.z.void())
|
|
1505
1507
|
.output(zod_1.z.array(PublicLoginMethodSchema).readonly())
|
|
1506
1508
|
.query(async () => aggregateLoginMethods()),
|
|
1509
|
+
/**
|
|
1510
|
+
* The hub's human name (D861), public like `listLoginMethods` and exposing
|
|
1511
|
+
* nothing else. A separate method on purpose: `listLoginMethods` stays the bare
|
|
1512
|
+
* array every installed viewer already parses.
|
|
1513
|
+
*/
|
|
1514
|
+
getServerName: trpc_middleware_js_1.publicProcedure
|
|
1515
|
+
.input(zod_1.z.void())
|
|
1516
|
+
.output(types_1.ServerNameInfoSchema)
|
|
1517
|
+
.query(async () => serverNameReader.read()),
|
|
1507
1518
|
});
|
|
1508
1519
|
}
|
|
@@ -87,6 +87,7 @@ const failure_contributions_js_1 = require("./failure-contributions.js");
|
|
|
87
87
|
const load_contributions_js_1 = require("./load-contributions.js");
|
|
88
88
|
const logging_settings_js_1 = require("./logging-settings.js");
|
|
89
89
|
const request_census_settings_js_1 = require("./request-census-settings.js");
|
|
90
|
+
const server_name_js_1 = require("./server-name.js");
|
|
90
91
|
const site_location_js_1 = require("./site-location.js");
|
|
91
92
|
// ── system ──────────────────────────────────────────────────────────
|
|
92
93
|
function getRetention(registry) {
|
|
@@ -95,6 +96,7 @@ function getRetention(registry) {
|
|
|
95
96
|
function buildSystemProvider(feature, registry, logger, census = (0, http_request_census_singleton_js_1.getHttpRequestCensus)()) {
|
|
96
97
|
const siteLocation = buildSiteLocationService(registry, logger);
|
|
97
98
|
const store = buildSettingsStoreGateway(registry);
|
|
99
|
+
const serverName = new server_name_js_1.ServerNameReader({ store, ...(logger !== undefined ? { logger } : {}) });
|
|
98
100
|
const requestCensus = new request_census_settings_js_1.RequestCensusService({
|
|
99
101
|
census,
|
|
100
102
|
store,
|
|
@@ -130,7 +132,7 @@ function buildSystemProvider(feature, registry, logger, census = (0, http_reques
|
|
|
130
132
|
...(logger !== undefined ? { logger } : {}),
|
|
131
133
|
});
|
|
132
134
|
return {
|
|
133
|
-
info: async () => feature.getManifest(),
|
|
135
|
+
info: async () => ({ ...feature.getManifest(), serverName: await serverName.read() }),
|
|
134
136
|
health: async () => ({ status: 'ok', uptime: process.uptime() }),
|
|
135
137
|
featureFlags: async () => feature.getManifest(),
|
|
136
138
|
networkAddresses: async () => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reads the hub's human name (D861) from the one row that holds it,
|
|
4
|
+
* `system-settings` / `server.name`, and falls back to the machine hostname.
|
|
5
|
+
*
|
|
6
|
+
* Read-only on purpose: the only writer is the `system-config` addon's form
|
|
7
|
+
* (System -> Settings), which validates with the same `ServerNameSchema`. Nothing
|
|
8
|
+
* here writes a default, at boot or on a read.
|
|
9
|
+
*
|
|
10
|
+
* A read that FAILS is not an unset name: it answers the hostname default for
|
|
11
|
+
* display (a title is never worth a failed page) but says so in the log, so a
|
|
12
|
+
* hub that looks unnamed because its store is down is not mistaken for one that
|
|
13
|
+
* was never named.
|
|
14
|
+
*/
|
|
15
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
16
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.ServerNameReader = void 0;
|
|
20
|
+
exports.serverNameReaderFor = serverNameReaderFor;
|
|
21
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
22
|
+
const types_1 = require("@camstack/types");
|
|
23
|
+
const SYSTEM_SETTINGS_COLLECTION = 'system-settings';
|
|
24
|
+
class ServerNameReader {
|
|
25
|
+
deps;
|
|
26
|
+
constructor(deps) {
|
|
27
|
+
this.deps = deps;
|
|
28
|
+
}
|
|
29
|
+
async read() {
|
|
30
|
+
const hostname = (this.deps.hostname ?? node_os_1.default.hostname)();
|
|
31
|
+
try {
|
|
32
|
+
const stored = await this.deps.store.get({
|
|
33
|
+
collection: SYSTEM_SETTINGS_COLLECTION,
|
|
34
|
+
key: types_1.SERVER_NAME_SETTING_KEY,
|
|
35
|
+
});
|
|
36
|
+
return (0, types_1.resolveServerName)(stored, hostname);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
this.deps.logger?.warn('server name read failed - answering the hostname default', {
|
|
40
|
+
meta: { error: (0, types_1.errMsg)(err) },
|
|
41
|
+
});
|
|
42
|
+
return (0, types_1.resolveServerName)(undefined, hostname);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.ServerNameReader = ServerNameReader;
|
|
47
|
+
/**
|
|
48
|
+
* A reader that resolves the `settings-store` cap lazily from the registry (it
|
|
49
|
+
* boots after the routers are built). Without a registry, or before the cap is
|
|
50
|
+
* up, the read throws and {@link ServerNameReader} answers the hostname default.
|
|
51
|
+
*/
|
|
52
|
+
function serverNameReaderFor(registry, logger) {
|
|
53
|
+
return new ServerNameReader({
|
|
54
|
+
store: {
|
|
55
|
+
get: async (input) => {
|
|
56
|
+
const provider = registry?.getSingleton('settings-store') ?? null;
|
|
57
|
+
if (provider === null)
|
|
58
|
+
throw new Error('settings-store unavailable: cap not registered yet');
|
|
59
|
+
return provider.get(input);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
...(logger !== undefined ? { logger } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.renderConsentPage = renderConsentPage;
|
|
4
4
|
const app_page_shell_js_1 = require("../../auth/app-page-shell.js");
|
|
5
|
+
const app_page_strings_js_1 = require("../../auth/app-page-strings.js");
|
|
5
6
|
/** Allow/Deny consent screen on the hub's shared page shell (the look of the
|
|
6
7
|
* app sign-in pages). Submitting POSTs to the same path with the hidden query
|
|
7
8
|
* fields + `consent=allow|deny`. */
|
|
@@ -9,17 +10,19 @@ function renderConsentPage(input) {
|
|
|
9
10
|
const hidden = Object.entries(input.hidden)
|
|
10
11
|
.map(([name, value]) => (0, app_page_shell_js_1.appHiddenField)(name, value))
|
|
11
12
|
.join('');
|
|
13
|
+
const t = (0, app_page_strings_js_1.appStrings)(input.lang).consent;
|
|
12
14
|
const destination = input.redirectUri
|
|
13
|
-
? `<p class="hint"
|
|
15
|
+
? `<p class="hint">${(0, app_page_shell_js_1.escapeHtml)(t.sendingTo)}<code>${(0, app_page_shell_js_1.escapeHtml)(input.redirectUri)}</code></p>`
|
|
14
16
|
: '';
|
|
15
17
|
return (0, app_page_shell_js_1.renderAppPage)({
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
lang: input.lang,
|
|
19
|
+
title: `${t.titlePrefix}${input.displayName}`,
|
|
20
|
+
heading: `${t.headingPrefix}${input.displayName}`,
|
|
18
21
|
body: [
|
|
19
|
-
`<p
|
|
20
|
-
(0, app_page_shell_js_1.appHint)(
|
|
22
|
+
`<p>${(0, app_page_shell_js_1.escapeHtml)(t.signedInAs)}<strong>${(0, app_page_shell_js_1.escapeHtml)(input.username)}</strong></p>`,
|
|
23
|
+
(0, app_page_shell_js_1.appHint)(`${t.grantPrefix}${input.scopeSummary}`),
|
|
21
24
|
destination,
|
|
22
|
-
`<form method="POST">${hidden}<div class="actions">${(0, app_page_shell_js_1.appSubmitButton)(
|
|
25
|
+
`<form method="POST">${hidden}<div class="actions">${(0, app_page_shell_js_1.appSubmitButton)(t.allow, 'consent', 'allow')}${(0, app_page_shell_js_1.appSubmitButton)(t.deny, 'consent', 'deny', true)}</div></form>`,
|
|
23
26
|
].join(''),
|
|
24
27
|
});
|
|
25
28
|
}
|
|
@@ -7,6 +7,7 @@ exports.isRedirectUriAllowed = isRedirectUriAllowed;
|
|
|
7
7
|
exports.summariseScopes = summariseScopes;
|
|
8
8
|
exports.registerOauth2Routes = registerOauth2Routes;
|
|
9
9
|
const session_cookie_js_1 = require("../../auth/session-cookie.js");
|
|
10
|
+
const app_page_strings_js_1 = require("../../auth/app-page-strings.js");
|
|
10
11
|
const consent_page_js_1 = require("./consent-page.js");
|
|
11
12
|
const private_host_js_1 = require("./private-host.js");
|
|
12
13
|
/**
|
|
@@ -341,6 +342,10 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
341
342
|
.send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
|
|
342
343
|
}
|
|
343
344
|
const html = (0, consent_page_js_1.renderConsentPage)({
|
|
345
|
+
lang: (0, app_page_strings_js_1.resolveAppPageLang)({
|
|
346
|
+
lang: request.query['lang'],
|
|
347
|
+
acceptLanguage: request.headers['accept-language'],
|
|
348
|
+
}),
|
|
344
349
|
displayName: descriptor.displayName,
|
|
345
350
|
username: tokenInfo.username ?? '',
|
|
346
351
|
scopeSummary: summariseScopes(descriptor.requestedScopes),
|
|
@@ -364,6 +364,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
|
|
|
364
364
|
return {
|
|
365
365
|
user,
|
|
366
366
|
req: opts.req,
|
|
367
|
+
...(user === null && token !== null && token !== '' ? { credentialRejected: true } : {}),
|
|
367
368
|
...(user !== null && sessionId !== undefined
|
|
368
369
|
? {
|
|
369
370
|
sessionLive: () => authService.checkSession({
|
|
@@ -41,6 +41,7 @@ exports.wrapWebrtcSessionProviderWithRelay = wrapWebrtcSessionProviderWithRelay;
|
|
|
41
41
|
exports.buildAppRouter = buildAppRouter;
|
|
42
42
|
const path = __importStar(require("node:path"));
|
|
43
43
|
const addon_settings_router_js_1 = require("../core/addon-settings.router.js");
|
|
44
|
+
const session_connections_js_1 = require("../../auth/session-connections.js");
|
|
44
45
|
const agent_pairing_router_js_1 = require("../core/agent-pairing.router.js");
|
|
45
46
|
const certificates_router_js_1 = require("../core/certificates.router.js");
|
|
46
47
|
const node_certificates_client_js_1 = require("../../core/agent/node-certificates-client.js");
|
|
@@ -199,10 +200,36 @@ function enrichHintsWithClientClass(input, _clientClass) {
|
|
|
199
200
|
function wrapWebrtcSessionProviderWithRelay(provider, ctx) {
|
|
200
201
|
const userAgent = (0, client_ip_js_1.extractUserAgent)(ctx.req);
|
|
201
202
|
const clientClass = (0, client_ip_js_1.classifyClientRequest)(ctx.req);
|
|
203
|
+
// The login session this request runs under: media opened here dies with it
|
|
204
|
+
// (`session-connections.ts`). A principal with no `sid` has nothing to tie to.
|
|
205
|
+
const authSessionId = ctx.user?.sessionId;
|
|
206
|
+
const hold = (deviceId, mediaSessionId) => {
|
|
207
|
+
if (authSessionId === undefined)
|
|
208
|
+
return;
|
|
209
|
+
(0, session_connections_js_1.getSessionConnections)().trackMedia(authSessionId, {
|
|
210
|
+
deviceId,
|
|
211
|
+
mediaSessionId,
|
|
212
|
+
close: () => provider.closeSession({ deviceId, sessionId: mediaSessionId }),
|
|
213
|
+
});
|
|
214
|
+
};
|
|
202
215
|
return {
|
|
203
216
|
...provider,
|
|
204
|
-
createSession: (input) =>
|
|
205
|
-
|
|
217
|
+
createSession: async (input) => {
|
|
218
|
+
const created = await provider.createSession(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(input, userAgent), clientClass), clientClass));
|
|
219
|
+
hold(input.deviceId, created.sessionId);
|
|
220
|
+
return created;
|
|
221
|
+
},
|
|
222
|
+
handleOffer: async (input) => {
|
|
223
|
+
const answered = await provider.handleOffer(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(stripTalkbackFromRequest(input, clientClass), userAgent), clientClass), clientClass));
|
|
224
|
+
hold(input.deviceId, answered.sessionId);
|
|
225
|
+
return answered;
|
|
226
|
+
},
|
|
227
|
+
closeSession: (input) => {
|
|
228
|
+
if (authSessionId !== undefined) {
|
|
229
|
+
(0, session_connections_js_1.getSessionConnections)().forgetMedia(authSessionId, input.sessionId);
|
|
230
|
+
}
|
|
231
|
+
return provider.closeSession(input);
|
|
232
|
+
},
|
|
206
233
|
};
|
|
207
234
|
}
|
|
208
235
|
/**
|
|
@@ -17,9 +17,11 @@ exports.appConfirmRefusalPage = appConfirmRefusalPage;
|
|
|
17
17
|
* `error=cancelled`.
|
|
18
18
|
*/
|
|
19
19
|
const app_page_shell_js_1 = require("./app-page-shell.js");
|
|
20
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
20
21
|
const PAGE_SCRIPT = `(function () {
|
|
21
22
|
var btn = document.getElementById('go');
|
|
22
23
|
var msg = document.getElementById('msg');
|
|
24
|
+
var S = function (k) { return document.body.getAttribute('data-s-' + k) || ''; };
|
|
23
25
|
var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
|
|
24
26
|
btn.addEventListener('click', function () {
|
|
25
27
|
btn.disabled = true;
|
|
@@ -32,11 +34,11 @@ const PAGE_SCRIPT = `(function () {
|
|
|
32
34
|
cache: 'no-store'
|
|
33
35
|
}).then(function (r) { return r.json(); }).then(function (body) {
|
|
34
36
|
if (typeof body.location !== 'string' || !APP_CALLBACK.test(body.location)) {
|
|
35
|
-
throw new Error(
|
|
37
|
+
throw new Error('bad-location');
|
|
36
38
|
}
|
|
37
39
|
location.assign(body.location);
|
|
38
40
|
}).catch(function () {
|
|
39
|
-
msg.textContent = '
|
|
41
|
+
msg.textContent = S('confirm-expired');
|
|
40
42
|
});
|
|
41
43
|
});
|
|
42
44
|
})();`;
|
|
@@ -46,27 +48,31 @@ exports.APP_CONFIRM_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCR
|
|
|
46
48
|
exports.APP_CONFIRM_REFUSAL_CSP = (0, app_page_shell_js_1.appPageCsp)();
|
|
47
49
|
/** The confirmation page for `username`; `cancelHref` is the app callback carrying `error=cancelled`. */
|
|
48
50
|
function appConfirmPage(input) {
|
|
51
|
+
const t = (0, app_page_strings_js_1.appStrings)(input.lang);
|
|
49
52
|
return (0, app_page_shell_js_1.renderAppPage)({
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
lang: input.lang,
|
|
54
|
+
title: t.confirm.title,
|
|
55
|
+
heading: t.confirm.heading,
|
|
52
56
|
noReferrer: true,
|
|
53
57
|
script: PAGE_SCRIPT,
|
|
58
|
+
scriptStrings: { 'confirm-expired': t.script.confirmExpired },
|
|
54
59
|
body: [
|
|
55
|
-
`<p
|
|
56
|
-
(0, app_page_shell_js_1.appHint)(
|
|
57
|
-
(0, app_page_shell_js_1.appButton)(
|
|
60
|
+
`<p>${(0, app_page_shell_js_1.escapeHtml)(t.confirm.signedInAsBefore)}<strong>${(0, app_page_shell_js_1.escapeHtml)(input.username)}</strong>${(0, app_page_shell_js_1.escapeHtml)(t.confirm.signedInAsAfter)}</p>`,
|
|
61
|
+
(0, app_page_shell_js_1.appHint)(t.confirm.hint),
|
|
62
|
+
(0, app_page_shell_js_1.appButton)(t.confirm.continueLabel, `id="go" data-confirm="${(0, app_page_shell_js_1.escapeHtml)(input.token)}"`),
|
|
58
63
|
(0, app_page_shell_js_1.appErrorLine)('msg'),
|
|
59
|
-
(0, app_page_shell_js_1.appCancelLink)(input.cancelHref,
|
|
60
|
-
|
|
64
|
+
(0, app_page_shell_js_1.appCancelLink)(input.cancelHref, t.confirm.cancel),
|
|
65
|
+
`<noscript><p class="error">${(0, app_page_shell_js_1.escapeHtml)(t.confirm.noscript)}</p></noscript>`,
|
|
61
66
|
].join(''),
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
69
|
/** A confirmation that is gone (expired, used, or opened in another browser): a sentence, nothing runs. */
|
|
65
|
-
function appConfirmRefusalPage() {
|
|
70
|
+
function appConfirmRefusalPage(lang) {
|
|
71
|
+
const t = (0, app_page_strings_js_1.appStrings)(lang).confirmExpired;
|
|
66
72
|
return (0, app_page_shell_js_1.renderAppPage)({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
lang,
|
|
74
|
+
title: t.title,
|
|
75
|
+
heading: t.title,
|
|
76
|
+
body: (0, app_page_shell_js_1.appParagraph)(t.body) + (0, app_page_shell_js_1.appHint)(t.hint),
|
|
71
77
|
});
|
|
72
78
|
}
|
|
@@ -17,6 +17,7 @@ const types_1 = require("@camstack/types");
|
|
|
17
17
|
const zod_1 = require("zod");
|
|
18
18
|
const app_confirmations_js_1 = require("./app-confirmations.js");
|
|
19
19
|
const app_confirm_page_js_1 = require("./app-confirm-page.js");
|
|
20
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
20
21
|
const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
|
|
21
22
|
const sso_finish_deps_js_1 = require("./sso-finish-deps.js");
|
|
22
23
|
exports.APP_CONFIRM_PATH = '/auth/app/confirm';
|
|
@@ -75,10 +76,16 @@ function registerAppConfirmRoutes(fastify, deps) {
|
|
|
75
76
|
meta: { reason: 'unknown-expired-or-other-browser' },
|
|
76
77
|
});
|
|
77
78
|
reply.header('Content-Security-Policy', app_confirm_page_js_1.APP_CONFIRM_REFUSAL_CSP);
|
|
78
|
-
return reply
|
|
79
|
+
return reply
|
|
80
|
+
.status(400)
|
|
81
|
+
.send((0, app_confirm_page_js_1.appConfirmRefusalPage)((0, app_page_strings_js_1.resolveAppPageLang)({ acceptLanguage: request.headers['accept-language'] })));
|
|
79
82
|
}
|
|
80
83
|
reply.header('Content-Security-Policy', app_confirm_page_js_1.APP_CONFIRM_CSP);
|
|
81
84
|
return reply.send((0, app_confirm_page_js_1.appConfirmPage)({
|
|
85
|
+
lang: (0, app_page_strings_js_1.resolveAppPageLang)({
|
|
86
|
+
lang: confirmation.app.lang,
|
|
87
|
+
acceptLanguage: request.headers['accept-language'],
|
|
88
|
+
}),
|
|
82
89
|
username: confirmation.username,
|
|
83
90
|
token,
|
|
84
91
|
cancelHref: (0, types_1.appFlowLocation)(confirmation.app.redirect, { error: 'cancelled' }, confirmation.app.state),
|
|
@@ -95,7 +95,10 @@ function escapeHtml(text) {
|
|
|
95
95
|
function renderAppPage(input) {
|
|
96
96
|
const referrer = input.noReferrer === true ? '<meta name="referrer" content="no-referrer">' : '';
|
|
97
97
|
const script = input.script === undefined ? '' : `<script>${input.script}</script>`;
|
|
98
|
-
|
|
98
|
+
const strings = Object.entries(input.scriptStrings ?? {})
|
|
99
|
+
.map(([key, value]) => ` data-s-${key}="${escapeHtml(value)}"`)
|
|
100
|
+
.join('');
|
|
101
|
+
return `<!doctype html><html lang="${input.lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark">${referrer}<title>${escapeHtml(input.title)}</title><style>${exports.APP_PAGE_STYLE}</style></head><body${strings}><main><div class="brand">${LOGO_DARK}${LOGO_LIGHT}</div><div class="card"><h1>${escapeHtml(input.heading)}</h1>${input.body}</div></main>${script}</body></html>`;
|
|
99
102
|
}
|
|
100
103
|
/** A paragraph of the card (`text` is escaped). */
|
|
101
104
|
const appParagraph = (text) => `<p>${escapeHtml(text)}</p>`;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.APP_PAGE_STRINGS = exports.APP_PAGE_DEFAULT_LANG = void 0;
|
|
4
|
+
exports.appStrings = appStrings;
|
|
5
|
+
exports.langFromAcceptLanguage = langFromAcceptLanguage;
|
|
6
|
+
exports.resolveAppPageLang = resolveAppPageLang;
|
|
7
|
+
/**
|
|
8
|
+
* Every word the hub's app landing pages show (D858), in both languages, and
|
|
9
|
+
* the one rule that picks the language.
|
|
10
|
+
*
|
|
11
|
+
* The pages (`app-confirm-page.ts`, `app-passkey-page.ts`, `app-refused-route.ts`,
|
|
12
|
+
* the OAuth2 consent page) hold NO prose of their own: they ask {@link appStrings}.
|
|
13
|
+
* A key present in one locale and not the other is a compile error AND a spec
|
|
14
|
+
* failure; a page that renders an English literal is a spec failure
|
|
15
|
+
* (`app-page-strings.spec.ts`).
|
|
16
|
+
*
|
|
17
|
+
* Language choice ({@link resolveAppPageLang}): the `lang` the app sent
|
|
18
|
+
* (allow-listed) wins; else the first supported tag of `Accept-Language`
|
|
19
|
+
* (`it*` -> `it`); else English. Neither input is ever reflected into a page.
|
|
20
|
+
*/
|
|
21
|
+
const types_1 = require("@camstack/types");
|
|
22
|
+
exports.APP_PAGE_DEFAULT_LANG = 'en';
|
|
23
|
+
const EN = {
|
|
24
|
+
confirm: {
|
|
25
|
+
title: 'Sign in the CamStack app',
|
|
26
|
+
heading: 'Sign in the CamStack app?',
|
|
27
|
+
signedInAsBefore: 'The CamStack app on this device will be signed in as ',
|
|
28
|
+
signedInAsAfter: '.',
|
|
29
|
+
hint: 'Continue only if you just started this from the CamStack app.',
|
|
30
|
+
continueLabel: 'Continue',
|
|
31
|
+
cancel: 'Cancel',
|
|
32
|
+
noscript: 'JavaScript is required to continue.',
|
|
33
|
+
},
|
|
34
|
+
confirmExpired: {
|
|
35
|
+
title: 'Confirmation expired',
|
|
36
|
+
body: 'This sign-in confirmation is no longer valid.',
|
|
37
|
+
hint: 'Close this window and start the sign-in again from the app.',
|
|
38
|
+
},
|
|
39
|
+
passkey: {
|
|
40
|
+
title: 'Sign in with a passkey',
|
|
41
|
+
heading: 'Sign in to CamStack',
|
|
42
|
+
body: 'Use a passkey saved on this device to sign in to the CamStack app.',
|
|
43
|
+
button: 'Sign in with a passkey',
|
|
44
|
+
cancel: 'Cancel',
|
|
45
|
+
noscript: 'JavaScript is required to use a passkey.',
|
|
46
|
+
},
|
|
47
|
+
refused: {
|
|
48
|
+
title: 'Sign-in request refused',
|
|
49
|
+
hint: 'Close this window and try again from the app.',
|
|
50
|
+
generic: 'The sign-in request was refused.',
|
|
51
|
+
reasons: types_1.APP_START_REFUSAL_MESSAGES,
|
|
52
|
+
},
|
|
53
|
+
consent: {
|
|
54
|
+
titlePrefix: 'CamStack · Authorize ',
|
|
55
|
+
headingPrefix: 'Authorize ',
|
|
56
|
+
signedInAs: 'Signed in as ',
|
|
57
|
+
grantPrefix: 'This will grant: ',
|
|
58
|
+
sendingTo: 'Sending the authorization to ',
|
|
59
|
+
allow: 'Allow',
|
|
60
|
+
deny: 'Deny',
|
|
61
|
+
},
|
|
62
|
+
script: {
|
|
63
|
+
confirmExpired: 'This confirmation expired. Start the sign-in again from the app.',
|
|
64
|
+
passkeyUnsupported: 'This browser cannot use passkeys.',
|
|
65
|
+
passkeyUnavailable: 'Passkeys are not available.',
|
|
66
|
+
passkeyCancelled: 'The passkey request was cancelled.',
|
|
67
|
+
signInFailed: 'Sign-in failed. Try again.',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const IT = {
|
|
71
|
+
confirm: {
|
|
72
|
+
title: "Accedi all'app CamStack",
|
|
73
|
+
heading: "Accedere all'app CamStack?",
|
|
74
|
+
signedInAsBefore: "L'app CamStack su questo dispositivo accederà come ",
|
|
75
|
+
signedInAsAfter: '.',
|
|
76
|
+
hint: "Continua solo se hai appena avviato l'operazione dall'app CamStack.",
|
|
77
|
+
continueLabel: 'Continua',
|
|
78
|
+
cancel: 'Annulla',
|
|
79
|
+
noscript: 'Per continuare serve JavaScript.',
|
|
80
|
+
},
|
|
81
|
+
confirmExpired: {
|
|
82
|
+
title: 'Conferma scaduta',
|
|
83
|
+
body: 'Questa conferma di accesso non è più valida.',
|
|
84
|
+
hint: "Chiudi questa finestra e riavvia l'accesso dall'app.",
|
|
85
|
+
},
|
|
86
|
+
passkey: {
|
|
87
|
+
title: 'Accedi con una passkey',
|
|
88
|
+
heading: 'Accedi a CamStack',
|
|
89
|
+
body: "Usa una passkey salvata su questo dispositivo per accedere all'app CamStack.",
|
|
90
|
+
button: 'Accedi con una passkey',
|
|
91
|
+
cancel: 'Annulla',
|
|
92
|
+
noscript: 'Per usare una passkey serve JavaScript.',
|
|
93
|
+
},
|
|
94
|
+
refused: {
|
|
95
|
+
title: 'Richiesta di accesso rifiutata',
|
|
96
|
+
hint: "Chiudi questa finestra e riprova dall'app.",
|
|
97
|
+
generic: 'La richiesta di accesso è stata rifiutata.',
|
|
98
|
+
reasons: {
|
|
99
|
+
'bad-challenge': 'La richiesta di accesso non ha una sfida PKCE valida (S256).',
|
|
100
|
+
'bad-challenge-method': 'È supportato solo il metodo PKCE S256.',
|
|
101
|
+
'bad-state': 'La richiesta di accesso non ha uno state valido.',
|
|
102
|
+
'bad-purpose': 'La richiesta di accesso indica uno scopo sconosciuto.',
|
|
103
|
+
'bad-redirect': 'La richiesta di accesso indica un indirizzo di ritorno che questo hub non accetta.',
|
|
104
|
+
'combined-flow': "L'accesso dell'app non può essere combinato con il collegamento o con un popup.",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
consent: {
|
|
108
|
+
titlePrefix: 'CamStack · Autorizza ',
|
|
109
|
+
headingPrefix: 'Autorizza ',
|
|
110
|
+
signedInAs: 'Accesso eseguito come ',
|
|
111
|
+
grantPrefix: 'Concederà: ',
|
|
112
|
+
sendingTo: "Invio dell'autorizzazione a ",
|
|
113
|
+
allow: 'Consenti',
|
|
114
|
+
deny: 'Nega',
|
|
115
|
+
},
|
|
116
|
+
script: {
|
|
117
|
+
confirmExpired: "Questa conferma è scaduta. Riavvia l'accesso dall'app.",
|
|
118
|
+
passkeyUnsupported: 'Questo browser non supporta le passkey.',
|
|
119
|
+
passkeyUnavailable: 'Le passkey non sono disponibili.',
|
|
120
|
+
passkeyCancelled: 'La richiesta della passkey è stata annullata.',
|
|
121
|
+
signInFailed: 'Accesso non riuscito. Riprova.',
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
exports.APP_PAGE_STRINGS = { en: EN, it: IT };
|
|
125
|
+
function appStrings(lang) {
|
|
126
|
+
return exports.APP_PAGE_STRINGS[lang];
|
|
127
|
+
}
|
|
128
|
+
/** The first supported language of an `Accept-Language` value (by q, then order), or `undefined`. */
|
|
129
|
+
function langFromAcceptLanguage(header) {
|
|
130
|
+
const value = typeof header === 'string' ? header : header?.join(',');
|
|
131
|
+
if (value === undefined || value.length > 512)
|
|
132
|
+
return undefined;
|
|
133
|
+
const ranked = value
|
|
134
|
+
.split(',')
|
|
135
|
+
.map((part, index) => {
|
|
136
|
+
const [tag = '', ...params] = part.trim().split(';');
|
|
137
|
+
const q = params
|
|
138
|
+
.map((p) => /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p)?.[1])
|
|
139
|
+
.find((m) => m !== undefined);
|
|
140
|
+
const weight = q === undefined ? 1 : Number.parseFloat(q);
|
|
141
|
+
return { primary: tag.trim().toLowerCase().split('-')[0] ?? '', weight, index };
|
|
142
|
+
})
|
|
143
|
+
.filter((t) => Number.isFinite(t.weight) && t.weight > 0)
|
|
144
|
+
.sort((a, b) => b.weight - a.weight || a.index - b.index);
|
|
145
|
+
for (const { primary } of ranked) {
|
|
146
|
+
const lang = (0, types_1.parseAppPageLang)(primary);
|
|
147
|
+
if (lang !== undefined)
|
|
148
|
+
return lang;
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
/** The page language: the app's `lang` (allow-listed), else `Accept-Language`, else English. */
|
|
153
|
+
function resolveAppPageLang(input) {
|
|
154
|
+
return ((0, types_1.parseAppPageLang)(input.lang) ??
|
|
155
|
+
langFromAcceptLanguage(input.acceptLanguage) ??
|
|
156
|
+
exports.APP_PAGE_DEFAULT_LANG);
|
|
157
|
+
}
|
|
@@ -20,12 +20,14 @@ exports.appPasskeyRefusalPage = appPasskeyRefusalPage;
|
|
|
20
20
|
* (allow-listed, HTML-escaped) cancel link.
|
|
21
21
|
*/
|
|
22
22
|
const app_page_shell_js_1 = require("./app-page-shell.js");
|
|
23
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
23
24
|
const PAGE_SCRIPT = `(function () {
|
|
24
25
|
var q = new URLSearchParams(location.search);
|
|
25
26
|
var base = location.pathname.replace(/\\/+$/, '');
|
|
26
27
|
var btn = document.getElementById('go');
|
|
27
28
|
var msg = document.getElementById('msg');
|
|
28
|
-
var
|
|
29
|
+
var S = function (k) { return document.body.getAttribute('data-s-' + k) || ''; };
|
|
30
|
+
var KEYS = ['code_challenge', 'code_challenge_method', 'state', 'purpose', 'redirect', 'lang'];
|
|
29
31
|
var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
|
|
30
32
|
function fromB64u(s) {
|
|
31
33
|
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
@@ -58,10 +60,10 @@ const PAGE_SCRIPT = `(function () {
|
|
|
58
60
|
btn.disabled = true;
|
|
59
61
|
msg.textContent = '';
|
|
60
62
|
if (!window.PublicKeyCredential || !navigator.credentials) {
|
|
61
|
-
return fail('
|
|
63
|
+
return fail(S('passkey-unsupported'));
|
|
62
64
|
}
|
|
63
65
|
post('/begin', flow()).then(function (begin) {
|
|
64
|
-
if (!begin.ok) throw new Error(
|
|
66
|
+
if (!begin.ok) throw new Error('unavailable');
|
|
65
67
|
var opts = begin.body.optionsJSON;
|
|
66
68
|
var pk = {
|
|
67
69
|
challenge: fromB64u(opts.challenge),
|
|
@@ -98,11 +100,15 @@ const PAGE_SCRIPT = `(function () {
|
|
|
98
100
|
}
|
|
99
101
|
var target = done.body && done.body.location;
|
|
100
102
|
if (typeof target !== 'string' || !APP_CALLBACK.test(target)) {
|
|
101
|
-
throw new Error(
|
|
103
|
+
throw new Error('failed');
|
|
102
104
|
}
|
|
103
105
|
location.assign(target);
|
|
104
106
|
}).catch(function (e) {
|
|
105
|
-
fail(
|
|
107
|
+
fail(
|
|
108
|
+
e && e.name === 'NotAllowedError' ? S('passkey-cancelled')
|
|
109
|
+
: e && e.message === 'unavailable' ? S('passkey-unavailable')
|
|
110
|
+
: S('sign-in-failed')
|
|
111
|
+
);
|
|
106
112
|
});
|
|
107
113
|
}
|
|
108
114
|
btn.addEventListener('click', go);
|
|
@@ -113,27 +119,37 @@ const PAGE_SCRIPT = `(function () {
|
|
|
113
119
|
*/
|
|
114
120
|
exports.APP_PASSKEY_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCRIPT });
|
|
115
121
|
/** The page; `cancelHref` is the app callback carrying `error=cancelled`. */
|
|
116
|
-
function appPasskeyPage(cancelHref) {
|
|
122
|
+
function appPasskeyPage(cancelHref, lang) {
|
|
123
|
+
const t = (0, app_page_strings_js_1.appStrings)(lang);
|
|
117
124
|
return (0, app_page_shell_js_1.renderAppPage)({
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
lang,
|
|
126
|
+
title: t.passkey.title,
|
|
127
|
+
heading: t.passkey.heading,
|
|
120
128
|
noReferrer: true,
|
|
121
129
|
script: PAGE_SCRIPT,
|
|
130
|
+
scriptStrings: {
|
|
131
|
+
'passkey-unsupported': t.script.passkeyUnsupported,
|
|
132
|
+
'passkey-unavailable': t.script.passkeyUnavailable,
|
|
133
|
+
'passkey-cancelled': t.script.passkeyCancelled,
|
|
134
|
+
'sign-in-failed': t.script.signInFailed,
|
|
135
|
+
},
|
|
122
136
|
body: [
|
|
123
|
-
(0, app_page_shell_js_1.appParagraph)(
|
|
124
|
-
(0, app_page_shell_js_1.appButton)(
|
|
137
|
+
(0, app_page_shell_js_1.appParagraph)(t.passkey.body),
|
|
138
|
+
(0, app_page_shell_js_1.appButton)(t.passkey.button, 'id="go"'),
|
|
125
139
|
(0, app_page_shell_js_1.appErrorLine)('msg'),
|
|
126
|
-
(0, app_page_shell_js_1.appCancelLink)(cancelHref,
|
|
127
|
-
|
|
140
|
+
(0, app_page_shell_js_1.appCancelLink)(cancelHref, t.passkey.cancel),
|
|
141
|
+
`<noscript><p class="error">${(0, app_page_shell_js_1.escapeHtml)(t.passkey.noscript)}</p></noscript>`,
|
|
128
142
|
].join(''),
|
|
129
143
|
});
|
|
130
144
|
}
|
|
131
145
|
/** The page for a request the hub refuses to serve: a sentence, no script, no assets. */
|
|
132
|
-
function appPasskeyRefusalPage(message) {
|
|
146
|
+
function appPasskeyRefusalPage(message, lang) {
|
|
147
|
+
const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
|
|
133
148
|
return (0, app_page_shell_js_1.renderAppPage)({
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
149
|
+
lang,
|
|
150
|
+
title: t.title,
|
|
151
|
+
heading: t.title,
|
|
152
|
+
body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
|
|
137
153
|
});
|
|
138
154
|
}
|
|
139
155
|
/** CSP of the refusal page: the shell's style only, nothing runs. */
|
|
@@ -22,6 +22,7 @@ const types_1 = require("@camstack/types");
|
|
|
22
22
|
const zod_1 = require("zod");
|
|
23
23
|
const app_confirm_routes_js_1 = require("./app-confirm-routes.js");
|
|
24
24
|
const app_passkey_page_js_1 = require("./app-passkey-page.js");
|
|
25
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
25
26
|
const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
|
|
26
27
|
const session_mint_js_1 = require("./session-mint.js");
|
|
27
28
|
exports.APP_PASSKEY_PATH = '/auth/app/passkey';
|
|
@@ -32,6 +33,7 @@ const FinishBodySchema = zod_1.z.object({
|
|
|
32
33
|
state: zod_1.z.string().optional(),
|
|
33
34
|
purpose: zod_1.z.string().optional(),
|
|
34
35
|
redirect: zod_1.z.string().optional(),
|
|
36
|
+
lang: zod_1.z.string().optional(),
|
|
35
37
|
});
|
|
36
38
|
function noStore(reply) {
|
|
37
39
|
reply.header('Cache-Control', 'no-store');
|
|
@@ -53,6 +55,10 @@ function registerAppPasskeyRoutes(fastify, deps) {
|
|
|
53
55
|
noStore(reply);
|
|
54
56
|
reply.type('text/html; charset=utf-8');
|
|
55
57
|
const parsed = (0, types_1.parseAppFlowRequest)(request.query);
|
|
58
|
+
const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
|
|
59
|
+
lang: parsed.ok ? parsed.request.lang : request.query['lang'],
|
|
60
|
+
acceptLanguage: request.headers['accept-language'],
|
|
61
|
+
});
|
|
56
62
|
if (!parsed.ok) {
|
|
57
63
|
deps.logger.warn('Passkey app sign-in refused at the page', {
|
|
58
64
|
meta: { reason: parsed.reason },
|
|
@@ -60,11 +66,11 @@ function registerAppPasskeyRoutes(fastify, deps) {
|
|
|
60
66
|
reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_REFUSAL_CSP);
|
|
61
67
|
return reply
|
|
62
68
|
.status(400)
|
|
63
|
-
.send((0, app_passkey_page_js_1.appPasskeyRefusalPage)(
|
|
69
|
+
.send((0, app_passkey_page_js_1.appPasskeyRefusalPage)((0, app_page_strings_js_1.appStrings)(lang).refused.reasons[parsed.reason], lang));
|
|
64
70
|
}
|
|
65
71
|
reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_CSP);
|
|
66
72
|
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));
|
|
73
|
+
return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel, lang));
|
|
68
74
|
});
|
|
69
75
|
fastify.post(`${exports.APP_PASSKEY_PATH}/begin`, async (request, reply) => {
|
|
70
76
|
noStore(reply);
|
|
@@ -103,6 +109,7 @@ function registerAppPasskeyRoutes(fastify, deps) {
|
|
|
103
109
|
state: body.data.state,
|
|
104
110
|
purpose: body.data.purpose,
|
|
105
111
|
redirect: body.data.redirect,
|
|
112
|
+
lang: body.data.lang,
|
|
106
113
|
});
|
|
107
114
|
if (!parsed.ok)
|
|
108
115
|
return reply.status(400).send({ error: types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason] });
|
|
@@ -8,20 +8,22 @@ exports.registerAppRefusedRoute = registerAppRefusedRoute;
|
|
|
8
8
|
* sign-in could not start (D858).
|
|
9
9
|
*
|
|
10
10
|
* The OIDC start lives in an addon, which cannot reach the hub's theme; it
|
|
11
|
-
* redirects here with a reason CODE and the
|
|
12
|
-
* same shell as every other app page. The query is never echoed: an unknown or
|
|
11
|
+
* redirects here with a reason CODE (and the app's optional `lang`) and the hub renders the
|
|
12
|
+
* sentence, in the language of `lang`/`Accept-Language`, in the same shell as every other app page. The query is never echoed: an unknown or
|
|
13
13
|
* absent code gets a generic sentence, a known one its fixed words.
|
|
14
14
|
*/
|
|
15
15
|
const types_1 = require("@camstack/types");
|
|
16
16
|
const app_page_shell_js_1 = require("./app-page-shell.js");
|
|
17
|
-
const
|
|
17
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
18
18
|
/** The refusal page for `reason` (a known code, or anything: unknown renders the generic sentence). */
|
|
19
|
-
function appRefusedPage(reason) {
|
|
20
|
-
const
|
|
19
|
+
function appRefusedPage(reason, lang) {
|
|
20
|
+
const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
|
|
21
|
+
const message = (0, types_1.isAppStartRefusal)(reason) ? t.reasons[reason] : t.generic;
|
|
21
22
|
return (0, app_page_shell_js_1.renderAppPage)({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
lang,
|
|
24
|
+
title: t.title,
|
|
25
|
+
heading: t.title,
|
|
26
|
+
body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
|
|
25
27
|
});
|
|
26
28
|
}
|
|
27
29
|
/** CSP of the refusal page: the shell's style only, nothing runs. */
|
|
@@ -32,6 +34,10 @@ function registerAppRefusedRoute(fastify) {
|
|
|
32
34
|
reply.header('Referrer-Policy', 'no-referrer');
|
|
33
35
|
reply.header('Content-Security-Policy', exports.APP_REFUSED_CSP);
|
|
34
36
|
reply.type('text/html; charset=utf-8');
|
|
35
|
-
|
|
37
|
+
const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
|
|
38
|
+
lang: request.query['lang'],
|
|
39
|
+
acceptLanguage: request.headers['accept-language'],
|
|
40
|
+
});
|
|
41
|
+
return reply.status(400).send(appRefusedPage(request.query['reason'], lang));
|
|
36
42
|
});
|
|
37
43
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a login session still HOLDS on the hub — its open WebSockets and its
|
|
4
|
+
* live media sessions — so that revoking the session ends them.
|
|
5
|
+
*
|
|
6
|
+
* A revoked `sid` is refused on the NEXT call (`ctx.sessionLive`), which is not
|
|
7
|
+
* the same as ending anything: the socket stayed open, the client kept polling
|
|
8
|
+
* as an anonymous principal and its stream kept playing until the app was
|
|
9
|
+
* killed (2026-10-16, "Sign out all my sessions"). The login-session table
|
|
10
|
+
* announces every revocation (`onSessionsRevoked`); this registry turns that
|
|
11
|
+
* into closed sockets and closed media, touching ONLY the sessions named.
|
|
12
|
+
*
|
|
13
|
+
* - A socket closes with {@link SESSION_ENDED_CLOSE_CODE} / {@link SESSION_ENDED_CLOSE_REASON}
|
|
14
|
+
* so the client can tell "your session ended" from a network drop.
|
|
15
|
+
* - A media hold is whatever the creating call registered as its way to end
|
|
16
|
+
* the session (the WebRTC `closeSession`); recorded playback rides the same
|
|
17
|
+
* cap, so the same hold covers it.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.SessionConnections = exports.SESSION_ENDED_CLOSE_REASON = exports.SESSION_ENDED_CLOSE_CODE = void 0;
|
|
21
|
+
exports.getSessionConnections = getSessionConnections;
|
|
22
|
+
exports.__resetSessionConnectionsForTests = __resetSessionConnectionsForTests;
|
|
23
|
+
/** WebSocket close code a client recognises as "the hub ended this session". */
|
|
24
|
+
exports.SESSION_ENDED_CLOSE_CODE = 4401;
|
|
25
|
+
exports.SESSION_ENDED_CLOSE_REASON = 'session-ended';
|
|
26
|
+
/** Bound on live media holds kept per login session (oldest dropped past it). */
|
|
27
|
+
const MAX_MEDIA_HOLDS_PER_SESSION = 256;
|
|
28
|
+
class SessionConnections {
|
|
29
|
+
sockets = new Set();
|
|
30
|
+
media = new Map();
|
|
31
|
+
logger = null;
|
|
32
|
+
setLogger(logger) {
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
}
|
|
35
|
+
/** Track an authenticated socket; returns the untrack (call on socket close). */
|
|
36
|
+
trackSocket(socket, identity) {
|
|
37
|
+
const tracked = { socket, identity };
|
|
38
|
+
this.sockets.add(tracked);
|
|
39
|
+
return () => {
|
|
40
|
+
this.sockets.delete(tracked);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Track a live media session opened under `sessionId`; returns the untrack. */
|
|
44
|
+
trackMedia(sessionId, hold) {
|
|
45
|
+
const holds = this.media.get(sessionId) ?? new Map();
|
|
46
|
+
holds.set(hold.mediaSessionId, hold);
|
|
47
|
+
if (holds.size > MAX_MEDIA_HOLDS_PER_SESSION) {
|
|
48
|
+
const oldest = holds.keys().next();
|
|
49
|
+
if (!oldest.done)
|
|
50
|
+
holds.delete(oldest.value);
|
|
51
|
+
}
|
|
52
|
+
this.media.set(sessionId, holds);
|
|
53
|
+
return () => {
|
|
54
|
+
const current = this.media.get(sessionId);
|
|
55
|
+
current?.delete(hold.mediaSessionId);
|
|
56
|
+
if (current?.size === 0)
|
|
57
|
+
this.media.delete(sessionId);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** A media session the client closed itself: nothing left to end. */
|
|
61
|
+
forgetMedia(sessionId, mediaSessionId) {
|
|
62
|
+
const current = this.media.get(sessionId);
|
|
63
|
+
current?.delete(mediaSessionId);
|
|
64
|
+
if (current?.size === 0)
|
|
65
|
+
this.media.delete(sessionId);
|
|
66
|
+
}
|
|
67
|
+
/** End what the revoked sessions hold. Sessions not named are never touched. */
|
|
68
|
+
endSessions(input) {
|
|
69
|
+
const named = new Set(input.sessionIds);
|
|
70
|
+
let sockets = 0;
|
|
71
|
+
for (const tracked of [...this.sockets]) {
|
|
72
|
+
const { sessionId, userId } = tracked.identity;
|
|
73
|
+
const hit = sessionId !== undefined
|
|
74
|
+
? named.has(sessionId)
|
|
75
|
+
: input.allOfUser && input.userId !== null && userId === input.userId;
|
|
76
|
+
if (!hit)
|
|
77
|
+
continue;
|
|
78
|
+
this.sockets.delete(tracked);
|
|
79
|
+
try {
|
|
80
|
+
tracked.socket.close(exports.SESSION_ENDED_CLOSE_CODE, exports.SESSION_ENDED_CLOSE_REASON);
|
|
81
|
+
sockets += 1;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
this.logger?.warn('Closing a socket of an ended session failed', {
|
|
85
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let mediaSessions = 0;
|
|
90
|
+
for (const sessionId of named) {
|
|
91
|
+
const holds = this.media.get(sessionId);
|
|
92
|
+
if (holds === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
this.media.delete(sessionId);
|
|
95
|
+
for (const hold of holds.values()) {
|
|
96
|
+
mediaSessions += 1;
|
|
97
|
+
void this.closeMedia(hold, input.reason);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (sockets > 0 || mediaSessions > 0) {
|
|
101
|
+
this.logger?.info('Revoked sessions ended their connections', {
|
|
102
|
+
meta: { reason: input.reason, sessions: input.sessionIds.length, sockets, mediaSessions },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return { sockets, mediaSessions };
|
|
106
|
+
}
|
|
107
|
+
async closeMedia(hold, reason) {
|
|
108
|
+
try {
|
|
109
|
+
await hold.close();
|
|
110
|
+
this.logger?.info('Media session closed by session revocation', {
|
|
111
|
+
tags: { deviceId: hold.deviceId },
|
|
112
|
+
meta: { mediaSessionId: hold.mediaSessionId, reason },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
this.logger?.warn('Closing a media session of an ended session failed', {
|
|
117
|
+
tags: { deviceId: hold.deviceId },
|
|
118
|
+
meta: {
|
|
119
|
+
mediaSessionId: hold.mediaSessionId,
|
|
120
|
+
error: error instanceof Error ? error.message : String(error),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.SessionConnections = SessionConnections;
|
|
127
|
+
let instance = null;
|
|
128
|
+
/** The process-wide registry (the WS factory, the WebRTC wrapper and the revoke listener share it). */
|
|
129
|
+
function getSessionConnections() {
|
|
130
|
+
instance ??= new SessionConnections();
|
|
131
|
+
return instance;
|
|
132
|
+
}
|
|
133
|
+
/** Test-only. */
|
|
134
|
+
function __resetSessionConnectionsForTests() {
|
|
135
|
+
instance = null;
|
|
136
|
+
}
|
|
@@ -82,7 +82,13 @@ async function finishSsoApp(bridge, deps) {
|
|
|
82
82
|
const parked = deps.confirmations.create({
|
|
83
83
|
userId: record.id,
|
|
84
84
|
username: record.username,
|
|
85
|
-
app: {
|
|
85
|
+
app: {
|
|
86
|
+
codeChallenge: app.codeChallenge,
|
|
87
|
+
state: app.state,
|
|
88
|
+
purpose: app.purpose,
|
|
89
|
+
redirect,
|
|
90
|
+
...(app.lang !== undefined ? { lang: app.lang } : {}),
|
|
91
|
+
},
|
|
86
92
|
authMethod: 'sso',
|
|
87
93
|
});
|
|
88
94
|
return { kind: 'confirm', ...parked };
|
package/dist/main.js
CHANGED
|
@@ -80,6 +80,7 @@ const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
|
|
|
80
80
|
const http_request_census_singleton_1 = require("./api/trpc/http-request-census.singleton");
|
|
81
81
|
const http_request_census_hook_1 = require("./api/trpc/http-request-census-hook");
|
|
82
82
|
const trpc_context_1 = require("./api/trpc/trpc.context");
|
|
83
|
+
const session_connections_js_1 = require("./auth/session-connections.js");
|
|
83
84
|
const trpc_router_1 = require("./api/trpc/trpc.router");
|
|
84
85
|
const trpc_error_device_tags_1 = require("./api/trpc/trpc-error-device-tags");
|
|
85
86
|
const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
|
|
@@ -1356,6 +1357,19 @@ async function bootstrap() {
|
|
|
1356
1357
|
(0, ws_request_census_1.attachWsCensusSession)(httpRequestCensus, client, req, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'));
|
|
1357
1358
|
wsSlowConsumerGuard.attach(client, (0, ws_slow_consumer_guard_js_1.wsConnectionFacts)(req));
|
|
1358
1359
|
});
|
|
1360
|
+
// Revoking a login session ends what it holds: its sockets (close 4401
|
|
1361
|
+
// `session-ended`) and its live media. See `auth/session-connections.ts`.
|
|
1362
|
+
const sessionConnections = (0, session_connections_js_1.getSessionConnections)();
|
|
1363
|
+
sessionConnections.setLogger(app.get(logging_service_1.LoggingService).createLogger('session-revocation'));
|
|
1364
|
+
(0, system_1.onSessionsRevoked)((event) => {
|
|
1365
|
+
sessionConnections.endSessions({
|
|
1366
|
+
sessionIds: event.sessionIds,
|
|
1367
|
+
userId: event.userId,
|
|
1368
|
+
allOfUser: event.allOfUser,
|
|
1369
|
+
exceptSessionId: event.exceptSessionId,
|
|
1370
|
+
reason: event.reason,
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1359
1373
|
(0, ws_1.applyWSSHandler)({
|
|
1360
1374
|
wss,
|
|
1361
1375
|
router: appRouter,
|
|
@@ -1376,6 +1390,23 @@ async function bootstrap() {
|
|
|
1376
1390
|
const principal = (0, trpc_error_principal_1.describeTrpcPrincipal)(wsCtx.user);
|
|
1377
1391
|
(0, ws_request_census_1.identifyWsCensusSession)(opts.res, principal);
|
|
1378
1392
|
wsSlowConsumerGuard.identify(opts.res, principal);
|
|
1393
|
+
const connectionLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC:ws');
|
|
1394
|
+
if (wsCtx.credentialRejected === true) {
|
|
1395
|
+
// A bearer that resolves to nobody (revoked, expired): say so with
|
|
1396
|
+
// the close code the client recognises, instead of leaving an
|
|
1397
|
+
// anonymous socket open for the client to poll.
|
|
1398
|
+
connectionLogger.info('Closing a socket whose credential was refused', {
|
|
1399
|
+
meta: { principal, code: session_connections_js_1.SESSION_ENDED_CLOSE_CODE },
|
|
1400
|
+
});
|
|
1401
|
+
opts.res.close(session_connections_js_1.SESSION_ENDED_CLOSE_CODE, session_connections_js_1.SESSION_ENDED_CLOSE_REASON);
|
|
1402
|
+
}
|
|
1403
|
+
else if (wsCtx.user !== null) {
|
|
1404
|
+
const untrack = sessionConnections.trackSocket(opts.res, {
|
|
1405
|
+
userId: wsCtx.user.id,
|
|
1406
|
+
sessionId: wsCtx.user.sessionId,
|
|
1407
|
+
});
|
|
1408
|
+
opts.res.once('close', untrack);
|
|
1409
|
+
}
|
|
1379
1410
|
return wsCtx;
|
|
1380
1411
|
},
|
|
1381
1412
|
onError: ({ path: trpcPath, error, ctx, input, }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.580",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -38,19 +38,19 @@
|
|
|
38
38
|
]
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
42
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
43
|
-
"@camstack/addon-auth": "1.2.
|
|
41
|
+
"@camstack/addon-admin-ui": "1.2.428",
|
|
42
|
+
"@camstack/addon-agent-ui": "1.2.235",
|
|
43
|
+
"@camstack/addon-auth": "1.2.147",
|
|
44
44
|
"@camstack/addon-decoder-nodeav": "1.2.123",
|
|
45
45
|
"@camstack/addon-notifiers": "1.2.134",
|
|
46
|
-
"@camstack/addon-pipeline": "1.2.
|
|
46
|
+
"@camstack/addon-pipeline": "1.2.370",
|
|
47
47
|
"@camstack/addon-pipeline-orchestrator": "1.2.323",
|
|
48
48
|
"@camstack/addon-post-analysis": "1.2.369",
|
|
49
49
|
"@camstack/develop": "0.1.11",
|
|
50
50
|
"@camstack/sdk": "1.2.131",
|
|
51
|
-
"@camstack/system": "1.2.
|
|
52
|
-
"@camstack/types": "1.2.
|
|
53
|
-
"@camstack/ui-library": "1.2.
|
|
51
|
+
"@camstack/system": "1.2.437",
|
|
52
|
+
"@camstack/types": "1.2.373",
|
|
53
|
+
"@camstack/ui-library": "1.2.291",
|
|
54
54
|
"@fastify/compress": "^9.0.0",
|
|
55
55
|
"@fastify/cookie": "^11.0.2",
|
|
56
56
|
"@fastify/cors": "^11.2.0",
|