rack-firebase 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3dcb76b9dd459436772a9058c79c1fda81de49e4b1ce0c76e74e6611a96718d3
4
- data.tar.gz: 9e15447178511fe28a6e367ae2a1ce97fd7c4664d416cca36a507b90055a3e6c
3
+ metadata.gz: 8e56bf82dd7ed836213b95db8e833c94c06c020ba654d4d132c7659dbc30ffbc
4
+ data.tar.gz: 888ed5477400ef8ba5f5deb9650076b5d2d88554fc750c7e888a8a7780f56267
5
5
  SHA512:
6
- metadata.gz: 9786153f3cc1e21470bf30df88b1cb06c54dbf4f5f3f1e12d738dee189e373da35b2a98e3f8e03ca558cbd33735a7e828f25cdaf529f87c874991b94d6702d97
7
- data.tar.gz: 910c6ab79bf38b4634278f5710ba42de25a9f81bba5bd3a8590bfcd8671a6a3e30b157ea5e9db974245c87119e774799132034a5c4a14c289a7564fafc0e5dff
6
+ metadata.gz: be204dc9f7da121a6c72d3f34ad8d74f767e55ac25d357cd9b96588a980501620af57b4678d952f5803f2487f1d456f2d3b06995b94451f4e1f37d21ae078277
7
+ data.tar.gz: 87b20058c4410c576c0fadc2127c91c9860d16419a8b5bd3cae854e41ff1c613d728d66fb1341d5561ac32b5d8d29ff6c72528340fa812f0c976d2156f5d3c47
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Rack Firebase Middleware
2
2
 
