firejwt 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f6b26968a9c1544b8a72fce5e0320c274bc47a80f6dd6c03dadd75ca932ae4f
4
- data.tar.gz: 74dd23cd6b76f1f051607a0bcf352d6a6ca51fa52fa31db1d6f3c824be6c0739
3
+ metadata.gz: 22a3cbe836d5c04927447040ffdc8580f0a8863798dc1089e3e4fa6940989b0f
4
+ data.tar.gz: 886dbe43fee7a2132259c1997018e711a08e54e53366a461efc77516c6893bfc
5
5
  SHA512:
6
- metadata.gz: 35bc306bb1ae2ff4c20b7deb9b57894db1bc67c6841e221c97c2ea2a27c3c9d9dbdfa10699364e5dd8dd2655721d693ab15b77b9ad410293af1393d472db0cab
7
- data.tar.gz: da26ad61964ac2ae8519b70da1eb2cf236456d3af8b974403dda6e252abdb4031b9c8f1a880d3ea3a2303c3d16f5baf46fd0d84e848cef7450d80f1a77cdb305
6
+ metadata.gz: de05062161e6489c4c074b786a4125a2c412d3b4641b23e91c18f3e6f734993e81ea98eea4a0775259f10183c9872597e84e2a3d944c405bd6ea38d3edd82862
7
+ data.tar.gz: c62208610c25313796a7a663dcad304c7568293a9096bce5c26b9d0de493fb90eedc159cc8047864f9eb7ca028caa4361c508b276ff2733bb7662e13b96e9a25
@@ -17,4 +17,7 @@ matrix:
17
17
  - gem install bundler
18
18
  - language: go
19
19
  go:
20
- - 1.13.x
20
+ - 1.15.x
21
+ - language: go
22
+ go:
23
+ - 1.14.x
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- firejwt (0.1.2)
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.3)
14
- safe_yaml (~> 1.0.0)
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.1)
18
- parallel (1.19.1)
19
- parser (2.7.1.3)
20
- ast (~> 2.4.0)
21
- public_suffix (4.0.5)
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.7.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.2)
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.85.1)
38
+ rubocop (0.92.0)
40
39
  parallel (~> 1.10)
41
- parser (>= 2.7.0.1)
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.3)
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.0.3)
49
- parser (>= 2.7.0.1)
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.8.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-audience'}
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(nil)
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-audience"}
49
+ log.Println(tk.Claims) // => {"sub": "me@example.com", "aud": "my-project"}
50
50
  }
