Skip to content

Commit 90695ae

Browse files
committed
fix: clean up CoderAuthInputForm
1 parent 6e50b24 commit 90695ae

File tree

4 files changed

+146
-105
lines changed

4 files changed

+146
-105
lines changed

packages/app/src/components/catalog/EntityPage.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,6 @@ const coderAppConfig: CoderAppConfig = {
136136
accessUrl: 'https://dev.coder.com',
137137
},
138138

139-
oauth: {
140-
clientId: '09cd00cf-9517-401c-9601-3712f187b53c',
141-
backendUrl: 'http://localhost:7007',
142-
},
143-
144139
workspaces: {
145140
defaultTemplateName: 'devcontainers',
146141
defaultMode: 'manual',

plugins/backstage-plugin-coder/dev/DevPage.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,6 @@ const appConfig: CoderAppConfig = {
2323
accessUrl: 'https://dev.coder.com',
2424
},
2525

26-
oauth: {
27-
clientId: '09cd00cf-9517-401c-9601-3712f187b53c',
28-
backendUrl: 'http://localhost:7007',
29-
},
30-
3126
workspaces: {
3227
defaultTemplateName: 'devcontainers',
3328
defaultMode: 'manual',

plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx

Lines changed: 146 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
import React, { type FormEvent, useState, useEffect } from 'react';
1+
import React, { useState, useEffect } from 'react';
22
import { useId } from '../../hooks/hookPolyfills';
33
import {
44
type CoderAuthStatus,
55
useCoderAppConfig,
66
useInternalCoderAuth,
77
} from '../CoderProvider';
8-
98
import { CoderLogo } from '../CoderLogo';
109
import { Link, LinkButton } from '@backstage/core-components';
1110
import { VisuallyHidden } from '../VisuallyHidden';
1211
import { makeStyles } from '@material-ui/core';
1312
import TextField from '@material-ui/core/TextField';
1413
import ErrorIcon from '@material-ui/icons/ErrorOutline';
1514
import SyncIcon from '@material-ui/icons/Sync';
15+
import { configApiRef, errorApiRef, useApi } from '@backstage/core-plugin-api';
16+
import { useUrlSync } from '../../hooks/useUrlSync';
1617

1718
const useStyles = makeStyles(theme => ({
1819
formContainer: {
@@ -45,88 +46,128 @@ const useStyles = makeStyles(theme => ({
4546
},
4647

4748
oauthSection: {
48-
marginTop: theme.spacing(3),
49-
marginBottom: theme.spacing(2),
49+
paddingTop: theme.spacing(0.5),
50+
paddingBottom: theme.spacing(0.5),
5051
},
5152

5253
oauthButton: {
5354
display: 'block',
55+
// Deliberately making this button bigger than the token button, because we
56+
// want to start pushing users to use oauth as the default. The old token
57+
// approach may end up getting deprecated
5458
width: '100%',
55-
maxWidth: '100%',
5659
},
5760

5861
divider: {
5962
display: 'flex',
6063
alignItems: 'center',
6164
textAlign: 'center',
62-
marginTop: theme.spacing(2),
63-
marginBottom: theme.spacing(2),
65+
paddingTop: theme.spacing(2),
66+
paddingBottom: theme.spacing(0.5),
6467

6568
'&::before, &::after': {
6669
content: '""',
67-
flex: 1,
70+
flexGrow: 1,
6871
borderBottom: `1px solid ${theme.palette.divider}`,
6972
},
7073
},
7174

7275
dividerText: {
76+
textTransform: 'uppercase',
7377
padding: `0 ${theme.spacing(1)}px`,
7478
color: theme.palette.text.secondary,
75-
fontSize: '0.875rem',
79+
fontSize: '0.75rem',
80+
fontWeight: 500,
81+
},
82+
83+
tokenSection: {
84+
paddingTop: `${theme.spacing(1.5)}px`,
85+
},
86+
87+
tokenInstructions: {
88+
margin: 0,
89+
marginBottom: `-${theme.spacing(0.5)}px`,
7690
},
7791
}));
7892

7993
export const CoderAuthInputForm = () => {
8094
const hookId = useId();
8195
const styles = useStyles();
8296
const appConfig = useCoderAppConfig();
97+
const urlSync = useUrlSync();
98+
const configApi = useApi(configApiRef);
99+
const errorApi = useApi(errorApiRef);
83100
const { status, registerNewToken } = useInternalCoderAuth();
84101

102+
const backendUrl = urlSync.state.baseUrl;
85103
useEffect(() => {
86-
const handleOAuthMessage = (event: MessageEvent) => {
87-
// Verify the message is from our OAuth callback backend
88-
const backendUrl = appConfig.oauth?.backendUrl;
89-
90-
// If backendUrl is configured, verify the origin matches
91-
if (backendUrl) {
92-
const backendOrigin = new URL(backendUrl).origin;
93-
if (event.origin !== backendOrigin) {
94-
return;
95-
}
104+
if (!backendUrl) {
105+
return undefined;
106+
}
107+
108+
const onOauthMessage = (event: MessageEvent<unknown>): void => {
109+
// Even though we're going to add the event listener to the window object
110+
// directly, we still want to make sure that the event originated on the
111+
// window, and wasn't received from a DOM node via event bubbling
112+
if (event.target !== window) {
113+
return;
96114
}
97115

98-
if (event.data?.type === 'coder-oauth-success' && event.data?.token) {
99-
registerNewToken(event.data.token);
116+
const backendOrigin = new URL(backendUrl).origin;
117+
const originMismatch = event.origin !== backendOrigin;
118+
if (originMismatch) {
119+
return;
100120
}
101-
};
102121

103-
window.addEventListener('message', handleOAuthMessage);
104-
return () => window.removeEventListener('message', handleOAuthMessage);
105-
}, [registerNewToken, appConfig.oauth?.backendUrl]);
106-
107-
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
108-
event.preventDefault();
109-
const formData = Object.fromEntries(new FormData(event.currentTarget));
110-
const newToken =
111-
typeof formData.authToken === 'string' ? formData.authToken : '';
122+
const { data } = event;
123+
const messageIsOauthPayload =
124+
typeof data === 'object' && data !== null && 'token' in data;
125+
if (!messageIsOauthPayload) {
126+
return;
127+
}
128+
// For some reason, TypeScript won't narrow properly if you move this
129+
// check to the messageIsOauthPayload boolean
130+
if (typeof data.token === 'string') {
131+
registerNewToken(data.token);
132+
}
133+
};
112134

113-
registerNewToken(newToken);
114-
};
135+
window.addEventListener('message', onOauthMessage);
136+
return () => window.removeEventListener('message', onOauthMessage);
137+
}, [registerNewToken, backendUrl]);
115138

116139
const handleOAuthLogin = () => {
117-
const authUrl = `${appConfig.deployment.accessUrl}/oauth2/authorize`;
118-
const clientId = appConfig.oauth?.clientId || 'backstage';
119-
const backendUrl =
120-
appConfig.oauth?.backendUrl ||
121-
`${window.location.protocol}//${window.location.hostname}:7007`;
122-
const redirectUri = `${backendUrl}/api/auth/coder/oauth/callback`;
123-
const state = btoa(JSON.stringify({ returnTo: window.location.pathname }));
124-
125-
const oauthUrl = `${authUrl}?client_id=${clientId}&redirect_uri=${encodeURIComponent(
126-
redirectUri,
127-
)}&response_type=code&state=${state}`;
128-
129-
// Open OAuth flow in popup window
140+
const clientId = configApi.getOptionalString('coder.oauth.clientId');
141+
if (!clientId) {
142+
errorApi.post(
143+
{
144+
name: 'Coder oauth clientId is missing',
145+
message:
146+
'Please see plugin documentation for how to add clientId to your Backstage deployment',
147+
},
148+
{ hidden: false },
149+
);
150+
return;
151+
}
152+
153+
const params = new URLSearchParams({
154+
/**
155+
* @todo See what we can do to move the state calculations to the backend.
156+
* The state should actually be cryptographically generated and should
157+
* have a high number of bits of entropy, too.
158+
*/
159+
state: btoa(JSON.stringify({ returnTo: window.location.pathname })),
160+
response_type: 'code',
161+
client_id: clientId,
162+
redirect_uri: encodeURIComponent(
163+
`${backendUrl}/api/auth/coder/oauth/callback`,
164+
),
165+
});
166+
167+
const oauthUrl = `${
168+
appConfig.deployment.accessUrl
169+
}/oauth2/authorize?${params.toString()}`;
170+
130171
const width = 600;
131172
const height = 700;
132173
const left = window.screen.width / 2 - width / 2;
@@ -148,7 +189,14 @@ export const CoderAuthInputForm = () => {
148189
<form
149190
aria-labelledby={formHeaderId}
150191
className={styles.formContainer}
151-
onSubmit={onSubmit}
192+
onSubmit={event => {
193+
event.preventDefault();
194+
const formData = Object.fromEntries(new FormData(event.currentTarget));
195+
const newToken =
196+
typeof formData.authToken === 'string' ? formData.authToken : '';
197+
198+
registerNewToken(newToken);
199+
}}
152200
>
153201
<h3 hidden id={formHeaderId}>
154202
Authenticate with Coder
@@ -170,56 +218,64 @@ export const CoderAuthInputForm = () => {
170218
className={styles.oauthButton}
171219
onClick={handleOAuthLogin}
172220
>
173-
Sign in with Coder
221+
Sign in with Coder OAuth
174222
</LinkButton>
175223
</div>
176224

177225
<div className={styles.divider}>
178-
<span className={styles.dividerText}>OR</span>
226+
<span className={styles.dividerText}>or</span>
179227
</div>
180228

181-
<p>
182-
Alternatively, enter a token from your{' '}
183-
<Link to={`${appConfig.deployment.accessUrl}/cli-auth`} target="_blank">
184-
Coder deployment's token page
185-
<VisuallyHidden> (link opens in new tab)</VisuallyHidden>
186-
</Link>
187-
.
188-
</p>
189-
190-
<fieldset className={styles.authInputFieldset} aria-labelledby={legendId}>
191-
<legend hidden id={legendId}>
192-
Auth input
193-
</legend>
194-
195-
<TextField
196-
// Adding the label prop directly to the TextField will place a label
197-
// in the HTML, so sighted users are fine. But for some reason, it
198-
// won't connect the label and input together, which breaks
199-
// accessibility for screen readers. Need to wire up extra IDs, sadly.
200-
label="Auth token"
201-
InputLabelProps={{ htmlFor: authTokenInputId }}
202-
InputProps={{ id: authTokenInputId }}
203-
required
204-
name="authToken"
205-
type="password"
206-
defaultValue=""
207-
aria-errormessage={warningBannerId}
208-
style={{ width: '100%' }}
209-
/>
210-
211-
<LinkButton
212-
disableRipple
213-
to=""
214-
component="button"
215-
type="submit"
216-
color="primary"
217-
variant="contained"
218-
className={styles.authButton}
229+
<div className={styles.tokenSection}>
230+
<p className={styles.tokenInstructions}>
231+
Enter a token from your{' '}
232+
<Link
233+
to={`${appConfig.deployment.accessUrl}/cli-auth`}
234+
target="_blank"
235+
>
236+
Coder deployment's token page
237+
<VisuallyHidden> (link opens in new tab)</VisuallyHidden>
238+
</Link>
239+
.
240+
</p>
241+
242+
<fieldset
243+
className={styles.authInputFieldset}
244+
aria-labelledby={legendId}
219245
>
220-
Authenticate
221-
</LinkButton>
222-
</fieldset>
246+
<legend hidden id={legendId}>
247+
Auth input
248+
</legend>
249+
250+
<TextField
251+
// Adding the label prop directly to the TextField will place a label
252+
// in the HTML, so sighted users are fine. But for some reason, it
253+
// won't connect the label and input together, which breaks
254+
// accessibility for screen readers. Need to wire up extra IDs, sadly.
255+
label="Auth token"
256+
InputLabelProps={{ htmlFor: authTokenInputId }}
257+
InputProps={{ id: authTokenInputId }}
258+
required
259+
name="authToken"
260+
type="password"
261+
defaultValue=""
262+
aria-errormessage={warningBannerId}
263+
style={{ width: '100%' }}
264+
/>
265+
266+
<LinkButton
267+
disableRipple
268+
to=""
269+
component="button"
270+
type="submit"
271+
color="primary"
272+
variant="contained"
273+
className={styles.authButton}
274+
>
275+
Use token
276+
</LinkButton>
277+
</fieldset>
278+
</div>
223279

224280
{(status === 'invalid' || status === 'authenticating') && (
225281
<InvalidStatusNotifier authStatus={status} bannerId={warningBannerId} />

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ export type CoderAppConfig = Readonly<{
1010
accessUrl: string;
1111
}>;
1212

13-
oauth?: Readonly<{
14-
clientId?: string;
15-
backendUrl?: string;
16-
}>;
17-
1813
// Type is meant to be used with YamlConfig from useCoderWorkspacesConfig;
1914
// not using a mapped type because there's just enough differences that
2015
// maintaining a relationship that way would be a nightmare of ternaries

0 commit comments

Comments
 (0)