masks 0.6.0 → 0.8.0

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.
@@ -0,0 +1,91 @@
1
+ require "masks/client"
2
+
3
+ module Masks
4
+ module Client
5
+ class Delegations
6
+ class Fake
7
+ Connected = Struct.new(:connection, :provider, :subject, :secret, :refused, :unavailable, keyword_init: true)
8
+
9
+ attr_reader :releases, :redirect_uri
10
+
11
+ def initialize(redirect_uri: "https://app.test/connect/callback", lifetime: 3600)
12
+ @redirect_uri = redirect_uri
13
+ @lifetime = lifetime
14
+ @codes = {}
15
+ @connections = {}
16
+ @releases = 0
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ def start(provider:, prompt: nil, max_age: nil, state: SecureRandom.urlsafe_base64(24))
21
+ query = URI.encode_www_form({ "provider" => provider, "state" => state, "prompt" => prompt, "max_age" => max_age }.compact)
22
+
23
+ { "url" => "https://masks.fake/authorize?#{query}", "state" => state, "verifier" => SecureRandom.hex(16), "provider" => provider.to_s }
24
+ end
25
+
26
+ def approve(started, subject: "fake-subject", connection: SecureRandom.uuid)
27
+ code = SecureRandom.hex(12)
28
+
29
+ @lock.synchronize do
30
+ @codes[code] = { "provider" => started["provider"], "subject" => subject, "connection" => connection }
31
+ end
32
+
33
+ { "code" => code, "state" => started["state"] }
34
+ end
35
+
36
+ def deny(started, error: "access_denied", description: "the person declined")
37
+ { "error" => error, "error_description" => description, "state" => started["state"] }
38
+ end
39
+
40
+ def finish(params:, started:)
41
+ params = params.to_h.transform_keys(&:to_s)
42
+
43
+ raise Refused.new(params["error"], params["error_description"]) if params["error"].to_s != ""
44
+ raise Refused.new("invalid_state", "the state did not match the one this connection started with") unless params["state"] == started["state"]
45
+
46
+ granted = @lock.synchronize { @codes.delete(params["code"]) }
47
+
48
+ raise Refused.new("invalid_grant", "that code is not valid") if granted.nil?
49
+
50
+ secret = SecureRandom.hex(16)
51
+
52
+ @lock.synchronize do
53
+ @connections[granted["connection"]] = Connected.new(
54
+ connection: granted["connection"], provider: granted["provider"], subject: granted["subject"], secret: secret
55
+ )
56
+ end
57
+
58
+ Held.new(connection: granted["connection"], provider: granted["provider"], provider_name: granted["provider"].to_s.capitalize,
59
+ label: nil, subject: granted["subject"], secret: secret)
60
+ end
61
+
62
+ def token(secret, connection:)
63
+ held = @lock.synchronize { @connections[connection.to_s] }
64
+
65
+ raise Refused.new("invalid_grant", "that connection is unknown") if held.nil?
66
+ raise Unavailable.new("masks is not answering", secret: secret) if held.unavailable
67
+ raise Refused.new("invalid_grant", held.refused, secret: secret) if held.refused
68
+ raise Refused.new("invalid_grant", "that refresh token is not valid or has expired") unless held.secret == secret.to_s
69
+
70
+ rotated = SecureRandom.hex(16)
71
+
72
+ @lock.synchronize do
73
+ held.secret = rotated
74
+ @releases += 1
75
+ end
76
+
77
+ Upstream.new(access_token: "#{held.provider}-access-#{@releases}", expires_at: Time.now.to_i + @lifetime,
78
+ scope: "", secret: rotated)
79
+ end
80
+
81
+ def revoke(connection, reason: "the person stopped this application using that account")
82
+ @lock.synchronize { @connections.fetch(connection.to_s).refused = reason }
83
+ end
84
+
85
+ def unavailable(connection, now: true)
86
+ @lock.synchronize { @connections.fetch(connection.to_s).unavailable = now }
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,117 @@
1
+ module Masks
2
+ module Client
3
+ class Delegations
4
+ SCOPE = "masks:delegate:".freeze
5
+ UPSTREAM_ACCESS_TOKEN = "urn:masks:params:oauth:token-type:upstream_access_token".freeze
6
+ REFUSALS = %w[invalid_grant insufficient_scope invalid_target unauthorized_client access_denied
7
+ login_required interaction_required consent_required invalid_scope].freeze
8
+
9
+ class Refused < Error
10
+ attr_reader :code, :description, :secret
11
+
12
+ def initialize(code, description, secret: nil)
13
+ super([ code, description ].compact.join(": "))
14
+
15
+ @code = code
16
+ @description = description
17
+ @secret = secret
18
+ end
19
+
20
+ def signed_in_again?
21
+ %w[login_required interaction_required].include?(code)
22
+ end
23
+ end
24
+
25
+ class Unavailable < Error
26
+ attr_reader :secret
27
+
28
+ def initialize(message, secret: nil)
29
+ super(message)
30
+
31
+ @secret = secret
32
+ end
33
+ end
34
+
35
+ Held = Struct.new(:connection, :provider, :provider_name, :label, :subject, :secret, keyword_init: true)
36
+
37
+ Upstream = Struct.new(:access_token, :expires_at, :scope, :secret, keyword_init: true) do
38
+ def expired?(leeway: 60)
39
+ Time.now.to_i + leeway >= expires_at.to_i
40
+ end
41
+ end
42
+
43
+ attr_reader :issuer, :client_id, :client_secret, :redirect_uri
44
+
45
+ def initialize(issuer:, client_id:, client_secret:, redirect_uri:)
46
+ @issuer = Issuer.resolve(issuer)
47
+ @client_id = client_id
48
+ @client_secret = client_secret
49
+ @redirect_uri = redirect_uri
50
+ end
51
+
52
+ def start(provider:, prompt: nil, max_age: nil, state: SecureRandom.urlsafe_base64(24))
53
+ started = session.start(scope: [ "openid", "offline_access", "#{SCOPE}#{provider}" ], prompt: prompt,
54
+ max_age: max_age, state: state)
55
+
56
+ { "url" => started[:url], "state" => started[:state], "verifier" => started[:verifier], "provider" => provider.to_s }
57
+ end
58
+
59
+ def finish(params:, started:)
60
+ params = params.to_h.transform_keys(&:to_s)
61
+ started = started.to_h.transform_keys(&:to_s)
62
+
63
+ raise Refused.new(params["error"], params["error_description"]) if params["error"].to_s != ""
64
+
65
+ unless params["state"].to_s != "" && OpenSSL.secure_compare(params["state"].to_s, started["state"].to_s)
66
+ raise Refused.new("invalid_state", "the state did not match the one this connection started with")
67
+ end
68
+
69
+ tokens = answered { session.complete(code: params["code"].to_s, verifier: started["verifier"].to_s) }
70
+ held = tokens.delegations.find { |one| one["provider"] == started["provider"] }
71
+
72
+ raise Refused.new("access_denied", "masks connected nothing for #{started['provider']}") if held.nil?
73
+ raise Refused.new("invalid_grant", "masks issued no refresh token to keep the connection with") if tokens.refresh_token.to_s == ""
74
+
75
+ Held.new(
76
+ connection: held["connection"], provider: held["provider"], provider_name: held["provider_name"],
77
+ label: held["label"], subject: held["subject"], secret: tokens.refresh_token
78
+ )
79
+ end
80
+
81
+ def token(secret, connection:)
82
+ refreshed = answered { session.refresh(secret.to_s) }
83
+ rotated = refreshed.refresh_token.to_s == "" ? secret : refreshed.refresh_token
84
+
85
+ released = answered(secret: rotated) do
86
+ session.exchange(refreshed.access_token, requested_token_type: UPSTREAM_ACCESS_TOKEN, audience: connection.to_s)
87
+ end
88
+
89
+ Upstream.new(access_token: released.access_token, expires_at: released.expires_at, scope: released.scope, secret: rotated)
90
+ end
91
+
92
+ private
93
+
94
+ def session
95
+ @session ||= Session.new(issuer: issuer, client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri)
96
+ end
97
+
98
+ def answered(secret: nil)
99
+ yield
100
+ rescue Unregistered
101
+ raise
102
+ rescue Rejected => e
103
+ raise Unavailable.new("masks answered #{e.status}: #{e.message}", secret: secret) if unavailable?(e)
104
+
105
+ raise Refused.new(e.code, e.description, secret: secret)
106
+ rescue Unreachable => e
107
+ raise Unavailable.new(e.message, secret: secret)
108
+ end
109
+
110
+ def unavailable?(rejection)
111
+ return false if REFUSALS.include?(rejection.code)
112
+
113
+ rejection.code == "temporarily_unavailable" || rejection.status.to_i >= 500 || rejection.status.to_i == 429
114
+ end
115
+ end
116
+ end
117
+ end
@@ -5,16 +5,18 @@ module Masks
5
5
  GRANT_TYPES = %w[authorization_code refresh_token].freeze