51
51
  ```
@@ -0,0 +1,6 @@
1
+ package firejwt
2
+
3
+ // Mocked creates a validator with a mock URL
4
+ func Mocked(url string) (*Validator, error) {
5
+ return newValidator("mock-project", url)
6
+ }
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'firejwt'
3
- s.version = '0.1.2'
3
+ s.version = '0.2.0'
4
4
  s.authors = ['Black Square Media Ltd']
5
5
  s.email = ['info@blacksquaremedia.com']
6
6
  s.summary = %(Firebase JWT validation)
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
- opt *Options
21
- htc http.Client
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
- func New(opt *Options) (*Validator, error) {
30
- v := &Validator{opt: opt.norm()}
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.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
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.opt.URL)
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().Sub(time.Now()) - time.Hour
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
+ }
@@ -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
- "io/ioutil"
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
- const kid = "e5a91d9f39fa4de254a1e89df00f05b7e248b985"
30
+ generate := func() string {
31
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
32
+ token.Header["kid"] = certKID
23
33
 
24
- decode := func(method jwt.SigningMethod, claims *jwt.StandardClaims) (*jwt.Token, error) {
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
- kid: string(certPEM),
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.New(&firejwt.Options{URL: server.URL})
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 := decode(jwt.SigningMethodRS256, &jwt.StandardClaims{
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(HaveKeyWithValue("sub", "me@example.com"))
66
+ Expect(token.Claims).To(Equal(claims))
65
67
  })
66
68
 
67
69
  It("should reject bad tokens", func() {
68
- _, err := subject.Decode("BADTOKEN")
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 reject expired tokens", func() {
73
- _, err := decode(jwt.SigningMethodRS256, &jwt.StandardClaims{
74
- Subject: "me@example.com",
75
- ExpiresAt: time.Now().Add(-time.Minute).Unix(),
76
- })
77
- Expect(err).To(MatchError(`Token is expired`))
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
- certPEM []byte
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
- privPEM, err := ioutil.ReadFile("testdata/priv.pem")
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
- privKey, err = jwt.ParseRSAPrivateKeyFromPEM(privPEM)
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
+ }
@@ -1,5 +1,5 @@
1
1
  module FireJWT
2
- autoload :KeySet, 'firejwt/key_set'
2
+ autoload :Certificates, 'firejwt/certificates'
3
3
  autoload :Validator, 'firejwt/validator'
4
4
 
5
5
  class Token < Hash
@@ -4,7 +4,7 @@ require 'uri'
4
4
  require 'openssl'
5
5
 
6
6
  module FireJWT
7
- class KeySet < Hash
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 = URI(url)
15
+ @url = URI(url)
16
+ @keys = {}
17
+
16
18
  expire!
17
19
  refresh!
18
20
  end
19
21
 
20
- def get(key)
22
+ def get(kid)
21
23
  refresh! if expired?
22
- self[key]
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
- JSON.parse(resp.body).each do |kid, cert|
37
- store kid, OpenSSL::X509::Certificate.new(cert).public_key
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
 
@@ -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 [Hash] opts
8
- # @option opts [String] :algorithm the expected algorithm. Default: RS256.
9
- # @option opts [String] :aud verify the audience claim against the given value. Default: nil (= do not validate).
10
- # @option opts [String] :iss verify the issuer claim against the given value. Default: nil (= do not verify).
11
- # @option opts [String] :sub verify the subject claim against the given value. Default: nil (= do not verify).
12
- # @option opts [Boolean] :verify_iat verify the issued at claim. Default: false.
13
- # @option opts [Integer] :exp_leeway expiration leeway in seconds. Default: none.
14
- def initialize(**opts)
15
- @defaults = opts.dup
16
- @keys = KeySet.new
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, allow_expired: false, **opts)
31
- opts = norm_opts(@defaults.merge(opts))
32
- payload, header = JWT.decode token, nil, !allow_expired, opts do |header|
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
- private
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
- def norm_opts(opts)
41
- opts[:verify_aud] = opts.key?(:aud) unless opts.key?(:verify_aud)
42
- opts[:verify_iss] = opts.key?(:iss) unless opts.key?(:verify_iss)
43
- opts[:verify_sub] = opts.key?(:sub) unless opts.key?(:verify_sub)
44
- opts[:algorithm] ||= 'RS256'
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::KeySet do
4
- let! :keys_request do
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: MOCK_RESPONSE.to_json,
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(MOCK_KID)).to be_instance_of(OpenSSL::PKey::RSA)
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! :keys_request do
5
- stub_request(:get, FireJWT::KeySet::URL.to_s).to_return(
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: MOCK_RESPONSE.to_json,
8
+ body: cert.to_json,
9
9
  )
10
10
  end
11
11
 
12
- let :exp_time do
13
- Time.now.to_i + 3600
14
- end
15
-
16
- let :token do
17
- payload = {
18
- sub: 'me@example.com',
19
- aud: 'you',
20
- iss: 'me',
21
- exp: exp_time,
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' => 'e5a91d9f39fa4de254a1e89df00f05b7e248b985',
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 audiences' do
58
- expect(subject.decode(token, aud: 'you')).to be_instance_of(FireJWT::Token)
59
- expect { subject.decode(token, aud: 'other') }.to raise_error(JWT::InvalidAudError)
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 issuers' do
63
- expect(subject.decode(token, iss: 'me')).to be_instance_of(FireJWT::Token)
64
- expect { subject.decode(token, iss: 'other') }.to raise_error(JWT::InvalidIssuerError)
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 subjects' do
68
- expect(subject.decode(token, sub: 'me@example.com')).to be_instance_of(FireJWT::Token)
69
- expect { subject.decode(token, sub: 'other') }.to raise_error(JWT::InvalidSubError)
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
@@ -4,8 +4,34 @@ require 'webmock/rspec'
4
4
 
5
5
  WebMock.disable_net_connect!
6
6
 
7
- MOCK_KID = 'e5a91d9f39fa4de254a1e89df00f05b7e248b985'.freeze
8
- MOCK_RSA = OpenSSL::PKey::RSA.new File.read(File.expand_path('../testdata/priv.pem', __dir__))
9
- MOCK_RESPONSE = {
10
- MOCK_KID => File.read(File.expand_path('../testdata/cert.pem', __dir__)),
11
- }.freeze
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.1.2
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-06-11 00:00:00.000000000 Z
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/key_set.rb
106
+ - lib/firejwt/certificates.rb
106
107
  - lib/firejwt/validator.rb
107
- - opt.go
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.4
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/key_set_spec.rb
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
- }
@@ -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-----
@@ -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-----