Merge 15dfd6e617 into 1f049e5ebd
This commit is contained in:
commit
a7c1d8a706
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.<br/>You may choose to run both HTTP and HTTPS servers simultaneously.<br/>This can be done by setting the BindAddress and the SecureBindAddress simultaneously.<br/>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<br/>yet working.** [This feature is tracked in<br/>#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<br/>By default `aud` claim is used for verification. |
|
||||
| `extraAudiences` | _[]string_ | ExtraAudiences is a list of additional audiences that are allowed<br/>to pass verification in addition to the client id. |
|
||||
| `enabledSigningAlgs` | _[]string_ | EnabledSigningAlgs is a list of allowed JWT signing algorithms.<br/>When discovery is enabled, the effective set is the intersection<br/>between this list and the provider's discovered supported algorithms.<br/>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.<br/>Possible values are:<br/> - "inParams" (or "params"): sends credentials in the POST body as application/x-www-form-urlencoded parameters<br/> - "inHeader" (or "header"): sends credentials using HTTP Basic Authorization<br/> - "" (empty, default): auto-detect by trying both ways<br/>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<br/>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.<br/>This value is required for all providers. |
|
||||
| `provider` | _[ProviderType](#providertype)_ | Type is the OAuth provider<br/>must be set from the supported providers group,<br/>otherwise 'Google' is set as default |
|
||||
| `name` | _string_ | Name is the providers display name<br/>if set, it will be shown to the users in the login page. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +103,7 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifi
|
|||
ClientSecret: clientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: p.RedeemURL.String(),
|
||||
AuthStyle: p.AuthStyle,
|
||||
},
|
||||
RedirectURL: redirectURL,
|
||||
}
|
||||
|
|
@ -172,6 +188,7 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
|
|||
ClientSecret: clientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: p.RedeemURL.String(),
|
||||
AuthStyle: p.AuthStyle,
|
||||
},
|
||||
}
|
||||
t := &oauth2.Token{
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ type ProviderData struct {
|
|||
ClientID string
|
||||
ClientSecret string
|
||||
ClientSecretFile 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue