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
data/config/routes.rb
CHANGED
|
@@ -1,46 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
get "/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
get "
|
|
8
|
-
post "
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# recover credentials
|
|
12
|
-
get "recover", to: "recoveries#new", as: :recover
|
|
13
|
-
post "recover", to: "recoveries#create"
|
|
14
|
-
get "recovery", to: "recoveries#password", as: :recover_password
|
|
15
|
-
post "recovery", to: "recoveries#reset"
|
|
16
|
-
|
|
17
|
-
# manage account details, password, etc
|
|
18
|
-
get "me", to: "actors#current", as: :current
|
|
19
|
-
get "password", to: "passwords#edit", as: :password
|
|
20
|
-
post "password", to: "passwords#update"
|
|
21
|
-
post "device/:key", to: "devices#update", as: :device
|
|
22
|
-
|
|
23
|
-
# manage emails
|
|
24
|
-
get "emails", to: "emails#new", as: :emails
|
|
25
|
-
post "emails", to: "emails#create"
|
|
26
|
-
patch "emails", to: "emails#notify"
|
|
27
|
-
delete "emails", to: "emails#delete"
|
|
28
|
-
get "email/:email/verify", to: "emails#verify", as: :email_verify
|
|
29
|
-
|
|
30
|
-
# keys
|
|
31
|
-
get "keys", to: "keys#new", as: :keys
|
|
32
|
-
post "keys", to: "keys#create"
|
|
33
|
-
delete "keys", to: "keys#delete"
|
|
34
|
-
|
|
35
|
-
# manage 2nd factor options
|
|
36
|
-
get "one-time-codes", to: "one_time_code#new", as: :one_time_code
|
|
37
|
-
post "one-time-codes", to: "one_time_code#create"
|
|
38
|
-
delete "one-time-codes", to: "one_time_code#destroy"
|
|
39
|
-
get "backup-codes", to: "backup_codes#new", as: :backup_codes
|
|
40
|
-
post "backup-codes", to: "backup_codes#create"
|
|
41
|
-
|
|
42
|
-
# managers-only section
|
|
43
|
-
get "actors", to: "manage/actors#index", as: :actors
|
|
44
|
-
get "actors/:actor", to: "manage/actor#show", as: :actor
|
|
45
|
-
patch "actors/:actor", to: "manage/actor#update"
|
|
1
|
+
Masks::Rails::Engine.routes.draw do
|
|
2
|
+
get "/", to: "sessions#start", as: :start
|
|
3
|
+
get "/session", to: "sessions#show", as: :session
|
|
4
|
+
get "/callback", to: "sessions#callback", as: :callback
|
|
5
|
+
match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
|
|
6
|
+
|
|
7
|
+
get "/handshake", to: "handshakes#show", as: :handshake
|
|
8
|
+
post "/handshake", to: "handshakes#create"
|
|
9
|
+
get "/handshake/callback", to: "handshakes#callback", as: :handshake_callback
|
|
10
|
+
delete "/handshake", to: "handshakes#destroy"
|
|
46
11
|
end
|
|
@@ -1,33 +1,46 @@
|
|
|
1
|
-
|
|
1
|
+
require "rails/generators/base"
|
|
2
2
|
|
|
3
3
|
module Masks
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
route 'mount Masks::Engine => "/"'
|
|
10
|
-
end
|
|
8
|
+
desc "Mount the masks engine, write an initializer, and say what to set."
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
class_option :mount, type: :string, default: "/auth",
|
|
11
|
+
desc: "Where to mount the engine"
|
|
12
|
+
class_option :resource, type: :string, default: nil,
|
|
13
|
+
desc: "This app's resource identifier, if it is also a resource server"
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
def create_initializer
|
|
16
|
+
@mount_path = options[:mount]
|
|
17
|
+
@resource = options[:resource]
|
|
18
|
+
|
|
19
|
+
template "masks.rb.tt", "config/initializers/masks.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def mount_engine
|
|
23
|
+
route %(mount Masks::Rails::Engine, at: "#{options[:mount]}")
|
|
24
|
+
end
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
rails_command "masks:install:migrations"
|
|
23
|
-
else
|
|
24
|
-
puts
|
|
25
|
-
puts 'run "rails masks:install:migrations" to add them later on...'
|
|
26
|
+
def ignore_credentials
|
|
27
|
+
append_to_file ".gitignore", "\n# masks: what the handshake gave this app\n/config/masks.json\n"
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
def say_what_is_left
|
|
31
|
+
say ""
|
|
32
|
+
say "masks is mounted at #{options[:mount]}.", :green
|
|
33
|
+
say ""
|
|
34
|
+
say " 1. Set MASKS_ISSUER in the environment, or edit the initializer."
|
|
35
|
+
say " 2. Start the app and open #{options[:mount]}/handshake."
|
|
36
|
+
say " 3. Approve it at your masks server. The credentials land in"
|
|
37
|
+
say " config/masks.json, which the generator has gitignored."
|
|
38
|
+
say ""
|
|
39
|
+
say "Include Masks::Rails::Authentication in the controllers that want"
|
|
40
|
+
say "it, or set config.authenticate_everything = true to have it on all"
|
|
41
|
+
say "of them. Nothing is included for you."
|
|
42
|
+
say ""
|
|
43
|
+
end
|
|
31
44
|
end
|
|
32
45
|
end
|
|
33
46
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Masks::Rails.configure do |config|
|
|
2
|
+
# The issuer this app signs in against. A callable taking the request works
|
|
3
|
+
# too, which is what a subdomain-per-tenant host needs.
|
|
4
|
+
config.issuer = ENV.fetch("MASKS_ISSUER", "https://auth.example.com")
|
|
5
|
+
<% if @resource -%>
|
|
6
|
+
|
|
7
|
+
# This app is also a resource server: the identifier tokens are minted for,
|
|
8
|
+
# and the scopes it accepts. It is what `resource=` sends at authorize and
|
|
9
|
+
# what `aud` is checked against.
|
|
10
|
+
config.resource = "<%= @resource %>"
|
|
11
|
+
config.resource_scopes = []
|
|
12
|
+
<% else -%>
|
|
13
|
+
|
|
14
|
+
# Uncomment when this app also accepts tokens of its own. `resource` is the
|
|
15
|
+
# identifier tokens are minted for; `resource_scopes` are what it accepts.
|
|
16
|
+
# config.resource = "https://app.example.com/api"
|
|
17
|
+
# config.resource_scopes = []
|
|
18
|
+
<% end -%>
|
|
19
|
+
|
|
20
|
+
# Where to land after each. Both must be paths on this host.
|
|
21
|
+
config.after_sign_in = "/"
|
|
22
|
+
config.after_sign_out = "/"
|
|
23
|
+
|
|
24
|
+
# Credentials arrive from the handshake and are kept in config/masks.json.
|
|
25
|
+
# Replace both with your own storage when one file is not the right shape —
|
|
26
|
+
# a multi-tenant app wants a row per tenant rather than a file per install.
|
|
27
|
+
#
|
|
28
|
+
# config.credentials = ->(request) { Tenant.for(request).masks_credentials }
|
|
29
|
+
# config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
|
|
30
|
+
|
|
31
|
+
# Signing out of this app does not sign you out of the issuer unless you say
|
|
32
|
+
# so. Leave it false and offer "sign out everywhere" as its own action.
|
|
33
|
+
# config.sign_out_of_issuer = false
|
|
34
|
+
|
|
35
|
+
# The engine's pages inherit this, so a consumer's layout, helpers and
|
|
36
|
+
# before_actions apply to them.
|
|
37
|
+
# config.parent_controller = "ApplicationController"
|
|
38
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Claims
|
|
4
|
+
class Tenant
|
|
5
|
+
attr_reader :to_h
|
|
6
|
+
|
|
7
|
+
def initialize(hash)
|
|
8
|
+
@to_h = hash.is_a?(Hash) ? hash : {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def uuid
|
|
12
|
+
to_h["uuid"]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def subdomain
|
|
16
|
+
to_h["subdomain"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name
|
|
20
|
+
to_h["name"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def present?
|
|
24
|
+
!uuid.nil? || !subdomain.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other)
|
|
28
|
+
other.is_a?(Tenant) ? to_h == other.to_h : false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :to_h
|
|
33
|
+
|
|
34
|
+
def initialize(claims)
|
|
35
|
+
@to_h = claims
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def [](name)
|
|
39
|
+
to_h[name.to_s]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subject
|
|
43
|
+
self["sub"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def issuer
|
|
47
|
+
self["iss"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def audience
|
|
51
|
+
Array(self["aud"])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def client_id
|
|
55
|
+
self["client_id"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def jti
|
|
59
|
+
self["jti"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def act
|
|
63
|
+
self["act"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tenant
|
|
67
|
+
@tenant ||= Tenant.new(self["tenant"])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scopes
|
|
71
|
+
@scopes ||= self["scope"].to_s.split(/\s+/).reject(&:empty?).freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def permits?(scope)
|
|
75
|
+
scopes.include?(scope.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def permit!(scope)
|
|
79
|
+
return true if permits?(scope)
|
|
80
|
+
|
|
81
|
+
raise Forbidden.new(
|
|
82
|
+
"insufficient_scope",
|
|
83
|
+
"this token does not carry #{scope}",
|
|
84
|
+
scope: scope.to_s
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def expires_at
|
|
89
|
+
@expires_at ||= self["exp"] && Time.at(self["exp"])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def issued_at
|
|
93
|
+
@issued_at ||= self["iat"] && Time.at(self["iat"])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def expired?(leeway: 0)
|
|
97
|
+
return false if expires_at.nil?
|
|
98
|
+
|
|
99
|
+
Time.now.to_i + leeway >= expires_at.to_i
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
class Unreachable < Error; end
|
|
6
|
+
|
|
7
|
+
class Rejected < Error
|
|
8
|
+
attr_reader :code, :description, :status
|
|
9
|
+
|
|
10
|
+
def initialize(code, description, status: nil)
|
|
11
|
+
super([ code, description ].compact.join(": "))
|
|
12
|
+
|
|
13
|
+
@code = code
|
|
14
|
+
@description = description
|
|
15
|
+
@status = status
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class InvalidToken < Error; end
|
|
20
|
+
|
|
21
|
+
class Challenge < Error
|
|
22
|
+
attr_reader :code, :description, :status, :scope
|
|
23
|
+
|
|
24
|
+
def initialize(code, description, status:, scope: nil)
|
|
25
|
+
super([ code, description ].compact.join(": "))
|
|
26
|
+
|
|
27
|
+
@code = code
|
|
28
|
+
@description = description
|
|
29
|
+
@status = status
|
|
30
|
+
@scope = scope
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Unauthenticated < Challenge
|
|
35
|
+
def initialize(description = "a bearer token is required", code: nil)
|
|
36
|
+
super(code, description, status: 401)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Unauthorized < Challenge
|
|
41
|
+
def initialize(description, code: "invalid_token")
|
|
42
|
+
super(code, description, status: 401)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Forbidden < Challenge
|
|
47
|
+
def initialize(code, description, scope: nil)
|
|
48
|
+
super(code, description, status: 403, scope: scope)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Handshake
|
|
4
|
+
PATH = "/handshake".freeze
|
|
5
|
+
GRANT_TYPES = %w[authorization_code refresh_token].freeze
|
|
6
|
+
AUTH_METHOD = "client_secret_basic".freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to
|
|
9
|
+
|
|
10
|
+
def initialize(issuer, name:, resource:, redirect_uris:, return_to:,
|
|
11
|
+
scope: Session::DEFAULT_SCOPE)
|
|
12
|
+
@issuer = Issuer.resolve(issuer)
|
|
13
|
+
@name = name.to_s
|
|
14
|
+
@resource = resource.to_s
|
|
15
|
+
@redirect_uris = Array(redirect_uris).map(&:to_s)
|
|
16
|
+
@scope = Array(scope).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?)
|
|
17
|
+
@return_to = return_to.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def endpoint
|
|
21
|
+
@endpoint ||= advertised || "#{issuer.url}#{PATH}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start(state: SecureRandom.urlsafe_base64(32))
|
|
25
|
+
{ url: url(state: state), state: state }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def url(state:)
|
|
29
|
+
query = [
|
|
30
|
+
[ "client_name", name ],
|
|
31
|
+
[ "resource", resource ],
|
|
32
|
+
[ "scope", scope.join(" ") ],
|
|
33
|
+
[ "return_to", return_to ],
|
|
34
|
+
[ "state", state ]
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
redirect_uris.each { |uri| query << [ "redirect_uris", uri ] }
|
|
38
|
+
|
|
39
|
+
"#{endpoint}?#{URI.encode_www_form(query)}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def complete(params, state:)
|
|
43
|
+
held = normalize(params)
|
|
44
|
+
|
|
45
|
+
refuse(held["error"], held["error_description"]) if present?(held["error"])
|
|
46
|
+
verify_state!(held["state"], state)
|
|
47
|
+
verify_issuer!(held["iss"])
|
|
48
|
+
|
|
49
|
+
redeem(held["initial_access_token"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def redeem(token)
|
|
53
|
+
unless present?(token)
|
|
54
|
+
refuse("invalid_request", "the handshake came back without a token")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Registration.create(
|
|
58
|
+
issuer,
|
|
59
|
+
token: token,
|
|
60
|
+
name: name,
|
|
61
|
+
redirect_uris: redirect_uris,
|
|
62
|
+
grant_types: GRANT_TYPES,
|
|
63
|
+
scope: scope,
|
|
64
|
+
token_endpoint_auth_method: AUTH_METHOD
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def advertised
|
|
71
|
+
value = issuer.discovery["handshake_endpoint"]
|
|
72
|
+
present?(value) ? value.to_s : nil
|
|
73
|
+
rescue Masks::Client::Error
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def verify_state!(returned, held)
|
|
78
|
+
return if present?(held) && present?(returned) &&
|
|
79
|
+
OpenSSL.secure_compare(returned.to_s, held.to_s)
|
|
80
|
+
|
|
81
|
+
refuse("invalid_state", "the handshake did not match this browser")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def verify_issuer!(named)
|
|
85
|
+
return unless present?(named)
|
|
86
|
+
return if Issuer.normalize(named) == issuer.url
|
|
87
|
+
|
|
88
|
+
refuse("invalid_issuer", "that came back from #{named} rather than #{issuer.url}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def refuse(code, description)
|
|
92
|
+
raise Rejected.new(code.to_s, description.to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize(params)
|
|
96
|
+
params.to_h.each_with_object({}) { |(key, value), held| held[key.to_s] = value }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def present?(value)
|
|
100
|
+
!value.nil? && !value.to_s.empty?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
module HTTP
|
|
4
|
+
OPEN_TIMEOUT = 5
|
|
5
|
+
READ_TIMEOUT = 10
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def get(url, headers = {})
|
|
10
|
+
request(Net::HTTP::Get.new(URI.parse(url.to_s), default_headers.merge(headers)))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def post_form(url, form, headers = {})
|
|
14
|
+
uri = URI.parse(url.to_s)
|
|
15
|
+
request = Net::HTTP::Post.new(uri, default_headers.merge(headers))
|
|
16
|
+
request.body = URI.encode_www_form(form)
|
|
17
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
18
|
+
|
|
19
|
+
self.request(request)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def post_json(url, body, headers = {})
|
|
23
|
+
json(Net::HTTP::Post, url, body, headers)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def put_json(url, body, headers = {})
|
|
27
|
+
json(Net::HTTP::Put, url, body, headers)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delete(url, headers = {})
|
|
31
|
+
request(Net::HTTP::Delete.new(URI.parse(url.to_s), default_headers.merge(headers)))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def json(verb, url, body, headers)
|
|
35
|
+
uri = URI.parse(url.to_s)
|
|
36
|
+
request = verb.new(uri, default_headers.merge(headers))
|
|
37
|
+
request.body = JSON.generate(body)
|
|
38
|
+
request["Content-Type"] = "application/json"
|
|
39
|
+
|
|
40
|
+
self.request(request)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def default_headers
|
|
44
|
+
{ "Accept" => "application/json", "User-Agent" => "masks/#{Masks::VERSION}" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def request(request)
|
|
48
|
+
uri = request.uri
|
|
49
|
+
|
|
50
|
+
response = Net::HTTP.start(
|
|
51
|
+
uri.hostname, uri.port,
|
|
52
|
+
use_ssl: uri.scheme == "https",
|
|
53
|
+
open_timeout: OPEN_TIMEOUT,
|
|
54
|
+
read_timeout: READ_TIMEOUT
|
|
55
|
+
) { |http| http.request(request) }
|
|
56
|
+
|
|
57
|
+
parse(response)
|
|
58
|
+
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
59
|
+
raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse(response)
|
|
63
|
+
body = begin
|
|
64
|
+
JSON.parse(response.body.to_s)
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
{}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
70
|
+
raise Rejected.new(
|
|
71
|
+
body["error"] || "http_#{response.code}",
|
|
72
|
+
body["error_description"] || response.message,
|
|
73
|
+
status: response.code.to_i
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
body
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Introspection < Claims
|
|
4
|
+
def active?
|
|
5
|
+
to_h["active"] == true
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def username
|
|
9
|
+
self["username"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def token_type
|
|
13
|
+
self["token_type"]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def permits?(scope)
|
|
17
|
+
active? && super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def permit!(scope)
|
|
21
|
+
unless active?
|
|
22
|
+
raise Unauthorized.new("the issuer reports this token is not active")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Issuer
|
|
4
|
+
DISCOVERY_PATH = "/.well-known/openid-configuration".freeze
|
|
5
|
+
TTL = 300
|
|
6
|
+
|
|
7
|
+
def self.normalize(url)
|
|
8
|
+
url.to_s.chomp("/")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.resolve(issuer, ttl: TTL)
|
|
12
|
+
return issuer if issuer.is_a?(self)
|
|
13
|
+
|
|
14
|
+
Masks::Client.registry[issuer, ttl: ttl]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :url
|
|
18
|
+
|
|
19
|
+
def initialize(url, ttl: TTL)
|
|
20
|
+
@url = self.class.normalize(url)
|
|
21
|
+
@ttl = ttl
|
|
22
|
+
@cache = {}
|
|
23
|
+
@lock = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def discovery
|
|
27
|
+
fetch(:discovery) do
|
|
28
|
+
document = HTTP.get("#{url}#{DISCOVERY_PATH}")
|
|
29
|
+
named = self.class.normalize(document["issuer"])
|
|
30
|
+
|
|
31
|
+
unless named == url
|
|
32
|
+
raise Rejected.new(
|
|
33
|
+
"invalid_issuer",
|
|
34
|
+
"#{url} publishes a document naming #{document['issuer'].inspect}"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
document
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def jwks
|
|
43
|
+
fetch(:jwks) { HTTP.get(endpoint("jwks_uri")) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tenant
|
|
47
|
+
discovery["tenant"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def endpoint(name)
|
|
51
|
+
discovery.fetch(name) { raise Rejected.new("invalid_issuer", "#{url} publishes no #{name}") }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def refresh!
|
|
55
|
+
@lock.synchronize { @cache = {} }
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def fetch(key)
|
|
62
|
+
cached = @lock.synchronize { @cache[key] }
|
|
63
|
+
return cached[:value] if fresh?(cached)
|
|
64
|
+
|
|
65
|
+
value = yield
|
|
66
|
+
|
|
67
|
+
@lock.synchronize { @cache[key] = { value: value, at: Time.now.to_i } }
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fresh?(entry)
|
|
72
|
+
entry && entry[:at] + @ttl > Time.now.to_i
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Pkce
|
|
4
|
+
METHOD = "S256".freeze
|
|
5
|
+
|
|
6
|
+
attr_reader :verifier
|
|
7
|
+
|
|
8
|
+
def self.generate
|
|
9
|
+
new(SecureRandom.urlsafe_base64(64))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(verifier)
|
|
13
|
+
@verifier = verifier
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def challenge
|
|
17
|
+
Base64.urlsafe_encode64(
|
|
18
|
+
OpenSSL::Digest::SHA256.digest(verifier), padding: false
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def method
|
|
23
|
+
METHOD
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|