Skip to content

Commit 8c5e700

Browse files
authored
feat: support the OAuth2 device flow with GitHub for signing in (#16585)
First PR in a series to address #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.
1 parent 6607464 commit 8c5e700

24 files changed

+657
-111
lines changed

cli/server.go

+29-2
Original file line numberDiff line numberDiff line change
@@ -677,12 +677,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
677677
}
678678
}
679679

680-
if vals.OAuth2.Github.ClientSecret != "" {
680+
if vals.OAuth2.Github.ClientSecret != "" || vals.OAuth2.Github.DeviceFlow.Value() {
681681
options.GithubOAuth2Config, err = configureGithubOAuth2(
682682
oauthInstrument,
683683
vals.AccessURL.Value(),
684684
vals.OAuth2.Github.ClientID.String(),
685685
vals.OAuth2.Github.ClientSecret.String(),
686+
vals.OAuth2.Github.DeviceFlow.Value(),
686687
vals.OAuth2.Github.AllowSignups.Value(),
687688
vals.OAuth2.Github.AllowEveryone.Value(),
688689
vals.OAuth2.Github.AllowedOrgs,
@@ -1831,8 +1832,10 @@ func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
18311832
return nil
18321833
}
18331834

1835+
// TODO: convert the argument list to a struct, it's easy to mix up the order of the arguments
1836+
//
18341837
//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
1835-
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
1838+
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, deviceFlow, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
18361839
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
18371840
if err != nil {
18381841
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
@@ -1898,6 +1901,17 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
18981901
return github.NewClient(client), nil
18991902
}
19001903

1904+
var deviceAuth *externalauth.DeviceAuth
1905+
if deviceFlow {
1906+
deviceAuth = &externalauth.DeviceAuth{
1907+
Config: instrumentedOauth,
1908+
ClientID: clientID,
1909+
TokenURL: endpoint.TokenURL,
1910+
Scopes: []string{"read:user", "read:org", "user:email"},
1911+
CodeURL: endpoint.DeviceAuthURL,
1912+
}
1913+
}
1914+
19011915
return &coderd.GithubOAuth2Config{
19021916
OAuth2Config: instrumentedOauth,
19031917
AllowSignups: allowSignups,
@@ -1941,6 +1955,19 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
19411955
team, _, err := api.Teams.GetTeamMembershipBySlug(ctx, org, teamSlug, username)
19421956
return team, err
19431957
},
1958+
DeviceFlowEnabled: deviceFlow,
1959+
ExchangeDeviceCode: func(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
1960+
if !deviceFlow {
1961+
return nil, xerrors.New("device flow is not enabled")
1962+
}
1963+
return deviceAuth.ExchangeDeviceCode(ctx, deviceCode)
1964+
},
1965+
AuthorizeDevice: func(ctx context.Context) (*codersdk.ExternalAuthDevice, error) {
1966+
if !deviceFlow {
1967+
return nil, xerrors.New("device flow is not enabled")
1968+
}
1969+
return deviceAuth.AuthorizeDevice(ctx)
1970+
},
19441971
}, nil
19451972
}
19461973

cli/testdata/coder_server_--help.golden

+3
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,9 @@ OAUTH2 / GITHUB OPTIONS:
498498
--oauth2-github-client-secret string, $CODER_OAUTH2_GITHUB_CLIENT_SECRET
499499
Client secret for Login with GitHub.
500500

501+
--oauth2-github-device-flow bool, $CODER_OAUTH2_GITHUB_DEVICE_FLOW (default: false)
502+
Enable device flow for Login with GitHub.
503+
501504
--oauth2-github-enterprise-base-url string, $CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL
502505
Base URL of a GitHub Enterprise deployment to use for Login with
503506
GitHub.

cli/testdata/server-config.yaml.golden

+3
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ oauth2:
262262
# Client ID for Login with GitHub.
263263
# (default: <unset>, type: string)
264264
clientID: ""
265+
# Enable device flow for Login with GitHub.
266+
# (default: false, type: bool)
267+
deviceFlow: false
265268
# Organizations the user must be a member of to Login with GitHub.
266269
# (default: <unset>, type: string-array)
267270
allowedOrgs: []

coderd/apidoc/docs.go

