feat: Enhance Apple provider

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
This commit is contained in:
LYJW131 2026-01-24 15:18:09 +08:00
parent 54b50cd9e9
commit 4ede1aa158
5 changed files with 100 additions and 147 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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{

View File

@ -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
}