6
6
  AUTH_METHOD = "client_secret_basic".freeze
7
7
 
8
- attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to
8
+ attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to,
9
+ :backchannel_logout_uri
9
10
 
10
11
  def initialize(issuer, name:, resource:, redirect_uris:, return_to:,
11
- scope: Session::DEFAULT_SCOPE)
12
+ scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil)
12
13
  @issuer = Issuer.resolve(issuer)
13
14
  @name = name.to_s
14
15
  @resource = resource.to_s
15
16
  @redirect_uris = Array(redirect_uris).map(&:to_s)
16
17
  @scope = Array(scope).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?)
17
18
  @return_to = return_to.to_s
19
+ @backchannel_logout_uri = backchannel_logout_uri&.to_s
18
20
  end
19
21
 
20
22
  def endpoint
@@ -34,6 +36,7 @@ module Masks
34
36
  [ "state", state ]
35
37
  ]
36
38
 
39
+ query << [ "backchannel_logout_uri", backchannel_logout_uri ] if backchannel_logout_uri
37
40
  redirect_uris.each { |uri| query << [ "redirect_uris", uri ] }
38
41
 
39
42
  "#{endpoint}?#{URI.encode_www_form(query)}"
@@ -61,7 +64,8 @@ module Masks
61
64
  redirect_uris: redirect_uris,
