mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
* feat: add RFC 8707 resource indicator via resource config `Config.Resource` is an alternative to `audience` for IdPs that implement RFC 8707. `buildAuthURL` sends it as the `resource` parameter on the authorization request, before `extraAuthParams` so an operator-configured `resource` key there cannot override or duplicate it. `exchangeTokens` repeats it on both the `authorization_code` and `refresh_token` grants (RFC 8707 §2.2), so a refreshed token keeps the audience binding of the original grant. Only one resource is accepted. RFC 8707 permits a list, but no provider that supports the parameter handles a list usefully: one rejects any request naming more than one resource with HTTP 400 `invalid_target`; node-oidc-provider accepts several and issues an opaque token bound to none of them. `Config.Validate()` requires an absolute URI of at most 256 characters with no fragment, wildcard, or control characters. `http`/`https` values go through `isValidSecureURL`, so HTTPS is required outside loopback; other schemes (`urn:`) pass as-is. When `resource` is set and `audience` is not, `audience` defaults to the resource for access-token validation; `strictAudienceValidation` applies the same way whichever of the two is set. That default is validation-only. A new `TraefikOidc.explicitAudience` mirrors `Config.Audience` verbatim, and `buildAuthURL` keys the non-standard `audience` request parameter (the Auth0 custom-API pattern) on it rather than on `audience`, so a resource-derived default is never sent as an unsolicited `audience` parameter alongside `resource` — a combination that misbehaves against providers such as Ory Hydra, which give `audience` a different meaning. `audience_test.go` and `url_helpers_ultra_test.go` set `audience` on the struct directly, bypassing `New()`, and now set `explicitAudience` too. docs/CONFIGURATION.md carries only the reference-table row and a short pointer; the new docs/RFC8707.md holds the request behaviour, the single-resource rationale, the `resource`/`audience` relationship, and a provider-support table from interop testing: node-oidc-provider supports it; Zitadel accepts and silently ignores it (`oidc.AuthRequest` has no `resource` field, confirmed against a live instance); Authentik returns `invalid_target`; Keycloak has no support through 26.6; Ory Hydra/Fosite uses its own `audience` parameter; Azure AD/Entra ID v2 is reported to reject `resource` combined with v2-style `scope`; Auth0 and AWS Cognito claims are unverified. README.md, docs/index.html, and .traefik.yml each point at docs/RFC8707.md instead of repeating it, and the comments on `Config.Resource`, in main.go, and in url_helpers.go defer to it as well. rfc8707_resource_test.go covers the authorization-URL parameter (set, unset, query-bearing value percent-encoded rather than merged, `extraAuthParams` unable to override), both grants sending `resource` in the form body, the `Config.Validate()` rules table, audience defaulting through `New()`, and a regression pair: a resource-derived audience default is not sent as `audience`, while an explicitly configured one still is. * fix: accept Resource in place of Audience for bearer-auth validation `NewWithContext` rejected `EnableBearerAuth=true` whenever `config.Audience` was empty, even though `t.audience` already falls back to `config.Resource` in that case. The check now fails only when both `Audience` and `Resource` are empty, and the error names both fields. `TestStartupValidation_BearerAcceptsResourceInPlaceOfAudience` covers the `Resource`-only configuration. `docs/RFC8707.md` links the `audience` parameter note to `docs/AUTH0_AUDIENCE_GUIDE.md` instead of restating it, and drops the provider table's GitHub row and the interop-testing paragraph, which are covered elsewhere.
493 lines
18 KiB
Go
493 lines
18 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)
|
|
}
|
|
|
|
// RFC 8707 §2.2: repeat the same resource indicator sent on the
|
|
// authorization request so refreshed tokens keep the same audience
|
|
// binding as the original grant.
|
|
if t.resource != "" {
|
|
data.Set("resource", t.resource)
|
|
}
|
|
|
|
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
|
|
// localRedirect is used when there is no provider end-session endpoint and
|
|
// the plugin redirects the browser itself. It must never be an absolute URL
|
|
// derived from the request host (X-Forwarded-Host is client-controllable and
|
|
// would be an open redirect); use a host-relative path, or the operator's
|
|
// own configured absolute URL, instead.
|
|
localRedirect := "/"
|
|
if postLogoutRedirectURI == "" {
|
|
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
|
|
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
|
|
localRedirect = normalizeLogoutPath(postLogoutRedirectURI)
|
|
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
|
|
} else {
|
|
localRedirect = 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, localRedirect, 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
|
|
}
|