Skip to content

Commit b835851

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

6 files changed

Lines changed: 347 additions & 8 deletions

File tree

.npmrc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
2-
@runtimed:registry=https://npm.pkg.github.com/
3-
always-auth=true
2+
@runtimed/anaconda:registry=https://npm.pkg.github.com/
3+
always-auth=false

package-lock.json

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

0 commit comments

Comments
 (0)