mirror of https://github.com/coder/coder.git
chore: instrument external oauth2 requests (#11519)
* chore: instrument external oauth2 requests External requests made by oauth2 configs are now instrumented into prometheus metrics.
This commit is contained in:
parent
aa7fe075a8
commit
50b78e3325
|
@ -767,11 +767,11 @@ func TestCreateWithGitAuth(t *testing.T) {
|
||||||
|
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Regex: regexp.MustCompile(`github\.com`),
|
Regex: regexp.MustCompile(`github\.com`),
|
||||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||||
DisplayName: "GitHub",
|
DisplayName: "GitHub",
|
||||||
}},
|
}},
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
})
|
})
|
||||||
|
|
|
@ -80,6 +80,7 @@ import (
|
||||||
"github.com/coder/coder/v2/coderd/oauthpki"
|
"github.com/coder/coder/v2/coderd/oauthpki"
|
||||||
"github.com/coder/coder/v2/coderd/prometheusmetrics"
|
"github.com/coder/coder/v2/coderd/prometheusmetrics"
|
||||||
"github.com/coder/coder/v2/coderd/prometheusmetrics/insights"
|
"github.com/coder/coder/v2/coderd/prometheusmetrics/insights"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/coderd/schedule"
|
"github.com/coder/coder/v2/coderd/schedule"
|
||||||
"github.com/coder/coder/v2/coderd/telemetry"
|
"github.com/coder/coder/v2/coderd/telemetry"
|
||||||
"github.com/coder/coder/v2/coderd/tracing"
|
"github.com/coder/coder/v2/coderd/tracing"
|
||||||
|
@ -133,7 +134,7 @@ func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*co
|
||||||
Scopes: vals.OIDC.Scopes,
|
Scopes: vals.OIDC.Scopes,
|
||||||
}
|
}
|
||||||
|
|
||||||
var useCfg httpmw.OAuth2Config = oauthCfg
|
var useCfg promoauth.OAuth2Config = oauthCfg
|
||||||
if vals.OIDC.ClientKeyFile != "" {
|
if vals.OIDC.ClientKeyFile != "" {
|
||||||
// PKI authentication is done in the params. If a
|
// PKI authentication is done in the params. If a
|
||||||
// counter example is found, we can add a config option to
|
// counter example is found, we can add a config option to
|
||||||
|
@ -523,8 +524,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||||
return xerrors.Errorf("read external auth providers from env: %w", err)
|
return xerrors.Errorf("read external auth providers from env: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
promRegistry := prometheus.NewRegistry()
|
||||||
|
oauthInstrument := promoauth.NewFactory(promRegistry)
|
||||||
vals.ExternalAuthConfigs.Value = append(vals.ExternalAuthConfigs.Value, extAuthEnv...)
|
vals.ExternalAuthConfigs.Value = append(vals.ExternalAuthConfigs.Value, extAuthEnv...)
|
||||||
externalAuthConfigs, err := externalauth.ConvertConfig(
|
externalAuthConfigs, err := externalauth.ConvertConfig(
|
||||||
|
oauthInstrument,
|
||||||
vals.ExternalAuthConfigs.Value,
|
vals.ExternalAuthConfigs.Value,
|
||||||
vals.AccessURL.Value(),
|
vals.AccessURL.Value(),
|
||||||
)
|
)
|
||||||
|
@ -571,7 +575,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||||
// the DeploymentValues instead, this just serves to indicate the source of each
|
// the DeploymentValues instead, this just serves to indicate the source of each
|
||||||
// option. This is just defensive to prevent accidentally leaking.
|
// option. This is just defensive to prevent accidentally leaking.
|
||||||
DeploymentOptions: codersdk.DeploymentOptionsWithoutSecrets(opts),
|
DeploymentOptions: codersdk.DeploymentOptionsWithoutSecrets(opts),
|
||||||
PrometheusRegistry: prometheus.NewRegistry(),
|
PrometheusRegistry: promRegistry,
|
||||||
APIRateLimit: int(vals.RateLimit.API.Value()),
|
APIRateLimit: int(vals.RateLimit.API.Value()),
|
||||||
LoginRateLimit: loginRateLimit,
|
LoginRateLimit: loginRateLimit,
|
||||||
FilesRateLimit: filesRateLimit,
|
FilesRateLimit: filesRateLimit,
|
||||||
|
@ -617,7 +621,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||||
}
|
}
|
||||||
|
|
||||||
if vals.OAuth2.Github.ClientSecret != "" {
|
if vals.OAuth2.Github.ClientSecret != "" {
|
||||||
options.GithubOAuth2Config, err = configureGithubOAuth2(vals.AccessURL.Value(),
|
options.GithubOAuth2Config, err = configureGithubOAuth2(
|
||||||
|
oauthInstrument,
|
||||||
|
vals.AccessURL.Value(),
|
||||||
vals.OAuth2.Github.ClientID.String(),
|
vals.OAuth2.Github.ClientID.String(),
|
||||||
vals.OAuth2.Github.ClientSecret.String(),
|
vals.OAuth2.Github.ClientSecret.String(),
|
||||||
vals.OAuth2.Github.AllowSignups.Value(),
|
vals.OAuth2.Github.AllowSignups.Value(),
|
||||||
|
@ -636,6 +642,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||||
logger.Warn(ctx, "coder will not check email_verified for OIDC logins")
|
logger.Warn(ctx, "coder will not check email_verified for OIDC logins")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This OIDC config is **not** being instrumented with the
|
||||||
|
// oauth2 instrument wrapper. If we implement the missing
|
||||||
|
// oidc methods, then we can instrument it.
|
||||||
|
// Missing:
|
||||||
|
// - Userinfo
|
||||||
|
// - Verify
|
||||||
oc, err := createOIDCConfig(ctx, vals)
|
oc, err := createOIDCConfig(ctx, vals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("create oidc config: %w", err)
|
return xerrors.Errorf("create oidc config: %w", err)
|
||||||
|
@ -1737,7 +1749,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)
|
//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
|
||||||
func configureGithubOAuth2(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, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
|
||||||
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
|
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
|
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
|
||||||
|
@ -1790,7 +1802,7 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
|
||||||
}
|
}
|
||||||
|
|
||||||
return &coderd.GithubOAuth2Config{
|
return &coderd.GithubOAuth2Config{
|
||||||
OAuth2Config: &oauth2.Config{
|
OAuth2Config: instrument.New("github-login", &oauth2.Config{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
|
@ -1800,7 +1812,7 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
|
||||||
"read:org",
|
"read:org",
|
||||||
"user:email",
|
"user:email",
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
AllowSignups: allowSignups,
|
AllowSignups: allowSignups,
|
||||||
AllowEveryone: allowEveryone,
|
AllowEveryone: allowEveryone,
|
||||||
AllowOrganizations: allowOrgs,
|
AllowOrganizations: allowOrgs,
|
||||||
|
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"github.com/go-jose/go-jose/v3"
|
"github.com/go-jose/go-jose/v3"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
@ -33,6 +34,7 @@ import (
|
||||||
"cdr.dev/slog/sloggers/slogtest"
|
"cdr.dev/slog/sloggers/slogtest"
|
||||||
"github.com/coder/coder/v2/coderd"
|
"github.com/coder/coder/v2/coderd"
|
||||||
"github.com/coder/coder/v2/coderd/externalauth"
|
"github.com/coder/coder/v2/coderd/externalauth"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/coderd/util/syncmap"
|
"github.com/coder/coder/v2/coderd/util/syncmap"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
)
|
)
|
||||||
|
@ -223,6 +225,10 @@ func (f *FakeIDP) WellknownConfig() ProviderJSON {
|
||||||
return f.provider
|
return f.provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FakeIDP) IssuerURL() *url.URL {
|
||||||
|
return f.issuerURL
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) {
|
func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
@ -397,6 +403,44 @@ func (f *FakeIDP) ExternalLogin(t testing.TB, client *codersdk.Client, opts ...f
|
||||||
_ = res.Body.Close()
|
_ = res.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// actual requests.
|
||||||
|
func (f *FakeIDP) CreateAuthCode(t testing.TB, state string, opts ...func(r *http.Request)) string {
|
||||||
|
// We need to store some claims, because this is also an OIDC provider, and
|
||||||
|
// it expects some claims to be present.
|
||||||
|
f.stateToIDTokenClaims.Store(state, jwt.MapClaims{})
|
||||||
|
|
||||||
|
u := f.cfg.AuthCodeURL(state)
|
||||||
|
r, err := http.NewRequestWithContext(context.Background(), http.MethodPost, u, nil)
|
||||||
|
require.NoError(t, err, "failed to create auth request")
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
f.handler.ServeHTTP(rw, r)
|
||||||
|
resp := rw.Result()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode, "expected redirect")
|
||||||
|
to := resp.Header.Get("Location")
|
||||||
|
require.NotEmpty(t, to, "expected redirect location")
|
||||||
|
|
||||||
|
toURL, err := url.Parse(to)
|
||||||
|
require.NoError(t, err, "failed to parse redirect location")
|
||||||
|
|
||||||
|
code := toURL.Query().Get("code")
|
||||||
|
require.NotEmpty(t, code, "expected code in redirect location")
|
||||||
|
|
||||||
|
newState := toURL.Query().Get("state")
|
||||||
|
require.Equal(t, state, newState, "expected state to match")
|
||||||
|
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
// OIDCCallback will emulate the IDP redirecting back to the Coder callback.
|
// OIDCCallback will emulate the IDP redirecting back to the Coder callback.
|
||||||
// This is helpful if no Coderd exists because the IDP needs to redirect to
|
// This is helpful if no Coderd exists because the IDP needs to redirect to
|
||||||
// something.
|
// something.
|
||||||
|
@ -901,9 +945,10 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
|
||||||
handle(email, rw, r)
|
handle(email, rw, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
instrumentF := promoauth.NewFactory(prometheus.NewRegistry())
|
||||||
cfg := &externalauth.Config{
|
cfg := &externalauth.Config{
|
||||||
OAuth2Config: f.OIDCConfig(t, nil),
|
InstrumentedOAuth2Config: instrumentF.New(f.clientID, f.OIDCConfig(t, nil)),
|
||||||
ID: id,
|
ID: id,
|
||||||
// No defaults for these fields by omitting the type
|
// No defaults for these fields by omitting the type
|
||||||
Type: "",
|
Type: "",
|
||||||
DisplayIcon: f.WellknownConfig().UserInfoURL,
|
DisplayIcon: f.WellknownConfig().UserInfoURL,
|
||||||
|
@ -920,10 +965,10 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
|
||||||
// OIDCConfig returns the OIDC config to use for Coderd.
|
// OIDCConfig returns the OIDC config to use for Coderd.
|
||||||
func (f *FakeIDP) OIDCConfig(t testing.TB, scopes []string, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig {
|
func (f *FakeIDP) OIDCConfig(t testing.TB, scopes []string, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if len(scopes) == 0 {
|
if len(scopes) == 0 {
|
||||||
scopes = []string{"openid", "email", "profile"}
|
scopes = []string{"openid", "email", "profile"}
|
||||||
}
|
}
|
||||||
|
|
||||||
oauthCfg := &oauth2.Config{
|
oauthCfg := &oauth2.Config{
|
||||||
ClientID: f.clientID,
|
ClientID: f.clientID,
|
||||||
ClientSecret: f.clientSecret,
|
ClientSecret: f.clientSecret,
|
||||||
|
@ -966,7 +1011,6 @@ func (f *FakeIDP) OIDCConfig(t testing.TB, scopes []string, opts ...func(cfg *co
|
||||||
}
|
}
|
||||||
|
|
||||||
f.cfg = oauthCfg
|
f.cfg = oauthCfg
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,19 +22,14 @@ import (
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/retry"
|
"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.
|
// Config is used for authentication for Git operations.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
OAuth2Config
|
promoauth.InstrumentedOAuth2Config
|
||||||
// ID is a unique identifier for the authenticator.
|
// ID is a unique identifier for the authenticator.
|
||||||
ID string
|
ID string
|
||||||
// Type is the type of provider.
|
// Type is the type of provider.
|
||||||
|
@ -192,12 +187,8 @@ func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *coders
|
||||||
return false, nil, err
|
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))
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
res, err := cli.Do(req)
|
res, err := c.InstrumentedOAuth2Config.Do(ctx, promoauth.SourceValidateToken, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, err
|
return false, nil, err
|
||||||
}
|
}
|
||||||
|
@ -247,7 +238,7 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
res, err := http.DefaultClient.Do(req)
|
res, err := c.InstrumentedOAuth2Config.Do(ctx, promoauth.SourceAppInstallations, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
@ -287,6 +278,8 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeviceAuth struct {
|
type DeviceAuth struct {
|
||||||
|
// Config is provided for the http client method.
|
||||||
|
Config promoauth.InstrumentedOAuth2Config
|
||||||
ClientID string
|
ClientID string
|
||||||
TokenURL string
|
TokenURL string
|
||||||
Scopes []string
|
Scopes []string
|
||||||
|
@ -307,8 +300,17 @@ func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAut
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
do := http.DefaultClient.Do
|
||||||
|
if c.Config != nil {
|
||||||
|
// The cfg can be nil in unit tests.
|
||||||
|
do = func(req *http.Request) (*http.Response, error) {
|
||||||
|
return c.Config.Do(ctx, promoauth.SourceAuthorizeDevice, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := do(req)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -401,7 +403,7 @@ func (c *DeviceAuth) formatDeviceCodeURL() (string, error) {
|
||||||
|
|
||||||
// ConvertConfig converts the SDK configuration entry format
|
// ConvertConfig converts the SDK configuration entry format
|
||||||
// to the parsed and ready-to-consume in coderd provider type.
|
// to the parsed and ready-to-consume in coderd provider type.
|
||||||
func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) {
|
func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) {
|
||||||
ids := map[string]struct{}{}
|
ids := map[string]struct{}{}
|
||||||
configs := []*Config{}
|
configs := []*Config{}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
@ -453,7 +455,7 @@ func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([
|
||||||
Scopes: entry.Scopes,
|
Scopes: entry.Scopes,
|
||||||
}
|
}
|
||||||
|
|
||||||
var oauthConfig OAuth2Config = oc
|
var oauthConfig promoauth.OAuth2Config = oc
|
||||||
// Azure DevOps uses JWT token authentication!
|
// Azure DevOps uses JWT token authentication!
|
||||||
if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) {
|
if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) {
|
||||||
oauthConfig = &jwtConfig{oc}
|
oauthConfig = &jwtConfig{oc}
|
||||||
|
@ -463,17 +465,17 @@ func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
OAuth2Config: oauthConfig,
|
InstrumentedOAuth2Config: instrument.New(entry.ID, oauthConfig),
|
||||||
ID: entry.ID,
|
ID: entry.ID,
|
||||||
Regex: regex,
|
Regex: regex,
|
||||||
Type: entry.Type,
|
Type: entry.Type,
|
||||||
NoRefresh: entry.NoRefresh,
|
NoRefresh: entry.NoRefresh,
|
||||||
ValidateURL: entry.ValidateURL,
|
ValidateURL: entry.ValidateURL,
|
||||||
AppInstallationsURL: entry.AppInstallationsURL,
|
AppInstallationsURL: entry.AppInstallationsURL,
|
||||||
AppInstallURL: entry.AppInstallURL,
|
AppInstallURL: entry.AppInstallURL,
|
||||||
DisplayName: entry.DisplayName,
|
DisplayName: entry.DisplayName,
|
||||||
DisplayIcon: entry.DisplayIcon,
|
DisplayIcon: entry.DisplayIcon,
|
||||||
ExtraTokenKeys: entry.ExtraTokenKeys,
|
ExtraTokenKeys: entry.ExtraTokenKeys,
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.DeviceFlow {
|
if entry.DeviceFlow {
|
||||||
|
@ -481,6 +483,7 @@ func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([
|
||||||
return nil, xerrors.Errorf("external auth provider %q: device auth url must be provided", entry.ID)
|
return nil, xerrors.Errorf("external auth provider %q: device auth url must be provided", entry.ID)
|
||||||
}
|
}
|
||||||
cfg.DeviceAuth = &DeviceAuth{
|
cfg.DeviceAuth = &DeviceAuth{
|
||||||
|
Config: cfg,
|
||||||
ClientID: entry.ClientID,
|
ClientID: entry.ClientID,
|
||||||
TokenURL: oc.Endpoint.TokenURL,
|
TokenURL: oc.Endpoint.TokenURL,
|
||||||
Scopes: entry.Scopes,
|
Scopes: entry.Scopes,
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
@ -22,6 +23,7 @@ import (
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbmem"
|
"github.com/coder/coder/v2/coderd/database/dbmem"
|
||||||
"github.com/coder/coder/v2/coderd/externalauth"
|
"github.com/coder/coder/v2/coderd/externalauth"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/testutil"
|
"github.com/coder/coder/v2/testutil"
|
||||||
)
|
)
|
||||||
|
@ -94,7 +96,7 @@ func TestRefreshToken(t *testing.T) {
|
||||||
t.Run("FalseIfTokenSourceFails", func(t *testing.T) {
|
t.Run("FalseIfTokenSourceFails", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
config := &externalauth.Config{
|
config := &externalauth.Config{
|
||||||
OAuth2Config: &testutil.OAuth2Config{
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{
|
||||||
TokenSourceFunc: func() (*oauth2.Token, error) {
|
TokenSourceFunc: func() (*oauth2.Token, error) {
|
||||||
return nil, xerrors.New("failure")
|
return nil, xerrors.New("failure")
|
||||||
},
|
},
|
||||||
|
@ -301,9 +303,10 @@ func TestRefreshToken(t *testing.T) {
|
||||||
|
|
||||||
func TestExchangeWithClientSecret(t *testing.T) {
|
func TestExchangeWithClientSecret(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
instrument := promoauth.NewFactory(prometheus.NewRegistry())
|
||||||
// This ensures a provider that requires the custom
|
// This ensures a provider that requires the custom
|
||||||
// client secret exchange works.
|
// client secret exchange works.
|
||||||
configs, err := externalauth.ConvertConfig([]codersdk.ExternalAuthConfig{{
|
configs, err := externalauth.ConvertConfig(instrument, []codersdk.ExternalAuthConfig{{
|
||||||
// JFrog just happens to require this custom type.
|
// JFrog just happens to require this custom type.
|
||||||
|
|
||||||
Type: codersdk.EnhancedExternalAuthProviderJFrog.String(),
|
Type: codersdk.EnhancedExternalAuthProviderJFrog.String(),
|
||||||
|
@ -335,6 +338,8 @@ func TestExchangeWithClientSecret(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertYAML(t *testing.T) {
|
func TestConvertYAML(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
instrument := promoauth.NewFactory(prometheus.NewRegistry())
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
Name string
|
Name string
|
||||||
Input []codersdk.ExternalAuthConfig
|
Input []codersdk.ExternalAuthConfig
|
||||||
|
@ -387,7 +392,7 @@ func TestConvertYAML(t *testing.T) {
|
||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.Name, func(t *testing.T) {
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
output, err := externalauth.ConvertConfig(tc.Input, &url.URL{})
|
output, err := externalauth.ConvertConfig(instrument, tc.Input, &url.URL{})
|
||||||
if tc.Error != "" {
|
if tc.Error != "" {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Contains(t, err.Error(), tc.Error)
|
require.Contains(t, err.Error(), tc.Error)
|
||||||
|
@ -399,7 +404,7 @@ func TestConvertYAML(t *testing.T) {
|
||||||
|
|
||||||
t.Run("CustomScopesAndEndpoint", func(t *testing.T) {
|
t.Run("CustomScopesAndEndpoint", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
config, err := externalauth.ConvertConfig([]codersdk.ExternalAuthConfig{{
|
config, err := externalauth.ConvertConfig(instrument, []codersdk.ExternalAuthConfig{{
|
||||||
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
||||||
ClientID: "id",
|
ClientID: "id",
|
||||||
ClientSecret: "secret",
|
ClientSecret: "secret",
|
||||||
|
@ -433,10 +438,12 @@ func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *ext
|
||||||
append([]oidctest.FakeIDPOpt{}, settings.FakeIDPOpts...)...,
|
append([]oidctest.FakeIDPOpt{}, settings.FakeIDPOpts...)...,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
f := promoauth.NewFactory(prometheus.NewRegistry())
|
||||||
config := &externalauth.Config{
|
config := &externalauth.Config{
|
||||||
OAuth2Config: fake.OIDCConfig(t, nil, settings.CoderOIDCConfigOpts...),
|
InstrumentedOAuth2Config: f.New("test-oauth2",
|
||||||
ID: providerID,
|
fake.OIDCConfig(t, nil, settings.CoderOIDCConfigOpts...)),
|
||||||
ValidateURL: fake.WellknownConfig().UserInfoURL,
|
ID: providerID,
|
||||||
|
ValidateURL: fake.WellknownConfig().UserInfoURL,
|
||||||
}
|
}
|
||||||
settings.ExternalAuthOpt(config)
|
settings.ExternalAuthOpt(config)
|
||||||
|
|
||||||
|
|
|
@ -316,10 +316,10 @@ func TestExternalAuthCallback(t *testing.T) {
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Regex: regexp.MustCompile(`github\.com`),
|
Regex: regexp.MustCompile(`github\.com`),
|
||||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
user := coderdtest.CreateFirstUser(t, client)
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
@ -347,10 +347,10 @@ func TestExternalAuthCallback(t *testing.T) {
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Regex: regexp.MustCompile(`github\.com`),
|
Regex: regexp.MustCompile(`github\.com`),
|
||||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
resp := coderdtest.RequestExternalAuthCallback(t, "github", client)
|
resp := coderdtest.RequestExternalAuthCallback(t, "github", client)
|
||||||
|
@ -361,10 +361,10 @@ func TestExternalAuthCallback(t *testing.T) {
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Regex: regexp.MustCompile(`github\.com`),
|
Regex: regexp.MustCompile(`github\.com`),
|
||||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
@ -387,11 +387,11 @@ func TestExternalAuthCallback(t *testing.T) {
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
ValidateURL: srv.URL,
|
ValidateURL: srv.URL,
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Regex: regexp.MustCompile(`github\.com`),
|
Regex: regexp.MustCompile(`github\.com`),
|
||||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
user := coderdtest.CreateFirstUser(t, client)
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
@ -443,7 +443,7 @@ func TestExternalAuthCallback(t *testing.T) {
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
OAuth2Config: &testutil.OAuth2Config{
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{
|
||||||
Token: &oauth2.Token{
|
Token: &oauth2.Token{
|
||||||
AccessToken: "token",
|
AccessToken: "token",
|
||||||
RefreshToken: "something",
|
RefreshToken: "something",
|
||||||
|
@ -497,10 +497,10 @@ func TestExternalAuthCallback(t *testing.T) {
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Regex: regexp.MustCompile(`github\.com`),
|
Regex: regexp.MustCompile(`github\.com`),
|
||||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
user := coderdtest.CreateFirstUser(t, client)
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/coderd/rbac"
|
"github.com/coder/coder/v2/coderd/rbac"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
)
|
)
|
||||||
|
@ -74,8 +75,8 @@ func UserAuthorization(r *http.Request) Authorization {
|
||||||
// OAuth2Configs is a collection of configurations for OAuth-based authentication.
|
// OAuth2Configs is a collection of configurations for OAuth-based authentication.
|
||||||
// This should be extended to support other authentication types in the future.
|
// This should be extended to support other authentication types in the future.
|
||||||
type OAuth2Configs struct {
|
type OAuth2Configs struct {
|
||||||
Github OAuth2Config
|
Github promoauth.OAuth2Config
|
||||||
OIDC OAuth2Config
|
OIDC promoauth.OAuth2Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OAuth2Configs) IsZero() bool {
|
func (c *OAuth2Configs) IsZero() bool {
|
||||||
|
@ -270,7 +271,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var oauthConfig OAuth2Config
|
var oauthConfig promoauth.OAuth2Config
|
||||||
switch key.LoginType {
|
switch key.LoginType {
|
||||||
case database.LoginTypeGithub:
|
case database.LoginTypeGithub:
|
||||||
oauthConfig = cfg.OAuth2Configs.Github
|
oauthConfig = cfg.OAuth2Configs.Github
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/cryptorand"
|
"github.com/coder/coder/v2/cryptorand"
|
||||||
)
|
)
|
||||||
|
@ -22,14 +23,6 @@ type OAuth2State struct {
|
||||||
StateString string
|
StateString string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2Config exposes a subset of *oauth2.Config functions for easier testing.
|
|
||||||
// *oauth2.Config should be used instead of implementing this in production.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuth2 returns the state from an oauth request.
|
// OAuth2 returns the state from an oauth request.
|
||||||
func OAuth2(r *http.Request) OAuth2State {
|
func OAuth2(r *http.Request) OAuth2State {
|
||||||
oauth, ok := r.Context().Value(oauth2StateKey{}).(OAuth2State)
|
oauth, ok := r.Context().Value(oauth2StateKey{}).(OAuth2State)
|
||||||
|
@ -44,7 +37,7 @@ func OAuth2(r *http.Request) OAuth2State {
|
||||||
// a "code" URL parameter will be redirected.
|
// a "code" URL parameter will be redirected.
|
||||||
// AuthURLOpts are passed to the AuthCodeURL function. If this is nil,
|
// AuthURLOpts are passed to the AuthCodeURL function. If this is nil,
|
||||||
// the default option oauth2.AccessTypeOffline will be used.
|
// the default option oauth2.AccessTypeOffline will be used.
|
||||||
func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[string]string) func(http.Handler) http.Handler {
|
func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOpts map[string]string) func(http.Handler) http.Handler {
|
||||||
opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1)
|
opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1)
|
||||||
opts = append(opts, oauth2.AccessTypeOffline)
|
opts = append(opts, oauth2.AccessTypeOffline)
|
||||||
for k, v := range authURLOpts {
|
for k, v := range authURLOpts {
|
||||||
|
|
|
@ -20,7 +20,7 @@ import (
|
||||||
"golang.org/x/oauth2/jws"
|
"golang.org/x/oauth2/jws"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/httpmw"
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config uses jwt assertions over client_secret for oauth2 authentication of
|
// Config uses jwt assertions over client_secret for oauth2 authentication of
|
||||||
|
@ -33,7 +33,7 @@ import (
|
||||||
//
|
//
|
||||||
// https://datatracker.ietf.org/doc/html/rfc7523
|
// https://datatracker.ietf.org/doc/html/rfc7523
|
||||||
type Config struct {
|
type Config struct {
|
||||||
cfg httpmw.OAuth2Config
|
cfg promoauth.OAuth2Config
|
||||||
|
|
||||||
// These values should match those provided in the oauth2.Config.
|
// These values should match those provided in the oauth2.Config.
|
||||||
// Because the inner config is an interface, we need to duplicate these
|
// Because the inner config is an interface, we need to duplicate these
|
||||||
|
@ -57,7 +57,7 @@ type ConfigParams struct {
|
||||||
PemEncodedKey []byte
|
PemEncodedKey []byte
|
||||||
PemEncodedCert []byte
|
PemEncodedCert []byte
|
||||||
|
|
||||||
Config httpmw.OAuth2Config
|
Config promoauth.OAuth2Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOauth2PKIConfig creates the oauth2 config for PKI based auth. It requires the certificate and it's private key.
|
// NewOauth2PKIConfig creates the oauth2 config for PKI based auth. It requires the certificate and it's private key.
|
||||||
|
@ -180,6 +180,8 @@ func (src *jwtTokenSource) Token() (*oauth2.Token, error) {
|
||||||
}
|
}
|
||||||
cli := http.DefaultClient
|
cli := http.DefaultClient
|
||||||
if v, ok := src.ctx.Value(oauth2.HTTPClient).(*http.Client); ok {
|
if v, ok := src.ctx.Value(oauth2.HTTPClient).(*http.Client); ok {
|
||||||
|
// This client should be the instrumented client already. So no need to
|
||||||
|
// handle this manually.
|
||||||
cli = v
|
cli = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Package promoauth is for instrumenting oauth2 flows with prometheus metrics.
|
||||||
|
// Specifically, it is intended to count the number of external requests made
|
||||||
|
// by the underlying oauth2 exchanges.
|
||||||
|
package promoauth
|
|
@ -0,0 +1,173 @@
|
||||||
|
package promoauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Oauth2Source string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceValidateToken Oauth2Source = "ValidateToken"
|
||||||
|
SourceExchange Oauth2Source = "Exchange"
|
||||||
|
SourceTokenSource Oauth2Source = "TokenSource"
|
||||||
|
SourceAppInstallations Oauth2Source = "AppInstallations"
|
||||||
|
SourceAuthorizeDevice Oauth2Source = "AuthorizeDevice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuth2Config exposes a subset of *oauth2.Config functions for easier testing.
|
||||||
|
// *oauth2.Config should be used instead of implementing this in production.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstrumentedOAuth2Config extends OAuth2Config with a `Do` method that allows
|
||||||
|
// external oauth related calls to be instrumented. This is to support
|
||||||
|
// "ValidateToken" which is not an oauth2 specified method.
|
||||||
|
// These calls still count against the api rate limit, and should be instrumented.
|
||||||
|
type InstrumentedOAuth2Config interface {
|
||||||
|
OAuth2Config
|
||||||
|
|
||||||
|
// Do is provided as a convenience method to make a request with the oauth2 client.
|
||||||
|
// It mirrors `http.Client.Do`.
|
||||||
|
Do(ctx context.Context, source Oauth2Source, req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ OAuth2Config = (*Config)(nil)
|
||||||
|
|
||||||
|
// Factory allows us to have 1 set of metrics for all oauth2 providers.
|
||||||
|
// Primarily to avoid any prometheus errors registering duplicate metrics.
|
||||||
|
type Factory struct {
|
||||||
|
metrics *metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// metrics is the reusable metrics for all oauth2 providers.
|
||||||
|
type metrics struct {
|
||||||
|
externalRequestCount *prometheus.CounterVec
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFactory(registry prometheus.Registerer) *Factory {
|
||||||
|
factory := promauto.With(registry)
|
||||||
|
|
||||||
|
return &Factory{
|
||||||
|
metrics: &metrics{
|
||||||
|
externalRequestCount: factory.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "coderd",
|
||||||
|
Subsystem: "oauth2",
|
||||||
|
Name: "external_requests_total",
|
||||||
|
Help: "The total number of api calls made to external oauth2 providers. 'status_code' will be 0 if the request failed with no response.",
|
||||||
|
}, []string{
|
||||||
|
"name",
|
||||||
|
"source",
|
||||||
|
"status_code",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Factory) New(name string, under OAuth2Config) *Config {
|
||||||
|
return &Config{
|
||||||
|
name: name,
|
||||||
|
underlying: under,
|
||||||
|
metrics: f.metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
// Name is a human friendly name to identify the oauth2 provider. This should be
|
||||||
|
// deterministic from restart to restart, as it is going to be used as a label in
|
||||||
|
// prometheus metrics.
|
||||||
|
name string
|
||||||
|
underlying OAuth2Config
|
||||||
|
metrics *metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Do(ctx context.Context, source Oauth2Source, req *http.Request) (*http.Response, error) {
|
||||||
|
cli := c.oauthHTTPClient(ctx, source)
|
||||||
|
return cli.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||||
|
// No external requests are made when constructing the auth code url.
|
||||||
|
return c.underlying.AuthCodeURL(state, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||||
|
return c.underlying.Exchange(c.wrapClient(ctx, SourceExchange), code, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource {
|
||||||
|
return c.underlying.TokenSource(c.wrapClient(ctx, SourceTokenSource), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapClient is the only way we can accurately instrument the oauth2 client.
|
||||||
|
// This is because method calls to the 'OAuth2Config' interface are not 1:1 with
|
||||||
|
// network requests.
|
||||||
|
//
|
||||||
|
// For example, the 'TokenSource' method will return a token
|
||||||
|
// source that will make a network request when the 'Token' method is called on
|
||||||
|
// it if the token is expired.
|
||||||
|
func (c *Config) wrapClient(ctx context.Context, source Oauth2Source) context.Context {
|
||||||
|
return context.WithValue(ctx, oauth2.HTTPClient, c.oauthHTTPClient(ctx, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthHTTPClient returns an http client that will instrument every request made.
|
||||||
|
func (c *Config) oauthHTTPClient(ctx context.Context, source Oauth2Source) *http.Client {
|
||||||
|
cli := &http.Client{}
|
||||||
|
|
||||||
|
// Check if the context has a http client already.
|
||||||
|
if hc, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok {
|
||||||
|
cli = hc
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new tripper will instrument every request made by the oauth2 client.
|
||||||
|
cli.Transport = newInstrumentedTripper(c, source, cli.Transport)
|
||||||
|
return cli
|
||||||
|
}
|
||||||
|
|
||||||
|
type instrumentedTripper struct {
|
||||||
|
c *Config
|
||||||
|
source Oauth2Source
|
||||||
|
underlying http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInstrumentedTripper intercepts a http request, and increments the
|
||||||
|
// externalRequestCount metric.
|
||||||
|
func newInstrumentedTripper(c *Config, source Oauth2Source, under http.RoundTripper) *instrumentedTripper {
|
||||||
|
if under == nil {
|
||||||
|
under = http.DefaultTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the underlying transport is the default, we need to clone it.
|
||||||
|
// We should also clone it if it supports cloning.
|
||||||
|
if tr, ok := under.(*http.Transport); ok {
|
||||||
|
under = tr.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &instrumentedTripper{
|
||||||
|
c: c,
|
||||||
|
source: source,
|
||||||
|
underlying: under,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *instrumentedTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := i.underlying.RoundTrip(r)
|
||||||
|
var statusCode int
|
||||||
|
if resp != nil {
|
||||||
|
statusCode = resp.StatusCode
|
||||||
|
}
|
||||||
|
i.c.metrics.externalRequestCount.With(prometheus.Labels{
|
||||||
|
"name": i.c.name,
|
||||||
|
"source": string(i.source),
|
||||||
|
"status_code": fmt.Sprintf("%d", statusCode),
|
||||||
|
}).Inc()
|
||||||
|
return resp, err
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
package promoauth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
ptestutil "github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
||||||
|
"github.com/coder/coder/v2/coderd/externalauth"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
|
"github.com/coder/coder/v2/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstrument(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
idp := oidctest.NewFakeIDP(t, oidctest.WithServing())
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
count := func() int {
|
||||||
|
return ptestutil.CollectAndCount(reg, "coderd_oauth2_external_requests_total")
|
||||||
|
}
|
||||||
|
|
||||||
|
factory := promoauth.NewFactory(reg)
|
||||||
|
const id = "test"
|
||||||
|
cfg := externalauth.Config{
|
||||||
|
InstrumentedOAuth2Config: factory.New(id, idp.OIDCConfig(t, []string{})),
|
||||||
|
ID: "test",
|
||||||
|
ValidateURL: must[*url.URL](t)(idp.IssuerURL().Parse("/oauth2/userinfo")).String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 Requests before we start
|
||||||
|
require.Equal(t, count(), 0)
|
||||||
|
|
||||||
|
// Exchange should trigger a request
|
||||||
|
code := idp.CreateAuthCode(t, "foo")
|
||||||
|
token, err := cfg.Exchange(ctx, code)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, count(), 1)
|
||||||
|
|
||||||
|
// Force a refresh
|
||||||
|
token.Expiry = time.Now().Add(time.Hour * -1)
|
||||||
|
src := cfg.TokenSource(ctx, token)
|
||||||
|
refreshed, err := src.Token()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, token.AccessToken, refreshed.AccessToken, "token refreshed")
|
||||||
|
require.Equal(t, count(), 2)
|
||||||
|
|
||||||
|
// Try a validate
|
||||||
|
valid, _, err := cfg.ValidateToken(ctx, refreshed.AccessToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, valid)
|
||||||
|
require.Equal(t, count(), 3)
|
||||||
|
|
||||||
|
// Verify the default client was not broken. This check is added because we
|
||||||
|
// extend the http.DefaultTransport. If a `.Clone()` is not done, this can be
|
||||||
|
// mis-used. It is cheap to run this quick check.
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
must[*url.URL](t)(idp.IssuerURL().Parse("/.well-known/openid-configuration")).String(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, count(), 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func must[V any](t *testing.T) func(v V, err error) V {
|
||||||
|
return func(v V, err error) V {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,7 +32,7 @@ import (
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||||
"github.com/coder/coder/v2/coderd/externalauth"
|
"github.com/coder/coder/v2/coderd/externalauth"
|
||||||
"github.com/coder/coder/v2/coderd/httpmw"
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/coderd/schedule"
|
"github.com/coder/coder/v2/coderd/schedule"
|
||||||
"github.com/coder/coder/v2/coderd/telemetry"
|
"github.com/coder/coder/v2/coderd/telemetry"
|
||||||
"github.com/coder/coder/v2/coderd/tracing"
|
"github.com/coder/coder/v2/coderd/tracing"
|
||||||
|
@ -55,7 +55,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
OIDCConfig httpmw.OAuth2Config
|
OIDCConfig promoauth.OAuth2Config
|
||||||
ExternalAuthConfigs []*externalauth.Config
|
ExternalAuthConfigs []*externalauth.Config
|
||||||
// TimeNowFn is only used in tests
|
// TimeNowFn is only used in tests
|
||||||
TimeNowFn func() time.Time
|
TimeNowFn func() time.Time
|
||||||
|
@ -96,7 +96,7 @@ type server struct {
|
||||||
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
|
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
|
||||||
DeploymentValues *codersdk.DeploymentValues
|
DeploymentValues *codersdk.DeploymentValues
|
||||||
|
|
||||||
OIDCConfig httpmw.OAuth2Config
|
OIDCConfig promoauth.OAuth2Config
|
||||||
|
|
||||||
TimeNowFn func() time.Time
|
TimeNowFn func() time.Time
|
||||||
|
|
||||||
|
@ -1736,7 +1736,7 @@ func deleteSessionToken(ctx context.Context, db database.Store, workspace databa
|
||||||
|
|
||||||
// obtainOIDCAccessToken returns a valid OpenID Connect access token
|
// obtainOIDCAccessToken returns a valid OpenID Connect access token
|
||||||
// for the user if it's able to obtain one, otherwise it returns an empty string.
|
// for the user if it's able to obtain one, otherwise it returns an empty string.
|
||||||
func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig httpmw.OAuth2Config, userID uuid.UUID) (string, error) {
|
func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig promoauth.OAuth2Config, userID uuid.UUID) (string, error) {
|
||||||
link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
LoginType: database.LoginTypeOIDC,
|
LoginType: database.LoginTypeOIDC,
|
||||||
|
|
|
@ -187,8 +187,8 @@ func TestAcquireJob(t *testing.T) {
|
||||||
srv, db, ps, _ := setup(t, false, &overrides{
|
srv, db, ps, _ := setup(t, false, &overrides{
|
||||||
deploymentValues: dv,
|
deploymentValues: dv,
|
||||||
externalAuthConfigs: []*externalauth.Config{{
|
externalAuthConfigs: []*externalauth.Config{{
|
||||||
ID: gitAuthProvider,
|
ID: gitAuthProvider,
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
|
|
@ -335,10 +335,10 @@ func TestTemplateVersionsExternalAuth(t *testing.T) {
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
IncludeProvisionerDaemon: true,
|
IncludeProvisionerDaemon: true,
|
||||||
ExternalAuthConfigs: []*externalauth.Config{{
|
ExternalAuthConfigs: []*externalauth.Config{{
|
||||||
OAuth2Config: &testutil.OAuth2Config{},
|
InstrumentedOAuth2Config: &testutil.OAuth2Config{},
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Regex: regexp.MustCompile(`github\.com`),
|
Regex: regexp.MustCompile(`github\.com`),
|
||||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
user := coderdtest.CreateFirstUser(t, client)
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
|
@ -31,6 +31,7 @@ import (
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
"github.com/coder/coder/v2/coderd/httpmw"
|
"github.com/coder/coder/v2/coderd/httpmw"
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
"github.com/coder/coder/v2/coderd/rbac"
|
"github.com/coder/coder/v2/coderd/rbac"
|
||||||
"github.com/coder/coder/v2/coderd/userpassword"
|
"github.com/coder/coder/v2/coderd/userpassword"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
@ -438,7 +439,7 @@ type GithubOAuth2Team struct {
|
||||||
|
|
||||||
// GithubOAuth2Provider exposes required functions for the Github authentication flow.
|
// GithubOAuth2Provider exposes required functions for the Github authentication flow.
|
||||||
type GithubOAuth2Config struct {
|
type GithubOAuth2Config struct {
|
||||||
httpmw.OAuth2Config
|
promoauth.OAuth2Config
|
||||||
AuthenticatedUser func(ctx context.Context, client *http.Client) (*github.User, error)
|
AuthenticatedUser func(ctx context.Context, client *http.Client) (*github.User, error)
|
||||||
ListEmails func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error)
|
ListEmails func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error)
|
||||||
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
|
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
|
||||||
|
@ -662,7 +663,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDCConfig struct {
|
type OIDCConfig struct {
|
||||||
httpmw.OAuth2Config
|
promoauth.OAuth2Config
|
||||||
|
|
||||||
Provider *oidc.Provider
|
Provider *oidc.Provider
|
||||||
Verifier *oidc.IDTokenVerifier
|
Verifier *oidc.IDTokenVerifier
|
||||||
|
|
|
@ -2,10 +2,13 @@ package testutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/promoauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OAuth2Config struct {
|
type OAuth2Config struct {
|
||||||
|
@ -13,6 +16,10 @@ type OAuth2Config struct {
|
||||||
TokenSourceFunc OAuth2TokenSource
|
TokenSourceFunc OAuth2TokenSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (*OAuth2Config) Do(_ context.Context, _ promoauth.Oauth2Source, req *http.Request) (*http.Response, error) {
|
||||||
|
return http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
func (*OAuth2Config) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string {
|
func (*OAuth2Config) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string {
|
||||||
return "/?state=" + url.QueryEscape(state)
|
return "/?state=" + url.QueryEscape(state)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue