Skip to content

Commit 2896490

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

File tree

6 files changed

+112
-6
lines changed

6 files changed

+112
-6
lines changed

cli/server.go

Lines changed: 15 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,7 @@ 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+
DeviceAuth: deviceAuth,
19441957
}, nil
19451958
}
19461959

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: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,22 @@ type GithubOAuth2Config struct {
752752
AllowEveryone bool
753753
AllowOrganizations []string
754754
AllowTeams []GithubOAuth2Team
755+
// DeviceAuth is set if the provider uses the device flow.
756+
DeviceAuth *externalauth.DeviceAuth
757+
}
758+
759+
func (c *GithubOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
760+
if c.DeviceAuth == nil {
761+
return c.OAuth2Config.Exchange(ctx, code, opts...)
762+
}
763+
return c.DeviceAuth.ExchangeDeviceCode(ctx, code)
764+
}
765+
766+
func (c *GithubOAuth2Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
767+
if c.DeviceAuth == nil {
768+
return c.OAuth2Config.AuthCodeURL(state, opts...)
769+
}
770+
return "/login/device?state=" + state
755771
}
756772

757773
// @Summary Get authentication methods
@@ -786,6 +802,53 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
786802
})
787803
}
788804

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

10181081
redirect = uriFromURL(redirect)
1019-
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
1082+
if api.GithubOAuth2Config.DeviceAuth != nil {
1083+
// In the device flow, the redirect is handled client-side.
1084+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{
1085+
RedirectURL: redirect,
1086+
})
1087+
} else {
1088+
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
1089+
}
10201090
}
10211091

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