+28
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+24
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,7 @@ func New(options *Options) *API {
11061106
r.Post("/validate-password", api.validateUserPassword)
11071107
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
11081108
r.Route("/oauth2", func(r chi.Router) {
1109+
r.Get("/github/device", api.userOAuth2GithubDevice)
11091110
r.Route("/github", func(r chi.Router) {
11101111
r.Use(
11111112
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil),

coderd/httpmw/oauth2.go

+10-3
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,16 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOp
167167

168168
oauthToken, err := config.Exchange(ctx, code)
169169
if err != nil {
170-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
171-
Message: "Internal error exchanging Oauth code.",
172-
Detail: err.Error(),
170+
errorCode := http.StatusInternalServerError
171+
detail := err.Error()
172+
if detail == "authorization_pending" {
173+
// In the device flow, the token may not be immediately
174+
// available. This is expected, and the client will retry.
175+
errorCode = http.StatusBadRequest
176+
}
177+
httpapi.Write(ctx, rw, errorCode, codersdk.Response{
178+
Message: "Failed exchanging Oauth code.",
179+
Detail: detail,
173180
})
174181
return
175182
}

coderd/userauth.go

+75-1
Original file line numberDiff line numberDiff line change
@@ -748,12 +748,32 @@ type GithubOAuth2Config struct {
748748
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
749749
TeamMembership func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error)
750750

751+
DeviceFlowEnabled bool
752+
ExchangeDeviceCode func(ctx context.Context, deviceCode string) (*oauth2.Token, error)
753+
AuthorizeDevice func(ctx context.Context) (*codersdk.ExternalAuthDevice, error)
754+
751755
AllowSignups bool
752756
AllowEveryone bool
753757
AllowOrganizations []string
754758
AllowTeams []GithubOAuth2Team
755759
}
756760

761+
func (c *GithubOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
762+
if !c.DeviceFlowEnabled {
763+
return c.OAuth2Config.Exchange(ctx, code, opts...)
764+
}
765+
return c.ExchangeDeviceCode(ctx, code)
766+
}
767+
768+
func (c *GithubOAuth2Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
769+
if !c.DeviceFlowEnabled {
770+
return c.OAuth2Config.AuthCodeURL(state, opts...)
771+
}
772+
// This is an absolute path in the Coder app. The device flow is orchestrated
773+
// by the Coder frontend, so we need to redirect the user to the device flow page.
774+
return "/login/device?state=" + state
775+
}
776+
757777
// @Summary Get authentication methods
758778
// @ID get-authentication-methods
759779
// @Security CoderSessionToken
@@ -786,6 +806,53 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
786806
})
787807
}
788808

809+
// @Summary Get Github device auth.
810+
// @ID get-github-device-auth
811+
// @Security CoderSessionToken
812+
// @Produce json
813+
// @Tags Users
814+
// @Success 200 {object} codersdk.ExternalAuthDevice
815+
// @Router /users/oauth2/github/device [get]
816+
func (api *API) userOAuth2GithubDevice(rw http.ResponseWriter, r *http.Request) {
817+
var (
818+
ctx = r.Context()
819+
auditor = api.Auditor.Load()
820+
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
821+
Audit: *auditor,
822+
Log: api.Logger,
823+
Request: r,
824+
Action: database.AuditActionLogin,
825+
})
826+
)
827+
aReq.Old = database.APIKey{}
828+
defer commitAudit()
829+
830+
if api.GithubOAuth2Config == nil {
831+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
832+
Message: "Github OAuth2 is not enabled.",
833+
})
834+
return
835+
}
836+
837+
if !api.GithubOAuth2Config.DeviceFlowEnabled {
838+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
839+
Message: "Device flow is not enabled for Github OAuth2.",
840+
})
841+
return
842+
}
843+
844+
deviceAuth, err := api.GithubOAuth2Config.AuthorizeDevice(ctx)
845+
if err != nil {
846+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
847+
Message: "Failed to authorize device.",
848+
Detail: err.Error(),
849+
})
850+
return
851+
}
852+
853+
httpapi.Write(ctx, rw, http.StatusOK, deviceAuth)
854+
}
855+
789856
// @Summary OAuth 2.0 GitHub Callback
790857
// @ID oauth-20-github-callback
791858
// @Security CoderSessionToken
@@ -1016,7 +1083,14 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
10161083
}
10171084

