From dd8db0bf5f9c54341f079bf70ffe62b506fb11f9 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Sun, 16 Feb 2025 16:21:16 +0000 Subject: [PATCH 1/5] github oauth2 device flow backend --- cli/server.go | 31 +++++++++++++++-- coderd/coderd.go | 1 + coderd/httpmw/oauth2.go | 13 +++++-- coderd/userauth.go | 76 ++++++++++++++++++++++++++++++++++++++++- codersdk/deployment.go | 11 ++++++ codersdk/oauth2.go | 4 +++ 6 files changed, 130 insertions(+), 6 deletions(-) diff --git a/cli/server.go b/cli/server.go index 103eafcd20da2..01b8f2c4da055 100644 --- a/cli/server.go +++ b/cli/server.go @@ -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 } diff --git a/coderd/coderd.go b/coderd/coderd.go index 93aeb02adb6e3..d7bfcfad12432 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1087,6 +1087,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), diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 7afa622d97af6..49e98da685e0f 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -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 } diff --git a/coderd/userauth.go b/coderd/userauth.go index 15eea78b5bc8c..d6931486e67b9 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -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 { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index e1c0b977c00d2..3aa203da5bd46 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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.", diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go index 726a50907e3fd..bb198d04a6108 100644 --- a/codersdk/oauth2.go +++ b/codersdk/oauth2.go @@ -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"` +} From ce5ffcd17d76d220b3aeac3548eec3a4a2e1cf22 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 19 Feb 2025 16:05:30 +0000 Subject: [PATCH 2/5] backend tests --- coderd/userauth_test.go | 87 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index b0a4dd80efa03..b0ada8b9ab6f5 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -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 From 8a1bef4e91826cefca20ffe22b48bd372e5970d2 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Sun, 16 Feb 2025 17:15:45 +0000 Subject: [PATCH 3/5] github oauth2 device flow frontend --- site/src/api/api.ts | 23 +++ site/src/api/queries/oauth2.ts | 14 ++ .../GitDeviceAuth/GitDeviceAuth.tsx | 136 ++++++++++++++++++ .../ExternalAuthPage/ExternalAuthPageView.tsx | 107 +------------- .../LoginOAuthDevicePage.tsx | 87 +++++++++++ .../LoginOAuthDevicePageView.tsx | 57 ++++++++ site/src/router.tsx | 2 + 7 files changed, 321 insertions(+), 105 deletions(-) create mode 100644 site/src/components/GitDeviceAuth/GitDeviceAuth.tsx create mode 100644 site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePage.tsx create mode 100644 site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePageView.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3da968bd8aa69..f9d11649e3456 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1605,6 +1605,29 @@ class ApiMethods { return resp.data; }; + getOAuth2GitHubDeviceFlowCallback = async ( + code: string, + state: string, + ): Promise => { + 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 => { + const resp = await this.axios.get("/api/v2/users/oauth2/github/device"); + return resp.data; + }; + getOAuth2ProviderApps = async ( filter?: TypesGen.OAuth2ProviderAppFilter, ): Promise => { diff --git a/site/src/api/queries/oauth2.ts b/site/src/api/queries/oauth2.ts index 66547418c8f73..a124dbd032480 100644 --- a/site/src/api/queries/oauth2.ts +++ b/site/src/api/queries/oauth2.ts @@ -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, diff --git a/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx b/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx new file mode 100644 index 0000000000000..a8391de36622c --- /dev/null +++ b/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx @@ -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 = ({ + externalAuthDevice, + deviceExchangeError, +}) => { + let status = ( +

+ + Checking for authentication... +

+ ); + if (deviceExchangeError) { + // See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + switch (deviceExchangeError.detail) { + case "authorization_pending": + break; + case "expired_token": + status = ( + + The one-time code has expired. Refresh to get a new one! + + ); + break; + case "access_denied": + status = ( + Access to the Git provider was denied. + ); + break; + default: + status = ( + + {deviceExchangeError.message} + {deviceExchangeError.detail && ( + {deviceExchangeError.detail} + )} + + ); + break; + } + } + + // If the error comes from the `externalAuthDevice` query, + // we cannot even display the user_code. + if (deviceExchangeError && !externalAuthDevice) { + return
{status}
; + } + + if (!externalAuthDevice) { + return ; + } + + return ( +
+

+ Copy your one-time code:  +

+ {externalAuthDevice.user_code} +   +
+
+ Then open the link below and paste it: +

+
+ + + Open and Paste + +
+ + {status} +
+ ); +}; + +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>; diff --git a/site/src/pages/ExternalAuthPage/ExternalAuthPageView.tsx b/site/src/pages/ExternalAuthPage/ExternalAuthPageView.tsx index 5ff3b5a626b93..fd379bf0121fa 100644 --- a/site/src/pages/ExternalAuthPage/ExternalAuthPageView.tsx +++ b/site/src/pages/ExternalAuthPage/ExternalAuthPageView.tsx @@ -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 = ({ ); }; -interface GitDeviceAuthProps { - externalAuthDevice?: ExternalAuthDevice; - deviceExchangeError?: ApiErrorResponse; -} - -const GitDeviceAuth: FC = ({ - externalAuthDevice, - deviceExchangeError, -}) => { - let status = ( -

- - Checking for authentication... -

- ); - if (deviceExchangeError) { - // See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 - switch (deviceExchangeError.detail) { - case "authorization_pending": - break; - case "expired_token": - status = ( - - The one-time code has expired. Refresh to get a new one! - - ); - break; - case "access_denied": - status = ( - Access to the Git provider was denied. - ); - break; - default: - status = ( - - {deviceExchangeError.message} - {deviceExchangeError.detail && ( - {deviceExchangeError.detail} - )} - - ); - break; - } - } - - // If the error comes from the `externalAuthDevice` query, - // we cannot even display the user_code. - if (deviceExchangeError && !externalAuthDevice) { - return
{status}
; - } - - if (!externalAuthDevice) { - return ; - } - - return ( -
-

- Copy your one-time code:  -

- {externalAuthDevice.user_code} -   -
-
- Then open the link below and paste it: -

-
- - - Open and Paste - -
- - {status} -
- ); -}; - 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, diff --git a/site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePage.tsx b/site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePage.tsx new file mode 100644 index 0000000000000..db7b267a2e99a --- /dev/null +++ b/site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePage.tsx @@ -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 ( + + Missing OAuth2 state + + ); + } + + 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 ( + + ); +}; + +export default LoginOAuthDevicePage; diff --git a/site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePageView.tsx b/site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePageView.tsx new file mode 100644 index 0000000000000..9cdea2ed0aacb --- /dev/null +++ b/site/src/pages/LoginOAuthDevicePage/LoginOAuthDevicePageView.tsx @@ -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 = ({ + authenticated, + redirectUrl, + deviceExchangeError, + externalAuthDevice, +}) => { + if (!authenticated) { + return ( + + Authenticate with GitHub + + + + ); + } + + return ( + + You've authenticated with GitHub! + +

+ If you're not redirected automatically,{" "} + click here. +

+
+ ); +}; + +export default LoginOAuthDevicePageView; + +const styles = { + text: (theme) => ({ + fontSize: 16, + color: theme.palette.text.secondary, + textAlign: "center", + lineHeight: "160%", + margin: 0, + }), +} satisfies Record>; diff --git a/site/src/router.tsx b/site/src/router.tsx index 7e7776eeecf18..eba5fe7fcccb1 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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"; @@ -369,6 +370,7 @@ export const router = createBrowserRouter( errorElement={} > } /> + } /> } /> } /> From 21a9205e2744eca4c640e09c40ee1ed4aa701816 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 17 Feb 2025 12:55:01 +0000 Subject: [PATCH 4/5] make gen --- coderd/apidoc/docs.go | 28 +++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 24 +++++++++++++++++++++++ docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 5 +++++ docs/reference/api/users.md | 35 ++++++++++++++++++++++++++++++++++ docs/reference/cli/server.md | 11 +++++++++++ site/src/api/typesGenerated.ts | 6 ++++++ 7 files changed, 110 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 089f98d0f1f49..227fb12cb70f9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c2e40ac88ebdf..8615223ebaf74 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" } diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 66e85f3f6978a..5d54993722f4b 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -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" } }, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d13a46ed9b365..32805725d2d29 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index d8aac77cfa83b..4055a4170baa5 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -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 diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 98cb2a90c20da..62af563f17ad1 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -362,6 +362,17 @@ Client ID for Login with GitHub. Client secret for Login with GitHub. +### --oauth2-github-device-flow + +| | | +|-------------|-----------------------------------------------| +| Type | bool | +| Environment | $CODER_OAUTH2_GITHUB_DEVICE_FLOW | +| YAML | oauth2.github.deviceFlow | +| Default | false | + +Enable device flow for Login with GitHub. + ### --oauth2-github-allowed-orgs | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 34fe3360601af..747459ea4efb9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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; From cfc364520f1f5388ec18390cefa4e284cc8dd61d Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 17 Feb 2025 18:30:55 +0000 Subject: [PATCH 5/5] golden files --- cli/testdata/coder_server_--help.golden | 3 +++ cli/testdata/server-config.yaml.golden | 3 +++ enterprise/cli/testdata/coder_server_--help.golden | 3 +++ 3 files changed, 9 insertions(+) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 93d9d69517ec9..73ada6a92445d 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -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. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 96a03c5b1f05e..acfcf9f421e13 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -262,6 +262,9 @@ oauth2: # Client ID for Login with GitHub. # (default: , 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: , type: string-array) allowedOrgs: [] diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index ebaf1a5ac0bbd..d0437fdff6ad3 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -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.