masks 0.4.0 → 0.6.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 +29 -0
- data/LICENSE +21 -0
- data/README.md +235 -29
- data/app/controllers/masks/rails/avatars_controller.rb +51 -0
- data/app/controllers/masks/rails/base_controller.rb +13 -0
- data/app/controllers/masks/rails/handshakes_controller.rb +86 -0
- data/app/controllers/masks/rails/sessions_controller.rb +118 -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 +11 -82
- 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 +144 -0
- data/lib/masks/client/errors.rb +60 -0
- data/lib/masks/client/handshake.rb +104 -0
- data/lib/masks/client/http.rb +96 -0
- data/lib/masks/client/introspection.rb +29 -0
- data/lib/masks/client/issuer.rb +92 -0
- data/lib/masks/client/pkce.rb +27 -0
- data/lib/masks/client/rack.rb +62 -0
- data/lib/masks/client/registration.rb +115 -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 +260 -0
- data/lib/masks/rails/configurable.rb +11 -0
- data/lib/masks/rails/configuration.rb +184 -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 -183
- metadata +48 -416
- data/MIT-LICENSE +0 -20
- data/Rakefile +0 -11
- data/app/assets/builds/masks/application.css +0 -1
- data/app/assets/builds/masks/application.js +0 -8549
- 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 -21
- 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/javascripts/controllers/table_controller.js +0 -15
- data/app/assets/manifest.js +0 -2
- data/app/assets/masks_manifest.js +0 -2
- data/app/assets/stylesheets/application.css +0 -34
- 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/actors_controller.rb +0 -83
- data/app/controllers/masks/manage/base_controller.rb +0 -20
- data/app/controllers/masks/manage/clients_controller.rb +0 -84
- data/app/controllers/masks/manage/dashboard_controller.rb +0 -15
- data/app/controllers/masks/manage/devices_controller.rb +0 -19
- data/app/controllers/masks/one_time_code_controller.rb +0 -49
- data/app/controllers/masks/openid/authorizations_controller.rb +0 -45
- data/app/controllers/masks/openid/discoveries_controller.rb +0 -55
- data/app/controllers/masks/openid/tokens_controller.rb +0 -45
- data/app/controllers/masks/openid/userinfo_controller.rb +0 -28
- 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 -21
- data/app/models/masks/access/actor_scopes.rb +0 -18
- data/app/models/masks/access/actor_signup.rb +0 -21
- 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/access_token.rb +0 -60
- 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/return_to.rb +0 -27
- 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 -266
- data/app/models/masks/openid/authorization.rb +0 -116
- data/app/models/masks/openid/token.rb +0 -56
- data/app/models/masks/rails/actor.rb +0 -212
- 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/openid/access_token.rb +0 -55
- data/app/models/masks/rails/openid/authorization.rb +0 -45
- data/app/models/masks/rails/openid/client.rb +0 -186
- data/app/models/masks/rails/openid/id_token.rb +0 -43
- 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 -27
- 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 -42
- data/app/views/masks/actor_mailer/recover_credentials.html.erb +0 -32
- data/app/views/masks/actor_mailer/recover_credentials.text.erb +0 -1
- data/app/views/masks/actor_mailer/verify_email.html.erb +0 -33
- data/app/views/masks/actor_mailer/verify_email.text.erb +0 -8
- data/app/views/masks/actors/current.html.erb +0 -145
- data/app/views/masks/application/_header.html.erb +0 -30
- data/app/views/masks/backup_codes/new.html.erb +0 -117
- data/app/views/masks/emails/new.html.erb +0 -109
- 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/actors/index.html.erb +0 -104
- data/app/views/masks/manage/actors/show.html.erb +0 -172
- data/app/views/masks/manage/clients/index.html.erb +0 -102
- data/app/views/masks/manage/clients/show.html.erb +0 -156
- data/app/views/masks/manage/dashboard/index.html.erb +0 -10
- data/app/views/masks/manage/devices/index.html.erb +0 -47
- data/app/views/masks/one_time_code/new.html.erb +0 -167
- data/app/views/masks/openid/authorizations/error.html.erb +0 -23
- data/app/views/masks/openid/authorizations/new.html.erb +0 -46
- data/app/views/masks/passwords/edit.html.erb +0 -71
- data/app/views/masks/recoveries/new.html.erb +0 -69
- data/app/views/masks/recoveries/password.html.erb +0 -63
- data/app/views/masks/sessions/new.html.erb +0 -152
- data/config/brakeman.ignore +0 -28
- data/config/initializers/inflections.rb +0 -5
- data/config/locales/en.yml +0 -307
- data/db/migrate/20231205173845_create_actors.rb +0 -94
- data/db/migrate/20240329182422_support_openid.rb +0 -64
- 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 -9
- data/lib/masks/configuration.rb +0 -249
- 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 -315
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<% if @connected %>
|
|
2
|
+
<% content_for :title, "Reconnect this app" %>
|
|
3
|
+
|
|
4
|
+
<h1>This app is connected</h1>
|
|
5
|
+
|
|
6
|
+
<p class="muted">
|
|
7
|
+
Reconnecting replaces the credentials it holds now with a fresh pair. It is
|
|
8
|
+
how you rotate them, and it is the only way to move this app to a different
|
|
9
|
+
account. Nothing is typed in and no secret travels through your browser.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p class="muted">
|
|
13
|
+
It will stop working between the approval and the moment it picks the new
|
|
14
|
+
credentials up.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<%= form_with url: handshake_path, method: :post do %>
|
|
18
|
+
<button type="submit">Reconnect it</button>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if @can_disconnect %>
|
|
22
|
+
<%= form_with url: handshake_path, method: :delete do %>
|
|
23
|
+
<button type="submit">Disconnect it</button>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% else %>
|
|
27
|
+
<% content_for :title, "Connect this app" %>
|
|
28
|
+
|
|
29
|
+
<h1>This app has not been connected yet</h1>
|
|
30
|
+
|
|
31
|
+
<p class="muted">
|
|
32
|
+
Nobody can sign in here until it holds credentials from the server that signs
|
|
33
|
+
people in. Getting them takes one approval, from whoever owns this tenant —
|
|
34
|
+
nothing is typed in, and no secret travels through your browser.
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
<%= form_with url: handshake_path, method: :post do %>
|
|
38
|
+
<button type="submit">Connect it</button>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -1,83 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
get "/
|
|
5
|
-
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
# OAuth/OpenID support
|
|
43
|
-
get "client/:id/.well-known/openid-configuration",
|
|
44
|
-
to: "openid/discoveries#new",
|
|
45
|
-
as: :openid_discovery
|
|
46
|
-
get "client/:id/jwks.json", to: "openid/discoveries#jwks", as: :openid_jwks
|
|
47
|
-
get "client/:id",
|
|
48
|
-
to:
|
|
49
|
-
redirect { |params, _|
|
|
50
|
-
"client/#{params[:id]}/.well-known/openid-configuration"
|
|
51
|
-
},
|
|
52
|
-
as: :openid_issuer
|
|
53
|
-
get "authorize", to: "openid/authorizations#new", as: :openid_authorization
|
|
54
|
-
post "authorize", to: "openid/authorizations#create"
|
|
55
|
-
post "token",
|
|
56
|
-
to: proc { |env| Masks::OpenID::Token.new.call(env) },
|
|
57
|
-
as: :openid_token
|
|
58
|
-
match "userinfo",
|
|
59
|
-
to: "openid/userinfo#show",
|
|
60
|
-
via: %i[get post],
|
|
61
|
-
as: :openid_userinfo
|
|
62
|
-
|
|
63
|
-
# managers-only section
|
|
64
|
-
namespace :manage do
|
|
65
|
-
get "/", to: "dashboard#index"
|
|
66
|
-
|
|
67
|
-
# manage clients
|
|
68
|
-
get "clients", to: "clients#index", as: :clients
|
|
69
|
-
post "clients", to: "clients#create"
|
|
70
|
-
get "clients/:id", to: "clients#show", as: :client
|
|
71
|
-
patch "clients/:id", to: "clients#update"
|
|
72
|
-
delete "clients/:id", to: "clients#destroy"
|
|
73
|
-
|
|
74
|
-
# manage actors
|
|
75
|
-
get "actors", to: "actors#index", as: :actors
|
|
76
|
-
get "actors/:actor", to: "actors#show", as: :actor
|
|
77
|
-
post "actors", to: "actors#create"
|
|
78
|
-
patch "actors/:actor", to: "actors#update"
|
|
79
|
-
|
|
80
|
-
# manage devices
|
|
81
|
-
get "devices", to: "devices#index", as: :devices
|
|
82
|
-
end
|
|
1
|
+
Masks::Rails::Engine.routes.draw do
|
|
2
|
+
get "/", to: "sessions#start", as: :start
|
|
3
|
+
get "/session", to: "sessions#show", as: :session
|
|
4
|
+
get "/avatar", to: "avatars#show", as: :avatar
|
|
5
|
+
get "/callback", to: "sessions#callback", as: :callback
|
|
6
|
+
match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
|
|
7
|
+
|
|
8
|
+
get "/handshake", to: "handshakes#show", as: :handshake
|
|
9
|
+
post "/handshake", to: "handshakes#create"
|
|
10
|
+
get "/handshake/callback", to: "handshakes#callback", as: :handshake_callback
|
|
11
|
+
delete "/handshake", to: "handshakes#destroy"
|
|
83
12
|
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,144 @@
|
|
|
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
|
+
class Avatars
|
|
33
|
+
STYLES = %w[photo identicon initials].freeze
|
|
34
|
+
FALLBACK = "identicon".freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :to_h
|
|
37
|
+
|
|
38
|
+
def initialize(hash)
|
|
39
|
+
@to_h = hash.is_a?(Hash) ? hash : {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
STYLES.each do |style|
|
|
43
|
+
define_method(style) { to_h[style] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def [](style)
|
|
47
|
+
to_h[style.to_s]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def photo?
|
|
51
|
+
!photo.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def present?
|
|
55
|
+
to_h.any?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ==(other)
|
|
59
|
+
other.is_a?(Avatars) ? to_h == other.to_h : false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
AVATARS = "masks:avatars".freeze
|
|
64
|
+
|
|
65
|
+
attr_reader :to_h
|
|
66
|
+
|
|
67
|
+
def initialize(claims)
|
|
68
|
+
@to_h = claims
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def [](name)
|
|
72
|
+
to_h[name.to_s]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def subject
|
|
76
|
+
self["sub"]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def issuer
|
|
80
|
+
self["iss"]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def audience
|
|
84
|
+
Array(self["aud"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def client_id
|
|
88
|
+
self["client_id"]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def jti
|
|
92
|
+
self["jti"]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def act
|
|
96
|
+
self["act"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def tenant
|
|
100
|
+
@tenant ||= Tenant.new(self["tenant"])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def avatars
|
|
104
|
+
@avatars ||= Avatars.new(self[AVATARS])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def picture
|
|
108
|
+
self["picture"] || avatars.photo || avatars.identicon
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def scopes
|
|
112
|
+
@scopes ||= self["scope"].to_s.split(/\s+/).reject(&:empty?).freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def permits?(scope)
|
|
116
|
+
scopes.include?(scope.to_s)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def permit!(scope)
|
|
120
|
+
return true if permits?(scope)
|
|
121
|
+
|
|
122
|
+
raise Forbidden.new(
|
|
123
|
+
"insufficient_scope",
|
|
124
|
+
"this token does not carry #{scope}",
|
|
125
|
+
scope: scope.to_s
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def expires_at
|
|
130
|
+
@expires_at ||= self["exp"] && Time.at(self["exp"])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def issued_at
|
|
134
|
+
@issued_at ||= self["iat"] && Time.at(self["iat"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def expired?(leeway: 0)
|
|
138
|
+
return false if expires_at.nil?
|
|
139
|
+
|
|
140
|
+
Time.now.to_i + leeway >= expires_at.to_i
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
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 Unregistered < Rejected
|
|
20
|
+
CODE = "invalid_client".freeze
|
|
21
|
+
|
|
22
|
+
def self.raised_by?(body)
|
|
23
|
+
body["error"].to_s == CODE
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class InvalidToken < Error; end
|
|
28
|
+
|
|
29
|
+
class Challenge < Error
|
|
30
|
+
attr_reader :code, :description, :status, :scope
|
|
31
|
+
|
|
32
|
+
def initialize(code, description, status:, scope: nil)
|
|
33
|
+
super([ code, description ].compact.join(": "))
|
|
34
|
+
|
|
35
|
+
@code = code
|
|
36
|
+
@description = description
|
|
37
|
+
@status = status
|
|
38
|
+
@scope = scope
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class Unauthenticated < Challenge
|
|
43
|
+
def initialize(description = "a bearer token is required", code: nil)
|
|
44
|
+
super(code, description, status: 401)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Unauthorized < Challenge
|
|
49
|
+
def initialize(description, code: "invalid_token")
|
|
50
|
+
super(code, description, status: 401)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Forbidden < Challenge
|
|
55
|
+
def initialize(code, description, scope: nil)
|
|
56
|
+
super(code, description, status: 403, scope: scope)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
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,96 @@
|
|
|
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 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
|
+
|
|
47
|
+
def json(verb, url, body, headers)
|
|
48
|
+
uri = URI.parse(url.to_s)
|
|
49
|
+
request = verb.new(uri, default_headers.merge(headers))
|
|
50
|
+
request.body = JSON.generate(body)
|
|
51
|
+
request["Content-Type"] = "application/json"
|
|
52
|
+
|
|
53
|
+
self.request(request)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def default_headers
|
|
57
|
+
{ "Accept" => "application/json", "User-Agent" => "masks/#{Masks::VERSION}" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def request(request)
|
|
61
|
+
uri = request.uri
|
|
62
|
+
|
|
63
|
+
response = Net::HTTP.start(
|
|
64
|
+
uri.hostname, uri.port,
|
|
65
|
+
use_ssl: uri.scheme == "https",
|
|
66
|
+
open_timeout: OPEN_TIMEOUT,
|
|
67
|
+
read_timeout: READ_TIMEOUT
|
|
68
|
+
) { |http| http.request(request) }
|
|
69
|
+
|
|
70
|
+
parse(response)
|
|
71
|
+
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
72
|
+
raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse(response)
|
|
76
|
+
body = begin
|
|
77
|
+
JSON.parse(response.body.to_s)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
83
|
+
refusal = Unregistered.raised_by?(body) ? Unregistered : Rejected
|
|
84
|
+
|
|
85
|
+
raise refusal.new(
|
|
86
|
+
body["error"] || "http_#{response.code}",
|
|
87
|
+
body["error_description"] || response.message,
|
|
88
|
+
status: response.code.to_i
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
body
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
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
|