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. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy