Skip to content

Commit b23c29d

Browse files
authored
Merge branch 'main' into 16570-2180-2185
2 parents 54e1c6a + f8a49f4 commit b23c29d

File tree

24 files changed

+657
-111
lines changed

24 files changed

+657
-111
lines changed

cli/server.go

Lines changed: 29 additions & 2 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
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

Lines changed: 10 additions & 3 deletions
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

Lines changed: 75 additions & 1 deletion
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

Lines changed: 87 additions & 0 deletions
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)