1- import React , { type FormEvent , useState , useEffect } from 'react' ;
1+ import React , { useState , useEffect } from 'react' ;
22import { useId } from '../../hooks/hookPolyfills' ;
33import {
44 type CoderAuthStatus ,
55 useCoderAppConfig ,
66 useInternalCoderAuth ,
77} from '../CoderProvider' ;
8-
98import { CoderLogo } from '../CoderLogo' ;
109import { Link , LinkButton } from '@backstage/core-components' ;
1110import { VisuallyHidden } from '../VisuallyHidden' ;
1211import { makeStyles } from '@material-ui/core' ;
1312import TextField from '@material-ui/core/TextField' ;
1413import ErrorIcon from '@material-ui/icons/ErrorOutline' ;
1514import SyncIcon from '@material-ui/icons/Sync' ;
15+ import { configApiRef , errorApiRef , useApi } from '@backstage/core-plugin-api' ;
16+ import { useUrlSync } from '../../hooks/useUrlSync' ;
1617
1718const 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
7993export 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 } />
0 commit comments