Skip to content

feat: support the OAuth2 device flow with GitHub for signing in #16585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really make these args a struct soon. Not necessary in this PR, just a ton of options that can be misorderd.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a TODO

redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
if err != nil {
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down
28 changes: 28 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 10 additions & 3 deletions coderd/httpmw/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +172 to +176
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this error comparison correct?

Comparing to the usage in the oauth lib, it is checking the ErrorCode field of the RetreiveError.

Usage: https://github.com/golang/oauth2/blob/master/deviceauth.go#L189-L190
Error type: https://github.com/golang/oauth2/blob/master/token.go#L187-L198

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually don't use the DeviceAccessToken method from the oauth lib. We have a custom implementation of ExchangeDeviceCode which we use for the device flow. That implementation calls codersdk.ReadBodyAsError on an HTTP response it gets from GitHub, which populates the detail field with "authorization_pending". I verified that the check is valid.

For context, we use a custom implementation instead of the lib's DeviceAccessToken because the latter keeps retrying until the code is successfully exchanged. We keep the retry logic on the frontend. That way you don't have an HTTP call that hangs for what can be minutes until the user authenticates with GitHub.

httpapi.Write(ctx, rw, errorCode, codersdk.Response{
Message: "Failed exchanging Oauth code.",
Detail: detail,
})
return
}
Expand Down
76 changes: 75 additions & 1 deletion coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a relative url to our Coder app? Not an external auth url?

Can you just leave a comment that mentions this. I say that becaused AuthCodeURL in a regular Oauth flow is a link to an external domain. So it's not obvious imo.

}

// @Summary Get authentication methods
// @ID get-authentication-methods
// @Security CoderSessionToken
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Comment on lines +1087 to +1090
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

} else {
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
}

type OIDCConfig struct {
Expand Down
87 changes: 87 additions & 0 deletions coderd/userauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just sharing some info. Our oidctest.FakeIDP supports device auth flow.

Here is an example external auth test:

t.Run("WithFakeIDP", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
externalID := "fake-idp"
cfg := fake.ExternalAuthConfig(t, externalID, &oidctest.ExternalAuthConfigOptions{
UseDeviceAuth: true,
})
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{cfg},
})
coderdtest.CreateFirstUser(t, client)
// Login!
fake.DeviceLogin(t, client, externalID)
extAuth, err := client.ExternalAuthByID(context.Background(), externalID)
require.NoError(t, err)
require.True(t, extAuth.Authenticated)
})

So we could write a test against a "live" idp server. This test uses mocked out responses for ExchangeDeviceCode and AuthorizeDevice. It does not test any of the oauth logic behind that.

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
Expand Down
Loading
Loading