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/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/docs/docs/configuration/providers/apple.md b/docs/docs/configuration/providers/apple.md new file mode 100644 index 00000000..4db81de7 --- /dev/null +++ b/docs/docs/configuration/providers/apple.md @@ -0,0 +1,138 @@ +--- +id: apple +title: Apple +--- + +[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 + +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 + +:::note +The Apple provider is only configurable via AlphaConfig. +::: + +### Configuration Options + +| 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 `privateKeyFile` or `privateKey`, but not both. + +### 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_domains` to allow only specific email domains +- `authenticated_emails_file` to allow only specific email addresses + +Example: +```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/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) diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 6f115f8a..e2b4f646 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 { @@ -326,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 { @@ -337,6 +349,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/pkg/validation/providers.go b/pkg/validation/providers.go index 0c8e28db..a0d222f2 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 == options.AppleProvider { + return false + } + return true } diff --git a/providers/apple.go b/providers/apple.go new file mode 100644 index 00000000..75c1b25b --- /dev/null +++ b/providers/apple.go @@ -0,0 +1,169 @@ +package providers + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "net/url" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "golang.org/x/oauth2" +) + +const ( + appleProviderName = "Apple" + appleDefaultScope = "openid email name" + + 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 +// 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 + + 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, + }) + // 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, + TeamID: appleOpts.TeamID, + KeyID: appleOpts.KeyID, + } + + 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 +} + +// 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") + } + 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) +// 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) + 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 +// 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{ + 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) +} diff --git a/providers/apple_test.go b/providers/apple_test.go new file mode 100644 index 00000000..ae60b2c6 --- /dev/null +++ b/providers/apple_test.go @@ -0,0 +1,316 @@ +package providers + +import ( + "context" + "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") + 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 + } + + verificationOptions := internaloidc.IDTokenVerificationOptions{ + AudienceClaims: []string{"aud"}, + ClientID: appleTestClientID, + } + + p, err := NewAppleProvider( + &ProviderData{ + ProviderName: "", + LoginURL: &url.URL{}, + RedeemURL: &url.URL{}, + ProfileURL: &url.URL{}, + ValidateURL: &url.URL{}, + Scope: "", + ClientID: appleTestClientID, + EmailClaim: "email", + UserClaim: "sub", + Verifier: internaloidc.NewVerifier(oidc.NewVerifier( + appleTestIssuer, + mockAppleJWKS{}, + &oidc.Config{ClientID: appleTestClientID}, + ), verificationOptions), + }, + 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 with claims matching the verifier configuration + expiresIn := int64(3600) + idTokenClaims := jwt.MapClaims{ + "iss": appleTestIssuer, + "sub": "user123", + "aud": appleTestClientID, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "email": "user@example.com", + } + + // 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 + 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/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 } 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)