feat: Enhance Apple provider
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
This commit is contained in:
parent
54b50cd9e9
commit
4ede1aa158
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue