chore: move /gitauth to /externalauth on the frontend (#9954)

* chore: move `/gitauth` to `/externalauth` on the frontend

This actually took a lot more jank than anticipated,
so I wanted to split this up before adding the ability
to embed new providers.

* Rename FE

* Fix em' up

* Fix linting error

* Fix e2e tests

* chore: update helm golden files
This commit is contained in:
Kyle Carberry
2023-09-30 14:30:01 -05:00
committed by GitHub
parent 16a2d4d733
commit 5596fb20b5
59 changed files with 1231 additions and 1211 deletions
+355
View File
@@ -0,0 +1,355 @@
package externalauth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"time"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"github.com/google/go-github/v43/github"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/retry"
)
type OAuth2Config interface {
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource
}
// Config is used for authentication for Git operations.
type Config struct {
OAuth2Config
// ID is a unique identifier for the authenticator.
ID string
// Type is the type of provider.
Type codersdk.ExternalAuthProvider
// DeviceAuth is set if the provider uses the device flow.
DeviceAuth *DeviceAuth
// NoRefresh stops Coder from using the refresh token
// to renew the access token.
//
// Some organizations have security policies that require
// re-authentication for every token.
NoRefresh bool
// ValidateURL ensures an access token is valid before
// returning it to the user. If omitted, tokens will
// not be validated before being returned.
ValidateURL string
// Regex is a Regexp matched against URLs for
// a Git clone. e.g. "Username for 'https://github.com':"
// The regex would be `github\.com`..
Regex *regexp.Regexp
// AppInstallURL is for GitHub App's (and hopefully others eventually)
// to provide a link to install the app. There's installation
// of the application, and user authentication. It's possible
// for the user to authenticate but the application to not.
AppInstallURL string
// AppInstallationsURL is an API endpoint that returns a list of
// installations for the user. This is used for GitHub Apps.
AppInstallationsURL string
}
// RefreshToken automatically refreshes the token if expired and permitted.
// It returns the token and a bool indicating if the token is valid.
func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, bool, error) {
// If the token is expired and refresh is disabled, we prompt
// the user to authenticate again.
if c.NoRefresh &&
// If the time is set to 0, then it should never expire.
// This is true for github, which has no expiry.
!externalAuthLink.OAuthExpiry.IsZero() &&
externalAuthLink.OAuthExpiry.Before(dbtime.Now()) {
return externalAuthLink, false, nil
}
// This is additional defensive programming. Because TokenSource is an interface,
// we cannot be sure that the implementation will treat an 'IsZero' time
// as "not-expired". The default implementation does, but a custom implementation
// might not. Removing the refreshToken will guarantee a refresh will fail.
refreshToken := externalAuthLink.OAuthRefreshToken
if c.NoRefresh {
refreshToken = ""
}
token, err := c.TokenSource(ctx, &oauth2.Token{
AccessToken: externalAuthLink.OAuthAccessToken,
RefreshToken: refreshToken,
Expiry: externalAuthLink.OAuthExpiry,
}).Token()
if err != nil {
// Even if the token fails to be obtained, we still return false because
// we aren't trying to surface an error, we're just trying to obtain a valid token.
return externalAuthLink, false, nil
}
r := retry.New(50*time.Millisecond, 200*time.Millisecond)
// See the comment below why the retry and cancel is required.
retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second)
defer retryCtxCancel()
validate:
valid, _, err := c.ValidateToken(ctx, token.AccessToken)
if err != nil {
return externalAuthLink, false, xerrors.Errorf("validate external auth token: %w", err)
}
if !valid {
// A customer using GitHub in Australia reported that validating immediately
// after refreshing the token would intermittently fail with a 401. Waiting
// a few milliseconds with the exact same token on the exact same request
// would resolve the issue. It seems likely that the write is not propagating
// to the read replica in time.
//
// We do an exponential backoff here to give the write time to propagate.
if c.Type == codersdk.ExternalAuthProviderGitHub && r.Wait(retryCtx) {
goto validate
}
// The token is no longer valid!
return externalAuthLink, false, nil
}
if token.AccessToken != externalAuthLink.OAuthAccessToken {
// Update it
externalAuthLink, err = db.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
ProviderID: c.ID,
UserID: externalAuthLink.UserID,
UpdatedAt: dbtime.Now(),
OAuthAccessToken: token.AccessToken,
OAuthRefreshToken: token.RefreshToken,
OAuthExpiry: token.Expiry,
})
if err != nil {
return externalAuthLink, false, xerrors.Errorf("update external auth link: %w", err)
}
}
return externalAuthLink, true, nil
}
// ValidateToken ensures the Git token provided is valid!
// The user is optionally returned if the provider supports it.
func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *codersdk.ExternalAuthUser, error) {
if c.ValidateURL == "" {
// Default that the token is valid if no validation URL is provided.
return true, nil, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ValidateURL, nil)
if err != nil {
return false, nil, err
}
cli := http.DefaultClient
if v, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok {
cli = v
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := cli.Do(req)
if err != nil {
return false, nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
// The token is no longer valid!
return false, nil, nil
}
if res.StatusCode != http.StatusOK {
data, _ := io.ReadAll(res.Body)
return false, nil, xerrors.Errorf("status %d: body: %s", res.StatusCode, data)
}
var user *codersdk.ExternalAuthUser
if c.Type == codersdk.ExternalAuthProviderGitHub {
var ghUser github.User
err = json.NewDecoder(res.Body).Decode(&ghUser)
if err == nil {
user = &codersdk.ExternalAuthUser{
Login: ghUser.GetLogin(),
AvatarURL: ghUser.GetAvatarURL(),
ProfileURL: ghUser.GetHTMLURL(),
Name: ghUser.GetName(),
}
}
}
return true, user, nil
}
type AppInstallation struct {
ID int
// Login is the username of the installation.
Login string
// URL is a link to configure the app install.
URL string
}
// AppInstallations returns a list of app installations for the given token.
// If the provider does not support app installations, it returns nil.
func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk.ExternalAuthAppInstallation, bool, error) {
if c.AppInstallationsURL == "" {
return nil, false, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil)
if err != nil {
return nil, false, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, false, err
}
defer res.Body.Close()
// It's possible the installation URL is misconfigured, so we don't
// want to return an error here.
if res.StatusCode != http.StatusOK {
return nil, false, nil
}
installs := []codersdk.ExternalAuthAppInstallation{}
if c.Type == codersdk.ExternalAuthProviderGitHub {
var ghInstalls struct {
Installations []*github.Installation `json:"installations"`
}
err = json.NewDecoder(res.Body).Decode(&ghInstalls)
if err != nil {
return nil, false, err
}
for _, installation := range ghInstalls.Installations {
account := installation.GetAccount()
if account == nil {
continue
}
installs = append(installs, codersdk.ExternalAuthAppInstallation{
ID: int(installation.GetID()),
ConfigureURL: installation.GetHTMLURL(),
Account: codersdk.ExternalAuthUser{
Login: account.GetLogin(),
AvatarURL: account.GetAvatarURL(),
ProfileURL: account.GetHTMLURL(),
Name: account.GetName(),
},
})
}
}
return installs, true, nil
}
// ConvertConfig converts the SDK configuration entry format
// to the parsed and ready-to-consume in coderd provider type.
func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Config, error) {
ids := map[string]struct{}{}
configs := []*Config{}
for _, entry := range entries {
var typ codersdk.ExternalAuthProvider
switch codersdk.ExternalAuthProvider(entry.Type) {
case codersdk.ExternalAuthProviderAzureDevops:
typ = codersdk.ExternalAuthProviderAzureDevops
case codersdk.ExternalAuthProviderBitBucket:
typ = codersdk.ExternalAuthProviderBitBucket
case codersdk.ExternalAuthProviderGitHub:
typ = codersdk.ExternalAuthProviderGitHub
case codersdk.ExternalAuthProviderGitLab:
typ = codersdk.ExternalAuthProviderGitLab
default:
return nil, xerrors.Errorf("unknown git provider type: %q", entry.Type)
}
if entry.ID == "" {
// Default to the type.
entry.ID = string(typ)
}
if valid := httpapi.NameValid(entry.ID); valid != nil {
return nil, xerrors.Errorf("external auth provider %q doesn't have a valid id: %w", entry.ID, valid)
}
_, exists := ids[entry.ID]
if exists {
if entry.ID == string(typ) {
return nil, xerrors.Errorf("multiple %s external auth providers provided. you must specify a unique id for each", typ)
}
return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID)
}
ids[entry.ID] = struct{}{}
if entry.ClientID == "" {
return nil, xerrors.Errorf("%q external auth provider: client_id must be provided", entry.ID)
}
authRedirect, err := accessURL.Parse(fmt.Sprintf("/externalauth/%s/callback", entry.ID))
if err != nil {
return nil, xerrors.Errorf("parse externalauth callback url: %w", err)
}
regex := regex[typ]
if entry.Regex != "" {
regex, err = regexp.Compile(entry.Regex)
if err != nil {
return nil, xerrors.Errorf("compile regex for external auth provider %q: %w", entry.ID, entry.Regex)
}
}
oc := &oauth2.Config{
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
Endpoint: endpoint[typ],
RedirectURL: authRedirect.String(),
Scopes: scope[typ],
}
if entry.AuthURL != "" {
oc.Endpoint.AuthURL = entry.AuthURL
}
if entry.TokenURL != "" {
oc.Endpoint.TokenURL = entry.TokenURL
}
if entry.Scopes != nil && len(entry.Scopes) > 0 {
oc.Scopes = entry.Scopes
}
if entry.ValidateURL == "" {
entry.ValidateURL = validateURL[typ]
}
if entry.AppInstallationsURL == "" {
entry.AppInstallationsURL = appInstallationsURL[typ]
}
var oauthConfig OAuth2Config = oc
// Azure DevOps uses JWT token authentication!
if typ == codersdk.ExternalAuthProviderAzureDevops {
oauthConfig = &jwtConfig{oc}
}
cfg := &Config{
OAuth2Config: oauthConfig,
ID: entry.ID,
Regex: regex,
Type: typ,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
AppInstallationsURL: entry.AppInstallationsURL,
AppInstallURL: entry.AppInstallURL,
}
if entry.DeviceFlow {
if entry.DeviceCodeURL == "" {
entry.DeviceCodeURL = deviceAuthURL[typ]
}
if entry.DeviceCodeURL == "" {
return nil, xerrors.Errorf("external auth provider %q: device auth url must be provided", entry.ID)
}
cfg.DeviceAuth = &DeviceAuth{
ClientID: entry.ClientID,
TokenURL: oc.Endpoint.TokenURL,
Scopes: entry.Scopes,
CodeURL: entry.DeviceCodeURL,
}
}
configs = append(configs, cfg)
}
return configs, nil
}
+408
View File
@@ -0,0 +1,408 @@
package externalauth_test
import (
"context"
"net/http"
"net/url"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestRefreshToken(t *testing.T) {
t.Parallel()
expired := time.Now().Add(time.Hour * -1)
t.Run("NoRefreshExpired", func(t *testing.T) {
t.Parallel()
fake, config, link := setupOauth2Test(t, testConfig{
FakeIDPOpts: []oidctest.FakeIDPOpt{
oidctest.WithRefresh(func(_ string) error {
t.Error("refresh on the IDP was called, but NoRefresh was set")
return xerrors.New("should not be called")
}),
// The IDP should not be contacted since the token is expired. An expired
// token with 'NoRefresh' should early abort.
oidctest.WithDynamicUserInfo(func(_ string) (jwt.MapClaims, error) {
t.Error("token was validated, but it was expired and this should never have happened.")
return nil, xerrors.New("should not be called")
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.NoRefresh = true
},
})
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
// Expire the link
link.OAuthExpiry = expired
_, refreshed, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.False(t, refreshed)
})
// NoRefreshNoExpiry tests that an oauth token without an expiry is always valid.
// The "validate url" should be hit, but the refresh endpoint should not.
t.Run("NoRefreshNoExpiry", func(t *testing.T) {
t.Parallel()
validated := false
fake, config, link := setupOauth2Test(t, testConfig{
FakeIDPOpts: []oidctest.FakeIDPOpt{
oidctest.WithRefresh(func(_ string) error {
t.Error("refresh on the IDP was called, but NoRefresh was set")
return xerrors.New("should not be called")
}),
oidctest.WithDynamicUserInfo(func(_ string) (jwt.MapClaims, error) {
validated = true
return jwt.MapClaims{}, nil
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.NoRefresh = true
},
})
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
// Zero time used
link.OAuthExpiry = time.Time{}
_, refreshed, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.True(t, refreshed, "token without expiry is always valid")
require.True(t, validated, "token should have been validated")
})
t.Run("FalseIfTokenSourceFails", func(t *testing.T) {
t.Parallel()
config := &externalauth.Config{
OAuth2Config: &testutil.OAuth2Config{
TokenSourceFunc: func() (*oauth2.Token, error) {
return nil, xerrors.New("failure")
},
},
}
_, refreshed, err := config.RefreshToken(context.Background(), nil, database.ExternalAuthLink{
OAuthExpiry: expired,
})
require.NoError(t, err)
require.False(t, refreshed)
})
t.Run("ValidateServerError", func(t *testing.T) {
t.Parallel()
const staticError = "static error"
validated := false
fake, config, link := setupOauth2Test(t, testConfig{
FakeIDPOpts: []oidctest.FakeIDPOpt{
oidctest.WithDynamicUserInfo(func(_ string) (jwt.MapClaims, error) {
validated = true
return jwt.MapClaims{}, xerrors.New(staticError)
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
},
})
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
link.OAuthExpiry = expired
_, _, err := config.RefreshToken(ctx, nil, link)
require.ErrorContains(t, err, staticError)
require.True(t, validated, "token should have been attempted to be validated")
})
// ValidateFailure tests if the token is no longer valid with a 401 response.
t.Run("ValidateFailure", func(t *testing.T) {
t.Parallel()
const staticError = "static error"
validated := false
fake, config, link := setupOauth2Test(t, testConfig{
FakeIDPOpts: []oidctest.FakeIDPOpt{
oidctest.WithDynamicUserInfo(func(_ string) (jwt.MapClaims, error) {
validated = true
return jwt.MapClaims{}, oidctest.StatusError(http.StatusUnauthorized, xerrors.New(staticError))
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
},
})
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
link.OAuthExpiry = expired
_, refreshed, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err, staticError)
require.False(t, refreshed)
require.True(t, validated, "token should have been attempted to be validated")
})
t.Run("ValidateRetryGitHub", func(t *testing.T) {
t.Parallel()
const staticError = "static error"
validateCalls := 0
fake, config, link := setupOauth2Test(t, testConfig{
FakeIDPOpts: []oidctest.FakeIDPOpt{
oidctest.WithRefresh(func(_ string) error {
t.Error("refresh on the IDP was called, but the token is not expired")
return xerrors.New("should not be called")
}),
oidctest.WithDynamicUserInfo(func(_ string) (jwt.MapClaims, error) {
validateCalls++
// Make the first call return a 401, subsequent calls should return a 200.
if validateCalls > 1 {
return jwt.MapClaims{}, nil
}
return jwt.MapClaims{}, oidctest.StatusError(http.StatusUnauthorized, xerrors.New(staticError))
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.Type = codersdk.ExternalAuthProviderGitHub
},
})
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
// Unlimited lifetime, this is what GitHub returns tokens as
link.OAuthExpiry = time.Time{}
_, ok, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, 2, validateCalls, "token should have been attempted to be validated more than once")
})
t.Run("ValidateNoUpdate", func(t *testing.T) {
t.Parallel()
validateCalls := 0
fake, config, link := setupOauth2Test(t, testConfig{
FakeIDPOpts: []oidctest.FakeIDPOpt{
oidctest.WithRefresh(func(_ string) error {
t.Error("refresh on the IDP was called, but the token is not expired")
return xerrors.New("should not be called")
}),
oidctest.WithDynamicUserInfo(func(_ string) (jwt.MapClaims, error) {
validateCalls++
return jwt.MapClaims{}, nil
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.Type = codersdk.ExternalAuthProviderGitHub
},
})
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
_, ok, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, 1, validateCalls, "token is validated")
})
// A token update comes from a refresh.
t.Run("Updates", func(t *testing.T) {
t.Parallel()
db := dbfake.New()
validateCalls := 0
refreshCalls := 0
fake, config, link := setupOauth2Test(t, testConfig{
FakeIDPOpts: []oidctest.FakeIDPOpt{
oidctest.WithRefresh(func(_ string) error {
refreshCalls++
return nil
}),
oidctest.WithDynamicUserInfo(func(_ string) (jwt.MapClaims, error) {
validateCalls++
return jwt.MapClaims{}, nil
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.Type = codersdk.ExternalAuthProviderGitHub
},
DB: db,
})
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
// Force a refresh
link.OAuthExpiry = expired
updated, ok, err := config.RefreshToken(ctx, db, link)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, 1, validateCalls, "token is validated")
require.Equal(t, 1, refreshCalls, "token is refreshed")
require.NotEqualf(t, link.OAuthAccessToken, updated.OAuthAccessToken, "token is updated")
//nolint:gocritic // testing
dbLink, err := db.GetExternalAuthLink(dbauthz.AsSystemRestricted(context.Background()), database.GetExternalAuthLinkParams{
ProviderID: link.ProviderID,
UserID: link.UserID,
})
require.NoError(t, err)
require.Equal(t, updated.OAuthAccessToken, dbLink.OAuthAccessToken, "token is updated in the DB")
})
}
func TestConvertYAML(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
Name string
Input []codersdk.GitAuthConfig
Output []*externalauth.Config
Error string
}{{
Name: "InvalidType",
Input: []codersdk.GitAuthConfig{{
Type: "moo",
}},
Error: "unknown git provider type",
}, {
Name: "InvalidID",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
ID: "$hi$",
}},
Error: "doesn't have a valid id",
}, {
Name: "NoClientID",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
}},
Error: "client_id must be provided",
}, {
Name: "DuplicateType",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
ClientID: "example",
ClientSecret: "example",
}, {
Type: string(codersdk.ExternalAuthProviderGitHub),
}},
Error: "multiple github external auth providers provided",
}, {
Name: "InvalidRegex",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
ClientID: "example",
ClientSecret: "example",
Regex: `\K`,
}},
Error: "compile regex for external auth provider",
}, {
Name: "NoDeviceURL",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitLab),
ClientID: "example",
ClientSecret: "example",
DeviceFlow: true,
}},
Error: "device auth url must be provided",
}} {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
output, err := externalauth.ConvertConfig(tc.Input, &url.URL{})
if tc.Error != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.Error)
return
}
require.Equal(t, tc.Output, output)
})
}
t.Run("CustomScopesAndEndpoint", func(t *testing.T) {
t.Parallel()
config, err := externalauth.ConvertConfig([]codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitLab),
ClientID: "id",
ClientSecret: "secret",
AuthURL: "https://auth.com",
TokenURL: "https://token.com",
Scopes: []string{"read"},
}}, &url.URL{})
require.NoError(t, err)
require.Equal(t, "https://auth.com?client_id=id&redirect_uri=%2Fexternalauth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL(""))
})
}
type testConfig struct {
FakeIDPOpts []oidctest.FakeIDPOpt
CoderOIDCConfigOpts []func(cfg *coderd.OIDCConfig)
GitConfigOpt func(cfg *externalauth.Config)
// If DB is passed in, the link will be inserted into the DB.
DB database.Store
}
// setupTest will configure a fake IDP and a externalauth.Config for testing.
// The Fake's userinfo endpoint is used for validating tokens.
// No http servers are started so use the fake IDP's HTTPClient to make requests.
// The returned token is a fully valid token for the IDP. Feel free to manipulate it
// to test different scenarios.
func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *externalauth.Config, database.ExternalAuthLink) {
t.Helper()
const providerID = "test-idp"
fake := oidctest.NewFakeIDP(t,
append([]oidctest.FakeIDPOpt{}, settings.FakeIDPOpts...)...,
)
config := &externalauth.Config{
OAuth2Config: fake.OIDCConfig(t, nil, settings.CoderOIDCConfigOpts...),
ID: providerID,
ValidateURL: fake.WellknownConfig().UserInfoURL,
}
settings.GitConfigOpt(config)
oauthToken, err := fake.GenerateAuthenticatedToken(jwt.MapClaims{
"email": "test@coder.com",
})
require.NoError(t, err)
now := time.Now()
link := database.ExternalAuthLink{
ProviderID: providerID,
UserID: uuid.New(),
CreatedAt: now,
UpdatedAt: now,
OAuthAccessToken: oauthToken.AccessToken,
OAuthRefreshToken: oauthToken.RefreshToken,
// The caller can manually expire this if they want.
OAuthExpiry: now.Add(time.Hour),
}
if settings.DB != nil {
// Feel free to insert additional things like the user, etc if required.
link, err = settings.DB.InsertExternalAuthLink(context.Background(), database.InsertExternalAuthLinkParams{
ProviderID: link.ProviderID,
UserID: link.UserID,
CreatedAt: link.CreatedAt,
UpdatedAt: link.UpdatedAt,
OAuthAccessToken: link.OAuthAccessToken,
OAuthRefreshToken: link.OAuthRefreshToken,
OAuthExpiry: link.OAuthExpiry,
})
require.NoError(t, err, "failed to insert link into DB")
}
return fake, config, link
}
+212
View File
@@ -0,0 +1,212 @@
package externalauth
import (
"context"
"encoding/json"
"net/http"
"net/url"
"regexp"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
)
// endpoint contains default SaaS URLs for each Git provider.
var endpoint = map[codersdk.ExternalAuthProvider]oauth2.Endpoint{
codersdk.ExternalAuthProviderAzureDevops: {
AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize",
TokenURL: "https://app.vssps.visualstudio.com/oauth2/token",
},
codersdk.ExternalAuthProviderBitBucket: {
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
},
codersdk.ExternalAuthProviderGitLab: {
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
},
codersdk.ExternalAuthProviderGitHub: github.Endpoint,
}
// validateURL contains defaults for each provider.
var validateURL = map[codersdk.ExternalAuthProvider]string{
codersdk.ExternalAuthProviderGitHub: "https://api.github.com/user",
codersdk.ExternalAuthProviderGitLab: "https://gitlab.com/oauth/token/info",
codersdk.ExternalAuthProviderBitBucket: "https://api.bitbucket.org/2.0/user",
}
var deviceAuthURL = map[codersdk.ExternalAuthProvider]string{
codersdk.ExternalAuthProviderGitHub: "https://github.com/login/device/code",
}
var appInstallationsURL = map[codersdk.ExternalAuthProvider]string{
codersdk.ExternalAuthProviderGitHub: "https://api.github.com/user/installations",
}
// scope contains defaults for each Git provider.
var scope = map[codersdk.ExternalAuthProvider][]string{
codersdk.ExternalAuthProviderAzureDevops: {"vso.code_write"},
codersdk.ExternalAuthProviderBitBucket: {"account", "repository:write"},
codersdk.ExternalAuthProviderGitLab: {"write_repository"},
// "workflow" is required for managing GitHub Actions in a repository.
codersdk.ExternalAuthProviderGitHub: {"repo", "workflow"},
}
// regex provides defaults for each Git provider to match their SaaS host URL.
// This is configurable by each provider.
var regex = map[codersdk.ExternalAuthProvider]*regexp.Regexp{
codersdk.ExternalAuthProviderAzureDevops: regexp.MustCompile(`^(https?://)?dev\.azure\.com(/.*)?$`),
codersdk.ExternalAuthProviderBitBucket: regexp.MustCompile(`^(https?://)?bitbucket\.org(/.*)?$`),
codersdk.ExternalAuthProviderGitLab: regexp.MustCompile(`^(https?://)?gitlab\.com(/.*)?$`),
codersdk.ExternalAuthProviderGitHub: regexp.MustCompile(`^(https?://)?github\.com(/.*)?$`),
}
// jwtConfig is a new OAuth2 config that uses a custom
// assertion method that works with Azure Devops. See:
// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops
type jwtConfig struct {
*oauth2.Config
}
func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...)
}
func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
v := url.Values{
"client_assertion_type": {},
"client_assertion": {c.ClientSecret},
"assertion": {code},
"grant_type": {},
}
if c.RedirectURL != "" {
v.Set("redirect_uri", c.RedirectURL)
}
return c.Config.Exchange(ctx, code,
append(opts,
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
oauth2.SetAuthURLParam("client_assertion", c.ClientSecret),
oauth2.SetAuthURLParam("assertion", code),
oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
oauth2.SetAuthURLParam("code", ""),
)...,
)
}
type DeviceAuth struct {
ClientID string
TokenURL string
Scopes []string
CodeURL string
}
// AuthorizeDevice begins the device authorization flow.
// See: https://tools.ietf.org/html/rfc8628#section-3.1
func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAuthDevice, error) {
if c.CodeURL == "" {
return nil, xerrors.New("oauth2: device code URL not set")
}
codeURL, err := c.formatDeviceCodeURL()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, codeURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r struct {
codersdk.ExternalAuthDevice
ErrorDescription string `json:"error_description"`
}
err = json.NewDecoder(resp.Body).Decode(&r)
if err != nil {
return nil, err
}
if r.ErrorDescription != "" {
return nil, xerrors.New(r.ErrorDescription)
}
return &r.ExternalAuthDevice, nil
}
type ExchangeDeviceCodeResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// ExchangeDeviceCode exchanges a device code for an access token.
// The boolean returned indicates whether the device code is still pending
// and the caller should try again.
func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
if c.TokenURL == "" {
return nil, xerrors.New("oauth2: token URL not set")
}
tokenURL, err := c.formatDeviceTokenURL(deviceCode)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, codersdk.ReadBodyAsError(resp)
}
var body ExchangeDeviceCodeResponse
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Error != "" {
return nil, xerrors.New(body.Error)
}
return &oauth2.Token{
AccessToken: body.AccessToken,
RefreshToken: body.RefreshToken,
Expiry: dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second),
}, nil
}
func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) (string, error) {
tok, err := url.Parse(c.TokenURL)
if err != nil {
return "", err
}
tok.RawQuery = url.Values{
"client_id": {c.ClientID},
"device_code": {deviceCode},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
}.Encode()
return tok.String(), nil
}
func (c *DeviceAuth) formatDeviceCodeURL() (string, error) {
cod, err := url.Parse(c.CodeURL)
if err != nil {
return "", err
}
cod.RawQuery = url.Values{
"client_id": {c.ClientID},
"scope": c.Scopes,
}.Encode()
return cod.String(), nil
}