From 8c5e7007cd63d56b2eb2cf1fb681672584478673 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Fri, 21 Feb 2025 18:42:16 +0100 Subject: [PATCH] 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. --- cli/server.go | 31 +++- cli/testdata/coder_server_--help.golden | 3 + cli/testdata/server-config.yaml.golden | 3 + coderd/apidoc/docs.go | 28 ++++ coderd/apidoc/swagger.json | 24 ++++ coderd/coderd.go | 1 + coderd/httpmw/oauth2.go | 13 +- coderd/userauth.go | 76 +++++++++- coderd/userauth_test.go | 87 +++++++++++ codersdk/deployment.go | 11 ++ codersdk/oauth2.go | 4 + docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 5 + docs/reference/api/users.md | 35 +++++ docs/reference/cli/server.md | 11 ++ .../cli/testdata/coder_server_--help.golden | 3 + site/src/api/api.ts | 23 +++ site/src/api/queries/oauth2.ts | 14 ++ site/src/api/typesGenerated.ts | 6 + .../GitDeviceAuth/GitDeviceAuth.tsx | 136 ++++++++++++++++++ .../ExternalAuthPage/ExternalAuthPageView.tsx | 107 +------------- .../LoginOAuthDevicePage.tsx | 87 +++++++++++ .../LoginOAuthDevicePageView.tsx | 57 ++++++++ site/src/router.tsx | 2 + 24 files changed, 657 insertions(+), 111 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/cli/server.go b/cli/server.go index 2426bf888e..328dedda7d 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/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 93d9d69517..73ada6a924 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 96a03c5b1f..acfcf9f421 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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 089f98d0f1..227fb12cb7 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 c2e40ac88e..8615223eba 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/coderd/coderd.go b/coderd/coderd.go index 65b943cd3a..1cb4c0592b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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), diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 7afa622d97..49e98da685 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 15eea78b5b..d6931486e6 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/coderd/userauth_test.go b/coderd/userauth_test.go index b0a4dd80ef..b0ada8b9ab 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 diff --git a/codersdk/deployment.go b/codersdk/deployment.go index e1c0b977c0..3aa203da5b 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 726a50907e..bb198d04a6 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"` +} diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 66e85f3f69..5d54993722 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 d13a46ed9b..32805725d2 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 d8aac77cfa..4055a4170b 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 98cb2a90c2..62af563f17 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/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index ebaf1a5ac0..d0437fdff6 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. diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0bdd0cfac8..a1aeeca8a9 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 66547418c8..a124dbd032 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/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 34fe336060..747459ea4e 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; diff --git a/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx b/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx new file mode 100644 index 0000000000..a8391de366 --- /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 5ff3b5a626..fd379bf012 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 0000000000..db7b267a2e --- /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 0000000000..9cdea2ed0a --- /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 85133f7e6e..8490c966c8 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"; @@ -373,6 +374,7 @@ export const router = createBrowserRouter( errorElement={} > } /> + } /> } /> } />