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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/LICENSE +1 -1
- data/README.md +110 -32
- data/app/controllers/masks/rails/avatars_controller.rb +51 -0
- data/app/controllers/masks/rails/handshakes_controller.rb +18 -18
- data/app/controllers/masks/rails/logouts_controller.rb +32 -0
- data/app/controllers/masks/rails/sessions_controller.rb +5 -0
- data/app/views/masks/rails/handshakes/show.html.erb +17 -33
- data/config/routes.rb +2 -0
- data/lib/masks/client/claims.rb +41 -0
- data/lib/masks/client/delegations/fake.rb +91 -0
- data/lib/masks/client/delegations.rb +117 -0
- data/lib/masks/client/errors.rb +8 -0
- data/lib/masks/client/handshake.rb +7 -3
- data/lib/masks/client/http.rb +16 -1
- data/lib/masks/client/introspection.rb +1 -1
- data/lib/masks/client/issuer.rb +20 -0
- data/lib/masks/client/logout.rb +60 -0
- data/lib/masks/client/registration.rb +29 -6
- data/lib/masks/client/session.rb +10 -2
- data/lib/masks/client/tokens.rb +3 -1
- data/lib/masks/client.rb +46 -0
- data/lib/masks/rails/authentication.rb +45 -2
- data/lib/masks/rails/configuration.rb +40 -16
- data/lib/masks/rails/credentials.rb +0 -4
- data/lib/masks/rails/engine.rb +0 -5
- data/lib/masks/rails.rb +26 -0
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +17 -0
- metadata +7 -2
|
@@ -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
|
data/lib/masks/client/errors.rb
CHANGED
|
@@ -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
|
|
data/lib/masks/client/http.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/masks/client/issuer.rb
CHANGED
|
@@ -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(
|
|
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
|
data/lib/masks/client/session.rb
CHANGED
|
@@ -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
|
data/lib/masks/client/tokens.rb
CHANGED
|
@@ -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
|