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.
Files changed (180) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +21 -0
  4. data/README.md +235 -29
  5. data/app/controllers/masks/rails/avatars_controller.rb +51 -0
  6. data/app/controllers/masks/rails/base_controller.rb +13 -0
  7. data/app/controllers/masks/rails/handshakes_controller.rb +86 -0
  8. data/app/controllers/masks/rails/sessions_controller.rb +118 -0
  9. data/app/views/layouts/masks/rails/plain.html.erb +47 -0
  10. data/app/views/masks/rails/handshakes/refused.html.erb +12 -0
  11. data/app/views/masks/rails/handshakes/show.html.erb +40 -0
  12. data/config/routes.rb +11 -82
  13. data/lib/generators/masks/install/install_generator.rb +35 -22
  14. data/lib/generators/masks/install/templates/masks.rb.tt +38 -0
  15. data/lib/masks/client/claims.rb +144 -0
  16. data/lib/masks/client/errors.rb +60 -0
  17. data/lib/masks/client/handshake.rb +104 -0
  18. data/lib/masks/client/http.rb +96 -0
  19. data/lib/masks/client/introspection.rb +29 -0
  20. data/lib/masks/client/issuer.rb +92 -0
  21. data/lib/masks/client/pkce.rb +27 -0
  22. data/lib/masks/client/rack.rb +62 -0
  23. data/lib/masks/client/registration.rb +115 -0
  24. data/lib/masks/client/registry.rb +25 -0
  25. data/lib/masks/client/resource.rb +92 -0
  26. data/lib/masks/client/session.rb +146 -0
  27. data/lib/masks/client/stores.rb +40 -0
  28. data/lib/masks/client/tokens.rb +70 -0
  29. data/lib/masks/client/tracker.rb +122 -0
  30. data/lib/masks/client/verifier.rb +42 -0
  31. data/lib/masks/client.rb +51 -0
  32. data/lib/masks/rails/authentication.rb +260 -0
  33. data/lib/masks/rails/configurable.rb +11 -0
  34. data/lib/masks/rails/configuration.rb +184 -0
  35. data/lib/masks/rails/credentials.rb +60 -0
  36. data/lib/masks/rails/engine.rb +20 -0
  37. data/lib/masks/rails/protected_resource.rb +51 -0
  38. data/lib/masks/rails.rb +10 -0
  39. data/lib/masks/version.rb +1 -3
  40. data/lib/masks.rb +3 -183
  41. metadata +48 -416
  42. data/MIT-LICENSE +0 -20
  43. data/Rakefile +0 -11
  44. data/app/assets/builds/masks/application.css +0 -1
  45. data/app/assets/builds/masks/application.js +0 -8549
  46. data/app/assets/builds/masks/application.js.map +0 -7
  47. data/app/assets/images/masks.png +0 -0
  48. data/app/assets/javascripts/application.js +0 -2
  49. data/app/assets/javascripts/controllers/application.js +0 -9
  50. data/app/assets/javascripts/controllers/emails_controller.js +0 -28
  51. data/app/assets/javascripts/controllers/index.js +0 -21
  52. data/app/assets/javascripts/controllers/keys_controller.js +0 -20
  53. data/app/assets/javascripts/controllers/recover_controller.js +0 -21
  54. data/app/assets/javascripts/controllers/recover_password_controller.js +0 -21
  55. data/app/assets/javascripts/controllers/session_controller.js +0 -94
  56. data/app/assets/javascripts/controllers/table_controller.js +0 -15
  57. data/app/assets/manifest.js +0 -2
  58. data/app/assets/masks_manifest.js +0 -2
  59. data/app/assets/stylesheets/application.css +0 -34
  60. data/app/controllers/concerns/masks/controller.rb +0 -114
  61. data/app/controllers/masks/actors_controller.rb +0 -15
  62. data/app/controllers/masks/application_controller.rb +0 -35
  63. data/app/controllers/masks/backup_codes_controller.rb +0 -34
  64. data/app/controllers/masks/debug_controller.rb +0 -9
  65. data/app/controllers/masks/devices_controller.rb +0 -20
  66. data/app/controllers/masks/emails_controller.rb +0 -60
  67. data/app/controllers/masks/error_controller.rb +0 -14
  68. data/app/controllers/masks/keys_controller.rb +0 -45
  69. data/app/controllers/masks/manage/actors_controller.rb +0 -83
  70. data/app/controllers/masks/manage/base_controller.rb +0 -20
  71. data/app/controllers/masks/manage/clients_controller.rb +0 -84
  72. data/app/controllers/masks/manage/dashboard_controller.rb +0 -15
  73. data/app/controllers/masks/manage/devices_controller.rb +0 -19
  74. data/app/controllers/masks/one_time_code_controller.rb +0 -49
  75. data/app/controllers/masks/openid/authorizations_controller.rb +0 -45
  76. data/app/controllers/masks/openid/discoveries_controller.rb +0 -55
  77. data/app/controllers/masks/openid/tokens_controller.rb +0 -45
  78. data/app/controllers/masks/openid/userinfo_controller.rb +0 -28
  79. data/app/controllers/masks/passwords_controller.rb +0 -33
  80. data/app/controllers/masks/recoveries_controller.rb +0 -43
  81. data/app/controllers/masks/sessions_controller.rb +0 -53
  82. data/app/helpers/masks/application_helper.rb +0 -49
  83. data/app/jobs/masks/application_job.rb +0 -7
  84. data/app/jobs/masks/expire_actors_job.rb +0 -15
  85. data/app/jobs/masks/expire_recoveries_job.rb +0 -15
  86. data/app/mailers/masks/actor_mailer.rb +0 -22
  87. data/app/mailers/masks/application_mailer.rb +0 -15
  88. data/app/models/concerns/masks/access.rb +0 -162
  89. data/app/models/concerns/masks/actor.rb +0 -132
  90. data/app/models/concerns/masks/adapter.rb +0 -68
  91. data/app/models/concerns/masks/role.rb +0 -9
  92. data/app/models/concerns/masks/scoped.rb +0 -54
  93. data/app/models/masks/access/actor_password.rb +0 -21
  94. data/app/models/masks/access/actor_scopes.rb +0 -18
  95. data/app/models/masks/access/actor_signup.rb +0 -21
  96. data/app/models/masks/actors/anonymous.rb +0 -40
  97. data/app/models/masks/actors/system.rb +0 -24
  98. data/app/models/masks/adapters/active_record.rb +0 -85
  99. data/app/models/masks/application_model.rb +0 -15
  100. data/app/models/masks/application_record.rb +0 -8
  101. data/app/models/masks/check.rb +0 -192
  102. data/app/models/masks/credential.rb +0 -166
  103. data/app/models/masks/credentials/access_token.rb +0 -60
  104. data/app/models/masks/credentials/backup_code.rb +0 -30
  105. data/app/models/masks/credentials/device.rb +0 -59
  106. data/app/models/masks/credentials/email.rb +0 -48
  107. data/app/models/masks/credentials/factor2.rb +0 -71
  108. data/app/models/masks/credentials/key.rb +0 -38
  109. data/app/models/masks/credentials/last_login.rb +0 -12
  110. data/app/models/masks/credentials/masquerade.rb +0 -32
  111. data/app/models/masks/credentials/nickname.rb +0 -63
  112. data/app/models/masks/credentials/one_time_code.rb +0 -34
  113. data/app/models/masks/credentials/password.rb +0 -28
  114. data/app/models/masks/credentials/recovery.rb +0 -71
  115. data/app/models/masks/credentials/return_to.rb +0 -27
  116. data/app/models/masks/credentials/session.rb +0 -67
  117. data/app/models/masks/device.rb +0 -30
  118. data/app/models/masks/error.rb +0 -51
  119. data/app/models/masks/event.rb +0 -14
  120. data/app/models/masks/mask.rb +0 -266
  121. data/app/models/masks/openid/authorization.rb +0 -116
  122. data/app/models/masks/openid/token.rb +0 -56
  123. data/app/models/masks/rails/actor.rb +0 -212
  124. data/app/models/masks/rails/actor_role.rb +0 -12
  125. data/app/models/masks/rails/device.rb +0 -47
  126. data/app/models/masks/rails/email.rb +0 -96
  127. data/app/models/masks/rails/key.rb +0 -61
  128. data/app/models/masks/rails/openid/access_token.rb +0 -55
  129. data/app/models/masks/rails/openid/authorization.rb +0 -45
  130. data/app/models/masks/rails/openid/client.rb +0 -186
  131. data/app/models/masks/rails/openid/id_token.rb +0 -43
  132. data/app/models/masks/rails/recovery.rb +0 -116
  133. data/app/models/masks/rails/role.rb +0 -20
  134. data/app/models/masks/rails/scope.rb +0 -15
  135. data/app/models/masks/session.rb +0 -447
  136. data/app/models/masks/sessions/access.rb +0 -27
  137. data/app/models/masks/sessions/inline.rb +0 -16
  138. data/app/models/masks/sessions/request.rb +0 -42
  139. data/app/resources/masks/actor_resource.rb +0 -9
  140. data/app/resources/masks/session_resource.rb +0 -15
  141. data/app/views/layouts/masks/application.html.erb +0 -17
  142. data/app/views/layouts/masks/mailer.html.erb +0 -17
  143. data/app/views/layouts/masks/mailer.text.erb +0 -1
  144. data/app/views/layouts/masks/manage.html.erb +0 -42
  145. data/app/views/masks/actor_mailer/recover_credentials.html.erb +0 -32
  146. data/app/views/masks/actor_mailer/recover_credentials.text.erb +0 -1
  147. data/app/views/masks/actor_mailer/verify_email.html.erb +0 -33
  148. data/app/views/masks/actor_mailer/verify_email.text.erb +0 -8
  149. data/app/views/masks/actors/current.html.erb +0 -145
  150. data/app/views/masks/application/_header.html.erb +0 -30
  151. data/app/views/masks/backup_codes/new.html.erb +0 -117
  152. data/app/views/masks/emails/new.html.erb +0 -109
  153. data/app/views/masks/emails/verify.html.erb +0 -51
  154. data/app/views/masks/keys/new.html.erb +0 -127
  155. data/app/views/masks/manage/actors/index.html.erb +0 -104
  156. data/app/views/masks/manage/actors/show.html.erb +0 -172
  157. data/app/views/masks/manage/clients/index.html.erb +0 -102
  158. data/app/views/masks/manage/clients/show.html.erb +0 -156
  159. data/app/views/masks/manage/dashboard/index.html.erb +0 -10
  160. data/app/views/masks/manage/devices/index.html.erb +0 -47
  161. data/app/views/masks/one_time_code/new.html.erb +0 -167
  162. data/app/views/masks/openid/authorizations/error.html.erb +0 -23
  163. data/app/views/masks/openid/authorizations/new.html.erb +0 -46
  164. data/app/views/masks/passwords/edit.html.erb +0 -71
  165. data/app/views/masks/recoveries/new.html.erb +0 -69
  166. data/app/views/masks/recoveries/password.html.erb +0 -63
  167. data/app/views/masks/sessions/new.html.erb +0 -152
  168. data/config/brakeman.ignore +0 -28
  169. data/config/initializers/inflections.rb +0 -5
  170. data/config/locales/en.yml +0 -307
  171. data/db/migrate/20231205173845_create_actors.rb +0 -94
  172. data/db/migrate/20240329182422_support_openid.rb +0 -64
  173. data/lib/generators/masks/install/USAGE +0 -8
  174. data/lib/generators/masks/install/templates/initializer.rb +0 -5
  175. data/lib/generators/masks/install/templates/masks.json +0 -9
  176. data/lib/masks/configuration.rb +0 -249
  177. data/lib/masks/engine.rb +0 -25
  178. data/lib/masks/middleware.rb +0 -70
  179. data/lib/tasks/masks_tasks.rake +0 -72
  180. 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
- # frozen_string_literal: true
2
-
3
- Masks::Engine.routes.draw do
4
- get "/debug", to: "debug#show" if Rails.env.development?
5
-
6
- # signup/login
7
- get "session", to: "sessions#new", as: :session
8
- post "session", to: "sessions#create"
9
- delete "session", to: "sessions#destroy"
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
- # 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
- # frozen_string_literal: true
1
+ require "rails/generators/base"
2
2
 
3
3
  module Masks
4
- # Generator for +rails g masks:install+.
5
- class InstallGenerator < ::Rails::Generators::Base
6
- source_root File.expand_path("templates", __dir__)
4
+ module Generators
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
7
 
8
- def add_routing
9
- route 'mount Masks::Engine => "/"'
10
- end
8
+ desc "Mount the masks engine, write an initializer, and say what to set."
11
9
 
12
- def copy_initializer_file
13
- copy_file "initializer.rb", "config/initializers/masks.rb"
14
- end
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
- def copy_masks_json
17
- copy_file "masks.json", "masks.json"
18
- end
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
- def add_migrations
21
- if yes?("generate migrations for masks?")
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
- puts
29
- puts "[masks] welcome!"
30
- puts "[masks] visit https://masks.geiger.to for more information."
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