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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/LICENSE +1 -1
- data/README.md +136 -32
- data/app/controllers/masks/rails/handshakes_controller.rb +13 -12
- data/app/controllers/masks/rails/logouts_controller.rb +32 -0
- data/app/controllers/masks/rails/sessions_controller.rb +3 -1
- data/app/views/masks/rails/handshakes/show.html.erb +17 -33
- data/config/routes.rb +1 -0
- data/lib/masks/client/claims.rb +69 -0
- data/lib/masks/client/delegations/fake.rb +91 -0
- data/lib/masks/client/delegations.rb +117 -0
- data/lib/masks/client/handshake.rb +7 -3
- data/lib/masks/client/introspection.rb +7 -1
- data/lib/masks/client/issuer.rb +4 -0
- data/lib/masks/client/logout.rb +60 -0
- data/lib/masks/client/registration.rb +2 -3
- data/lib/masks/client/resource.rb +3 -2
- data/lib/masks/client/session.rb +77 -10
- data/lib/masks/client/tokens.rb +4 -1
- data/lib/masks/client/verifier.rb +18 -3
- data/lib/masks/client.rb +46 -0
- data/lib/masks/rails/authentication.rb +78 -11
- data/lib/masks/rails/configuration.rb +36 -20
- data/lib/masks/rails/credentials.rb +0 -4
- data/lib/masks/rails/engine.rb +0 -5
- data/lib/masks/rails/protected_resource.rb +5 -4
- data/lib/masks/rails.rb +26 -0
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +17 -0
- metadata +8 -3
|
@@ -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
|
|
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
|
data/lib/masks/client/issuer.rb
CHANGED
|
@@ -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
|
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))
|
|
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(
|
|
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(
|
|
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",
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/masks/client/tokens.rb
CHANGED
|
@@ -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
|
-
)
|
|
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]
|