Skip to content

Commit 9609bf0

Browse files
committed
github oauth2 device flow frontend
1 parent b888145 commit 9609bf0

File tree

7 files changed

+321
-105
lines changed

7 files changed

+321
-105
lines changed

site/src/api/api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,6 +1605,29 @@ class ApiMethods {
16051605
return resp.data;
16061606
};
16071607

1608+
getOAuth2GitHubDeviceFlowCallback = async (
1609+
code: string,
1610+
state: string,
1611+
): Promise<TypesGen.OAuth2DeviceFlowCallbackResponse> => {
1612+
const resp = await this.axios.get(
1613+
`/api/v2/users/oauth2/github/callback?code=${code}&state=${state}`,
1614+
);
1615+
// sanity check
1616+
if (
1617+
typeof resp.data !== "object" ||
1618+
typeof resp.data.redirect_url !== "string"
1619+
) {
1620+
console.error("Invalid response from OAuth2 GitHub callback", resp);
1621+
throw new Error("Invalid response from OAuth2 GitHub callback");
1622+
}
1623+
return resp.data;
1624+
};
1625+
1626+
getOAuth2GitHubDevice = async (): Promise<TypesGen.ExternalAuthDevice> => {
1627+
const resp = await this.axios.get("/api/v2/users/oauth2/github/device");
1628+
return resp.data;
1629+
};
1630+
16081631
getOAuth2ProviderApps = async (
16091632
filter?: TypesGen.OAuth2ProviderAppFilter,
16101633
): Promise<TypesGen.OAuth2ProviderApp[]> => {

site/src/api/queries/oauth2.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ const userAppsKey = (userId: string) => appsKey.concat(userId);
77
const appKey = (appId: string) => appsKey.concat(appId);
88
const appSecretsKey = (appId: string) => appKey(appId).concat("secrets");
99

10+
export const getGitHubDevice = () => {
11+
return {
12+
queryKey: ["oauth2-provider", "github", "device"],
13+
queryFn: () => API.getOAuth2GitHubDevice(),
14+
};
15+
};
16+
17+
export const getGitHubDeviceFlowCallback = (code: string, state: string) => {
18+
return {
19+
queryKey: ["oauth2-provider", "github", "callback", code, state],
20+
queryFn: () => API.getOAuth2GitHubDeviceFlowCallback(code, state),
21+
};
22+
};
23+
1024
export const getApps = (userId?: string) => {
1125
return {
1226
queryKey: userId ? appsKey.concat(userId) : appsKey,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
3+
import AlertTitle from "@mui/material/AlertTitle";
4+
import CircularProgress from "@mui/material/CircularProgress";
5+
import Link from "@mui/material/Link";
6+
import type { ApiErrorResponse } from "api/errors";
7+
import type { ExternalAuthDevice } from "api/typesGenerated";
8+
import { Alert, AlertDetail } from "components/Alert/Alert";
9+
import { CopyButton } from "components/CopyButton/CopyButton";
10+
import type { FC } from "react";
11+
12+
interface GitDeviceAuthProps {
13+
externalAuthDevice?: ExternalAuthDevice;
14+
deviceExchangeError?: ApiErrorResponse;
15+
}
16+
17+
export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
18+
externalAuthDevice,
19+
deviceExchangeError,
20+
}) => {
21+
let status = (
22+
<p css={styles.status}>
23+
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
24+
Checking for authentication...
25+
</p>
26+
);
27+
if (deviceExchangeError) {
28+
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
29+
switch (deviceExchangeError.detail) {
30+
case "authorization_pending":
31+
break;
32+
case "expired_token":
33+
status = (
34+
<Alert severity="error">
35+
The one-time code has expired. Refresh to get a new one!
36+
</Alert>
37+
);
38+
break;
39+
case "access_denied":
40+
status = (
41+
<Alert severity="error">Access to the Git provider was denied.</Alert>
42+
);
43+
break;
44+
default:
45+
status = (
46+
<Alert severity="error">
47+
<AlertTitle>{deviceExchangeError.message}</AlertTitle>
48+
{deviceExchangeError.detail && (
49+
<AlertDetail>{deviceExchangeError.detail}</AlertDetail>
50+
)}
51+
</Alert>
52+
);
53+
break;
54+
}
55+
}
56+
57+
// If the error comes from the `externalAuthDevice` query,
58+
// we cannot even display the user_code.
59+
if (deviceExchangeError && !externalAuthDevice) {
60+
return <div>{status}</div>;
61+
}
62+
63+
if (!externalAuthDevice) {
64+
return <CircularProgress />;
65+
}
66+
67+
return (
68+
<div>
69+
<p css={styles.text}>
70+
Copy your one-time code:&nbsp;
71+
<div css={styles.copyCode}>
72+
<span css={styles.code}>{externalAuthDevice.user_code}</span>
73+
&nbsp; <CopyButton text={externalAuthDevice.user_code} />
74+
</div>
75+
<br />
76+
Then open the link below and paste it:
77+
</p>
78+
<div css={styles.links}>
79+
<Link
80+
css={styles.link}
81+
href={externalAuthDevice.verification_uri}
82+
target="_blank"
83+
rel="noreferrer"
84+
>
85+
<OpenInNewIcon fontSize="small" />
86+
Open and Paste
87+
</Link>
88+
</div>
89+
90+
{status}
91+
</div>
92+
);
93+
};
94+
95+
const styles = {
96+
text: (theme) => ({
97+
fontSize: 16,
98+
color: theme.palette.text.secondary,
99+
textAlign: "center",
100+
lineHeight: "160%",
101+
margin: 0,
102+
}),
103+
104+
copyCode: {
105+
display: "inline-flex",
106+
alignItems: "center",
107+
},
108+
109+
code: (theme) => ({
110+
fontWeight: "bold",
111+
color: theme.palette.text.primary,
112+
}),
113+
114+
links: {
115+
display: "flex",
116+
gap: 4,
117+
margin: 16,
118+
flexDirection: "column",
119+
},
120+
121+
link: {
122+
display: "flex",
123+
alignItems: "center",
124+
justifyContent: "center",
125+
fontSize: 16,
126+
gap: 8,
127+
},
128+
129+
status: (theme) => ({
130+
display: "flex",
131+
alignItems: "center",
132+
justifyContent: "center",
133+
gap: 8,
134+
color: theme.palette.text.disabled,
135+
}),
136+
} satisfies Record<string, Interpolation<Theme>>;

site/src/pages/ExternalAuthPage/ExternalAuthPageView.tsx

Lines changed: 2 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import type { Interpolation, Theme } from "@emotion/react";
22
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
33
import RefreshIcon from "@mui/icons-material/Refresh";
4-
import AlertTitle from "@mui/material/AlertTitle";
5-
import CircularProgress from "@mui/material/CircularProgress";
64
import Link from "@mui/material/Link";
75
import Tooltip from "@mui/material/Tooltip";
86
import type { ApiErrorResponse } from "api/errors";
97
import type { ExternalAuth, ExternalAuthDevice } from "api/typesGenerated";
10-
import { Alert, AlertDetail } from "components/Alert/Alert";
8+
import { Alert } from "components/Alert/Alert";
119
import { Avatar } from "components/Avatar/Avatar";
12-
import { CopyButton } from "components/CopyButton/CopyButton";
10+
import { GitDeviceAuth } from "components/GitDeviceAuth/GitDeviceAuth";
1311
import { SignInLayout } from "components/SignInLayout/SignInLayout";
1412
import { Welcome } from "components/Welcome/Welcome";
1513
import type { FC, ReactNode } from "react";
@@ -141,89 +139,6 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
141139
);
142140
};
143141

