authentic-rb 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|