mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-06 22:49:43 +00:00
1e33bb0a4d
revocation endpoints, joining the existing client_secret_post default. Both are opt-in via the new clientAuthMethod config field. Closes #135. private_key_jwt (RFC 7523 §2.2 / OpenID Connect Core §9) ======================================================== Plugin signs a short-lived JWT with a configured private key and presents it as client_assertion. Use when the IdP enforces short secret TTLs or requires secretless client auth (Microsoft Entra ID / Azure AD, Okta, Auth0, Keycloak). New Config fields: clientAuthMethod (default: client_secret_post) clientAssertionPrivateKey (inline PEM) clientAssertionKeyPath (PEM file path; mutually exclusive) clientAssertionKeyID (JWS kid header — required) clientAssertionAlg (default: RS256; RS/PS/ES 256–512 supported) PEM forms accepted: PKCS#8, PKCS#1, SEC1. Assertion claims: iss=sub=clientID, aud=tokenURL, iat=now, exp=now+60s, random 16-byte hex jti per request. ECDSA signatures are raw r||s per RFC 7515 (not ASN.1). client_secret_basic (RFC 6749 §2.3.1) ===================================== Sends credentials in the Authorization: Basic header instead of the body. Both halves are form-urlencoded individually before base64 — that encoding step is required by the spec and is NOT what stdlib's http.Request.SetBasicAuth does, so the plugin uses its own helper. The form body omits client_id and client_secret on this path. Wire-up ======= Both methods are dispatched at the same two call sites: helpers.go:exchangeTokens — auth_code + refresh_token grants token_manager.go:RevokeTokenWithProvider — RFC 7009 revocation Existing clientSecret deployments are unaffected — empty clientAuthMethod maps to the historical client_secret_post behavior, and clientAssertion remains nil unless the new fields are set. Yaegi compatibility =================== All required crypto/rsa, crypto/ecdsa, crypto/x509, encoding/pem and crypto/sha256/384/512 symbols are exposed by the traefik/yaegi stdlib symbol tables (RSA SignPKCS1v15 + SignPSS, ECDSA Sign, ParsePKCS8/1PrivateKey, ParseECPrivateKey). Tests (16 new) ============== Algorithm-family coverage: TestIssue135_SignerRSAFamily — RS256/384/512 + PS256/384/512 TestIssue135_SignerECDSAFamily — ES256/384/512, raw r||s shape TestIssue135_SignerRejectsAlgKeyMismatch TestIssue135_SignerJTIUniqueness — 50 sigs, all jti distinct TestIssue135_SignerPEMVariants — PKCS#8, PKCS#1, SEC1 Config validation: TestIssue135_ConfigValidation — full Validate() matrix TestIssue135_ConfigKeyPathLoadsFile Wire-up: TestIssue135_AuthCodeExchangeUsesAssertion TestIssue135_RefreshTokenUsesAssertion TestIssue135_BackcompatClientSecretPath TestIssue135_RevocationUsesAssertion TestIssue135_BuildSignerFromInlineConfig TestIssue135_BuildSignerDefaultsToRS256 TestIssue135_ClientSecretBasicAuth — Authorization header, no body creds TestIssue135_ClientSecretBasicURLEncodesReservedChars — :, +, /, @, =, & TestIssue135_ClientSecretBasicRevocation — revocation parity Documentation ============= README.md — required-row note + 5 optional rows + dedicated section docs/CONFIGURATION.md — new Client Authentication section with three method subsections, OpenSSL keygen snippet, RFC links docs/index.html — 5 new config-table rows + Private Key JWT explainer card .traefik.yml + examples/complete-traefik-config.yaml — commented opt-in example Out of scope (deferred) ======================= mTLS / tls_client_auth (RFC 8705) — separate change; requires per-call http.Client with tls.Config.Certificates and conflicts with the current pooled HTTP client architecture.
477 lines
17 KiB
Go
477 lines
17 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/traefikoidc/internal/utils"
|
|
)
|
|
|
|
// newUUIDv4 returns an RFC 4122 v4 UUID string (e.g.
|
|
// "f47ac10b-58cc-4372-a567-0e02b2c3d479") backed by crypto/rand. Used for CSRF
|
|
// tokens and other opaque random identifiers — replaces github.com/google/uuid
|
|
// to keep the plugin stdlib-only on the production path.
|
|
func newUUIDv4() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", fmt.Errorf("could not generate UUID: %w", err)
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
|
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
|
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
|
}
|
|
|
|
// generateNonce creates a cryptographically secure random nonce for OIDC flows.
|
|
// The nonce is used to prevent replay attacks and associate client sessions with ID tokens.
|
|
// Returns:
|
|
// - A base64 URL-encoded nonce string (43 characters)
|
|
// - An error if the random byte generation fails
|
|
func generateNonce() (string, error) {
|
|
nonceBytes := make([]byte, 32)
|
|
_, err := rand.Read(nonceBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not generate nonce: %w", err)
|
|
}
|
|
return base64.URLEncoding.EncodeToString(nonceBytes), nil
|
|
}
|
|
|
|
// generateCodeVerifier creates a PKCE code verifier according to RFC 7636.
|
|
// The code verifier is a cryptographically random string used for the PKCE flow
|
|
// to prevent authorization code interception attacks.
|
|
// Returns:
|
|
// - A base64 raw URL-encoded code verifier string (43 characters)
|
|
// - An error if the random byte generation fails
|
|
func generateCodeVerifier() (string, error) {
|
|
verifierBytes := make([]byte, 32)
|
|
_, err := rand.Read(verifierBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not generate code verifier: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(verifierBytes), nil
|
|
}
|
|
|
|
// deriveCodeChallenge creates a PKCE code challenge from the code verifier.
|
|
// It computes the SHA-256 hash of the code verifier and base64 URL-encodes it
|
|
// according to RFC 7636 specification.
|
|
// Parameters:
|
|
// - codeVerifier: The code verifier string
|
|
//
|
|
// Returns:
|
|
// - The base64 URL encoded SHA-256 hash of the code verifier (code challenge)
|
|
func deriveCodeChallenge(codeVerifier string) string {
|
|
hasher := sha256.New()
|
|
hasher.Write([]byte(codeVerifier))
|
|
hash := hasher.Sum(nil)
|
|
|
|
return base64.RawURLEncoding.EncodeToString(hash)
|
|
}
|
|
|
|
// TokenResponse represents the standard OAuth 2.0/OIDC token response.
|
|
// It contains the tokens and metadata returned by the authorization server during
|
|
// code exchange or token refresh operations.
|
|
type TokenResponse struct {
|
|
// IDToken contains the OpenID Connect identity token (JWT)
|
|
IDToken string `json:"id_token"`
|
|
// AccessToken is the OAuth 2.0 access token for API access
|
|
AccessToken string `json:"access_token"`
|
|
// RefreshToken allows obtaining new tokens when the access token expires
|
|
RefreshToken string `json:"refresh_token"`
|
|
// TokenType specifies the token type (typically "Bearer")
|
|
TokenType string `json:"token_type"`
|
|
// ExpiresIn indicates token lifetime in seconds
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
// exchangeTokens performs OAuth 2.0 token exchange with the authorization server.
|
|
// It supports both authorization code and refresh token grant types with PKCE support.
|
|
// Parameters:
|
|
// - ctx: Context for request timeout and cancellation
|
|
// - grantType: OAuth grant type ("authorization_code" or "refresh_token")
|
|
// - codeOrToken: Authorization code or refresh token depending on grant type
|
|
// - redirectURL: Redirect URI used in authorization (required for code exchange)
|
|
// - codeVerifier: PKCE code verifier (optional, used with PKCE flow)
|
|
//
|
|
// Returns:
|
|
// - *TokenResponse: Parsed token response from the authorization server
|
|
// - An error if the token exchange fails (e.g., network error, provider error, invalid grant)
|
|
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
|
data := url.Values{
|
|
"grant_type": {grantType},
|
|
}
|
|
// client_id is sent in the body for every method except client_secret_basic,
|
|
// where it is carried in the Authorization header per RFC 6749 §2.3.1.
|
|
if t.clientAuthMethod != "client_secret_basic" || t.clientAssertion != nil {
|
|
data.Set("client_id", t.clientID)
|
|
}
|
|
|
|
if grantType == "authorization_code" {
|
|
data.Set("code", codeOrToken)
|
|
data.Set("redirect_uri", redirectURL)
|
|
|
|
if codeVerifier != "" {
|
|
data.Set("code_verifier", codeVerifier)
|
|
}
|
|
} else if grantType == "refresh_token" {
|
|
data.Set("refresh_token", codeOrToken)
|
|
}
|
|
|
|
client := t.tokenHTTPClient
|
|
if client == nil {
|
|
// Use shared transport pool to prevent memory leaks
|
|
jar, _ := cookiejar.New(nil) // Safe to ignore: cookiejar creation with nil options rarely fails
|
|
pooledClient := CreateTokenHTTPClient()
|
|
client = &http.Client{
|
|
Transport: pooledClient.Transport,
|
|
Timeout: pooledClient.Timeout,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 50 {
|
|
return fmt.Errorf("stopped after 50 redirects")
|
|
}
|
|
return nil
|
|
},
|
|
Jar: jar,
|
|
}
|
|
}
|
|
|
|
// Read tokenURL with RLock — needed as audience for private_key_jwt (RFC 7523 §3).
|
|
t.metadataMu.RLock()
|
|
tokenURL := t.tokenURL
|
|
t.metadataMu.RUnlock()
|
|
|
|
useBasicAuth := false
|
|
if t.clientAssertion != nil {
|
|
assertion, err := t.clientAssertion.Sign(tokenURL, t.clientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to sign client assertion: %w", err)
|
|
}
|
|
data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
|
|
data.Set("client_assertion", assertion)
|
|
} else if t.clientAuthMethod == "client_secret_basic" {
|
|
useBasicAuth = true
|
|
} else {
|
|
data.Set("client_secret", t.clientSecret)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if useBasicAuth {
|
|
setOAuthBasicAuth(req, t.clientID, t.clientSecret)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
|
|
}
|
|
defer func() {
|
|
_, _ = io.Copy(io.Discard, resp.Body) // Safe to ignore: draining response body on defer
|
|
_ = resp.Body.Close() // Safe to ignore: closing body on defer
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
limitReader := io.LimitReader(resp.Body, 1024*10)
|
|
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
|
|
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var tokenResponse TokenResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
|
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
|
}
|
|
|
|
return &tokenResponse, nil
|
|
}
|
|
|
|
// getNewTokenWithRefreshToken refreshes access and ID tokens using a refresh token.
|
|
// This is used when the current tokens are expired but the refresh token is still valid.
|
|
// It now uses the TokenResilienceManager for circuit breaker and retry logic.
|
|
// Parameters:
|
|
// - refreshToken: The refresh token to exchange for new tokens
|
|
//
|
|
// Returns:
|
|
// - *TokenResponse: New token set from the authorization server
|
|
// - An error if the refresh operation fails
|
|
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
|
|
ctx := context.Background()
|
|
|
|
// Use token resilience manager if available, otherwise fall back to direct call
|
|
if t.tokenResilienceManager != nil {
|
|
return t.tokenResilienceManager.ExecuteTokenRefresh(ctx, t, refreshToken)
|
|
}
|
|
|
|
// Fallback for backward compatibility
|
|
tokenResponse, err := t.exchangeTokens(ctx, "refresh_token", refreshToken, "", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
|
}
|
|
|
|
t.logger.Debugf("Token response: %+v", tokenResponse)
|
|
return tokenResponse, nil
|
|
}
|
|
|
|
// extractClaims extracts and parses claims from a JWT token without signature verification.
|
|
// This is a utility function for quickly accessing token payload data when signature
|
|
// verification is not required or has already been performed.
|
|
// Parameters:
|
|
// - tokenString: The JWT token string to parse
|
|
//
|
|
// Returns:
|
|
// - map[string]interface{}: Parsed claims from the token payload
|
|
// - An error if the token format is invalid, decoding fails, or JSON unmarshaling fails
|
|
func extractClaims(tokenString string) (map[string]interface{}, error) {
|
|
parts := strings.Split(tokenString, ".")
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("invalid token format")
|
|
}
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode token payload: %w", err)
|
|
}
|
|
|
|
var claims map[string]interface{}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
|
|
// It wraps the UniversalCache with token-specific operations.
|
|
type TokenCache struct {
|
|
// cache is the underlying universal cache implementation
|
|
cache *UniversalCache
|
|
}
|
|
|
|
// NewTokenCache creates and initializes a new TokenCache.
|
|
// It uses the global cache manager to ensure singleton behavior.
|
|
func NewTokenCache() *TokenCache {
|
|
manager := GetUniversalCacheManager(nil)
|
|
return &TokenCache{
|
|
cache: manager.GetTokenCache(),
|
|
}
|
|
}
|
|
|
|
// Set stores parsed token claims in the cache with expiration.
|
|
// The token is prefixed to prevent collisions with other cache entries.
|
|
// Parameters:
|
|
// - token: The JWT token string (used as cache key)
|
|
// - claims: Parsed claims from the token
|
|
// - expiration: The duration for which the cache entry should be valid
|
|
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
|
|
token = "t-" + token
|
|
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
|
|
}
|
|
|
|
// Get retrieves cached claims for a token.
|
|
// Parameters:
|
|
// - token: The JWT token string to look up
|
|
//
|
|
// Returns:
|
|
// - map[string]interface{}: The cached claims if found
|
|
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise)
|
|
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
|
token = "t-" + token
|
|
value, found := tc.cache.Get(token)
|
|
if !found {
|
|
return nil, false
|
|
}
|
|
claims, ok := value.(map[string]interface{})
|
|
return claims, ok
|
|
}
|
|
|
|
// Delete removes a token from the cache.
|
|
// Parameters:
|
|
// - token: The raw token string to remove from the cache
|
|
func (tc *TokenCache) Delete(token string) {
|
|
token = "t-" + token
|
|
tc.cache.Delete(token)
|
|
}
|
|
|
|
// Cleanup removes expired entries from the token cache.
|
|
// This is a no-op as cleanup is handled internally by UniversalCache.
|
|
func (tc *TokenCache) Cleanup() {
|
|
// Cleanup is handled internally by UniversalCache
|
|
}
|
|
|
|
// Close stops the cleanup goroutine and releases resources.
|
|
// This is a no-op as the cache is managed globally.
|
|
func (tc *TokenCache) Close() {
|
|
// Cache is managed globally by UniversalCacheManager
|
|
}
|
|
|
|
// Clear removes all items from the cache
|
|
func (tc *TokenCache) Clear() {
|
|
tc.cache.Clear()
|
|
}
|
|
|
|
// exchangeCodeForToken exchanges an authorization code for tokens.
|
|
// This implements the OAuth 2.0 authorization code flow with optional PKCE support.
|
|
// It now uses the TokenResilienceManager for circuit breaker and retry logic.
|
|
// Parameters:
|
|
// - code: The authorization code received from the authorization server
|
|
// - redirectURL: The redirect URI used in the authorization request
|
|
// - codeVerifier: PKCE code verifier (used if PKCE is enabled)
|
|
//
|
|
// Returns:
|
|
// - *TokenResponse: The token response containing access, refresh, and ID tokens
|
|
// - An error if the code exchange fails
|
|
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
|
ctx := context.Background()
|
|
|
|
effectiveCodeVerifier := ""
|
|
if t.enablePKCE && codeVerifier != "" {
|
|
effectiveCodeVerifier = codeVerifier
|
|
}
|
|
|
|
// Use token resilience manager if available, otherwise fall back to direct call
|
|
if t.tokenResilienceManager != nil {
|
|
return t.tokenResilienceManager.ExecuteTokenExchange(ctx, t, "authorization_code", code, redirectURL, effectiveCodeVerifier)
|
|
}
|
|
|
|
// Fallback for backward compatibility
|
|
tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, redirectURL, effectiveCodeVerifier)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
|
}
|
|
return tokenResponse, nil
|
|
}
|
|
|
|
// createStringMap converts a slice of strings to a set-like map for fast lookups.
|
|
// This is a utility function for creating efficient membership tests.
|
|
// Parameters:
|
|
// - keys: Slice of strings to convert to a map
|
|
//
|
|
// Returns:
|
|
// - A map where the keys are the strings from the input slice and the values are empty structs
|
|
func createStringMap(keys []string) map[string]struct{} {
|
|
result := make(map[string]struct{})
|
|
for _, key := range keys {
|
|
result[key] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// handleLogout processes user logout requests and performs proper session cleanup.
|
|
// It retrieves the ID token for logout URL construction, clears the session,
|
|
// and redirects to the provider's logout endpoint or configured post-logout URI.
|
|
// It handles potential errors during session retrieval or clearing.
|
|
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
|
t.logger.Debug("Processing logout request")
|
|
session, err := t.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.logger.Errorf("Error getting session: %v", err)
|
|
http.Error(rw, "Session error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
idToken := session.GetIDToken()
|
|
|
|
if err := session.Clear(req, rw); err != nil {
|
|
t.logger.Errorf("Error clearing session: %v", err)
|
|
http.Error(rw, "Session error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
host := utils.DetermineHost(req)
|
|
scheme := utils.DetermineScheme(req, t.forceHTTPS)
|
|
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
|
|
|
postLogoutRedirectURI := t.postLogoutRedirectURI
|
|
if postLogoutRedirectURI == "" {
|
|
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
|
|
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
|
|
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
|
|
}
|
|
|
|
// Read endSessionURL with RLock
|
|
t.metadataMu.RLock()
|
|
endSessionURL := t.endSessionURL
|
|
t.metadataMu.RUnlock()
|
|
|
|
if endSessionURL != "" && idToken != "" {
|
|
logoutURL, err := BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI)
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to build logout URL: %v", err)
|
|
http.Error(rw, "Logout error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(rw, req, logoutURL, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
http.Redirect(rw, req, postLogoutRedirectURI, http.StatusFound)
|
|
}
|
|
|
|
// BuildLogoutURL constructs a logout URL for the OIDC provider's end session endpoint.
|
|
// It includes the ID token hint and post-logout redirect URI according to OIDC specifications.
|
|
// Parameters:
|
|
// - endSessionURL: The provider's logout/end session endpoint
|
|
// - idToken: The ID token to include as a hint
|
|
// - postLogoutRedirectURI: Where to redirect after logout
|
|
//
|
|
// Returns:
|
|
// - The complete logout URL with query parameters
|
|
// - An error if the provided endSessionURL is invalid
|
|
func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (string, error) {
|
|
u, err := url.Parse(endSessionURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse end session URL: %w", err)
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Set("id_token_hint", idToken)
|
|
if postLogoutRedirectURI != "" {
|
|
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
return u.String(), nil
|
|
}
|
|
|
|
// setOAuthBasicAuth sets the Authorization header per RFC 6749 §2.3.1: the
|
|
// client_id and client_secret are form-urlencoded individually, joined with a
|
|
// colon, then base64-encoded. This differs from http.Request.SetBasicAuth,
|
|
// which skips the form-urlencode step — that matters for credentials with
|
|
// reserved characters (`:`, `@`, `+`, `%`, etc.) where the wire format would
|
|
// otherwise diverge from what the spec mandates.
|
|
func setOAuthBasicAuth(req *http.Request, clientID, clientSecret string) {
|
|
user := url.QueryEscape(clientID)
|
|
pass := url.QueryEscape(clientSecret)
|
|
auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
|
req.Header.Set("Authorization", "Basic "+auth)
|
|
}
|
|
|
|
// deduplicateScopes removes duplicate scopes from a slice while preserving order.
|
|
// This ensures that OAuth scope parameters don't contain duplicates which could
|
|
// cause issues with some authorization servers.
|
|
// The first occurrence of each scope is kept.
|
|
func deduplicateScopes(scopes []string) []string {
|
|
if len(scopes) == 0 {
|
|
return []string{}
|
|
}
|
|
seen := make(map[string]struct{})
|
|
result := []string{}
|
|
for _, scope := range scopes {
|
|
if _, ok := seen[scope]; !ok {
|
|
seen[scope] = struct{}{}
|
|
result = append(result, scope)
|
|
}
|
|
}
|
|
return result
|
|
}
|