diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 9649a0d4..e2b4f646 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -331,6 +331,13 @@ type OIDCOptions struct { // between this list and the provider's discovered supported algorithms. // By default `RS256` is used if nothing has been discovered or specified. EnabledSigningAlgs []string `yaml:"enabledSigningAlgs,omitempty"` + // AuthStyle specifies how the endpoint wants the client ID & client secret sent. + // Possible values are: + // - "inParams" (or "params"): sends credentials in the POST body as application/x-www-form-urlencoded parameters + // - "inHeader" (or "header"): sends credentials using HTTP Basic Authorization + // - "" (empty, default): auto-detect by trying both ways + // Some providers like Apple require "inParams". + AuthStyle string `yaml:"authStyle,omitempty"` } type LoginGovOptions struct { diff --git a/providers/apple.go b/providers/apple.go index 87d928f8..241ac6b9 100644 --- a/providers/apple.go +++ b/providers/apple.go @@ -1,7 +1,6 @@ package providers import ( - "bytes" "context" "crypto/ecdsa" "crypto/x509" @@ -17,16 +16,14 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" + "golang.org/x/oauth2" ) const ( appleProviderName = "Apple" appleDefaultScope = "openid email name" - appleIssuerURL = "https://appleid.apple.com" - appleAuthURL = "https://appleid.apple.com/auth/authorize" - appleTokenURL = "https://appleid.apple.com/auth/token" - appleAudience = "https://appleid.apple.com" + appleAudience = "https://appleid.apple.com" ) var ( @@ -44,6 +41,7 @@ var ( ) // AppleProvider represents the Apple Sign in with Apple OIDC provider +// See: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api type AppleProvider struct { *OIDCProvider @@ -66,26 +64,29 @@ func NewAppleProvider(p *ProviderData, appleOpts options.AppleOptions, oidcOpts }) p.getAuthorizationHeaderFunc = makeOIDCHeader - oidcProvider := &OIDCProvider{ - ProviderData: p, - SkipNonce: true, // Apple doesn't use nonce in the standard way - } - provider := &AppleProvider{ - OIDCProvider: oidcProvider, - TeamID: appleOpts.TeamID, - KeyID: appleOpts.KeyID, + OIDCProvider: &OIDCProvider{ + ProviderData: p, + SkipNonce: true, // Apple doesn't use nonce in the standard way + AuthStyle: oauth2.AuthStyleInParams, // Apple requires credentials in POST body + }, + TeamID: appleOpts.TeamID, + KeyID: appleOpts.KeyID, } - if err := provider.configure(appleOpts); err != nil { - return nil, fmt.Errorf("could not configure Apple provider: %v", err) + if err := provider.initialize(appleOpts); err != nil { + return nil, fmt.Errorf("could not initialize Apple provider: %v", err) } + // Set up dynamic client secret generation + // Apple requires client_secret to be a JWT signed with ES256 + p.ClientSecretFunc = provider.generateClientSecret + return provider, nil } -// configure validates and sets up the Apple provider with the private key -func (p *AppleProvider) configure(opts options.AppleOptions) error { +// initialize validates and configures the Apple provider with the private key +func (p *AppleProvider) initialize(opts options.AppleOptions) error { if opts.TeamID == "" { return errors.New("apple provider requires teamID") } @@ -121,6 +122,7 @@ func (p *AppleProvider) configure(opts options.AppleOptions) error { } // parseECPrivateKey parses a PEM-encoded EC private key (Apple .p8 format) +// See: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens func parseECPrivateKey(keyData []byte) (*ecdsa.PrivateKey, error) { // Apple .p8 files contain a PEM-encoded PKCS#8 private key block, _ := pem.Decode(keyData) @@ -148,6 +150,7 @@ func parseECPrivateKey(keyData []byte) (*ecdsa.PrivateKey, error) { // generateClientSecret creates a JWT client_secret for Apple token requests // Apple requires the client_secret to be a JWT signed with ES256 +// See: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens func (p *AppleProvider) generateClientSecret() (string, error) { now := time.Now() claims := &jwt.RegisteredClaims{ @@ -173,124 +176,6 @@ func (p *AppleProvider) GetLoginURL(redirectURI, state, nonce string, extraParam return p.OIDCProvider.GetLoginURL(redirectURI, state, nonce, extraParams) } -// Redeem exchanges the authorization code for tokens -func (p *AppleProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) { - if code == "" { - return nil, ErrMissingCode - } - - clientSecret, err := p.generateClientSecret() - if err != nil { - return nil, fmt.Errorf("failed to generate client secret: %v", err) - } - - params := url.Values{} - params.Add("client_id", p.ClientID) - params.Add("client_secret", clientSecret) - params.Add("code", code) - params.Add("grant_type", "authorization_code") - params.Add("redirect_uri", redirectURL) - if codeVerifier != "" { - params.Add("code_verifier", codeVerifier) - } - - var jsonResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - } - - err = requests.New(p.RedeemURL.String()). - WithContext(ctx). - WithMethod("POST"). - WithBody(bytes.NewBufferString(params.Encode())). - SetHeader("Content-Type", "application/x-www-form-urlencoded"). - Do(). - UnmarshalInto(&jsonResponse) - if err != nil { - return nil, fmt.Errorf("token exchange failed: %v", err) - } - - ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) - - // Build session from ID token claims - ss, err := p.buildSessionFromClaims(jsonResponse.IDToken, jsonResponse.AccessToken) - if err != nil { - return nil, fmt.Errorf("failed to build session from claims: %v", err) - } - - ss.AccessToken = jsonResponse.AccessToken - ss.RefreshToken = jsonResponse.RefreshToken - ss.IDToken = jsonResponse.IDToken - - ss.CreatedAtNow() - ss.ExpiresIn(time.Duration(jsonResponse.ExpiresIn) * time.Second) - - return ss, nil -} - -// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens -func (p *AppleProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) { - if s == nil || s.RefreshToken == "" { - return false, nil - } - - clientSecret, err := p.generateClientSecret() - if err != nil { - return false, fmt.Errorf("failed to generate client secret: %v", err) - } - - params := url.Values{} - params.Add("client_id", p.ClientID) - params.Add("client_secret", clientSecret) - params.Add("grant_type", "refresh_token") - params.Add("refresh_token", s.RefreshToken) - - var jsonResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - } - - err = requests.New(p.RedeemURL.String()). - WithContext(ctx). - WithMethod("POST"). - WithBody(bytes.NewBufferString(params.Encode())). - SetHeader("Content-Type", "application/x-www-form-urlencoded"). - Do(). - UnmarshalInto(&jsonResponse) - if err != nil { - return false, fmt.Errorf("refresh token failed: %v", err) - } - - // Update session with new tokens - if jsonResponse.IDToken != "" { - ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) - newSession, err := p.buildSessionFromClaims(jsonResponse.IDToken, jsonResponse.AccessToken) - if err == nil { - s.Email = newSession.Email - s.User = newSession.User - s.Groups = newSession.Groups - s.PreferredUsername = newSession.PreferredUsername - } - s.IDToken = jsonResponse.IDToken - } - - s.AccessToken = jsonResponse.AccessToken - if jsonResponse.RefreshToken != "" { - s.RefreshToken = jsonResponse.RefreshToken - } - - s.CreatedAtNow() - s.ExpiresIn(time.Duration(jsonResponse.ExpiresIn) * time.Second) - - return true, nil -} - // ValidateSession validates the session's ID token func (p *AppleProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) @@ -300,7 +185,7 @@ func (p *AppleProvider) ValidateSession(ctx context.Context, s *sessions.Session if _, err := p.Verifier.Verify(ctx, s.IDToken); err != nil { return false } - // ID token is valid - Apple doesn't provide a token validation endpoint, + // ID token is valid - Apple doesn't provide a token validation endpoint return true } diff --git a/providers/apple_test.go b/providers/apple_test.go index 24f9efc1..ae60b2c6 100644 --- a/providers/apple_test.go +++ b/providers/apple_test.go @@ -5,22 +5,44 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" + "fmt" "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v5" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + internaloidc "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/providers/oidc" . "github.com/onsi/gomega" "github.com/stretchr/testify/assert" ) +const ( + appleTestIssuer = "https://appleid.apple.com" + appleTestClientID = "com.example.client" +) + +// mockAppleJWKS implements oidc.KeySet for testing +type mockAppleJWKS struct{} + +func (mockAppleJWKS) VerifySignature(_ context.Context, jwt string) ([]byte, error) { + decoded, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode JWT: %v", err) + } + return decoded, nil +} + func newAppleServer(body []byte) (*url.URL, *httptest.Server) { s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") @@ -55,6 +77,11 @@ func newAppleProvider() (*AppleProvider, *ecdsa.PrivateKey, error) { return nil, nil, err } + verificationOptions := internaloidc.IDTokenVerificationOptions{ + AudienceClaims: []string{"aud"}, + ClientID: appleTestClientID, + } + p, err := NewAppleProvider( &ProviderData{ ProviderName: "", @@ -63,7 +90,14 @@ func newAppleProvider() (*AppleProvider, *ecdsa.PrivateKey, error) { ProfileURL: &url.URL{}, ValidateURL: &url.URL{}, Scope: "", - ClientID: "com.example.client", + ClientID: appleTestClientID, + EmailClaim: "email", + UserClaim: "sub", + Verifier: internaloidc.NewVerifier(oidc.NewVerifier( + appleTestIssuer, + mockAppleJWKS{}, + &oidc.Config{ClientID: appleTestClientID}, + ), verificationOptions), }, options.AppleOptions{ TeamID: "TEAM123456", @@ -211,21 +245,21 @@ func TestAppleProviderRedeem(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, p) - // Create a mock ID token + // Create a mock ID token with claims matching the verifier configuration expiresIn := int64(3600) idTokenClaims := jwt.MapClaims{ - "iss": "https://appleid.apple.com", + "iss": appleTestIssuer, "sub": "user123", - "aud": "com.example.client", + "aud": appleTestClientID, "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "email": "user@example.com", } - // Sign with test key for mock purposes - privKey, _, _ := generateTestECPrivateKey() - idToken := jwt.NewWithClaims(jwt.SigningMethodES256, idTokenClaims) - signedIDToken, err := idToken.SignedString(privKey) + // Sign with RSA key (RS256) as expected by the verifier + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + idToken := jwt.NewWithClaims(jwt.SigningMethodRS256, idTokenClaims) + signedIDToken, err := idToken.SignedString(rsaKey) assert.NoError(t, err) // Set up mock server response diff --git a/providers/oidc.go b/providers/oidc.go index aa022f63..c9407e50 100644 --- a/providers/oidc.go +++ b/providers/oidc.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "strings" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -21,6 +22,7 @@ type OIDCProvider struct { *ProviderData SkipNonce bool + AuthStyle oauth2.AuthStyle } const oidcDefaultScope = "openid email profile" @@ -52,6 +54,19 @@ func NewOIDCProvider(p *ProviderData, opts options.OIDCOptions) *OIDCProvider { return &OIDCProvider{ ProviderData: p, SkipNonce: ptr.Deref(opts.InsecureSkipNonce, options.DefaultInsecureSkipNonce), + AuthStyle: parseAuthStyle(opts.AuthStyle), + } +} + +// parseAuthStyle converts the string AuthStyle option to oauth2.AuthStyle +func parseAuthStyle(style string) oauth2.AuthStyle { + switch strings.ToLower(style) { + case "inparams", "params": + return oauth2.AuthStyleInParams + case "inheader", "header": + return oauth2.AuthStyleInHeader + default: + return oauth2.AuthStyleAutoDetect } } @@ -87,7 +102,8 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifi ClientID: p.ClientID, ClientSecret: clientSecret, Endpoint: oauth2.Endpoint{ - TokenURL: p.RedeemURL.String(), + TokenURL: p.RedeemURL.String(), + AuthStyle: p.AuthStyle, }, RedirectURL: redirectURL, } @@ -171,7 +187,8 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi ClientID: p.ClientID, ClientSecret: clientSecret, Endpoint: oauth2.Endpoint{ - TokenURL: p.RedeemURL.String(), + TokenURL: p.RedeemURL.String(), + AuthStyle: p.AuthStyle, }, } t := &oauth2.Token{ diff --git a/providers/provider_data.go b/providers/provider_data.go index 80bd77ae..73587dad 100644 --- a/providers/provider_data.go +++ b/providers/provider_data.go @@ -36,7 +36,11 @@ type ProviderData struct { ClientID string ClientSecret string ClientSecretFile string - Scope string + // ClientSecretFunc is an optional function to dynamically generate client secret. + // If set, it takes precedence over ClientSecret and ClientSecretFile. + // This is used by providers like Apple that require a JWT-signed client secret. + ClientSecretFunc func() (string, error) + Scope string // The response mode requested from the provider or empty for default ("query") AuthRequestResponseMode string // The picked CodeChallenge Method or empty if none. @@ -82,6 +86,12 @@ type ProviderData struct { func (p *ProviderData) Data() *ProviderData { return p } func (p *ProviderData) GetClientSecret() (clientSecret string, err error) { + // If ClientSecretFunc is set, use it to generate the client secret dynamically + // This is used by providers like Apple that require a JWT-signed client secret + if p.ClientSecretFunc != nil { + return p.ClientSecretFunc() + } + if p.ClientSecret != "" || p.ClientSecretFile == "" { return p.ClientSecret, nil }