3
- [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md)
3
+ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) [![Gem Version](https://badge.fury.io/rb/rack-firebase.svg)](https://rubygems.org/gems/rack-firebase)
4
4
 
5
5
 
6
6
  A rack middleware for verifying ID tokens from Google's Firebase. It provides token decoding and verification using [Firebase's 3rd Party Verification constraints](https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=en&authuser=3#verify_id_tokens_using_a_third-party_jwt_library).
@@ -70,6 +70,85 @@ end
70
70
 
71
71
  From here, you can invoke `authenticate_user!` to ensure the token subject is actually a user in your application and use `current_user` to scope your requests or handle more granular authorization.
72
72
 
73
+ ### Testing
74
+
75
+ In order to test your authenticated routes, you'll need to provide a valid token in the authorization header.
76
+
77
+ Since Firebase typically issues signed tokens using their certificates, this can make it difficult to test your authenticated routes with valid tokens.
78
+
79
+ As such, some test helpers are provided to help faciliate automated testing.
80
+
81
+ > Note: For manual testing, it is recommended to create a test-specific user in your Firebase project and test the full authentication flow with your routes.
82
+
83
+ #### Mocking requests for public keys
84
+
85
+ There are two options for mocking the requests from Google; choose the one that best fits your testing library and needs:
86
+
87
+ 1. Explicitly start and stop firebase mocks, or
88
+ 2. Wrap each example that needs to be mocked.
89
+
90
+ ```ruby
91
+ # Require the test helpers
92
+ require "rack/firebase/test_helpers"
93
+
94
+ describe "your request specs" do
95
+ # Explicitly mock before/after
96
+ before { Rack::Firebase::TestHelpers.mock_start }
97
+ after { Rack::Firebase::TestHelpers.mock_end }
98
+
99
+ # Or wrap each example
100
+ # This is for RSpec only! Use setup/teardown or before/after with the explicit mock for Minitest.
101
+ around(:example) do |example|
102
+ Rack::Firebase::TestHelpers.mock_signature_verification do
103
+ example.run
104
+ end
105
+ end
106
+ end
107
+ ```
108
+
109
+ Optionally, you can pass minutes as a number to `mock_start` to overwrite when the cache key should be refreshed. By default, this is set to `5000`, or approximately 83 minutes.
110
+
111
+ #### Generating Tokens
112
+
113
+ A test helper is provided that will generate a valid token and add it to your request headers for your specs.
114
+
115
+ ```ruby
116
+ # Require the test helpers
117
+ require "rack/firebase/test_helpers"
118
+
119
+ it "tests something" do
120
+ user = fetch_user() # this presumes your user has a `uid`!
121
+ headers = { "Accept" => "application/json", "Content-Type" => "application/json" }
122
+
123
+ # This will generate a valid token and add it to your headers
124
+ auth_headers = Rack::Firebase::TestHelpers.auth_headers(headers, user.uid)
125
+
126
+ get "/", headers: auth_headers
127
+
128
+ expect(last_response).to be_ok
129
+ end
130
+ ```
131
+
132
+ ##### Customizing
133
+
134
+ By default, the token will be created using the first project ID provided in your configuration. If your app is configured for multiple projects and you wish to test one of the other projects, you can optionally add the `aud` to the arguments:
135
+
136
+ ```ruby
137
+ Rack::Firebase::TestHelpers.auth_headers(headers, user.uid, aud: "different-project")
138
+ ```
139
+
140
+ There is a fourth arg that takes a hash of options that will let you alter the payload.
141
+
142
+ > Caution: it is possible for provided options to produce invalid tokens.
143
+
144
+ | argument | description | default |
145
+ | --- | --- | --- |
146
+ | email | User's email address used for authentication. Added to the payload as `email` and included in the array of email identities in `firebase.identities.email` | test@test.com |
147
+ | verified | Denotes if the email used to sign in is verified by the user | false |
148
+ | auth_time | The last authentication time recorded by Google | Current time |
149
+ | iat | Denotes the time the token was issued. When iat is provided, but not an auth time, the auth time is also set to the iat time. | Current time |
150
+ | exp | Denotes when the token expires | Current time + 5000 |
151
+
73
152
  ## Contributing
74
153
 
75
154
  Bug reports and pull requests are welcome on GitHub.
@@ -1,7 +1,7 @@
1
1
  module Rack
2
2
  module Firebase
3
3
  class Configuration
4
- attr_accessor :project_ids
4
+ attr_accessor :project_ids, :public_routes
5
5
 
6
6
  def initialize
7
7
  reset!
@@ -9,6 +9,7 @@ module Rack
9
9
 
10
10
  def reset!
11
11
  @project_ids = []
12
+ @public_routes = []
12
13
  end
13
14
  end
14
15
  end
@@ -1,6 +1,7 @@
1
1
  module Rack
2
2
  module Firebase
3
3
  class InvalidSubError < StandardError; end
4
+
4
5
  class InvalidAuthTimeError < StandardError; end
5
6
  end
6
7
  end
@@ -4,8 +4,6 @@ require "net/http"
4
4
  module Rack
5
5
  module Firebase
6
6
  class Middleware
7
- ALG = "RS256".freeze
8
- CERTIFICATE_URL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com".freeze
9
7
  USER_UID = "firebase.user.uid"
10
8
 
11
9
  def initialize(app)
@@ -18,39 +16,37 @@ module Rack
18
16
  end
19
17
 
20
18
  def call(env)
21
- token = AuthorizationHeader.read_token(env)
22
- decoded_token, _ = JWT.decode(
23
- token, nil, true,
24
- {
25
- jwks: jwt_loader,
26
- algorithm: ALG,
27
- verify_iat: true,
28
- verify_aud: true, aud: config.project_ids,
29
- verify_iss: true, iss: firebase_issuers
30
- }
31
- )
19
+ path = env.fetch("PATH_INFO", "no-match")
20
+ if config.public_routes.none? { |r| r.match(path) }
21
+ begin
22
+ token = AuthorizationHeader.read_token(env)
23
+ decoded_token = TokenDecoder.new.call(token)
32
24
 
33
- raise Rack::Firebase::InvalidSubError.new("Invalid subject") if decoded_token["sub"].nil? || decoded_token["sub"] == ""
34
- raise Rack::Firebase::InvalidAuthTimeError.new("Invalid auth time") unless decoded_token["auth_time"] <= Time.now.to_i
25
+ raise Rack::Firebase::InvalidSubError.new("Invalid subject") if decoded_token["sub"].nil? || decoded_token["sub"] == ""
26
+ raise Rack::Firebase::InvalidAuthTimeError.new("Invalid auth time") unless decoded_token["auth_time"] <= Time.now.to_i
35
27
 
36
- env[USER_UID] = decoded_token["sub"]
37
- @app.call(env)
38
- rescue JWT::JWKError => error # Issues with fetched JWKs
39
- error_responder.call(error, "unauthorized")
40
- rescue JWT::ExpiredSignature => error # Token has expired
41
- error_responder.call(error, "expired")
42
- rescue JWT::InvalidIatError => error # invalid issued at claim (iat)
43
- error_responder.call(error, "unauthorized")
44
- rescue JWT::InvalidIssuerError => error # invalid issuer
45
- error_responder.call(error, "unauthorized")
46
- rescue JWT::InvalidAudError => error # invalid audience
47
- error_responder.call(error, "unauthorized")
48
- rescue JWT::DecodeError => error # General JWT error
49
- error_responder.call(error, "unauthorized")
50
- rescue Rack::Firebase::InvalidSubError => error # subject is empty or missing
51
- error_responder.call(error, "unauthorized")
52
- rescue Rack::Firebase::InvalidAuthTimeError => error # auth time is in the future
53
- error_responder.call(error, "unauthorized")
28
+ env[USER_UID] = decoded_token["sub"]
29
+ @app.call(env)
30
+ rescue JWT::JWKError => error # Issues with fetched JWKs
31
+ error_responder.call(error, "unauthorized")
32
+ rescue JWT::ExpiredSignature => error # Token has expired
33
+ error_responder.call(error, "expired")
34
+ rescue JWT::InvalidIatError => error # invalid issued at claim (iat)
35
+ error_responder.call(error, "unauthorized")
36
+ rescue JWT::InvalidIssuerError => error # invalid issuer
37
+ error_responder.call(error, "unauthorized")
38
+ rescue JWT::InvalidAudError => error # invalid audience
39
+ error_responder.call(error, "unauthorized")
40
+ rescue JWT::DecodeError => error # General JWT error
41
+ error_responder.call(error, "unauthorized")
42
+ rescue Rack::Firebase::InvalidSubError => error # subject is empty or missing
43
+ error_responder.call(error, "unauthorized")
44
+ rescue Rack::Firebase::InvalidAuthTimeError => error # auth time is in the future
45
+ error_responder.call(error, "unauthorized")
46
+ end
47
+ else
48
+ @app.call(env)
49
+ end
54
50
  end
55
51
 
56
52
  private
@@ -0,0 +1,57 @@
1
+ module Rack
2
+ module Firebase
3
+ module TestHelpers
4
+ def self.auth_headers(headers, uid, aud: nil, options: {})
5
+ cached_current_time = Time.now.to_i
6
+ aud ||= Rack::Firebase.configuration.project_ids.first
7
+
8
+ payload = {
9
+ iss: "https://securetoken.google.com/#{aud}",
10
+ aud: aud,
11
+ auth_time: options[:auth_time] || options[:iat] || cached_current_time,
12
+ user_id: uid,
13
+ sub: uid,
14
+ iat: options[:iat] || cached_current_time,
15
+ exp: options[:exp] || cached_current_time + 5000,
16
+ email: options[:email] || "test@test.com",
17
+ email_verified: !!options[:verified],
18
+ firebase: {
19
+ identities: {
20
+ email: [
21
+ options[:email],
22
+ "test@test.com"
23
+ ]
24
+ },
25
+ sign_in_provider: "password"
26
+ }
27
+ }
28
+ token = JWT.encode(payload, secret_key, Rack::Firebase::ALG, kid: "1234567890")
29
+ headers = headers.dup
30
+ headers["Authorization"] = "Bearer #{token}"
31
+ headers
32
+ end
33
+
34
+ def self.mock_start(expires_in = 5000)
35
+ Rack::Firebase.instance_variable_set(:@cached_keys, [JWT::JWK::RSA.new(secret_key.public_key, kid: "1234567890")])
36
+ Rack::Firebase.instance_variable_set(:@refresh_cache_by, Time.now.to_i + expires_in)
37
+ end
38
+
39
+ def self.mock_signature_verification
40
+ mock_start
41
+ yield
42
+ mock_end
43
+ end
44
+
45
+ def self.mock_end
46
+ Rack::Firebase.instance_variable_set(:@cached_keys, nil)
47
+ Rack::Firebase.instance_variable_set(:@refresh_cache_by, nil)
48
+ end
49
+
50
+ private_class_method
51
+
52
+ def self.secret_key
53
+ @secret_key ||= OpenSSL::PKey::RSA.generate(2048)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,35 @@
1
+ require "jwt"
2
+ require "net/http"
3
+ require "rack/firebase"
4
+
5
+ module Rack
6
+ module Firebase
7
+ class TokenDecoder
8
+ attr_accessor :config, :jwt_loader
9
+
10
+ def initialize
11
+ @jwt_loader = FIREBASE_KEY_LOADER
12
+ @config = Rack::Firebase.configuration
13
+ end
14
+
15
+ def call(token)
16
+ JWT.decode(
17
+ token, nil, true,
18
+ {
19
+ jwks: jwt_loader,
20
+ algorithm: ALG,
21
+ verify_iat: true,
22
+ verify_aud: true, aud: config.project_ids,
23
+ verify_iss: true, iss: firebase_issuers
24
+ }
25
+ )[0]
26
+ end
27
+
28
+ def firebase_issuers
29
+ config.project_ids.map { |project_id|
30
+ "https://securetoken.google.com/#{project_id}"
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,5 @@
1
1
  module Rack
2
2
  module Firebase
3
- VERSION = "0.1.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
data/lib/rack/firebase.rb CHANGED
@@ -14,8 +14,31 @@ module Rack
14
14
  def self.configure
15
15
  yield configuration
16
16
  end
17
+
18
+ ALG = "RS256".freeze
19
+ CERTIFICATE_URL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com".freeze
20
+ FIREBASE_KEY_LOADER = lambda do |options|
21
+ if options[:kid_not_found] || (@refresh_cache_by.to_i < Time.now.to_i + 3600)
22
+ @cached_keys = nil
23
+ end
24
+
25
+ @cached_keys ||= begin
26
+ response = ::Net::HTTP.get_response(URI(CERTIFICATE_URL))
27
+ cache_control = response["Cache-Control"]
28
+
29
+ expires_in = cache_control.match(/max-age=([0-9]+)/).captures.first.to_i
30
+ @refresh_cache_by = Time.now.to_i + expires_in
31
+
32
+ json = JSON.parse(response.body)
33
+ json.map do |kid, cert_string|
34
+ key = OpenSSL::X509::Certificate.new(cert_string).public_key
35
+ JWT::JWK::RSA.new(key, kid: kid)
36
+ end
37
+ end
38
+ end
17
39
  end
18
40
  end
19
41
  require "rack/firebase/error"
20
42
  require "rack/firebase/authorization_header"
43
+ require "rack/firebase/token_decoder"
21
44
  require "rack/firebase/middleware"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-firebase
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laura Mosher
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-12-31 00:00:00.000000000 Z
11
+ date: 2023-01-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '2.14'
61
+ version: '3.12'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '2.14'
68
+ version: '3.12'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: rack-test
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -100,14 +100,14 @@ dependencies:
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: 1.9.0
103
+ version: 1.20.0
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: 1.9.0
110
+ version: 1.20.0
111
111
  description: A simple, lightweight Rack middleware to verify Firebase tokens.
112
112
  email: laura@mosher.tech
113
113
  executables: []
@@ -121,6 +121,8 @@ files:
121
121
  - lib/rack/firebase/configuration.rb
122
122
  - lib/rack/firebase/error.rb
123
123
  - lib/rack/firebase/middleware.rb
124
+ - lib/rack/firebase/test_helpers.rb
125
+ - lib/rack/firebase/token_decoder.rb
124
126
  - lib/rack/firebase/version.rb
125
127
  homepage: https://github.com/lauramosher/rack-firebase
126
128
  licenses:
@@ -141,7 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
143
  - !ruby/object:Gem::Version
142
144
  version: '0'
143
145
  requirements: []
144
- rubygems_version: 3.1.2
146
+ rubygems_version: 3.4.1
145
147
  signing_key:
146
148
  specification_version: 4
147
149
  summary: Verify Firebase ID Tokens in Middleware