mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
1926b7e658
ValidateToken treated all 403 responses as "token invalid," including GitHub rate limits. isFailedRefresh included 403 in the status code fallthrough, destroying tokens on rate-limited refresh attempts. Split the combined 401/403 check in ValidateToken into a switch on status code. On 403, inspect X-RateLimit-Remaining and Retry-After headers; if either indicates a rate limit, return optimistically valid. Handle 429 the same way. Plain 403 without rate-limit headers preserves the existing invalid-token behavior. Add incorrect_client_credentials and invalid_client to isFailedRefresh error code switch. Remove 403 from the status code fallthrough since no known provider returns 403 from the token endpoint.
327 lines
11 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|