@acarmisc/backstage-plugin-litellm-backend 0.2.2 → 0.3.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
@@ -97,5 +97,34 @@ export interface Config {
97
97
  metadata?: Record<string, string>;
98
98
  }>;
99
99
  };
100
+
101
+ /**
102
+ * CLI bridge — exposes /api/litellm/bridge/* for CLI clients (Abby) that
103
+ * authenticate with a Keycloak access token (JWKS-verified). Lets them
104
+ * list/mint virtual keys without holding the master key. Disabled by
105
+ * default; enable explicitly when the CLI is in use.
106
+ */
107
+ bridge?: {
108
+ /**
109
+ * When true, mount the /bridge/keys, /bridge/keys (POST), /bridge/models
110
+ * routes and verify caller JWTs against the Keycloak realm JWKS.
111
+ * @default false
112
+ */
113
+ enabled?: boolean;
114
+
115
+ /**
116
+ * Keycloak realm issuer used to fetch JWKS and verify the token issuer,
117
+ * e.g. https://auth.ces.abssrv.it/realms/solution-innovation.
118
+ * Required when enabled.
119
+ */
120
+ issuer?: string;
121
+
122
+ /**
123
+ * OIDC public client the CLI uses (default "abby-cli"). The token's
124
+ * azp (or aud) must equal this.
125
+ * @default "abby-cli"
126
+ */
127
+ clientId?: string;
128
+ };
100
129
  };
101
130
  }
