Skip to content

Commit bf6a62b

Browse files
committed
feat: added examples for abm
1 parent aa03887 commit bf6a62b

File tree

22 files changed

+3239
-263
lines changed

22 files changed

+3239
-263
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@
1515
.cursor
1616

1717
# temp
18-
./temp
18+
./temp
19+
20+
code-snippet.sh

client/axm/auth.go

Lines changed: 115 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
2121
type 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
2934
type 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
3743
func 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
5162
func (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
84181
type APIKeyAuth struct {
85182
apiKey string

0 commit comments

Comments
 (0)