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 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-----