62
65
  grant_types: GRANT_TYPES,
63
66
  scope: scope,
64
- token_endpoint_auth_method: AUTH_METHOD
67
+ token_endpoint_auth_method: AUTH_METHOD,
68
+ backchannel_logout_uri: backchannel_logout_uri
65
69
  )
66
70
  end
67
71
 
@@ -5,7 +5,7 @@ module Masks
5
5
  to_h["active"] == true
6
6
  end
7
7
 
8
- def username
8
+ def nickname
9
9
  self["username"]
10
10
  end
11
11
 
@@ -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
@@ -51,6 +51,10 @@ module Masks
51
51
  discovery.fetch(name) { raise Rejected.new("invalid_issuer", "#{url} publishes no #{name}") }
52
52
  end
53
53
 
54
+ def backchannel_logout?
55
+ discovery["backchannel_logout_supported"] == true
56
+ end
57
+
54
58
  def avatar_styles
55
59
  discovery["avatar_styles_supported"] || Claims::Avatars::STYLES
56
60
  end
@@ -0,0 +1,60 @@
1
+ module Masks
2
+ module Client
3
+ class Logout
4
+ EVENT = "http://schemas.openid.net/event/backchannel-logout".freeze
5
+ ALGORITHMS = Verifier::ALGORITHMS
6
+ LEEWAY = 60
7
+
8
+ class << self
9
+ def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
10
+ held = Verifier
11
+ .new(issuer, audience: audience, algorithms: algorithms)
12
+ .verify(token, required: %w[iss aud iat jti events], typ: Verifier::LOGOUT_TOKEN)
13
+
14
+ new(held).validate!
15
+ end
16
+ end
17
+
18
+ attr_reader :claims
19
+
20
+ def initialize(claims)
21
+ @claims = claims
22
+ end
23
+
24
+ def subject
25
+ claims["sub"]
26
+ end
27
+
28
+ def sid
29
+ claims["sid"]
30
+ end
31
+
32
+ def jti
33
+ claims["jti"]
34
+ end
35
+
36
+ def issued_at
37
+ Time.at(claims["iat"].to_i).utc
38
+ end
39
+
40
+ def validate!
41
+ refuse!("logout token carries a nonce, so it is an id token") if claims.key?("nonce")
42
+ refuse!("logout token names neither a subject nor a session") if subject.nil? && sid.nil?
43
+ refuse!("logout token was issued in the future") if issued_at > Time.now.utc + LEEWAY
44
+
45
+ held = claims["events"]
46
+
47
+ refuse!("logout token has no events claim") unless held.is_a?(Hash)
48
+ refuse!("logout token is not about a logout") unless held[EVENT].is_a?(Hash)
49
+
50
+ self
51
+ end
52
+
53
+ private
54
+
55
+ def refuse!(said)
56
+ raise InvalidToken, said
57
+ end
58
+ end
59
+ end
60
+ end
@@ -23,7 +23,8 @@ module Masks
23
23
  "token_endpoint_auth_method" => attributes[:token_endpoint_auth_method],
