Skip to content

Commit b0101aa

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

File tree

6 files changed

+127
-6
lines changed

6 files changed

+127
-6
lines changed

cli/server.go

Lines changed: 28 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,16 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
18981899
return github.NewClient(client), nil
18991900
}
19001901

1902+
createDeviceAuth := func() *externalauth.DeviceAuth {
1903+
return &externalauth.DeviceAuth{
1904+
Config: instrumentedOauth,
1905+
ClientID: clientID,
1906+
TokenURL: endpoint.TokenURL,
1907+
Scopes: []string{"read:user", "read:org", "user:email"},
1908+
CodeURL: endpoint.DeviceAuthURL,
1909+
}
1910+
}
1911+
19011912
return &coderd.GithubOAuth2Config{
19021913
OAuth2Config: instrumentedOauth,
19031914
AllowSignups: allowSignups,
@@ -1941,6 +1952,21 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
19411952
team, _, err := api.Teams.GetTeamMembershipBySlug(ctx, org, teamSlug, username)
19421953
return team, err
19431954
},
1955+
DeviceFlowEnabled: deviceFlow,
1956+
ExchangeDeviceCode: func(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
1957+
if !deviceFlow {
1958+
return nil, xerrors.New("device flow is not enabled")
1959+
}
1960+
deviceAuth := createDeviceAuth()
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+
deviceAuth := createDeviceAuth()
1968+
return deviceAuth.AuthorizeDevice(ctx)
1969+
},
19441970
}, nil
19451971
}
19461972

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: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,12 +748,30 @@ 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+
return "/login/device?state=" + state
773+
}
774+
757775
// @Summary Get authentication methods
758776
// @ID get-authentication-methods
759777
// @Security CoderSessionToken
@@ -786,6 +804,53 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
786804
})
787805
}
788806

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

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

10221094
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)