@acarmisc/backstage-plugin-litellm-backend 0.16.0 → 0.16.1

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/config.d.ts CHANGED
@@ -390,6 +390,15 @@ export interface Config {
390
390
  * @default "abby-cli"
391
391
  */
392
392
  clientId?: string;
393
+
394
+ /**
395
+ * Email domains whose *verified* addresses may use the bridge. Defaults
396
+ * to `litellm.userIdDomain` when that is set. With neither configured
397
+ * the bridge rejects every caller. The LiteLLM user is then the same one
398
+ * the UI addresses (the Keycloak `preferred_username`, with
399
+ * `litellm.userIdDomain` applied when set) — the email is only the gate.
400
+ */
401
+ allowedEmailDomains?: string[];
393
402
  };
394
403
  };
395
404
  }
package/dist/bridge.d.ts CHANGED
@@ -25,6 +25,12 @@ export interface BridgeConfig {
25
25
  issuer?: string;
26
26
  /** OIDC public client the CLI uses; checked against azp / aud. */
27
27
  clientId: string;
28
+ /**
29
+ * Email domains whose verified addresses may use the bridge. Defaults to
30
+ * `litellm.userIdDomain` when that is set; with neither, the bridge rejects
31
+ * every caller (fail closed).
32
+ */
33
+ allowedEmailDomains: string[];
28
34
  }
29
35
  export declare function readBridgeConfig(config: Config): BridgeConfig;
30
36
  /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
@@ -57,28 +63,41 @@ export declare class KeycloakJWTVerifier implements TokenVerifier {
57
63
  /** Builds the default verifier from config, or throws BridgeConfigError. */
58
64
  export declare function newDefaultVerifier(cfg: BridgeConfig): TokenVerifier;
59
65
  /**
60
- * Resolves the LiteLLM user_id from the verified claims with strict identity validation.
66
+ * How the bridge maps a verified token to a LiteLLM user.
67
+ * - `userIdDomain`: the same `litellm.userIdDomain` the UI uses to build ids.
68
+ * - `trustedEmailDomains`: email domains allowed to use the bridge; defaults to
69
+ * `[userIdDomain]` when that is set.
70
+ */
71
+ export interface BridgeIdentityOptions {
72
+ userIdDomain?: string;
73
+ trustedEmailDomains?: string[];
74
+ }
75
+ /**
76
+ * Resolves the LiteLLM user_id for a verified bridge token.
61
77
  *
62
- * PR-6 identity mapping:
63
- * - Prefer `email` with `email_verified === true` whose domain equals `userIdDomain`.
64
- * - If userIdDomain is unset, always reject unverified emails (guard against misconfig).
65
- * - Never strip an arbitrary domain from `preferred_username`.
66
- * - Rejects invalid/missing identity with 403.
78
+ * Two separate questions, answered separately:
79
+ * 1. Is this person allowed in? The token must carry a *verified* email whose
80
+ * domain is in the trusted list (`litellm.bridge.allowedEmailDomains`,
81
+ * defaulting to `litellm.userIdDomain`). With no trusted domain configured
82
+ * everyone is rejected (fail closed). Failure → 403.
83
+ * 2. Which LiteLLM user are they? The same one the UI addresses: the Backstage
84
+ * user entity name (the Keycloak `preferred_username`) run through the same
85
+ * `litellm.userIdDomain` rule the UI applies. Using the email local part
86
+ * here would address a different user whenever a Keycloak username differs
87
+ * from the email (and would provision a duplicate). The realm is the same
88
+ * IdP Backstage signs users in with, so its usernames are the entity names.
67
89
  *
68
- * @param claims Verified JWT claims from the bridge token
69
- * @param userIdDomain Expected email domain (e.g. "example.com"); if set, email must match
70
- * @returns The resolved LiteLLM user_id
71
90
  * @throws BridgeIdentityError (403) if identity validation fails
72
91
  */
73
- export declare function resolveBridgeUserId(claims: BridgeClaims, userIdDomain?: string): string;
92
+ export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: string | BridgeIdentityOptions): string;
74
93
  /**
75
94
  * Ensures a LiteLLM user exists for the verified identity. If the user is
76
95
  * missing and provisioning is enabled, creates it from the JWT claims (email +
77
96
  * name); if provisioning is disabled, throws a 404 telling the caller to log
78
97
  * in to Backstage first (the UI is the primary provisioning entry point).
79
98
  */
80
- export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string): Promise<UserInfo>;
99
+ export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<UserInfo>;
81
100
  /** Lists the caller's virtual keys (provisioning the user first if needed). */
82
- export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string): Promise<VirtualKey[]>;
101
+ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<VirtualKey[]>;
83
102
  /** Mints a new virtual key for the caller (provisioning the user first if needed). */
