firejwt 0.1.0 → 0.3.1

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.
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
+ }