@camstack/server 1.2.480 → 1.2.482
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/agent/main.js +15 -4
- package/dist/api/core/auth.router.js +188 -192
- package/dist/api/core/cap-providers.js +49 -5
- package/dist/api/core/integration-secrets.js +60 -0
- package/dist/api/trpc/device-config-secret-redaction.js +111 -13
- package/dist/api/trpc/generated-cap-mounts.js +3 -1
- package/dist/api/trpc/generated-cap-routers.js +1710 -1345
- 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 +164 -39
- package/dist/boot/platform-keychain-key.js +94 -0
- package/dist/core/addon/addon-registry.service.js +59 -0
- package/dist/core/addon/addon-settings-provider.js +7 -1
- package/dist/core/moleculer/moleculer.service.js +31 -1
- package/dist/main.js +31 -4
- package/package.json +10 -10
|
@@ -269,7 +269,7 @@ function buildCapabilityRouters(services) {
|
|
|
269
269
|
// clusterNodes — fixed core API. Write-side purge for the durable
|
|
270
270
|
// offline-node history (Track A "Forget node"); read side is push-only.
|
|
271
271
|
clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
|
|
272
|
-
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService),
|
|
272
|
+
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService, services.externalLinkIntents),
|
|
273
273
|
// NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
|
|
274
274
|
// (positional args / sync returns) that don't match the codegen
|
|
275
275
|
// routers' {input}-object + Promise<T> contract. The runtime builder
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AUTH_GOVERNING_CAPS = void 0;
|
|
4
|
+
exports.isAuthAddonDisabled = isAuthAddonDisabled;
|
|
5
|
+
exports.bridgeProviderAddonId = bridgeProviderAddonId;
|
|
6
|
+
/** Capabilities whose per-provider toggle is an auth addon's enable. */
|
|
7
|
+
exports.AUTH_GOVERNING_CAPS = ['auth-provider', 'user-passkeys'];
|
|
8
|
+
/** Whether the operator disabled `addonId` on Identity → Authentication. */
|
|
9
|
+
function isAuthAddonDisabled(registry, addonId) {
|
|
10
|
+
return exports.AUTH_GOVERNING_CAPS.some((cap) => registry.isProviderDisabled(cap, addonId));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The addon behind an SSO bridge's `provider` claim: `auth-oidc/<instance>`
|
|
14
|
+
* is signed by `auth-oidc`, anything else names its addon directly.
|
|
15
|
+
*/
|
|
16
|
+
function bridgeProviderAddonId(provider) {
|
|
17
|
+
const slash = provider.indexOf('/');
|
|
18
|
+
return slash === -1 ? provider : provider.slice(0, slash);
|
|
19
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ExternalLinkIntents = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Pending "link this sign-in account" requests (D782).
|
|
6
|
+
*
|
|
7
|
+
* A signed-in user presses "Link Authentik": `auth.beginExternalLink` mints
|
|
8
|
+
* an intent for THAT session's user and returns the provider's start URL with
|
|
9
|
+
* the intent's opaque handle as `?link=`. The handle rides the IdP round trip
|
|
10
|
+
* (the OIDC addon keeps it in its server-side pending state, keyed by the
|
|
11
|
+
* OAuth `state`) and comes back inside the signed bridge. The SSO finish
|
|
12
|
+
* resolves it here — once — to the user it was minted for. The user id never
|
|
13
|
+
* travels through the browser.
|
|
14
|
+
*
|
|
15
|
+
* Hub-local and in memory on purpose: an intent lives minutes, and a hub
|
|
16
|
+
* restart mid-link costs the user one more click.
|
|
17
|
+
*/
|
|
18
|
+
const node_crypto_1 = require("node:crypto");
|
|
19
|
+
/** Long enough for an IdP login with a password manager; short enough to be useless later. */
|
|
20
|
+
const DEFAULT_TTL_MS = 10 * 60_000;
|
|
21
|
+
/** Bounded: a session minting intents in a loop cannot grow this without limit. */
|
|
22
|
+
const DEFAULT_MAX_ENTRIES = 1_000;
|
|
23
|
+
class ExternalLinkIntents {
|
|
24
|
+
intents = new Map();
|
|
25
|
+
now;
|
|
26
|
+
ttlMs;
|
|
27
|
+
maxEntries;
|
|
28
|
+
constructor(opts = {}) {
|
|
29
|
+
this.now = opts.now ?? Date.now;
|
|
30
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
31
|
+
this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
32
|
+
}
|
|
33
|
+
create(userId, provider) {
|
|
34
|
+
this.prune();
|
|
35
|
+
while (this.intents.size >= this.maxEntries) {
|
|
36
|
+
const oldest = this.intents.keys().next();
|
|
37
|
+
if (oldest.done)
|
|
38
|
+
break;
|
|
39
|
+
this.intents.delete(oldest.value);
|
|
40
|
+
}
|
|
41
|
+
const handle = `li_${(0, node_crypto_1.randomBytes)(24).toString('base64url')}`;
|
|
42
|
+
this.intents.set(handle, { userId, provider, expiresAt: this.now() + this.ttlMs });
|
|
43
|
+
return handle;
|
|
44
|
+
}
|
|
45
|
+
/** The intent behind `handle`, removing it — or `null` (unknown, used, expired). */
|
|
46
|
+
consume(handle) {
|
|
47
|
+
const stored = this.intents.get(handle);
|
|
48
|
+
if (stored === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
this.intents.delete(handle);
|
|
51
|
+
if (stored.expiresAt < this.now())
|
|
52
|
+
return null;
|
|
53
|
+
return { userId: stored.userId, provider: stored.provider };
|
|
54
|
+
}
|
|
55
|
+
prune() {
|
|
56
|
+
const now = this.now();
|
|
57
|
+
for (const [handle, intent] of this.intents) {
|
|
58
|
+
if (intent.expiresAt < now)
|
|
59
|
+
this.intents.delete(handle);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.ExternalLinkIntents = ExternalLinkIntents;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SetSecondFactorPreferenceSchema = exports.SecondFactorPreferenceSchema = exports.RemovePasskeySchema = exports.FinishDiscoverableAuthenticationSchema = exports.FinishAuthenticationSchema = exports.FinishRegistrationSchema = exports.OptionsJsonSchema = void 0;
|
|
4
|
+
exports.resolvePasskeyProvider = resolvePasskeyProvider;
|
|
5
|
+
exports.userHasPasskey = userHasPasskey;
|
|
6
|
+
exports.passkeySecondFactorOptedIn = passkeySecondFactorOptedIn;
|
|
7
|
+
const types_1 = require("@camstack/types");
|
|
8
|
+
const zod_1 = require("zod");
|
|
9
|
+
// ── Passkey (user-passkeys cap) resolution ───────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// Cap-agnostic: the passkey provider is resolved through the
|
|
12
|
+
// `user-passkeys` CAPABILITY, never by addon id. Any addon registering
|
|
13
|
+
// the cap works. Resolution mirrors `hwaccel.router`:
|
|
14
|
+
// 1. hub-in-process collection provider (a future hub-resident impl),
|
|
15
|
+
// 2. else the hub-local forked child over UDS via the Moleculer cap
|
|
16
|
+
// proxy (`createCapabilityProxy(cap, 'hub')`) — the same routing the
|
|
17
|
+
// generated cap routers use.
|
|
18
|
+
// The proxy is loosely typed (`Record<method, (params)=>Promise<unknown>>`);
|
|
19
|
+
// its outputs are narrowed through the cap's own Zod schemas rather than
|
|
20
|
+
// cast to the provider type (repo rule: no `as` on cross-boundary values).
|
|
21
|
+
exports.OptionsJsonSchema = zod_1.z.object({ optionsJSON: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) });
|
|
22
|
+
exports.FinishRegistrationSchema = zod_1.z.object({
|
|
23
|
+
success: zod_1.z.literal(true),
|
|
24
|
+
credentialId: zod_1.z.string(),
|
|
25
|
+
});
|
|
26
|
+
exports.FinishAuthenticationSchema = zod_1.z.object({ verified: zod_1.z.boolean() });
|
|
27
|
+
exports.FinishDiscoverableAuthenticationSchema = zod_1.z.object({
|
|
28
|
+
verified: zod_1.z.boolean(),
|
|
29
|
+
userId: zod_1.z.string().nullable(),
|
|
30
|
+
});
|
|
31
|
+
exports.RemovePasskeySchema = zod_1.z.object({ success: zod_1.z.literal(true) });
|
|
32
|
+
exports.SecondFactorPreferenceSchema = zod_1.z.object({ enabled: zod_1.z.boolean() });
|
|
33
|
+
exports.SetSecondFactorPreferenceSchema = zod_1.z.object({ success: zod_1.z.literal(true) });
|
|
34
|
+
function proxyPasskeyProvider(proxy) {
|
|
35
|
+
const call = async (method, params) => {
|
|
36
|
+
const fn = proxy[method];
|
|
37
|
+
if (typeof fn !== 'function')
|
|
38
|
+
throw new Error(`user-passkeys proxy missing method '${method}'`);
|
|
39
|
+
return fn(params);
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
beginRegistration: async (input) => exports.OptionsJsonSchema.parse(await call('beginRegistration', input)),
|
|
43
|
+
finishRegistration: async (input) => exports.FinishRegistrationSchema.parse(await call('finishRegistration', input)),
|
|
44
|
+
beginAuthentication: async (input) => exports.OptionsJsonSchema.parse(await call('beginAuthentication', input)),
|
|
45
|
+
finishAuthentication: async (input) => exports.FinishAuthenticationSchema.parse(await call('finishAuthentication', input)),
|
|
46
|
+
beginDiscoverableAuthentication: async (input) => exports.OptionsJsonSchema.parse(await call('beginDiscoverableAuthentication', input)),
|
|
47
|
+
finishDiscoverableAuthentication: async (input) => exports.FinishDiscoverableAuthenticationSchema.parse(await call('finishDiscoverableAuthentication', input)),
|
|
48
|
+
listPasskeys: async (input) => zod_1.z.array(types_1.PasskeySummarySchema).parse(await call('listPasskeys', input)),
|
|
49
|
+
removePasskey: async (input) => exports.RemovePasskeySchema.parse(await call('removePasskey', input)),
|
|
50
|
+
getSecondFactorPreference: async (input) => exports.SecondFactorPreferenceSchema.parse(await call('getSecondFactorPreference', input)),
|
|
51
|
+
setSecondFactorPreference: async (input) => exports.SetSecondFactorPreferenceSchema.parse(await call('setSecondFactorPreference', input)),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function resolvePasskeyProvider(registry, moleculer) {
|
|
55
|
+
const local = registry?.getCollection('user-passkeys')?.[0] ?? null;
|
|
56
|
+
if (local)
|
|
57
|
+
return local;
|
|
58
|
+
const proxy = moleculer?.createCapabilityProxy('user-passkeys', 'hub') ?? null;
|
|
59
|
+
return proxy ? proxyPasskeyProvider(proxy) : null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether the user has at least one enrolled passkey. Never throws — a
|
|
63
|
+
* missing provider or a transport hiccup degrades to `false` so the
|
|
64
|
+
* login flow is never blocked by passkey introspection.
|
|
65
|
+
*/
|
|
66
|
+
async function userHasPasskey(userId, provider) {
|
|
67
|
+
if (!provider)
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
const list = await provider.listPasskeys({ userId });
|
|
71
|
+
return list.length > 0;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether the user OPTED IN to their passkey doubling as a mandatory
|
|
79
|
+
* second factor after a password login. Enrolling a passkey alone only
|
|
80
|
+
* enables passkey-FIRST sign-in — this flag (default OFF, stored by the
|
|
81
|
+
* `user-passkeys` provider) is what re-arms the 2FA gate. Never throws:
|
|
82
|
+
* a missing provider, a provider predating the method, or a transport
|
|
83
|
+
* hiccup all read as `false` so the preference lookup can never lock a
|
|
84
|
+
* user out of the password flow.
|
|
85
|
+
*/
|
|
86
|
+
async function passkeySecondFactorOptedIn(userId, provider) {
|
|
87
|
+
if (!provider || typeof provider.getSecondFactorPreference !== 'function')
|
|
88
|
+
return false;
|
|
89
|
+
try {
|
|
90
|
+
const pref = await provider.getSecondFactorPreference({ userId });
|
|
91
|
+
return pref.enabled;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SecondFactorSchema = void 0;
|
|
4
|
+
exports.collectSecondFactors = collectSecondFactors;
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const passkey_provider_js_1 = require("./passkey-provider.js");
|
|
7
|
+
exports.SecondFactorSchema = zod_1.z.enum(['totp', 'passkey']);
|
|
8
|
+
async function collectSecondFactors(deps, userId, primary) {
|
|
9
|
+
const totpStatus = deps.getTotpStatus ? await deps.getTotpStatus({ userId }) : { enabled: false };
|
|
10
|
+
const factors = [];
|
|
11
|
+
if (totpStatus.enabled)
|
|
12
|
+
factors.push('totp');
|
|
13
|
+
if (primary !== 'passkey') {
|
|
14
|
+
const provider = deps.passkeyProvider();
|
|
15
|
+
const enrolled = await (0, passkey_provider_js_1.userHasPasskey)(userId, provider);
|
|
16
|
+
if (enrolled && (await (0, passkey_provider_js_1.passkeySecondFactorOptedIn)(userId, provider))) {
|
|
17
|
+
factors.push('passkey');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return factors;
|
|
21
|
+
}
|
|
@@ -52,13 +52,5 @@ function createSessionMinter(deps) {
|
|
|
52
52
|
findUserOrNull,
|
|
53
53
|
mintForRecord,
|
|
54
54
|
mintForUserId: async (userId) => mintForRecord(await findUser(userId)),
|
|
55
|
-
mintForFederatedIdentity: (identity) => mintForRecord({
|
|
56
|
-
id: identity.userId,
|
|
57
|
-
username: identity.username,
|
|
58
|
-
isAdmin: identity.isAdmin,
|
|
59
|
-
allowedProviders: '*',
|
|
60
|
-
allowedDevices: {},
|
|
61
|
-
scopes: [],
|
|
62
|
-
}),
|
|
63
55
|
};
|
|
64
56
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sessionUserIdFromCookie = sessionUserIdFromCookie;
|
|
4
|
+
exports.createSsoFinishDeps = createSsoFinishDeps;
|
|
5
|
+
const auth_provider_gate_js_1 = require("./auth-provider-gate.js");
|
|
6
|
+
const passkey_provider_js_1 = require("./passkey-provider.js");
|
|
7
|
+
const second_factors_js_1 = require("./second-factors.js");
|
|
8
|
+
const session_cookie_js_1 = require("./session-cookie.js");
|
|
9
|
+
/**
|
|
10
|
+
* The user a REAL session cookie names, or `null`. Same rule as
|
|
11
|
+
* `auth.exchangeSession`: a bridge or challenge token verifies under the same
|
|
12
|
+
* secret but carries a `kind`, and a session always has a boolean `isAdmin`
|
|
13
|
+
* and a string `userId`.
|
|
14
|
+
*/
|
|
15
|
+
function sessionUserIdFromCookie(auth, cookieHeader) {
|
|
16
|
+
const token = (0, session_cookie_js_1.readSessionCookieFromHeader)(cookieHeader);
|
|
17
|
+
if (token === null)
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const payload = auth.verifyToken(token);
|
|
21
|
+
if (Reflect.get(payload, 'kind') !== undefined)
|
|
22
|
+
return null;
|
|
23
|
+
if (typeof payload.isAdmin !== 'boolean' || typeof payload.userId !== 'string')
|
|
24
|
+
return null;
|
|
25
|
+
return payload.userId;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function createSsoFinishDeps(wiring, cookieHeader) {
|
|
32
|
+
const { auth, registry, moleculer } = wiring;
|
|
33
|
+
const userManagement = () => {
|
|
34
|
+
const um = registry.getSingleton('user-management');
|
|
35
|
+
if (!um)
|
|
36
|
+
throw new Error('Login unavailable — `user-management` capability not registered');
|
|
37
|
+
return um;
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
verifyBridge: (token) => auth.verifySsoBridgeToken(token),
|
|
41
|
+
minter: wiring.minter,
|
|
42
|
+
isProviderDisabled: (addonId) => (0, auth_provider_gate_js_1.isAuthAddonDisabled)(registry, addonId),
|
|
43
|
+
identities: {
|
|
44
|
+
findUser: async (provider, subject) => userManagement().findUserByExternalIdentity({ provider, subject }),
|
|
45
|
+
link: async (input) => {
|
|
46
|
+
const um = userManagement();
|
|
47
|
+
const holder = await um.findUserByExternalIdentity({
|
|
48
|
+
provider: input.provider,
|
|
49
|
+
subject: input.subject,
|
|
50
|
+
});
|
|
51
|
+
if (holder !== null && holder.userId !== input.userId)
|
|
52
|
+
return 'conflict';
|
|
53
|
+
await um.linkExternalIdentity(input);
|
|
54
|
+
return 'linked';
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
consumeLinkIntent: (handle) => wiring.linkIntents.consume(handle),
|
|
58
|
+
sessionUserId: () => sessionUserIdFromCookie(auth, cookieHeader),
|
|
59
|
+
secondFactors: (userId) => {
|
|
60
|
+
const um = registry.getSingleton('user-management');
|
|
61
|
+
return (0, second_factors_js_1.collectSecondFactors)({
|
|
62
|
+
...(um && typeof um.getTotpStatus === 'function'
|
|
63
|
+
? { getTotpStatus: (input) => um.getTotpStatus(input) }
|
|
64
|
+
: {}),
|
|
65
|
+
passkeyProvider: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer),
|
|
66
|
+
}, userId, 'sso');
|
|
67
|
+
},
|
|
68
|
+
signChallenge: (user) => auth.signTotpChallengeToken(user),
|
|
69
|
+
...(wiring.logFailure !== undefined ? { logFailure: wiring.logFailure } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/auth/sso-finish.js
CHANGED
|
@@ -1,40 +1,175 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.SSO_POPUP_COMPLETE_PATH = exports.SSO_LOGIN_PATH = exports.SSO_PROFILE_PATH = exports.OIDC_IDENTITY_PREFIX = exports.OIDC_BRIDGE_PROVIDER_PREFIX = exports.MAGIC_LINK_BRIDGE_PROVIDER = void 0;
|
|
4
|
+
exports.popupCompletionLocation = popupCompletionLocation;
|
|
4
5
|
exports.isLoginBridge = isLoginBridge;
|
|
5
6
|
exports.finishSsoLogin = finishSsoLogin;
|
|
7
|
+
const auth_provider_gate_js_1 = require("./auth-provider-gate.js");
|
|
6
8
|
const session_mint_js_1 = require("./session-mint.js");
|
|
7
9
|
/** The magic-link addon's bridge `provider`. */
|
|
8
10
|
exports.MAGIC_LINK_BRIDGE_PROVIDER = 'auth-magic-link';
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* `auth-oidc` signs `auth-oidc/<instance>`.
|
|
13
|
+
*
|
|
14
|
+
* Here the prefix is the READING of a bridge, not the choice of what is
|
|
15
|
+
* linkable (that is the `linkable` flag on a login method, which gates the
|
|
16
|
+
* intent a link needs). It stays because it is load-bearing: it is the
|
|
17
|
+
* allowlist that keeps the `oauth-*` tokens of the same signer from being
|
|
18
|
+
* swapped for a session, and it tells an identity-named bridge
|
|
19
|
+
* (`oidc:<instance>:<sub>`, linked users only) from a magic-link bridge that
|
|
20
|
+
* names a user record. A bridge with any other provider is refused, so a
|
|
21
|
+
* non-OIDC linkable provider fails closed until it has its own bridge format.
|
|
22
|
+
*/
|
|
10
23
|
exports.OIDC_BRIDGE_PROVIDER_PREFIX = 'auth-oidc/';
|
|
11
|
-
/** `auth-oidc` names
|
|
12
|
-
exports.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
/** `auth-oidc` names an identity `oidc:<instance>:<sub>`. */
|
|
25
|
+
exports.OIDC_IDENTITY_PREFIX = 'oidc:';
|
|
26
|
+
/**
|
|
27
|
+
* Where a completed (or refused) link lands — the signed-in user's profile —
|
|
28
|
+
* and where a login returns. Admin-ui routes, relative to its mount: the
|
|
29
|
+
* route in `main.ts` prepends the hub's mount prefix. (These read `/admin/…`
|
|
30
|
+
* once, a path admin-ui has no route for, so both returns drew a blank page.)
|
|
31
|
+
*/
|
|
32
|
+
exports.SSO_PROFILE_PATH = '/system/my-access';
|
|
33
|
+
exports.SSO_LOGIN_PATH = '/login';
|
|
34
|
+
const PROFILE_PATH = exports.SSO_PROFILE_PATH;
|
|
35
|
+
const LOGIN_PATH = exports.SSO_LOGIN_PATH;
|
|
36
|
+
/**
|
|
37
|
+
* The admin-ui page a POPUP sign-in lands on (D791): it hands the outcome to
|
|
38
|
+
* the window that opened it and closes, or — opener gone — continues to the
|
|
39
|
+
* page the outcome names, like a full-page return.
|
|
40
|
+
*/
|
|
41
|
+
exports.SSO_POPUP_COMPLETE_PATH = '/sso-complete';
|
|
42
|
+
/**
|
|
43
|
+
* The popup form of a return `location`: the completion page, with what the
|
|
44
|
+
* page return carried — the login FRAGMENT (session / challenge / error) or
|
|
45
|
+
* the profile QUERY (link outcome) — moved into the completion page's
|
|
46
|
+
* fragment, after `return=login|profile`. Fragment only: a session still
|
|
47
|
+
* never reaches a server log, and the completion page never posts it to
|
|
48
|
+
* another origin.
|
|
49
|
+
*/
|
|
50
|
+
function popupCompletionLocation(location) {
|
|
51
|
+
const hashAt = location.indexOf('#');
|
|
52
|
+
const pathAndQuery = hashAt === -1 ? location : location.slice(0, hashAt);
|
|
53
|
+
const fragment = hashAt === -1 ? '' : location.slice(hashAt + 1);
|
|
54
|
+
const queryAt = pathAndQuery.indexOf('?');
|
|
55
|
+
const path = queryAt === -1 ? pathAndQuery : pathAndQuery.slice(0, queryAt);
|
|
56
|
+
const query = queryAt === -1 ? '' : pathAndQuery.slice(queryAt + 1);
|
|
57
|
+
const target = path === exports.SSO_PROFILE_PATH ? 'profile' : 'login';
|
|
58
|
+
const carried = [query, fragment].filter((part) => part.length > 0).join('&');
|
|
59
|
+
return `${exports.SSO_POPUP_COMPLETE_PATH}#return=${target}${carried.length > 0 ? `&${carried}` : ''}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `(provider, subject, issuer)` of an OIDC bridge, or `null` when it is not a
|
|
63
|
+
* well-formed one. A subject is unique only per issuer, so a bridge without
|
|
64
|
+
* the verified `iss` names no identity.
|
|
65
|
+
*/
|
|
66
|
+
function oidcIdentityOf(claims) {
|
|
67
|
+
if (!claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX))
|
|
68
|
+
return null;
|
|
69
|
+
if (claims.issuer === undefined || claims.issuer.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const instance = claims.provider.slice(exports.OIDC_BRIDGE_PROVIDER_PREFIX.length);
|
|
72
|
+
if (instance.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const prefix = `${exports.OIDC_IDENTITY_PREFIX}${instance}:`;
|
|
75
|
+
if (!claims.userId.startsWith(prefix))
|
|
76
|
+
return null;
|
|
77
|
+
const subject = claims.userId.slice(prefix.length);
|
|
78
|
+
return subject.length === 0 ? null : { provider: claims.provider, subject, issuer: claims.issuer };
|
|
16
79
|
}
|
|
17
80
|
/** Whether `claims` is a LOGIN bridge — the only kind this endpoint swaps. */
|
|
18
81
|
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
|
-
});
|
|
82
|
+
return (claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER ||
|
|
83
|
+
claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX));
|
|
33
84
|
}
|
|
34
85
|
function unauthorized(error) {
|
|
35
86
|
return { kind: 'error', status: 401, body: { error } };
|
|
36
87
|
}
|
|
37
|
-
|
|
88
|
+
function redirect(location) {
|
|
89
|
+
return { kind: 'redirect', location };
|
|
90
|
+
}
|
|
91
|
+
function linkRefused(reason) {
|
|
92
|
+
return redirect(`${PROFILE_PATH}?linkError=${reason}`);
|
|
93
|
+
}
|
|
94
|
+
function failed(error, deps) {
|
|
95
|
+
if (error instanceof session_mint_js_1.SessionUserGoneError)
|
|
96
|
+
return unauthorized('User no longer exists');
|
|
97
|
+
// The detail stays in the server log: a store error can name user ids.
|
|
98
|
+
deps.logFailure?.(error);
|
|
99
|
+
return { kind: 'error', status: 500, body: { error: 'SSO finish failed' } };
|
|
100
|
+
}
|
|
101
|
+
/** A session for `record` — or the second-factor challenge it still owes. */
|
|
102
|
+
async function signIn(record, provider, deps) {
|
|
103
|
+
const factors = await deps.secondFactors(record.id);
|
|
104
|
+
if (factors.length > 0) {
|
|
105
|
+
const challenge = deps.signChallenge({
|
|
106
|
+
userId: record.id,
|
|
107
|
+
username: record.username,
|
|
108
|
+
isAdmin: record.isAdmin,
|
|
109
|
+
});
|
|
110
|
+
// The challenge rides the URL FRAGMENT, like the session token below: it
|
|
111
|
+
// never reaches a server log. The login page resumes at the factor step.
|
|
112
|
+
const fragment = new URLSearchParams({
|
|
113
|
+
challenge,
|
|
114
|
+
factors: factors.join(','),
|
|
115
|
+
provider,
|
|
116
|
+
});
|
|
117
|
+
return redirect(`${LOGIN_PATH}#${fragment.toString()}`);
|
|
118
|
+
}
|
|
119
|
+
const session = deps.minter.mintForRecord(record);
|
|
120
|
+
return redirect(`${LOGIN_PATH}#token=${encodeURIComponent(session.token)}&provider=${encodeURIComponent(provider)}`);
|
|
121
|
+
}
|
|
122
|
+
async function completeLink(claims, handle, deps) {
|
|
123
|
+
const identity = oidcIdentityOf(claims);
|
|
124
|
+
if (identity === null) {
|
|
125
|
+
return { kind: 'error', status: 400, body: { error: 'Only an OIDC sign-in can be linked' } };
|
|
126
|
+
}
|
|
127
|
+
const intent = deps.consumeLinkIntent(handle);
|
|
128
|
+
if (intent === null)
|
|
129
|
+
return linkRefused('expired');
|
|
130
|
+
if (intent.provider !== identity.provider)
|
|
131
|
+
return linkRefused('provider');
|
|
132
|
+
// Bound to the session that asked: the browser finishing the IdP round trip
|
|
133
|
+
// must be signed in as the user the intent was minted for. Without this a
|
|
134
|
+
// link started in one account could be completed with someone else's IdP
|
|
135
|
+
// login (or vice versa) and plant an identity on the wrong user.
|
|
136
|
+
if (deps.sessionUserId() !== intent.userId)
|
|
137
|
+
return linkRefused('session');
|
|
138
|
+
const result = await deps.identities.link({
|
|
139
|
+
userId: intent.userId,
|
|
140
|
+
provider: identity.provider,
|
|
141
|
+
subject: identity.subject,
|
|
142
|
+
issuer: identity.issuer,
|
|
143
|
+
label: claims.email ?? claims.username,
|
|
144
|
+
});
|
|
145
|
+
if (result === 'conflict')
|
|
146
|
+
return linkRefused('conflict');
|
|
147
|
+
return redirect(`${PROFILE_PATH}?linked=${encodeURIComponent(identity.provider)}`);
|
|
148
|
+
}
|
|
149
|
+
async function completeLogin(claims, deps) {
|
|
150
|
+
if (claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER) {
|
|
151
|
+
if (claims.userId.startsWith(exports.OIDC_IDENTITY_PREFIX))
|
|
152
|
+
return unauthorized('Not a user record');
|
|
153
|
+
return signIn(await deps.minter.findUser(claims.userId), claims.provider, deps);
|
|
154
|
+
}
|
|
155
|
+
const identity = oidcIdentityOf(claims);
|
|
156
|
+
if (identity === null)
|
|
157
|
+
return unauthorized('Malformed OIDC identity');
|
|
158
|
+
const link = await deps.identities.findUser(identity.provider, identity.subject);
|
|
159
|
+
// A link made under another issuer is not this identity: the instance was
|
|
160
|
+
// repointed at a different IdP, where the same `sub` names someone else.
|
|
161
|
+
if (link === null || link.issuer !== identity.issuer) {
|
|
162
|
+
return redirect(`${LOGIN_PATH}#error=sso-not-linked&provider=${encodeURIComponent(identity.provider)}`);
|
|
163
|
+
}
|
|
164
|
+
return signIn(await deps.minter.findUser(link.userId), claims.provider, deps);
|
|
165
|
+
}
|
|
166
|
+
async function finishSsoLogin(bridge, deps, options = {}) {
|
|
167
|
+
const outcome = await finishSsoReturn(bridge, deps);
|
|
168
|
+
if (options.popup !== true || outcome.kind !== 'redirect')
|
|
169
|
+
return outcome;
|
|
170
|
+
return redirect(popupCompletionLocation(outcome.location));
|
|
171
|
+
}
|
|
172
|
+
async function finishSsoReturn(bridge, deps) {
|
|
38
173
|
if (!bridge)
|
|
39
174
|
return { kind: 'error', status: 400, body: { error: 'Missing bridge token' } };
|
|
40
175
|
const claims = deps.verifyBridge(bridge);
|
|
@@ -42,25 +177,15 @@ async function finishSsoLogin(bridge, deps) {
|
|
|
42
177
|
return unauthorized('Invalid or expired bridge token');
|
|
43
178
|
if (!isLoginBridge(claims))
|
|
44
179
|
return unauthorized('Not a login bridge token');
|
|
180
|
+
if (deps.isProviderDisabled((0, auth_provider_gate_js_1.bridgeProviderAddonId)(claims.provider))) {
|
|
181
|
+
return unauthorized('Login provider is disabled');
|
|
182
|
+
}
|
|
45
183
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
kind: 'redirect',
|
|
51
|
-
location: `/admin/login#token=${encodeURIComponent(session.token)}&provider=${encodeURIComponent(claims.provider)}`,
|
|
52
|
-
};
|
|
184
|
+
return claims.linkIntent !== undefined
|
|
185
|
+
? await completeLink(claims, claims.linkIntent, deps)
|
|
186
|
+
: await completeLogin(claims, deps);
|
|
53
187
|
}
|
|
54
188
|
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
|
-
};
|
|
189
|
+
return failed(error, deps);
|
|
65
190
|
}
|
|
66
191
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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.keychainKeyFdFromArgv = keychainKeyFdFromArgv;
|
|
37
|
+
exports.loadPlatformKeychainKey = loadPlatformKeychainKey;
|
|
38
|
+
/**
|
|
39
|
+
* Read the keychain master key a DESKTOP app handed this hub over an inherited
|
|
40
|
+
* pipe (D788): `--keychain-key-fd=<n>` names the descriptor, the parent wrote
|
|
41
|
+
* the key (base64) and closed it. Read once, at boot, before anything else
|
|
42
|
+
* can — then the descriptor is closed and the key lives only in memory, in
|
|
43
|
+
* the platform-key slot the keychain builtin reads.
|
|
44
|
+
*
|
|
45
|
+
* An env var was rejected: a child's environment is readable by every process
|
|
46
|
+
* of the same user and is inherited by every addon runner the hub forks.
|
|
47
|
+
*/
|
|
48
|
+
const fs = __importStar(require("node:fs"));
|
|
49
|
+
const system_1 = require("@camstack/system");
|
|
50
|
+
const FD_ARG = '--keychain-key-fd=';
|
|
51
|
+
const KEY_BYTES = 32;
|
|
52
|
+
/** The OS keychain's name, for the Secrets page. */
|
|
53
|
+
function platformLabel(platform) {
|
|
54
|
+
switch (platform) {
|
|
55
|
+
case 'darwin':
|
|
56
|
+
return 'macOS Keychain';
|
|
57
|
+
case 'win32':
|
|
58
|
+
return 'Windows DPAPI';
|
|
59
|
+
default:
|
|
60
|
+
return 'OS secret service';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Parse the descriptor number out of argv; `null` when not passed. */
|
|
64
|
+
function keychainKeyFdFromArgv(argv) {
|
|
65
|
+
const arg = argv.find((a) => a.startsWith(FD_ARG));
|
|
66
|
+
if (arg === undefined)
|
|
67
|
+
return null;
|
|
68
|
+
const n = Number(arg.slice(FD_ARG.length));
|
|
69
|
+
return Number.isInteger(n) && n > 2 ? n : null;
|
|
70
|
+
}
|
|
71
|
+
function loadPlatformKeychainKey(argv = process.argv, readFd = (fd) => fs.readFileSync(fd, 'utf8'), closeFd = (fd) => fs.closeSync(fd)) {
|
|
72
|
+
const fd = keychainKeyFdFromArgv(argv);
|
|
73
|
+
if (fd === null)
|
|
74
|
+
return { kind: 'none' };
|
|
75
|
+
try {
|
|
76
|
+
const key = Buffer.from(readFd(fd).trim(), 'base64');
|
|
77
|
+
if (key.length !== KEY_BYTES)
|
|
78
|
+
return { kind: 'failed', error: 'not 32 bytes of key material' };
|
|
79
|
+
const label = platformLabel(process.platform);
|
|
80
|
+
(0, system_1.setPlatformKeychainKey)({ label, key });
|
|
81
|
+
return { kind: 'loaded', label };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return { kind: 'failed', error: err instanceof Error ? err.message : String(err) };
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
try {
|
|
88
|
+
closeFd(fd);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* already closed */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|