mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: support the OAuth2 device flow with GitHub for signing in (#16585)
First PR in a series to address https://github.com/coder/coder/issues/16230. Introduces support for logging in via the [GitHub OAuth2 Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow). It's previously been possible to configure external auth with the device flow, but it's not been possible to use it for logging in. This PR builds on the existing support we had to extend it to sign ins. When a user clicks "sign in with GitHub" when device auth is configured, they are redirected to the new `/login/device` page, which makes the flow possible from the client's side. The recording below shows the full flow. https://github.com/user-attachments/assets/90c06f1f-e42f-43e9-a128-462270c80fdd I've also manually tested that it works for converting from password-based auth to oauth. Device auth can be enabled by a deployment's admin by setting the `CODER_OAUTH2_GITHUB_DEVICE_FLOW` env variable or a corresponding config setting.
This commit is contained in:
+29
-2
@@ -677,12 +677,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
}
|
||||
}
|
||||
|
||||
if vals.OAuth2.Github.ClientSecret != "" {
|
||||
if vals.OAuth2.Github.ClientSecret != "" || vals.OAuth2.Github.DeviceFlow.Value() {
|
||||
options.GithubOAuth2Config, err = configureGithubOAuth2(
|
||||
oauthInstrument,
|
||||
vals.AccessURL.Value(),
|
||||
vals.OAuth2.Github.ClientID.String(),
|
||||
vals.OAuth2.Github.ClientSecret.String(),
|
||||
vals.OAuth2.Github.DeviceFlow.Value(),
|
||||
vals.OAuth2.Github.AllowSignups.Value(),
|
||||
vals.OAuth2.Github.AllowEveryone.Value(),
|
||||
vals.OAuth2.Github.AllowedOrgs,
|
||||
@@ -1831,8 +1832,10 @@ func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: convert the argument list to a struct, it's easy to mix up the order of the arguments
|
||||
//
|
||||
//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
|
||||
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
|
||||
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, deviceFlow, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
|
||||
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
|
||||
@@ -1898,6 +1901,17 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
|
||||
return github.NewClient(client), nil
|
||||
}
|
||||
|
||||
var deviceAuth *externalauth.DeviceAuth
|
||||
if deviceFlow {
|
||||
deviceAuth = &externalauth.DeviceAuth{
|
||||
Config: instrumentedOauth,
|
||||
ClientID: clientID,
|
||||
TokenURL: endpoint.TokenURL,
|
||||
Scopes: []string{"read:user", "read:org", "user:email"},
|
||||
CodeURL: endpoint.DeviceAuthURL,
|
||||
}
|
||||
}
|
||||
|
||||
return &coderd.GithubOAuth2Config{
|
||||
OAuth2Config: instrumentedOauth,
|
||||
AllowSignups: allowSignups,
|
||||
@@ -1941,6 +1955,19 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
|
||||
team, _, err := api.Teams.GetTeamMembershipBySlug(ctx, org, teamSlug, username)
|
||||
return team, err
|
||||
},
|
||||
DeviceFlowEnabled: deviceFlow,
|
||||
ExchangeDeviceCode: func(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
|
||||
if !deviceFlow {
|
||||
return nil, xerrors.New("device flow is not enabled")
|
||||
}
|
||||
return deviceAuth.ExchangeDeviceCode(ctx, deviceCode)
|
||||
},
|
||||
AuthorizeDevice: func(ctx context.Context) (*codersdk.ExternalAuthDevice, error) {
|
||||
if !deviceFlow {
|
||||
return nil, xerrors.New("device flow is not enabled")
|
||||
}
|
||||
return deviceAuth.AuthorizeDevice(ctx)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -498,6 +498,9 @@ OAUTH2 / GITHUB OPTIONS:
|
||||
--oauth2-github-client-secret string, $CODER_OAUTH2_GITHUB_CLIENT_SECRET
|
||||
Client secret for Login with GitHub.
|
||||
|
||||
--oauth2-github-device-flow bool, $CODER_OAUTH2_GITHUB_DEVICE_FLOW (default: false)
|
||||
Enable device flow for Login with GitHub.
|
||||
|
||||
--oauth2-github-enterprise-base-url string, $CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL
|
||||
Base URL of a GitHub Enterprise deployment to use for Login with
|
||||
GitHub.
|
||||
|
||||
+3
@@ -262,6 +262,9 @@ oauth2:
|
||||
# Client ID for Login with GitHub.
|
||||
# (default: <unset>, type: string)
|
||||
clientID: ""
|
||||
# Enable device flow for Login with GitHub.
|
||||
# (default: false, type: bool)
|
||||
deviceFlow: false
|
||||
# Organizations the user must be a member of to Login with GitHub.
|
||||
# (default: <unset>, type: string-array)
|
||||
allowedOrgs: []
|
||||
|
||||
Generated
+28
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+24
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1106,6 +1106,7 @@ func New(options *Options) *API {
|
||||
r.Post("/validate-password", api.validateUserPassword)
|
||||
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
|
||||
r.Route("/oauth2", func(r chi.Router) {
|
||||
r.Get("/github/device", api.userOAuth2GithubDevice)
|
||||
r.Route("/github", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil),
|
||||
|
||||
+10
-3
@@ -167,9 +167,16 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOp
|
||||
|
||||
oauthToken, err := config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error exchanging Oauth code.",
|
||||
Detail: err.Error(),
|
||||
errorCode := http.StatusInternalServerError
|
||||
detail := err.Error()
|
||||
if detail == "authorization_pending" {
|
||||
// In the device flow, the token may not be immediately
|
||||
// available. This is expected, and the client will retry.
|
||||
errorCode = http.StatusBadRequest
|
||||
}
|
||||
httpapi.Write(ctx, rw, errorCode, codersdk.Response{
|
||||
Message: "Failed exchanging Oauth code.",
|
||||
Detail: detail,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
+75
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Generated
+1
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+5
@@ -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
|
||||
|
||||
Generated
+35
@@ -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
|
||||
|
||||
Generated
+11
@@ -362,6 +362,17 @@ Client ID for Login with GitHub.
|
||||
|
||||
Client secret for Login with GitHub.
|
||||
|
||||
### --oauth2-github-device-flow
|
||||
|
||||
| | |
|
||||
|-------------|-----------------------------------------------|
|
||||
| Type | <code>bool</code> |
|
||||
| Environment | <code>$CODER_OAUTH2_GITHUB_DEVICE_FLOW</code> |
|
||||
| YAML | <code>oauth2.github.deviceFlow</code> |
|
||||
| Default | <code>false</code> |
|
||||
|
||||
Enable device flow for Login with GitHub.
|
||||
|
||||
### --oauth2-github-allowed-orgs
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1605,6 +1605,29 @@ class ApiMethods {
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
getOAuth2GitHubDeviceFlowCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<TypesGen.OAuth2DeviceFlowCallbackResponse> => {
|
||||
const resp = await this.axios.get(
|
||||
`/api/v2/users/oauth2/github/callback?code=${code}&state=${state}`,
|
||||
);
|
||||
// sanity check
|
||||
if (
|
||||
typeof resp.data !== "object" ||
|
||||
typeof resp.data.redirect_url !== "string"
|
||||
) {
|
||||
console.error("Invalid response from OAuth2 GitHub callback", resp);
|
||||
throw new Error("Invalid response from OAuth2 GitHub callback");
|
||||
}
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
getOAuth2GitHubDevice = async (): Promise<TypesGen.ExternalAuthDevice> => {
|
||||
const resp = await this.axios.get("/api/v2/users/oauth2/github/device");
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
getOAuth2ProviderApps = async (
|
||||
filter?: TypesGen.OAuth2ProviderAppFilter,
|
||||
): Promise<TypesGen.OAuth2ProviderApp[]> => {
|
||||
|
||||
@@ -7,6 +7,20 @@ const userAppsKey = (userId: string) => appsKey.concat(userId);
|
||||
const appKey = (appId: string) => appsKey.concat(appId);
|
||||
const appSecretsKey = (appId: string) => appKey(appId).concat("secrets");
|
||||
|
||||
export const getGitHubDevice = () => {
|
||||
return {
|
||||
queryKey: ["oauth2-provider", "github", "device"],
|
||||
queryFn: () => API.getOAuth2GitHubDevice(),
|
||||
};
|
||||
};
|
||||
|
||||
export const getGitHubDeviceFlowCallback = (code: string, state: string) => {
|
||||
return {
|
||||
queryKey: ["oauth2-provider", "github", "callback", code, state],
|
||||
queryFn: () => API.getOAuth2GitHubDeviceFlowCallback(code, state),
|
||||
};
|
||||
};
|
||||
|
||||
export const getApps = (userId?: string) => {
|
||||
return {
|
||||
queryKey: userId ? appsKey.concat(userId) : appsKey,
|
||||
|
||||
Generated
+6
@@ -1312,10 +1312,16 @@ export interface OAuth2Config {
|
||||
readonly github: OAuth2GithubConfig;
|
||||
}
|
||||
|
||||
// From codersdk/oauth2.go
|
||||
export interface OAuth2DeviceFlowCallbackResponse {
|
||||
readonly redirect_url: string;
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export interface OAuth2GithubConfig {
|
||||
readonly client_id: string;
|
||||
readonly client_secret: string;
|
||||
readonly device_flow: boolean;
|
||||
readonly allowed_orgs: string;
|
||||
readonly allowed_teams: string;
|
||||
readonly allow_signups: boolean;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Link from "@mui/material/Link";
|
||||
import type { ApiErrorResponse } from "api/errors";
|
||||
import type { ExternalAuthDevice } from "api/typesGenerated";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import { CopyButton } from "components/CopyButton/CopyButton";
|
||||
import type { FC } from "react";
|
||||
|
||||
interface GitDeviceAuthProps {
|
||||
externalAuthDevice?: ExternalAuthDevice;
|
||||
deviceExchangeError?: ApiErrorResponse;
|
||||
}
|
||||
|
||||
export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
|
||||
externalAuthDevice,
|
||||
deviceExchangeError,
|
||||
}) => {
|
||||
let status = (
|
||||
<p css={styles.status}>
|
||||
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
|
||||
Checking for authentication...
|
||||
</p>
|
||||
);
|
||||
if (deviceExchangeError) {
|
||||
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
|
||||
switch (deviceExchangeError.detail) {
|
||||
case "authorization_pending":
|
||||
break;
|
||||
case "expired_token":
|
||||
status = (
|
||||
<Alert severity="error">
|
||||
The one-time code has expired. Refresh to get a new one!
|
||||
</Alert>
|
||||
);
|
||||
break;
|
||||
case "access_denied":
|
||||
status = (
|
||||
<Alert severity="error">Access to the Git provider was denied.</Alert>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
status = (
|
||||
<Alert severity="error">
|
||||
<AlertTitle>{deviceExchangeError.message}</AlertTitle>
|
||||
{deviceExchangeError.detail && (
|
||||
<AlertDetail>{deviceExchangeError.detail}</AlertDetail>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the error comes from the `externalAuthDevice` query,
|
||||
// we cannot even display the user_code.
|
||||
if (deviceExchangeError && !externalAuthDevice) {
|
||||
return <div>{status}</div>;
|
||||
}
|
||||
|
||||
if (!externalAuthDevice) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p css={styles.text}>
|
||||
Copy your one-time code:
|
||||
<div css={styles.copyCode}>
|
||||
<span css={styles.code}>{externalAuthDevice.user_code}</span>
|
||||
<CopyButton text={externalAuthDevice.user_code} />
|
||||
</div>
|
||||
<br />
|
||||
Then open the link below and paste it:
|
||||
</p>
|
||||
<div css={styles.links}>
|
||||
<Link
|
||||
css={styles.link}
|
||||
href={externalAuthDevice.verification_uri}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
Open and Paste
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
text: (theme) => ({
|
||||
fontSize: 16,
|
||||
color: theme.palette.text.secondary,
|
||||
textAlign: "center",
|
||||
lineHeight: "160%",
|
||||
margin: 0,
|
||||
}),
|
||||
|
||||
copyCode: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
code: (theme) => ({
|
||||
fontWeight: "bold",
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
|
||||
links: {
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
margin: 16,
|
||||
flexDirection: "column",
|
||||
},
|
||||
|
||||
link: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 16,
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
status: (theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
color: theme.palette.text.disabled,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Link from "@mui/material/Link";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import type { ApiErrorResponse } from "api/errors";
|
||||
import type { ExternalAuth, ExternalAuthDevice } from "api/typesGenerated";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { CopyButton } from "components/CopyButton/CopyButton";
|
||||
import { GitDeviceAuth } from "components/GitDeviceAuth/GitDeviceAuth";
|
||||
import { SignInLayout } from "components/SignInLayout/SignInLayout";
|
||||
import { Welcome } from "components/Welcome/Welcome";
|
||||
import type { FC, ReactNode } from "react";
|
||||
@@ -141,89 +139,6 @@ const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface GitDeviceAuthProps {
|
||||
externalAuthDevice?: ExternalAuthDevice;
|
||||
deviceExchangeError?: ApiErrorResponse;
|
||||
}
|
||||
|
||||
const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
|
||||
externalAuthDevice,
|
||||
deviceExchangeError,
|
||||
}) => {
|
||||
let status = (
|
||||
<p css={styles.status}>
|
||||
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
|
||||
Checking for authentication...
|
||||
</p>
|
||||
);
|
||||
if (deviceExchangeError) {
|
||||
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
|
||||
switch (deviceExchangeError.detail) {
|
||||
case "authorization_pending":
|
||||
break;
|
||||
case "expired_token":
|
||||
status = (
|
||||
<Alert severity="error">
|
||||
The one-time code has expired. Refresh to get a new one!
|
||||
</Alert>
|
||||
);
|
||||
break;
|
||||
case "access_denied":
|
||||
status = (
|
||||
<Alert severity="error">Access to the Git provider was denied.</Alert>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
status = (
|
||||
<Alert severity="error">
|
||||
<AlertTitle>{deviceExchangeError.message}</AlertTitle>
|
||||
{deviceExchangeError.detail && (
|
||||
<AlertDetail>{deviceExchangeError.detail}</AlertDetail>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the error comes from the `externalAuthDevice` query,
|
||||
// we cannot even display the user_code.
|
||||
if (deviceExchangeError && !externalAuthDevice) {
|
||||
return <div>{status}</div>;
|
||||
}
|
||||
|
||||
if (!externalAuthDevice) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p css={styles.text}>
|
||||
Copy your one-time code:
|
||||
<div css={styles.copyCode}>
|
||||
<span css={styles.code}>{externalAuthDevice.user_code}</span>
|
||||
<CopyButton text={externalAuthDevice.user_code} />
|
||||
</div>
|
||||
<br />
|
||||
Then open the link below and paste it:
|
||||
</p>
|
||||
<div css={styles.links}>
|
||||
<Link
|
||||
css={styles.link}
|
||||
href={externalAuthDevice.verification_uri}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
Open and Paste
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalAuthPageView;
|
||||
|
||||
const styles = {
|
||||
@@ -235,16 +150,6 @@ const styles = {
|
||||
margin: 0,
|
||||
}),
|
||||
|
||||
copyCode: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
code: (theme) => ({
|
||||
fontWeight: "bold",
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
|
||||
installAlert: {
|
||||
margin: 16,
|
||||
},
|
||||
@@ -264,14 +169,6 @@ const styles = {
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
status: (theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
color: theme.palette.text.disabled,
|
||||
}),
|
||||
|
||||
authorizedInstalls: (theme) => ({
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ApiErrorResponse } from "api/errors";
|
||||
import {
|
||||
getGitHubDevice,
|
||||
getGitHubDeviceFlowCallback,
|
||||
} from "api/queries/oauth2";
|
||||
import { isAxiosError } from "axios";
|
||||
import { SignInLayout } from "components/SignInLayout/SignInLayout";
|
||||
import { Welcome } from "components/Welcome/Welcome";
|
||||
import { useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import LoginOAuthDevicePageView from "./LoginOAuthDevicePageView";
|
||||
|
||||
const isErrorRetryable = (error: unknown) => {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
return error.response?.data?.detail === "authorization_pending";
|
||||
};
|
||||
|
||||
// The page is hardcoded to only use GitHub,
|
||||
// as that's the only OAuth2 login provider in our backend
|
||||
// that currently supports the device flow.
|
||||
const LoginOAuthDevicePage: FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const state = searchParams.get("state");
|
||||
if (!state) {
|
||||
return (
|
||||
<SignInLayout>
|
||||
<Welcome>Missing OAuth2 state</Welcome>
|
||||
</SignInLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const externalAuthDeviceQuery = useQuery({
|
||||
...getGitHubDevice(),
|
||||
refetchOnMount: false,
|
||||
});
|
||||
const exchangeExternalAuthDeviceQuery = useQuery({
|
||||
...getGitHubDeviceFlowCallback(
|
||||
externalAuthDeviceQuery.data?.device_code ?? "",
|
||||
state,
|
||||
),
|
||||
enabled: Boolean(externalAuthDeviceQuery.data),
|
||||
retry: (_, error) => isErrorRetryable(error),
|
||||
retryDelay: (externalAuthDeviceQuery.data?.interval || 5) * 1000,
|
||||
refetchOnWindowFocus: (query) =>
|
||||
query.state.status === "success" ||
|
||||
(query.state.error != null && !isErrorRetryable(query.state.error))
|
||||
? false
|
||||
: "always",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!exchangeExternalAuthDeviceQuery.isSuccess) {
|
||||
return;
|
||||
}
|
||||
// We use window.location.href in lieu of a navigate hook
|
||||
// because we need to refresh the page after the GitHub
|
||||
// callback query sets a session cookie.
|
||||
window.location.href = exchangeExternalAuthDeviceQuery.data.redirect_url;
|
||||
}, [
|
||||
exchangeExternalAuthDeviceQuery.isSuccess,
|
||||
exchangeExternalAuthDeviceQuery.data?.redirect_url,
|
||||
]);
|
||||
|
||||
let deviceExchangeError: ApiErrorResponse | undefined;
|
||||
if (isAxiosError(exchangeExternalAuthDeviceQuery.failureReason)) {
|
||||
deviceExchangeError =
|
||||
exchangeExternalAuthDeviceQuery.failureReason.response?.data;
|
||||
} else if (isAxiosError(externalAuthDeviceQuery.failureReason)) {
|
||||
deviceExchangeError = externalAuthDeviceQuery.failureReason.response?.data;
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginOAuthDevicePageView
|
||||
authenticated={exchangeExternalAuthDeviceQuery.isSuccess}
|
||||
redirectUrl={exchangeExternalAuthDeviceQuery.data?.redirect_url ?? "/"}
|
||||
deviceExchangeError={deviceExchangeError}
|
||||
externalAuthDevice={externalAuthDeviceQuery.data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginOAuthDevicePage;
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { ApiErrorResponse } from "api/errors";
|
||||
import type { ExternalAuthDevice } from "api/typesGenerated";
|
||||
import { GitDeviceAuth } from "components/GitDeviceAuth/GitDeviceAuth";
|
||||
import { SignInLayout } from "components/SignInLayout/SignInLayout";
|
||||
import { Welcome } from "components/Welcome/Welcome";
|
||||
import type { FC } from "react";
|
||||
|
||||
export interface LoginOAuthDevicePageViewProps {
|
||||
authenticated: boolean;
|
||||
redirectUrl: string;
|
||||
externalAuthDevice?: ExternalAuthDevice;
|
||||
deviceExchangeError?: ApiErrorResponse;
|
||||
}
|
||||
|
||||
const LoginOAuthDevicePageView: FC<LoginOAuthDevicePageViewProps> = ({
|
||||
authenticated,
|
||||
redirectUrl,
|
||||
deviceExchangeError,
|
||||
externalAuthDevice,
|
||||
}) => {
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<SignInLayout>
|
||||
<Welcome>Authenticate with GitHub</Welcome>
|
||||
|
||||
<GitDeviceAuth
|
||||
deviceExchangeError={deviceExchangeError}
|
||||
externalAuthDevice={externalAuthDevice}
|
||||
/>
|
||||
</SignInLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SignInLayout>
|
||||
<Welcome>You've authenticated with GitHub!</Welcome>
|
||||
|
||||
<p css={styles.text}>
|
||||
If you're not redirected automatically,{" "}
|
||||
<a href={redirectUrl}>click here</a>.
|
||||
</p>
|
||||
</SignInLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginOAuthDevicePageView;
|
||||
|
||||
const styles = {
|
||||
text: (theme) => ({
|
||||
fontSize: 16,
|
||||
color: theme.palette.text.secondary,
|
||||
textAlign: "center",
|
||||
lineHeight: "160%",
|
||||
margin: 0,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -13,6 +13,7 @@ import { RequireAuth } from "./contexts/auth/RequireAuth";
|
||||
import { DashboardLayout } from "./modules/dashboard/DashboardLayout";
|
||||
import AuditPage from "./pages/AuditPage/AuditPage";
|
||||
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
|
||||
import LoginOAuthDevicePage from "./pages/LoginOAuthDevicePage/LoginOAuthDevicePage";
|
||||
import LoginPage from "./pages/LoginPage/LoginPage";
|
||||
import { SetupPage } from "./pages/SetupPage/SetupPage";
|
||||
import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout";
|
||||
@@ -373,6 +374,7 @@ export const router = createBrowserRouter(
|
||||
errorElement={<GlobalErrorBoundary />}
|
||||
>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="login/device" element={<LoginOAuthDevicePage />} />
|
||||
<Route path="setup" element={<SetupPage />} />
|
||||
<Route path="reset-password">
|
||||
<Route index element={<RequestOTPPage />} />
|
||||
|
||||
Reference in New Issue
Block a user