Skip to content

Commit 32dedef

Browse files
Implement the api key provider
This implementation proxies the requests to Anaconda's api key endpoint making the appropriate translations in request and response types
1 parent 04e4101 commit 32dedef

File tree

5 files changed

+351
-9
lines changed

5 files changed

+351
-9
lines changed

package-lock.json

Lines changed: 28 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@
3333
"build": "npx tsc"
3434
},
3535
"dependencies": {
36-
"@runtimed/extensions": "^0.2.0"
36+
"@runtimed/extensions": "^0.3.0",
37+
"jose": "^6.0.12"
3738
},
3839
"devDependencies": {
3940
"prettier": "^3.6.2",
4041
"typescript": "^5.9.2"
42+
},
43+
"peerDependencies": {
44+
"@cloudflare/workers-types": "^4.20250813.0"
4145
}
4246
}

src/api_key.ts

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import {
2+
AuthType,
3+
ErrorType,
4+
RuntError,
5+
Scope,
6+
type ProviderContext,
7+
type AuthenticatedProviderContext,
8+
type Passport,
9+
} from '@runtimed/extensions';
10+
import {
11+
ApiKeyCapabilities,
12+
ApiKeyProvider,
13+
type CreateApiKeyRequest,
14+
type ApiKey,
15+
type ListApiKeysRequest,
16+
} from '@runtimed/extensions/providers/api_key';
17+
import * as jose from 'jose';
18+
19+
type ExtensionConfig = {
20+
apiKeyUrl: string;
21+
userinfoUrl: string;
22+
};
23+
24+
type AnacondaWhoamiResponse = {
25+
passport: {
26+
user_id: string;
27+
profile: {
28+
email: string;
29+
first_name: string;
30+
last_name: string;
31+
is_confirmed: boolean;
32+
};
33+
scopes: string[];
34+
source: string;
35+
};
36+
};
37+
type AnacondaCreateApiKeyRequest = {
38+
scopes: string[];
39+
user_created: boolean;
40+
name: string;
41+
tags: string[];
42+
expires_at: string;
43+
};
44+
45+
type AnacondaCreateApiKeyResponse = {
46+
id: string;
47+
api_key: string;
48+
expires_at: string;
49+
};
50+
51+
type AnacondaGetApiKeyResponse = {
52+
id: string;
53+
name: string;
54+
user_created: boolean;
55+
tags: string[];
56+
scopes: string[];
57+
created_at: string;
58+
expires_at: string;
59+
};
60+
61+
const getExtensionConfig = (context: ProviderContext): ExtensionConfig => {
62+
let config: ExtensionConfig;
63+
try {
64+
config = JSON.parse(context.env.EXTENSION_CONFIG) as ExtensionConfig;
65+
} catch (error) {
66+
throw new RuntError(ErrorType.ServerMisconfigured, {
67+
message: 'The EXTENSION_CONFIG environment variable is not properly set',
68+
cause: error as Error,
69+
});
70+
}
71+
if (!config.apiKeyUrl || !config.userinfoUrl) {
72+
throw new RuntError(ErrorType.ServerMisconfigured, {
73+
message: 'The apiKeyUrl is not properly set',
74+
});
75+
}
76+
return config;
77+
};
78+
79+
function createFailureHandler(url: string) {
80+
return (err: unknown) => {
81+
throw new RuntError(ErrorType.Unknown, {
82+
message: `Failed to fetch from ${url}`,
83+
cause: err as Error,
84+
});
85+
};
86+
}
87+
88+
async function handleAnacondaResponse<T>(response: Response): Promise<T> {
89+
let body: string;
90+
try {
91+
body = await response.text();
92+
} catch (error) {
93+
throw new RuntError(ErrorType.Unknown, {
94+
message: `Failed to get the body from ${response.url}`,
95+
cause: error as Error,
96+
});
97+
}
98+
if (response.status === 400) {
99+
throw new RuntError(ErrorType.InvalidRequest, {
100+
message: 'Invalid request',
101+
responsePayload: {
102+
upstreamCode: response.status,
103+
},
104+
debugPayload: {
105+
upstreamBody: body,
106+
},
107+
});
108+
}
109+
if (response.status === 401) {
110+
throw new RuntError(ErrorType.AuthTokenInvalid, {
111+
responsePayload: {
112+
upstreamCode: response.status,
113+
},
114+
debugPayload: {
115+
upstreamBody: body,
116+
},
117+
});
118+
}
119+
if (response.status === 403) {
120+
throw new RuntError(ErrorType.AccessDenied, {
121+
responsePayload: {
122+
upstreamCode: response.status,
123+
},
124+
debugPayload: {
125+
upstreamBody: body,
126+
},
127+
});
128+
}
129+
if (response.status === 404) {
130+
throw new RuntError(ErrorType.NotFound, {
131+
responsePayload: {
132+
upstreamCode: response.status,
133+
},
134+
debugPayload: {
135+
upstreamBody: body,
136+
},
137+
});
138+
}
139+
if (!response.ok) {
140+
throw new RuntError(ErrorType.Unknown, {
141+
responsePayload: {
142+
upstreamCode: response.status,
143+
},
144+
debugPayload: {
145+
upstreamBody: body,
146+
},
147+
});
148+
}
149+
if (response.status === 204) {
150+
return undefined as T;
151+
}
152+
try {
153+
return JSON.parse(body) as T;
154+
} catch (error) {
155+
throw new RuntError(ErrorType.Unknown, {
156+
message: 'Invalid JSON response',
157+
responsePayload: {
158+
upstreamCode: response.status,
159+
},
160+
});
161+
}
162+
}
163+
164+
const anacondaToRuntScopes = (scopes: string[]): Scope[] => {
165+
let result: Scope[] = [];
166+
for (const scope of scopes) {
167+
if (scope === 'cloud:read') {
168+
result.push(Scope.RuntRead);
169+
}
170+
if (scope === 'cloud:write') {
171+
result.push(Scope.RuntExecute);
172+
}
173+
}
174+
return result;
175+
};
176+
177+
const anacondaToRuntApiKey = (
178+
id: string,
179+
context: AuthenticatedProviderContext,
180+
anacondaResponse: AnacondaGetApiKeyResponse
181+
): ApiKey => {
182+
return {
183+
id,
184+
userId: context.passport.user.id,
185+
name: anacondaResponse.name,
186+
scopes: anacondaToRuntScopes(anacondaResponse.scopes),
187+
expiresAt: anacondaResponse.expires_at,
188+
userGenerated: anacondaResponse.user_created,
189+
revoked: false,
190+
};
191+
};
192+
193+
const provider: ApiKeyProvider = {
194+
capabilities: new Set([ApiKeyCapabilities.Delete]),
195+
isApiKey: (context: ProviderContext): boolean => {
196+
if (!context.bearerToken) {
197+
return false;
198+
}
199+
const unverified = jose.decodeJwt(context.bearerToken);
200+
return unverified.ver === 'api:1';
201+
},
202+
validateApiKey: async (context: ProviderContext): Promise<Passport> => {
203+
if (!context.bearerToken) {
204+
throw new RuntError(ErrorType.MissingAuthToken);
205+
}
206+
const config = getExtensionConfig(context);
207+
const whoami: AnacondaWhoamiResponse = await fetch(config.userinfoUrl, {
208+
headers: {
209+
Authorization: `Bearer ${context.bearerToken}`,
210+
},
211+
})
212+
.catch(createFailureHandler(config.userinfoUrl))
213+
.then(handleAnacondaResponse<AnacondaWhoamiResponse>);
214+
215+
if (whoami.passport.source !== 'api_key') {
216+
throw new RuntError(ErrorType.AuthTokenInvalid, {
217+
message: 'Non api key used',
218+
debugPayload: {
219+
upstreamCode: 401,
220+
upstreamBody: whoami,
221+
},
222+
});
223+
}
224+
225+
let scopes: Scope[] = anacondaToRuntScopes(whoami.passport.scopes);
226+
return {
227+
type: AuthType.ApiKey,
228+
user: {
229+
id: whoami.passport.user_id,
230+
email: whoami.passport.profile.email,
231+
givenName: whoami.passport.profile.first_name,
232+
familyName: whoami.passport.profile.last_name,
233+
},
234+
claims: jose.decodeJwt(context.bearerToken),
235+
scopes,
236+
resources: null,
237+
};
238+
},
239+
createApiKey: async (context: AuthenticatedProviderContext, request: CreateApiKeyRequest): Promise<string> => {
240+
const config = getExtensionConfig(context);
241+
const scopeMapping: Record<Scope, string> = {
242+
[Scope.RuntRead]: 'cloud:read',
243+
[Scope.RuntExecute]: 'cloud:write',
244+
};
245+
246+
const requestBody: AnacondaCreateApiKeyRequest = {
247+
scopes: request.scopes.map(scope => scopeMapping[scope]),
248+
user_created: request.userGenerated,
249+
name: request.name ?? 'runt-api-key',
250+
tags: ['runt'],
251+
expires_at: request.expiresAt,
252+
};
253+
let result: AnacondaCreateApiKeyResponse = await fetch(config.apiKeyUrl, {
254+
method: 'POST',
255+
body: JSON.stringify(requestBody),
256+
headers: {
257+
'Content-Type': 'application/json',
258+
Authorization: `Bearer ${context.bearerToken}`,
259+
},
260+
})
261+
.catch(createFailureHandler(config.apiKeyUrl))
262+
.then(handleAnacondaResponse<AnacondaCreateApiKeyResponse>);
263+
return result.api_key;
264+
},
265+
getApiKey: async (context: AuthenticatedProviderContext, id: string): Promise<ApiKey> => {
266+
// Anaconda's API auth doesn't have an endpoint to get a single api key
267+
// Instead, we have to list all of them and then filter out the correct one
268+
const config = getExtensionConfig(context);
269+
const result: AnacondaGetApiKeyResponse[] = await fetch(config.apiKeyUrl, {
270+
headers: {
271+
Authorization: `Bearer ${context.bearerToken}`,
272+
},
273+
})
274+
.catch(createFailureHandler(config.apiKeyUrl))
275+
.then(handleAnacondaResponse<AnacondaGetApiKeyResponse[]>);
276+
const match = result.find(r => r.id === id);
277+
if (!match) {
278+
throw new RuntError(ErrorType.NotFound, {
279+
message: 'Api key not found',
280+
});
281+
}
282+
return anacondaToRuntApiKey(id, context, match);
283+
},
284+
listApiKeys: async (context: AuthenticatedProviderContext, request: ListApiKeysRequest): Promise<ApiKey[]> => {
285+
const config = getExtensionConfig(context);
286+
const result: AnacondaGetApiKeyResponse[] = await fetch(config.apiKeyUrl, {
287+
headers: {
288+
Authorization: `Bearer ${context.bearerToken}`,
289+
},
290+
})
291+
.catch(createFailureHandler(config.apiKeyUrl))
292+
.then(handleAnacondaResponse<AnacondaGetApiKeyResponse[]>);
293+
return result.map(r => anacondaToRuntApiKey(r.id, context, r));
294+
},
295+
revokeApiKey: async (_context: AuthenticatedProviderContext, _id: string): Promise<void> => {
296+
throw new RuntError(ErrorType.CapabilityNotAvailable, {
297+
message: 'revoke capability is not supported',
298+
});
299+
},
300+
deleteApiKey: async (context: AuthenticatedProviderContext, id: string): Promise<void> => {
301+
const config = getExtensionConfig(context);
302+
await fetch(`${config.apiKeyUrl}/${id}`, {
303+
method: 'DELETE',
304+
headers: {
305+
Authorization: `Bearer ${context.bearerToken}`,
306+
},
307+
})
308+
.catch(createFailureHandler(config.apiKeyUrl))
309+
.then(handleAnacondaResponse<void>);
310+
},
311+
};
312+
313+
export default provider;

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { BackendExtension } from '@runtimed/extensions';
2+
import apiKeyProvider from './api_key';
23

3-
const extension: BackendExtension = {};
4+
const extension: BackendExtension = {
5+
apiKey: apiKeyProvider,
6+
};
47
export default extension;

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"declarationMap": true,
1212
"declaration": true,
1313
"lib": ["es2024"],
14+
"types": ["@cloudflare/workers-types"],
1415
"strict": true,
1516
"skipLibCheck": true,
1617
"isolatedModules": true,

0 commit comments

Comments
 (0)