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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +54 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +9 -2
- data/Gemfile +0 -4
- data/Gemfile.lock +70 -61
- data/LICENSE +198 -10
- data/Makefile +3 -3
- data/README.md +5 -5
- data/ext_test.go +6 -0
- data/firejwt.gemspec +2 -2
- data/firejwt.go +99 -21
- data/firejwt_test.go +138 -33
- data/go.mod +2 -5
- data/go.sum +4 -59
- data/lib/firejwt.rb +1 -1
- data/lib/firejwt/{key_set.rb → certificates.rb} +12 -6
- data/lib/firejwt/validator.rb +33 -31
- data/spec/firejwt/{key_set_spec.rb → certificates_spec.rb} +10 -10
- data/spec/firejwt/validator_spec.rb +59 -32
- data/spec/spec_helper.rb +31 -5
- metadata +9 -12
- data/.travis.yml +0 -20
- data/opt.go +0 -20
- data/testdata/cert.pem +0 -22
- data/testdata/priv.pem +0 -28
data/Makefile
CHANGED
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# FireJWT
|
2
2
|
|
3
|
-
[](https://github.com/bsm/firejwt/actions/workflows/test.yml)
|
4
4
|
[](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-
|
20
|
+
validator.decode('eyJh...YbQ') # => {'sub' => 'me@example.com', 'aud' => 'my-project'}
|
21
21
|
rescue JWT::DecodeError
|
22
22
|
nil
|
23
23
|
end
|
@@ -35,7 +35,7 @@ import (
|
|
35
35
|
)
|
36
36
|
|
37
37
|
func main() {
|
38
|
-
vr, err := firejwt.New(
|
38
|
+
vr, err := firejwt.New("my-project")
|
39
39
|
if err != nil {
|
40
40
|
log.Fatalln(err)
|
41
41
|
}
|
@@ -46,6 +46,6 @@ func main() {
|
|
46
46
|
log.Fatalln(err)
|
47
47
|
}
|
48
48
|
|
49
|
-
log.Println(tk.Claims) // => {"sub": "me@example.com", "aud": "my-
|
49
|
+
log.Println(tk.Claims) // => {"sub": "me@example.com", "aud": "my-project"}
|
50
50
|
}
|
51
51
|
```
|
data/ext_test.go
ADDED
data/firejwt.gemspec
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'firejwt'
|
3
|
-
s.version = '0.1
|
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
|
-
|
21
|
-
|
23
|
+
audience string
|
24
|
+
issuer string
|
25
|
+
url string
|
26
|
+
htc http.Client
|
22
27
|
|
23
28
|
cancel context.CancelFunc
|
24
29
|
keyset atomic.Value
|
25
30
|
expires int64
|
26
31
|
}
|
27
32
|
|
28
|
-
// New issues a new Validator
|
29
|
-
|
30
|
-
|
33
|
+
// New issues a new Validator with a projectID, a unique identifier for your
|
34
|
+
// Firebase project, which can be found in the URL of that project's console.
|
35
|
+
func New(projectID string) (*Validator, error) {
|
36
|
+
return newValidator(projectID, defaultURL)
|
37
|
+
}
|
38
|
+
|
39
|
+
func newValidator(projectID, url string) (*Validator, error) {
|
40
|
+
v := &Validator{
|
41
|
+
audience: projectID,
|
42
|
+
issuer: "https://securetoken.google.com/" + projectID,
|
43
|
+
url: url,
|
44
|
+
}
|
31
45
|
if err := v.Refresh(); err != nil {
|
32
46
|
return nil, err
|
33
47
|
}
|
@@ -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) (*
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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.
|
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()
|
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
|
-
"
|
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
|
-
|
30
|
+
generate := func() string {
|
31
|
+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, seeds)
|
32
|
+
token.Header["kid"] = certKID
|
23
33
|
|
24
|
-
|
25
|
-
src := jwt.NewWithClaims(method, claims)
|
26
|
-
src.Header["kid"] = kid
|
27
|
-
|
28
|
-
str, err := src.SignedString(privKey)
|
34
|
+
data, err := token.SignedString(privKey)
|
29
35
|
Expect(err).NotTo(HaveOccurred())
|
30
|
-
|
31
|
-
return subject.Decode(str)
|
36
|
+
return data
|
32
37
|
}
|
33
38
|
|
34
39
|
BeforeEach(func() {
|
35
40
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
36
41
|
w.Header().Set("expires", "Mon, 20 Jan 2020 23:40:59 GMT")
|
37
42
|
json.NewEncoder(w).Encode(map[string]string{
|
38
|
-
|
43
|
+
certKID: string(certPEM),
|
39
44
|
})
|
40
45
|
}))
|
46
|
+
seeds = mockClaims(time.Now().Unix())
|
41
47
|
|
42
48
|
var err error
|
43
|
-
subject, err = firejwt.
|
49
|
+
subject, err = firejwt.Mocked(server.URL)
|
44
50
|
Expect(err).NotTo(HaveOccurred())
|
45
51
|
})
|
46
52
|
|
47
53
|
AfterEach(func() {
|
54
|
+
server.Close()
|
48
55
|
subject.Stop()
|
49
56
|
})
|
50
57
|
|
@@ -53,28 +60,83 @@ var _ = Describe("Validator", func() {
|
|
53
60
|
})
|
54
61
|
|
55
62
|
It("should decode tokens", func() {
|
56
|
-
|
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(
|
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("
|
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
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
})
|
77
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
+
}
|