Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit8c5e700

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 DeviceFlow](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 deviceflow, but it's not been possible to use it for logging in. This PRbuilds 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 theflow possible from the client's side. The recording below shows the fullflow.https://github.com/user-attachments/assets/90c06f1f-e42f-43e9-a128-462270c80fddI've also manually tested that it works for converting frompassword-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 configsetting.
1 parent6607464 commit8c5e700

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-
ifvals.OAuth2.Github.ClientSecret!="" {
680+
ifvals.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
returnnil
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-
funcconfigureGithubOAuth2(instrument*promoauth.Factory,accessURL*url.URL,clientID,clientSecretstring,allowSignups,allowEveryonebool,allowOrgs []string,rawTeams []string,enterpriseBaseURLstring) (*coderd.GithubOAuth2Config,error) {
1838+
funcconfigureGithubOAuth2(instrument*promoauth.Factory,accessURL*url.URL,clientID,clientSecretstring,deviceFlow,allowSignups,allowEveryonebool,allowOrgs []string,rawTeams []string,enterpriseBaseURLstring) (*coderd.GithubOAuth2Config,error) {
18361839
redirectURL,err:=accessURL.Parse("/api/v2/users/oauth2/github/callback")
18371840
iferr!=nil {
18381841
returnnil,xerrors.Errorf("parse github oauth callback url: %w",err)
@@ -1898,6 +1901,17 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
18981901
returngithub.NewClient(client),nil
18991902
}
19001903

1904+
vardeviceAuth*externalauth.DeviceAuth
1905+
ifdeviceFlow {
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
returnteam,err
19431957
},
1958+
DeviceFlowEnabled:deviceFlow,
1959+
ExchangeDeviceCode:func(ctx context.Context,deviceCodestring) (*oauth2.Token,error) {
1960+
if!deviceFlow {
1961+
returnnil,xerrors.New("device flow is not enabled")
1962+
}
1963+
returndeviceAuth.ExchangeDeviceCode(ctx,deviceCode)
1964+
},
1965+
AuthorizeDevice:func(ctx context.Context) (*codersdk.ExternalAuthDevice,error) {
1966+
if!deviceFlow {
1967+
returnnil,xerrors.New("device flow is not enabled")
1968+
}
1969+
returndeviceAuth.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 aboutcustomizing 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 aboutcustomizing 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
iferr!=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+
ifdetail=="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
ListOrganizationMembershipsfunc(ctx context.Context,client*http.Client) ([]*github.Membership,error)
749749
TeamMembershipfunc(ctx context.Context,client*http.Client,org,team,usernamestring) (*github.Membership,error)
750750

751+
DeviceFlowEnabledbool
752+
ExchangeDeviceCodefunc(ctx context.Context,deviceCodestring) (*oauth2.Token,error)
753+
AuthorizeDevicefunc(ctx context.Context) (*codersdk.ExternalAuthDevice,error)
754+
751755
AllowSignupsbool
752756
AllowEveryonebool
753757
AllowOrganizations []string
754758
AllowTeams []GithubOAuth2Team
755759
}
756760

761+
func (c*GithubOAuth2Config)Exchange(ctx context.Context,codestring,opts...oauth2.AuthCodeOption) (*oauth2.Token,error) {
762+
if!c.DeviceFlowEnabled {
763+
returnc.OAuth2Config.Exchange(ctx,code,opts...)
764+
}
765+
returnc.ExchangeDeviceCode(ctx,code)
766+
}
767+
768+
func (c*GithubOAuth2Config)AuthCodeURL(statestring,opts...oauth2.AuthCodeOption)string {
769+
if!c.DeviceFlowEnabled {
770+
returnc.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+
defercommitAudit()
829+
830+
ifapi.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+
iferr!=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+
ifapi.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
typeOIDCConfigstruct {

‎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+
returnhttp.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+
deferres.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+
deferres.Body.Close()
966+
967+
require.Equal(t,http.StatusOK,res.StatusCode)
968+
varresp 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)

[8]ページ先頭

©2009-2025 Movatter.jp