masks 0.5.0 → 0.7.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
@@ -16,6 +16,14 @@ module Masks
16
16
  end
17
17
  end
18
18
 
19
+ class Unregistered < Rejected
20
+ CODE = "invalid_client".freeze
21
+
22
+ def self.raised_by?(body)
23
+ body["error"].to_s == CODE
24
+ end
25
+ end
26
+
19
27
  class InvalidToken < Error; end
20
28
 
21
29
  class Challenge < Error
@@ -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
 
@@ -31,6 +31,19 @@ module Masks
31
31
  request(Net::HTTP::Delete.new(URI.parse(url.to_s), default_headers.merge(headers)))
32
32
  end
33
33
 
34
+ def fetch(url, headers = {})
35
+ uri = URI.parse(url.to_s)
36
+
37
+ Net::HTTP.start(
38
+ uri.hostname, uri.port,
39
+ use_ssl: uri.scheme == "https",
40
+ open_timeout: OPEN_TIMEOUT,
41
+ read_timeout: READ_TIMEOUT
42
+ ) { |http| http.request(Net::HTTP::Get.new(uri, headers)) }
43
+ rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
44
+ raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
45
+ end
46
+
34
47
  def json(verb, url, body, headers)
35
48
  uri = URI.parse(url.to_s)
36
49
  request = verb.new(uri, default_headers.merge(headers))
@@ -67,7 +80,9 @@ module Masks
67
80
  end
68
81
 
69
82
  unless response.is_a?(Net::HTTPSuccess)
70
- raise Rejected.new(
83
+ refusal = Unregistered.raised_by?(body) ? Unregistered : Rejected
84
+
85
+ raise refusal.new(
71
86
  body["error"] || "http_#{response.code}",
72
87
  body["error_description"] || response.message,
73
88
  status: response.code.to_i
@@ -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
 
@@ -51,6 +51,26 @@ 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
+
58
+ def avatar_styles
59
+ discovery["avatar_styles_supported"] || Claims::Avatars::STYLES
60
+ end
61
+
62
+ def avatar_url(subject, style: nil, size: nil)
63
+ wanted = (style || Claims::Avatars::FALLBACK).to_s
64
+
65
+ unless avatar_styles.include?(wanted)
66
+ raise Rejected.new("invalid_style", "#{url} does not serve #{wanted} avatars")
67
+ end
68
+
69
+ query = size ? "?size=#{size.to_i}" : ""
70
+
71
+ "#{endpoint('avatar_endpoint')}/#{subject}/#{wanted}#{query}"
72
+ end
73
+
54
74
  def refresh!
55
75
  @lock.synchronize { @cache = {} }
56
76
  self
@@ -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])
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?
@@ -59,18 +58,30 @@ module Masks
59
58
  metadata["registration_client_uri"]
60
59
  end
61
60
 
61
+ REFUSED = [ 401, 403 ].freeze
62
+ GONE = (REFUSED + [ 404 ]).freeze
63
+
62
64
  def read
63
- HTTP.get(uri, authorization)
65
+ still_ours(REFUSED) { HTTP.get(uri, authorization) }
64
66
  end
65
67
 
66
68
  def update(**attributes)
67
- @metadata = metadata.merge(HTTP.put_json(uri, self.class.stringify(attributes), authorization))
69
+ @metadata = metadata.merge(
70
+ still_ours(REFUSED) { HTTP.put_json(uri, self.class.stringify(attributes), authorization) }
71
+ )
68
72
  self
69
73
  end
70
74
 
71
75
  def delete
72
- HTTP.delete(uri, authorization)
76
+ still_ours(GONE) { HTTP.delete(uri, authorization) }
77
+ true
78
+ end
79
+
80
+ def known?
81
+ read
73
82
  true
83
+ rescue Unregistered
84
+ false
74
85
  end
75
86
 
76
87
  def authorization
@@ -86,6 +97,18 @@ module Masks
86
97
  scope: scope
87
98
  )
88
99
  end
100
+
101
+ private
102
+
103
+ def still_ours(statuses)
104
+ yield
105
+ rescue Unregistered
106
+ raise
107
+ rescue Rejected => e
108
+ raise unless statuses.include?(e.status)
109
+
110
+ raise Unregistered.new(Unregistered::CODE, e.description, status: e.status)
111
+ end
89
112
  end
90
113
  end
91
114
  end
@@ -14,7 +14,7 @@ module Masks
14
14
  end
15
15
 
16
16
  def start(resource: nil, prompt: nil, scope: nil, state: SecureRandom.urlsafe_base64(24),
17
- nonce: SecureRandom.urlsafe_base64(24))
17
+ nonce: SecureRandom.urlsafe_base64(24), max_age: nil)
18
18
  pkce = Pkce.generate
19
19
  scopes = Array(scope || self.scope)
20
20
  nonce = nil unless scopes.include?("openid")
@@ -33,6 +33,7 @@ module Masks
33
33
 
34
34
  Array(resource).each { |value| pairs << [ "resource", value ] }
35
35
  pairs << [ "prompt", prompt ] if prompt
36
+ pairs << [ "max_age", max_age.to_i ] if max_age
36
37
 
