Files
coder/coderd/externalauth/externalauth_internal_test.go
T
github-actions[bot] e4defea43c fix(coderd/externalauth): detect rate-limit 403 and narrow isFailedRefresh (#24334) (#25504)
Backport of https://github.com/coder/coder/pull/24334

Original PR: #24334 — fix(coderd/externalauth): detect rate-limit 403
and narrow isFailedRefresh
Merge commit: 1926b7e658
Requested by: @f0ssel

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2026-05-19 16:43:47 -04:00

327 lines
11 KiB
Go

package externalauth
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/codersdk"
)
func TestGitlabDefaults(t *testing.T) {
t.Parallel()
// The default cloud setup. Copying this here as hard coded
// values.
cloud := func() codersdk.ExternalAuthConfig {
return codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ID: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
RevokeURL: "https://gitlab.com/oauth/revoke",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
APIBaseURL: "https://gitlab.com/api/v4",
Scopes: []string{"write_repository"},
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
}
}
tests := []struct {
name string
input codersdk.ExternalAuthConfig
expected codersdk.ExternalAuthConfig
mutateExpected func(*codersdk.ExternalAuthConfig)
}{
// Cloud
{
name: "OnlyType",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
},
expected: cloud(),
},
{
// If someone was to manually configure the gitlab cli.
name: "CloudByConfig",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize",
},
expected: cloud(),
},
{
// Changing some of the defaults of the cloud option
name: "CloudWithChanges",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
// Adding an extra query param intentionally to break simple
// string comparisons.
AuthURL: "https://gitlab.com/oauth/authorize?foo=bar",
DisplayName: "custom",
Regex: ".*",
},
expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.com/oauth/authorize?foo=bar"
config.DisplayName = "custom"
config.Regex = ".*"
},
},
// Self-hosted
{
// Dynamically figures out the Validate, Token, and Regex fields.
name: "SelfHostedOnlyAuthURL",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.company.org/oauth/authorize?foo=bar",
},
expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.company.org/oauth/authorize?foo=bar"
config.ValidateURL = "https://gitlab.company.org/oauth/token/info"
config.TokenURL = "https://gitlab.company.org/oauth/token"
config.RevokeURL = "https://gitlab.company.org/oauth/revoke"
config.Regex = `^(https?://)?gitlab\.company\.org(/.*)?$`
},
},
{
// Strange values
name: "RandomValues",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke",
Regex: "random",
CodeChallengeMethodsSupported: []string{"random"},
},
expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://auth.com/auth"
config.ValidateURL = "https://validate.com/validate"
config.TokenURL = "https://token.com/token"
config.RevokeURL = "https://token.com/revoke"
config.Regex = `random`
config.CodeChallengeMethodsSupported = []string{"random"}
},
},
}
for _, c := range tests {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
applyDefaultsToConfig(&c.input)
if c.mutateExpected != nil {
c.mutateExpected(&c.expected)
}
require.Equal(t, c.input, c.expected)
})
}
}
func TestIsFailedRefresh(t *testing.T) {
t.Parallel()
expiredToken := &oauth2.Token{
RefreshToken: "refresh-token",
// isFailedRefresh returns early at the existingToken.Valid()
// guard if the token is valid. Valid() requires
// AccessToken != "" AND not expired. This fixture has no
// AccessToken so Valid() is always false, but we set an
// expired time as a safety net in case someone later adds
// an AccessToken field.
Expiry: time.Now().Add(-time.Hour),
}
tests := []struct {
name string
err error
expected bool
}{
{
name: "IncorrectClientCredentials_StatusOK",
err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusOK},
ErrorCode: "incorrect_client_credentials",
},
// StatusOK fallthrough also returns true, so this test
// documents the combined behavior. See the 403-status
// variant below for error-code-only isolation.
expected: true,
},
{
// Uses 403 status (excluded from the status code switch)
// so the only path to true is the error code switch.
name: "IncorrectClientCredentials_Status403",
err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusForbidden},
ErrorCode: "incorrect_client_credentials",
},
expected: true,
},
{
name: "InvalidClient_Status401",
err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusUnauthorized},
ErrorCode: "invalid_client",
},
// StatusUnauthorized fallthrough also returns true, so
// this test documents the combined behavior.
expected: true,
},
{
// Uses 403 status (excluded from the status code switch)
// so the only path to true is the error code switch.
name: "InvalidClient_Status403",
err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusForbidden},
ErrorCode: "invalid_client",
},
expected: true,
},
{
name: "UnknownErrorCode_Status403_Transient",
err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusForbidden},
ErrorCode: "unknown_code",
},
// 403 with unknown error code should be transient (safe
// default: retry rather than destroy the token).
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := isFailedRefresh(expiredToken, tt.err)
assert.Equal(t, tt.expected, got)
})
}
}
func Test_bitbucketServerConfigDefaults(t *testing.T) {
t.Parallel()
bbType := string(codersdk.EnhancedExternalAuthProviderBitBucketServer)
tests := []struct {
name string
config *codersdk.ExternalAuthConfig
expected codersdk.ExternalAuthConfig
}{
{
// Very few fields are statically defined for Bitbucket Server.
name: "EmptyBitbucketServer",
config: &codersdk.ExternalAuthConfig{
Type: bbType,
},
expected: codersdk.ExternalAuthConfig{
Type: bbType,
ID: bbType,
DisplayName: "Bitbucket Server",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
DisplayIcon: "/icon/bitbucket.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
},
{
// Only the AuthURL is required for defaults to work.
name: "AuthURL",
config: &codersdk.ExternalAuthConfig{
Type: bbType,
AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
},
expected: codersdk.ExternalAuthConfig{
Type: bbType,
ID: bbType,
AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
TokenURL: "https://bitbucket.example.com/rest/oauth2/latest/token",
ValidateURL: "https://bitbucket.example.com/rest/api/latest/inbox/pull-requests/count",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
Regex: `^(https?://)?bitbucket\.example\.com(/.*)?$`,
DisplayName: "Bitbucket Server",
DisplayIcon: "/icon/bitbucket.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
},
{
// Ensure backwards compatibility. The type should update to "bitbucket-cloud",
// but the ID and other fields should remain the same.
name: "BitbucketLegacy",
config: &codersdk.ExternalAuthConfig{
Type: "bitbucket",
},
expected: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderBitBucketCloud),
ID: "bitbucket", // Legacy ID remains unchanged
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
ValidateURL: "https://api.bitbucket.org/2.0/user",
DisplayName: "BitBucket",
DisplayIcon: "/icon/bitbucket.svg",
Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"},
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
applyDefaultsToConfig(tt.config)
require.Equal(t, tt.expected, *tt.config)
})
}
}
func TestUntyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input codersdk.ExternalAuthConfig
expected codersdk.ExternalAuthConfig
}{
{
// Unknown Type uses S256 by default.
name: "RandomValues",
input: codersdk.ExternalAuthConfig{
Type: "unknown",
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke",
Regex: "random",
},
expected: codersdk.ExternalAuthConfig{
ID: "unknown",
Type: "unknown",
DisplayName: "unknown",
DisplayIcon: "/emojis/1f511.png",
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke",
Regex: `random`,
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
},
},
}
for _, c := range tests {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
applyDefaultsToConfig(&c.input)
require.Equal(t, c.input, c.expected)
})
}
}