authentic-rb 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b3df53a31e58a8aa456c9e9f7056921cbd361f9f68610fe0beaa50cea027ec1
4
+ data.tar.gz: e8c51fe75e10af3718d636bf5b501e8f8f9e82ff8bd1079a0dd58752dac4cbdf
5
+ SHA512:
6
+ metadata.gz: 7c67772174a1c70156c47f4acb9cfabdc7b1af5cedb9becd18db487fbc1b4ffd060d83b35bcb1c5c17dcbad4f52113bdea0e687cac0a439ffafd06b57be5f17c
7
+ data.tar.gz: 0333a4447268ccd350841ff55b72c7cdd48ebc1e164a8710484bb39f928837b752bd231b12453127ddc180d2640f411ee3c8840d4c31c194698195bfcd3174b6
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # nodoc
4
+ module Authentic
5
+ # Public: Represents a request error when requesting OIDC config or JWKs from authorization server.
6
+ class RequestError < StandardError
7
+ attr_reader :code
8
+ def initialize(msg, code)
9
+ @code = code
10
+ super(msg)
11
+ end
12
+ end
13
+
14
+ # Public: Represents an error with JWK.
15
+ class InvalidKey < StandardError; end
16
+
17
+ # Public: Represents a error with options passed to Authentic::Validator.
18
+ class IncompleteOptions < StandardError; end
19
+
20
+ # Public: Represents a bad JWT.
21
+ class InvalidToken < StandardError; end
22
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json/jwt'
4
+ require 'unirest'
5
+ require 'uri'
6
+ require 'authentic/key_store'
7
+
8
+ module Authentic
9
+ # Internal: manages JWK retrieval, caching, and validation.
10
+ class KeyManager
11
+ attr_reader :store, :well_known
12
+
13
+ def initialize(max_age)
14
+ @store = KeyStore.new(max_age || '10h')
15
+ @well_known = '/.well-known/openid-configuration'
16
+ end
17
+
18
+ # Public: retrieves JWK.
19
+ #
20
+ # jwt - JSON::JWT.
21
+ #
22
+ # Returns JSON::JWK.
23
+ def get(jwt)
24
+ iss = jwt.fetch(:iss)
25
+
26
+ result = store.get(iss, jwt.kid)
27
+
28
+ return result unless result.nil?
29
+
30
+ # Refresh all keys for an issuer while I have the updated data on hand
31
+ hydrate_iss_keys iss
32
+ store.get(iss, jwt.kid)
33
+ end
34
+
35
+ # Internal: validates RSA key.
36
+ #
37
+ # key - hash with key data.
38
+ #
39
+ # Returns boolean.
40
+ def valid_rsa_key(key)
41
+ key['use'] == 'sig' && key['kty'] == 'RSA' && key['kid']
42
+ end
43
+
44
+ # Internal: performs JSON request.
45
+ #
46
+ # key - hash with JWK data.
47
+ #
48
+ # Returns boolean.
49
+ def valid_key(key)
50
+ valid_rsa_key(key) && (key['x5c']&.length || (key['n'] && key['e']))
51
+ end
52
+
53
+ # Internal: performs JSON request.
54
+ #
55
+ # uri - endpoint to request.
56
+ #
57
+ # Returns JSON.
58
+ def json_req(uri)
59
+ resp = Unirest.get(uri, headers: { 'Accept' => 'application/json' })
60
+ raise RequestError.new("failed to retrieve JWK, status #{resp.code}", resp.code) unless (200..299).cover? resp.code
61
+
62
+ resp.body
63
+ end
64
+
65
+ # Internal: hydrates JWK cache.
66
+ #
67
+ # iss - issuer URI.
68
+ #
69
+ # Returns nothing.
70
+ def hydrate_iss_keys(iss)
71
+ uri = URI.join iss, well_known
72
+ json = json_req uri.to_s
73
+ body = json_req json['jwks_uri']
74
+
75
+ raise InvalidKey, "no valid JWK found, #{json['jwks_uri']}" if body['keys']&.blank?
76
+
77
+ keys = body['keys'].select { |key| valid_key(key) }
78
+ hydrate_store(keys, iss)
79
+ end
80
+
81
+ # Internal: hydrates key store.
82
+ #
83
+ # keys - array of keys hash.
84
+ # iss - JWT issuer endpoint.
85
+ #
86
+ # Returns nothing.
87
+ def hydrate_store(keys, iss)
88
+ keys.each do |key|
89
+ store.set(
90
+ iss, key['kid'],
91
+ JSON::JWK.new(
92
+ kty: key['kty'],
93
+ e: key['e'],
94
+ n: key['n'],
95
+ kid: key['kid']
96
+ )
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'authentic/oidc_key'
4
+
5
+ # nodoc
6
+ module Authentic
7
+ # Internal: Key store for caching JWKs.
8
+ class KeyStore
9
+ # Public: cache data
10
+ attr_reader :data, :max_age, :max_age_seconds
11
+
12
+ def initialize(max_age, data = {})
13
+ @data = data
14
+ @max_age = max_age
15
+ @max_age_seconds = human_time_to_seconds
16
+ end
17
+
18
+ # Public: Sets data, and wraps it in OIDCKey class if not presented as that type.
19
+ #
20
+ # iss - issuer
21
+ # kid - key id
22
+ #
23
+ # Returns JSON::JWK
24
+ def get(iss, kid)
25
+ key = get_key(iss, kid)
26
+ expires!(key)
27
+ data[key]&.value
28
+ end
29
+
30
+ # Internal: builds cache key
31
+ #
32
+ # iss - issuer
33
+ # kid - key id
34
+ #
35
+ # Returns string
36
+ def get_key(iss, kid)
37
+ "#{iss}/#{kid}"
38
+ end
39
+
40
+ # Public: Sets data, and wraps it in OIDCKey class if not presented as that type.
41
+ #
42
+ # iss - issuer
43
+ # kid - key id
44
+ # data - data to cache which is usually a single OIDC public key.
45
+ #
46
+ # Returns JSON::JWK
47
+ def set(iss, kid, new_data)
48
+ key = get_key(iss, kid)
49
+ data[key] = new_data.is_a?(OIDCKey) ? new_data : OIDCKey.new(new_data, max_age_seconds)
50
+ get(iss, kid)
51
+ end
52
+
53
+ # Internal: Verifies if data is expired and unset it
54
+ def expires!(key)
55
+ unset(key) if data[key]&.expired?
56
+ end
57
+
58
+ # Internal: deletes data from cache
59
+ def unset(key)
60
+ data.delete(key)
61
+ end
62
+
63
+ # frozen_string_literal: true
64
+
65
+ # Internal: converts human time to seconds for consumption of the cache service. Format ``
66
+ #
67
+ # human_time - represents time in hours, minutes, and seconds.
68
+ #
69
+ # Returns seconds.
70
+ def human_time_to_seconds
71
+ m = /(?:(\d*)h)?\s?(?:(\d*)?m)?\s?(?:(\d*)?s)?/.match(max_age)
72
+ h = ((m[1].to_i || 0) * 60) * 60
73
+ mi = (m[2].to_i || 0) * 60
74
+ s = (m[3].to_i || 0)
75
+ h + mi + s
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # nodoc
4
+ module Authentic
5
+ # Internal: JWKs cache data.
6
+ class OIDCKey
7
+ attr_reader :expires, :value
8
+
9
+ def initialize(value, max_age_seconds)
10
+ @value = value
11
+ @expires = max_age_seconds.nil? ? nil : Time.now.utc + max_age_seconds
12
+ end
13
+
14
+ def expired?
15
+ !expires.nil? && Time.now.utc > expires
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'authentic/error'
4
+ require 'authentic/key_manager'
5
+
6
+ # Public: proper validation of JWTs against JWKs.
7
+ module Authentic
8
+ # Public: validate JWTs against JWKs using iss whitelist in an environment variable.
9
+ #
10
+ # token - raw JWT.
11
+ #
12
+ # Returns boolean.
13
+ def self.valid?(token)
14
+ Validator.new.valid?(token)
15
+ end
16
+
17
+ # Public: uses environment variable for iss whitelist and validates JWT,
18
+ # raises an error for invalid JWTs, errors requesting JWKs, the lack of valid JWKs, or non white listed ISS.
19
+ #
20
+ # token - raw JWT.
21
+ #
22
+ # Returns nothing.
23
+ def self.ensure_valid(token)
24
+ Validator.new.ensure_valid(token)
25
+ end
26
+
27
+ # Public: validates JWTs against JWKs.
28
+ class Validator
29
+ attr_reader :iss_whitelist, :manager, :opts
30
+
31
+ def initialize(options = {})
32
+ @opts = options
33
+ @iss_whitelist = opts.fetch(:iss_whitelist) { ENV['AUTHENTIC_ISS_WHITELIST']&.split(',') }
34
+ valid_opts = !iss_whitelist&.empty?
35
+ raise IncompleteOptions unless valid_opts
36
+
37
+ @manager = KeyManager.new opts[:cache_max_age]
38
+ end
39
+
40
+ # Public: validates JWT, returns true if valid, false if not.
41
+ #
42
+ # token - raw JWT.
43
+ #
44
+ # Returns boolean.
45
+ def valid?(token)
46
+ ensure_valid token
47
+ true
48
+ rescue InvalidToken, InvalidKey, RequestError
49
+ false
50
+ end
51
+
52
+ # Public: validates JWT, raises an error for invalid JWTs, errors requesting JWKs,
53
+ # the lack of valid JWKs, or non white listed ISS.
54
+ #
55
+ # token - raw JWT.
56
+ #
57
+ # Returns nothing.
58
+ def ensure_valid(token)
59
+ jwt = decode_jwt token
60
+
61
+ begin
62
+ key = manager.get jwt
63
+
64
+ # Slightly more accurate to raise a key error here for nil key,
65
+ # rather then verify raising an error that would lead to InvalidToken
66
+ raise InvalidKey, 'invalid JWK' if key.nil?
67
+
68
+ jwt.verify! key
69
+ rescue JSON::JWT::UnexpectedAlgorithm, JSON::JWT::VerificationFailed
70
+ raise InvalidToken, 'failed to validate token against JWK'
71
+ rescue OpenSSL::PKey::PKeyError
72
+ raise InvalidKey, 'invalid JWK'
73
+ end
74
+ end
75
+
76
+ # Decodes and does basic validation of JWT.
77
+ #
78
+ # token - raw JWT.
79
+ #
80
+ # Returns JSON::JWT
81
+ def decode_jwt(token)
82
+ raise InvalidToken, 'invalid nil JWT provided' unless token
83
+
84
+ JSON::JWT.decode(token, :skip_verification).tap do |jwt|
85
+ raise InvalidToken, 'JWT iss was not located in provided whitelist' unless iss_whitelist.index jwt[:iss]
86
+ end
87
+ rescue JSON::JWT::InvalidFormat
88
+ raise InvalidToken, 'invalid JWT format'
89
+ end
90
+ end
91
+ end
data/lib/authentic.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'authentic/validator'
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: authentic-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Articulate
8
+ - Andy Gertjejansen
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2018-11-26 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: json-jwt
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '1.9'
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 1.9.4
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - "~>"
29
+ - !ruby/object:Gem::Version
30
+ version: '1.9'
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.9.4
34
+ - !ruby/object:Gem::Dependency
35
+ name: unirest
36
+ requirement: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 1.1.2
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: 1.1.2
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 1.1.2
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 1.1.2
54
+ description: Ruby toolkit for Auth0 API https://auth0.com.
55
+ email:
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - lib/authentic.rb
61
+ - lib/authentic/error.rb
62
+ - lib/authentic/key_manager.rb
63
+ - lib/authentic/key_store.rb
64
+ - lib/authentic/oidc_key.rb
65
+ - lib/authentic/validator.rb
66
+ homepage: https://rubygems.org/gems/authentic-rb
67
+ licenses: []
68
+ metadata:
69
+ source_code_uri: https://github.com/articulate/authentic-rb
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubyforge_project:
86
+ rubygems_version: 2.7.8
87
+ signing_key:
88
+ specification_version: 4
89
+ summary: validation of JWTs against JWKs
90
+ test_files: []