firebase_id_token 2.5.2 → 4.0.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 +4 -4
- data/.github/workflows/test.yml +28 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +71 -1
- data/Gemfile +7 -0
- data/README.md +97 -56
- data/firebase_id_token.gemspec +14 -12
- data/lib/firebase_id_token/certificates/active_support.rb +92 -0
- data/lib/firebase_id_token/certificates/redis.rb +46 -0
- data/lib/firebase_id_token/certificates.rb +69 -60
- data/lib/firebase_id_token/configuration.rb +10 -3
- data/lib/firebase_id_token/exceptions/unsupported_cache_operation_error.rb +11 -0
- data/lib/firebase_id_token/signature.rb +30 -13
- data/lib/firebase_id_token/testing/certificates.rb +1 -1
- data/lib/firebase_id_token/version.rb +1 -1
- data/lib/firebase_id_token.rb +8 -6
- data/spec/firebase_id_token/certificates/active_support_spec.rb +43 -0
- data/spec/firebase_id_token/certificates/redis_spec.rb +39 -0
- data/spec/firebase_id_token/certificates_session_cookie_spec.rb +49 -0
- data/spec/firebase_id_token/configuration_spec.rb +0 -6
- data/spec/firebase_id_token/signature_session_cookie_spec.rb +65 -0
- data/spec/firebase_id_token/signature_spec.rb +15 -1
- data/spec/firebase_id_token/signature_test_spec.rb +4 -0
- data/spec/firebase_id_token_spec.rb +1 -1
- data/spec/spec_helper.rb +4 -0
- data/spec/support/certificates_shared_examples.rb +196 -0
- metadata +81 -60
- data/CODE_OF_CONDUCT.md +0 -74
- data/spec/firebase_id_token/certificates_spec.rb +0 -164
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
module FirebaseIdToken
|
|
2
2
|
# Manage download and access of Google's x509 certificates. Keeps
|
|
3
|
-
# certificates on
|
|
3
|
+
# certificates on an ActiveSupport cache.
|
|
4
4
|
#
|
|
5
5
|
# ## Download & Access Certificates
|
|
6
6
|
#
|
|
7
7
|
# It describes two ways to download it: {.request} and {.request!}.
|
|
8
|
-
# The first will only do something when
|
|
8
|
+
# The first will only do something when the certificates cache is empty,
|
|
9
9
|
# the second one will always request a new download to Google's API and
|
|
10
10
|
# override the database with the response.
|
|
11
11
|
#
|
|
12
12
|
# It's important to note that when saving a set of certificates, it will also
|
|
13
|
-
# set a
|
|
14
|
-
# this time went out,
|
|
13
|
+
# set a expiration time to match Google's API header `expires`. **After
|
|
14
|
+
# this time went out, the cache will no longer provide those certificates.**
|
|
15
15
|
#
|
|
16
16
|
# *To know how many seconds left until the expiration you can use {.ttl}.*
|
|
17
17
|
#
|
|
18
18
|
# When comes to accessing it, you can either use {.present?} to check if
|
|
19
|
-
# there's any data inside
|
|
19
|
+
# there's any data inside the cache or {.all} to obtain an
|
|
20
20
|
# `Array` of current certificates.
|
|
21
21
|
#
|
|
22
22
|
# @example `.request` will only download once
|
|
@@ -30,30 +30,46 @@ module FirebaseIdToken
|
|
|
30
30
|
# FirebaseIdToken::Certificates.request! # Downloads certificates.
|
|
31
31
|
#
|
|
32
32
|
class Certificates
|
|
33
|
-
#
|
|
34
|
-
attr_reader :redis
|
|
35
|
-
# Certificates saved in the Redis (JSON `String` or `nil`).
|
|
33
|
+
# Certificates saved in the cache (JSON `String` or `nil`).
|
|
36
34
|
attr_reader :local_certs
|
|
37
35
|
|
|
38
|
-
#
|
|
36
|
+
# Certificate source (`:id_token` or `:session_cookie`).
|
|
37
|
+
attr_reader :source
|
|
38
|
+
|
|
39
|
+
# Google's x509 certificates API URL for ID Tokens.
|
|
39
40
|
URL = 'https://www.googleapis.com/robot/v1/metadata/x509/'\
|
|
40
41
|
'securetoken@system.gserviceaccount.com'
|
|
41
42
|
|
|
42
|
-
#
|
|
43
|
+
# Google's x509 certificates API URL for Session Cookies. Session Cookies
|
|
44
|
+
# are signed by a different key set than ID Tokens.
|
|
45
|
+
SESSION_COOKIE_URL = 'https://www.googleapis.com/identitytoolkit/v3/'\
|
|
46
|
+
'relyingparty/publicKeys'
|
|
47
|
+
|
|
48
|
+
# Certificates API URL of each source.
|
|
49
|
+
URLS = { id_token: URL, session_cookie: SESSION_COOKIE_URL }.freeze
|
|
50
|
+
|
|
51
|
+
# Cache key of each source, so both certificate sets live side by side.
|
|
52
|
+
CACHE_KEYS = {
|
|
53
|
+
id_token: 'certificates',
|
|
54
|
+
session_cookie: 'session_cookie_certificates'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
# Calls {.request!} only if there are no certificates in the cache. It will
|
|
43
58
|
# return `nil` otherwise.
|
|
44
59
|
#
|
|
45
60
|
# It will raise {Exceptions::CertificatesRequestError} if the request
|
|
46
61
|
# fails or {Exceptions::CertificatesTtlError} when Google responds with a
|
|
47
62
|
# low TTL, check out {.request!} for more info.
|
|
48
63
|
#
|
|
64
|
+
# @param [Symbol] source `:id_token` (default) or `:session_cookie`
|
|
49
65
|
# @return [nil, Hash]
|
|
50
66
|
# @see Certificates.request!
|
|
51
|
-
def self.request
|
|
52
|
-
|
|
67
|
+
def self.request(source: :id_token)
|
|
68
|
+
new_child(source: source).request
|
|
53
69
|
end
|
|
54
70
|
|
|
55
71
|
# Triggers a HTTPS request to Google's x509 certificates API. If it
|
|
56
|
-
# responds with a status `200 OK`, saves the request body into
|
|
72
|
+
# responds with a status `200 OK`, saves the request body into the cache and
|
|
57
73
|
# returns it as a `Hash`.
|
|
58
74
|
#
|
|
59
75
|
# Otherwise it will raise a {Exceptions::CertificatesRequestError}.
|
|
@@ -61,9 +77,10 @@ module FirebaseIdToken
|
|
|
61
77
|
# This is really rare to happen, but Google may respond with a low TTL
|
|
62
78
|
# certificate. This is a `SecurityError` and will raise a
|
|
63
79
|
# {Exceptions::CertificatesTtlError}. You are mostly like to never face it.
|
|
80
|
+
# @param [Symbol] source `:id_token` (default) or `:session_cookie`
|
|
64
81
|
# @return [Hash]
|
|
65
|
-
def self.request!
|
|
66
|
-
|
|
82
|
+
def self.request!(source: :id_token)
|
|
83
|
+
new_child(source: source).request!
|
|
67
84
|
end
|
|
68
85
|
|
|
69
86
|
# @deprecated Use only `request!` in favor of Ruby conventions.
|
|
@@ -73,46 +90,46 @@ module FirebaseIdToken
|
|
|
73
90
|
warn 'WARNING: FirebaseIdToken::Certificates.request_anyway is '\
|
|
74
91
|
'deprecated. Use FirebaseIdToken::Certificates.request! instead.'
|
|
75
92
|
|
|
76
|
-
|
|
93
|
+
new_child.request!
|
|
77
94
|
end
|
|
78
95
|
|
|
79
|
-
# Returns `true` if there's certificates data
|
|
96
|
+
# Returns `true` if there's certificates data in the cache, `false` otherwise.
|
|
80
97
|
# @example
|
|
81
98
|
# FirebaseIdToken::Certificates.present? #=> false
|
|
82
99
|
# FirebaseIdToken::Certificates.request
|
|
83
100
|
# FirebaseIdToken::Certificates.present? #=> true
|
|
84
|
-
def self.present?
|
|
85
|
-
!
|
|
101
|
+
def self.present?(source: :id_token)
|
|
102
|
+
! new_child(source: source).local_certs.empty?
|
|
86
103
|
end
|
|
87
104
|
|
|
88
105
|
# Returns an array of hashes, each hash is a single `{key => value}` pair
|
|
89
106
|
# containing the certificate KID `String` as key and a
|
|
90
107
|
# `OpenSSL::X509::Certificate` object of the respective certificate as
|
|
91
|
-
# value. Returns a empty `Array` when there's no certificates data
|
|
92
|
-
#
|
|
108
|
+
# value. Returns a empty `Array` when there's no certificates data in
|
|
109
|
+
# the cache.
|
|
93
110
|
# @return [Array]
|
|
94
111
|
# @example
|
|
95
112
|
# FirebaseIdToken::Certificates.request
|
|
96
113
|
# certs = FirebaseIdToken::Certificates.all
|
|
97
114
|
# certs.first #=> {"1d6d01c7[...]" => #<OpenSSL::X509::Certificate[...]}
|
|
98
|
-
def self.all
|
|
99
|
-
|
|
115
|
+
def self.all(source: :id_token)
|
|
116
|
+
new_child(source: source).local_certs.map { |kid, cert|
|
|
100
117
|
{ kid => OpenSSL::X509::Certificate.new(cert) } }
|
|
101
118
|
end
|
|
102
119
|
|
|
103
120
|
# Returns a `OpenSSL::X509::Certificate` object of the requested Key ID
|
|
104
121
|
# (KID) if there's one. Returns `nil` otherwise.
|
|
105
122
|
#
|
|
106
|
-
# It will raise a {Exceptions::NoCertificatesError} if the
|
|
107
|
-
# certificates
|
|
123
|
+
# It will raise a {Exceptions::NoCertificatesError} if the
|
|
124
|
+
# certificates cache is empty.
|
|
108
125
|
# @param [String] kid Key ID
|
|
109
126
|
# @return [nil, OpenSSL::X509::Certificate]
|
|
110
127
|
# @example
|
|
111
128
|
# FirebaseIdToken::Certificates.request
|
|
112
129
|
# cert = FirebaseIdToken::Certificates.find "1d6d01f4w7d54c7[...]"
|
|
113
130
|
# #=> <OpenSSL::X509::Certificate: subject=#<OpenSSL [...]
|
|
114
|
-
def self.find(kid, raise_error: false)
|
|
115
|
-
certs =
|
|
131
|
+
def self.find(kid, raise_error: false, source: :id_token)
|
|
132
|
+
certs = new_child(source: source).local_certs
|
|
116
133
|
raise Exceptions::NoCertificatesError if certs.empty?
|
|
117
134
|
|
|
118
135
|
return OpenSSL::X509::Certificate.new certs[kid] if certs[kid]
|
|
@@ -128,8 +145,8 @@ module FirebaseIdToken
|
|
|
128
145
|
#
|
|
129
146
|
# @raise {Exceptions::CertificateNotFound} if it cannot be found.
|
|
130
147
|
#
|
|
131
|
-
# @raise {Exceptions::NoCertificatesError} if the
|
|
132
|
-
#
|
|
148
|
+
# @raise {Exceptions::NoCertificatesError} if the certificates cache
|
|
149
|
+
# is empty.
|
|
133
150
|
#
|
|
134
151
|
# @param [String] kid Key ID
|
|
135
152
|
# @return [OpenSSL::X509::Certificate]
|
|
@@ -137,26 +154,33 @@ module FirebaseIdToken
|
|
|
137
154
|
# FirebaseIdToken::Certificates.request
|
|
138
155
|
# cert = FirebaseIdToken::Certificates.find! "1d6d01f4w7d54c7[...]"
|
|
139
156
|
# #=> <OpenSSL::X509::Certificate: subject=#<OpenSSL [...]
|
|
140
|
-
def self.find!(kid)
|
|
141
|
-
find(kid, raise_error: true)
|
|
157
|
+
def self.find!(kid, source: :id_token)
|
|
158
|
+
find(kid, raise_error: true, source: source)
|
|
142
159
|
end
|
|
143
160
|
|
|
144
161
|
# Returns the current certificates TTL (Time-To-Live) in seconds. *Zero
|
|
145
162
|
# meaning no certificates.* It's the same as the certificates expiration
|
|
146
163
|
# time, use it to know when to request again.
|
|
147
164
|
# @return [Fixnum]
|
|
148
|
-
def self.ttl
|
|
149
|
-
|
|
150
|
-
ttl
|
|
165
|
+
def self.ttl(source: :id_token)
|
|
166
|
+
# call a child class based on the configuration
|
|
167
|
+
FirebaseIdToken.configuration.klass.ttl(source: source)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# When called on Certificates itself, picks the store class from the
|
|
171
|
+
# configuration. When called on a subclass, instantiates that subclass.
|
|
172
|
+
def self.new_child(source: :id_token)
|
|
173
|
+
return new(source: source) unless self == Certificates
|
|
174
|
+
|
|
175
|
+
FirebaseIdToken.configuration.klass.new(source: source)
|
|
151
176
|
end
|
|
152
177
|
|
|
153
|
-
# Sets two instance attributes: `:
|
|
154
|
-
# respectively a
|
|
178
|
+
# Sets two instance attributes: `:cach_store` and `:local_certs`. Those are
|
|
179
|
+
# respectively a cache instance from {FirebaseIdToken::Configuration} and
|
|
155
180
|
# the certificates in it.
|
|
156
|
-
def initialize
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
@local_certs = read_certificates
|
|
181
|
+
def initialize(source: :id_token)
|
|
182
|
+
# this should not be called directly. Call a child class
|
|
183
|
+
raise NotImplementedError
|
|
160
184
|
end
|
|
161
185
|
|
|
162
186
|
# @see Certificates.request
|
|
@@ -166,7 +190,7 @@ module FirebaseIdToken
|
|
|
166
190
|
|
|
167
191
|
# @see Certificates.request!
|
|
168
192
|
def request!
|
|
169
|
-
@request = HTTParty.get
|
|
193
|
+
@request = HTTParty.get URLS.fetch(@source)
|
|
170
194
|
code = @request.code
|
|
171
195
|
if code == 200
|
|
172
196
|
save_certificates
|
|
@@ -177,25 +201,10 @@ module FirebaseIdToken
|
|
|
177
201
|
|
|
178
202
|
private
|
|
179
203
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def save_certificates
|
|
186
|
-
@redis.setex 'certificates', ttl, @request.body
|
|
187
|
-
@local_certs = read_certificates
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def ttl
|
|
191
|
-
cache_control = @request.headers['cache-control']
|
|
192
|
-
ttl = cache_control.match(/max-age=([0-9]+)/).captures.first.to_i
|
|
193
|
-
|
|
194
|
-
if ttl > 3600
|
|
195
|
-
ttl
|
|
196
|
-
else
|
|
197
|
-
raise Exceptions::CertificatesTtlError
|
|
198
|
-
end
|
|
204
|
+
# Cache key of the certificate source, so ID Token and Session Cookie
|
|
205
|
+
# certificates never mix.
|
|
206
|
+
def cache_key
|
|
207
|
+
CACHE_KEYS.fetch(@source)
|
|
199
208
|
end
|
|
200
209
|
end
|
|
201
210
|
end
|
|
@@ -4,12 +4,19 @@ module FirebaseIdToken
|
|
|
4
4
|
LIB_PATH = File.expand_path('../../', __FILE__)
|
|
5
5
|
|
|
6
6
|
class Configuration
|
|
7
|
-
attr_accessor :redis, :project_ids, :
|
|
7
|
+
attr_accessor :redis, :project_ids, :cache_store
|
|
8
|
+
attr_writer :certificates
|
|
8
9
|
|
|
9
10
|
def initialize
|
|
10
|
-
@redis = Redis.new
|
|
11
11
|
@project_ids = []
|
|
12
|
-
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def certificates
|
|
15
|
+
@certificates || klass
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def klass
|
|
19
|
+
redis ? FirebaseIdToken::Certificates::Redis : FirebaseIdToken::Certificates::ActiveSupport
|
|
13
20
|
end
|
|
14
21
|
end
|
|
15
22
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module FirebaseIdToken
|
|
2
|
+
module Exceptions
|
|
3
|
+
# @see FirebaseIdToken::Certificates.request
|
|
4
|
+
# @see FirebaseIdToken::Certificates.request!
|
|
5
|
+
class UnsupportedCacheOperationError < StandardError
|
|
6
|
+
def initialize(message = "Cache store does not support a TTL read on entries")
|
|
7
|
+
super message
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -10,13 +10,13 @@ module FirebaseIdToken
|
|
|
10
10
|
#
|
|
11
11
|
# ## Verifying a Firebase ID Token
|
|
12
12
|
#
|
|
13
|
-
# *Be sure to configure the gem to set your Firebase Project ID
|
|
14
|
-
#
|
|
13
|
+
# *Be sure to configure the gem to set your Firebase Project ID cache
|
|
14
|
+
# store.*
|
|
15
15
|
#
|
|
16
16
|
# **See the README for a complete guide.**
|
|
17
17
|
#
|
|
18
18
|
# **WARNING:** Trying to verify a token without any certificate saved in
|
|
19
|
-
#
|
|
19
|
+
# the cache raises a {Exceptions::NoCertificatesError}.
|
|
20
20
|
#
|
|
21
21
|
# @example
|
|
22
22
|
# FirebaseIdToken::Signature.verify(thrusty_token)
|
|
@@ -31,6 +31,12 @@ module FirebaseIdToken
|
|
|
31
31
|
# [here](https://goo.gl/uOK5Jx).
|
|
32
32
|
JWT_DEFAULTS = { algorithm: 'RS256', verify_iat: true }
|
|
33
33
|
|
|
34
|
+
# Issuer base URL of each token type.
|
|
35
|
+
ISSUER_BASE_URLS = {
|
|
36
|
+
id_token: 'https://securetoken.google.com',
|
|
37
|
+
session_cookie: 'https://session.firebase.google.com'
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
34
40
|
# Returns the decoded JWT hash payload of the Firebase ID Token if the
|
|
35
41
|
# signature in the token matches with one of the certificates downloaded
|
|
36
42
|
# by {FirebaseIdToken::Certificates.request}, returns `nil` otherwise.
|
|
@@ -39,8 +45,8 @@ module FirebaseIdToken
|
|
|
39
45
|
# JWT fields are valid, as recommended [here](https://goo.gl/yOrZZX) by
|
|
40
46
|
# Firebase official documentation.
|
|
41
47
|
#
|
|
42
|
-
# Note that it will raise a {Exceptions::NoCertificatesError} if the
|
|
43
|
-
#
|
|
48
|
+
# Note that it will raise a {Exceptions::NoCertificatesError} if the cache
|
|
49
|
+
# is empty. Ensure to call {Certificates.request}
|
|
44
50
|
# before, ideally in a background job if you are using Rails.
|
|
45
51
|
#
|
|
46
52
|
# If you would like this to raise and error, rather than silently failing,
|
|
@@ -49,18 +55,26 @@ module FirebaseIdToken
|
|
|
49
55
|
# FirebaseIdToken::Signature
|
|
50
56
|
# .verify(token, raise_error: Rails.env.development?)
|
|
51
57
|
#
|
|
58
|
+
# Use `type: :session_cookie` to verify a Firebase Session Cookie instead
|
|
59
|
+
# of an ID Token. Session Cookies are issued by
|
|
60
|
+
# `https://session.firebase.google.com` and signed by a different
|
|
61
|
+
# certificate set. Example:
|
|
62
|
+
#
|
|
63
|
+
# FirebaseIdToken::Signature.verify(cookie, type: :session_cookie)
|
|
64
|
+
#
|
|
52
65
|
# @param raise_error [Boolean] default: false
|
|
66
|
+
# @param type [Symbol] `:id_token` (default) or `:session_cookie`
|
|
53
67
|
# @return [nil, Hash]
|
|
54
|
-
def self.verify(jwt_token, raise_error: false)
|
|
55
|
-
new(jwt_token, raise_error: raise_error).verify
|
|
68
|
+
def self.verify(jwt_token, raise_error: false, type: :id_token)
|
|
69
|
+
new(jwt_token, raise_error: raise_error, type: type).verify
|
|
56
70
|
end
|
|
57
71
|
|
|
58
72
|
# Equivalent to `.verify(jwt_token, raise_error: true)`.
|
|
59
73
|
#
|
|
60
74
|
# @see {Signature.verify}
|
|
61
75
|
# @return [Hash]
|
|
62
|
-
def self.verify!(jwt_token)
|
|
63
|
-
new(jwt_token, raise_error: true).verify
|
|
76
|
+
def self.verify!(jwt_token, type: :id_token)
|
|
77
|
+
new(jwt_token, raise_error: true, type: type).verify
|
|
64
78
|
end
|
|
65
79
|
|
|
66
80
|
attr_accessor :firebase_id_token_certificates
|
|
@@ -68,8 +82,9 @@ module FirebaseIdToken
|
|
|
68
82
|
# Loads attributes: `:project_ids` from {FirebaseIdToken::Configuration},
|
|
69
83
|
# and `:kid`, `:jwt_token` from the related `jwt_token`.
|
|
70
84
|
# @param [String] jwt_token Firebase ID Token
|
|
71
|
-
def initialize(jwt_token, raise_error: false)
|
|
85
|
+
def initialize(jwt_token, raise_error: false, type: :id_token)
|
|
72
86
|
@raise_error = raise_error
|
|
87
|
+
@type = type
|
|
73
88
|
@project_ids = FirebaseIdToken.configuration.project_ids
|
|
74
89
|
@kid = extract_kid(jwt_token)
|
|
75
90
|
@jwt_token = jwt_token
|
|
@@ -78,7 +93,8 @@ module FirebaseIdToken
|
|
|
78
93
|
|
|
79
94
|
# @see Signature.verify
|
|
80
95
|
def verify
|
|
81
|
-
certificate = firebase_id_token_certificates.find(
|
|
96
|
+
certificate = firebase_id_token_certificates.find(
|
|
97
|
+
@kid, raise_error: @raise_error, source: @type)
|
|
82
98
|
return unless certificate
|
|
83
99
|
|
|
84
100
|
payload = decode_jwt_payload(@jwt_token, certificate.public_key)
|
|
@@ -113,7 +129,7 @@ module FirebaseIdToken
|
|
|
113
129
|
still_valid?(payload) &&
|
|
114
130
|
@project_ids.include?(payload['aud']) &&
|
|
115
131
|
issuer_authorized?(payload) &&
|
|
116
|
-
! payload['sub'].empty?
|
|
132
|
+
! payload['sub'].to_s.empty?
|
|
117
133
|
end
|
|
118
134
|
|
|
119
135
|
def still_valid?(payload)
|
|
@@ -122,7 +138,8 @@ module FirebaseIdToken
|
|
|
122
138
|
end
|
|
123
139
|
|
|
124
140
|
def issuer_authorized?(payload)
|
|
125
|
-
|
|
141
|
+
base_url = ISSUER_BASE_URLS.fetch(@type)
|
|
142
|
+
issuers = @project_ids.map { |i| "#{base_url}/#{i}" }
|
|
126
143
|
issuers.include? payload['iss']
|
|
127
144
|
end
|
|
128
145
|
end
|
|
@@ -15,7 +15,7 @@ module FirebaseIdToken
|
|
|
15
15
|
# `.find` is stubbed to always return the same certificate.
|
|
16
16
|
# @param [String] kid Key ID
|
|
17
17
|
# @return [nil, OpenSSL::X509::Certificate]
|
|
18
|
-
def self.find(kid, raise_error: false)
|
|
18
|
+
def self.find(kid, raise_error: false, source: :id_token)
|
|
19
19
|
cert = certificate
|
|
20
20
|
OpenSSL::X509::Certificate.new cert
|
|
21
21
|
end
|
data/lib/firebase_id_token.rb
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
require 'redis'
|
|
2
|
-
require 'redis-namespace'
|
|
3
1
|
require 'httparty'
|
|
4
2
|
require 'jwt'
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/time'
|
|
5
|
+
require 'active_support/cache'
|
|
5
6
|
|
|
6
7
|
require 'firebase_id_token/version'
|
|
7
8
|
require 'firebase_id_token/exceptions/no_certificates_error'
|
|
8
9
|
require 'firebase_id_token/exceptions/certificates_request_error'
|
|
9
10
|
require 'firebase_id_token/exceptions/certificates_ttl_error'
|
|
11
|
+
require 'firebase_id_token/exceptions/unsupported_cache_operation_error'
|
|
10
12
|
require 'firebase_id_token/exceptions/certificate_not_found'
|
|
11
13
|
require 'firebase_id_token/configuration'
|
|
12
14
|
require 'firebase_id_token/certificates'
|
|
15
|
+
require 'firebase_id_token/certificates/active_support'
|
|
16
|
+
require 'firebase_id_token/certificates/redis'
|
|
13
17
|
require 'firebase_id_token/signature'
|
|
14
18
|
|
|
15
19
|
# ## List of available methods
|
|
@@ -28,20 +32,18 @@ require 'firebase_id_token/signature'
|
|
|
28
32
|
#
|
|
29
33
|
# ## Configuration
|
|
30
34
|
#
|
|
31
|
-
# You need to set your Firebase Project ID
|
|
32
|
-
# server instance in case you don't use Redis defaults.
|
|
35
|
+
# You need to set your Firebase Project ID and cache store.
|
|
33
36
|
#
|
|
34
37
|
# **WARNING:** Your `project_ids` must be a `Array`.
|
|
35
38
|
# ```
|
|
36
39
|
# FirebaseIdToken.configure do |config|
|
|
37
40
|
# config.project_ids = ['my-project-id', 'another-project-id']
|
|
38
|
-
# congig.
|
|
41
|
+
# congig.cache_store = ActiveSupport::Cache::RedisCacheStore.new
|
|
39
42
|
# end
|
|
40
43
|
# ```
|
|
41
44
|
#
|
|
42
45
|
# **Defaults**
|
|
43
46
|
# + `project_ids` => `[]`
|
|
44
|
-
# + `redis` => `Redis.new`
|
|
45
47
|
#
|
|
46
48
|
module FirebaseIdToken
|
|
47
49
|
class << self
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'firebase_id_token/certificates/active_support'
|
|
3
|
+
|
|
4
|
+
module FirebaseIdToken
|
|
5
|
+
describe Certificates::ActiveSupport do
|
|
6
|
+
let (:certs) { File.read('spec/fixtures/files/certificates.json') }
|
|
7
|
+
let (:cache) { 'public, max-age=19302, must-revalidate, no-transform' }
|
|
8
|
+
let (:low_cache) { 'public, max-age=2160, must-revalidate, no-transform' }
|
|
9
|
+
let (:kid) { JSON.parse(certs).first[0] }
|
|
10
|
+
let (:expires_in) { (DateTime.now + (5/24r)).to_s }
|
|
11
|
+
let (:response) { double }
|
|
12
|
+
|
|
13
|
+
let (:mock_response) {
|
|
14
|
+
allow(response).to receive(:code) { 200 }
|
|
15
|
+
allow(response).to receive(:headers) { { 'cache-control' => cache } }
|
|
16
|
+
allow(response).to receive(:body) { certs }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let(:mock_request) {
|
|
20
|
+
mock_response
|
|
21
|
+
allow(HTTParty).to receive(:get).
|
|
22
|
+
with(an_instance_of(String)) { response }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
context 'RedisCacheStore' do
|
|
26
|
+
before :each do
|
|
27
|
+
allow(FirebaseIdToken.configuration).to receive(:cache_store).and_return(
|
|
28
|
+
ActiveSupport::Cache::RedisCacheStore.new(namespace: 'firebase_id_token_test'))
|
|
29
|
+
FirebaseIdToken.configuration.cache_store.delete 'certificates'
|
|
30
|
+
end
|
|
31
|
+
it_behaves_like 'a certificate store', lazy: true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
context 'MemoryStore' do
|
|
35
|
+
before :each do
|
|
36
|
+
allow(FirebaseIdToken.configuration).to receive(:cache_store).and_return(
|
|
37
|
+
ActiveSupport::Cache::MemoryStore.new(namespace: "firebase_auth"))
|
|
38
|
+
FirebaseIdToken.configuration.cache_store.delete 'certificates'
|
|
39
|
+
end
|
|
40
|
+
it_behaves_like 'a certificate store', lazy: true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'redis'
|
|
3
|
+
require 'redis-namespace'
|
|
4
|
+
require 'firebase_id_token/certificates/redis'
|
|
5
|
+
|
|
6
|
+
module FirebaseIdToken
|
|
7
|
+
describe Certificates::Redis do
|
|
8
|
+
let (:redis) { Redis::Namespace.new 'firebase_id_token', redis: Redis.new }
|
|
9
|
+
let (:certs) { File.read('spec/fixtures/files/certificates.json') }
|
|
10
|
+
let (:cache) { 'public, max-age=19302, must-revalidate, no-transform' }
|
|
11
|
+
let (:low_cache) { 'public, max-age=2160, must-revalidate, no-transform' }
|
|
12
|
+
let (:kid) { JSON.parse(certs).first[0] }
|
|
13
|
+
let (:expires_in) { (DateTime.now + (5/24r)).to_s }
|
|
14
|
+
let (:response) { double }
|
|
15
|
+
|
|
16
|
+
let (:mock_response) {
|
|
17
|
+
allow(response).to receive(:code) { 200 }
|
|
18
|
+
allow(response).to receive(:headers) { { 'cache-control' => cache } }
|
|
19
|
+
allow(response).to receive(:body) { certs }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let(:mock_request) {
|
|
23
|
+
mock_response
|
|
24
|
+
allow(HTTParty).to receive(:get).
|
|
25
|
+
with(an_instance_of(String)) { response }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
before :each do
|
|
29
|
+
FirebaseIdToken.configure { |config| config.redis = ::Redis.new }
|
|
30
|
+
redis.del 'certificates'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
after :each do
|
|
34
|
+
FirebaseIdToken.reset
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it_behaves_like 'a certificate store'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
module FirebaseIdToken
|
|
4
|
+
describe 'Certificates with the session cookie source' do
|
|
5
|
+
let (:certs) { File.read('spec/fixtures/files/certificates.json') }
|
|
6
|
+
let (:cache) { 'public, max-age=19302, must-revalidate, no-transform' }
|
|
7
|
+
let (:kid) { JSON.parse(certs).first[0] }
|
|
8
|
+
let (:response) { double }
|
|
9
|
+
|
|
10
|
+
before :each do
|
|
11
|
+
allow(response).to receive(:code) { 200 }
|
|
12
|
+
allow(response).to receive(:headers) { { 'cache-control' => cache } }
|
|
13
|
+
allow(response).to receive(:body) { certs }
|
|
14
|
+
allow(HTTParty).to receive(:get).with(an_instance_of(String)) { response }
|
|
15
|
+
allow(FirebaseIdToken.configuration).to receive(:cache_store).and_return(
|
|
16
|
+
ActiveSupport::Cache::MemoryStore.new(namespace: 'firebase_auth'))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
after :each do
|
|
20
|
+
FirebaseIdToken.reset
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'requests the session cookie certificates URL' do
|
|
24
|
+
expect(HTTParty).to receive(:get).
|
|
25
|
+
with(Certificates::SESSION_COOKIE_URL).at_least(:once) { response }
|
|
26
|
+
|
|
27
|
+
Certificates.request!(source: :session_cookie)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'stores session cookie certificates apart from ID Token ones' do
|
|
31
|
+
Certificates.request!(source: :session_cookie)
|
|
32
|
+
cache_store = FirebaseIdToken.configuration.cache_store
|
|
33
|
+
|
|
34
|
+
expect(cache_store.read('session_cookie_certificates')).not_to be_nil
|
|
35
|
+
expect(cache_store.read('certificates')).to be_nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'finds a certificate by kid on the session cookie source' do
|
|
39
|
+
expect(Certificates.find(kid, source: :session_cookie)).
|
|
40
|
+
to be_a(OpenSSL::X509::Certificate)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'reports the TTL of the session cookie source' do
|
|
44
|
+
Certificates.request!(source: :session_cookie)
|
|
45
|
+
|
|
46
|
+
expect(Certificates.ttl(source: :session_cookie)).to be > 3600
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -16,12 +16,6 @@ module FirebaseIdToken
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
describe '.redis' do
|
|
20
|
-
it 'sets a Redis instance as default' do
|
|
21
|
-
expect(Configuration.new.redis).to be_a(Redis)
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
19
|
describe '.redis=' do
|
|
26
20
|
it 'changes default values' do
|
|
27
21
|
config = Configuration.new
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
module FirebaseIdToken
|
|
4
|
+
describe Signature do
|
|
5
|
+
describe 'verifying a Session Cookie' do
|
|
6
|
+
let(:jwt) { JSON.parse File.read('spec/fixtures/files/jwt.json') }
|
|
7
|
+
let(:rsa_private) { OpenSSL::PKey::RSA.new(jwt['private_key']) }
|
|
8
|
+
|
|
9
|
+
let(:session_cookie_payload) do
|
|
10
|
+
{
|
|
11
|
+
'iss' => 'https://session.firebase.google.com/firebase-id-token',
|
|
12
|
+
'aud' => 'firebase-id-token',
|
|
13
|
+
'sub' => 'user-123',
|
|
14
|
+
'iat' => Time.current.to_i - 10,
|
|
15
|
+
'exp' => Time.current.to_i + 3600
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
let(:session_cookie) do
|
|
20
|
+
JWT.encode session_cookie_payload, rsa_private, 'RS256', kid: 'test'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
let(:mock_certificates) do
|
|
24
|
+
allow(Certificates)
|
|
25
|
+
.to(receive(:find))
|
|
26
|
+
.with(an_instance_of(String), raise_error: false, source: anything)
|
|
27
|
+
.and_return(OpenSSL::X509::Certificate.new(jwt['certificate']))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
before :each do
|
|
31
|
+
mock_certificates
|
|
32
|
+
FirebaseIdToken.configure do |config|
|
|
33
|
+
config.project_ids = ['firebase-id-token']
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
after :each do
|
|
38
|
+
FirebaseIdToken.reset
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'returns the payload with type: :session_cookie' do
|
|
42
|
+
expect(described_class.verify(session_cookie, type: :session_cookie)).
|
|
43
|
+
to be_a(Hash)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'looks up certificates on the session cookie source' do
|
|
47
|
+
expect(Certificates).to receive(:find).
|
|
48
|
+
with(an_instance_of(String), raise_error: false,
|
|
49
|
+
source: :session_cookie).
|
|
50
|
+
and_return(OpenSSL::X509::Certificate.new(jwt['certificate']))
|
|
51
|
+
|
|
52
|
+
described_class.verify(session_cookie, type: :session_cookie)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'returns nil when verifying a Session Cookie as an ID Token' do
|
|
56
|
+
expect(described_class.verify(session_cookie)).to be(nil)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'returns nil when verifying an ID Token as a Session Cookie' do
|
|
60
|
+
expect(described_class.verify(jwt['jwt_token'], type: :session_cookie)).
|
|
61
|
+
to be(nil)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|