@camstack/server 1.2.479 → 1.2.481
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/core/auth.router.js +188 -192
- package/dist/api/trpc/device-config-secret-redaction.js +111 -13
- package/dist/api/trpc/generated-cap-routers.js +54 -0
- package/dist/api/trpc/trpc.router.js +1 -1
- package/dist/auth/auth-provider-gate.js +19 -0
- package/dist/auth/external-link-intents.js +63 -0
- package/dist/auth/passkey-provider.js +96 -0
- package/dist/auth/second-factors.js +21 -0
- package/dist/auth/session-mint.js +0 -8
- package/dist/auth/sso-finish-deps.js +71 -0
- package/dist/auth/sso-finish.js +124 -38
- package/dist/core/addon/addon-registry.service.js +6 -3
- package/dist/core/addon/materialize-route-path.js +26 -0
- package/dist/main.js +16 -3
- package/package.json +8 -8
package/dist/auth/sso-finish.js
CHANGED
|
@@ -1,39 +1,135 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.OIDC_IDENTITY_PREFIX = exports.OIDC_BRIDGE_PROVIDER_PREFIX = exports.MAGIC_LINK_BRIDGE_PROVIDER = void 0;
|
|
4
4
|
exports.isLoginBridge = isLoginBridge;
|
|
5
5
|
exports.finishSsoLogin = finishSsoLogin;
|
|
6
|
+
const auth_provider_gate_js_1 = require("./auth-provider-gate.js");
|
|
6
7
|
const session_mint_js_1 = require("./session-mint.js");
|
|
7
8
|
/** The magic-link addon's bridge `provider`. */
|
|
8
9
|
exports.MAGIC_LINK_BRIDGE_PROVIDER = 'auth-magic-link';
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* `auth-oidc` signs `auth-oidc/<instance>`.
|
|
12
|
+
*
|
|
13
|
+
* Here the prefix is the READING of a bridge, not the choice of what is
|
|
14
|
+
* linkable (that is the `linkable` flag on a login method, which gates the
|
|
15
|
+
* intent a link needs). It stays because it is load-bearing: it is the
|
|
16
|
+
* allowlist that keeps the `oauth-*` tokens of the same signer from being
|
|
17
|
+
* swapped for a session, and it tells an identity-named bridge
|
|
18
|
+
* (`oidc:<instance>:<sub>`, linked users only) from a magic-link bridge that
|
|
19
|
+
* names a user record. A bridge with any other provider is refused, so a
|
|
20
|
+
* non-OIDC linkable provider fails closed until it has its own bridge format.
|
|
21
|
+
*/
|
|
10
22
|
exports.OIDC_BRIDGE_PROVIDER_PREFIX = 'auth-oidc/';
|
|
11
|
-
/** `auth-oidc` names
|
|
12
|
-
exports.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
/** `auth-oidc` names an identity `oidc:<instance>:<sub>`. */
|
|
24
|
+
exports.OIDC_IDENTITY_PREFIX = 'oidc:';
|
|
25
|
+
/** Where a completed (or refused) link lands — the signed-in user's profile. */
|
|
26
|
+
const PROFILE_PATH = '/admin/system/my-access';
|
|
27
|
+
const LOGIN_PATH = '/admin/login';
|
|
28
|
+
/**
|
|
29
|
+
* `(provider, subject, issuer)` of an OIDC bridge, or `null` when it is not a
|
|
30
|
+
* well-formed one. A subject is unique only per issuer, so a bridge without
|
|
31
|
+
* the verified `iss` names no identity.
|
|
32
|
+
*/
|
|
33
|
+
function oidcIdentityOf(claims) {
|
|
34
|
+
if (!claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX))
|
|
35
|
+
return null;
|
|
36
|
+
if (claims.issuer === undefined || claims.issuer.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
const instance = claims.provider.slice(exports.OIDC_BRIDGE_PROVIDER_PREFIX.length);
|
|
39
|
+
if (instance.length === 0)
|
|
40
|
+
return null;
|
|
41
|
+
const prefix = `${exports.OIDC_IDENTITY_PREFIX}${instance}:`;
|
|
42
|
+
if (!claims.userId.startsWith(prefix))
|
|
43
|
+
return null;
|
|
44
|
+
const subject = claims.userId.slice(prefix.length);
|
|
45
|
+
return subject.length === 0 ? null : { provider: claims.provider, subject, issuer: claims.issuer };
|
|
16
46
|
}
|
|
17
47
|
/** Whether `claims` is a LOGIN bridge — the only kind this endpoint swaps. */
|
|
18
48
|
function isLoginBridge(claims) {
|
|
19
|
-
return claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER ||
|
|
20
|
-
|
|
21
|
-
/** A record wins; only an OIDC identity may stand without one. */
|
|
22
|
-
async function mintForBridge(claims, minter) {
|
|
23
|
-
const record = await minter.findUserOrNull(claims.userId);
|
|
24
|
-
if (record !== null)
|
|
25
|
-
return minter.mintForRecord(record);
|
|
26
|
-
if (!isOidcBridge(claims))
|
|
27
|
-
throw new session_mint_js_1.SessionUserGoneError(claims.userId);
|
|
28
|
-
return minter.mintForFederatedIdentity({
|
|
29
|
-
userId: claims.userId,
|
|
30
|
-
username: claims.username,
|
|
31
|
-
isAdmin: claims.isAdmin,
|
|
32
|
-
});
|
|
49
|
+
return (claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER ||
|
|
50
|
+
claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX));
|
|
33
51
|
}
|
|
34
52
|
function unauthorized(error) {
|
|
35
53
|
return { kind: 'error', status: 401, body: { error } };
|
|
36
54
|
}
|
|
55
|
+
function redirect(location) {
|
|
56
|
+
return { kind: 'redirect', location };
|
|
57
|
+
}
|
|
58
|
+
function linkRefused(reason) {
|
|
59
|
+
return redirect(`${PROFILE_PATH}?linkError=${reason}`);
|
|
60
|
+
}
|
|
61
|
+
function failed(error, deps) {
|
|
62
|
+
if (error instanceof session_mint_js_1.SessionUserGoneError)
|
|
63
|
+
return unauthorized('User no longer exists');
|
|
64
|
+
// The detail stays in the server log: a store error can name user ids.
|
|
65
|
+
deps.logFailure?.(error);
|
|
66
|
+
return { kind: 'error', status: 500, body: { error: 'SSO finish failed' } };
|
|
67
|
+
}
|
|
68
|
+
/** A session for `record` — or the second-factor challenge it still owes. */
|
|
69
|
+
async function signIn(record, provider, deps) {
|
|
70
|
+
const factors = await deps.secondFactors(record.id);
|
|
71
|
+
if (factors.length > 0) {
|
|
72
|
+
const challenge = deps.signChallenge({
|
|
73
|
+
userId: record.id,
|
|
74
|
+
username: record.username,
|
|
75
|
+
isAdmin: record.isAdmin,
|
|
76
|
+
});
|
|
77
|
+
// The challenge rides the URL FRAGMENT, like the session token below: it
|
|
78
|
+
// never reaches a server log. The login page resumes at the factor step.
|
|
79
|
+
const fragment = new URLSearchParams({
|
|
80
|
+
challenge,
|
|
81
|
+
factors: factors.join(','),
|
|
82
|
+
provider,
|
|
83
|
+
});
|
|
84
|
+
return redirect(`${LOGIN_PATH}#${fragment.toString()}`);
|
|
85
|
+
}
|
|
86
|
+
const session = deps.minter.mintForRecord(record);
|
|
87
|
+
return redirect(`${LOGIN_PATH}#token=${encodeURIComponent(session.token)}&provider=${encodeURIComponent(provider)}`);
|
|
88
|
+
}
|
|
89
|
+
async function completeLink(claims, handle, deps) {
|
|
90
|
+
const identity = oidcIdentityOf(claims);
|
|
91
|
+
if (identity === null) {
|
|
92
|
+
return { kind: 'error', status: 400, body: { error: 'Only an OIDC sign-in can be linked' } };
|
|
93
|
+
}
|
|
94
|
+
const intent = deps.consumeLinkIntent(handle);
|
|
95
|
+
if (intent === null)
|
|
96
|
+
return linkRefused('expired');
|
|
97
|
+
if (intent.provider !== identity.provider)
|
|
98
|
+
return linkRefused('provider');
|
|
99
|
+
// Bound to the session that asked: the browser finishing the IdP round trip
|
|
100
|
+
// must be signed in as the user the intent was minted for. Without this a
|
|
101
|
+
// link started in one account could be completed with someone else's IdP
|
|
102
|
+
// login (or vice versa) and plant an identity on the wrong user.
|
|
103
|
+
if (deps.sessionUserId() !== intent.userId)
|
|
104
|
+
return linkRefused('session');
|
|
105
|
+
const result = await deps.identities.link({
|
|
106
|
+
userId: intent.userId,
|
|
107
|
+
provider: identity.provider,
|
|
108
|
+
subject: identity.subject,
|
|
109
|
+
issuer: identity.issuer,
|
|
110
|
+
label: claims.email ?? claims.username,
|
|
111
|
+
});
|
|
112
|
+
if (result === 'conflict')
|
|
113
|
+
return linkRefused('conflict');
|
|
114
|
+
return redirect(`${PROFILE_PATH}?linked=${encodeURIComponent(identity.provider)}`);
|
|
115
|
+
}
|
|
116
|
+
async function completeLogin(claims, deps) {
|
|
117
|
+
if (claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER) {
|
|
118
|
+
if (claims.userId.startsWith(exports.OIDC_IDENTITY_PREFIX))
|
|
119
|
+
return unauthorized('Not a user record');
|
|
120
|
+
return signIn(await deps.minter.findUser(claims.userId), claims.provider, deps);
|
|
121
|
+
}
|
|
122
|
+
const identity = oidcIdentityOf(claims);
|
|
123
|
+
if (identity === null)
|
|
124
|
+
return unauthorized('Malformed OIDC identity');
|
|
125
|
+
const link = await deps.identities.findUser(identity.provider, identity.subject);
|
|
126
|
+
// A link made under another issuer is not this identity: the instance was
|
|
127
|
+
// repointed at a different IdP, where the same `sub` names someone else.
|
|
128
|
+
if (link === null || link.issuer !== identity.issuer) {
|
|
129
|
+
return redirect(`${LOGIN_PATH}#error=sso-not-linked&provider=${encodeURIComponent(identity.provider)}`);
|
|
130
|
+
}
|
|
131
|
+
return signIn(await deps.minter.findUser(link.userId), claims.provider, deps);
|
|
132
|
+
}
|
|
37
133
|
async function finishSsoLogin(bridge, deps) {
|
|
38
134
|
if (!bridge)
|
|
39
135
|
return { kind: 'error', status: 400, body: { error: 'Missing bridge token' } };
|
|
@@ -42,25 +138,15 @@ async function finishSsoLogin(bridge, deps) {
|
|
|
42
138
|
return unauthorized('Invalid or expired bridge token');
|
|
43
139
|
if (!isLoginBridge(claims))
|
|
44
140
|
return unauthorized('Not a login bridge token');
|
|
141
|
+
if (deps.isProviderDisabled((0, auth_provider_gate_js_1.bridgeProviderAddonId)(claims.provider))) {
|
|
142
|
+
return unauthorized('Login provider is disabled');
|
|
143
|
+
}
|
|
45
144
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
kind: 'redirect',
|
|
51
|
-
location: `/admin/login#token=${encodeURIComponent(session.token)}&provider=${encodeURIComponent(claims.provider)}`,
|
|
52
|
-
};
|
|
145
|
+
return claims.linkIntent !== undefined
|
|
146
|
+
? await completeLink(claims, claims.linkIntent, deps)
|
|
147
|
+
: await completeLogin(claims, deps);
|
|
53
148
|
}
|
|
54
149
|
catch (error) {
|
|
55
|
-
|
|
56
|
-
return unauthorized('User no longer exists');
|
|
57
|
-
return {
|
|
58
|
-
kind: 'error',
|
|
59
|
-
status: 500,
|
|
60
|
-
body: {
|
|
61
|
-
error: 'SSO finish failed',
|
|
62
|
-
message: error instanceof Error ? error.message : String(error),
|
|
63
|
-
},
|
|
64
|
-
};
|
|
150
|
+
return failed(error, deps);
|
|
65
151
|
}
|
|
66
152
|
}
|
|
@@ -48,16 +48,17 @@ const types_1 = require("@camstack/types");
|
|
|
48
48
|
const client_1 = require("@trpc/client");
|
|
49
49
|
const addon_call_gateway_js_1 = require("./addon-call-gateway.js");
|
|
50
50
|
const addon_row_manifest_1 = require("./addon-row-manifest");
|
|
51
|
-
const settings_door_wiring_js_1 = require("./settings-door-wiring.js");
|
|
52
51
|
const addon_settings_provider_js_1 = require("./addon-settings-provider.js");
|
|
53
52
|
const device_meta_mirror_js_1 = require("./device-meta-mirror.js");
|
|
53
|
+
const forked_custom_actions_js_1 = require("./forked-custom-actions.js");
|
|
54
54
|
const integration_visibility_js_1 = require("./integration-visibility.js");
|
|
55
|
+
const materialize_route_path_js_1 = require("./materialize-route-path.js");
|
|
55
56
|
const package_dir_utils_1 = require("./package-dir-utils");
|
|
56
|
-
const forked_custom_actions_js_1 = require("./forked-custom-actions.js");
|
|
57
57
|
const prune_misplaced_addons_js_1 = require("./prune-misplaced-addons.js");
|
|
58
58
|
const runner_convergence_1 = require("./runner-convergence");
|
|
59
59
|
const runner_serialization_1 = require("./runner-serialization");
|
|
60
60
|
const runner_spawn_fanout_js_1 = require("./runner-spawn-fanout.js");
|
|
61
|
+
const settings_door_wiring_js_1 = require("./settings-door-wiring.js");
|
|
61
62
|
/**
|
|
62
63
|
* Decide whether an in-memory addon entry should be evicted because it is no
|
|
63
64
|
* longer on disk (the uninstall path in `loadNewAddons`).
|
|
@@ -2672,7 +2673,9 @@ class AddonRegistryService {
|
|
|
2672
2673
|
handler: async (req, reply) => {
|
|
2673
2674
|
const envelope = await invoker.invoke({
|
|
2674
2675
|
method: route.method,
|
|
2675
|
-
path:
|
|
2676
|
+
// The CONCRETE path, not the pattern: the child re-matches it and
|
|
2677
|
+
// would otherwise bind every param to its own `:placeholder`.
|
|
2678
|
+
path: (0, materialize_route_path_js_1.materializeRoutePath)(route.path, req.params),
|
|
2676
2679
|
params: req.params,
|
|
2677
2680
|
query: req.query,
|
|
2678
2681
|
body: req.body,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.materializeRoutePath = materializeRoutePath;
|
|
4
|
+
/**
|
|
5
|
+
* Rebuild the concrete request path from a route pattern and the params the
|
|
6
|
+
* hub already matched (`/:providerId/start` + `{providerId:'authentik'}` →
|
|
7
|
+
* `/authentik/start`).
|
|
8
|
+
*
|
|
9
|
+
* The forked-addon bridge forwards a request to the child's
|
|
10
|
+
* `buildAddonRouteProvider().invoke`, which matches the path again to pick the
|
|
11
|
+
* route and extract params. Sending it the PATTERN made that second match bind
|
|
12
|
+
* every param to its own placeholder (`providerId = ':providerId'`), which
|
|
13
|
+
* overwrote the real value — every parameterized route of a forked addon saw
|
|
14
|
+
* literal `:name` params.
|
|
15
|
+
*/
|
|
16
|
+
function materializeRoutePath(pattern, params) {
|
|
17
|
+
return pattern
|
|
18
|
+
.split('/')
|
|
19
|
+
.map((part) => {
|
|
20
|
+
if (!part.startsWith(':'))
|
|
21
|
+
return part;
|
|
22
|
+
const value = params[part.slice(1)];
|
|
23
|
+
return value === undefined ? part : encodeURIComponent(value);
|
|
24
|
+
})
|
|
25
|
+
.join('/');
|
|
26
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -89,7 +89,9 @@ const addon_route_share_gate_js_1 = require("./auth/addon-route-share-gate.js");
|
|
|
89
89
|
const auth_rate_limit_1 = require("./auth/auth-rate-limit");
|
|
90
90
|
const session_cookie_js_1 = require("./auth/session-cookie.js");
|
|
91
91
|
const session_mint_js_1 = require("./auth/session-mint.js");
|
|
92
|
+
const external_link_intents_js_1 = require("./auth/external-link-intents.js");
|
|
92
93
|
const sso_finish_js_1 = require("./auth/sso-finish.js");
|
|
94
|
+
const sso_finish_deps_js_1 = require("./auth/sso-finish-deps.js");
|
|
93
95
|
const boot_config_1 = require("./boot/boot-config");
|
|
94
96
|
const integration_id_backfill_1 = require("./boot/integration-id-backfill");
|
|
95
97
|
const post_boot_service_1 = require("./boot/post-boot.service");
|
|
@@ -523,10 +525,14 @@ async function bootstrap() {
|
|
|
523
525
|
// minted by `auth.createHandoffCode`, redeemed by the viewer app via the
|
|
524
526
|
// public `auth.redeemHandoffCode`.
|
|
525
527
|
const handoffCodeService = new handoff_code_service_1.HandoffCodeService();
|
|
528
|
+
// Pending "link this sign-in account" intents (D782) — minted by
|
|
529
|
+
// `auth.beginExternalLink`, consumed once by `/api/auth/sso/finish`.
|
|
530
|
+
const externalLinkIntents = new external_link_intents_js_1.ExternalLinkIntents();
|
|
526
531
|
const buildRouter = () => (0, trpc_router_1.buildAppRouter)({
|
|
527
532
|
authService: app.get(auth_service_1.AuthService),
|
|
528
533
|
shareTokenService,
|
|
529
534
|
handoffCodeService,
|
|
535
|
+
externalLinkIntents,
|
|
530
536
|
configService: config,
|
|
531
537
|
featureService: app.get(feature_service_1.FeatureService),
|
|
532
538
|
loggingService,
|
|
@@ -810,15 +816,22 @@ async function bootstrap() {
|
|
|
810
816
|
// minted by THE session mint from the user record as it is now (D712) —
|
|
811
817
|
// `auth/sso-finish.ts` holds the rules, this route only maps the reply.
|
|
812
818
|
const ssoAuth = app.get(auth_service_1.AuthService);
|
|
819
|
+
const ssoLogger = app.get(logging_service_1.LoggingService).createLogger('sso-finish');
|
|
813
820
|
const ssoMinter = (0, session_mint_js_1.createSessionMinter)({
|
|
814
821
|
signer: ssoAuth,
|
|
815
822
|
directory: () => capabilityRegistry.getSingleton('user-management'),
|
|
816
823
|
});
|
|
817
824
|
fastify.get('/api/auth/sso/finish', async (request, reply) => {
|
|
818
|
-
const outcome = await (0, sso_finish_js_1.finishSsoLogin)(request.query.bridge, {
|
|
819
|
-
|
|
825
|
+
const outcome = await (0, sso_finish_js_1.finishSsoLogin)(request.query.bridge, (0, sso_finish_deps_js_1.createSsoFinishDeps)({
|
|
826
|
+
auth: ssoAuth,
|
|
827
|
+
registry: capabilityRegistry,
|
|
828
|
+
moleculer: app.get(moleculer_service_1.MoleculerService),
|
|
820
829
|
minter: ssoMinter,
|
|
821
|
-
|
|
830
|
+
linkIntents: externalLinkIntents,
|
|
831
|
+
logFailure: (error) => ssoLogger.error('SSO finish failed', {
|
|
832
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
833
|
+
}),
|
|
834
|
+
}, request.headers.cookie));
|
|
822
835
|
if (outcome.kind === 'error')
|
|
823
836
|
return reply.status(outcome.status).send(outcome.body);
|
|
824
837
|
reply.code(302);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.481",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
41
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
42
|
-
"@camstack/addon-auth": "1.2.
|
|
40
|
+
"@camstack/addon-admin-ui": "1.2.354",
|
|
41
|
+
"@camstack/addon-agent-ui": "1.2.191",
|
|
42
|
+
"@camstack/addon-auth": "1.2.131",
|
|
43
43
|
"@camstack/addon-decoder-nodeav": "1.2.121",
|
|
44
44
|
"@camstack/addon-notifiers": "1.2.128",
|
|
45
45
|
"@camstack/addon-pipeline": "1.2.345",
|
|
46
46
|
"@camstack/addon-pipeline-orchestrator": "1.2.297",
|
|
47
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
47
|
+
"@camstack/addon-post-analysis": "1.2.343",
|
|
48
48
|
"@camstack/sdk": "1.2.126",
|
|
49
|
-
"@camstack/system": "1.2.
|
|
50
|
-
"@camstack/types": "1.2.
|
|
51
|
-
"@camstack/ui-library": "1.2.
|
|
49
|
+
"@camstack/system": "1.2.376",
|
|
50
|
+
"@camstack/types": "1.2.327",
|
|
51
|
+
"@camstack/ui-library": "1.2.249",
|
|
52
52
|
"@fastify/compress": "^9.0.0",
|
|
53
53
|
"@fastify/cookie": "^11.0.2",
|
|
54
54
|
"@fastify/cors": "^11.2.0",
|