Skip to content

Commit 286abb7

Browse files
committed
github oauth2 device flow backend
1 parent 53f0007 commit 286abb7

File tree

6 files changed

+128
-6
lines changed

6 files changed

+128
-6
lines changed

cli/server.go

Lines changed: 27 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,
@@ -1832,7 +1833,7 @@ func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
18321833
}
18331834

18341835
//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) {
1836+
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, deviceFlow, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
18361837
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
18371838
if err != nil {
18381839
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
@@ -1898,6 +1899,17 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
18981899
return github.NewClient(client), nil
18991900
}
19001901

1902+
var deviceAuth *externalauth.DeviceAuth
1903+
if deviceFlow {
1904+
deviceAuth = &externalauth.DeviceAuth{
1905+
Config: instrumentedOauth,
1906+
ClientID: clientID,
1907+
TokenURL: endpoint.TokenURL,
1908+
Scopes: []string{"read:user", "read:org", "user:email"},
1909+
CodeURL: endpoint.DeviceAuthURL,
1910+
}
1911+
}
1912+
19011913
return &coderd.GithubOAuth2Config{
19021914
OAuth2Config: instrumentedOauth,
19031915
AllowSignups: allowSignups,
@@ -1941,6 +1953,19 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
19411953
team, _, err := api.Teams.GetTeamMembershipBySlug(ctx, org, teamSlug, username)
19421954
return team, err
19431955
},
1956+
DeviceFlowEnabled: deviceFlow,
1957+
ExchangeDeviceCode: func(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
1958+
if !deviceFlow {
1959+
return nil, xerrors.New("device flow is not enabled")
1960+
}
1961+
return deviceAuth.ExchangeDeviceCode(ctx, deviceCode)
1962+
},
1963+
AuthorizeDevice: func(ctx context.Context) (*codersdk.ExternalAuthDevice, error) {
1964+
if !deviceFlow {
1965+
return nil, xerrors.New("device flow is not enabled")
1966+
}
1967+
return deviceAuth.AuthorizeDevice(ctx)
1968+
},
19441969
}, nil
19451970
}
19461971

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,7 @@ func New(options *Options) *API {
10871087
r.Post("/validate-password", api.validateUserPassword)
10881088
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
10891089
r.Route("/oauth2", func(r chi.Router) {
1090+
r.Get("/github/device", api.userOAuth2GithubDevice)
10901091
r.Route("/github", func(r chi.Router) {
10911092
r.Use(
10921093
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 {

codersdk/deployment.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ type OAuth2Config struct {
505505
type OAuth2GithubConfig struct {
506506
ClientID serpent.String `json:"client_id" typescript:",notnull"`
507507
ClientSecret serpent.String `json:"client_secret" typescript:",notnull"`
508+
DeviceFlow serpent.Bool `json:"device_flow" typescript:",notnull"`
508509
AllowedOrgs serpent.StringArray `json:"allowed_orgs" typescript:",notnull"`
509510
AllowedTeams serpent.StringArray `json:"allowed_teams" typescript:",notnull"`
510511
AllowSignups serpent.Bool `json:"allow_signups" typescript:",notnull"`
@@ -1572,6 +1573,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
15721573
Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"),
15731574
Group: &deploymentGroupOAuth2GitHub,
15741575
},
1576+
{
1577+
Name: "OAuth2 GitHub Device Flow",
1578+
Description: "Enable device flow for Login with GitHub.",
1579+
Flag: "oauth2-github-device-flow",
1580+
Env: "CODER_OAUTH2_GITHUB_DEVICE_FLOW",
1581+
Value: &c.OAuth2.Github.DeviceFlow,
1582+
Group: &deploymentGroupOAuth2GitHub,
1583+
YAML: "deviceFlow",
1584+
Default: "false",
1585+
},
15751586
{
15761587
Name: "OAuth2 GitHub Allowed Orgs",
15771588
Description: "Organizations the user must be a member of to Login with GitHub.",

codersdk/oauth2.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,7 @@ func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) e
227227
}
228228
return nil
229229
}
230+
231+
type OAuth2DeviceFlowCallbackResponse struct {
232+
RedirectURL string `json:"redirect_url"`
233+
}

0 commit comments

Comments
 (0)