@@ -17,69 +17,166 @@ type AuthProvider interface {
1717 ApplyAuth (req * resty.Request ) error
1818}
1919
20- // JWTAuth implements JWT-based authentication for Apple Business Manager API
20+ // JWTAuth implements OAuth 2.0 JWT-based authentication for Apple Business Manager API
2121type JWTAuth struct {
22- keyID string
23- issuerID string
24- privateKey * rsa.PrivateKey
25- audience string
22+ keyID string
23+ issuerID string
24+ privateKey any // Can be *rsa.PrivateKey or *ecdsa.PrivateKey
25+ audience string
26+ scope string
27+ accessToken string
28+ tokenExpiry time.Time
29+ mutex sync.RWMutex
30+ httpClient * resty.Client
2631}
2732
2833// JWTAuthConfig holds configuration for JWT authentication
2934type JWTAuthConfig struct {
3035 KeyID string
3136 IssuerID string
32- PrivateKey * rsa.PrivateKey
37+ PrivateKey any // Can be *rsa.PrivateKey or *ecdsa .PrivateKey
3338 Audience string // Usually "appstoreconnect-v1"
39+ Scope string // "business.api" or "school.api"
3440}
3541
36- // NewJWTAuth creates a new JWT authentication provider
42+ // NewJWTAuth creates a new OAuth 2.0 JWT authentication provider
3743func NewJWTAuth (config JWTAuthConfig ) * JWTAuth {
3844 if config .Audience == "" {
3945 config .Audience = "appstoreconnect-v1"
4046 }
47+ if config .Scope == "" {
48+ config .Scope = "business.api"
49+ }
4150
4251 return & JWTAuth {
4352 keyID : config .KeyID ,
4453 issuerID : config .IssuerID ,
4554 privateKey : config .PrivateKey ,
4655 audience : config .Audience ,
56+ scope : config .Scope ,
57+ httpClient : resty .New (),
4758 }
4859}
4960
50- // ApplyAuth applies JWT authentication to the request
61+ // ApplyAuth applies OAuth 2.0 authentication to the request
5162func (j * JWTAuth ) ApplyAuth (req * resty.Request ) error {
52- token , err := j .generateJWT ()
63+ accessToken , err := j .getAccessToken ()
5364 if err != nil {
54- return fmt .Errorf ("failed to generate JWT : %w" , err )
65+ return fmt .Errorf ("failed to get access token : %w" , err )
5566 }
5667
57- req .SetAuthToken (token )
68+ req .SetAuthToken (accessToken )
5869 return nil
5970}
6071
61- // generateJWT creates a JWT token for API authentication
62- func (j * JWTAuth ) generateJWT () (string , error ) {
72+ // getAccessToken returns a valid access token, refreshing if necessary
73+ func (j * JWTAuth ) getAccessToken () (string , error ) {
74+ j .mutex .RLock ()
75+ if j .accessToken != "" && time .Now ().Before (j .tokenExpiry .Add (- 5 * time .Minute )) {
76+ token := j .accessToken
77+ j .mutex .RUnlock ()
78+ return token , nil
79+ }
80+ j .mutex .RUnlock ()
81+
82+ j .mutex .Lock ()
83+ defer j .mutex .Unlock ()
84+
85+ // Double-check after acquiring write lock
86+ if j .accessToken != "" && time .Now ().Before (j .tokenExpiry .Add (- 5 * time .Minute )) {
87+ return j .accessToken , nil
88+ }
89+
90+ // Generate client assertion
91+ clientAssertion , err := j .generateClientAssertion ()
92+ if err != nil {
93+ return "" , fmt .Errorf ("failed to generate client assertion: %w" , err )
94+ }
95+
96+ // Exchange for access token
97+ tokenResp , err := j .exchangeForAccessToken (clientAssertion )
98+ if err != nil {
99+ return "" , fmt .Errorf ("failed to exchange for access token: %w" , err )
100+ }
101+
102+ // Store the token
103+ j .accessToken = tokenResp .AccessToken
104+ j .tokenExpiry = time .Now ().Add (time .Duration (tokenResp .ExpiresIn ) * time .Second )
105+
106+ return j .accessToken , nil
107+ }
108+
109+ // generateClientAssertion creates a JWT client assertion for OAuth 2.0 authentication
110+ func (j * JWTAuth ) generateClientAssertion () (string , error ) {
63111 now := time .Now ()
64112
113+ // Create client assertion claims as per Apple's OAuth 2.0 spec
65114 claims := jwt.MapClaims {
66- "iss" : j .issuerID ,
115+ "iss" : j .issuerID , // team_id (issuer)
116+ "sub" : j .issuerID , // client_id (subject) - same as issuer for Apple
117+ "aud" : "https://account.apple.com/auth/oauth2/v2/token" , // OAuth 2.0 token endpoint
67118 "iat" : now .Unix (),
68- "exp" : now .Add (20 * time .Minute ).Unix (), // Apple recommends 20 minutes max
69- "aud " : j . audience ,
119+ "exp" : now .Add (180 * 24 * time .Hour ).Unix (), // Max 180 days as per Apple docs
120+ "jti " : fmt . Sprintf ( "%d" , now . UnixNano ()), // Unique identifier
70121 }
71122
72- token := jwt .NewWithClaims (jwt .SigningMethodRS256 , claims )
123+ // Determine signing method based on key type
124+ var signingMethod jwt.SigningMethod
125+ switch j .privateKey .(type ) {
126+ case * ecdsa.PrivateKey :
127+ signingMethod = jwt .SigningMethodES256 // ES256 for ECDSA keys
128+ case * rsa.PrivateKey :
129+ signingMethod = jwt .SigningMethodRS256 // RS256 for RSA keys (fallback)
130+ default :
131+ return "" , fmt .Errorf ("unsupported private key type: %T" , j .privateKey )
132+ }
133+
134+ token := jwt .NewWithClaims (signingMethod , claims )
73135 token .Header ["kid" ] = j .keyID
74136
75137 tokenString , err := token .SignedString (j .privateKey )
76138 if err != nil {
77- return "" , fmt .Errorf ("failed to sign JWT: %w" , err )
139+ return "" , fmt .Errorf ("failed to sign JWT client assertion : %w" , err )
78140 }
79141
80142 return tokenString , nil
81143}
82144
145+ // TokenResponse represents the OAuth 2.0 token response from Apple
146+ type TokenResponse struct {
147+ AccessToken string `json:"access_token"`
148+ TokenType string `json:"token_type"`
149+ ExpiresIn int `json:"expires_in"`
150+ Scope string `json:"scope"`
151+ }
152+
153+ // exchangeForAccessToken exchanges the client assertion for an access token
154+ func (j * JWTAuth ) exchangeForAccessToken (clientAssertion string ) (* TokenResponse , error ) {
155+ var tokenResp TokenResponse
156+ resp , err := j .httpClient .R ().
157+ SetFormData (map [string ]string {
158+ "grant_type" : "client_credentials" ,
159+ "client_id" : j .issuerID ,
160+ "client_assertion_type" : "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" ,
161+ "client_assertion" : clientAssertion ,
162+ "scope" : j .scope ,
163+ }).
164+ SetHeader ("Content-Type" , "application/x-www-form-urlencoded" ).
165+ SetHeader ("Host" , "account.apple.com" ).
166+ SetResult (& tokenResp ).
167+ Post ("https://account.apple.com/auth/oauth2/v2/token" )
168+
169+ if err != nil {
170+ return nil , fmt .Errorf ("failed to make token request: %w" , err )
171+ }
172+
173+ if resp .StatusCode () != 200 {
174+ return nil , fmt .Errorf ("token request failed with status %d: %s" , resp .StatusCode (), resp .String ())
175+ }
176+
177+ return & tokenResp , nil
178+ }
179+
83180// APIKeyAuth implements simple API key authentication
84181type APIKeyAuth struct {
85182 apiKey string
0 commit comments