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
+29 -2
View File
@@ -677,12 +677,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
}
if vals.OAuth2.Github.ClientSecret != "" {
if vals.OAuth2.Github.ClientSecret != "" || vals.OAuth2.Github.DeviceFlow.Value() {
options.GithubOAuth2Config, err = configureGithubOAuth2(
oauthInstrument,
vals.AccessURL.Value(),
vals.OAuth2.Github.ClientID.String(),
vals.OAuth2.Github.ClientSecret.String(),
vals.OAuth2.Github.DeviceFlow.Value(),
vals.OAuth2.Github.AllowSignups.Value(),
vals.OAuth2.Github.AllowEveryone.Value(),
vals.OAuth2.Github.AllowedOrgs,
@@ -1831,8 +1832,10 @@ func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
return nil
}
// TODO: convert the argument list to a struct, it's easy to mix up the order of the arguments
//
//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, deviceFlow, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
if err != nil {
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
@@ -1898,6 +1901,17 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
return github.NewClient(client), nil
}
var deviceAuth *externalauth.DeviceAuth
if deviceFlow {
deviceAuth = &externalauth.DeviceAuth{
Config: instrumentedOauth,
ClientID: clientID,
TokenURL: endpoint.TokenURL,
Scopes: []string{"read:user", "read:org", "user:email"},
CodeURL: endpoint.DeviceAuthURL,
}
}
return &coderd.GithubOAuth2Config{
OAuth2Config: instrumentedOauth,
AllowSignups: allowSignups,
@@ -1941,6 +1955,19 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
team, _, err := api.Teams.GetTeamMembershipBySlug(ctx, org, teamSlug, username)
return team, err
},
DeviceFlowEnabled: deviceFlow,
ExchangeDeviceCode: func(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
if !deviceFlow {
return nil, xerrors.New("device flow is not enabled")
}
return deviceAuth.ExchangeDeviceCode(ctx, deviceCode)
},
AuthorizeDevice: func(ctx context.Context) (*codersdk.ExternalAuthDevice, error) {
if !deviceFlow {
return nil, xerrors.New("device flow is not enabled")
}
return deviceAuth.AuthorizeDevice(ctx)
},
}, nil
}
+3
View File
@@ -498,6 +498,9 @@ OAUTH2 / GITHUB OPTIONS:
--oauth2-github-client-secret string, $CODER_OAUTH2_GITHUB_CLIENT_SECRET
Client secret for Login with GitHub.
--oauth2-github-device-flow bool, $CODER_OAUTH2_GITHUB_DEVICE_FLOW (default: false)
Enable device flow for Login with GitHub.
--oauth2-github-enterprise-base-url string, $CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL
Base URL of a GitHub Enterprise deployment to use for Login with
GitHub.
+3
View File
@@ -262,6 +262,9 @@ oauth2:
# Client ID for Login with GitHub.
# (default: <unset>, type: string)
clientID: ""
# Enable device flow for Login with GitHub.
# (default: false, type: bool)
deviceFlow: false
# Organizations the user must be a member of to Login with GitHub.
# (default: <unset>, type: string-array)
allowedOrgs: []
+28
View File
@@ -6167,6 +6167,31 @@ const docTemplate = `{
}
}
},
"/users/oauth2/github/device": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get Github device auth.",
"operationId": "get-github-device-auth",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ExternalAuthDevice"
}
}
}
}
},
"/users/oidc/callback": {
"get": {
"security": [
@@ -12494,6 +12519,9 @@ const docTemplate = `{
"client_secret": {
"type": "string"
},
"device_flow": {
"type": "boolean"
},
"enterprise_base_url": {
"type": "string"
}
+24
View File
@@ -5449,6 +5449,27 @@
}
}
},
"/users/oauth2/github/device": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Get Github device auth.",
"operationId": "get-github-device-auth",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ExternalAuthDevice"
}
}
}
}
},
"/users/oidc/callback": {
"get": {
"security": [
@@ -11234,6 +11255,9 @@
"client_secret": {
"type": "string"
},
"device_flow": {
"type": "boolean"
},
"enterprise_base_url": {
"type": "string"
}
+1
View File
@@ -1106,6 +1106,7 @@ func New(options *Options) *API {
r.Post("/validate-password", api.validateUserPassword)
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
r.Route("/oauth2", func(r chi.Router) {
r.Get("/github/device", api.userOAuth2GithubDevice)
r.Route("/github", func(r chi.Router) {
r.Use(
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil),
+10 -3
View File
@@ -167,9 +167,16 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOp
oauthToken, err := config.Exchange(ctx, code)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error exchanging Oauth code.",
Detail: err.Error(),
errorCode := http.StatusInternalServerError
detail := err.Error()
if detail == "authorization_pending" {
// In the device flow, the token may not be immediately
// available. This is expected, and the client will retry.
errorCode = http.StatusBadRequest
}
httpapi.Write(ctx, rw, errorCode, codersdk.Response{
Message: "Failed exchanging Oauth code.",
Detail: detail,
})
return
}
+75 -1
View File
@@ -748,12 +748,32 @@ type GithubOAuth2Config struct {
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
TeamMembership func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error)
DeviceFlowEnabled bool
ExchangeDeviceCode func(ctx context.Context, deviceCode string) (*oauth2.Token, error)
AuthorizeDevice func(ctx context.Context) (*codersdk.ExternalAuthDevice, error)
AllowSignups bool
AllowEveryone bool
AllowOrganizations []string
AllowTeams []GithubOAuth2Team
}
func (c *GithubOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
if !c.DeviceFlowEnabled {
return c.OAuth2Config.Exchange(ctx, code, opts...)
}
return c.ExchangeDeviceCode(ctx, code)
}
func (c *GithubOAuth2Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
if !c.DeviceFlowEnabled {
return c.OAuth2Config.AuthCodeURL(state, opts...)
}
// This is an absolute path in the Coder app. The device flow is orchestrated
// by the Coder frontend, so we need to redirect the user to the device flow page.
return "/login/device?state=" + state
}
// @Summary Get authentication methods
// @ID get-authentication-methods
// @Security CoderSessionToken
@@ -786,6 +806,53 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
})
}
// @Summary Get Github device auth.
// @ID get-github-device-auth
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Success 200 {object} codersdk.ExternalAuthDevice
// @Router /users/oauth2/github/device [get]
func (api *API) userOAuth2GithubDevice(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionLogin,
})
)
aReq.Old = database.APIKey{}
defer commitAudit()
if api.GithubOAuth2Config == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Github OAuth2 is not enabled.",
})
return
}
if !api.GithubOAuth2Config.DeviceFlowEnabled {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Device flow is not enabled for Github OAuth2.",
})
return
}
deviceAuth, err := api.GithubOAuth2Config.AuthorizeDevice(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to authorize device.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, deviceAuth)
}
// @Summary OAuth 2.0 GitHub Callback
// @ID oauth-20-github-callback
// @Security CoderSessionToken
@@ -1016,7 +1083,14 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
}
redirect = uriFromURL(redirect)
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
if api.GithubOAuth2Config.DeviceFlowEnabled {
// In the device flow, the redirect is handled client-side.
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{
RedirectURL: redirect,
})
} else {
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
}
type OIDCConfig struct {
+87
View File
@@ -22,6 +22,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"cdr.dev/slog"
@@ -882,6 +883,92 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created")
require.Equal(t, user.Email, newEmail)
})
t.Run("DeviceFlow", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
AllowSignups: true,
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("testuser"),
Name: github.String("The Right Honorable Sir Test McUser"),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("testuser@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
DeviceFlowEnabled: true,
ExchangeDeviceCode: func(_ context.Context, _ string) (*oauth2.Token, error) {
return &oauth2.Token{
AccessToken: "access_token",
RefreshToken: "refresh_token",
Expiry: time.Now().Add(time.Hour),
}, nil
},
AuthorizeDevice: func(_ context.Context) (*codersdk.ExternalAuthDevice, error) {
return &codersdk.ExternalAuthDevice{
DeviceCode: "device_code",
UserCode: "user_code",
}, nil
},
},
})
client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
// Ensure that we redirect to the device login page when the user is not logged in.
oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback")
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
require.Equal(t, "/login/device", location.Path)
query := location.Query()
require.NotEmpty(t, query.Get("state"))
// Ensure that we return a JSON response when the code is successfully exchanged.
oauthURL, err = client.URL.Parse("/api/v2/users/oauth2/github/callback?code=hey&state=somestate")
require.NoError(t, err)
req, err = http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
req.AddCookie(&http.Cookie{
Name: "oauth_state",
Value: "somestate",
})
require.NoError(t, err)
res, err = client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var resp codersdk.OAuth2DeviceFlowCallbackResponse
require.NoError(t, json.NewDecoder(res.Body).Decode(&resp))
require.Equal(t, "/", resp.RedirectURL)
})
}
// nolint:bodyclose
+11
View File
@@ -505,6 +505,7 @@ type OAuth2Config struct {
type OAuth2GithubConfig struct {
ClientID serpent.String `json:"client_id" typescript:",notnull"`
ClientSecret serpent.String `json:"client_secret" typescript:",notnull"`
DeviceFlow serpent.Bool `json:"device_flow" typescript:",notnull"`
AllowedOrgs serpent.StringArray `json:"allowed_orgs" typescript:",notnull"`
AllowedTeams serpent.StringArray `json:"allowed_teams" typescript:",notnull"`
AllowSignups serpent.Bool `json:"allow_signups" typescript:",notnull"`
@@ -1572,6 +1573,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"),
Group: &deploymentGroupOAuth2GitHub,
},
{
Name: "OAuth2 GitHub Device Flow",
Description: "Enable device flow for Login with GitHub.",
Flag: "oauth2-github-device-flow",
Env: "CODER_OAUTH2_GITHUB_DEVICE_FLOW",
Value: &c.OAuth2.Github.DeviceFlow,
Group: &deploymentGroupOAuth2GitHub,
YAML: "deviceFlow",
Default: "false",
},
{
Name: "OAuth2 GitHub Allowed Orgs",
Description: "Organizations the user must be a member of to Login with GitHub.",
+4
View File
@@ -227,3 +227,7 @@ func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) e
}
return nil
}
type OAuth2DeviceFlowCallbackResponse struct {
RedirectURL string `json:"redirect_url"`
}
+1
View File
@@ -328,6 +328,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
],
"client_id": "string",
"client_secret": "string",
"device_flow": true,
"enterprise_base_url": "string"
}
},
+5
View File
@@ -1977,6 +1977,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
],
"client_id": "string",
"client_secret": "string",
"device_flow": true,
"enterprise_base_url": "string"
}
},
@@ -2447,6 +2448,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
],
"client_id": "string",
"client_secret": "string",
"device_flow": true,
"enterprise_base_url": "string"
}
},
@@ -3803,6 +3805,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
],
"client_id": "string",
"client_secret": "string",
"device_flow": true,
"enterprise_base_url": "string"
}
}
@@ -3828,6 +3831,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
],
"client_id": "string",
"client_secret": "string",
"device_flow": true,
"enterprise_base_url": "string"
}
```
@@ -3842,6 +3846,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `allowed_teams` | array of string | false | | |
| `client_id` | string | false | | |
| `client_secret` | string | false | | |
| `device_flow` | boolean | false | | |
| `enterprise_base_url` | string | false | | |
## codersdk.OAuth2ProviderApp
+35
View File
@@ -337,6 +337,41 @@ curl -X GET http://coder-server:8080/api/v2/users/oauth2/github/callback \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get Github device auth
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/oauth2/github/device \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/oauth2/github/device`
### Example responses
> 200 Response
```json
{
"device_code": "string",
"expires_in": 0,
"interval": 0,
"user_code": "string",
"verification_uri": "string"
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAuthDevice](schemas.md#codersdkexternalauthdevice) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## OpenID Connect Callback
### Code samples
+11
View File
@@ -362,6 +362,17 @@ Client ID for Login with GitHub.
Client secret for Login with GitHub.
### --oauth2-github-device-flow
| | |
|-------------|-----------------------------------------------|
| Type | <code>bool</code> |
| Environment | <code>$CODER_OAUTH2_GITHUB_DEVICE_FLOW</code> |
| YAML | <code>oauth2.github.deviceFlow</code> |
| Default | <code>false</code> |
Enable device flow for Login with GitHub.
### --oauth2-github-allowed-orgs
| | |
+3
View File
@@ -499,6 +499,9 @@ OAUTH2 / GITHUB OPTIONS:
--oauth2-github-client-secret string, $CODER_OAUTH2_GITHUB_CLIENT_SECRET
Client secret for Login with GitHub.
--oauth2-github-device-flow bool, $CODER_OAUTH2_GITHUB_DEVICE_FLOW (default: false)
Enable device flow for Login with GitHub.
--oauth2-github-enterprise-base-url string, $CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL
Base URL of a GitHub Enterprise deployment to use for Login with
GitHub.
+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 />} />