firejwt 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +4 -1
- data/Gemfile.lock +16 -18
- data/README.md +4 -4
- data/ext_test.go +6 -0
- data/firejwt.gemspec +1 -1
- data/firejwt.go +90 -20
- data/firejwt_test.go +136 -30
- data/lib/firejwt.rb +1 -1
- data/lib/firejwt/{key_set.rb → certificates.rb} +12 -6
- data/lib/firejwt/validator.rb +33 -31
- data/spec/firejwt/{key_set_spec.rb → certificates_spec.rb} +6 -7
- data/spec/firejwt/validator_spec.rb +56 -42
- data/spec/spec_helper.rb +31 -5
- metadata +7 -9
- data/opt.go +0 -20
- data/testdata/cert.pem +0 -22
- data/testdata/priv.pem +0 -28
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 22a3cbe836d5c04927447040ffdc8580f0a8863798dc1089e3e4fa6940989b0f
|
4
|
+
data.tar.gz: 886dbe43fee7a2132259c1997018e711a08e54e53366a461efc77516c6893bfc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: de05062161e6489c4c074b786a4125a2c412d3b4641b23e91c18f3e6f734993e81ea98eea4a0775259f10183c9872597e84e2a3d944c405bd6ea38d3edd82862
|
7
|
+
data.tar.gz: c62208610c25313796a7a663dcad304c7568293a9096bce5c26b9d0de493fb90eedc159cc8047864f9eb7ca028caa4361c508b276ff2733bb7662e13b96e9a25
|
data/.travis.yml
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
firejwt (0.
|
4
|
+
firejwt (0.2.0)
|
5
5
|
jwt
|
6
6
|
|
7
7
|
GEM
|
@@ -10,24 +10,23 @@ GEM
|
|
10
10
|
addressable (2.7.0)
|
11
11
|
public_suffix (>= 2.0.2, < 5.0)
|
12
12
|
ast (2.4.1)
|
13
|
-
crack (0.4.
|
14
|
-
|
15
|
-
diff-lcs (1.3)
|
13
|
+
crack (0.4.4)
|
14
|
+
diff-lcs (1.4.4)
|
16
15
|
hashdiff (1.0.1)
|
17
|
-
jwt (2.2.
|
18
|
-
parallel (1.19.
|
19
|
-
parser (2.7.1.
|
20
|
-
ast (~> 2.4.
|
21
|
-
public_suffix (4.0.
|
16
|
+
jwt (2.2.2)
|
17
|
+
parallel (1.19.2)
|
18
|
+
parser (2.7.1.5)
|
19
|
+
ast (~> 2.4.1)
|
20
|
+
public_suffix (4.0.6)
|
22
21
|
rainbow (3.0.0)
|
23
22
|
rake (13.0.1)
|
24
|
-
regexp_parser (1.
|
23
|
+
regexp_parser (1.8.1)
|
25
24
|
rexml (3.2.4)
|
26
25
|
rspec (3.9.0)
|
27
26
|
rspec-core (~> 3.9.0)
|
28
27
|
rspec-expectations (~> 3.9.0)
|
29
28
|
rspec-mocks (~> 3.9.0)
|
30
|
-
rspec-core (3.9.
|
29
|
+
rspec-core (3.9.3)
|
31
30
|
rspec-support (~> 3.9.3)
|
32
31
|
rspec-expectations (3.9.2)
|
33
32
|
diff-lcs (>= 1.2.0, < 2.0)
|
@@ -36,21 +35,20 @@ GEM
|
|
36
35
|
diff-lcs (>= 1.2.0, < 2.0)
|
37
36
|
rspec-support (~> 3.9.0)
|
38
37
|
rspec-support (3.9.3)
|
39
|
-
rubocop (0.
|
38
|
+
rubocop (0.92.0)
|
40
39
|
parallel (~> 1.10)
|
41
|
-
parser (>= 2.7.
|
40
|
+
parser (>= 2.7.1.5)
|
42
41
|
rainbow (>= 2.2.2, < 4.0)
|
43
42
|
regexp_parser (>= 1.7)
|
44
43
|
rexml
|
45
|
-
rubocop-ast (>= 0.0
|
44
|
+
rubocop-ast (>= 0.5.0)
|
46
45
|
ruby-progressbar (~> 1.7)
|
47
46
|
unicode-display_width (>= 1.4.0, < 2.0)
|
48
|
-
rubocop-ast (0.
|
49
|
-
parser (>= 2.7.
|
47
|
+
rubocop-ast (0.7.1)
|
48
|
+
parser (>= 2.7.1.5)
|
50
49
|
ruby-progressbar (1.10.1)
|
51
|
-
safe_yaml (1.0.5)
|
52
50
|
unicode-display_width (1.7.0)
|
53
|
-
webmock (3.
|
51
|
+
webmock (3.9.1)
|
54
52
|
addressable (>= 2.3.6)
|
55
53
|
crack (>= 0.3.2)
|
56
54
|
hashdiff (>= 0.4.0, < 2.0.0)
|
data/README.md
CHANGED
@@ -13,11 +13,11 @@ Decode and validate [Google Firebase](https://firebase.google.com/) JWT tokens w
|
|
13
13
|
require 'firejwt'
|
14
14
|
|
15
15
|
# Init a validator
|
16
|
-
validator = FireJWT::Validator.new
|
16
|
+
validator = FireJWT::Validator.new 'my-project'
|
17
17
|
|
18
18
|
# Decode a token
|
19
19
|
token = begin
|
20
|
-
validator.decode('eyJh...YbQ') # => {'sub' => 'me@example.com', 'aud' => 'my-
|
20
|
+
validator.decode('eyJh...YbQ') # => {'sub' => 'me@example.com', 'aud' => 'my-project'}
|
21
21
|
rescue JWT::DecodeError
|
22
22
|
nil
|
23
23
|
end
|
@@ -35,7 +35,7 @@ import (
|
|
35
35
|
)
|
36
36
|
|
37
37
|
func main() {
|
38
|
-
vr, err := firejwt.New(
|
38
|
+
vr, err := firejwt.New("my-project")
|
39
39
|
if err != nil {
|
40
40
|
log.Fatalln(err)
|
41
41
|
}
|
@@ -46,6 +46,6 @@ func main() {
|
|
46
46
|
log.Fatalln(err)
|
47
47
|
}
|
48
48
|
|
49
|
-
log.Println(tk.Claims) // => {"sub": "me@example.com", "aud": "my-
|
49
|
+
log.Println(tk.Claims) // => {"sub": "me@example.com", "aud": "my-project"}
|
50
50
|
}
|
51
51
|
```
|
data/ext_test.go
ADDED
data/firejwt.gemspec
CHANGED
data/firejwt.go
CHANGED
@@ -6,6 +6,7 @@ import (
|
|
6
6
|
"crypto/x509"
|
7
7
|
"encoding/json"
|
8
8
|
"encoding/pem"
|
9
|
+
"errors"
|
9
10
|
"fmt"
|
10
11
|
"log"
|
11
12
|
"net/http"
|
@@ -15,19 +16,32 @@ import (
|
|
15
16
|
"github.com/dgrijalva/jwt-go"
|
16
17
|
)
|
17
18
|
|
19
|
+
const defaultURL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
|
20
|
+
|
18
21
|
// Validator validates Firebase JWTs
|
19
22
|
type Validator struct {
|
20
|
-
|
21
|
-
|
23
|
+
audience string
|
24
|
+
issuer string
|
25
|
+
url string
|
26
|
+
htc http.Client
|
22
27
|
|
23
28
|
cancel context.CancelFunc
|
24
29
|
keyset atomic.Value
|
25
30
|
expires int64
|
26
31
|
}
|
27
32
|
|
28
|
-
// New issues a new Validator
|
29
|
-
|
30
|
-
|
33
|
+
// New issues a new Validator with a projectID, a unique identifier for your
|
34
|
+
// Firebase project, which can be found in the URL of that project's console.
|
35
|
+
func New(projectID string) (*Validator, error) {
|
36
|
+
return newValidator(projectID, defaultURL)
|
37
|
+
}
|
38
|
+
|
39
|
+
func newValidator(projectID, url string) (*Validator, error) {
|
40
|
+
v := &Validator{
|
41
|
+
audience: projectID,
|
42
|
+
issuer: "https://securetoken.google.com/" + projectID,
|
43
|
+
url: url,
|
44
|
+
}
|
31
45
|
if err := v.Refresh(); err != nil {
|
32
46
|
return nil, err
|
33
47
|
}
|
@@ -46,19 +60,7 @@ func (v *Validator) Stop() {
|
|
46
60
|
|
47
61
|
// Decode decodes the token
|
48
62
|
func (v *Validator) Decode(tokenString string) (*jwt.Token, error) {
|
49
|
-
return jwt.
|
50
|
-
kid, ok := token.Header["kid"].(string)
|
51
|
-
if !ok {
|
52
|
-
return nil, fmt.Errorf("missing kid header")
|
53
|
-
}
|
54
|
-
|
55
|
-
key, ok := v.keyset.Load().(map[string]publicKey)[kid]
|
56
|
-
if !ok {
|
57
|
-
return nil, fmt.Errorf("unknown kid header %s", kid)
|
58
|
-
}
|
59
|
-
|
60
|
-
return key.PublicKey, nil
|
61
|
-
})
|
63
|
+
return jwt.ParseWithClaims(tokenString, new(Claims), v.verify)
|
62
64
|
}
|
63
65
|
|
64
66
|
// ExpTime returns the expiration time.
|
@@ -68,7 +70,7 @@ func (v *Validator) ExpTime() time.Time {
|
|
68
70
|
|
69
71
|
// Refresh retrieves the latest keys.
|
70
72
|
func (v *Validator) Refresh() error {
|
71
|
-
resp, err := v.htc.Get(v.
|
73
|
+
resp, err := v.htc.Get(v.url)
|
72
74
|
if err != nil {
|
73
75
|
return err
|
74
76
|
}
|
@@ -89,12 +91,50 @@ func (v *Validator) Refresh() error {
|
|
89
91
|
return nil
|
90
92
|
}
|
91
93
|
|
94
|
+
var (
|
95
|
+
errKIDMissing = errors.New("missing kid header")
|
96
|
+
errExpired = errors.New("token has expired")
|
97
|
+
errIssuedFuture = errors.New("issued in the future")
|
98
|
+
errNoSubject = errors.New("subject is missing")
|
99
|
+
errAuthFuture = errors.New("auth-time in the future")
|
100
|
+
)
|
101
|
+
|
102
|
+
func (v *Validator) verify(token *jwt.Token) (interface{}, error) {
|
103
|
+
kid, ok := token.Header["kid"].(string)
|
104
|
+
if !ok {
|
105
|
+
return nil, errKIDMissing
|
106
|
+
}
|
107
|
+
|
108
|
+
key, ok := v.keyset.Load().(map[string]publicKey)[kid]
|
109
|
+
if !ok {
|
110
|
+
return nil, fmt.Errorf("invalid kid header %q", kid)
|
111
|
+
}
|
112
|
+
|
113
|
+
now := time.Now().Unix()
|
114
|
+
claims := token.Claims.(*Claims)
|
115
|
+
if claims.Audience != v.audience {
|
116
|
+
return nil, fmt.Errorf("invalid audience claim %q", claims.Audience)
|
117
|
+
} else if claims.Issuer != v.issuer {
|
118
|
+
return nil, fmt.Errorf("invalid issuer claim %q", claims.Issuer)
|
119
|
+
} else if claims.Subject == "" {
|
120
|
+
return nil, errNoSubject
|
121
|
+
} else if claims.ExpiresAt <= now {
|
122
|
+
return nil, errExpired
|
123
|
+
} else if claims.IssuedAt > now {
|
124
|
+
return nil, errIssuedFuture
|
125
|
+
} else if claims.AuthAt > now {
|
126
|
+
return nil, errAuthFuture
|
127
|
+
}
|
128
|
+
|
129
|
+
return key.PublicKey, nil
|
130
|
+
}
|
131
|
+
|
92
132
|
func (v *Validator) loop(ctx context.Context) {
|
93
133
|
t := time.NewTimer(time.Minute)
|
94
134
|
defer t.Stop()
|
95
135
|
|
96
136
|
for {
|
97
|
-
d := v.ExpTime()
|
137
|
+
d := time.Until(v.ExpTime()) - time.Hour
|
98
138
|
if d < time.Minute {
|
99
139
|
d = time.Minute
|
100
140
|
}
|
@@ -135,3 +175,33 @@ func (k *publicKey) UnmarshalText(data []byte) error {
|
|
135
175
|
*k = publicKey{PublicKey: cert.PublicKey.(*rsa.PublicKey)}
|
136
176
|
return nil
|
137
177
|
}
|
178
|
+
|
179
|
+
// --------------------------------------------------------------------
|
180
|
+
|
181
|
+
// Claims are included in the token.
|
182
|
+
type Claims struct {
|
183
|
+
Subject string `json:"sub,omitempty"`
|
184
|
+
Audience string `json:"aud,omitempty"`
|
185
|
+
Issuer string `json:"iss,omitempty"`
|
186
|
+
IssuedAt int64 `json:"iat,omitempty"`
|
187
|
+
ExpiresAt int64 `json:"exp,omitempty"`
|
188
|
+
|
189
|
+
Name string `json:"name,omitempty"`
|
190
|
+
Picture string `json:"picture,omitempty"`
|
191
|
+
UserID string `json:"user_id,omitempty"`
|
192
|
+
AuthAt int64 `json:"auth_time,omitempty"`
|
193
|
+
Email string `json:"email,omitempty"`
|
194
|
+
EmailVerified bool `json:"email_verified"`
|
195
|
+
Firebase *FirebaseClaim `json:"firebase,omitempty"`
|
196
|
+
}
|
197
|
+
|
198
|
+
// FirebaseClaim represents firebase specific claim.
|
199
|
+
type FirebaseClaim struct {
|
200
|
+
SignInProvider string `json:"sign_in_provider,omitempty"`
|
201
|
+
Identities map[string][]string `json:"identities,omitempty"`
|
202
|
+
}
|
203
|
+
|
204
|
+
// Valid implements the jwt.Claims interface.
|
205
|
+
func (c *Claims) Valid() error {
|
206
|
+
return nil
|
207
|
+
}
|
data/firejwt_test.go
CHANGED
@@ -1,9 +1,16 @@
|
|
1
1
|
package firejwt_test
|
2
2
|
|
3
3
|
import (
|
4
|
+
"bytes"
|
5
|
+
"crypto/rand"
|
4
6
|
"crypto/rsa"
|
7
|
+
"crypto/sha1"
|
8
|
+
"crypto/x509"
|
9
|
+
"crypto/x509/pkix"
|
10
|
+
"encoding/hex"
|
5
11
|
"encoding/json"
|
6
|
-
"
|
12
|
+
"encoding/pem"
|
13
|
+
"math/big"
|
7
14
|
"net/http"
|
8
15
|
"net/http/httptest"
|
9
16
|
"testing"
|
@@ -18,33 +25,33 @@ import (
|
|
18
25
|
var _ = Describe("Validator", func() {
|
19
26
|
var subject *firejwt.Validator
|
20
27
|
var server *httptest.Server
|
28
|
+
var claims *firejwt.Claims
|
21
29
|
|
22
|
-
|
30
|
+
generate := func() string {
|
31
|
+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
32
|
+
token.Header["kid"] = certKID
|
23
33
|
|
24
|
-
|
25
|
-
src := jwt.NewWithClaims(method, claims)
|
26
|
-
src.Header["kid"] = kid
|
27
|
-
|
28
|
-
str, err := src.SignedString(privKey)
|
34
|
+
data, err := token.SignedString(privKey)
|
29
35
|
Expect(err).NotTo(HaveOccurred())
|
30
|
-
|
31
|
-
return subject.Decode(str)
|
36
|
+
return data
|
32
37
|
}
|
33
38
|
|
34
39
|
BeforeEach(func() {
|
35
40
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
36
41
|
w.Header().Set("expires", "Mon, 20 Jan 2020 23:40:59 GMT")
|
37
42
|
json.NewEncoder(w).Encode(map[string]string{
|
38
|
-
|
43
|
+
certKID: string(certPEM),
|
39
44
|
})
|
40
45
|
}))
|
46
|
+
claims = mockClaims(time.Now().Unix())
|
41
47
|
|
42
48
|
var err error
|
43
|
-
subject, err = firejwt.
|
49
|
+
subject, err = firejwt.Mocked(server.URL)
|
44
50
|
Expect(err).NotTo(HaveOccurred())
|
45
51
|
})
|
46
52
|
|
47
53
|
AfterEach(func() {
|
54
|
+
server.Close()
|
48
55
|
subject.Stop()
|
49
56
|
})
|
50
57
|
|
@@ -53,28 +60,84 @@ var _ = Describe("Validator", func() {
|
|
53
60
|
})
|
54
61
|
|
55
62
|
It("should decode tokens", func() {
|
56
|
-
token, err :=
|
57
|
-
Subject: "me@example.com",
|
58
|
-
Audience: "you",
|
59
|
-
Issuer: "me",
|
60
|
-
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
61
|
-
})
|
63
|
+
token, err := subject.Decode(generate())
|
62
64
|
Expect(err).NotTo(HaveOccurred())
|
63
65
|
Expect(token.Valid).To(BeTrue())
|
64
|
-
Expect(token.Claims).To(
|
66
|
+
Expect(token.Claims).To(Equal(claims))
|
65
67
|
})
|
66
68
|
|
67
69
|
It("should reject bad tokens", func() {
|
68
|
-
_, err := subject.Decode("
|
70
|
+
_, err := subject.Decode("BAD")
|
69
71
|
Expect(err).To(MatchError(`token contains an invalid number of segments`))
|
72
|
+
Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
|
73
|
+
})
|
74
|
+
|
75
|
+
It("should verify exp", func() {
|
76
|
+
claims.ExpiresAt = time.Now().Unix() - 1
|
77
|
+
_, err := subject.Decode(generate())
|
78
|
+
Expect(err).To(MatchError(`token has expired`))
|
79
|
+
Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
|
80
|
+
})
|
81
|
+
|
82
|
+
It("should verify iat", func() {
|
83
|
+
claims.IssuedAt = time.Now().Unix() + 1
|
84
|
+
_, err := subject.Decode(generate())
|
85
|
+
Expect(err).To(MatchError(`issued in the future`))
|
86
|
+
Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
|
70
87
|
})
|
71
88
|
|
72
|
-
It("should
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
})
|
77
|
-
|
89
|
+
It("should verify aud", func() {
|
90
|
+
claims.Audience = "other"
|
91
|
+
_, err := subject.Decode(generate())
|
92
|
+
Expect(err).To(MatchError(`invalid audience claim "other"`))
|
93
|
+
Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
|
94
|
+
})
|
95
|
+
|
96
|
+
It("should verify iss", func() {
|
97
|
+
claims.Issuer = "other"
|
98
|
+
_, err := subject.Decode(generate())
|
99
|
+
Expect(err).To(MatchError(`invalid issuer claim "other"`))
|
100
|
+
Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
|
101
|
+
})
|
102
|
+
|
103
|
+
It("should verify sub", func() {
|
104
|
+
claims.Subject = ""
|
105
|
+
_, err := subject.Decode(generate())
|
106
|
+
Expect(err).To(MatchError(`subject is missing`))
|
107
|
+
Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
|
108
|
+
})
|
109
|
+
|
110
|
+
It("should verify auth time", func() {
|
111
|
+
claims.AuthAt = time.Now().Unix() + 1
|
112
|
+
_, err := subject.Decode(generate())
|
113
|
+
Expect(err).To(MatchError(`auth-time in the future`))
|
114
|
+
Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
|
115
|
+
})
|
116
|
+
})
|
117
|
+
|
118
|
+
var _ = Describe("Claims", func() {
|
119
|
+
It("should be JWT compatible", func() {
|
120
|
+
claims := mockClaims(1515151515)
|
121
|
+
Expect(json.Marshal(claims)).To(MatchJSON(`{
|
122
|
+
"name": "Me",
|
123
|
+
"picture": "https://test.host/me.jpg",
|
124
|
+
"sub": "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
|
125
|
+
"user_id": "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
|
126
|
+
"aud": "mock-project",
|
127
|
+
"iss": "https://securetoken.google.com/mock-project",
|
128
|
+
"iat": 1515149715,
|
129
|
+
"exp": 1515155115,
|
130
|
+
"auth_time": 1515151515,
|
131
|
+
"email": "me@example.com",
|
132
|
+
"email_verified": true,
|
133
|
+
"firebase": {
|
134
|
+
"sign_in_provider": "google.com",
|
135
|
+
"identities": {
|
136
|
+
"google.com": ["123123123123123123123"],
|
137
|
+
"email": ["me@example.com"]
|
138
|
+
}
|
139
|
+
}
|
140
|
+
}`))
|
78
141
|
})
|
79
142
|
})
|
80
143
|
|
@@ -86,19 +149,62 @@ func TestSuite(t *testing.T) {
|
|
86
149
|
// --------------------------------------------------------------------
|
87
150
|
|
88
151
|
var (
|
89
|
-
|
152
|
+
certKID string
|
153
|
+
certPEM string
|
90
154
|
privKey *rsa.PrivateKey
|
91
155
|
)
|
92
156
|
|
93
157
|
var _ = BeforeSuite(func() {
|
158
|
+
// seed private key
|
94
159
|
var err error
|
95
|
-
|
96
|
-
certPEM, err = ioutil.ReadFile("testdata/cert.pem")
|
160
|
+
privKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
97
161
|
Expect(err).NotTo(HaveOccurred())
|
98
162
|
|
99
|
-
|
163
|
+
// seed certificate
|
164
|
+
now := time.Now()
|
165
|
+
template := x509.Certificate{
|
166
|
+
SerialNumber: big.NewInt(2605014480174073526),
|
167
|
+
Subject: pkix.Name{CommonName: "securetoken.system.gserviceaccount.com"},
|
168
|
+
NotBefore: now,
|
169
|
+
NotAfter: now.Add(23775 * time.Minute),
|
170
|
+
KeyUsage: x509.KeyUsageDigitalSignature,
|
171
|
+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
172
|
+
BasicConstraintsValid: true,
|
173
|
+
}
|
174
|
+
cert, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
|
100
175
|
Expect(err).NotTo(HaveOccurred())
|
101
176
|
|
102
|
-
|
177
|
+
// calculate key ID
|
178
|
+
kh := sha1.New()
|
179
|
+
_, err = kh.Write(cert)
|
103
180
|
Expect(err).NotTo(HaveOccurred())
|
181
|
+
certKID = hex.EncodeToString(kh.Sum(nil))
|
182
|
+
|
183
|
+
// convert to PEM
|
184
|
+
buf := new(bytes.Buffer)
|
185
|
+
Expect(pem.Encode(buf, &pem.Block{Type: "CERTIFICATE", Bytes: cert})).To(Succeed())
|
186
|
+
certPEM = buf.String()
|
104
187
|
})
|
188
|
+
|
189
|
+
func mockClaims(now int64) *firejwt.Claims {
|
190
|
+
return &firejwt.Claims{
|
191
|
+
Name: "Me",
|
192
|
+
Picture: "https://test.host/me.jpg",
|
193
|
+
Subject: "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
|
194
|
+
UserID: "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
|
195
|
+
Audience: "mock-project",
|
196
|
+
Issuer: "https://securetoken.google.com/mock-project",
|
197
|
+
IssuedAt: now - 1800,
|
198
|
+
ExpiresAt: now + 3600,
|
199
|
+
AuthAt: now,
|
200
|
+
Email: "me@example.com",
|
201
|
+
EmailVerified: true,
|
202
|
+
Firebase: &firejwt.FirebaseClaim{
|
203
|
+
SignInProvider: "google.com",
|
204
|
+
Identities: map[string][]string{
|
205
|
+
"google.com": {"123123123123123123123"},
|
206
|
+
"email": {"me@example.com"},
|
207
|
+
},
|
208
|
+
},
|
209
|
+
}
|
210
|
+
}
|
data/lib/firejwt.rb
CHANGED
@@ -4,7 +4,7 @@ require 'uri'
|
|
4
4
|
require 'openssl'
|
5
5
|
|
6
6
|
module FireJWT
|
7
|
-
class
|
7
|
+
class Certificates
|
8
8
|
URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'.freeze
|
9
9
|
|
10
10
|
attr_reader :expires_at
|
@@ -12,14 +12,17 @@ module FireJWT
|
|
12
12
|
def initialize(url: URL)
|
13
13
|
super()
|
14
14
|
|
15
|
-
@url
|
15
|
+
@url = URI(url)
|
16
|
+
@keys = {}
|
17
|
+
|
16
18
|
expire!
|
17
19
|
refresh!
|
18
20
|
end
|
19
21
|
|
20
|
-
def get(
|
22
|
+
def get(kid)
|
21
23
|
refresh! if expired?
|
22
|
-
|
24
|
+
|
25
|
+
@keys[kid]
|
23
26
|
end
|
24
27
|
|
25
28
|
def refresh!(limit = 5)
|
@@ -33,8 +36,11 @@ module FireJWT
|
|
33
36
|
raise ArgumentError, 'Expires header not included in the response' unless resp['expires']
|
34
37
|
|
35
38
|
@expires_at = Time.httpdate(resp['expires'])
|
36
|
-
|
37
|
-
|
39
|
+
@keys.clear
|
40
|
+
|
41
|
+
JSON.parse(resp.body).each do |kid, pem|
|
42
|
+
cert = OpenSSL::X509::Certificate.new(pem)
|
43
|
+
@keys.store kid, cert.public_key
|
38
44
|
end
|
39
45
|
end
|
40
46
|
|
data/lib/firejwt/validator.rb
CHANGED
@@ -3,46 +3,48 @@ require 'jwt'
|
|
3
3
|
require 'net/http'
|
4
4
|
|
5
5
|
module FireJWT
|
6
|
+
class InvalidAuthTimeError < JWT::DecodeError; end
|
7
|
+
|
8
|
+
# Validator validates tokens applying guidelines outlined in
|
9
|
+
# https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library.
|
6
10
|
class Validator
|
7
|
-
# @param [
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
11
|
+
# @param [String] project_id the unique identifier for your Firebase project, which can be found in the URL of that project's console.
|
12
|
+
def initialize(project_id)
|
13
|
+
project_id = project_id.to_s
|
14
|
+
|
15
|
+
@certs = Certificates.new
|
16
|
+
@opts = {
|
17
|
+
algorithms: %w[RS256].freeze,
|
18
|
+
|
19
|
+
# exp must be in the future, iat must be in the past
|
20
|
+
verify_expiration: true,
|
21
|
+
verify_iat: true,
|
22
|
+
|
23
|
+
# aud must be your Firebase project ID
|
24
|
+
verify_aud: true, aud: project_id,
|
25
|
+
|
26
|
+
# iss must be "https://securetoken.google.com/<projectId>"
|
27
|
+
verify_iss: true, iss: "https://securetoken.google.com/#{project_id}",
|
28
|
+
}
|
17
29
|
end
|
18
30
|
|
19
31
|
# @param [String] token the token string
|
20
|
-
# @param [Hash] opts options
|
21
|
-
# @option opts [Boolean] :allow_expired allow expired tokens. Default: false.
|
22
|
-
# @option opts [String] :algorithm the expected algorithm. Default: RS256.
|
23
|
-
# @option opts [String] :aud verify the audience claim against the given value. Default: nil (= do not validate).
|
24
|
-
# @option opts [String] :iss verify the issuer claim against the given value. Default: nil (= do not verify).
|
25
|
-
# @option opts [String] :sub verify the subject claim against the given value. Default: nil (= do not verify).
|
26
|
-
# @option opts [Boolean] :verify_iat verify the issued at claim. Default: false.
|
27
|
-
# @option opts [Integer] :exp_leeway expiration leeway in seconds. Default: none.
|
28
32
|
# @return [FireJWT::Token] the token
|
29
33
|
# @raises [JWT::DecodeError] validation errors
|
30
|
-
def decode(token
|
31
|
-
|
32
|
-
|
33
|
-
@keys.get(header['kid'])
|
34
|
+
def decode(token)
|
35
|
+
payload, header = JWT.decode token, nil, true, **@opts do |header|
|
36
|
+
@certs.get(header['kid'])
|
34
37
|
end
|
35
|
-
Token.new(payload, header)
|
36
|
-
end
|
37
38
|
|
38
|
-
|
39
|
+
# sub must be a non-empty string
|
40
|
+
sub = payload['sub']
|
41
|
+
raise(JWT::InvalidSubError, 'Invalid subject. Expected non-empty string') unless sub.is_a?(String) && !sub.empty?
|
39
42
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
opts
|
43
|
+
# auth_time must be in the past
|
44
|
+
aut = payload['auth_time']
|
45
|
+
raise(InvalidAuthTimeError, 'Invalid auth_time') if !aut.is_a?(Numeric) || aut.to_f > Time.now.to_f
|
46
|
+
|
47
|
+
Token.new(payload, header)
|
46
48
|
end
|
47
49
|
end
|
48
50
|
end
|
@@ -1,25 +1,24 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
RSpec.describe FireJWT::
|
4
|
-
let
|
3
|
+
RSpec.describe FireJWT::Certificates do
|
4
|
+
let(:cert) { MockCert.new }
|
5
|
+
|
6
|
+
let! :http_request do
|
5
7
|
stub_request(:get, described_class::URL.to_s).to_return(
|
6
8
|
status: 200,
|
7
9
|
headers: { expires: (Time.now + 3600).httpdate },
|
8
|
-
body:
|
10
|
+
body: cert.to_json,
|
9
11
|
)
|
10
12
|
end
|
11
13
|
|
12
14
|
it 'should init' do
|
13
|
-
expect(subject).to include(
|
14
|
-
MOCK_KID => instance_of(OpenSSL::PKey::RSA),
|
15
|
-
)
|
16
15
|
expect(subject.expires_at).to be_within(10).of(Time.now + 3600)
|
17
16
|
expect(subject).not_to be_expired
|
18
17
|
end
|
19
18
|
|
20
19
|
it 'should retrieve keys' do
|
21
20
|
expect(subject.get('BAD')).to be_nil
|
22
|
-
expect(subject.get(
|
21
|
+
expect(subject.get(cert.kid)).to be_instance_of(OpenSSL::PKey::RSA)
|
23
22
|
end
|
24
23
|
|
25
24
|
it 'should check/update expiration status' do
|
@@ -1,71 +1,85 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
RSpec.describe FireJWT::Validator do
|
4
|
-
let! :
|
5
|
-
stub_request(:get, FireJWT::
|
4
|
+
let! :http_request do
|
5
|
+
stub_request(:get, FireJWT::Certificates::URL.to_s).to_return(
|
6
6
|
status: 200,
|
7
7
|
headers: { expires: (Time.now + 3600).httpdate },
|
8
|
-
body:
|
8
|
+
body: cert.to_json,
|
9
9
|
)
|
10
10
|
end
|
11
11
|
|
12
|
-
let :
|
13
|
-
Time.now.to_i
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
aud
|
20
|
-
iss
|
21
|
-
|
12
|
+
let :payload do
|
13
|
+
now = Time.now.to_i
|
14
|
+
{
|
15
|
+
'name' => 'Me',
|
16
|
+
'picture' => 'https://test.host/me.jpg',
|
17
|
+
'sub' => 'MDYwNDQwNjUtYWQ0ZC00ZDkwLThl',
|
18
|
+
'user_id' => 'MDYwNDQwNjUtYWQ0ZC00ZDkwLThl',
|
19
|
+
'aud' => project_id,
|
20
|
+
'iss' => 'https://securetoken.google.com/' << project_id,
|
21
|
+
'iat' => now - 1800,
|
22
|
+
'exp' => now + 3600,
|
23
|
+
'auth_time' => now,
|
24
|
+
'email' => 'me@example.com',
|
25
|
+
'email_verified' => true,
|
26
|
+
'firebase' => {
|
27
|
+
'sign_in_provider' => 'google.com',
|
28
|
+
'identities' => {
|
29
|
+
'google.com' => ['123123123123123123123'],
|
30
|
+
'email' => ['me@example.com'],
|
31
|
+
},
|
32
|
+
},
|
22
33
|
}
|
23
|
-
JWT.encode payload, MOCK_RSA, 'RS256', kid: MOCK_KID
|
24
34
|
end
|
25
35
|
|
36
|
+
let(:cert) { MockCert.new }
|
37
|
+
let(:project_id) { 'mock-project' }
|
38
|
+
let(:token) { JWT.encode payload, cert.pkey, 'RS256', kid: cert.kid }
|
39
|
+
|
40
|
+
subject { described_class.new(project_id) }
|
41
|
+
|
26
42
|
it 'should decode' do
|
27
43
|
decoded = subject.decode(token)
|
28
44
|
expect(decoded).to be_instance_of(FireJWT::Token)
|
29
|
-
expect(decoded).to eq(
|
30
|
-
'sub' => 'me@example.com',
|
31
|
-
'aud' => 'you',
|
32
|
-
'iss' => 'me',
|
33
|
-
'exp' => exp_time,
|
34
|
-
)
|
45
|
+
expect(decoded).to eq(payload)
|
35
46
|
expect(decoded.header).to eq(
|
36
47
|
'alg' => 'RS256',
|
37
|
-
'kid' =>
|
48
|
+
'kid' => cert.kid,
|
38
49
|
)
|
39
50
|
end
|
40
51
|
|
41
|
-
it 'should normalize options' do
|
42
|
-
expect(JWT).to receive(:decode).with(
|
43
|
-
instance_of(String),
|
44
|
-
nil,
|
45
|
-
true,
|
46
|
-
algorithm: 'RS256',
|
47
|
-
verify_aud: false,
|
48
|
-
verify_iss: false,
|
49
|
-
verify_sub: false,
|
50
|
-
).and_return([{}, {}])
|
51
|
-
subject.decode(token)
|
52
|
-
end
|
53
52
|
it 'should reject bad tokens' do
|
54
53
|
expect { subject.decode('BAD') }.to raise_error(JWT::DecodeError)
|
55
54
|
end
|
56
55
|
|
57
|
-
it 'should verify
|
58
|
-
|
59
|
-
expect { subject.decode(token
|
56
|
+
it 'should verify exp' do
|
57
|
+
payload['exp'] = Time.now.to_i - 1
|
58
|
+
expect { subject.decode(token) }.to raise_error(JWT::ExpiredSignature)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'should verify iat' do
|
62
|
+
payload['iat'] = Time.now.to_i + 10
|
63
|
+
expect { subject.decode(token) }.to raise_error(JWT::InvalidIatError)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'should verify aud' do
|
67
|
+
payload['aud'] = 'other'
|
68
|
+
expect { subject.decode(token) }.to raise_error(JWT::InvalidAudError)
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'should verify iss' do
|
72
|
+
payload['iss'] = 'other'
|
73
|
+
expect { subject.decode(token) }.to raise_error(JWT::InvalidIssuerError)
|
60
74
|
end
|
61
75
|
|
62
|
-
it 'should verify
|
63
|
-
|
64
|
-
expect { subject.decode(token
|
76
|
+
it 'should verify sub' do
|
77
|
+
payload['sub'] = ''
|
78
|
+
expect { subject.decode(token) }.to raise_error(JWT::InvalidSubError)
|
65
79
|
end
|
66
80
|
|
67
|
-
it 'should verify
|
68
|
-
|
69
|
-
expect { subject.decode(token
|
81
|
+
it 'should verify auth_time' do
|
82
|
+
payload['auth_time'] = Time.now.to_i + 10
|
83
|
+
expect { subject.decode(token) }.to raise_error(FireJWT::InvalidAuthTimeError)
|
70
84
|
end
|
71
85
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -4,8 +4,34 @@ require 'webmock/rspec'
|
|
4
4
|
|
5
5
|
WebMock.disable_net_connect!
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
7
|
+
class MockCert
|
8
|
+
attr_reader :cert, :pkey
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@pkey = OpenSSL::PKey::RSA.new 2048
|
12
|
+
@cert = OpenSSL::X509::Certificate.new
|
13
|
+
@cert.version = 2
|
14
|
+
@cert.serial = 2605014480174073526
|
15
|
+
@cert.subject = OpenSSL::X509::Name.parse('/CN=securetoken.system.gserviceaccount.com')
|
16
|
+
@cert.issuer = @cert.subject
|
17
|
+
@cert.public_key = @pkey.public_key
|
18
|
+
@cert.not_before = Time.now
|
19
|
+
@cert.not_after = @cert.not_before + 3600
|
20
|
+
|
21
|
+
exts = OpenSSL::X509::ExtensionFactory.new
|
22
|
+
exts.subject_certificate = cert
|
23
|
+
exts.issuer_certificate = cert
|
24
|
+
@cert.add_extension(exts.create_extension('basicConstraints', 'CA:FALSE', true))
|
25
|
+
@cert.add_extension(exts.create_extension('keyUsage', 'Digital Signature', true))
|
26
|
+
@cert.add_extension(exts.create_extension('extendedKeyUsage', 'TLS Web Client Authentication', true))
|
27
|
+
@cert.sign(@pkey, OpenSSL::Digest.new('SHA256'))
|
28
|
+
end
|
29
|
+
|
30
|
+
def kid
|
31
|
+
@kid ||= Digest::SHA1.hexdigest(@cert.to_der)
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_json(*)
|
35
|
+
{ kid => @cert }.to_json
|
36
|
+
end
|
37
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: firejwt
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Black Square Media Ltd
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-10-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jwt
|
@@ -96,20 +96,18 @@ files:
|
|
96
96
|
- Makefile
|
97
97
|
- README.md
|
98
98
|
- Rakefile
|
99
|
+
- ext_test.go
|
99
100
|
- firejwt.gemspec
|
100
101
|
- firejwt.go
|
101
102
|
- firejwt_test.go
|
102
103
|
- go.mod
|
103
104
|
- go.sum
|
104
105
|
- lib/firejwt.rb
|
105
|
-
- lib/firejwt/
|
106
|
+
- lib/firejwt/certificates.rb
|
106
107
|
- lib/firejwt/validator.rb
|
107
|
-
-
|
108
|
-
- spec/firejwt/key_set_spec.rb
|
108
|
+
- spec/firejwt/certificates_spec.rb
|
109
109
|
- spec/firejwt/validator_spec.rb
|
110
110
|
- spec/spec_helper.rb
|
111
|
-
- testdata/cert.pem
|
112
|
-
- testdata/priv.pem
|
113
111
|
homepage: https://github.com/bsm/firejwt
|
114
112
|
licenses:
|
115
113
|
- Apache-2.0
|
@@ -129,11 +127,11 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
129
127
|
- !ruby/object:Gem::Version
|
130
128
|
version: '0'
|
131
129
|
requirements: []
|
132
|
-
rubygems_version: 3.1.
|
130
|
+
rubygems_version: 3.1.2
|
133
131
|
signing_key:
|
134
132
|
specification_version: 4
|
135
133
|
summary: Firebase JWT validation
|
136
134
|
test_files:
|
137
|
-
- spec/firejwt/
|
135
|
+
- spec/firejwt/certificates_spec.rb
|
138
136
|
- spec/firejwt/validator_spec.rb
|
139
137
|
- spec/spec_helper.rb
|
data/opt.go
DELETED
@@ -1,20 +0,0 @@
|
|
1
|
-
package firejwt
|
2
|
-
|
3
|
-
// Options contains optional configuration for the Validator.
|
4
|
-
type Options struct {
|
5
|
-
// Custom KID URL
|
6
|
-
URL string
|
7
|
-
}
|
8
|
-
|
9
|
-
func (o *Options) norm() *Options {
|
10
|
-
var o2 Options
|
11
|
-
if o != nil {
|
12
|
-
o2 = *o
|
13
|
-
}
|
14
|
-
|
15
|
-
if o2.URL == "" {
|
16
|
-
o2.URL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
|
17
|
-
}
|
18
|
-
|
19
|
-
return &o2
|
20
|
-
}
|
data/testdata/cert.pem
DELETED
@@ -1,22 +0,0 @@
|
|
1
|
-
-----BEGIN CERTIFICATE-----
|
2
|
-
MIIDlzCCAn+gAwIBAgIUO5f7OhG0b1i6ped8/d0hRruQ7ccwDQYJKoZIhvcNAQEL
|
3
|
-
BQAwWjELMAkGA1UEBhMCR0IxDzANBgNVBAgMBkxvbmRvbjEbMBkGA1UECgwSQmxh
|
4
|
-
Y2sgU3F1YXJlIE1lZGlhMR0wGwYDVQQDDBRibGFja3NxdWFyZW1lZGlhLmNvbTAg
|
5
|
-
Fw0yMDAxMjIxNTI5NDJaGA8yMTE5MTIyOTE1Mjk0MlowWjELMAkGA1UEBhMCR0Ix
|
6
|
-
DzANBgNVBAgMBkxvbmRvbjEbMBkGA1UECgwSQmxhY2sgU3F1YXJlIE1lZGlhMR0w
|
7
|
-
GwYDVQQDDBRibGFja3NxdWFyZW1lZGlhLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD
|
8
|
-
ggEPADCCAQoCggEBAMY6tyEp1lExQFxMa3znxjbIHYsSwl4t9NVQzeOibbMbI3Go
|
9
|
-
eiyuGhnJVhPNycvsDpkJVF7Q/FB4i2OesxcerRo5VYopOA77XDmyeCZ9E6Q5ELXc
|
10
|
-
i2QWJcRIXZk6EujwGTEQouYoaosNYLMz5NQ7rVdTpa5wgLlys5RatfcNR9nOL1Mr
|
11
|
-
it5/HrEbgQB8JCKPQA41DIhymI6MqtkPop2spLU2i809sh+Lk5vRCmkhH7A0SNaN
|
12
|
-
YPKLfI731dnTRqQHBFP5N3UBzSS/tCfeCwezmk+rkaV9zZpUkZ8XmuvOCG9PUe2u
|
13
|
-
C5Zq3ziA0QD+fn6bOlLFsZJx3aKEtSEavU/YRjsCAwEAAaNTMFEwHQYDVR0OBBYE
|
14
|
-
FIrrQ2gBVTfCJUeUNdxazBCQvdnJMB8GA1UdIwQYMBaAFIrrQ2gBVTfCJUeUNdxa
|
15
|
-
zBCQvdnJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAMW3up1k
|
16
|
-
ENUlySp/XYhwsa4mJEsba0LIyJO0/X6LKAPl0qnVmlHY5fC3j/kCx5MyjQVsHZCQ
|
17
|
-
l2vZPAJDF/XE8uC4CHc4ig7RSRCDeiK0M766z9agZwUaQOgR2XqwkM2wLAtvnUpF
|
18
|
-
c4KgolHedFZRobk+pwlvpoyl9k3W8nDsMdI3I6EgBJvFhVGXiAL2kFILIyiBX5TN
|
19
|
-
rTiMEER+Fd5WEfQpsXukBWsibWwqE7ZP940z2oOVfggakZIPrgaTRWJhAIKUEt4n
|
20
|
-
QvWjvRVEdr5L7PKxoOiPnz592Cojb/DXtS0Bu71lraVHHsMsIu1QFlclAOI5bBI1
|
21
|
-
4TfOw5ufs75LxDE=
|
22
|
-
-----END CERTIFICATE-----
|
data/testdata/priv.pem
DELETED
@@ -1,28 +0,0 @@
|
|
1
|
-
-----BEGIN PRIVATE KEY-----
|
2
|
-
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDGOrchKdZRMUBc
|
3
|
-
TGt858Y2yB2LEsJeLfTVUM3jom2zGyNxqHosrhoZyVYTzcnL7A6ZCVRe0PxQeItj
|
4
|
-
nrMXHq0aOVWKKTgO+1w5sngmfROkORC13ItkFiXESF2ZOhLo8BkxEKLmKGqLDWCz
|
5
|
-
M+TUO61XU6WucIC5crOUWrX3DUfZzi9TK4refx6xG4EAfCQij0AONQyIcpiOjKrZ
|
6
|
-
D6KdrKS1NovNPbIfi5Ob0QppIR+wNEjWjWDyi3yO99XZ00akBwRT+Td1Ac0kv7Qn
|
7
|
-
3gsHs5pPq5Glfc2aVJGfF5rrzghvT1HtrguWat84gNEA/n5+mzpSxbGScd2ihLUh
|
8
|
-
Gr1P2EY7AgMBAAECggEAaXiYM6cFB1JDQljO4DiZ+E/lmDe0/1NIb698vN+RqriH
|
9
|
-
1VOlHdzMumerywG1mzDQW5DhOUnM1iwtTiYEeAq0Y72Zy9c+oooPeguBbkkiiEBs
|
10
|
-
qbbc27YFBjjSxFJn+VS2sqp9YiSi+7V0fCTiXiIaitpQz03Az+s9rXPOWdLRJgtj
|
11
|
-
4XEUrsMu+Ejz3YhOWH6kCZfWqa9nAy3mUi8/x+cFaEBMFfcZZJYcfvjMmwoKaAR9
|
12
|
-
24u7JGEnpLfV/wEwLtSS+ABynpT+HJtU9s7K3Jbe+Bd1g0qYtcbhIaNhg1rhV6sc
|
13
|
-
AglO5UO4p9Egr9UgcG8tbvYAcZtaaDADjND2f8wIsQKBgQD2qlWdF9HW9F2klNZY
|
14
|
-
WdkgtdDh0MAZW5VAfXvN+60tdE2tqOAnj7oWjxWhGL5GK0wqhApn/tADQF5rrbqq
|
15
|
-
eoQJcmxbI8fNBXstirLlUS8FZVijkBr9LdAJDY/5xM2BgsDaauSIikmNWnZImILN
|
16
|
-
EtGF5UoE3TfIOUl988ylwejQxwKBgQDNuyNwpdTtNIMoJwR9ANZzY8HFEeUmAnYi
|
17
|
-
ZSsvMJRm1Bhk/WNB+Ib2uIAQu2RScuYMjjOTauXzsRFqHz1eJ759+3hfhJsJOoYL
|
18
|
-
hj//8Bei4vbttgDzg4d2ovg2DGDyJph0J5wtpW6VON9Ym4mnmezQJfsCzEBh8Vr2
|
19
|
-
XBElv1cS7QKBgHHq6s05Yf0PMGxBHNkC7ccwkP6pRP6xEDYPfez8jddPPky0kIlU
|
20
|
-
1JF0lX2oCsAnYO7FunSa9wB5auH6AxqWqIIgaTCSTsU+AcxfoQ1NOBUa4BvyArTo
|
21
|
-
wopbzCGDJZHpjB2TfmYcz6lLnRMb9FS3mzJmWY/zhr6eznUv8lSfQGGjAoGAd980
|
22
|
-
dSyK9nOEgF7LpLJaQf28J8GXjSAeCUh9cw+RSKEIXb+ul//hU9yI8jbd65R7KpGo
|
23
|
-
x5qfxfBEP1tYfIYX3nwp1S4Ez8nD1O8yV0Rj4UrxqexEfZ8DzUKD8aogyrdmWTfD
|
24
|
-
Lm2YE2aB7LUj7f4oF9gpe6XbVbY11Bos+5uTdrkCgYB331Fptm1CPC3DnudvcVUB
|
25
|
-
HwDZ7xMENdTQyx6WZX4SvjiFa8vWS1bq7TZhwIotLLerP0tQS7zP58wVDmyVWqz0
|
26
|
-
2hvnZrJDZYlB9qD/rCfhHjM5FO7GEXhxh8nKHFUHivoh1n2DG9oxjUEDbg61jjwz
|
27
|
-
erRo6a3govRlEGXVFkC4dA==
|
28
|
-
-----END PRIVATE KEY-----
|