firejwt 0.1.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
data/Makefile CHANGED
@@ -1,7 +1,7 @@
1
- default: vet test
1
+ default: test
2
2
 
3
3
  test:
4
4
  go test ./...
5
5
 
6
- vet:
7
- go vet ./...
6
+ staticcheck:
7
+ staticcheck ./...
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # FireJWT
2
2
 
3
- [![Build Status](https://travis-ci.org/bsm/firejwt.png?branch=master)](https://travis-ci.org/bsm/firejwt)
3
+ [![Test](https://github.com/bsm/firejwt/actions/workflows/test.yml/badge.svg)](https://github.com/bsm/firejwt/actions/workflows/test.yml)
4
4
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
5
5
 
6
6
  Decode and validate [Google Firebase](https://firebase.google.com/) JWT tokens with [Ruby](https://www.ruby-lang.org/) and [Go](https://golang.org/).
@@ -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
  ```
data/ext_test.go ADDED
@@ -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
+ }
data/firejwt.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'firejwt'
3
- s.version = '0.1.0'
3
+ s.version = '0.3.1'
4
4
  s.authors = ['Black Square Media Ltd']
5
5
  s.email = ['info@blacksquaremedia.com']
6
6
  s.summary = %(Firebase JWT validation)
@@ -16,6 +16,6 @@ Gem::Specification.new do |s|
16
16
  s.add_dependency 'jwt'
17
17
  s.add_development_dependency 'rake'
18
18
  s.add_development_dependency 'rspec'
19
- s.add_development_dependency 'rubocop'
19
+ s.add_development_dependency 'rubocop-bsm'
20
20
  s.add_development_dependency 'webmock'
21
21
  end
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
  }
@@ -45,20 +59,15 @@ func (v *Validator) Stop() {
45
59
  }
46
60
 
47
61
  // Decode decodes the token
48
- 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
- })
62
+ func (v *Validator) Decode(tokenString string) (*Claims, error) {
63
+ claims := new(Claims)
64
+ token, err := jwt.ParseWithClaims(tokenString, claims, v.verify)
65
+ if err != nil {
66
+ return nil, err
67
+ } else if !token.Valid {
68
+ return nil, errTokenInvalid
69
+ }
70
+ return claims, nil
62
71
  }
63
72
 
64
73
  // ExpTime returns the expiration time.
@@ -68,7 +77,7 @@ func (v *Validator) ExpTime() time.Time {
68
77
 
69
78
  // Refresh retrieves the latest keys.
70
79
  func (v *Validator) Refresh() error {
71
- resp, err := v.htc.Get(v.opt.URL)
80
+ resp, err := v.htc.Get(v.url)
72
81
  if err != nil {
73
82
  return err
74
83
  }
@@ -89,12 +98,51 @@ func (v *Validator) Refresh() error {
89
98
  return nil
90
99
  }
91
100
 
101
+ var (
102
+ errKIDMissing = errors.New("missing kid header")
103
+ errExpired = errors.New("token has expired")
104
+ errIssuedFuture = errors.New("issued in the future")
105
+ errNoSubject = errors.New("subject is missing")
106
+ errAuthFuture = errors.New("auth-time in the future")
107
+ errTokenInvalid = errors.New("token is invalid")
108
+ )
109
+
110
+ func (v *Validator) verify(token *jwt.Token) (interface{}, error) {
111
+ kid, ok := token.Header["kid"].(string)
112
+ if !ok {
113
+ return nil, errKIDMissing
114
+ }
115
+
116
+ key, ok := v.keyset.Load().(map[string]publicKey)[kid]
117
+ if !ok {
118
+ return nil, fmt.Errorf("invalid kid header %q", kid)
119
+ }
120
+
121
+ now := time.Now().Unix()
122
+ claims := token.Claims.(*Claims)
123
+ if claims.Audience != v.audience {
124
+ return nil, fmt.Errorf("invalid audience claim %q", claims.Audience)
125
+ } else if claims.Issuer != v.issuer {
126
+ return nil, fmt.Errorf("invalid issuer claim %q", claims.Issuer)
127
+ } else if claims.Subject == "" {
128
+ return nil, errNoSubject
129
+ } else if claims.ExpiresAt <= now {
130
+ return nil, errExpired
131
+ } else if claims.IssuedAt > now {
132
+ return nil, errIssuedFuture
133
+ } else if claims.AuthAt > now {
134
+ return nil, errAuthFuture
135
+ }
136
+
137
+ return key.PublicKey, nil
138
+ }
139
+
92
140
  func (v *Validator) loop(ctx context.Context) {
93
141
  t := time.NewTimer(time.Minute)
94
142
  defer t.Stop()
95
143
 
96
144
  for {
97
- d := v.ExpTime().Sub(time.Now()) - time.Hour
145
+ d := time.Until(v.ExpTime()) - time.Hour
98
146
  if d < time.Minute {
99
147
  d = time.Minute
100
148
  }
@@ -135,3 +183,33 @@ func (k *publicKey) UnmarshalText(data []byte) error {
135
183
  *k = publicKey{PublicKey: cert.PublicKey.(*rsa.PublicKey)}
136
184
  return nil
137
185
  }
186
+
187
+ // --------------------------------------------------------------------
188
+
189
+ // Claims are included in the token.
190
+ type Claims struct {
191
+ Subject string `json:"sub,omitempty"`
192
+ Audience string `json:"aud,omitempty"`
193
+ Issuer string `json:"iss,omitempty"`
194
+ IssuedAt int64 `json:"iat,omitempty"`
195
+ ExpiresAt int64 `json:"exp,omitempty"`
196
+
197
+ Name string `json:"name,omitempty"`
198
+ Picture string `json:"picture,omitempty"`
199
+ UserID string `json:"user_id,omitempty"`
200
+ AuthAt int64 `json:"auth_time,omitempty"`
201
+ Email string `json:"email,omitempty"`
202
+ EmailVerified bool `json:"email_verified"`
203
+ Firebase *FirebaseClaim `json:"firebase,omitempty"`
204
+ }
205
+
206
+ // FirebaseClaim represents firebase specific claim.
207
+ type FirebaseClaim struct {
208
+ SignInProvider string `json:"sign_in_provider,omitempty"`
209
+ Identities map[string][]string `json:"identities,omitempty"`
210
+ }
211
+
212
+ // Valid implements the jwt.Claims interface.
213
+ func (c *Claims) Valid() error {
214
+ return nil
215
+ }
data/firejwt_test.go CHANGED
@@ -1,50 +1,57 @@
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"
10
17
  "time"
11
18
 
12
19
  "github.com/bsm/firejwt"
20
+ . "github.com/bsm/ginkgo"
21
+ . "github.com/bsm/gomega"
13
22
  "github.com/dgrijalva/jwt-go"
14
- . "github.com/onsi/ginkgo"
15
- . "github.com/onsi/gomega"
16
23
  )
17
24
 
18
25
  var _ = Describe("Validator", func() {
19
26
  var subject *firejwt.Validator
20
27
  var server *httptest.Server
28
+ var seeds *firejwt.Claims
21
29
 
22
- const kid = "e5a91d9f39fa4de254a1e89df00f05b7e248b985"
30
+ generate := func() string {
31
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, seeds)
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
+ seeds = 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,83 @@ 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
+ claims, err := subject.Decode(generate())
62
64
  Expect(err).NotTo(HaveOccurred())
63
- Expect(token.Valid).To(BeTrue())
64
- Expect(token.Claims).To(HaveKeyWithValue("sub", "me@example.com"))
65
+ Expect(claims).To(Equal(seeds))
65
66
  })
66
67
 
67
68
  It("should reject bad tokens", func() {
68
- _, err := subject.Decode("BADTOKEN")
69
+ _, err := subject.Decode("BAD")
69
70
  Expect(err).To(MatchError(`token contains an invalid number of segments`))
71
+ Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
72
+ })
73
+
74
+ It("should verify exp", func() {
75
+ seeds.ExpiresAt = time.Now().Unix() - 1
76
+ _, err := subject.Decode(generate())
77
+ Expect(err).To(MatchError(`token has expired`))
78
+ Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
79
+ })
80
+
81
+ It("should verify iat", func() {
82
+ seeds.IssuedAt = time.Now().Unix() + 1
83
+ _, err := subject.Decode(generate())
84
+ Expect(err).To(MatchError(`issued in the future`))
85
+ Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
70
86
  })
71
87
 
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`))
88
+ It("should verify aud", func() {
89
+ seeds.Audience = "other"
90
+ _, err := subject.Decode(generate())
91
+ Expect(err).To(MatchError(`invalid audience claim "other"`))
92
+ Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
93
+ })
94
+
95
+ It("should verify iss", func() {
96
+ seeds.Issuer = "other"
97
+ _, err := subject.Decode(generate())
98
+ Expect(err).To(MatchError(`invalid issuer claim "other"`))
99
+ Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
100
+ })
101
+
102
+ It("should verify sub", func() {
103
+ seeds.Subject = ""
104
+ _, err := subject.Decode(generate())
105
+ Expect(err).To(MatchError(`subject is missing`))
106
+ Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
107
+ })
108
+
109
+ It("should verify auth time", func() {
110
+ seeds.AuthAt = time.Now().Unix() + 1
111
+ _, err := subject.Decode(generate())
112
+ Expect(err).To(MatchError(`auth-time in the future`))
113
+ Expect(err).To(BeAssignableToTypeOf(&jwt.ValidationError{}))
114
+ })
115
+ })
116
+
117
+ var _ = Describe("Claims", func() {
118
+ It("should be JWT compatible", func() {
119
+ subject := mockClaims(1515151515)
120
+ Expect(json.Marshal(subject)).To(MatchJSON(`{
121
+ "name": "Me",
122
+ "picture": "https://test.host/me.jpg",
123
+ "sub": "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
124
+ "user_id": "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
125
+ "aud": "mock-project",
126
+ "iss": "https://securetoken.google.com/mock-project",
127
+ "iat": 1515149715,
128
+ "exp": 1515155115,
129
+ "auth_time": 1515151515,
130
+ "email": "me@example.com",
131
+ "email_verified": true,
132
+ "firebase": {
133
+ "sign_in_provider": "google.com",
134
+ "identities": {
135
+ "google.com": ["123123123123123123123"],
136
+ "email": ["me@example.com"]
137
+ }
138
+ }
139
+ }`))
78
140
  })
79
141
  })
80
142
 
@@ -86,19 +148,62 @@ func TestSuite(t *testing.T) {
86
148
  // --------------------------------------------------------------------
87
149
 
88
150
  var (
89
- certPEM []byte
151
+ certKID string
152
+ certPEM string
90
153
  privKey *rsa.PrivateKey
91
154
  )
92
155
 
93
156
  var _ = BeforeSuite(func() {
157
+ // seed private key
94
158
  var err error
95
-
96
- certPEM, err = ioutil.ReadFile("testdata/cert.pem")
159
+ privKey, err = rsa.GenerateKey(rand.Reader, 2048)
97
160
  Expect(err).NotTo(HaveOccurred())
98
161
 
99
- privPEM, err := ioutil.ReadFile("testdata/priv.pem")
162
+ // seed certificate
163
+ now := time.Now()
164
+ template := x509.Certificate{
165
+ SerialNumber: big.NewInt(2605014480174073526),
166
+ Subject: pkix.Name{CommonName: "securetoken.system.gserviceaccount.com"},
167
+ NotBefore: now,
168
+ NotAfter: now.Add(23775 * time.Minute),
169
+ KeyUsage: x509.KeyUsageDigitalSignature,
170
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
171
+ BasicConstraintsValid: true,
172
+ }
173
+ cert, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
100
174
  Expect(err).NotTo(HaveOccurred())
101
175
 
102
- privKey, err = jwt.ParseRSAPrivateKeyFromPEM(privPEM)
176
+ // calculate key ID
177
+ kh := sha1.New()
178
+ _, err = kh.Write(cert)
103
179
  Expect(err).NotTo(HaveOccurred())
180
+ certKID = hex.EncodeToString(kh.Sum(nil))
181
+
182
+ // convert to PEM
183
+ buf := new(bytes.Buffer)
184
+ Expect(pem.Encode(buf, &pem.Block{Type: "CERTIFICATE", Bytes: cert})).To(Succeed())
185
+ certPEM = buf.String()
104
186
  })
187
+
188
+ func mockClaims(now int64) *firejwt.Claims {
189
+ return &firejwt.Claims{
190
+ Name: "Me",
191
+ Picture: "https://test.host/me.jpg",
192
+ Subject: "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
193
+ UserID: "MDYwNDQwNjUtYWQ0ZC00ZDkwLThl",
194
+ Audience: "mock-project",
195
+ Issuer: "https://securetoken.google.com/mock-project",
196
+ IssuedAt: now - 1800,
197
+ ExpiresAt: now + 3600,
198
+ AuthAt: now,
199
+ Email: "me@example.com",
200
+ EmailVerified: true,
201
+ Firebase: &firejwt.FirebaseClaim{
202
+ SignInProvider: "google.com",
203
+ Identities: map[string][]string{
204
+ "google.com": {"123123123123123123123"},
205
+ "email": {"me@example.com"},
206
+ },
207
+ },
208
+ }
209
+ }