masks 0.7.0 → 0.9.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 +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +145 -100
- data/app/controllers/masks/rails/handshakes_controller.rb +11 -0
- data/app/controllers/masks/rails/sessions_controller.rb +39 -2
- data/lib/masks/client/claims.rb +69 -0
- data/lib/masks/client/errors.rb +8 -3
- data/lib/masks/client/introspection.rb +6 -0
- data/lib/masks/client/logout.rb +1 -1
- data/lib/masks/client/pkce.rb +1 -3
- data/lib/masks/client/proof.rb +153 -0
- data/lib/masks/client/rack.rb +15 -1
- data/lib/masks/client/registry.rb +0 -4
- data/lib/masks/client/resource.rb +31 -9
- data/lib/masks/client/session.rb +69 -10
- data/lib/masks/client/tokens.rb +1 -0
- data/lib/masks/client/verifier.rb +18 -7
- data/lib/masks/client.rb +1 -0
- data/lib/masks/rails/authentication.rb +86 -13
- data/lib/masks/rails/configuration.rb +13 -14
- data/lib/masks/rails/protected_resource.rb +10 -4
- data/lib/masks/rails.rb +2 -2
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +2 -2
- metadata +4 -2
data/lib/masks/client/claims.rb
CHANGED
|
@@ -60,7 +60,51 @@ module Masks
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
class Organization
|
|
64
|
+
OWNER = "owner".freeze
|
|
65
|
+
|
|
66
|
+
attr_reader :to_h
|
|
67
|
+
|
|
68
|
+
def initialize(hash)
|
|
69
|
+
@to_h = hash.is_a?(Hash) ? hash.transform_keys(&:to_s) : {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def id
|
|
73
|
+
to_h["id"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def key
|
|
77
|
+
to_h["key"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def name
|
|
81
|
+
to_h["name"]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def role
|
|
85
|
+
to_h["role"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def role?(*roles)
|
|
89
|
+
roles.flatten.map(&:to_s).include?(role)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def owner?
|
|
93
|
+
role?(OWNER)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def present?
|
|
97
|
+
!id.nil? && !key.nil? && !role.nil?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ==(other)
|
|
101
|
+
other.is_a?(Organization) ? to_h == other.to_h : false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
63
105
|
AVATARS = "masks:avatars".freeze
|
|
106
|
+
ORGANIZATION = "org".freeze
|
|
107
|
+
ORGANIZATIONS = "orgs".freeze
|
|
64
108
|
|
|
65
109
|
attr_reader :to_h
|
|
66
110
|
|
|
@@ -104,6 +148,31 @@ module Masks
|
|
|
104
148
|
@avatars ||= Avatars.new(self[AVATARS])
|
|
105
149
|
end
|
|
106
150
|
|
|
151
|
+
def organization
|
|
152
|
+
@organization ||= Organization.new(self[ORGANIZATION])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def organizations
|
|
156
|
+
@organizations ||= Array(self[ORGANIZATIONS]).map { |held| Organization.new(held) }.select(&:present?).freeze
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def member!(*roles, organization: nil)
|
|
160
|
+
held = self.organization
|
|
161
|
+
|
|
162
|
+
unless held.present?
|
|
163
|
+
raise Forbidden.new("insufficient_organization", "this token names no organization",
|
|
164
|
+
scope: Session::ORGANIZATION)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if organization && held.key != organization.to_s && held.id != organization.to_s
|
|
168
|
+
raise Forbidden.new("insufficient_organization", "this token was issued for #{held.key}, not #{organization}")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
return held if roles.flatten.empty? || held.role?(*roles)
|
|
172
|
+
|
|
173
|
+
raise Forbidden.new("insufficient_role", "#{held.role} in #{held.key} is not #{roles.flatten.join(' or ')}")
|
|
174
|
+
end
|
|
175
|
+
|
|
107
176
|
def picture
|
|
108
177
|
self["picture"] || avatars.photo || avatars.identicon
|
|
109
178
|
end
|
data/lib/masks/client/errors.rb
CHANGED
|
@@ -29,13 +29,18 @@ module Masks
|
|
|
29
29
|
class Challenge < Error
|
|
30
30
|
attr_reader :code, :description, :status, :scope
|
|
31
31
|
|
|
32
|
-
def initialize(code, description, status:, scope: nil)
|
|
32
|
+
def initialize(code, description, status:, scope: nil, dpop: false)
|
|
33
33
|
super([ code, description ].compact.join(": "))
|
|
34
34
|
|
|
35
35
|
@code = code
|
|
36
36
|
@description = description
|
|
37
37
|
@status = status
|
|
38
38
|
@scope = scope
|
|
39
|
+
@dpop = dpop
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dpop?
|
|
43
|
+
@dpop
|
|
39
44
|
end
|
|
40
45
|
end
|
|
41
46
|
|
|
@@ -46,8 +51,8 @@ module Masks
|
|
|
46
51
|
end
|
|
47
52
|
|
|
48
53
|
class Unauthorized < Challenge
|
|
49
|
-
def initialize(description, code: "invalid_token")
|
|
50
|
-
super(code, description, status: 401)
|
|
54
|
+
def initialize(description, code: "invalid_token", dpop: false)
|
|
55
|
+
super(code, description, status: 401, dpop: dpop)
|
|
51
56
|
end
|
|
52
57
|
end
|
|
53
58
|
|
data/lib/masks/client/logout.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Masks
|
|
|
9
9
|
def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
|
|
10
10
|
held = Verifier
|
|
11
11
|
.new(issuer, audience: audience, algorithms: algorithms)
|
|
12
|
-
.verify(token, required: %w[iss aud iat jti events])
|
|
12
|
+
.verify(token, required: %w[iss aud iat jti events], typ: Verifier::LOGOUT_TOKEN)
|
|
13
13
|
|
|
14
14
|
new(held).validate!
|
|
15
15
|
end
|
data/lib/masks/client/pkce.rb
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Proof
|
|
4
|
+
class Invalid < Error; end
|
|
5
|
+
|
|
6
|
+
SCHEME = "DPoP".freeze
|
|
7
|
+
TYPE = "dpop+jwt".freeze
|
|
8
|
+
ALGORITHMS = %w[ES256 ES384 ES512 PS256 PS384 PS512 RS256].freeze
|
|
9
|
+
SECRET = %w[d p q dp dq qi k].freeze
|
|
10
|
+
THUMBED = { "EC" => %w[crv kty x y], "RSA" => %w[e kty n] }.freeze
|
|
11
|
+
LEEWAY = 30
|
|
12
|
+
WINDOW = 60
|
|
13
|
+
MEMORY = WINDOW + (LEEWAY * 2)
|
|
14
|
+
|
|
15
|
+
class Memory
|
|
16
|
+
def initialize
|
|
17
|
+
@held = {}
|
|
18
|
+
@lock = Mutex.new
|
|
19
|
+
@swept = 0.0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def first?(key, expires_in:)
|
|
23
|
+
now = Time.now.to_f
|
|
24
|
+
|
|
25
|
+
@lock.synchronize do
|
|
26
|
+
sweep(now) if now - @swept > 1
|
|
27
|
+
return false if @held.key?(key) && @held[key] >= now
|
|
28
|
+
|
|
29
|
+
@held[key] = now + expires_in
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def sweep(now)
|
|
37
|
+
@held.delete_if { |_, until_at| until_at < now }
|
|
38
|
+
@swept = now
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
MEMORY_LOCK = Mutex.new
|
|
43
|
+
|
|
44
|
+
def self.memory
|
|
45
|
+
MEMORY_LOCK.synchronize { @memory ||= Memory.new }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.thumbprint(jwk)
|
|
49
|
+
named = THUMBED[jwk["kty"]] || raise(Invalid, "the proof key is of a kind this resource does not read")
|
|
50
|
+
held = named.to_h { |field| [ field, jwk[field] ] }
|
|
51
|
+
|
|
52
|
+
raise Invalid, "the proof key is missing part of itself" if held.any? { |_, value| value.to_s.empty? }
|
|
53
|
+
|
|
54
|
+
digest(JSON.generate(held))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.digest(value)
|
|
58
|
+
Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(value.to_s), padding: false)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(proof, method:, url:, replay: nil)
|
|
62
|
+
held = proof.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
63
|
+
|
|
64
|
+
raise Invalid, "exactly one DPoP proof is required" unless held.size == 1
|
|
65
|
+
|
|
66
|
+
@proof = held.first
|
|
67
|
+
@method = method.to_s.upcase
|
|
68
|
+
@url = url.to_s
|
|
69
|
+
@replay = replay
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check!(access_token:, jkt:)
|
|
73
|
+
header = keyed
|
|
74
|
+
claims = verified(header)
|
|
75
|
+
thumbprint = self.class.thumbprint(header["jwk"])
|
|
76
|
+
|
|
77
|
+
raise Invalid, "that proof was made with another key than the token is bound to" unless same?(thumbprint, jkt)
|
|
78
|
+
|
|
79
|
+
matches!(claims)
|
|
80
|
+
timely!(claims)
|
|
81
|
+
raise Invalid, "that proof was made for another token" unless same?(claims["ath"].to_s, self.class.digest(access_token))
|
|
82
|
+
|
|
83
|
+
once!(claims, thumbprint)
|
|
84
|
+
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def keyed
|
|
91
|
+
header = JWT.decode(@proof, nil, false).last
|
|
92
|
+
|
|
93
|
+
raise Invalid, "a proof must be typed #{TYPE}" unless header["typ"].to_s.casecmp?(TYPE)
|
|
94
|
+
raise Invalid, "#{header['alg'] || 'that'} is not an algorithm a proof may use" unless ALGORITHMS.include?(header["alg"].to_s)
|
|
95
|
+
|
|
96
|
+
jwk = header["jwk"]
|
|
97
|
+
|
|
98
|
+
raise Invalid, "a proof must carry the public key that signed it" unless jwk.is_a?(Hash)
|
|
99
|
+
raise Invalid, "a proof key must be public" if jwk["kty"].to_s == "oct" || jwk.keys.any? { |field| SECRET.include?(field.to_s) }
|
|
100
|
+
|
|
101
|
+
header
|
|
102
|
+
rescue JWT::DecodeError
|
|
103
|
+
raise Invalid, "that proof is not a readable JWT"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def verified(header)
|
|
107
|
+
JWT.decode(@proof, JWT::JWK.new(header["jwk"]).verify_key, true,
|
|
108
|
+
algorithms: [ header["alg"].to_s ], verify_expiration: false).first
|
|
109
|
+
rescue JWT::DecodeError, JWT::JWKError, OpenSSL::PKey::PKeyError, ArgumentError
|
|
110
|
+
raise Invalid, "that proof was not signed by the key it carries"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def matches!(claims)
|
|
114
|
+
raise Invalid, "that proof was made for another method" unless claims["htm"].to_s.upcase == @method
|
|
115
|
+
raise Invalid, "that proof was made for another URL" unless tidied(claims["htu"]) == tidied(@url)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def tidied(value)
|
|
119
|
+
uri = URI.parse(value.to_s)
|
|
120
|
+
raise Invalid, "that proof names no URL" if uri.host.nil?
|
|
121
|
+
|
|
122
|
+
port = ":#{uri.port}" unless uri.port.nil? || uri.port == uri.default_port
|
|
123
|
+
path = uri.path.to_s == "/" ? "" : uri.path.to_s
|
|
124
|
+
|
|
125
|
+
"#{uri.scheme.to_s.downcase}://#{uri.host.downcase}#{port}#{path}"
|
|
126
|
+
rescue URI::InvalidURIError
|
|
127
|
+
raise Invalid, "that proof names a URL this resource cannot read"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def timely!(claims)
|
|
131
|
+
made = claims["iat"]
|
|
132
|
+
|
|
133
|
+
raise Invalid, "a proof must say when it was made" unless made.is_a?(Numeric)
|
|
134
|
+
raise Invalid, "that proof was made too long ago" if made < Time.now.to_i - WINDOW - LEEWAY
|
|
135
|
+
raise Invalid, "that proof was made in the future" if made > Time.now.to_i + LEEWAY
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def once!(claims, thumbprint)
|
|
139
|
+
jti = claims["jti"].to_s
|
|
140
|
+
|
|
141
|
+
raise Invalid, "a proof must carry a jti" if jti.empty?
|
|
142
|
+
return if @replay.nil? || @replay.first?("#{thumbprint}:#{jti}", expires_in: MEMORY)
|
|
143
|
+
|
|
144
|
+
raise Invalid, "that proof has already been used"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def same?(held, expected)
|
|
148
|
+
!held.to_s.empty? && held.bytesize == expected.to_s.bytesize &&
|
|
149
|
+
OpenSSL.fixed_length_secure_compare(held, expected.to_s)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/masks/client/rack.rb
CHANGED
|
@@ -15,7 +15,10 @@ module Masks
|
|
|
15
15
|
def call(env)
|
|
16
16
|
return @app.call(env) unless guards?(env)
|
|
17
17
|
|
|
18
|
-
env[CLAIMS] = resource(env).authenticate(
|
|
18
|
+
env[CLAIMS] = resource(env).authenticate(
|
|
19
|
+
env["HTTP_AUTHORIZATION"], scope: @scope,
|
|
20
|
+
proof: env["HTTP_DPOP"], method: env["REQUEST_METHOD"], url: url(env)
|
|
21
|
+
)
|
|
19
22
|
|
|
20
23
|
@app.call(env)
|
|
21
24
|
rescue Challenge => e
|
|
@@ -32,6 +35,17 @@ module Masks
|
|
|
32
35
|
@only.call(env)
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
def url(env)
|
|
39
|
+
if defined?(::Rack::Request)
|
|
40
|
+
request = ::Rack::Request.new(env)
|
|
41
|
+
return "#{request.base_url}#{request.path}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
host = env["HTTP_HOST"] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"
|
|
45
|
+
|
|
46
|
+
"#{env['rack.url_scheme'] || 'http'}://#{host}#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
47
|
+
end
|
|
48
|
+
|
|
35
49
|
def passthrough(env, error)
|
|
36
50
|
env[ERROR] = error
|
|
37
51
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Client
|
|
3
3
|
class Resource
|
|
4
|
-
|
|
4
|
+
PRESENTED = /\A(Bearer|DPoP)[ \t]+([^\s,]+)[ \t]*\z/i.freeze
|
|
5
5
|
METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
|
|
6
6
|
REQUIRED = %w[iss sub exp].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :issuer, :url, :scopes
|
|
9
9
|
|
|
10
10
|
def initialize(issuer:, url:, scopes: [], metadata_url: nil,
|
|
11
|
-
algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil)
|
|
11
|
+
algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil, replay: Proof.memory)
|
|
12
12
|
@issuer = Issuer.resolve(issuer)
|
|
13
13
|
@url = url.to_s
|
|
14
14
|
@descriptions = describe(scopes)
|
|
@@ -16,12 +16,17 @@ module Masks
|
|
|
16
16
|
@metadata_url = metadata_url
|
|
17
17
|
@required = Array(required)
|
|
18
18
|
@verifier = verifier || Verifier.new(@issuer, audience: @url, algorithms: algorithms)
|
|
19
|
+
@replay = replay
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def authenticate(authorization, scope: nil)
|
|
22
|
-
|
|
22
|
+
def authenticate(authorization, scope: nil, role: nil, organization: nil, proof: nil, method: nil, url: nil)
|
|
23
|
+
scheme, token = presented!(authorization)
|
|
24
|
+
held = @verifier.verify(token, required: @required, typ: Verifier::ACCESS_TOKEN)
|
|
25
|
+
bound!(held, scheme, token, proof: proof, method: method, url: url)
|
|
26
|
+
claims = Claims.new(held)
|
|
23
27
|
|
|
24
28
|
Array(scope).each { |name| claims.permit!(name) }
|
|
29
|
+
claims.member!(*Array(role), organization: organization) if role || organization
|
|
25
30
|
|
|
26
31
|
claims
|
|
27
32
|
rescue InvalidToken => e
|
|
@@ -29,7 +34,7 @@ module Masks
|
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def token(authorization)
|
|
32
|
-
authorization.to_s[
|
|
37
|
+
authorization.to_s[PRESENTED, 2]
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
def metadata_url
|
|
@@ -42,7 +47,8 @@ module Masks
|
|
|
42
47
|
"authorization_servers" => [ issuer.url ],
|
|
43
48
|
"scopes_supported" => scopes,
|
|
44
49
|
"scope_descriptions" => @descriptions,
|
|
45
|
-
"bearer_methods_supported" => [ "header" ]
|
|
50
|
+
"bearer_methods_supported" => [ "header" ],
|
|
51
|
+
"dpop_signing_alg_values_supported" => Proof::ALGORITHMS
|
|
46
52
|
}.reject { |_, value| value.respond_to?(:empty?) && value.empty? }
|
|
47
53
|
end
|
|
48
54
|
|
|
@@ -56,7 +62,9 @@ module Masks
|
|
|
56
62
|
[ "resource_metadata", metadata_url ]
|
|
57
63
|
]
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
scheme = error.dpop? ? "#{Proof::SCHEME} algs=\"#{Proof::ALGORITHMS.join(' ')}\", " : "Bearer "
|
|
66
|
+
|
|
67
|
+
scheme + parameters.filter_map { |name, value|
|
|
60
68
|
%(#{name}="#{quote(value)}") if value && !value.to_s.empty?
|
|
61
69
|
}.join(", ")
|
|
62
70
|
end
|
|
@@ -71,8 +79,22 @@ module Masks
|
|
|
71
79
|
end.freeze
|
|
72
80
|
end
|
|
73
81
|
|
|
74
|
-
def
|
|
75
|
-
|
|
82
|
+
def presented!(authorization)
|
|
83
|
+
match = authorization.to_s.match(PRESENTED) || raise(Unauthenticated.new)
|
|
84
|
+
|
|
85
|
+
[ match[1].casecmp?(Proof::SCHEME) ? :dpop : :bearer, match[2] ]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def bound!(claims, scheme, token, proof:, method:, url:)
|
|
89
|
+
jkt = claims.dig("cnf", "jkt")
|
|
90
|
+
|
|
91
|
+
raise Unauthorized.new("that token is not bound to a key, so it is presented as Bearer") if jkt.nil? && scheme == :dpop
|
|
92
|
+
raise Unauthorized.new("that token is bound to a key, so it is presented as DPoP with a proof", dpop: true) if jkt && scheme != :dpop
|
|
93
|
+
return if jkt.nil?
|
|
94
|
+
|
|
95
|
+
Proof.new(proof, method: method, url: url || self.url, replay: @replay).check!(access_token: token, jkt: jkt)
|
|
96
|
+
rescue Proof::Invalid => e
|
|
97
|
+
raise Unauthorized.new(e.message, code: "invalid_dpop_proof", dpop: true)
|
|
76
98
|
end
|
|
77
99
|
|
|
78
100
|
def origin
|
data/lib/masks/client/session.rb
CHANGED
|
@@ -2,21 +2,37 @@ module Masks
|
|
|
2
2
|
module Client
|
|
3
3
|
class Session
|
|
4
4
|
DEFAULT_SCOPE = %w[openid profile email].freeze
|
|
5
|
+
ORGANIZATION = "organization".freeze
|
|
6
|
+
ORGANIZATION_KEY = /\A[a-z0-9][a-z0-9-]*\z/
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
def self.organization_key(value)
|
|
9
|
+
key = value.to_s.strip.downcase
|
|
10
|
+
|
|
11
|
+
key.match?(ORGANIZATION_KEY) ? key : nil
|
|
12
|
+
end
|
|
13
|
+
ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".freeze
|
|
14
|
+
ASSERTION_LIFETIME = 60
|
|
15
|
+
|
|
16
|
+
attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope, :private_key, :key_id
|
|
17
|
+
|
|
18
|
+
def initialize(issuer:, client_id:, redirect_uri: nil, client_secret: nil, private_key: nil, key_id: nil,
|
|
19
|
+
scope: DEFAULT_SCOPE)
|
|
20
|
+
raise ArgumentError, "a client authenticates with a secret or a private key, not both" if client_secret && private_key
|
|
7
21
|
|
|
8
|
-
def initialize(issuer:, client_id:, redirect_uri:, client_secret: nil, scope: DEFAULT_SCOPE)
|
|
9
22
|
@issuer = Issuer.resolve(issuer)
|
|
10
23
|
@client_id = client_id
|
|
11
24
|
@client_secret = client_secret
|
|
25
|
+
@private_key = private_key.is_a?(String) ? OpenSSL::PKey.read(private_key) : private_key
|
|
26
|
+
@key_id = key_id
|
|
12
27
|
@redirect_uri = redirect_uri
|
|
13
28
|
@scope = Array(scope)
|
|
14
29
|
end
|
|
15
30
|
|
|
16
31
|
def start(resource: nil, prompt: nil, scope: nil, state: SecureRandom.urlsafe_base64(24),
|
|
17
|
-
nonce: SecureRandom.urlsafe_base64(24), max_age: nil)
|
|
32
|
+
nonce: SecureRandom.urlsafe_base64(24), max_age: nil, organization: nil)
|
|
18
33
|
pkce = Pkce.generate
|
|
19
34
|
scopes = Array(scope || self.scope)
|
|
35
|
+
organization = Session.organization_key(organization)
|
|
20
36
|
nonce = nil unless scopes.include?("openid")
|
|
21
37
|
|
|
22
38
|
pairs = [
|
|
@@ -34,6 +50,7 @@ module Masks
|
|
|
34
50
|
Array(resource).each { |value| pairs << [ "resource", value ] }
|
|
35
51
|
pairs << [ "prompt", prompt ] if prompt
|
|
36
52
|
pairs << [ "max_age", max_age.to_i ] if max_age
|
|
53
|
+
pairs << [ "organization", organization ] if organization
|
|
37
54
|
|
|
38
55
|
{
|
|
39
56
|
url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
|
|
@@ -54,7 +71,16 @@ module Masks
|
|
|
54
71
|
|
|
55
72
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
56
73
|
|
|
57
|
-
Tokens.granted(
|
|
74
|
+
Tokens.granted(post("token_endpoint", form))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def client_credentials(scope: nil, resource: nil)
|
|
78
|
+
form = [ [ "grant_type", "client_credentials" ], [ "client_id", client_id ] ]
|
|
79
|
+
|
|
80
|
+
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
81
|
+
Array(resource).each { |value| form << [ "resource", value ] }
|
|
82
|
+
|
|
83
|
+
Tokens.granted(post("token_endpoint", form))
|
|
58
84
|
end
|
|
59
85
|
|
|
60
86
|
def refresh(refresh_token, resource: nil, scope: nil)
|
|
@@ -67,17 +93,20 @@ module Masks
|
|
|
67
93
|
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
68
94
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
69
95
|
|
|
70
|
-
Tokens.granted(
|
|
96
|
+
Tokens.granted(post("token_endpoint", form))
|
|
71
97
|
end
|
|
72
98
|
|
|
73
|
-
def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil
|
|
99
|
+
def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil,
|
|
100
|
+
subject_token_type: Tokens::ACCESS_TOKEN, actor_token: nil, actor_token_type: Tokens::ACCESS_TOKEN)
|
|
74
101
|
form = [
|
|
75
102
|
[ "grant_type", Tokens::EXCHANGE ],
|
|
76
103
|
[ "client_id", client_id ],
|
|
77
104
|
[ "subject_token", subject_token ],
|
|
78
|
-
[ "subject_token_type",
|
|
105
|
+
[ "subject_token_type", subject_token_type ]
|
|
79
106
|
]
|
|
80
107
|
|
|
108
|
+
form.push([ "actor_token", actor_token ], [ "actor_token_type", actor_token_type ]) if actor_token
|
|
109
|
+
|
|
81
110
|
form << [ "requested_token_type", requested_token_type ] if requested_token_type
|
|
82
111
|
Array(audience).each { |value| form << [ "audience", value ] }
|
|
83
112
|
|
|
@@ -85,14 +114,14 @@ module Masks
|
|
|
85
114
|
form << [ "requested_lifetime", lifetime.to_i ] if lifetime
|
|
86
115
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
87
116
|
|
|
88
|
-
Tokens.granted(
|
|
117
|
+
Tokens.granted(post("token_endpoint", form))
|
|
89
118
|
end
|
|
90
119
|
|
|
91
120
|
def revoke(token, hint: nil)
|
|
92
121
|
form = [ [ "token", token ], [ "client_id", client_id ] ]
|
|
93
122
|
form << [ "token_type_hint", hint ] if hint
|
|
94
123
|
|
|
95
|
-
|
|
124
|
+
post("revocation_endpoint", form)
|
|
96
125
|
true
|
|
97
126
|
end
|
|
98
127
|
|
|
@@ -114,7 +143,7 @@ module Masks
|
|
|
114
143
|
form << [ "token_type_hint", hint ] if hint
|
|
115
144
|
|
|
116
145
|
Introspection.new(
|
|
117
|
-
|
|
146
|
+
post("introspection_endpoint", form)
|
|
118
147
|
)
|
|
119
148
|
end
|
|
120
149
|
|
|
@@ -140,6 +169,36 @@ module Masks
|
|
|
140
169
|
|
|
141
170
|
private
|
|
142
171
|
|
|
172
|
+
def post(endpoint, form)
|
|
173
|
+
url = issuer.endpoint(endpoint)
|
|
174
|
+
|
|
175
|
+
HTTP.post_form(url, form + assertion, authorization)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def assertion
|
|
179
|
+
return [] if private_key.nil?
|
|
180
|
+
|
|
181
|
+
now = Time.now.to_i
|
|
182
|
+
claims = {
|
|
183
|
+
"iss" => client_id, "sub" => client_id, "aud" => issuer.url,
|
|
184
|
+
"iat" => now, "exp" => now + ASSERTION_LIFETIME, "jti" => SecureRandom.uuid
|
|
185
|
+
}
|
|
186
|
+
header = key_id ? { kid: key_id } : {}
|
|
187
|
+
|
|
188
|
+
[
|
|
189
|
+
[ "client_assertion_type", ASSERTION_TYPE ],
|
|
190
|
+
[ "client_assertion", JWT.encode(claims, private_key, signing_algorithm, header) ]
|
|
191
|
+
]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def signing_algorithm
|
|
195
|
+
return "RS256" if private_key.is_a?(OpenSSL::PKey::RSA)
|
|
196
|
+
|
|
197
|
+
{ "prime256v1" => "ES256", "secp384r1" => "ES384", "secp521r1" => "ES512" }.fetch(private_key.group.curve_name) do
|
|
198
|
+
raise ArgumentError, "#{private_key.group.curve_name} is not a curve masks checks assertions for"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
143
202
|
def authorization
|
|
144
203
|
return {} if client_secret.nil?
|
|
145
204
|
|
data/lib/masks/client/tokens.rb
CHANGED
|
@@ -3,6 +3,7 @@ module Masks
|
|
|
3
3
|
class Tokens
|
|
4
4
|
EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
|
|
5
5
|
ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token".freeze
|
|
6
|
+
ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token".freeze
|
|
6
7
|
|
|
7
8
|
attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at,
|
|
8
9
|
:delegations
|
|
@@ -2,6 +2,9 @@ module Masks
|
|
|
2
2
|
module Client
|
|
3
3
|
class Verifier
|
|
4
4
|
ALGORITHMS = %w[RS256 ES256].freeze
|
|
5
|
+
ACCESS_TOKEN = "at+jwt".freeze
|
|
6
|
+
LOGOUT_TOKEN = "logout+jwt".freeze
|
|
7
|
+
ID_TOKEN = [ "jwt", nil ].freeze
|
|
5
8
|
|
|
6
9
|
attr_reader :issuer, :audience
|
|
7
10
|
|
|
@@ -11,8 +14,8 @@ module Masks
|
|
|
11
14
|
@algorithms = algorithms
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
def verify(token, required: %w[iss sub exp])
|
|
15
|
-
JWT.decode(
|
|
17
|
+
def verify(token, required: %w[iss sub exp], typ: ID_TOKEN)
|
|
18
|
+
claims, header = JWT.decode(
|
|
16
19
|
token, nil, true,
|
|
17
20
|
algorithms: @algorithms,
|
|
18
21
|
jwks: keys,
|
|
@@ -20,17 +23,25 @@ module Masks
|
|
|
20
23
|
aud: audience, verify_aud: true,
|
|
21
24
|
verify_expiration: true,
|
|
22
25
|
required_claims: required
|
|
23
|
-
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
typed!(header["typ"], Array(typ))
|
|
29
|
+
|
|
30
|
+
claims
|
|
24
31
|
rescue JWT::DecodeError => e
|
|
25
32
|
raise InvalidToken, e.message
|
|
26
33
|
end
|
|
27
34
|
|
|
28
|
-
def tenant(token)
|
|
29
|
-
verify(token)["tenant"]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
35
|
private
|
|
33
36
|
|
|
37
|
+
def typed!(held, accepted)
|
|
38
|
+
named = held&.to_s&.downcase&.delete_prefix("application/")
|
|
39
|
+
|
|
40
|
+
return if accepted.include?(named)
|
|
41
|
+
|
|
42
|
+
raise InvalidToken, "that token is typed #{held.inspect}, not #{accepted.compact.join(' or ')}"
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
def keys
|
|
35
46
|
->(options) do
|
|
36
47
|
issuer.refresh! if options[:invalidate]
|
data/lib/masks/client.rb
CHANGED
|
@@ -19,6 +19,7 @@ require_relative "client/claims"
|
|
|
19
19
|
require_relative "client/introspection"
|
|
20
20
|
require_relative "client/session"
|
|
21
21
|
require_relative "client/verifier"
|
|
22
|
+
require_relative "client/proof"
|
|
22
23
|
require_relative "client/logout"
|
|
23
24
|
require_relative "client/resource"
|
|
24
25
|
require_relative "client/rack"
|