feat: support the OAuth2 device flow with GitHub for signing in (#16585)

First PR in a series to address
https://github.com/coder/coder/issues/16230.

Introduces support for logging in via the [GitHub OAuth2 Device
Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow).

It's previously been possible to configure external auth with the device
flow, but it's not been possible to use it for logging in. This PR
builds on the existing support we had to extend it to sign ins.

When a user clicks "sign in with GitHub" when device auth is configured,
they are redirected to the new `/login/device` page, which makes the
flow possible from the client's side. The recording below shows the full
flow.


https://github.com/user-attachments/assets/90c06f1f-e42f-43e9-a128-462270c80fdd

I've also manually tested that it works for converting from
password-based auth to oauth.

Device auth can be enabled by a deployment's admin by setting the
`CODER_OAUTH2_GITHUB_DEVICE_FLOW` env variable or a corresponding config
setting.
This commit is contained in:
Hugo Dutka
2025-02-21 18:42:16 +01:00
committed by GitHub
parent 660746462e
commit 8c5e7007cd
24 changed files with 657 additions and 111 deletions
+23
View File
@@ -1605,6 +1605,29 @@ class ApiMethods {
return resp.data;
};
getOAuth2GitHubDeviceFlowCallback = async (
code: string,
state: string,
): Promise<TypesGen.OAuth2DeviceFlowCallbackResponse> => {
const resp = await this.axios.get(
`/api/v2/users/oauth2/github/callback?code=${code}&state=${state}`,
);
// sanity check
if (
typeof resp.data !== "object" ||
typeof resp.data.redirect_url !== "string"
) {
console.error("Invalid response from OAuth2 GitHub callback", resp);
throw new Error("Invalid response from OAuth2 GitHub callback");
}
return resp.data;
};
getOAuth2GitHubDevice = async (): Promise<TypesGen.ExternalAuthDevice> => {
const resp = await this.axios.get("/api/v2/users/oauth2/github/device");
return resp.data;
};
getOAuth2ProviderApps = async (
filter?: TypesGen.OAuth2ProviderAppFilter,
): Promise<TypesGen.OAuth2ProviderApp[]> => {
+14
View File
@@ -7,6 +7,20 @@ const userAppsKey = (userId: string) => appsKey.concat(userId);
const appKey = (appId: string) => appsKey.concat(appId);
const appSecretsKey = (appId: string) => appKey(appId).concat("secrets");
export const getGitHubDevice = () => {
return {
queryKey: ["oauth2-provider", "github", "device"],
queryFn: () => API.getOAuth2GitHubDevice(),
};
};
export const getGitHubDeviceFlowCallback = (code: string, state: string) => {
return {
queryKey: ["oauth2-provider", "github", "callback", code, state],
queryFn: () => API.getOAuth2GitHubDeviceFlowCallback(code, state),
};
};
export const getApps = (userId?: string) => {
return {
queryKey: userId ? appsKey.concat(userId) : appsKey,
+6
View File
@@ -1312,10 +1312,16 @@ export interface OAuth2Config {
readonly github: OAuth2GithubConfig;
}
// From codersdk/oauth2.go
export interface OAuth2DeviceFlowCallbackResponse {
readonly redirect_url: string;
}
// From codersdk/deployment.go
export interface OAuth2GithubConfig {
readonly client_id: string;
readonly client_secret: string;
readonly device_flow: boolean;
readonly allowed_orgs: string;
readonly allowed_teams: string;
readonly allow_signups: boolean;
@@ -0,0 +1,136 @@
import type { Interpolation, Theme } from "@emotion/react";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import AlertTitle from "@mui/material/AlertTitle";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import type { ApiErrorResponse } from "api/errors";
import type { ExternalAuthDevice } from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { CopyButton } from "components/CopyButton/CopyButton";
import type { FC } from "react";
interface GitDeviceAuthProps {
externalAuthDevice?: ExternalAuthDevice;
deviceExchangeError?: ApiErrorResponse;
}
export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
externalAuthDevice,
deviceExchangeError,
}) => {
let status = (
<p css={styles.status}>
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
Checking for authentication...
</p>
);
if (deviceExchangeError) {
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
switch (deviceExchangeError.detail) {
case "authorization_pending":
break;
case "expired_token":
status = (
<Alert severity="error">
The one-time code has expired. Refresh to get a new one!
</Alert>
);
break;
case "access_denied":
status = (
<Alert severity="error">Access to the Git provider was denied.</Alert>
);
break;
default:
status = (
<Alert severity="error">
<AlertTitle>{deviceExchangeError.message}</AlertTitle>
{deviceExchangeError.detail && (
<AlertDetail>{deviceExchangeError.detail}</AlertDetail>
)}
</Alert>
);
break;
}
}
// If the error comes from the `externalAuthDevice` query,
// we cannot even display the user_code.
if (deviceExchangeError && !externalAuthDevice) {
return <div>{status}</div>;
}
if (!externalAuthDevice) {
return <CircularProgress />;
}
return (
<div>
<p css={styles.text}>
Copy your one-time code:&nbsp;
<div css={styles.copyCode}>
<span css={styles.code}>{externalAuthDevice.user_code}</span>
&nbsp; <CopyButton text={externalAuthDevice.user_code} />
</div>
<br />
Then open the link below and paste it:
</p>
<div css={styles.links}>
<Link
css={styles.link}
href={externalAuthDevice.verification_uri}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon fontSize="small" />
Open and Paste
</Link>
</div>
{status}
</div>
);
};
const styles = {
text: (theme) => ({
fontSize: 16,
color: theme.palette.text.secondary,
textAlign: "center",
lineHeight: "160%",
margin: 0,
}),
copyCode: {
display: "inline-flex",
alignItems: "center",
},
code: (theme) => ({
fontWeight: "bold",
color: theme.palette.text.primary,
}),
links: {
display: "flex",
gap: 4,
margin: 16,
flexDirection: "column",
},
link: {
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
gap: 8,
},
status: (theme) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
color: theme.palette.text.disabled,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -1,15 +1,13 @@
import type { Interpolation, Theme } from "@emotion/react";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RefreshIcon from "@mui/icons-material/Refresh";
import AlertTitle from "@mui/material/AlertTitle";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import type { ApiErrorResponse } from "api/errors";
import type { ExternalAuth, ExternalAuthDevice } from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { Alert } from "components/Alert/Alert";
import { Avatar } from "components/Avatar/Avatar";
import { CopyButton } from "components/CopyButton/CopyButton";
import { GitDeviceAuth } from "components/GitDeviceAuth/GitDeviceAuth";
import { SignInLayout } from "components/SignInLayout/SignInLayout";
import { Welcome } from "components/Welcome/Welcome";
import type { FC, ReactNode } from "react";
@@ -141,89 +139,6 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
);
};
interface GitDeviceAuthProps {
externalAuthDevice?: ExternalAuthDevice;
deviceExchangeError?: ApiErrorResponse;
}
const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
externalAuthDevice,
deviceExchangeError,
}) => {
let status = (
<p css={styles.status}>
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
Checking for authentication...
</p>
);
if (deviceExchangeError) {
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
switch (deviceExchangeError.detail) {
case "authorization_pending":
break;
case "expired_token":
status = (
<Alert severity="error">
The one-time code has expired. Refresh to get a new one!
</Alert>
);
break;
case "access_denied":
status = (
<Alert severity="error">Access to the Git provider was denied.</Alert>
);
break;
default:
status = (
<Alert severity="error">
<AlertTitle>{deviceExchangeError.message}</AlertTitle>
{deviceExchangeError.detail && (
<AlertDetail>{deviceExchangeError.detail}</AlertDetail>
)}
</Alert>
);
break;
}
}
// If the error comes from the `externalAuthDevice` query,
// we cannot even display the user_code.
if (deviceExchangeError && !externalAuthDevice) {
return <div>{status}</div>;
}
if (!externalAuthDevice) {
return <CircularProgress />;
}
return (
<div>
<p css={styles.text}>
Copy your one-time code:&nbsp;
<div css={styles.copyCode}>
<span css={styles.code}>{externalAuthDevice.user_code}</span>
&nbsp; <CopyButton text={externalAuthDevice.user_code} />
</div>
<br />
Then open the link below and paste it:
</p>
<div css={styles.links}>
<Link
css={styles.link}
href={externalAuthDevice.verification_uri}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon fontSize="small" />
Open and Paste
</Link>
</div>
{status}
</div>
);
};
export default ExternalAuthPageView;
const styles = {
@@ -235,16 +150,6 @@ const styles = {
margin: 0,
}),
copyCode: {
display: "inline-flex",
alignItems: "center",
},
code: (theme) => ({
fontWeight: "bold",
color: theme.palette.text.primary,
}),
installAlert: {
margin: 16,
},
@@ -264,14 +169,6 @@ const styles = {
gap: 8,
},
status: (theme) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
color: theme.palette.text.disabled,
}),
authorizedInstalls: (theme) => ({
display: "flex",
gap: 4,
@@ -0,0 +1,87 @@
import type { ApiErrorResponse } from "api/errors";
import {
getGitHubDevice,
getGitHubDeviceFlowCallback,
} from "api/queries/oauth2";
import { isAxiosError } from "axios";
import { SignInLayout } from "components/SignInLayout/SignInLayout";
import { Welcome } from "components/Welcome/Welcome";
import { useEffect } from "react";
import type { FC } from "react";
import { useQuery } from "react-query";
import { useSearchParams } from "react-router-dom";
import LoginOAuthDevicePageView from "./LoginOAuthDevicePageView";
const isErrorRetryable = (error: unknown) => {
if (!isAxiosError(error)) {
return false;
}
return error.response?.data?.detail === "authorization_pending";
};
// The page is hardcoded to only use GitHub,
// as that's the only OAuth2 login provider in our backend
// that currently supports the device flow.
const LoginOAuthDevicePage: FC = () => {
const [searchParams] = useSearchParams();
const state = searchParams.get("state");
if (!state) {
return (
<SignInLayout>
<Welcome>Missing OAuth2 state</Welcome>
</SignInLayout>
);
}
const externalAuthDeviceQuery = useQuery({
...getGitHubDevice(),
refetchOnMount: false,
});
const exchangeExternalAuthDeviceQuery = useQuery({
...getGitHubDeviceFlowCallback(
externalAuthDeviceQuery.data?.device_code ?? "",
state,
),
enabled: Boolean(externalAuthDeviceQuery.data),
retry: (_, error) => isErrorRetryable(error),
retryDelay: (externalAuthDeviceQuery.data?.interval || 5) * 1000,
refetchOnWindowFocus: (query) =>
query.state.status === "success" ||
(query.state.error != null && !isErrorRetryable(query.state.error))
? false
: "always",
});
useEffect(() => {
if (!exchangeExternalAuthDeviceQuery.isSuccess) {
return;
}
// We use window.location.href in lieu of a navigate hook
// because we need to refresh the page after the GitHub
// callback query sets a session cookie.
window.location.href = exchangeExternalAuthDeviceQuery.data.redirect_url;
}, [
exchangeExternalAuthDeviceQuery.isSuccess,
exchangeExternalAuthDeviceQuery.data?.redirect_url,
]);
let deviceExchangeError: ApiErrorResponse | undefined;
if (isAxiosError(exchangeExternalAuthDeviceQuery.failureReason)) {
deviceExchangeError =
exchangeExternalAuthDeviceQuery.failureReason.response?.data;
} else if (isAxiosError(externalAuthDeviceQuery.failureReason)) {
deviceExchangeError = externalAuthDeviceQuery.failureReason.response?.data;
}
return (
<LoginOAuthDevicePageView
authenticated={exchangeExternalAuthDeviceQuery.isSuccess}
redirectUrl={exchangeExternalAuthDeviceQuery.data?.redirect_url ?? "/"}
deviceExchangeError={deviceExchangeError}
externalAuthDevice={externalAuthDeviceQuery.data}
/>
);
};
export default LoginOAuthDevicePage;
@@ -0,0 +1,57 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { ApiErrorResponse } from "api/errors";
import type { ExternalAuthDevice } from "api/typesGenerated";
import { GitDeviceAuth } from "components/GitDeviceAuth/GitDeviceAuth";
import { SignInLayout } from "components/SignInLayout/SignInLayout";
import { Welcome } from "components/Welcome/Welcome";
import type { FC } from "react";
export interface LoginOAuthDevicePageViewProps {
authenticated: boolean;
redirectUrl: string;
externalAuthDevice?: ExternalAuthDevice;
deviceExchangeError?: ApiErrorResponse;
}
const LoginOAuthDevicePageView: FC<LoginOAuthDevicePageViewProps> = ({
authenticated,
redirectUrl,
deviceExchangeError,
externalAuthDevice,
}) => {
if (!authenticated) {
return (
<SignInLayout>
<Welcome>Authenticate with GitHub</Welcome>
<GitDeviceAuth
deviceExchangeError={deviceExchangeError}
externalAuthDevice={externalAuthDevice}
/>
</SignInLayout>
);
}
return (
<SignInLayout>
<Welcome>You&apos;ve authenticated with GitHub!</Welcome>
<p css={styles.text}>
If you&apos;re not redirected automatically,{" "}
<a href={redirectUrl}>click here</a>.
</p>
</SignInLayout>
);
};
export default LoginOAuthDevicePageView;
const styles = {
text: (theme) => ({
fontSize: 16,
color: theme.palette.text.secondary,
textAlign: "center",
lineHeight: "160%",
margin: 0,
}),
} satisfies Record<string, Interpolation<Theme>>;
+2
View File
@@ -13,6 +13,7 @@ import { RequireAuth } from "./contexts/auth/RequireAuth";
import { DashboardLayout } from "./modules/dashboard/DashboardLayout";
import AuditPage from "./pages/AuditPage/AuditPage";
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
import LoginOAuthDevicePage from "./pages/LoginOAuthDevicePage/LoginOAuthDevicePage";
import LoginPage from "./pages/LoginPage/LoginPage";
import { SetupPage } from "./pages/SetupPage/SetupPage";
import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout";
@@ -373,6 +374,7 @@ export const router = createBrowserRouter(
errorElement={<GlobalErrorBoundary />}
>
<Route path="login" element={<LoginPage />} />
<Route path="login/device" element={<LoginOAuthDevicePage />} />
<Route path="setup" element={<SetupPage />} />
<Route path="reset-password">
<Route index element={<RequestOTPPage />} />