84
- export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, request: Partial<GenerateKeyRequest>, userIdDomain?: string): Promise<GenerateKeyResponse>;
103
+ export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, request: Partial<GenerateKeyRequest>, userIdDomain?: string | BridgeIdentityOptions): Promise<GenerateKeyResponse>;
package/dist/bridge.js CHANGED
@@ -28,7 +28,8 @@ function readBridgeConfig(config) {
28
28
  const enabled = config.getOptionalBoolean('litellm.bridge.enabled') ?? false;
29
29
  const issuer = config.getOptionalString('litellm.bridge.issuer');
30
30
  const clientId = config.getOptionalString('litellm.bridge.clientId') ?? 'abby-cli';
31
- return { enabled, issuer, clientId };
31
+ const allowedEmailDomains = (config.getOptionalStringArray('litellm.bridge.allowedEmailDomains') ?? []).map(d => d.trim().toLowerCase()).filter(Boolean);
32
+ return { enabled, issuer, clientId, allowedEmailDomains };
32
33
  }
33
34
  /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
34
35
  class BridgeConfigError extends Error {
@@ -110,26 +111,40 @@ function newDefaultVerifier(cfg) {
110
111
  }
111
112
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
112
113
  }
114
+ /** A bare string is the legacy form: `userIdDomain`, also the only trusted domain. */
115
+ function normalizeIdentityOptions(opts) {
116
+ const o = typeof opts === 'string' ? { userIdDomain: opts } : opts ?? {};
117
+ let domains = [];
118
+ if (o.trustedEmailDomains?.length) {
119
+ domains = o.trustedEmailDomains;
120
+ }
121
+ else if (o.userIdDomain) {
122
+ domains = [o.userIdDomain];
123
+ }
124
+ const trusted = domains.map(d => d.toLowerCase());
125
+ return { userIdDomain: o.userIdDomain, trusted };
126
+ }
113
127
  /**
114
- * Resolves the LiteLLM user_id from the verified claims with strict identity validation.
128
+ * Resolves the LiteLLM user_id for a verified bridge token.
115
129
  *
116
- * PR-6 identity mapping:
117
- * - Prefer `email` with `email_verified === true` whose domain equals `userIdDomain`.
118
- * - If userIdDomain is unset, always reject unverified emails (guard against misconfig).
119
- * - Never strip an arbitrary domain from `preferred_username`.
120
- * - Rejects invalid/missing identity with 403.
130
+ * Two separate questions, answered separately:
131
+ * 1. Is this person allowed in? The token must carry a *verified* email whose
132
+ * domain is in the trusted list (`litellm.bridge.allowedEmailDomains`,
133
+ * defaulting to `litellm.userIdDomain`). With no trusted domain configured
134
+ * everyone is rejected (fail closed). Failure → 403.
135
+ * 2. Which LiteLLM user are they? The same one the UI addresses: the Backstage
136
+ * user entity name (the Keycloak `preferred_username`) run through the same
137
+ * `litellm.userIdDomain` rule the UI applies. Using the email local part
138
+ * here would address a different user whenever a Keycloak username differs
139
+ * from the email (and would provision a duplicate). The realm is the same
140
+ * IdP Backstage signs users in with, so its usernames are the entity names.
121
141
  *
122
- * @param claims Verified JWT claims from the bridge token
123
- * @param userIdDomain Expected email domain (e.g. "example.com"); if set, email must match
124
- * @returns The resolved LiteLLM user_id
125
142
  * @throws BridgeIdentityError (403) if identity validation fails
126
143
  */
