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.
@@ -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
@@ -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
 
@@ -24,6 +24,12 @@ module Masks
24
24
 
25
25
  super
26
26
  end
27
+
28
+ def member!(*roles, organization: nil)
29
+ raise Unauthorized.new("the issuer reports this token is not active") unless active?
30
+
31
+ super
32
+ end
27
33
  end
28
34
  end
29
35
  end
@@ -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
@@ -14,9 +14,7 @@ module Masks
14
14
  end
15
15
 
16
16
  def challenge
17
- Base64.urlsafe_encode64(
18
- OpenSSL::Digest::SHA256.digest(verifier), padding: false
19
- )
17
+ Proof.digest(verifier)
20
18
  end
21
19
 
22
20
  def method
@@ -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
@@ -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(env["HTTP_AUTHORIZATION"], scope: @scope)
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
 
@@ -16,10 +16,6 @@ module Masks
16
16
  @lock.synchronize { @issuers.clear }
17
17
  self
18
18
  end
19
-
20
- def urls
21
- @lock.synchronize { @issuers.keys }
22
- end
23
19
  end
24
20
  end
25
21
  end
@@ -1,14 +1,14 @@
1
1
  module Masks
2
2
  module Client
3
3
  class Resource
4
- BEARER = /\ABearer[ \t]+([^\s,]+)[ \t]*\z/i.freeze
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
- claims = Claims.new(@verifier.verify(token!(authorization), required: @required))
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[BEARER, 1]
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
- "Bearer " + parameters.filter_map { |name, value|
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 token!(authorization)
75
- token(authorization) || raise(Unauthenticated.new)
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
@@ -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
- attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope
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(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
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(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
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", Tokens::ACCESS_TOKEN ]
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(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
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
- HTTP.post_form(issuer.endpoint("revocation_endpoint"), form, authorization)
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
- HTTP.post_form(issuer.endpoint("introspection_endpoint"), form, authorization)
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
 
@@ -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
- ).first
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"