-
-
Notifications
You must be signed in to change notification settings - Fork 86
/
Copy pathapple.go
540 lines (440 loc) · 17.9 KB
/
apple.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
package provider
// Implementation sign in with Apple for allow users to sign in to web services using their Apple ID.
// For correct work this provider user must has Apple developer account and correct configure "sign in with Apple" at in
// See more: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api
// and https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/sha1"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"golang.org/x/oauth2"
"github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt"
"github.com/go-pkgz/auth/logger"
"github.com/go-pkgz/auth/token"
)
const (
// appleAuthURL is the base authentication URL for sign in with Apple ID and fetch request code for user validation request.
appleAuthURL = "https://appleid.apple.com/auth/authorize"
// appleTokenURL is the endpoint for verifying tokens and get user unique ID and E-mail
appleTokenURL = "https://appleid.apple.com/auth/token" // #nosec
// appleRequestContentType is the valid type which apple REST API accept only
appleRequestContentType = "application/x-www-form-urlencoded"
// UserAgent required to every request to Apple REST API
defaultUserAgent = "github.com/go-pkgz/auth"
// AcceptJSONHeader is the content to accept from response
AcceptJSONHeader = "application/json"
)
// appleVerificationResponse is based on https://developer.apple.com/documentation/signinwithapplerestapi/tokenresponse
type appleVerificationResponse struct {
// A token used to access allowed user data, but now not implemented public interface for it.
AccessToken string `json:"access_token"`
// Access token type, always equal the "bearer".
TokenType string `json:"token_type"`
// Access token expires time in seconds. Always equal 3600 seconds (1 hour)
ExpiresIn int `json:"expires_in"`
// The refresh token used to regenerate new access tokens.
RefreshToken string `json:"refresh_token"`
// Main JSON Web Token that contains the user’s identity information.
IDToken string `json:"id_token"`
// Used to capture any error returned in response. Always check error for empty
Error string `json:"error"`
}
// AppleConfig is the main oauth2 required parameters for "Sign in with Apple"
type AppleConfig struct {
ClientID string // the identifier Services ID for your app created in Apple developer account.
TeamID string // developer Team ID (10 characters), required for create JWT. It available, after signed in at developer account, by link: https://developer.apple.com/account/#/membership
KeyID string // private key ID assigned to private key obtain in Apple developer account
ResponseMode string // changes method of receiving data in callback. Default value "form_post" (https://developer.apple.com/documentation/sign_in_with_apple/request_an_authorization_to_the_sign_in_with_apple_server?changes=_1_2#4066168)
scopes []string // for this package allow only username scope and UID in token claims. Apple service API provide only "email" and "name" scope values (https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope)
privateKey interface{} // private key from Apple obtained in developer account (the keys section). Required for create the Client Secret (https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
publicKey crypto.PublicKey // need for validate sign of token
clientSecret string // is the JWT client secret will create after first call and then used until expired
jwkURL string // URL for fetch JWK Apple keys, need redefine for tests
}
// AppleHandler implements login via Apple ID
type AppleHandler struct {
Params
// all of these fields specific to particular oauth2 provider
name string
// infoURL string not implemented at Apple side
endpoint oauth2.Endpoint
mapUser func(jwt.MapClaims) token.User // map info from InfoURL to User
conf AppleConfig // main config for Apple auth provider
PrivateKeyLoader PrivateKeyLoaderInterface // custom function interface for load private key
}
// PrivateKeyLoaderInterface interface for implement custom loader for Apple private key from user source
type PrivateKeyLoaderInterface interface {
LoadPrivateKey() ([]byte, error)
}
// LoadFromFileFunc is the type for use pre-defined private key loader function
// Path field must be set with actual path to private key file
type LoadFromFileFunc struct {
Path string
}
// LoadApplePrivateKeyFromFile return instance for pre-defined loader function from local file
func LoadApplePrivateKeyFromFile(path string) LoadFromFileFunc {
return LoadFromFileFunc{
Path: path,
}
}
// LoadPrivateKey implement pre-defined (built-in) PrivateKeyLoaderInterface interface method for load private key from local file
func (lf LoadFromFileFunc) LoadPrivateKey() ([]byte, error) {
if lf.Path == "" {
return nil, fmt.Errorf("empty private key path not allowed")
}
keyFile, err := os.Open(lf.Path)
if err != nil {
return nil, err
}
keyValue, err := io.ReadAll(keyFile)
if err != nil {
return nil, err
}
err = keyFile.Close()
return keyValue, err
}
// NewApple create new AppleProvider instance with a user parameters
// Private key must be set, when instance create call, for create `client_secret`
func NewApple(p Params, appleCfg AppleConfig, privateKeyLoader PrivateKeyLoaderInterface) (*AppleHandler, error) {
if p.L == nil {
p.L = logger.NoOp
}
var emptyParams []string
// check required parameters filled
if appleCfg.ClientID == "" {
emptyParams = append(emptyParams, "ClientID")
}
if appleCfg.TeamID == "" {
emptyParams = append(emptyParams, "TeamID")
}
if appleCfg.KeyID == "" {
emptyParams = append(emptyParams, "KeyID")
}
if len(emptyParams) > 0 {
return nil, fmt.Errorf("required params missed: %s", strings.Join(emptyParams, ", "))
}
responseMode := "form_post"
if appleCfg.ResponseMode != "" {
responseMode = appleCfg.ResponseMode
}
ah := AppleHandler{
Params: p,
name: "apple", // static name for an Apple provider
conf: AppleConfig{
ClientID: appleCfg.ClientID,
TeamID: appleCfg.TeamID,
KeyID: appleCfg.KeyID,
scopes: []string{"name"},
jwkURL: appleKeysURL,
ResponseMode: responseMode,
},
endpoint: oauth2.Endpoint{
AuthURL: appleAuthURL,
TokenURL: appleTokenURL,
},
mapUser: func(claims jwt.MapClaims) token.User {
var usr token.User
if uid, ok := claims["sub"]; ok {
usr.ID = "apple_" + token.HashID(sha1.New(), uid.(string))
}
return usr
},
}
if privateKeyLoader == nil {
return nil, fmt.Errorf("private key loader undefined")
}
ah.PrivateKeyLoader = privateKeyLoader
err := ah.initPrivateKey()
return &ah, err
}
// initPrivateKey parse Apple private key and assign to AppleHandler
func (ah *AppleHandler) initPrivateKey() error {
sKey, err := ah.PrivateKeyLoader.LoadPrivateKey()
if err != nil {
return fmt.Errorf("problem with private key loading: %w", err)
}
block, _ := pem.Decode(sKey)
if block == nil {
return fmt.Errorf("empty block after decoding")
}
ah.conf.privateKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return err
}
publicKey, ok := ah.conf.privateKey.(*ecdsa.PrivateKey)
if !ok {
return fmt.Errorf("provided private key is not ECDSA")
}
ah.conf.publicKey = publicKey.Public()
ah.conf.clientSecret, err = ah.createClientSecret()
if err != nil {
return err
}
return nil
}
// tokenKeyFunc use for verify JWT sign, it receives the parsed token and should return the key for validating.
func (ah *AppleHandler) tokenKeyFunc(jwtToken *jwt.Token) (interface{}, error) {
if jwtToken == nil {
return nil, fmt.Errorf("failed to call token keyFunc, because token is nil")
}
return ah.conf.publicKey, nil // extract public key from private key
}
// Name of the provider
func (ah *AppleHandler) Name() string { return ah.name }
// LoginHandler - GET */{provider-name}/login
func (ah *AppleHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
ah.Logf("[DEBUG] login with %s", ah.Name())
// make state (random) and store in session
state, err := randToken()
if err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to make oauth2 state")
return
}
cid, err := randToken()
if err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to make claim's id")
return
}
claims := token.Claims{
Handshake: &token.Handshake{
State: state,
From: r.URL.Query().Get("from"),
},
SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0",
StandardClaims: jwt.StandardClaims{
Id: cid,
Audience: r.URL.Query().Get("site"),
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
},
}
if _, err = ah.JwtService.Set(w, claims); err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to set token")
return
}
// return login url
loginURL, err := ah.prepareLoginURL(state, r.URL.Path)
if err != nil {
errMsg := fmt.Sprintf("prepare login url for [%s] provider failed", ah.name)
ah.Logf("[ERROR] %s", errMsg)
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, errMsg)
return
}
ah.Logf("[DEBUG] login url %s, claims=%+v", loginURL, claims)
http.Redirect(w, r, loginURL, http.StatusFound)
}
// AuthHandler fills user info and redirects to "from" url. This is callback url redirected locally by browser
// GET /callback
func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
// read response form data
if err := r.ParseForm(); err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "read callback response from data failed")
return
}
state := r.FormValue("state") // state value which sent with auth request
code := r.FormValue("code") // client code for validation
// response with user name filed return only one time at first login, next login field user doesn't exist
// until user delete sign with Apple ID in account profile (security section)
// example response: {"name":{"firstName":"Chan","lastName":"Lu"},"email":"user@email.com"}
jUser := r.FormValue("user") // json string with user name
oauthClaims, _, err := ah.JwtService.Get(r)
if err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to get token")
return
}
if oauthClaims.Handshake == nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, nil, "invalid handshake token")
return
}
retrievedState := oauthClaims.Handshake.State
if retrievedState == "" || retrievedState != state {
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, nil, "unexpected state")
return
}
var resp appleVerificationResponse
err = ah.exchange(context.Background(), code, ah.makeRedirURL(r.URL.Path), &resp)
if err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "exchange failed")
return
}
ah.Logf("[DEBUG] response data %+v", resp)
if resp.Error != "" {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("fetch IDtoken response error: %s", resp.Error))
return
}
// trying to fetch Apple public key (JWK) for verify token signature, it need for verify IDToken received from Apple
keySet, err := fetchAppleJWK(r.Context(), ah.conf.jwkURL)
if err != nil {
ah.L.Logf("[ERROR] failed to fetch JWK from Apple key service: " + err.Error())
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("failed to fetch JWK from Apple key service: %s", resp.Error))
return
}
// get token claims for extract uid (and email or name if they exist in scope)
tokenClaims := jwt.MapClaims{}
_, err = jwt.ParseWithClaims(resp.IDToken, tokenClaims, keySet.keyFunc)
if err != nil {
ah.L.Logf("[ERROR] failed to get claims: " + err.Error())
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("failed to token validation, key is invalid: %s", resp.Error))
return
}
u := ah.mapUser(tokenClaims)
u, err = setAvatar(ah.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second})
if err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
return
}
// try parse username if one exist at response or noname assign
ah.parseUserData(&u, jUser)
cid, err := randToken()
if err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to make claim's id")
return
}
claims := token.Claims{
User: &u,
StandardClaims: jwt.StandardClaims{
Issuer: ah.Issuer,
Id: cid,
Audience: oauthClaims.Audience,
},
SessionOnly: false,
}
if _, err = ah.JwtService.Set(w, claims); err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to set token")
return
}
ah.Logf("[DEBUG] user info %+v", u)
// redirect to back url if presented in login query params
if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" {
http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect)
return
}
rest.RenderJSON(w, &u)
}
// LogoutHandler - GET /logout
func (ah AppleHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
if _, _, err := ah.JwtService.Get(r); err != nil {
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, err, "logout not allowed")
return
}
ah.JwtService.Reset(w)
}
// exchange sends the validation token request and gets access token and user claims
// (e.g. https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens)
func (ah *AppleHandler) exchange(ctx context.Context, code, redirectURI string, result *appleVerificationResponse) error {
// check client_secret for valid and recreate new (client_secret JWT) if required
if tkn, err := jwt.Parse(ah.conf.clientSecret, ah.tokenKeyFunc); err != nil || tkn == nil {
ah.conf.clientSecret, err = ah.createClientSecret()
if err != nil {
return fmt.Errorf("client secret create failed: %w", err)
}
}
data := url.Values{}
data.Set("client_id", ah.conf.ClientID)
data.Set("client_secret", ah.conf.clientSecret) // JWT signed with Apple private key
data.Set("code", code)
data.Set("redirect_uri", redirectURI) // redirect URL can't refer to localhost and must have trusted certificate and https protocol
data.Set("grant_type", "authorization_code")
client := http.Client{Timeout: time.Second * 5}
req, err := http.NewRequestWithContext(ctx, "POST", ah.endpoint.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Add("content-type", appleRequestContentType)
req.Header.Add("accept", AcceptJSONHeader)
req.Header.Add("user-agent", defaultUserAgent) // apple requires a user agent
res, err := client.Do(req)
if err != nil {
return err
}
// Trying to decode (unmarshal json) data of response
err = json.NewDecoder(res.Body).Decode(result)
if err != nil {
return fmt.Errorf("unmarshalling data from apple service response failed: %w", err)
}
defer func() {
if err = res.Body.Close(); err != nil {
ah.L.Logf("[ERROR] close request body failed when get access token: %v", err)
}
}()
// If above operation done successfully checking a response code and error descriptions, if one exist.
// Apple service will response either 200 (OK) or 400 (any error).
if res.StatusCode != http.StatusOK || result.Error != "" {
return fmt.Errorf("apple token service error: %s", result.Error)
}
return err
}
// createClientSecret use for create the JWT client secret required to make requests to the Apple validation server.
// for more details go to link: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048
func (ah *AppleHandler) createClientSecret() (string, error) {
if ah.conf.privateKey == nil {
return "", fmt.Errorf("private key can't be empty")
}
// Create a claims
now := time.Now()
exp := now.Add(time.Minute * 30).Unix() // default value
claims := &jwt.StandardClaims{
Issuer: ah.conf.TeamID,
IssuedAt: now.Unix(),
ExpiresAt: exp,
Audience: "https://appleid.apple.com",
Subject: ah.conf.ClientID,
}
tkn := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tkn.Header["alg"] = "ES256"
tkn.Header["kid"] = ah.conf.KeyID
return tkn.SignedString(ah.conf.privateKey)
}
func (ah *AppleHandler) parseUserData(user *token.User, jUser string) {
type UserData struct {
Name struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"name"`
Email string `json:"email"`
}
var userData UserData
// Catch error for log only. No need break flow if user name doesn't exist
if err := json.Unmarshal([]byte(jUser), &userData); err != nil {
ah.L.Logf("[DEBUG] failed to parse user data %s: %v", user, err)
user.Name = "noname_" + user.ID[6:12] // paste noname if user name failed to parse
return
}
user.Name = fmt.Sprintf("%s %s", userData.Name.FirstName, userData.Name.LastName)
}
func (ah *AppleHandler) prepareLoginURL(state, path string) (string, error) {
scopesList := strings.Join(ah.conf.scopes, " ")
if scopesList != "" && ah.conf.ResponseMode != "form_post" {
return "", fmt.Errorf("response_mode must be form_post if scope is not empty")
}
authURL, err := url.Parse(ah.endpoint.AuthURL)
if err != nil {
return "", err
}
query := authURL.Query()
query.Set("state", state)
query.Set("response_type", "code")
query.Set("response_mode", ah.conf.ResponseMode)
query.Set("client_id", ah.conf.ClientID)
query.Set("scope", scopesList)
query.Set("redirect_uri", ah.makeRedirURL(path))
authURL.RawQuery = query.Encode()
return authURL.String(), nil
}
func (ah AppleHandler) makeRedirURL(path string) string {
elems := strings.Split(path, "/")
newPath := strings.Join(elems[:len(elems)-1], "/")
return strings.TrimRight(ah.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix
}