127
- function resolveBridgeUserId(claims, userIdDomain) {
128
- // The bridge maps identities by verified email only. Without a configured
129
- // domain we can't tell our users from another tenant's, and stripping the
130
- // domain would merge alice@a.com and alice@b.com — fail closed.
131
- if (!userIdDomain) {
132
- throw new BridgeIdentityError('litellm.userIdDomain must be configured to use the CLI bridge');
144
+ function resolveBridgeUserId(claims, identity) {
145
+ const { userIdDomain, trusted } = normalizeIdentityOptions(identity);
146
+ if (trusted.length === 0) {
147
+ throw new BridgeIdentityError('set litellm.bridge.allowedEmailDomains (or litellm.userIdDomain) to use the CLI bridge');
133
148
  }
134
149
  if (!claims.email) {
135
150
  throw new BridgeIdentityError('no email found in token');
@@ -141,11 +156,12 @@ function resolveBridgeUserId(claims, userIdDomain) {
141
156
  if (parts.length !== 2 || !parts[0]) {
142
157
  throw new BridgeIdentityError('malformed email in token');
143
158
  }
144
- const [localPart, domain] = parts;
145
- if (domain.toLowerCase() !== userIdDomain.toLowerCase()) {
159
+ const [emailLocalPart, domain] = parts;
160
+ if (!trusted.includes(domain.toLowerCase())) {
146
161
  throw new BridgeIdentityError(`email domain mismatch: "${domain}" is not allowed for this deployment`);
147
162
  }
148
- return (0, provisioning_1.toLiteLLMUserId)(localPart, userIdDomain);
163
+ const name = claims.preferred_username?.trim() || emailLocalPart;
164
+ return (0, provisioning_1.toLiteLLMUserId)(name, userIdDomain);
149
165
  }
150
166
  /**
151
167
  * Ensures a LiteLLM user exists for the verified identity. If the user is
package/dist/index.cjs.js CHANGED
@@ -12616,7 +12616,8 @@ function readBridgeConfig(config) {
12616
12616
  const enabled = config.getOptionalBoolean("litellm.bridge.enabled") ?? false;
12617
12617
  const issuer = config.getOptionalString("litellm.bridge.issuer");
12618
12618
  const clientId = config.getOptionalString("litellm.bridge.clientId") ?? "abby-cli";
12619
- return { enabled, issuer, clientId };
12619
+ const allowedEmailDomains = (config.getOptionalStringArray("litellm.bridge.allowedEmailDomains") ?? []).map((d) => d.trim().toLowerCase()).filter(Boolean);
12620
+ return { enabled, issuer, clientId, allowedEmailDomains };
12620
12621
  }
12621
12622
  var BridgeConfigError = class extends Error {
12622
12623
  };
@@ -12686,10 +12687,22 @@ function newDefaultVerifier(cfg) {
12686
12687
  }
12687
12688
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
12688
12689
  }
12689
- function resolveBridgeUserId(claims, userIdDomain) {
12690
- if (!userIdDomain) {
12690
+ function normalizeIdentityOptions(opts) {
12691
+ const o = typeof opts === "string" ? { userIdDomain: opts } : opts ?? {};
12692
+ let domains = [];
12693
+ if (o.trustedEmailDomains?.length) {
12694
+ domains = o.trustedEmailDomains;
12695
+ } else if (o.userIdDomain) {
12696
+ domains = [o.userIdDomain];
12697
+ }
12698
+ const trusted = domains.map((d) => d.toLowerCase());
12699
+ return { userIdDomain: o.userIdDomain, trusted };
12700
+ }
12701
+ function resolveBridgeUserId(claims, identity) {
12702
+ const { userIdDomain, trusted } = normalizeIdentityOptions(identity);
12703
+ if (trusted.length === 0) {
12691
12704
  throw new BridgeIdentityError(
12692
- "litellm.userIdDomain must be configured to use the CLI bridge"
12705
+ "set litellm.bridge.allowedEmailDomains (or litellm.userIdDomain) to use the CLI bridge"
12693
12706
  );
12694
12707
  }
12695
12708
  if (!claims.email) {
@@ -12702,13 +12715,14 @@ function resolveBridgeUserId(claims, userIdDomain) {
12702
12715
  if (parts.length !== 2 || !parts[0]) {
12703
12716
  throw new BridgeIdentityError("malformed email in token");
12704
12717
  }
12705
- const [localPart, domain] = parts;
12706
- if (domain.toLowerCase() !== userIdDomain.toLowerCase()) {
12718
+ const [emailLocalPart, domain] = parts;
12719
+ if (!trusted.includes(domain.toLowerCase())) {
12707
12720
  throw new BridgeIdentityError(
12708
12721
  `email domain mismatch: "${domain}" is not allowed for this deployment`
12709
12722
  );
12710
12723
  }
12711
- return toLiteLLMUserId(localPart, userIdDomain);
12724
+ const name = claims.preferred_username?.trim() || emailLocalPart;
12725
+ return toLiteLLMUserId(name, userIdDomain);
12712
12726
  }
12713
12727
  async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
12714
12728
  const userId = resolveBridgeUserId(claims, userIdDomain);
@@ -12794,6 +12808,10 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12794
12808
  const { config } = bridgeOpts;
12795
12809
  const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common7.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
12796
12810
  const bridgeCfg = readBridgeConfig(config);
12811
+ const bridgeIdentity = {
12812
+ userIdDomain,
12813
+ trustedEmailDomains: bridgeCfg.allowedEmailDomains
12814
+ };
12797
12815
  let tokenVerifier = bridgeOpts.tokenVerifier;
12798
12816
  if (bridgeCfg.enabled && !tokenVerifier) {
12799
12817
  try {
@@ -12844,7 +12862,7 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12844
12862
  provisioningEnabled,
12845
12863
  provisioningDefaults,
12846
12864
  logger,
12847
- userIdDomain
12865
+ bridgeIdentity
12848
12866
  );
12849
12867
  res.json(keys);
12850
12868
  } catch (error) {
@@ -12864,7 +12882,7 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12864
12882
  return;
12865
12883
  }
12866
12884
  const input = parseResult.data;
12867
- const userId = resolveBridgeUserId(claims, userIdDomain);
12885
+ const userId = resolveBridgeUserId(claims, bridgeIdentity);
12868
12886
  const keyCreateCtx = {
12869
12887
  client,
12870
12888
  config: {