From a7062d45b3196208a23b9af81f77af431e10b477 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Fri, 26 Dec 2025 20:49:36 +0800 Subject: [PATCH 1/7] feat: add Apple Sign in with Apple OIDC provider. Signed-off-by: LYJW131 --- pkg/apis/options/providers.go | 16 ++ providers/apple.go | 305 ++++++++++++++++++++++++++++++++++ providers/apple_test.go | 282 +++++++++++++++++++++++++++++++ providers/providers.go | 4 +- 4 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 providers/apple.go create mode 100644 providers/apple_test.go diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 6f115f8a..9649a0d4 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -93,6 +93,8 @@ type Provider struct { OIDCConfig OIDCOptions `yaml:"oidcConfig,omitempty"` // LoginGovConfig holds all configurations for LoginGov provider. LoginGovConfig LoginGovOptions `yaml:"loginGovConfig,omitempty"` + // AppleConfig holds all configurations for Apple provider. + AppleConfig AppleOptions `yaml:"appleConfig,omitempty"` // ID should be a unique identifier for the provider. // This value is required for all providers. @@ -198,6 +200,9 @@ const ( // SourceHutProvider is the provider type for SourceHut SourceHutProvider ProviderType = "sourcehut" + + // AppleProvider is the provider type for Apple Sign in with Apple + AppleProvider ProviderType = "apple" ) type KeycloakOptions struct { @@ -337,6 +342,17 @@ type LoginGovOptions struct { PubJWKURL string `yaml:"pubjwkURL,omitempty"` } +type AppleOptions struct { + // TeamID is the 10-character Apple Developer Team ID + TeamID string `yaml:"teamID,omitempty"` + // KeyID is the 10-character identifier for the private key + KeyID string `yaml:"keyID,omitempty"` + // PrivateKey is the PEM-encoded ES256 private key content (from .p8 file) + PrivateKey string `yaml:"privateKey,omitempty"` + // PrivateKeyFile is the path to the .p8 private key file + PrivateKeyFile string `yaml:"privateKeyFile,omitempty"` +} + // Legacy default providers configuration func providerDefaults() Providers { providers := Providers{ diff --git a/providers/apple.go b/providers/apple.go new file mode 100644 index 00000000..b9d3f6a4 --- /dev/null +++ b/providers/apple.go @@ -0,0 +1,305 @@ +package providers + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "net/url" + "os" + "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" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" +) + +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" +) + +var ( + appleDefaultLoginURL = &url.URL{ + Scheme: "https", + Host: "appleid.apple.com", + Path: "/auth/authorize", + } + + appleDefaultRedeemURL = &url.URL{ + Scheme: "https", + Host: "appleid.apple.com", + Path: "/auth/token", + } +) + +// AppleProvider represents the Apple Sign in with Apple OIDC provider +type AppleProvider struct { + *OIDCProvider + + TeamID string + KeyID string + PrivateKey *ecdsa.PrivateKey +} + +var _ Provider = (*AppleProvider)(nil) + +// NewAppleProvider creates a new AppleProvider +func NewAppleProvider(p *ProviderData, appleOpts options.AppleOptions, oidcOpts options.OIDCOptions) (*AppleProvider, error) { + p.setProviderDefaults(providerDefaults{ + name: appleProviderName, + loginURL: appleDefaultLoginURL, + redeemURL: appleDefaultRedeemURL, + profileURL: nil, + validateURL: nil, + scope: appleDefaultScope, + }) + 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, + } + + if err := provider.configure(appleOpts); err != nil { + return nil, fmt.Errorf("could not configure Apple provider: %v", err) + } + + return provider, nil +} + +// configure validates and sets up the Apple provider with the private key +func (p *AppleProvider) configure(opts options.AppleOptions) error { + if opts.TeamID == "" { + return errors.New("apple provider requires teamID") + } + if opts.KeyID == "" { + return errors.New("apple provider requires keyID") + } + + // Private key can be supplied via config or file, but not both + switch { + case opts.PrivateKey != "" && opts.PrivateKeyFile != "": + return errors.New("cannot set both privateKey and privateKeyFile options") + case opts.PrivateKey == "" && opts.PrivateKeyFile == "": + return errors.New("apple provider requires a private key for signing JWTs") + case opts.PrivateKey != "": + key, err := parseECPrivateKey([]byte(opts.PrivateKey)) + if err != nil { + return fmt.Errorf("could not parse EC private key: %v", err) + } + p.PrivateKey = key + case opts.PrivateKeyFile != "": + keyData, err := os.ReadFile(opts.PrivateKeyFile) + if err != nil { + return fmt.Errorf("could not read private key file %s: %v", opts.PrivateKeyFile, err) + } + key, err := parseECPrivateKey(keyData) + if err != nil { + return fmt.Errorf("could not parse private key from file %s: %v", opts.PrivateKeyFile, err) + } + p.PrivateKey = key + } + + return nil +} + +// parseECPrivateKey parses a PEM-encoded EC private key (Apple .p8 format) +func parseECPrivateKey(keyData []byte) (*ecdsa.PrivateKey, error) { + // Apple .p8 files contain a PEM-encoded PKCS#8 private key + block, _ := pem.Decode(keyData) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + + // Try PKCS#8 first (Apple's format) + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err == nil { + if ecKey, ok := key.(*ecdsa.PrivateKey); ok { + return ecKey, nil + } + return nil, errors.New("key is not an EC private key") + } + + // Fall back to EC private key format + ecKey, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse EC private key: %v", err) + } + + return ecKey, nil +} + +// generateClientSecret creates a JWT client_secret for Apple token requests +// Apple requires the client_secret to be a JWT signed with ES256 +func (p *AppleProvider) generateClientSecret() (string, error) { + now := time.Now() + claims := &jwt.RegisteredClaims{ + Issuer: p.TeamID, + Subject: p.ClientID, + Audience: jwt.ClaimStrings{appleAudience}, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), // Short-lived for security + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = p.KeyID + + return token.SignedString(p.PrivateKey) +} + +// GetLoginURL returns the Apple authorization URL with required parameters +func (p *AppleProvider) GetLoginURL(redirectURI, state, nonce string, extraParams url.Values) string { + // Apple requires response_mode=form_post for web clients + if extraParams.Get("response_mode") == "" { + extraParams.Set("response_mode", "form_post") + } + 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) + + if s.IDToken != "" && p.Verifier != nil { + if _, err := p.Verifier.Verify(ctx, s.IDToken); err != nil { + return false + } + } + + return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken)) +} diff --git a/providers/apple_test.go b/providers/apple_test.go new file mode 100644 index 00000000..24f9efc1 --- /dev/null +++ b/providers/apple_test.go @@ -0,0 +1,282 @@ +package providers + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "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" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" +) + +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") + rw.Write(body) + })) + u, _ := url.Parse(s.URL) + return u, s +} + +func generateTestECPrivateKey() (*ecdsa.PrivateKey, []byte, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + + keyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, nil, err + } + + pemBlock := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: keyBytes, + } + + return privateKey, pem.EncodeToMemory(pemBlock), nil +} + +func newAppleProvider() (*AppleProvider, *ecdsa.PrivateKey, error) { + privKey, privKeyPEM, err := generateTestECPrivateKey() + if err != nil { + return nil, nil, err + } + + p, err := NewAppleProvider( + &ProviderData{ + ProviderName: "", + LoginURL: &url.URL{}, + RedeemURL: &url.URL{}, + ProfileURL: &url.URL{}, + ValidateURL: &url.URL{}, + Scope: "", + ClientID: "com.example.client", + }, + options.AppleOptions{ + TeamID: "TEAM123456", + KeyID: "KEY1234567", + PrivateKey: string(privKeyPEM), + }, + options.OIDCOptions{}, + ) + if err != nil { + return nil, nil, err + } + + return p, privKey, nil +} + +func TestNewAppleProvider(t *testing.T) { + g := NewWithT(t) + + _, privKeyPEM, err := generateTestECPrivateKey() + g.Expect(err).ToNot(HaveOccurred()) + + // Test that defaults are set when calling for a new provider + provider, err := NewAppleProvider( + &ProviderData{ + ClientID: "com.example.client", + }, + options.AppleOptions{ + TeamID: "TEAM123456", + KeyID: "KEY1234567", + PrivateKey: string(privKeyPEM), + }, + options.OIDCOptions{}, + ) + g.Expect(err).ToNot(HaveOccurred()) + + providerData := provider.Data() + g.Expect(providerData.ProviderName).To(Equal("Apple")) + g.Expect(providerData.LoginURL.String()).To(Equal("https://appleid.apple.com/auth/authorize")) + g.Expect(providerData.RedeemURL.String()).To(Equal("https://appleid.apple.com/auth/token")) + g.Expect(providerData.Scope).To(Equal("openid email name")) +} + +func TestAppleProviderMissingTeamID(t *testing.T) { + _, privKeyPEM, err := generateTestECPrivateKey() + assert.NoError(t, err) + + _, err = NewAppleProvider( + &ProviderData{}, + options.AppleOptions{ + KeyID: "KEY1234567", + PrivateKey: string(privKeyPEM), + }, + options.OIDCOptions{}, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "teamID") +} + +func TestAppleProviderMissingKeyID(t *testing.T) { + _, privKeyPEM, err := generateTestECPrivateKey() + assert.NoError(t, err) + + _, err = NewAppleProvider( + &ProviderData{}, + options.AppleOptions{ + TeamID: "TEAM123456", + PrivateKey: string(privKeyPEM), + }, + options.OIDCOptions{}, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "keyID") +} + +func TestAppleProviderMissingPrivateKey(t *testing.T) { + _, err := NewAppleProvider( + &ProviderData{}, + options.AppleOptions{ + TeamID: "TEAM123456", + KeyID: "KEY1234567", + }, + options.OIDCOptions{}, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "private key") +} + +func TestAppleProviderBothPrivateKeyOptions(t *testing.T) { + _, privKeyPEM, err := generateTestECPrivateKey() + assert.NoError(t, err) + + _, err = NewAppleProvider( + &ProviderData{}, + options.AppleOptions{ + TeamID: "TEAM123456", + KeyID: "KEY1234567", + PrivateKey: string(privKeyPEM), + PrivateKeyFile: "/path/to/key.p8", + }, + options.OIDCOptions{}, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot set both") +} + +func TestAppleProviderGenerateClientSecret(t *testing.T) { + p, privKey, err := newAppleProvider() + assert.NoError(t, err) + assert.NotNil(t, p) + + secret, err := p.generateClientSecret() + assert.NoError(t, err) + assert.NotEmpty(t, secret) + + // Verify the JWT + token, err := jwt.Parse(secret, func(token *jwt.Token) (interface{}, error) { + return &privKey.PublicKey, nil + }) + assert.NoError(t, err) + assert.True(t, token.Valid) + + // Verify claims + claims, ok := token.Claims.(jwt.MapClaims) + assert.True(t, ok) + assert.Equal(t, "TEAM123456", claims["iss"]) + assert.Equal(t, "com.example.client", claims["sub"]) + + // Verify header + assert.Equal(t, "ES256", token.Method.Alg()) + assert.Equal(t, "KEY1234567", token.Header["kid"]) +} + +func TestAppleProviderGetLoginURL(t *testing.T) { + p, _, err := newAppleProvider() + assert.NoError(t, err) + + result := p.GetLoginURL("https://example.com/callback", "state123", "nonce123", url.Values{}) + assert.Contains(t, result, "response_mode=form_post") + assert.Contains(t, result, "state=state123") + assert.Contains(t, result, "redirect_uri=") +} + +func TestAppleProviderRedeem(t *testing.T) { + p, _, err := newAppleProvider() + assert.NoError(t, err) + assert.NotNil(t, p) + + // Create a mock ID token + expiresIn := int64(3600) + idTokenClaims := jwt.MapClaims{ + "iss": "https://appleid.apple.com", + "sub": "user123", + "aud": "com.example.client", + "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) + assert.NoError(t, err) + + // Set up mock server response + body, err := json.Marshal(map[string]interface{}{ + "access_token": "mock_access_token", + "token_type": "Bearer", + "expires_in": expiresIn, + "refresh_token": "mock_refresh_token", + "id_token": signedIDToken, + }) + assert.NoError(t, err) + + var server *httptest.Server + p.RedeemURL, server = newAppleServer(body) + defer server.Close() + + session, err := p.Redeem(context.Background(), "https://example.com/callback", "code123", "") + assert.NoError(t, err) + assert.NotNil(t, session) + assert.Equal(t, "mock_access_token", session.AccessToken) + assert.Equal(t, "mock_refresh_token", session.RefreshToken) + assert.Equal(t, signedIDToken, session.IDToken) +} + +func TestAppleProviderRefreshSession(t *testing.T) { + p, _, err := newAppleProvider() + assert.NoError(t, err) + assert.NotNil(t, p) + + expiresIn := int64(3600) + + // Set up mock server response + body, err := json.Marshal(map[string]interface{}{ + "access_token": "new_access_token", + "token_type": "Bearer", + "expires_in": expiresIn, + "refresh_token": "new_refresh_token", + }) + assert.NoError(t, err) + + var server *httptest.Server + p.RedeemURL, server = newAppleServer(body) + defer server.Close() + + session := &sessions.SessionState{ + RefreshToken: "old_refresh_token", + } + + refreshed, err := p.RefreshSession(context.Background(), session) + assert.NoError(t, err) + assert.True(t, refreshed) + assert.Equal(t, "new_access_token", session.AccessToken) + assert.Equal(t, "new_refresh_token", session.RefreshToken) +} diff --git a/providers/providers.go b/providers/providers.go index f87d26a2..72083858 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -70,6 +70,8 @@ func NewProvider(providerConfig options.Provider) (Provider, error) { return NewNextcloudProvider(providerData), nil case options.OIDCProvider: return NewOIDCProvider(providerData, providerConfig.OIDCConfig), nil + case options.AppleProvider: + return NewAppleProvider(providerData, providerConfig.AppleConfig, providerConfig.OIDCConfig) case options.SourceHutProvider: return NewSourceHutProvider(providerData), nil default: @@ -194,7 +196,7 @@ func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bo options.NextCloudProvider, options.SourceHutProvider: return false, nil case options.OIDCProvider, options.ADFSProvider, options.AzureProvider, options.CidaasProvider, - options.GitLabProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider: + options.GitLabProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider, options.AppleProvider: return true, nil default: return false, fmt.Errorf("unknown provider type: %s", providerType) From cb4ba2cc1fc50871bc4077ce683375727b35f1a0 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Fri, 26 Dec 2025 20:49:36 +0800 Subject: [PATCH 2/7] refactor: Improve Apple provider session authentication logic and eliminate its requirement for client keys. Signed-off-by: LYJW131 --- pkg/validation/providers.go | 5 +++++ providers/apple.go | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go index 0c8e28db..47b8c880 100644 --- a/pkg/validation/providers.go +++ b/pkg/validation/providers.go @@ -106,6 +106,11 @@ func providerRequiresClientSecret(provider options.Provider) bool { return false } + // Apple uses a private key to dynamically generate client_secret JWTs + if provider.Type == "apple" { + return false + } + return true } diff --git a/providers/apple.go b/providers/apple.go index b9d3f6a4..87d928f8 100644 --- a/providers/apple.go +++ b/providers/apple.go @@ -295,11 +295,20 @@ func (p *AppleProvider) RefreshSession(ctx context.Context, s *sessions.SessionS func (p *AppleProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) + // Validate ID token if present if s.IDToken != "" && p.Verifier != nil { if _, err := p.Verifier.Verify(ctx, s.IDToken); err != nil { return false } + // ID token is valid - Apple doesn't provide a token validation endpoint, + return true } - return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken)) + // Fallback to access token validation if ValidateURL is set + if p.ValidateURL != nil && p.ValidateURL.String() != "" { + return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken)) + } + + // No validation possible, but session exists with valid data + return s.AccessToken != "" } From 54b50cd9e94d027efa22138ab0633ab60af5e3b1 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Fri, 26 Dec 2025 20:49:36 +0800 Subject: [PATCH 3/7] feat: Add Apple login provider documents Signed-off-by: LYJW131 --- CHANGELOG.md | 2 + docs/docs/configuration/providers/apple.md | 134 +++++++++++++++++++++ docs/docs/configuration/providers/index.md | 1 + 3 files changed, 137 insertions(+) create mode 100644 docs/docs/configuration/providers/apple.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 788e82c2..00e7830c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Changes since v7.15.3 +- [#3293](https://github.com/oauth2-proxy/oauth2-proxy/pull/3293) feat: add Apple Sign in with Apple provider (@LYJW131) + # V7.15.3 ## Release Highlights diff --git a/docs/docs/configuration/providers/apple.md b/docs/docs/configuration/providers/apple.md new file mode 100644 index 00000000..ea56a4d0 --- /dev/null +++ b/docs/docs/configuration/providers/apple.md @@ -0,0 +1,134 @@ +--- +id: apple +title: Apple +--- + +Apple Sign in with Apple is an OIDC provider that supports authentication using Apple ID. + +## Prerequisites + +1. You need an Apple Developer account +2. Register an App ID in the [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list) +3. Create a Service ID for Sign in with Apple +4. Create a private key for Sign in with Apple + +## Configuration + +### Step 1: Create an App ID + +1. Go to [Identifiers](https://developer.apple.com/account/resources/identifiers/list) in the Apple Developer Portal +2. Click the `+` button to create a new identifier +3. Select "App IDs" and continue +4. Fill in the description and Bundle ID +5. Under Capabilities, enable "Sign in with Apple" +6. Click "Continue" and then "Register" + +### Step 2: Create a Service ID + +1. Go to [Identifiers](https://developer.apple.com/account/resources/identifiers/list) +2. Click the `+` button to create a new identifier +3. Select "Services IDs" and continue +4. Fill in the description and identifier (this will be your `client-id`) +5. Enable "Sign in with Apple" and click "Configure" +6. Select your Primary App ID +7. Add your domain and return URL (e.g., `https://your-domain.com/oauth2/callback`) +8. Click "Continue" and then "Register" + +### Step 3: Create a Private Key + +1. Go to [Keys](https://developer.apple.com/account/resources/authkeys/list) +2. Click the `+` button to create a new key +3. Enter a key name and enable "Sign in with Apple" +4. Click "Configure" and select your Primary App ID +5. Click "Continue" and then "Register" +6. Download the `.p8` private key file (you can only download it once!) +7. Note the Key ID (you'll need this for configuration) + +### Step 4: Get Your Team ID + +Your Team ID can be found in the top right corner of the Apple Developer Portal, or in [Membership Details](https://developer.apple.com/account/#!/membership). + +## Usage + +To use the Apple provider, start oauth2-proxy with `--provider=apple` and the required options: + +```shell +oauth2-proxy \ + --provider=apple \ + --client-id=com.example.yourservice \ + --apple-team-id=TEAM123456 \ + --apple-key-id=KEY1234567 \ + --apple-private-key-file=/path/to/AuthKey_KEY1234567.p8 \ + --redirect-url=https://your-domain.com/oauth2/callback \ + --email-domain=* \ + --cookie-secret=your-cookie-secret +``` + +### Configuration Options + +| Option | Description | +|--------|-------------| +| `--apple-team-id` | Your 10-character Apple Developer Team ID | +| `--apple-key-id` | The 10-character Key ID of your private key | +| `--apple-private-key-file` | Path to the `.p8` private key file | +| `--apple-private-key` | The private key content directly (alternative to file) | + +**Note:** You must provide either `--apple-private-key-file` or `--apple-private-key`, but not both. + +### Alpha Configuration Example + +```yaml +providers: + - id: apple + provider: apple + clientID: com.example.yourservice + appleConfig: + teamID: TEAM123456 + keyID: KEY1234567 + privateKeyFile: /path/to/AuthKey_KEY1234567.p8 +``` + +Or with the private key content directly: + +```yaml +providers: + - id: apple + provider: apple + clientID: com.example.yourservice + appleConfig: + teamID: TEAM123456 + keyID: KEY1234567 + privateKey: | + -----BEGIN PRIVATE KEY----- + MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg... + -----END PRIVATE KEY----- +``` + +## How It Works + +Apple Sign in with Apple has some unique requirements compared to standard OIDC providers: + +1. **Dynamic Client Secret**: Apple requires the `client_secret` to be a JWT signed with your private key (ES256). OAuth2-Proxy automatically generates this JWT for each token request. + +2. **Form Post Response Mode**: Apple returns the authorization code via form POST rather than query parameters. OAuth2-Proxy handles this automatically. + +3. **Limited User Information**: Apple only provides the user's email (and optionally name) on the first authentication. Subsequent authentications may not include the name. + +## Restricting Access + +Like other providers, you can restrict access using: + +- `--email-domain` to allow only specific email domains +- `--authenticated-emails-file` to allow only specific email addresses + +Example: +```shell +oauth2-proxy \ + --provider=apple \ + --client-id=com.example.yourservice \ + --apple-team-id=TEAM123456 \ + --apple-key-id=KEY1234567 \ + --apple-private-key-file=/path/to/key.p8 \ + --email-domain=yourcompany.com \ + --upstream=http://localhost:3000/ +``` diff --git a/docs/docs/configuration/providers/index.md b/docs/docs/configuration/providers/index.md index 6f333e5a..5cabcfac 100644 --- a/docs/docs/configuration/providers/index.md +++ b/docs/docs/configuration/providers/index.md @@ -9,6 +9,7 @@ with Redirect URI(s) for the domain you intend to run `oauth2-proxy` on. Valid providers are : - [ADFS](adfs.md) +- [Apple](apple.md) - [Bitbucket](bitbucket.md) - [Cidaas](cidaas.md) - [CiscoDuo](cisco_duo.md) From 4ede1aa1587fdf00741475195619192ea477d9e0 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Sat, 24 Jan 2026 15:18:09 +0800 Subject: [PATCH 4/7] feat: Enhance Apple provider Signed-off-by: LYJW131 --- pkg/apis/options/providers.go | 7 ++ providers/apple.go | 157 +++++----------------------------- providers/apple_test.go | 50 +++++++++-- providers/oidc.go | 21 ++++- providers/provider_data.go | 12 ++- 5 files changed, 100 insertions(+), 147 deletions(-) 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 } From a8fd63f596c08a6f3dd46a98790343af51d0711f Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Sat, 24 Jan 2026 15:34:48 +0800 Subject: [PATCH 5/7] docs: Update Apple provider documentation for AlphaConfig usage Signed-off-by: LYJW131 --- docs/docs/configuration/providers/apple.md | 68 ++++++++++++---------- providers/apple.go | 1 + 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/docs/docs/configuration/providers/apple.md b/docs/docs/configuration/providers/apple.md index ea56a4d0..8e9ee8c8 100644 --- a/docs/docs/configuration/providers/apple.md +++ b/docs/docs/configuration/providers/apple.md @@ -50,32 +50,22 @@ Your Team ID can be found in the top right corner of the Apple Developer Portal, ## Usage -To use the Apple provider, start oauth2-proxy with `--provider=apple` and the required options: - -```shell -oauth2-proxy \ - --provider=apple \ - --client-id=com.example.yourservice \ - --apple-team-id=TEAM123456 \ - --apple-key-id=KEY1234567 \ - --apple-private-key-file=/path/to/AuthKey_KEY1234567.p8 \ - --redirect-url=https://your-domain.com/oauth2/callback \ - --email-domain=* \ - --cookie-secret=your-cookie-secret -``` +:::note +The Apple provider is only configurable via AlphaConfig. +::: ### Configuration Options -| Option | Description | -|--------|-------------| -| `--apple-team-id` | Your 10-character Apple Developer Team ID | -| `--apple-key-id` | The 10-character Key ID of your private key | -| `--apple-private-key-file` | Path to the `.p8` private key file | -| `--apple-private-key` | The private key content directly (alternative to file) | +| Option | Type | Description | +|--------|------|-------------| +| `teamID` | string | Your 10-character Apple Developer Team ID | +| `keyID` | string | The 10-character Key ID of your private key | +| `privateKeyFile` | string | Path to the `.p8` private key file | +| `privateKey` | string | The private key content directly (alternative to file) | -**Note:** You must provide either `--apple-private-key-file` or `--apple-private-key`, but not both. +**Note:** You must provide either `privateKeyFile` or `privateKey`, but not both. -### Alpha Configuration Example +### Example ```yaml providers: @@ -118,17 +108,31 @@ Apple Sign in with Apple has some unique requirements compared to standard OIDC Like other providers, you can restrict access using: -- `--email-domain` to allow only specific email domains -- `--authenticated-emails-file` to allow only specific email addresses +- `email_domains` to allow only specific email domains +- `authenticated_emails_file` to allow only specific email addresses Example: -```shell -oauth2-proxy \ - --provider=apple \ - --client-id=com.example.yourservice \ - --apple-team-id=TEAM123456 \ - --apple-key-id=KEY1234567 \ - --apple-private-key-file=/path/to/key.p8 \ - --email-domain=yourcompany.com \ - --upstream=http://localhost:3000/ +```yaml +providers: + - id: apple + provider: apple + clientID: com.example.yourservice + appleConfig: + teamID: TEAM123456 + keyID: KEY1234567 + privateKeyFile: /path/to/key.p8 + +upstreamConfig: + upstreams: + - id: backend + path: / + uri: http://localhost:3000/ + +injectRequestHeaders: + - name: X-Forwarded-Email + values: + - claim: email + +emailDomains: + - yourcompany.com ``` diff --git a/providers/apple.go b/providers/apple.go index 241ac6b9..b0ec76dc 100644 --- a/providers/apple.go +++ b/providers/apple.go @@ -41,6 +41,7 @@ var ( ) // AppleProvider represents the Apple Sign in with Apple OIDC provider +// This provider is only configurable via AlphaConfig. // See: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api type AppleProvider struct { *OIDCProvider From 33252dd11cfcc76863b2a5b92aa1e765eba3ec62 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Sun, 1 Feb 2026 20:26:16 +0800 Subject: [PATCH 6/7] docs: add Apple official docs link and fix branding - Add link to official Apple Sign in with Apple documentation - Change "OAuth2-Proxy" to "OAuth2 Proxy" per review feedback Signed-off-by: LYJW131 --- docs/docs/configuration/providers/apple.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/configuration/providers/apple.md b/docs/docs/configuration/providers/apple.md index 8e9ee8c8..4db81de7 100644 --- a/docs/docs/configuration/providers/apple.md +++ b/docs/docs/configuration/providers/apple.md @@ -3,7 +3,7 @@ id: apple title: Apple --- -Apple Sign in with Apple is an OIDC provider that supports authentication using Apple ID. +[Apple Sign in with Apple](https://developer.apple.com/documentation/sign_in_with_apple) is an OIDC provider that supports authentication using Apple ID. ## Prerequisites @@ -98,9 +98,9 @@ providers: Apple Sign in with Apple has some unique requirements compared to standard OIDC providers: -1. **Dynamic Client Secret**: Apple requires the `client_secret` to be a JWT signed with your private key (ES256). OAuth2-Proxy automatically generates this JWT for each token request. +1. **Dynamic Client Secret**: Apple requires the `client_secret` to be a JWT signed with your private key (ES256). OAuth2 Proxy automatically generates this JWT for each token request. -2. **Form Post Response Mode**: Apple returns the authorization code via form POST rather than query parameters. OAuth2-Proxy handles this automatically. +2. **Form Post Response Mode**: Apple returns the authorization code via form POST rather than query parameters. OAuth2 Proxy handles this automatically. 3. **Limited User Information**: Apple only provides the user's email (and optionally name) on the first authentication. Subsequent authentications may not include the name. From 15dfd6e617411276258316daf088e49923de3f1e Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Sun, 19 Jul 2026 13:20:15 +0800 Subject: [PATCH 7/7] refactor: reuse generic OIDC provider logic in Apple provider Address review feedback: - Wire OIDCOptions through NewOIDCProvider instead of constructing OIDCProvider manually - Default AuthRequestResponseMode to form_post instead of overriding GetLoginURL - Drop the ValidateSession override in favour of the generic OIDC one - Use options.AppleProvider constant in client secret validation - Regenerate alpha config docs (appleConfig, authStyle) - Fix CHANGELOG entry to reference #3293 Co-Authored-By: Claude Fable 5 Signed-off-by: LYJW131 --- docs/docs/configuration/alpha_config.md | 15 +++++++ pkg/validation/providers.go | 2 +- providers/apple.go | 55 ++++++------------------- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 680741ba..c4b18edd 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -312,6 +312,19 @@ They may change between releases without notice. | `metricsServer` | _[Server](#server)_ | MetricsServer is used to configure the HTTP(S) server for metrics.
You may choose to run both HTTP and HTTPS servers simultaneously.
This can be done by setting the BindAddress and the SecureBindAddress simultaneously.
To use the secure server you must configure a TLS certificate and key. | | `providers` | _[Providers](#providers)_ | Providers is used to configure your provider. **Multiple-providers is not
yet working.** [This feature is tracked in
#925](https://github.com/oauth2-proxy/oauth2-proxy/issues/926) | +### AppleOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `teamID` | _string_ | TeamID is the 10-character Apple Developer Team ID | +| `keyID` | _string_ | KeyID is the 10-character identifier for the private key | +| `privateKey` | _string_ | PrivateKey is the PEM-encoded ES256 private key content (from .p8 file) | +| `privateKeyFile` | _string_ | PrivateKeyFile is the path to the .p8 private key file | + ### AzureOptions (**Appears on:** [Provider](#provider)) @@ -552,6 +565,7 @@ character. | `audienceClaims` | _[]string_ | AudienceClaim allows to define any claim that is verified against the client id
By default `aud` claim is used for verification. | | `extraAudiences` | _[]string_ | ExtraAudiences is a list of additional audiences that are allowed
to pass verification in addition to the client id. | | `enabledSigningAlgs` | _[]string_ | EnabledSigningAlgs is a list of allowed JWT signing algorithms.
When discovery is enabled, the effective set is the intersection
between this list and the provider's discovered supported algorithms.
By default `RS256` is used if nothing has been discovered or specified. | +| `authStyle` | _string_ | 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". | ### Provider @@ -574,6 +588,7 @@ Provider holds all configuration for a single provider | `googleConfig` | _[GoogleOptions](#googleoptions)_ | GoogleConfig holds all configurations for Google provider. | | `oidcConfig` | _[OIDCOptions](#oidcoptions)_ | OIDCConfig holds all configurations for OIDC provider
or providers utilize OIDC configurations. | | `loginGovConfig` | _[LoginGovOptions](#logingovoptions)_ | LoginGovConfig holds all configurations for LoginGov provider. | +| `appleConfig` | _[AppleOptions](#appleoptions)_ | AppleConfig holds all configurations for Apple provider. | | `id` | _string_ | ID should be a unique identifier for the provider.
This value is required for all providers. | | `provider` | _[ProviderType](#providertype)_ | Type is the OAuth provider
must be set from the supported providers group,
otherwise 'Google' is set as default | | `name` | _string_ | Name is the providers display name
if set, it will be shown to the users in the login page. | diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go index 47b8c880..a0d222f2 100644 --- a/pkg/validation/providers.go +++ b/pkg/validation/providers.go @@ -107,7 +107,7 @@ func providerRequiresClientSecret(provider options.Provider) bool { } // Apple uses a private key to dynamically generate client_secret JWTs - if provider.Type == "apple" { + if provider.Type == options.AppleProvider { return false } diff --git a/providers/apple.go b/providers/apple.go index b0ec76dc..75c1b25b 100644 --- a/providers/apple.go +++ b/providers/apple.go @@ -1,7 +1,6 @@ package providers import ( - "context" "crypto/ecdsa" "crypto/x509" "encoding/pem" @@ -11,11 +10,8 @@ import ( "os" "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" - "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" "golang.org/x/oauth2" ) @@ -63,16 +59,20 @@ func NewAppleProvider(p *ProviderData, appleOpts options.AppleOptions, oidcOpts validateURL: nil, scope: appleDefaultScope, }) - p.getAuthorizationHeaderFunc = makeOIDCHeader + // Apple returns the authorization code via form POST rather than query + // parameters, so default the response mode accordingly + if p.AuthRequestResponseMode == "" { + p.AuthRequestResponseMode = "form_post" + } + + oidcProvider := NewOIDCProvider(p, oidcOpts) + // Apple requires the client credentials in the POST body + oidcProvider.AuthStyle = oauth2.AuthStyleInParams provider := &AppleProvider{ - 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, + OIDCProvider: oidcProvider, + TeamID: appleOpts.TeamID, + KeyID: appleOpts.KeyID, } if err := provider.initialize(appleOpts); err != nil { @@ -167,34 +167,3 @@ func (p *AppleProvider) generateClientSecret() (string, error) { return token.SignedString(p.PrivateKey) } - -// GetLoginURL returns the Apple authorization URL with required parameters -func (p *AppleProvider) GetLoginURL(redirectURI, state, nonce string, extraParams url.Values) string { - // Apple requires response_mode=form_post for web clients - if extraParams.Get("response_mode") == "" { - extraParams.Set("response_mode", "form_post") - } - return p.OIDCProvider.GetLoginURL(redirectURI, state, nonce, extraParams) -} - -// ValidateSession validates the session's ID token -func (p *AppleProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { - ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) - - // Validate ID token if present - if s.IDToken != "" && p.Verifier != nil { - if _, err := p.Verifier.Verify(ctx, s.IDToken); err != nil { - return false - } - // ID token is valid - Apple doesn't provide a token validation endpoint - return true - } - - // Fallback to access token validation if ValidateURL is set - if p.ValidateURL != nil && p.ValidateURL.String() != "" { - return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken)) - } - - // No validation possible, but session exists with valid data - return s.AccessToken != "" -}