@@ -0,0 +1,69 @@
1
+ import { Config } from '@backstage/config';
2
+ import { LiteLLMClient } from './client';
3
+ import { GenerateKeyRequest, GenerateKeyResponse, ProvisioningDefaults, UserInfo, VirtualKey } from './types';
4
+ /** Claims extracted from a verified Keycloak access token. */
5
+ export interface BridgeClaims {
6
+ sub: string;
7
+ email?: string;
8
+ preferred_username?: string;
9
+ name?: string;
10
+ /** Authorized party — Keycloak sets this to the client_id of the requester. */
11
+ azp?: string;
12
+ aud?: string | string[];
13
+ }
14
+ /** A token verifier pluggable for tests. */
15
+ export interface TokenVerifier {
16
+ verify(token: string): Promise<BridgeClaims>;
17
+ }
18
+ export interface BridgeConfig {
19
+ enabled: boolean;
20
+ /** Keycloak realm issuer, e.g. https://auth.ces.abssrv.it/realms/solution-innovation. */
21
+ issuer?: string;
22
+ /** OIDC public client the CLI uses; checked against azp / aud. */
23
+ clientId: string;
24
+ }
25
+ export declare function readBridgeConfig(config: Config): BridgeConfig;
26
+ /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
27
+ export declare class BridgeConfigError extends Error {
28
+ }
29
+ /** Thrown when a presented token fails verification → maps to HTTP 401. */
30
+ export declare class BridgeAuthError extends Error {
31
+ readonly status = 401;
32
+ }
33
+ export interface KeycloakJWTVerifierOptions {
34
+ issuer: string;
35
+ clientId: string;
36
+ }
37
+ /**
38
+ * Verifies a Keycloak access token against the realm JWKS and ensures it was
39
+ * issued for {@link clientId} (via azp, falling back to aud). Uses jose's
40
+ * remote JWKS client (cached, with cooldown on errors).
41
+ */
42
+ export declare class KeycloakJWTVerifier implements TokenVerifier {
43
+ private readonly issuer;
44
+ private readonly clientId;
45
+ private readonly jwks;
46
+ constructor(opts: KeycloakJWTVerifierOptions);
47
+ verify(token: string): Promise<BridgeClaims>;
48
+ }
49
+ /** Builds the default verifier from config, or throws BridgeConfigError. */
50
+ export declare function newDefaultVerifier(cfg: BridgeConfig): TokenVerifier;
51
+ /** Picks the LiteLLM user_id from the verified claims (email → username → sub). */
52
+ export declare function resolveBridgeUserId(claims: BridgeClaims): string;
53
+ /**
54
+ * Ensures a LiteLLM user exists for the verified identity. If the user is
55
+ * missing and provisioning is enabled, creates it from the JWT claims (email +
56
+ * name); if provisioning is disabled, throws a 404 telling the caller to log
57
+ * in to Backstage first (the UI is the primary provisioning entry point).
58
+ */
59
+ export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
60
+ info: (...args: unknown[]) => void;
61
+ }): Promise<UserInfo>;
62
+ /** Lists the caller's virtual keys (provisioning the user first if needed). */
63
+ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
64
+ info: (...args: unknown[]) => void;
65
+ }): Promise<VirtualKey[]>;
66
+ /** Mints a new virtual key for the caller (provisioning the user first if needed). */
67
+ export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
68
+ info: (...args: unknown[]) => void;
69
+ }, request: Partial<GenerateKeyRequest>): Promise<GenerateKeyResponse>;
package/dist/bridge.js ADDED
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KeycloakJWTVerifier = exports.BridgeAuthError = exports.BridgeConfigError = void 0;
4
+ exports.readBridgeConfig = readBridgeConfig;
5
+ exports.newDefaultVerifier = newDefaultVerifier;
6
+ exports.resolveBridgeUserId = resolveBridgeUserId;
7
+ exports.getOrProvisionUserFromClaims = getOrProvisionUserFromClaims;
8
+ exports.bridgeListKeys = bridgeListKeys;
9
+ exports.bridgeGenerateKey = bridgeGenerateKey;
10
+ /**
11
+ * Bridge — lets CLI clients (Abby) list/mint LiteLLM virtual keys without ever
12
+ * holding the LiteLLM master key.
13
+ *
14
+ * Trust model: the Backstage backend holds the master key (as it already does
15
+ * for the UI). A CLI authenticates with its Keycloak access token (the same
16
+ * Keycloak realm Backstage uses). This module verifies that JWT against the
17
+ * realm JWKS, resolves the caller to a LiteLLM user_id, ensures that user
18
+ * exists (provisioning from claims if enabled), and then lists/generates keys
19
+ * via the existing master-key-authed {@link LiteLLMClient}.
20
+ *
21
+ * Unlike the UI endpoints in router.ts, the bridge routes do NOT call
22
+ * Backstage's `auth.authenticate` (which expects a Backstage-issued token);
23
+ * they verify the raw Keycloak JWT themselves.
24
+ */
25
+ const jose_1 = require("jose");
26
+ const provisioning_1 = require("./provisioning");
27
+ function readBridgeConfig(config) {
28
+ const enabled = config.getOptionalBoolean('litellm.bridge.enabled') ?? false;
29
+ const issuer = config.getOptionalString('litellm.bridge.issuer');
30
+ const clientId = config.getOptionalString('litellm.bridge.clientId') ?? 'abby-cli';
31
+ return { enabled, issuer, clientId };
32
+ }
33
+ /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
34
+ class BridgeConfigError extends Error {
35
+ }
36
+ exports.BridgeConfigError = BridgeConfigError;
37
+ /** Thrown when a presented token fails verification → maps to HTTP 401. */
38
+ class BridgeAuthError extends Error {
39
+ constructor() {
40
+ super(...arguments);
41
+ this.status = 401;
42
+ }
43
+ }
44
+ exports.BridgeAuthError = BridgeAuthError;
45
+ /**
46
+ * Verifies a Keycloak access token against the realm JWKS and ensures it was
47
+ * issued for {@link clientId} (via azp, falling back to aud). Uses jose's
48
+ * remote JWKS client (cached, with cooldown on errors).
49
+ */
50
+ class KeycloakJWTVerifier {
51
+ constructor(opts) {
52
+ this.issuer = opts.issuer.replace(/\/$/, '');
53
+ this.clientId = opts.clientId;
54
+ this.jwks = (0, jose_1.createRemoteJWKSet)(new URL(`${this.issuer}/protocol/openid-connect/certs`));
55
+ }
56
+ async verify(token) {
57
+ let payload;
58
+ try {
59
+ const result = await (0, jose_1.jwtVerify)(token, this.jwks, {
60
+ issuer: this.issuer,
61
+ });
62
+ payload = result.payload;
63
+ }
64
+ catch (err) {
65
+ // Expired, bad signature, wrong issuer, malformed, JWKS unreachable, etc.
66
+ throw new BridgeAuthError(`invalid keycloak token: ${err.message ?? err}`);
67
+ }
68
+ const azp = payload.azp;
69
+ const aud = payload.aud;
70
+ const audMatches = Array.isArray(aud)
71
+ ? aud.includes(this.clientId)
72
+ : aud === this.clientId;
73
+ if (azp !== this.clientId && !audMatches) {
74
+ throw new BridgeAuthError(`token not issued for client "${this.clientId}" (azp=${azp ?? 'none'})`);
75
+ }
76
+ return {
77
+ sub: payload.sub ?? '',
78
+ email: payload.email,
79
+ preferred_username: payload
80
+ .preferred_username,
81
+ name: payload.name,
82
+ azp,
83
+ aud,
84
+ };
85
+ }
86
+ }
87
+ exports.KeycloakJWTVerifier = KeycloakJWTVerifier;
88
+ /** Builds the default verifier from config, or throws BridgeConfigError. */
89
+ function newDefaultVerifier(cfg) {
90
+ if (!cfg.issuer) {
91
+ throw new BridgeConfigError('litellm.bridge.issuer is required when litellm.bridge.enabled is true ' +
92
+ '(e.g. https://auth.ces.abssrv.it/realms/solution-innovation)');
93
+ }
94
+ return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
95
+ }
96
+ /** Picks the LiteLLM user_id from the verified claims (email → username → sub). */
97
+ function resolveBridgeUserId(claims) {
98
+ return claims.email ?? claims.preferred_username ?? claims.sub;
99
+ }
100
+ /**
101
+ * Ensures a LiteLLM user exists for the verified identity. If the user is
102
+ * missing and provisioning is enabled, creates it from the JWT claims (email +
103
+ * name); if provisioning is disabled, throws a 404 telling the caller to log
104
+ * in to Backstage first (the UI is the primary provisioning entry point).
105
+ */
106
+ async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger) {
107
+ const userId = resolveBridgeUserId(claims);
108
+ const existing = await client.getUserInfo(userId);
109
+ if (existing)
110
+ return existing;
111
+ if (!provisioningEnabled) {
112
+ throw new provisioning_1.ProvisioningError('User not found in LiteLLM', 'No LiteLLM user for this identity. Log in to Backstage once to be provisioned, or enable litellm.provisioning.enabled.', false);
113
+ }
114
+ const profile = {
115
+ email: claims.email ?? claims.preferred_username,
116
+ displayName: claims.name ?? claims.preferred_username,
117
+ };
118
+ const created = await (0, provisioning_1.provisionUser)(client, userId, provisioningDefaults, profile, undefined, logger);
119
+ if (!created) {
120
+ throw new provisioning_1.ProvisioningError('User not found in LiteLLM', 'Provisioning attempted but returned no user — check LiteLLM logs', true, 500);
121
+ }
122
+ return created;
123
+ }
124
+ /** Lists the caller's virtual keys (provisioning the user first if needed). */
125
+ async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger) {
126
+ await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger);
127
+ return client.listKeys(resolveBridgeUserId(claims));
128
+ }
129
+ /** Mints a new virtual key for the caller (provisioning the user first if needed). */
130
+ async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, request) {
131
+ await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger);
132
+ const userId = resolveBridgeUserId(claims);
133
+ const enriched = {
134
+ ...request,
135
+ user_id: userId,
136
+ metadata: {
137
+ ...(request.metadata ?? {}),
138
+ created_via: 'abby-cli',
139
+ created_by: userId,
140
+ created_at_iso: new Date().toISOString(),
141
+ },
142
+ };
143
+ return client.generateKey(enriched);
144
+ }