24
24
  "application_type" => attributes[:application_type],
25
25
  "client_uri" => attributes[:client_uri],
26
- "logo_uri" => attributes[:logo_uri]
26
+ "logo_uri" => attributes[:logo_uri],
27
+ "backchannel_logout_uri" => attributes[:backchannel_logout_uri]
27
28
  }.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
28
29
  end
29
30
 
@@ -32,8 +33,6 @@ module Masks
32
33
  list.empty? ? default : list
33
34
  end
34
35
 
35
- # Rebuild a registration from what a consumer stored, so RFC 7592's read,
36
- # update and delete are reachable without having just created it.
37
36
  def self.held(issuer, credentials)
38
37
  held = credentials.to_h.transform_keys(&:to_s)
39
38
  return nil if held["registration_client_uri"].blank? || held["registration_access_token"].blank?
@@ -18,10 +18,11 @@ module Masks
18
18
  @verifier = verifier || Verifier.new(@issuer, audience: @url, algorithms: algorithms)
19
19
  end
20
20
 
21
- def authenticate(authorization, scope: nil)
22
- claims = Claims.new(@verifier.verify(token!(authorization), required: @required))
21
+ def authenticate(authorization, scope: nil, role: nil, organization: nil)
22
+ claims = Claims.new(@verifier.verify(token!(authorization), required: @required, typ: Verifier::ACCESS_TOKEN))
23
23
 
24
24
  Array(scope).each { |name| claims.permit!(name) }
25
+ claims.member!(*Array(role), organization: organization) if role || organization
25
26
 
26
27
  claims
27
28
  rescue InvalidToken => e
@@ -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))
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 = [
@@ -33,6 +49,8 @@ module Masks
33
49
 
34
50
  Array(resource).each { |value| pairs << [ "resource", value ] }
35
51
  pairs << [ "prompt", prompt ] if prompt
52
+ pairs << [ "max_age", max_age.to_i ] if max_age
53
+ pairs << [ "organization", organization ] if organization
36
54
 
37
55
  {
38
56
  url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
@@ -53,7 +71,16 @@ module Masks
53
71
 
54
72
  Array(resource).each { |value| form << [ "resource", value ] }
55
73
 
56
- 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))
57
84
  end
58
85
 
