mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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[]> => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+6
@@ -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:
|
||||
<div css={styles.copyCode}>
|
||||
<span css={styles.code}>{externalAuthDevice.user_code}</span>
|
||||
<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:
|
||||
<div css={styles.copyCode}>
|
||||
<span css={styles.code}>{externalAuthDevice.user_code}</span>
|
||||
<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've authenticated with GitHub!</Welcome>
|
||||
|
||||
<p css={styles.text}>
|
||||
If you'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>>;
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user