144-
interface GitDeviceAuthProps {
145-
externalAuthDevice?: ExternalAuthDevice;
146-
deviceExchangeError?: ApiErrorResponse;
147-
}
148-
149-
const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
150-
externalAuthDevice,
151-
deviceExchangeError,
152-
}) => {
153-
let status = (
154-
<p css={styles.status}>
155-
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
156-
Checking for authentication...
157-
</p>
158-
);
159-
if (deviceExchangeError) {
160-
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
161-
switch (deviceExchangeError.detail) {
162-
case "authorization_pending":
163-
break;
164-
case "expired_token":
165-
status = (
166-
<Alert severity="error">
167-
The one-time code has expired. Refresh to get a new one!
168-
</Alert>
169-
);
170-
break;
171-
case "access_denied":
172-
status = (
173-
<Alert severity="error">Access to the Git provider was denied.</Alert>
174-
);
175-
break;
176-
default:
177-
status = (
178-
<Alert severity="error">
179-
<AlertTitle>{deviceExchangeError.message}</AlertTitle>
180-
{deviceExchangeError.detail && (
181-
<AlertDetail>{deviceExchangeError.detail}</AlertDetail>
182-
)}
183-
</Alert>
184-
);
185-
break;
186-
}
187-
}
188-
189-
// If the error comes from the `externalAuthDevice` query,
190-
// we cannot even display the user_code.
191-
if (deviceExchangeError && !externalAuthDevice) {
192-
return <div>{status}</div>;
193-
}
194-
195-
if (!externalAuthDevice) {
196-
return <CircularProgress />;
197-
}
198-
199-
return (
200-
<div>
201-
<p css={styles.text}>
202-
Copy your one-time code:&nbsp;
203-
<div css={styles.copyCode}>
204-
<span css={styles.code}>{externalAuthDevice.user_code}</span>
205-
&nbsp; <CopyButton text={externalAuthDevice.user_code} />
206-
</div>
207-
<br />
208-
Then open the link below and paste it:
209-
</p>
210-
<div css={styles.links}>
211-
<Link
212-
css={styles.link}
213-
href={externalAuthDevice.verification_uri}
214-
target="_blank"
215-
rel="noreferrer"
216-
>
217-
<OpenInNewIcon fontSize="small" />
218-
Open and Paste
219-
</Link>
220-
</div>
221-
222-
{status}
223-
</div>
224-
);
225-
};
226-
227142
export default ExternalAuthPageView;
228143

229144
const styles = {
@@ -235,16 +150,6 @@ const styles = {
235150
margin: 0,
236151
}),
237152

238-
copyCode: {
239-
display: "inline-flex",
240-
alignItems: "center",
241-
},
242-
243-
code: (theme) => ({
244-
fontWeight: "bold",
245-
color: theme.palette.text.primary,
246-
}),
247-
248153
installAlert: {
249154
margin: 16,
250155
},
@@ -264,14 +169,6 @@ const styles = {
264169
gap: 8,
265170
},
266171

267-
status: (theme) => ({
268-
display: "flex",
269-
alignItems: "center",
270-
justifyContent: "center",
271-
gap: 8,
272-
color: theme.palette.text.disabled,
273-
}),
274-
275172
authorizedInstalls: (theme) => ({
276173
display: "flex",
277174
gap: 4,

0 commit comments

Comments
 (0)