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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE +21 -0
  4. data/README.md +215 -29
  5. data/app/controllers/masks/rails/base_controller.rb +13 -0
  6. data/app/controllers/masks/rails/handshakes_controller.rb +87 -0
  7. data/app/controllers/masks/rails/sessions_controller.rb +113 -0
  8. data/app/views/layouts/masks/rails/plain.html.erb +47 -0
  9. data/app/views/masks/rails/handshakes/refused.html.erb +12 -0
  10. data/app/views/masks/rails/handshakes/show.html.erb +40 -0
  11. data/config/routes.rb +10 -45
  12. data/lib/generators/masks/install/install_generator.rb +35 -22
  13. data/lib/generators/masks/install/templates/masks.rb.tt +38 -0
  14. data/lib/masks/client/claims.rb +103 -0
  15. data/lib/masks/client/errors.rb +52 -0
  16. data/lib/masks/client/handshake.rb +104 -0
  17. data/lib/masks/client/http.rb +81 -0
  18. data/lib/masks/client/introspection.rb +29 -0
  19. data/lib/masks/client/issuer.rb +76 -0
  20. data/lib/masks/client/pkce.rb +27 -0
  21. data/lib/masks/client/rack.rb +62 -0
  22. data/lib/masks/client/registration.rb +91 -0
  23. data/lib/masks/client/registry.rb +25 -0
  24. data/lib/masks/client/resource.rb +92 -0
  25. data/lib/masks/client/session.rb +146 -0
  26. data/lib/masks/client/stores.rb +40 -0
  27. data/lib/masks/client/tokens.rb +70 -0
  28. data/lib/masks/client/tracker.rb +122 -0
  29. data/lib/masks/client/verifier.rb +42 -0
  30. data/lib/masks/client.rb +51 -0
  31. data/lib/masks/rails/authentication.rb +217 -0
  32. data/lib/masks/rails/configurable.rb +11 -0
  33. data/lib/masks/rails/configuration.rb +172 -0
  34. data/lib/masks/rails/credentials.rb +60 -0
  35. data/lib/masks/rails/engine.rb +20 -0
  36. data/lib/masks/rails/protected_resource.rb +51 -0
  37. data/lib/masks/rails.rb +10 -0
  38. data/lib/masks/version.rb +1 -3
  39. data/lib/masks.rb +3 -182
  40. metadata +47 -368
  41. data/MIT-LICENSE +0 -20
  42. data/Rakefile +0 -11
  43. data/app/assets/builds/application.css +0 -4764
  44. data/app/assets/builds/application.js +0 -8236
  45. data/app/assets/builds/application.js.map +0 -7
  46. data/app/assets/builds/masks/application.css +0 -1
  47. data/app/assets/builds/masks/application.js +0 -8462
  48. data/app/assets/builds/masks/application.js.map +0 -7
  49. data/app/assets/images/masks.png +0 -0
  50. data/app/assets/javascripts/application.js +0 -2
  51. data/app/assets/javascripts/controllers/application.js +0 -9
  52. data/app/assets/javascripts/controllers/emails_controller.js +0 -28
  53. data/app/assets/javascripts/controllers/index.js +0 -12
  54. data/app/assets/javascripts/controllers/keys_controller.js +0 -20
  55. data/app/assets/javascripts/controllers/recover_controller.js +0 -21
  56. data/app/assets/javascripts/controllers/recover_password_controller.js +0 -21
  57. data/app/assets/javascripts/controllers/session_controller.js +0 -94
  58. data/app/assets/manifest.js +0 -2
  59. data/app/assets/masks_manifest.js +0 -2
  60. data/app/assets/stylesheets/application.css +0 -26
  61. data/app/controllers/concerns/masks/controller.rb +0 -114
  62. data/app/controllers/masks/actors_controller.rb +0 -15
  63. data/app/controllers/masks/application_controller.rb +0 -35
  64. data/app/controllers/masks/backup_codes_controller.rb +0 -34
  65. data/app/controllers/masks/debug_controller.rb +0 -9
  66. data/app/controllers/masks/devices_controller.rb +0 -20
  67. data/app/controllers/masks/emails_controller.rb +0 -60
  68. data/app/controllers/masks/error_controller.rb +0 -14
  69. data/app/controllers/masks/keys_controller.rb +0 -45
  70. data/app/controllers/masks/manage/actor_controller.rb +0 -35
  71. data/app/controllers/masks/manage/actors_controller.rb +0 -12
  72. data/app/controllers/masks/manage/base_controller.rb +0 -12
  73. data/app/controllers/masks/one_time_code_controller.rb +0 -49
  74. data/app/controllers/masks/passwords_controller.rb +0 -33
  75. data/app/controllers/masks/recoveries_controller.rb +0 -43
  76. data/app/controllers/masks/sessions_controller.rb +0 -53
  77. data/app/helpers/masks/application_helper.rb +0 -49
  78. data/app/jobs/masks/application_job.rb +0 -7
  79. data/app/jobs/masks/expire_actors_job.rb +0 -15
  80. data/app/jobs/masks/expire_recoveries_job.rb +0 -15
  81. data/app/mailers/masks/actor_mailer.rb +0 -22
  82. data/app/mailers/masks/application_mailer.rb +0 -15
  83. data/app/models/concerns/masks/access.rb +0 -162
  84. data/app/models/concerns/masks/actor.rb +0 -132
  85. data/app/models/concerns/masks/adapter.rb +0 -68
  86. data/app/models/concerns/masks/role.rb +0 -9
  87. data/app/models/concerns/masks/scoped.rb +0 -54
  88. data/app/models/masks/access/actor_password.rb +0 -20
  89. data/app/models/masks/access/actor_scopes.rb +0 -18
  90. data/app/models/masks/access/actor_signup.rb +0 -22
  91. data/app/models/masks/actors/anonymous.rb +0 -40
  92. data/app/models/masks/actors/system.rb +0 -24
  93. data/app/models/masks/adapters/active_record.rb +0 -85
  94. data/app/models/masks/application_model.rb +0 -15
  95. data/app/models/masks/application_record.rb +0 -8
  96. data/app/models/masks/check.rb +0 -192
  97. data/app/models/masks/credential.rb +0 -166
  98. data/app/models/masks/credentials/backup_code.rb +0 -30
  99. data/app/models/masks/credentials/device.rb +0 -59
  100. data/app/models/masks/credentials/email.rb +0 -48
  101. data/app/models/masks/credentials/factor2.rb +0 -71
  102. data/app/models/masks/credentials/key.rb +0 -38
  103. data/app/models/masks/credentials/last_login.rb +0 -12
  104. data/app/models/masks/credentials/masquerade.rb +0 -32
  105. data/app/models/masks/credentials/nickname.rb +0 -63
  106. data/app/models/masks/credentials/one_time_code.rb +0 -34
  107. data/app/models/masks/credentials/password.rb +0 -28
  108. data/app/models/masks/credentials/recovery.rb +0 -71
  109. data/app/models/masks/credentials/session.rb +0 -67
  110. data/app/models/masks/device.rb +0 -30
  111. data/app/models/masks/error.rb +0 -51
  112. data/app/models/masks/event.rb +0 -14
  113. data/app/models/masks/mask.rb +0 -255
  114. data/app/models/masks/rails/actor.rb +0 -190
  115. data/app/models/masks/rails/actor_role.rb +0 -12
  116. data/app/models/masks/rails/device.rb +0 -47
  117. data/app/models/masks/rails/email.rb +0 -96
  118. data/app/models/masks/rails/key.rb +0 -61
  119. data/app/models/masks/rails/recovery.rb +0 -116
  120. data/app/models/masks/rails/role.rb +0 -20
  121. data/app/models/masks/rails/scope.rb +0 -15
  122. data/app/models/masks/session.rb +0 -447
  123. data/app/models/masks/sessions/access.rb +0 -26
  124. data/app/models/masks/sessions/inline.rb +0 -16
  125. data/app/models/masks/sessions/request.rb +0 -42
  126. data/app/resources/masks/actor_resource.rb +0 -9
  127. data/app/resources/masks/session_resource.rb +0 -15
  128. data/app/views/layouts/masks/application.html.erb +0 -17
  129. data/app/views/layouts/masks/mailer.html.erb +0 -17
  130. data/app/views/layouts/masks/mailer.text.erb +0 -1
  131. data/app/views/layouts/masks/manage.html.erb +0 -25
  132. data/app/views/masks/actor_mailer/recover_credentials.html.erb +0 -33
  133. data/app/views/masks/actor_mailer/recover_credentials.text.erb +0 -1
  134. data/app/views/masks/actor_mailer/verify_email.html.erb +0 -34
  135. data/app/views/masks/actor_mailer/verify_email.text.erb +0 -8
  136. data/app/views/masks/actors/current.html.erb +0 -152
  137. data/app/views/masks/application/_header.html.erb +0 -31
  138. data/app/views/masks/backup_codes/new.html.erb +0 -103
  139. data/app/views/masks/emails/new.html.erb +0 -103
  140. data/app/views/masks/emails/verify.html.erb +0 -51
  141. data/app/views/masks/keys/new.html.erb +0 -127
  142. data/app/views/masks/manage/actor/show.html.erb +0 -126
  143. data/app/views/masks/manage/actors/index.html.erb +0 -40
  144. data/app/views/masks/one_time_code/new.html.erb +0 -150
  145. data/app/views/masks/passwords/edit.html.erb +0 -58
  146. data/app/views/masks/recoveries/new.html.erb +0 -71
  147. data/app/views/masks/recoveries/password.html.erb +0 -64
  148. data/app/views/masks/sessions/new.html.erb +0 -153
  149. data/config/brakeman.ignore +0 -28
  150. data/config/locales/en.yml +0 -286
  151. data/db/migrate/20231205173845_create_actors.rb +0 -94
  152. data/lib/generators/masks/install/USAGE +0 -8
  153. data/lib/generators/masks/install/templates/initializer.rb +0 -5
  154. data/lib/generators/masks/install/templates/masks.json +0 -6
  155. data/lib/masks/configuration.rb +0 -236
  156. data/lib/masks/engine.rb +0 -25
  157. data/lib/masks/middleware.rb +0 -70
  158. data/lib/tasks/masks_tasks.rake +0 -72
  159. data/masks.json +0 -277
data/config/routes.rb CHANGED
@@ -1,46 +1,11 @@
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
- # 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
- # 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,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