Skip to content

feat: support the OAuth2 device flow with GitHub for signing in #16585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 21, 2025

Conversation

hugodutka
Copy link
Contributor

@hugodutka hugodutka commented Feb 16, 2025

First PR in a series to address #16230.

Introduces support for logging in via the GitHub OAuth2 Device Flow.

It's previously been possible to configure external auth with the device flow, but it's not been possible to use it for logging in. This PR builds 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 the flow possible from the client's side. The recording below shows the full flow.

Screen.Recording.2025-02-17.at.19.49.52.mov

I've also manually tested that it works for converting from password-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 config setting.

@hugodutka hugodutka force-pushed the hugodutka/github-oauth2-device-flow branch 12 times, most recently from f4a8918 to dab3105 Compare February 19, 2025 16:06
@hugodutka hugodutka requested review from Emyrk and jaaydenh February 19, 2025 16:19
@hugodutka hugodutka marked this pull request as ready for review February 19, 2025 16:19
@hugodutka
Copy link
Contributor Author

@jaaydenh I've never made any changes to the frontend before. Would you like to see some storybooks or e2e tests added in this PR?

Copy link
Member

@Emyrk Emyrk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LG. Some nits.

I did not look at the backend code.

I would like to see a test with the oidctest.FakeIDP, but not sure how difficult that would actually be to plug in.

Someone else should approve the FE

@@ -1832,7 +1833,7 @@ func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
}

//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, deviceFlow, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really make these args a struct soon. Not necessary in this PR, just a ton of options that can be misorderd.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a TODO

cli/server.go Outdated
Comment on lines 1902 to 1913
createDeviceAuth := func() *externalauth.DeviceAuth {
return &externalauth.DeviceAuth{
Config: instrumentedOauth,
ClientID: clientID,
TokenURL: endpoint.TokenURL,
Scopes: []string{"read:user", "read:org", "user:email"},
CodeURL: endpoint.DeviceAuthURL,
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if this has to be a new instance for each auth flow? If so, can you explain in a comment here?

As opposed to just

deviceAuth := &externalauth.DeviceAuth{
	Config:   instrumentedOauth,
	ClientID: clientID,
	TokenURL: endpoint.TokenURL,
	Scopes:   []string{"read:user", "read:org", "user:email"},
	CodeURL:  endpoint.DeviceAuthURL,
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it doesn't have to be a new instance. I mirrored what we were doing for the GitHub client without understanding why we did it. I changed it to your suggestion now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not have a strong opinion either way. Just curious 👍

Comment on lines +172 to +176
if detail == "authorization_pending" {
// In the device flow, the token may not be immediately
// available. This is expected, and the client will retry.
errorCode = http.StatusBadRequest
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this error comparison correct?

Comparing to the usage in the oauth lib, it is checking the ErrorCode field of the RetreiveError.

Usage: https://github.com/golang/oauth2/blob/master/deviceauth.go#L189-L190
Error type: https://github.com/golang/oauth2/blob/master/token.go#L187-L198

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually don't use the DeviceAccessToken method from the oauth lib. We have a custom implementation of ExchangeDeviceCode which we use for the device flow. That implementation calls codersdk.ReadBodyAsError on an HTTP response it gets from GitHub, which populates the detail field with "authorization_pending". I verified that the check is valid.

For context, we use a custom implementation instead of the lib's DeviceAccessToken because the latter keeps retrying until the code is successfully exchanged. We keep the retry logic on the frontend. That way you don't have an HTTP call that hangs for what can be minutes until the user authenticates with GitHub.

if !c.DeviceFlowEnabled {
return c.OAuth2Config.AuthCodeURL(state, opts...)
}
return "/login/device?state=" + state
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a relative url to our Coder app? Not an external auth url?

Can you just leave a comment that mentions this. I say that becaused AuthCodeURL in a regular Oauth flow is a link to an external domain. So it's not obvious imo.

Comment on lines +1085 to +1090
// In the device flow, the redirect is handled client-side.
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{
RedirectURL: redirect,
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -882,6 +883,92 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created")
require.Equal(t, user.Email, newEmail)
})
t.Run("DeviceFlow", func(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just sharing some info. Our oidctest.FakeIDP supports device auth flow.

Here is an example external auth test:

t.Run("WithFakeIDP", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
externalID := "fake-idp"
cfg := fake.ExternalAuthConfig(t, externalID, &oidctest.ExternalAuthConfigOptions{
UseDeviceAuth: true,
})
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{cfg},
})
coderdtest.CreateFirstUser(t, client)
// Login!
fake.DeviceLogin(t, client, externalID)
extAuth, err := client.ExternalAuthByID(context.Background(), externalID)
require.NoError(t, err)
require.True(t, extAuth.Authenticated)
})

So we could write a test against a "live" idp server. This test uses mocked out responses for ExchangeDeviceCode and AuthorizeDevice. It does not test any of the oauth logic behind that.

@jaaydenh
Copy link
Contributor

@hugodutka Regarding some of the comments about the FE regarding Tailwind and Material UI components. It looks you were modifying existing code more than adding new code. I would say its preferable to make the changes but it makes sense to not do it now if you feel your are just modifying existing code.

@jaaydenh
Copy link
Contributor

jaaydenh commented Feb 19, 2025

@jaaydenh I've never made any changes to the frontend before. Would you like to see some storybooks or e2e tests added in this PR?

It would make sense to add storybook tests for the pageview that has been added:
LoginOAuthDevicePageView.tsx

Playwright e2e tests would make sense as well if you have a good way to test this.

Both of these could be done in a separate PR to keep things moving.

Is there a simple enough way to test the Github device flow locally?

@hugodutka
Copy link
Contributor Author

@jaaydenh

I would say its preferable to make the changes but it makes sense to not do it now if you feel your are just modifying existing code.

I indeed just copy pasted and modified site/src/pages/ExternalAuthPage. I'd rather keep the refactoring to tailwind effort in a separate PR.

Both of these could be done in a separate PR to keep things moving.

Great, I'll do that in a separate PR.

Is there a simple enough way to test the Github device flow locally?

Yes, run the server like this:

export CODER_OAUTH2_GITHUB_DEVICE_FLOW=true
export CODER_OAUTH2_GITHUB_CLIENT_ID=Iv23liSBHklRMBNx5lk9
export CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true
export CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true
go run cmd/coder/main.go server

@hugodutka hugodutka force-pushed the hugodutka/github-oauth2-device-flow branch from dab3105 to be8cca1 Compare February 21, 2025 17:18
@hugodutka hugodutka force-pushed the hugodutka/github-oauth2-device-flow branch from be8cca1 to cfc3645 Compare February 21, 2025 17:23
@hugodutka
Copy link
Contributor Author

Thank you for the reviews @jaaydenh @Emyrk! I'll add storybook tests, a fakeIDP test, and - if I find a good way to do it - an E2E test in a follow up PR.

@hugodutka hugodutka merged commit 8c5e700 into main Feb 21, 2025
32 of 35 checks passed
@hugodutka hugodutka deleted the hugodutka/github-oauth2-device-flow branch February 21, 2025 17:42
@github-actions github-actions bot locked and limited conversation to collaborators Feb 21, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants