diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index 0238c41e5a0da..4a68eb3ea0b54 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -41,6 +41,7 @@ import ( "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/util/syncmap" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) type token struct { @@ -484,6 +485,31 @@ func (f *FakeIDP) ExternalLogin(t testing.TB, client *codersdk.Client, opts ...f _ = res.Body.Close() } +// DeviceLogin does the oauth2 device flow for external auth providers. +func (*FakeIDP) DeviceLogin(t testing.TB, client *codersdk.Client, externalAuthID string) { + // First we need to initiate the device flow. This will have Coder hit the + // fake IDP and get a device code. + device, err := client.ExternalAuthDeviceByID(context.Background(), externalAuthID) + require.NoError(t, err) + + // Now the user needs to go to the fake IDP page and click "allow" and enter + // the device code input. For our purposes, we just send an http request to + // the verification url. No additional user input is needed. + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + resp, err := client.Request(ctx, http.MethodPost, device.VerificationURI, nil) + require.NoError(t, err) + defer resp.Body.Close() + + // Now we need to exchange the device code for an access token. We do this + // in this method because it is the user that does the polling for the device + // auth flow, not the backend. + err = client.ExternalAuthDeviceExchange(context.Background(), externalAuthID, codersdk.ExternalAuthDeviceExchange{ + DeviceCode: device.DeviceCode, + }) + require.NoError(t, err) +} + // CreateAuthCode emulates a user clicking "allow" on the IDP page. When doing // unit tests, it's easier to skip this step sometimes. It does make an actual // request to the IDP, so it should be equivalent to doing this "manually" with diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 282c0d8a722b7..0c936743a0df5 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -6,9 +6,11 @@ import ( "encoding/json" "fmt" "io" + "mime" "net/http" "net/url" "regexp" + "strconv" "strings" "time" @@ -321,13 +323,31 @@ func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAut } err = json.NewDecoder(resp.Body).Decode(&r) if err != nil { - // Some status codes do not return json payloads, and we should - // return a better error. - switch resp.StatusCode { - case http.StatusTooManyRequests: - return nil, xerrors.New("rate limit hit, unable to authorize device. please try again later") + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + mediaType = "unknown" + } + + // If the json fails to decode, do a best effort to return a better error. + switch { + case resp.StatusCode == http.StatusTooManyRequests: + retryIn := "please try again later" + resetIn := resp.Header.Get("x-ratelimit-reset") + if resetIn != "" { + // Best effort to tell the user exactly how long they need + // to wait for. + unix, err := strconv.ParseInt(resetIn, 10, 64) + if err == nil { + waitFor := time.Unix(unix, 0).Sub(time.Now().Truncate(time.Second)) + retryIn = fmt.Sprintf(" retry after %s", waitFor.Truncate(time.Second)) + } + } + // 429 returns a plaintext payload with a message. + return nil, xerrors.New(fmt.Sprintf("rate limit hit, unable to authorize device. %s", retryIn)) + case mediaType == "application/x-www-form-urlencoded": + return nil, xerrors.Errorf("status_code=%d, payload response is form-url encoded, expected a json payload", resp.StatusCode) default: - return nil, fmt.Errorf("status_code=%d: %w", resp.StatusCode, err) + return nil, fmt.Errorf("status_code=%d, mediaType=%s: %w", resp.StatusCode, mediaType, err) } } if r.ErrorDescription != "" { diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go index 17adfac69dcd7..db40ccf38a554 100644 --- a/coderd/externalauth_test.go +++ b/coderd/externalauth_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "regexp" "strings" "testing" @@ -264,6 +265,27 @@ func TestExternalAuthManagement(t *testing.T) { func TestExternalAuthDevice(t *testing.T) { t.Parallel() + // This is an example test on how to do device auth flow using our fake idp. + 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) + }) + t.Run("NotSupported", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ @@ -363,6 +385,30 @@ func TestExternalAuthDevice(t *testing.T) { _, err := client.ExternalAuthDeviceByID(context.Background(), "test") require.ErrorContains(t, err, "rate limit hit") }) + + // If we forget to add the accept header, we get a form encoded body instead. + t.Run("FormEncodedBody", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + _, _ = w.Write([]byte(url.Values{"access_token": {"hey"}}.Encode())) + })) + defer srv.Close() + client := coderdtest.New(t, &coderdtest.Options{ + ExternalAuthConfigs: []*externalauth.Config{{ + ID: "test", + DeviceAuth: &externalauth.DeviceAuth{ + ClientID: "test", + CodeURL: srv.URL, + Scopes: []string{"repo"}, + }, + }}, + }) + coderdtest.CreateFirstUser(t, client) + _, err := client.ExternalAuthDeviceByID(context.Background(), "test") + require.Error(t, err) + require.ErrorContains(t, err, "is form-url encoded") + }) } // nolint:bodyclose