Skip to content

Commit 04a1c15

Browse files
authored
fix(Coder plugin): Update integration with Backstage's identity API (#118)
* fix: update UI code to forward bearer tokens properly * refactor: consolidate init setup logic * fix: update error catching logic * fix: add new mock to get current tests passing * fix: add mock bearer token * chore: add test middleware to verify bearer token behavior * refactor: update variable names for clarity
1 parent ea46efc commit 04a1c15

File tree

7 files changed

+154
-19
lines changed

7 files changed

+154
-19
lines changed

plugins/backstage-plugin-coder/src/api.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
WorkspaceAgentStatus,
1010
} from './typesConstants';
1111
import { CoderAuth, assertValidCoderAuth } from './components/CoderProvider';
12+
import { IdentityApi } from '@backstage/core-plugin-api';
1213

1314
export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin';
1415

@@ -19,9 +20,31 @@ export const ASSETS_ROUTE_PREFIX = PROXY_ROUTE_PREFIX;
1920
export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token';
2021
export const REQUEST_TIMEOUT_MS = 20_000;
2122

22-
function getCoderApiRequestInit(authToken: string): RequestInit {
23+
async function getCoderApiRequestInit(
24+
authToken: string,
25+
identity: IdentityApi,
26+
): Promise<RequestInit> {
27+
const headers: HeadersInit = {
28+
[CODER_AUTH_HEADER_KEY]: authToken,
29+
};
30+
31+
try {
32+
const credentials = await identity.getCredentials();
33+
if (credentials.token) {
34+
headers.Authorization = `Bearer ${credentials.token}`;
35+
}
36+
} catch (err) {
37+
if (err instanceof Error) {
38+
throw err;
39+
}
40+
41+
throw new Error(
42+
"Unable to parse user information for Coder requests. Please ensure that your Backstage deployment is integrated to use Backstage's Identity API",
43+
);
44+
}
45+
2346
return {
24-
headers: { [CODER_AUTH_HEADER_KEY]: authToken },
47+
headers,
2548
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2649
};
2750
}
@@ -53,6 +76,7 @@ export class BackstageHttpError extends Error {
5376
type FetchInputs = Readonly<{
5477
auth: CoderAuth;
5578
baseUrl: string;
79+
identity: IdentityApi;
5680
}>;
5781

5882
type WorkspacesFetchInputs = Readonly<
@@ -64,17 +88,18 @@ type WorkspacesFetchInputs = Readonly<
6488
async function getWorkspaces(
6589
fetchInputs: WorkspacesFetchInputs,
6690
): Promise<readonly Workspace[]> {
67-
const { baseUrl, coderQuery, auth } = fetchInputs;
91+
const { baseUrl, coderQuery, auth, identity } = fetchInputs;
6892
assertValidCoderAuth(auth);
6993

7094
const urlParams = new URLSearchParams({
7195
q: coderQuery,
7296
limit: '0',
7397
});
7498

99+
const requestInit = await getCoderApiRequestInit(auth.token, identity);
75100
const response = await fetch(
76101
`${baseUrl}${API_ROUTE_PREFIX}/workspaces?${urlParams.toString()}`,
77-
getCoderApiRequestInit(auth.token),
102+
requestInit,
78103
);
79104

80105
if (!response.ok) {
@@ -116,12 +141,13 @@ type BuildParamsFetchInputs = Readonly<
116141
>;
117142

118143
async function getWorkspaceBuildParameters(inputs: BuildParamsFetchInputs) {
119-
const { baseUrl, auth, workspaceBuildId } = inputs;
144+
const { baseUrl, auth, workspaceBuildId, identity } = inputs;
120145
assertValidCoderAuth(auth);
121146

147+
const requestInit = await getCoderApiRequestInit(auth.token, identity);
122148
const res = await fetch(
123149
`${baseUrl}${API_ROUTE_PREFIX}/workspacebuilds/${workspaceBuildId}/parameters`,
124-
getCoderApiRequestInit(auth.token),
150+
requestInit,
125151
);
126152

127153
if (!res.ok) {
@@ -256,16 +282,18 @@ export function workspacesByRepo(
256282
type AuthValidationInputs = Readonly<{
257283
baseUrl: string;
258284
authToken: string;
285+
identity: IdentityApi;
259286
}>;
260287

261288
async function isAuthValid(inputs: AuthValidationInputs): Promise<boolean> {
262-
const { baseUrl, authToken } = inputs;
289+
const { baseUrl, authToken, identity } = inputs;
263290

264291
// In this case, the request doesn't actually matter. Just need to make any
265292
// kind of dummy request to validate the auth
293+
const requestInit = await getCoderApiRequestInit(authToken, identity);
266294
const response = await fetch(
267295
`${baseUrl}${API_ROUTE_PREFIX}/users/me`,
268-
getCoderApiRequestInit(authToken),
296+
requestInit,
269297
);
270298

271299
if (response.status >= 400 && response.status !== 401) {

plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
authValidation,
2020
} from '../../api';
2121
import { useBackstageEndpoints } from '../../hooks/useBackstageEndpoints';
22+
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
2223

2324
const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token';
2425

@@ -98,6 +99,7 @@ export function useCoderAuth(): CoderAuth {
9899
type CoderAuthProviderProps = Readonly<PropsWithChildren<unknown>>;
99100

100101
export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => {
102+
const identity = useApi(identityApiRef);
101103
const { baseUrl } = useBackstageEndpoints();
102104
const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true);
103105

@@ -108,7 +110,7 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => {
108110
const [readonlyInitialAuthToken] = useState(authToken);
109111

110112
const authValidityQuery = useQuery({
111-
...authValidation({ baseUrl, authToken }),
113+
...authValidation({ baseUrl, authToken, identity }),
112114
refetchOnWindowFocus: query => query.state.data !== false,
113115
});
114116

plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { renderHook } from '@testing-library/react';
33
import { act, waitFor } from '@testing-library/react';
44

55
import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
6-
import { configApiRef, errorApiRef } from '@backstage/core-plugin-api';
6+
import {
7+
configApiRef,
8+
errorApiRef,
9+
identityApiRef,
10+
} from '@backstage/core-plugin-api';
711

812
import { CoderProvider } from './CoderProvider';
913
import { useCoderAppConfig } from './CoderAppConfigProvider';
@@ -12,6 +16,7 @@ import { type CoderAuth, useCoderAuth } from './CoderAuthProvider';
1216
import {
1317
getMockConfigApi,
1418
getMockErrorApi,
19+
getMockIdentityApi,
1520
mockAppConfig,
1621
mockCoderAuthToken,
1722
} from '../../testHelpers/mockBackstageData';
@@ -87,6 +92,7 @@ describe(`${CoderProvider.name}`, () => {
8792
<TestApiProvider
8893
apis={[
8994
[errorApiRef, getMockErrorApi()],
95+
[identityApiRef, getMockIdentityApi()],
9096
[configApiRef, getMockConfigApi()],
9197
]}
9298
>

plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { workspaces, workspacesByRepo } from '../api';
44
import { useCoderAuth } from '../components/CoderProvider/CoderAuthProvider';
55
import { useBackstageEndpoints } from './useBackstageEndpoints';
66
import { CoderWorkspacesConfig } from './useCoderWorkspacesConfig';
7+
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
78

89
type QueryInput = Readonly<{
910
coderQuery: string;
@@ -15,12 +16,19 @@ export function useCoderWorkspacesQuery({
1516
workspacesConfig,
1617
}: QueryInput) {
1718
const auth = useCoderAuth();
19+
const identity = useApi(identityApiRef);
1820
const { baseUrl } = useBackstageEndpoints();
1921
const hasRepoData = workspacesConfig && workspacesConfig.repoUrl;
2022

2123
const queryOptions = hasRepoData
22-
? workspacesByRepo({ coderQuery, auth, baseUrl, workspacesConfig })
23-
: workspaces({ coderQuery, auth, baseUrl });
24+
? workspacesByRepo({
25+
coderQuery,
26+
identity,
27+
auth,
28+
baseUrl,
29+
workspacesConfig,
30+
})
31+
: workspaces({ coderQuery, identity, auth, baseUrl });
2432

2533
return useQuery(queryOptions);
2634
}

plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { ScmIntegrationsApi } from '@backstage/integration-react';
1818

1919
import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api';
20+
import { IdentityApi } from '@backstage/core-plugin-api';
2021

2122
/**
2223
* This is the key that Backstage checks from the entity data to determine the
@@ -57,6 +58,7 @@ export const mockBackstageProxyEndpoint = `${mockBackstageUrlRoot}${API_ROUTE_PR
5758

5859
export const mockBackstageAssetsEndpoint = `${mockBackstageUrlRoot}${ASSETS_ROUTE_PREFIX}`;
5960

61+
export const mockBearerToken = 'This-is-an-opaque-value-by-design';
6062
export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X';
6163

6264
export const mockYamlConfig = {
@@ -207,6 +209,33 @@ export function getMockErrorApi() {
207209
return errorApi;
208210
}
209211

212+
export function getMockIdentityApi(): IdentityApi {
213+
return {
214+
signOut: async () => {
215+
return void 'Not going to implement this';
216+
},
217+
getProfileInfo: async () => {
218+
return {
219+
displayName: 'Dobah',
220+
email: 'i-love-my-dog-dobah@dog.ceo',
221+
picture: undefined,
222+
};
223+
},
224+
getBackstageIdentity: async () => {
225+
return {
226+
type: 'user',
227+
userEntityRef: 'User:default/Dobah',
228+
ownershipEntityRefs: [],
229+
};
230+
},
231+
getCredentials: async () => {
232+
return {
233+
token: mockBearerToken,
234+
};
235+
},
236+
};
237+
}
238+
210239
/**
211240
* Exposes a mock ScmIntegrationRegistry to be used with scmIntegrationsApiRef
212241
* for mocking out code that relies on source code data.

plugins/backstage-plugin-coder/src/testHelpers/server.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
/* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */
2-
import { RestHandler, rest } from 'msw';
2+
import {
3+
type DefaultBodyType,
4+
type ResponseResolver,
5+
type RestContext,
6+
type RestHandler,
7+
type RestRequest,
8+
rest,
9+
} from 'msw';
310
import { setupServer } from 'msw/node';
411
/* eslint-enable @backstage/no-undeclared-imports */
512

@@ -8,14 +15,60 @@ import {
815
mockWorkspaceBuildParameters,
916
} from './mockCoderAppData';
1017
import {
18+
mockBearerToken,
1119
mockCoderAuthToken,
1220
mockBackstageProxyEndpoint as root,
1321
} from './mockBackstageData';
1422
import type { Workspace, WorkspacesResponse } from '../typesConstants';
1523
import { CODER_AUTH_HEADER_KEY } from '../api';
1624

17-
const handlers: readonly RestHandler[] = [
18-
rest.get(`${root}/workspaces`, (req, res, ctx) => {
25+
type RestResolver<TBody extends DefaultBodyType = any> = ResponseResolver<
26+
RestRequest<TBody>,
27+
RestContext,
28+
TBody
29+
>;
30+
31+
export type RestResolverMiddleware<TBody extends DefaultBodyType = any> = (
32+
resolver: RestResolver<TBody>,
33+
) => RestResolver<TBody>;
34+
35+
const defaultMiddleware = [
36+
function validateBearerToken(handler) {
37+
return (req, res, ctx) => {
38+
const tokenRe = /^Bearer (.+)$/;
39+
const authHeader = req.headers.get('Authorization') ?? '';
40+
const [, bearerToken] = tokenRe.exec(authHeader) ?? [];
41+
42+
if (bearerToken === mockBearerToken) {
43+
return handler(req, res, ctx);
44+
}
45+
46+
return res(ctx.status(401));
47+
};
48+
},
49+
] as const satisfies readonly RestResolverMiddleware[];
50+
51+
export function wrapInDefaultMiddleware<TBody extends DefaultBodyType = any>(
52+
resolver: RestResolver<TBody>,
53+
): RestResolver<TBody> {
54+
return defaultMiddleware.reduceRight((currentResolver, middleware) => {
55+
const recastMiddleware =
56+
middleware as unknown as RestResolverMiddleware<TBody>;
57+
58+
return recastMiddleware(currentResolver);
59+
}, resolver);
60+
}
61+
62+
function wrappedGet<TBody extends DefaultBodyType = any>(
63+
path: string,
64+
resolver: RestResolver<TBody>,
65+
): RestHandler {
66+
const wrapped = wrapInDefaultMiddleware(resolver);
67+
return rest.get(path, wrapped);
68+
}
69+
70+
const mainTestHandlers: readonly RestHandler[] = [
71+
wrappedGet(`${root}/workspaces`, (req, res, ctx) => {
1972
const queryText = String(req.url.searchParams.get('q'));
2073

2174
let returnedWorkspaces: Workspace[];
@@ -36,7 +89,7 @@ const handlers: readonly RestHandler[] = [
3689
);
3790
}),
3891

39-
rest.get(
92+
wrappedGet(
4093
`${root}/workspacebuilds/:workspaceBuildId/parameters`,
4194
(req, res, ctx) => {
4295
const buildId = String(req.params.workspaceBuildId);
@@ -51,7 +104,7 @@ const handlers: readonly RestHandler[] = [
51104
),
52105

53106
// This is the dummy request used to verify a user's auth status
54-
rest.get(`${root}/users/me`, (req, res, ctx) => {
107+
wrappedGet(`${root}/users/me`, (req, res, ctx) => {
55108
const token = req.headers.get(CODER_AUTH_HEADER_KEY);
56109
if (token === mockCoderAuthToken) {
57110
return res(ctx.status(200));
@@ -61,4 +114,4 @@ const handlers: readonly RestHandler[] = [
61114
}),
62115
];
63116

64-
export const server = setupServer(...handlers);
117+
export const server = setupServer(...mainTestHandlers);

plugins/backstage-plugin-coder/src/testHelpers/setup.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import {
1212
import React from 'react';
1313
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
1414
import { scmIntegrationsApiRef } from '@backstage/integration-react';
15-
import { configApiRef, errorApiRef } from '@backstage/core-plugin-api';
15+
import {
16+
configApiRef,
17+
errorApiRef,
18+
identityApiRef,
19+
} from '@backstage/core-plugin-api';
1620
import { EntityProvider } from '@backstage/plugin-catalog-react';
1721
import {
1822
type CoderAuth,
@@ -30,6 +34,7 @@ import {
3034
getMockConfigApi,
3135
mockAuthStates,
3236
BackstageEntity,
37+
getMockIdentityApi,
3338
} from './mockBackstageData';
3439
import { CoderErrorBoundary } from '../plugin';
3540

@@ -159,6 +164,7 @@ export const renderHookAsCoderEntity = async <
159164
const mockErrorApi = getMockErrorApi();
160165
const mockSourceControl = getMockSourceControl();
161166
const mockConfigApi = getMockConfigApi();
167+
const mockIdentityApi = getMockIdentityApi();
162168
const mockQueryClient = getMockQueryClient();
163169

164170
const renderHookValue = renderHook(hook, {
@@ -168,6 +174,7 @@ export const renderHookAsCoderEntity = async <
168174
<TestApiProvider
169175
apis={[
170176
[errorApiRef, mockErrorApi],
177+
[identityApiRef, mockIdentityApi],
171178
[scmIntegrationsApiRef, mockSourceControl],
172179
[configApiRef, mockConfigApi],
173180
]}
@@ -215,11 +222,13 @@ export async function renderInCoderEnvironment({
215222
const mockErrorApi = getMockErrorApi();
216223
const mockSourceControl = getMockSourceControl();
217224
const mockConfigApi = getMockConfigApi();
225+
const mockIdentityApi = getMockIdentityApi();
218226

219227
const mainMarkup = (
220228
<TestApiProvider
221229
apis={[
222230
[errorApiRef, mockErrorApi],
231+
[identityApiRef, mockIdentityApi],
223232
[scmIntegrationsApiRef, mockSourceControl],
224233
[configApiRef, mockConfigApi],
225234
]}

0 commit comments

Comments
 (0)