@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.
@@ -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
+ }
@@ -1,40 +1,175 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.OIDC_FEDERATED_USER_PREFIX = exports.OIDC_BRIDGE_PROVIDER_PREFIX = exports.MAGIC_LINK_BRIDGE_PROVIDER = void 0;
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
- /** `auth-oidc` signs `auth-oidc/<instance>`. */
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 a federated identity `oidc:<instance>:<sub>`. */
12
- exports.OIDC_FEDERATED_USER_PREFIX = 'oidc:';
13
- function isOidcBridge(claims) {
14
- return (claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX) &&
15
- claims.userId.startsWith(exports.OIDC_FEDERATED_USER_PREFIX));
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 || isOidcBridge(claims);
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
- async function finishSsoLogin(bridge, deps) {
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
- const session = await mintForBridge(claims, deps.minter);
47
- // The token rides in the URL FRAGMENT: it never reaches a server log and
48
- // is not resent on later requests; the SPA's auth-context reads it on mount.
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
- if (error instanceof session_mint_js_1.SessionUserGoneError)
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
+ }