masks 0.3.2 → 0.5.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 +16 -0
- data/LICENSE +21 -0
- data/README.md +215 -29
- data/app/controllers/masks/rails/base_controller.rb +13 -0
- data/app/controllers/masks/rails/handshakes_controller.rb +87 -0
- data/app/controllers/masks/rails/sessions_controller.rb +113 -0
- data/app/views/layouts/masks/rails/plain.html.erb +47 -0
- data/app/views/masks/rails/handshakes/refused.html.erb +12 -0
- data/app/views/masks/rails/handshakes/show.html.erb +40 -0
- data/config/routes.rb +10 -45
- data/lib/generators/masks/install/install_generator.rb +35 -22
- data/lib/generators/masks/install/templates/masks.rb.tt +38 -0
- data/lib/masks/client/claims.rb +103 -0
- data/lib/masks/client/errors.rb +52 -0
- data/lib/masks/client/handshake.rb +104 -0
- data/lib/masks/client/http.rb +81 -0
- data/lib/masks/client/introspection.rb +29 -0
- data/lib/masks/client/issuer.rb +76 -0
- data/lib/masks/client/pkce.rb +27 -0
- data/lib/masks/client/rack.rb +62 -0
- data/lib/masks/client/registration.rb +91 -0
- data/lib/masks/client/registry.rb +25 -0
- data/lib/masks/client/resource.rb +92 -0
- data/lib/masks/client/session.rb +146 -0
- data/lib/masks/client/stores.rb +40 -0
- data/lib/masks/client/tokens.rb +70 -0
- data/lib/masks/client/tracker.rb +122 -0
- data/lib/masks/client/verifier.rb +42 -0
- data/lib/masks/client.rb +51 -0
- data/lib/masks/rails/authentication.rb +217 -0
- data/lib/masks/rails/configurable.rb +11 -0
- data/lib/masks/rails/configuration.rb +172 -0
- data/lib/masks/rails/credentials.rb +60 -0
- data/lib/masks/rails/engine.rb +20 -0
- data/lib/masks/rails/protected_resource.rb +51 -0
- data/lib/masks/rails.rb +10 -0
- data/lib/masks/version.rb +1 -3
- data/lib/masks.rb +3 -182
- metadata +47 -368
- data/MIT-LICENSE +0 -20
- data/Rakefile +0 -11
- data/app/assets/builds/application.css +0 -4764
- data/app/assets/builds/application.js +0 -8236
- data/app/assets/builds/application.js.map +0 -7
- data/app/assets/builds/masks/application.css +0 -1
- data/app/assets/builds/masks/application.js +0 -8462
- data/app/assets/builds/masks/application.js.map +0 -7
- data/app/assets/images/masks.png +0 -0
- data/app/assets/javascripts/application.js +0 -2
- data/app/assets/javascripts/controllers/application.js +0 -9
- data/app/assets/javascripts/controllers/emails_controller.js +0 -28
- data/app/assets/javascripts/controllers/index.js +0 -12
- data/app/assets/javascripts/controllers/keys_controller.js +0 -20
- data/app/assets/javascripts/controllers/recover_controller.js +0 -21
- data/app/assets/javascripts/controllers/recover_password_controller.js +0 -21
- data/app/assets/javascripts/controllers/session_controller.js +0 -94
- data/app/assets/manifest.js +0 -2
- data/app/assets/masks_manifest.js +0 -2
- data/app/assets/stylesheets/application.css +0 -26
- data/app/controllers/concerns/masks/controller.rb +0 -114
- data/app/controllers/masks/actors_controller.rb +0 -15
- data/app/controllers/masks/application_controller.rb +0 -35
- data/app/controllers/masks/backup_codes_controller.rb +0 -34
- data/app/controllers/masks/debug_controller.rb +0 -9
- data/app/controllers/masks/devices_controller.rb +0 -20
- data/app/controllers/masks/emails_controller.rb +0 -60
- data/app/controllers/masks/error_controller.rb +0 -14
- data/app/controllers/masks/keys_controller.rb +0 -45
- data/app/controllers/masks/manage/actor_controller.rb +0 -35
- data/app/controllers/masks/manage/actors_controller.rb +0 -12
- data/app/controllers/masks/manage/base_controller.rb +0 -12
- data/app/controllers/masks/one_time_code_controller.rb +0 -49
- data/app/controllers/masks/passwords_controller.rb +0 -33
- data/app/controllers/masks/recoveries_controller.rb +0 -43
- data/app/controllers/masks/sessions_controller.rb +0 -53
- data/app/helpers/masks/application_helper.rb +0 -49
- data/app/jobs/masks/application_job.rb +0 -7
- data/app/jobs/masks/expire_actors_job.rb +0 -15
- data/app/jobs/masks/expire_recoveries_job.rb +0 -15
- data/app/mailers/masks/actor_mailer.rb +0 -22
- data/app/mailers/masks/application_mailer.rb +0 -15
- data/app/models/concerns/masks/access.rb +0 -162
- data/app/models/concerns/masks/actor.rb +0 -132
- data/app/models/concerns/masks/adapter.rb +0 -68
- data/app/models/concerns/masks/role.rb +0 -9
- data/app/models/concerns/masks/scoped.rb +0 -54
- data/app/models/masks/access/actor_password.rb +0 -20
- data/app/models/masks/access/actor_scopes.rb +0 -18
- data/app/models/masks/access/actor_signup.rb +0 -22
- data/app/models/masks/actors/anonymous.rb +0 -40
- data/app/models/masks/actors/system.rb +0 -24
- data/app/models/masks/adapters/active_record.rb +0 -85
- data/app/models/masks/application_model.rb +0 -15
- data/app/models/masks/application_record.rb +0 -8
- data/app/models/masks/check.rb +0 -192
- data/app/models/masks/credential.rb +0 -166
- data/app/models/masks/credentials/backup_code.rb +0 -30
- data/app/models/masks/credentials/device.rb +0 -59
- data/app/models/masks/credentials/email.rb +0 -48
- data/app/models/masks/credentials/factor2.rb +0 -71
- data/app/models/masks/credentials/key.rb +0 -38
- data/app/models/masks/credentials/last_login.rb +0 -12
- data/app/models/masks/credentials/masquerade.rb +0 -32
- data/app/models/masks/credentials/nickname.rb +0 -63
- data/app/models/masks/credentials/one_time_code.rb +0 -34
- data/app/models/masks/credentials/password.rb +0 -28
- data/app/models/masks/credentials/recovery.rb +0 -71
- data/app/models/masks/credentials/session.rb +0 -67
- data/app/models/masks/device.rb +0 -30
- data/app/models/masks/error.rb +0 -51
- data/app/models/masks/event.rb +0 -14
- data/app/models/masks/mask.rb +0 -255
- data/app/models/masks/rails/actor.rb +0 -190
- data/app/models/masks/rails/actor_role.rb +0 -12
- data/app/models/masks/rails/device.rb +0 -47
- data/app/models/masks/rails/email.rb +0 -96
- data/app/models/masks/rails/key.rb +0 -61
- data/app/models/masks/rails/recovery.rb +0 -116
- data/app/models/masks/rails/role.rb +0 -20
- data/app/models/masks/rails/scope.rb +0 -15
- data/app/models/masks/session.rb +0 -447
- data/app/models/masks/sessions/access.rb +0 -26
- data/app/models/masks/sessions/inline.rb +0 -16
- data/app/models/masks/sessions/request.rb +0 -42
- data/app/resources/masks/actor_resource.rb +0 -9
- data/app/resources/masks/session_resource.rb +0 -15
- data/app/views/layouts/masks/application.html.erb +0 -17
- data/app/views/layouts/masks/mailer.html.erb +0 -17
- data/app/views/layouts/masks/mailer.text.erb +0 -1
- data/app/views/layouts/masks/manage.html.erb +0 -25
- data/app/views/masks/actor_mailer/recover_credentials.html.erb +0 -33
- data/app/views/masks/actor_mailer/recover_credentials.text.erb +0 -1
- data/app/views/masks/actor_mailer/verify_email.html.erb +0 -34
- data/app/views/masks/actor_mailer/verify_email.text.erb +0 -8
- data/app/views/masks/actors/current.html.erb +0 -152
- data/app/views/masks/application/_header.html.erb +0 -31
- data/app/views/masks/backup_codes/new.html.erb +0 -103
- data/app/views/masks/emails/new.html.erb +0 -103
- data/app/views/masks/emails/verify.html.erb +0 -51
- data/app/views/masks/keys/new.html.erb +0 -127
- data/app/views/masks/manage/actor/show.html.erb +0 -126
- data/app/views/masks/manage/actors/index.html.erb +0 -40
- data/app/views/masks/one_time_code/new.html.erb +0 -150
- data/app/views/masks/passwords/edit.html.erb +0 -58
- data/app/views/masks/recoveries/new.html.erb +0 -71
- data/app/views/masks/recoveries/password.html.erb +0 -64
- data/app/views/masks/sessions/new.html.erb +0 -153
- data/config/brakeman.ignore +0 -28
- data/config/locales/en.yml +0 -286
- data/db/migrate/20231205173845_create_actors.rb +0 -94
- data/lib/generators/masks/install/USAGE +0 -8
- data/lib/generators/masks/install/templates/initializer.rb +0 -5
- data/lib/generators/masks/install/templates/masks.json +0 -6
- data/lib/masks/configuration.rb +0 -236
- data/lib/masks/engine.rb +0 -25
- data/lib/masks/middleware.rb +0 -70
- data/lib/tasks/masks_tasks.rake +0 -72
- data/masks.json +0 -277
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Rack
|
|
4
|
+
CLAIMS = "masks.claims".freeze
|
|
5
|
+
ERROR = "masks.error".freeze
|
|
6
|
+
|
|
7
|
+
def initialize(app, resource:, scope: nil, only: nil, optional: false)
|
|
8
|
+
@app = app
|
|
9
|
+
@resource = resource
|
|
10
|
+
@scope = scope
|
|
11
|
+
@only = only
|
|
12
|
+
@optional = optional
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
return @app.call(env) unless guards?(env)
|
|
17
|
+
|
|
18
|
+
env[CLAIMS] = resource(env).authenticate(env["HTTP_AUTHORIZATION"], scope: @scope)
|
|
19
|
+
|
|
20
|
+
@app.call(env)
|
|
21
|
+
rescue Challenge => e
|
|
22
|
+
return passthrough(env, e) if @optional && e.is_a?(Unauthenticated)
|
|
23
|
+
|
|
24
|
+
refuse(resource(env), e)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def guards?(env)
|
|
30
|
+
return true if @only.nil?
|
|
31
|
+
|
|
32
|
+
@only.call(env)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def passthrough(env, error)
|
|
36
|
+
env[ERROR] = error
|
|
37
|
+
|
|
38
|
+
@app.call(env)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resource(env)
|
|
42
|
+
@resource.respond_to?(:call) ? @resource.call(env) : @resource
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def refuse(resource, error)
|
|
46
|
+
body = JSON.generate(
|
|
47
|
+
{ "error" => error.code, "error_description" => error.description }.compact
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
[
|
|
51
|
+
error.status,
|
|
52
|
+
{
|
|
53
|
+
"content-type" => "application/json",
|
|
54
|
+
"cache-control" => "no-store",
|
|
55
|
+
"www-authenticate" => resource.challenge(error)
|
|
56
|
+
},
|
|
57
|
+
[ body ]
|
|
58
|
+
]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Registration
|
|
4
|
+
attr_reader :issuer, :metadata, :access_token
|
|
5
|
+
|
|
6
|
+
def self.create(issuer, token: nil, **attributes)
|
|
7
|
+
issuer = Issuer.resolve(issuer)
|
|
8
|
+
headers = token ? { "Authorization" => "Bearer #{token}" } : {}
|
|
9
|
+
body = HTTP.post_json(
|
|
10
|
+
issuer.endpoint("registration_endpoint"), stringify(attributes), headers
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
new(issuer, body)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.stringify(attributes)
|
|
17
|
+
{
|
|
18
|
+
"client_name" => attributes[:name] || attributes[:client_name],
|
|
19
|
+
"redirect_uris" => Array(attributes[:redirect_uris]),
|
|
20
|
+
"grant_types" => fallback(attributes[:grant_types], %w[authorization_code]),
|
|
21
|
+
"response_types" => fallback(attributes[:response_types], %w[code]),
|
|
22
|
+
"scope" => Array(attributes[:scope]).join(" "),
|
|
23
|
+
"token_endpoint_auth_method" => attributes[:token_endpoint_auth_method],
|
|
24
|
+
"application_type" => attributes[:application_type],
|
|
25
|
+
"client_uri" => attributes[:client_uri],
|
|
26
|
+
"logo_uri" => attributes[:logo_uri]
|
|
27
|
+
}.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.fallback(value, default)
|
|
31
|
+
list = Array(value)
|
|
32
|
+
list.empty? ? default : list
|
|
33
|
+
end
|
|
34
|
+
|
|
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
|
+
def self.held(issuer, credentials)
|
|
38
|
+
held = credentials.to_h.transform_keys(&:to_s)
|
|
39
|
+
return nil if held["registration_client_uri"].blank? || held["registration_access_token"].blank?
|
|
40
|
+
|
|
41
|
+
new(Issuer.resolve(issuer), held)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(issuer, body)
|
|
45
|
+
@issuer = issuer
|
|
46
|
+
@metadata = body
|
|
47
|
+
@access_token = body["registration_access_token"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def client_id
|
|
51
|
+
metadata["client_id"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def client_secret
|
|
55
|
+
metadata["client_secret"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def uri
|
|
59
|
+
metadata["registration_client_uri"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read
|
|
63
|
+
HTTP.get(uri, authorization)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def update(**attributes)
|
|
67
|
+
@metadata = metadata.merge(HTTP.put_json(uri, self.class.stringify(attributes), authorization))
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delete
|
|
72
|
+
HTTP.delete(uri, authorization)
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def authorization
|
|
77
|
+
{ "Authorization" => "Bearer #{access_token}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def session(redirect_uri:, scope: Session::DEFAULT_SCOPE)
|
|
81
|
+
Session.new(
|
|
82
|
+
issuer: issuer,
|
|
83
|
+
client_id: client_id,
|
|
84
|
+
client_secret: client_secret,
|
|
85
|
+
redirect_uri: redirect_uri,
|
|
86
|
+
scope: scope
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Registry
|
|
4
|
+
def initialize
|
|
5
|
+
@issuers = {}
|
|
6
|
+
@lock = Mutex.new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def [](url, ttl: Issuer::TTL)
|
|
10
|
+
key = Issuer.normalize(url)
|
|
11
|
+
|
|
12
|
+
@lock.synchronize { @issuers[key] ||= Issuer.new(key, ttl: ttl) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clear!
|
|
16
|
+
@lock.synchronize { @issuers.clear }
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def urls
|
|
21
|
+
@lock.synchronize { @issuers.keys }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Resource
|
|
4
|
+
BEARER = /\ABearer[ \t]+([^\s,]+)[ \t]*\z/i.freeze
|
|
5
|
+
METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
|
|
6
|
+
REQUIRED = %w[iss sub exp].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :issuer, :url, :scopes
|
|
9
|
+
|
|
10
|
+
def initialize(issuer:, url:, scopes: [], metadata_url: nil,
|
|
11
|
+
algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil)
|
|
12
|
+
@issuer = Issuer.resolve(issuer)
|
|
13
|
+
@url = url.to_s
|
|
14
|
+
@descriptions = describe(scopes)
|
|
15
|
+
@scopes = (@descriptions.any? ? @descriptions.keys : Array(scopes).map(&:to_s)).freeze
|
|
16
|
+
@metadata_url = metadata_url
|
|
17
|
+
@required = Array(required)
|
|
18
|
+
@verifier = verifier || Verifier.new(@issuer, audience: @url, algorithms: algorithms)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authenticate(authorization, scope: nil)
|
|
22
|
+
claims = Claims.new(@verifier.verify(token!(authorization), required: @required))
|
|
23
|
+
|
|
24
|
+
Array(scope).each { |name| claims.permit!(name) }
|
|
25
|
+
|
|
26
|
+
claims
|
|
27
|
+
rescue InvalidToken => e
|
|
28
|
+
raise Unauthorized.new(e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def token(authorization)
|
|
32
|
+
authorization.to_s[BEARER, 1]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def metadata_url
|
|
36
|
+
@metadata_url ||= "#{origin}#{METADATA_PATH}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def metadata
|
|
40
|
+
{
|
|
41
|
+
"resource" => url,
|
|
42
|
+
"authorization_servers" => [ issuer.url ],
|
|
43
|
+
"scopes_supported" => scopes,
|
|
44
|
+
"scope_descriptions" => @descriptions,
|
|
45
|
+
"bearer_methods_supported" => [ "header" ]
|
|
46
|
+
}.reject { |_, value| value.respond_to?(:empty?) && value.empty? }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def challenge(error = nil)
|
|
50
|
+
error = Unauthorized.new(error.to_s) unless error.is_a?(Challenge)
|
|
51
|
+
|
|
52
|
+
parameters = [
|
|
53
|
+
[ "error", error.code ],
|
|
54
|
+
[ "error_description", error.description ],
|
|
55
|
+
[ "scope", error.scope || (scopes.join(" ") if scopes.any?) ],
|
|
56
|
+
[ "resource_metadata", metadata_url ]
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
"Bearer " + parameters.filter_map { |name, value|
|
|
60
|
+
%(#{name}="#{quote(value)}") if value && !value.to_s.empty?
|
|
61
|
+
}.join(", ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def describe(scopes)
|
|
67
|
+
return {} unless scopes.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
scopes.each_with_object({}) do |(scope, description), held|
|
|
70
|
+
held[scope.to_s] = description.to_s
|
|
71
|
+
end.freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def token!(authorization)
|
|
75
|
+
token(authorization) || raise(Unauthenticated.new)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def origin
|
|
79
|
+
uri = URI.parse(url)
|
|
80
|
+
port = ":#{uri.port}" unless uri.port.nil? || uri.default_port == uri.port
|
|
81
|
+
|
|
82
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
83
|
+
rescue URI::InvalidURIError
|
|
84
|
+
url
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def quote(value)
|
|
88
|
+
value.to_s.gsub(/[\\"]/, " ").gsub(/[[:cntrl:]]/, " ").strip
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Session
|
|
4
|
+
DEFAULT_SCOPE = %w[openid profile email].freeze
|
|
5
|
+
|
|
6
|
+
attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope
|
|
7
|
+
|
|
8
|
+
def initialize(issuer:, client_id:, redirect_uri:, client_secret: nil, scope: DEFAULT_SCOPE)
|
|
9
|
+
@issuer = Issuer.resolve(issuer)
|
|
10
|
+
@client_id = client_id
|
|
11
|
+
@client_secret = client_secret
|
|
12
|
+
@redirect_uri = redirect_uri
|
|
13
|
+
@scope = Array(scope)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start(resource: nil, prompt: nil, scope: nil, state: SecureRandom.urlsafe_base64(24),
|
|
17
|
+
nonce: SecureRandom.urlsafe_base64(24))
|
|
18
|
+
pkce = Pkce.generate
|
|
19
|
+
scopes = Array(scope || self.scope)
|
|
20
|
+
nonce = nil unless scopes.include?("openid")
|
|
21
|
+
|
|
22
|
+
pairs = [
|
|
23
|
+
[ "response_type", "code" ],
|
|
24
|
+
[ "client_id", client_id ],
|
|
25
|
+
[ "redirect_uri", redirect_uri ],
|
|
26
|
+
[ "scope", scopes.join(" ") ],
|
|
27
|
+
[ "state", state ],
|
|
28
|
+
[ "code_challenge", pkce.challenge ],
|
|
29
|
+
[ "code_challenge_method", pkce.method ]
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
pairs << [ "nonce", nonce ] if nonce
|
|
33
|
+
|
|
34
|
+
Array(resource).each { |value| pairs << [ "resource", value ] }
|
|
35
|
+
pairs << [ "prompt", prompt ] if prompt
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
|
|
39
|
+
state: state,
|
|
40
|
+
nonce: nonce,
|
|
41
|
+
verifier: pkce.verifier
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def complete(code:, verifier:, resource: nil)
|
|
46
|
+
form = [
|
|
47
|
+
[ "grant_type", "authorization_code" ],
|
|
48
|
+
[ "code", code ],
|
|
49
|
+
[ "client_id", client_id ],
|
|
50
|
+
[ "redirect_uri", redirect_uri ],
|
|
51
|
+
[ "code_verifier", verifier ]
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
Array(resource).each { |value| form << [ "resource", value ] }
|
|
55
|
+
|
|
56
|
+
Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def refresh(refresh_token, resource: nil, scope: nil)
|
|
60
|
+
form = [
|
|
61
|
+
[ "grant_type", "refresh_token" ],
|
|
62
|
+
[ "refresh_token", refresh_token ],
|
|
63
|
+
[ "client_id", client_id ]
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
67
|
+
Array(resource).each { |value| form << [ "resource", value ] }
|
|
68
|
+
|
|
69
|
+
Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def exchange(subject_token, scope: nil, resource: nil, lifetime: nil)
|
|
73
|
+
form = [
|
|
74
|
+
[ "grant_type", Tokens::EXCHANGE ],
|
|
75
|
+
[ "client_id", client_id ],
|
|
76
|
+
[ "subject_token", subject_token ],
|
|
77
|
+
[ "subject_token_type", Tokens::ACCESS_TOKEN ]
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
81
|
+
form << [ "requested_lifetime", lifetime.to_i ] if lifetime
|
|
82
|
+
Array(resource).each { |value| form << [ "resource", value ] }
|
|
83
|
+
|
|
84
|
+
Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def revoke(token, hint: nil)
|
|
88
|
+
form = [ [ "token", token ], [ "client_id", client_id ] ]
|
|
89
|
+
form << [ "token_type_hint", hint ] if hint
|
|
90
|
+
|
|
91
|
+
HTTP.post_form(issuer.endpoint("revocation_endpoint"), form, authorization)
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def end_session_url(post_logout_redirect_uri: nil, state: nil, id_token_hint: nil)
|
|
96
|
+
pairs = [ [ "client_id", client_id ] ]
|
|
97
|
+
pairs << [ "id_token_hint", id_token_hint ] if id_token_hint
|
|
98
|
+
pairs << [ "post_logout_redirect_uri", post_logout_redirect_uri ] if post_logout_redirect_uri
|
|
99
|
+
pairs << [ "state", state ] if state
|
|
100
|
+
|
|
101
|
+
"#{issuer.endpoint('end_session_endpoint')}?#{URI.encode_www_form(pairs)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def introspect(token, hint: nil)
|
|
105
|
+
form = [ [ "token", token ], [ "client_id", client_id ] ]
|
|
106
|
+
form << [ "token_type_hint", hint ] if hint
|
|
107
|
+
|
|
108
|
+
Introspection.new(
|
|
109
|
+
HTTP.post_form(issuer.endpoint("introspection_endpoint"), form, authorization)
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def userinfo(access_token)
|
|
114
|
+
HTTP.get(issuer.endpoint("userinfo_endpoint"), "Authorization" => "Bearer #{access_token}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def identity(tokens)
|
|
118
|
+
return nil if tokens.id_token.nil?
|
|
119
|
+
|
|
120
|
+
claims = Verifier.new(issuer, audience: client_id).verify(tokens.id_token)
|
|
121
|
+
|
|
122
|
+
profile(tokens).merge(claims)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def profile(tokens)
|
|
126
|
+
return {} if tokens.access_token.nil?
|
|
127
|
+
|
|
128
|
+
userinfo(tokens.access_token)
|
|
129
|
+
rescue Masks::Client::Error
|
|
130
|
+
{}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def authorization
|
|
136
|
+
return {} if client_secret.nil?
|
|
137
|
+
|
|
138
|
+
encoded = Base64.strict_encode64(
|
|
139
|
+
"#{URI.encode_www_form_component(client_id)}:#{URI.encode_www_form_component(client_secret)}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
{ "Authorization" => "Basic #{encoded}" }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
module Stores
|
|
4
|
+
class Memory
|
|
5
|
+
attr_reader :held
|
|
6
|
+
|
|
7
|
+
def initialize(held = {})
|
|
8
|
+
@held = held
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def read
|
|
12
|
+
held
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write(value)
|
|
16
|
+
@held = value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Session
|
|
21
|
+
def initialize(session, key)
|
|
22
|
+
@session = session
|
|
23
|
+
@key = key
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read
|
|
27
|
+
@session[@key]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def write(value)
|
|
31
|
+
if value.empty?
|
|
32
|
+
@session.delete(@key)
|
|
33
|
+
else
|
|
34
|
+
@session[@key] = value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Tokens
|
|
4
|
+
EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
|
|
5
|
+
ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token".freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at
|
|
8
|
+
|
|
9
|
+
def self.granted(body)
|
|
10
|
+
token = new(body)
|
|
11
|
+
|
|
12
|
+
if token.access_token.to_s.empty?
|
|
13
|
+
raise Rejected.new(
|
|
14
|
+
body["error"] || "invalid_token_response",
|
|
15
|
+
body["error_description"] || "the token endpoint answered without an access_token"
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
token
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(body)
|
|
23
|
+
@access_token = body["access_token"]
|
|
24
|
+
@id_token = body["id_token"]
|
|
25
|
+
@refresh_token = body["refresh_token"]
|
|
26
|
+
@token_type = body["token_type"] || "Bearer"
|
|
27
|
+
@scope = body["scope"].to_s
|
|
28
|
+
@expires_in = body["expires_in"].to_i
|
|
29
|
+
@obtained_at = Time.now.to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def expires_at
|
|
33
|
+
obtained_at + expires_in
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def expired?(leeway: 30)
|
|
37
|
+
Time.now.to_i + leeway >= expires_at
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scopes
|
|
41
|
+
scope.split(/\s+/).reject(&:empty?)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def authorization
|
|
45
|
+
"#{token_type} #{access_token}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{
|
|
50
|
+
"access_token" => access_token,
|
|
51
|
+
"id_token" => id_token,
|
|
52
|
+
"refresh_token" => refresh_token,
|
|
53
|
+
"token_type" => token_type,
|
|
54
|
+
"scope" => scope,
|
|
55
|
+
"expires_in" => expires_in,
|
|
56
|
+
"obtained_at" => obtained_at
|
|
57
|
+
}.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.from_h(data)
|
|
61
|
+
return nil if data.nil? || data.empty?
|
|
62
|
+
|
|
63
|
+
tokens = allocate
|
|
64
|
+
tokens.send(:initialize, data)
|
|
65
|
+
tokens.instance_variable_set(:@obtained_at, data["obtained_at"] || Time.now.to_i)
|
|
66
|
+
tokens
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Tracker
|
|
4
|
+
TTL = 3600
|
|
5
|
+
LIMIT = 5
|
|
6
|
+
|
|
7
|
+
class Entry
|
|
8
|
+
attr_reader :id, :data, :opened_at, :expires_at
|
|
9
|
+
|
|
10
|
+
def self.from_h(id, held)
|
|
11
|
+
return nil unless held.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
new(
|
|
14
|
+
id: id,
|
|
15
|
+
data: held["data"] || {},
|
|
16
|
+
opened_at: held["opened_at"].to_f,
|
|
17
|
+
expires_at: held["expires_at"].to_f
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(id:, data:, opened_at:, expires_at:)
|
|
22
|
+
@id = id
|
|
23
|
+
@data = data
|
|
24
|
+
@opened_at = opened_at
|
|
25
|
+
@expires_at = expires_at
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def live?(now = Time.now.to_f)
|
|
29
|
+
expires_at > now
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def [](key)
|
|
33
|
+
data[key.to_s]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
{ "data" => data, "opened_at" => opened_at, "expires_at" => expires_at }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :store, :ttl, :limit
|
|
42
|
+
|
|
43
|
+
def initialize(store, ttl: TTL, limit: LIMIT)
|
|
44
|
+
@store = store
|
|
45
|
+
@ttl = ttl
|
|
46
|
+
@limit = limit
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def open(**data)
|
|
50
|
+
id = SecureRandom.urlsafe_base64(32)
|
|
51
|
+
now = Time.now.to_f
|
|
52
|
+
|
|
53
|
+
entry = Entry.new(
|
|
54
|
+
id: id,
|
|
55
|
+
data: data.transform_keys(&:to_s).compact,
|
|
56
|
+
opened_at: now,
|
|
57
|
+
expires_at: now + ttl
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
write(sweep.merge(id => entry.to_h))
|
|
61
|
+
entry
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def amend(id, **data)
|
|
65
|
+
held = sweep
|
|
66
|
+
entry = Entry.from_h(id, held[id])
|
|
67
|
+
return nil if entry.nil?
|
|
68
|
+
|
|
69
|
+
merged = Entry.new(
|
|
70
|
+
id: id,
|
|
71
|
+
data: entry.data.merge(data.transform_keys(&:to_s).compact),
|
|
72
|
+
opened_at: entry.opened_at,
|
|
73
|
+
expires_at: entry.expires_at
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
write(held.merge(id => merged.to_h))
|
|
77
|
+
merged
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def claim(id)
|
|
81
|
+
return nil if id.nil? || id.empty?
|
|
82
|
+
|
|
83
|
+
held = sweep
|
|
84
|
+
entry = Entry.from_h(id, held[id])
|
|
85
|
+
return nil if entry.nil? || !entry.live?
|
|
86
|
+
|
|
87
|
+
write(held.except(id))
|
|
88
|
+
entry
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def clear!
|
|
92
|
+
write({})
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def size
|
|
96
|
+
sweep.size
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def sweep
|
|
102
|
+
now = Time.now.to_f
|
|
103
|
+
|
|
104
|
+
held = read.select do |id, value|
|
|
105
|
+
entry = Entry.from_h(id, value)
|
|
106
|
+
entry&.live?(now)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
held.to_a.last(limit).to_h
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def read
|
|
113
|
+
value = store.read
|
|
114
|
+
value.is_a?(Hash) ? value : {}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def write(held)
|
|
118
|
+
store.write(held)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Verifier
|
|
4
|
+
ALGORITHMS = %w[RS256 ES256].freeze
|
|
5
|
+
|
|
6
|
+
attr_reader :issuer, :audience
|
|
7
|
+
|
|
8
|
+
def initialize(issuer, audience:, algorithms: ALGORITHMS)
|
|
9
|
+
@issuer = Issuer.resolve(issuer)
|
|
10
|
+
@audience = Array(audience)
|
|
11
|
+
@algorithms = algorithms
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verify(token, required: %w[iss sub exp])
|
|
15
|
+
JWT.decode(
|
|
16
|
+
token, nil, true,
|
|
17
|
+
algorithms: @algorithms,
|
|
18
|
+
jwks: keys,
|
|
19
|
+
iss: issuer.url, verify_iss: true,
|
|
20
|
+
aud: audience, verify_aud: true,
|
|
21
|
+
verify_expiration: true,
|
|
22
|
+
required_claims: required
|
|
23
|
+
).first
|
|
24
|
+
rescue JWT::DecodeError => e
|
|
25
|
+
raise InvalidToken, e.message
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tenant(token)
|
|
29
|
+
verify(token)["tenant"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def keys
|
|
35
|
+
->(options) do
|
|
36
|
+
issuer.refresh! if options[:invalidate]
|
|
37
|
+
issuer.jwks
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|