10181085
redirect = uriFromURL(redirect)
1019-
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
1086+
if api.GithubOAuth2Config.DeviceFlowEnabled {
1087+
// In the device flow, the redirect is handled client-side.
1088+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{
1089+
RedirectURL: redirect,
1090+
})
1091+
} else {
1092+
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
1093+
}
10201094
}
10211095

10221096
type OIDCConfig struct {

coderd/userauth_test.go

+87
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/prometheus/client_golang/prometheus"
2323
"github.com/stretchr/testify/assert"
2424
"github.com/stretchr/testify/require"
25+
"golang.org/x/oauth2"
2526
"golang.org/x/xerrors"
2627

2728
"cdr.dev/slog"
@@ -882,6 +883,92 @@ func TestUserOAuth2Github(t *testing.T) {
882883
require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created")
883884
require.Equal(t, user.Email, newEmail)
884885
})
886+
t.Run("DeviceFlow", func(t *testing.T) {
887+
t.Parallel()
888+
client := coderdtest.New(t, &coderdtest.Options{
889+
GithubOAuth2Config: &coderd.GithubOAuth2Config{
890+
OAuth2Config: &testutil.OAuth2Config{},
891+
AllowOrganizations: []string{"coder"},
892+
AllowSignups: true,
893+
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
894+
return []*github.Membership{{
895+
State: &stateActive,
896+
Organization: &github.Organization{
897+
Login: github.String("coder"),
898+
},
899+
}}, nil
900+
},
901+
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
902+
return &github.User{
903+
ID: github.Int64(100),
904+
Login: github.String("testuser"),
905+
Name: github.String("The Right Honorable Sir Test McUser"),
906+
}, nil
907+
},
908+
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
909+
return []*github.UserEmail{{
910+
Email: github.String("testuser@coder.com"),
911+
Verified: github.Bool(true),
912+
Primary: github.Bool(true),
913+
}}, nil
914+
},
915+
DeviceFlowEnabled: true,
916+
ExchangeDeviceCode: func(_ context.Context, _ string) (*oauth2.Token, error) {
917+
return &oauth2.Token{
918+
AccessToken: "access_token",
919+
RefreshToken: "refresh_token",
920+
Expiry: time.Now().Add(time.Hour),
921+
}, nil
922+
},
923+
AuthorizeDevice: func(_ context.Context) (*codersdk.ExternalAuthDevice, error) {
924+
return &codersdk.ExternalAuthDevice{
925+
DeviceCode: "device_code",
926+
UserCode: "user_code",
927+
}, nil
928+
},
929+
},
930+
})
931+
client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
932+
return http.ErrUseLastResponse
933+
}
934+
935+
// Ensure that we redirect to the device login page when the user is not logged in.
936+
oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback")
937+
require.NoError(t, err)
938+
939+
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
940+
941+
require.NoError(t, err)
942+
res, err := client.HTTPClient.Do(req)
943+
require.NoError(t, err)
944+
defer res.Body.Close()
945+
946+
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
947+
location, err := res.Location()
948+
require.NoError(t, err)
949+
require.Equal(t, "/login/device", location.Path)
950+
query := location.Query()
951+
require.NotEmpty(t, query.Get("state"))
952+
953+
// Ensure that we return a JSON response when the code is successfully exchanged.
954+
oauthURL, err = client.URL.Parse("/api/v2/users/oauth2/github/callback?code=hey&state=somestate")
955+
require.NoError(t, err)
956+
957+
req, err = http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
958+
req.AddCookie(&http.Cookie{
959+
Name: "oauth_state",
960+
Value: "somestate",
961+
})
962+
require.NoError(t, err)
963+
res, err = client.HTTPClient.Do(req)
964+
require.NoError(t, err)
965+
defer res.Body.Close()
966+
967+
require.Equal(t, http.StatusOK, res.StatusCode)
968+
var resp codersdk.OAuth2DeviceFlowCallbackResponse
969+
require.NoError(t, json.NewDecoder(res.Body).Decode(&resp))
970+
require.Equal(t, "/", resp.RedirectURL)
971+
})
885972
}
886973

887974
// nolint:bodyclose

0 commit comments

Comments
 (0)