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 +7 -0
- data/lib/authentic/error.rb +22 -0
- data/lib/authentic/key_manager.rb +101 -0
- data/lib/authentic/key_store.rb +78 -0
- data/lib/authentic/oidc_key.rb +18 -0
- data/lib/authentic/validator.rb +91 -0
- data/lib/authentic.rb +3 -0
- metadata +90 -0
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
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: []
|