37
38
  {
38
39
  url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
@@ -69,7 +70,7 @@ module Masks
69
70
  Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
70
71
  end
71
72
 
72
- def exchange(subject_token, scope: nil, resource: nil, lifetime: nil)
73
+ def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil)
73
74
  form = [
74
75
  [ "grant_type", Tokens::EXCHANGE ],
75
76
  [ "client_id", client_id ],
@@ -77,6 +78,9 @@ module Masks
77
78
  [ "subject_token_type", Tokens::ACCESS_TOKEN ]
78
79
  ]
79
80
 
81
+ form << [ "requested_token_type", requested_token_type ] if requested_token_type
82
+ Array(audience).each { |value| form << [ "audience", value ] }
83
+
80
84
  form << [ "scope", Array(scope).join(" ") ] if scope
81
85
  form << [ "requested_lifetime", lifetime.to_i ] if lifetime
82
86
  Array(resource).each { |value| form << [ "resource", value ] }
@@ -92,6 +96,10 @@ module Masks
92
96
  true
93
97
  end
94
98
 
99
+ def logout_token(token)
100
+ Logout.verify(token, issuer: issuer, audience: client_id)
101
+ end
102
+
95
103
  def end_session_url(post_logout_redirect_uri: nil, state: nil, id_token_hint: nil)
96
104
  pairs = [ [ "client_id", client_id ] ]
97
105
  pairs << [ "id_token_hint", id_token_hint ] if id_token_hint
@@ -4,7 +4,8 @@ module Masks
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
6
 
7
- attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at
7
+ attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at,
8
+ :delegations
8
9
 
9
10
  def self.granted(body)
10
11
  token = new(body)
@@ -26,6 +27,7 @@ module Masks
26
27
  @token_type = body["token_type"] || "Bearer"
27
28
  @scope = body["scope"].to_s
28
29
  @expires_in = body["expires_in"].to_i
30
+ @delegations = Array(body["delegations"])
29
31
  @obtained_at = Time.now.to_i
30
32
  end
31
33
 
data/lib/masks/client.rb CHANGED
@@ -19,33 +19,79 @@ 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/logout"
22
23
  require_relative "client/resource"
23
24
  require_relative "client/rack"
24
25
  require_relative "client/registration"
25
26
  require_relative "client/handshake"
27
+ require_relative "client/delegations"
26
28
 
27
29
  module Masks
30
+ # = Masks::Client
31
+ #
32
+ # The protocol half of the gem: plain Ruby, no \Rails, no database. Every
33
+ # entry point here is a class method that builds one of the objects below.
34
+ #
35
+ # Which one you want depends on what the application is doing:
36
+ #
37
+ # [issuer] an app signing people in — discovery, PKCE, the code exchange
38
+ # [verifier] an API checking a bearer it was handed
39
+ # [resource] an API publishing what it is and which scopes it takes
40
+ # [handshake] an app registering itself, once, without a copied secret
41
+ #
42
+ # Everything reachable from here talks HTTP to a masks issuer and holds no
43
+ # state of its own beyond the discovery cache in ::registry.
28
44
  module Client
29
45
  class << self
46
+ # The process-wide cache of resolved issuers, so discovery is fetched
47
+ # once rather than per request.
48
+ #
49
+ # @return [Masks::Client::Registry]
30
50
  def registry
31
51
  @registry ||= Registry.new
32
52
  end
33
53
 
54
+ # Resolves +url+ through its discovery document and returns the issuer
55
+ # it describes. Cached in ::registry, so calling this per request is
56
+ # cheap after the first.
57
+ #
58
+ # issuer = Masks::Client.issuer("https://auth.example")
59
+ # issuer.authorization_url(client_id: id, redirect_uri: uri)
60
+ #
61
+ # @return [Masks::Client::Issuer]
34
62
  def issuer(url, **options)
35
63
  Issuer.resolve(url, **options)
36
64
  end
37
65
 
66
+ # Checks tokens minted by the issuer at +url+ against +audience+ — the
67
+ # API's own URL, which the token names in +aud+. A token issued for
68
+ # anything else is refused rather than merely noted.
69
+ #
70
+ # @return [Masks::Client::Verifier]
38
71
  def verifier(url, audience:, **options)
39
72
  Verifier.new(issuer(url), audience: audience, **options)
40
73
  end
41
74
 
75
+ # Describes this API to callers: the scopes it accepts and the issuer
76
+ # that may mint tokens for it, served as RFC 9728 metadata.
77
+ #
78
+ # @return [Masks::Client::Resource]
42
79
  def resource(url, issuer:, **options)
43
80
  Resource.new(issuer: issuer, url: url, **options)
44
81
  end
45
82
 
83
+ # Registers this application against a masks issuer. A person approves
84
+ # it in their browser and the credentials come back server to server, so
85
+ # no secret is pasted between the two.
86
+ #
87
+ # @return [Masks::Client::Handshake]
46
88
  def handshake(url, **options)
47
89
  Handshake.new(url, **options)
48
90
  end
91
+
92
+ def delegations(issuer, **options)
93
+ Delegations.new(issuer: issuer, **options)
94
+ end
49
95
  end
50
96
  end
51
97
  end