59
86
  def refresh(refresh_token, resource: nil, scope: nil)
@@ -66,32 +93,42 @@ module Masks
66
93
  form << [ "scope", Array(scope).join(" ") ] if scope
67
94
  Array(resource).each { |value| form << [ "resource", value ] }
68
95
 
69
- Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
96
+ Tokens.granted(post("token_endpoint", form))
70
97
  end
71
98
 
72
- def exchange(subject_token, scope: nil, resource: nil, lifetime: 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)
73
101
  form = [
74
102
  [ "grant_type", Tokens::EXCHANGE ],
75
103
  [ "client_id", client_id ],
76
104
  [ "subject_token", subject_token ],
77
- [ "subject_token_type", Tokens::ACCESS_TOKEN ]
105
+ [ "subject_token_type", subject_token_type ]
78
106
  ]
79
107
 
108
+ form.push([ "actor_token", actor_token ], [ "actor_token_type", actor_token_type ]) if actor_token
109
+
110
+ form << [ "requested_token_type", requested_token_type ] if requested_token_type
111
+ Array(audience).each { |value| form << [ "audience", value ] }
112
+
80
113
  form << [ "scope", Array(scope).join(" ") ] if scope
81
114
  form << [ "requested_lifetime", lifetime.to_i ] if lifetime
82
115
  Array(resource).each { |value| form << [ "resource", value ] }
83
116
 
84
- Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
117
+ Tokens.granted(post("token_endpoint", form))
85
118
  end
86
119
 
87
120
  def revoke(token, hint: nil)
88
121
  form = [ [ "token", token ], [ "client_id", client_id ] ]
89
122
  form << [ "token_type_hint", hint ] if hint
90
123
 
91
- HTTP.post_form(issuer.endpoint("revocation_endpoint"), form, authorization)
124
+ post("revocation_endpoint", form)
92
125
  true
93
126
  end
94
127
 
128
+ def logout_token(token)
129
+ Logout.verify(token, issuer: issuer, audience: client_id)
130
+ end
131
+
95
132
  def end_session_url(post_logout_redirect_uri: nil, state: nil, id_token_hint: nil)
96
133
  pairs = [ [ "client_id", client_id ] ]
97
134
  pairs << [ "id_token_hint", id_token_hint ] if id_token_hint
@@ -106,7 +143,7 @@ module Masks
106
143
  form << [ "token_type_hint", hint ] if hint
107
144
 
108
145
  Introspection.new(
109
- HTTP.post_form(issuer.endpoint("introspection_endpoint"), form, authorization)
146
+ post("introspection_endpoint", form)
110
147
  )
111
148
  end
112
149
 
@@ -132,6 +169,36 @@ module Masks
132
169
 
133
170
  private
134
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
+
135
202
  def authorization
136
203
  return {} if client_secret.nil?
137
204
 
@@ -3,8 +3,10 @@ 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
- attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at
8
+ attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at,
9
+ :delegations
8
10
 
9
11
  def self.granted(body)
10
12
  token = new(body)
@@ -26,6 +28,7 @@ module Masks
26
28
  @token_type = body["token_type"] || "Bearer"
27
29
  @scope = body["scope"].to_s
28
30
  @expires_in = body["expires_in"].to_i
31
+ @delegations = Array(body["delegations"])
29
32
  @obtained_at = Time.now.to_i
30
33
  end
31
34
 
@@ -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,7 +23,11 @@ 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
@@ -31,6 +38,14 @@ module Masks
31
38
 
32
39
  private
33
40
 
41
+ def typed!(held, accepted)
42
+ named = held&.to_s&.downcase&.delete_prefix("application/")
43
+
44
+ return if accepted.include?(named)
45
+
46
+ raise InvalidToken, "that token is typed #{held.inspect}, not #{accepted.compact.join(' or ')}"
47
+ end
48
+
34
49
  def keys
35
50
  ->(options) do
36
51
  issuer.refresh! if options[:invalidate]