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,92 @@
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 avatar_styles
55
+ discovery["avatar_styles_supported"] || Claims::Avatars::STYLES
56
+ end
57
+
58
+ def avatar_url(subject, style: nil, size: nil)
59
+ wanted = (style || Claims::Avatars::FALLBACK).to_s
60
+
61
+ unless avatar_styles.include?(wanted)
62
+ raise Rejected.new("invalid_style", "#{url} does not serve #{wanted} avatars")
63
+ end
64
+
65
+ query = size ? "?size=#{size.to_i}" : ""
66
+
67
+ "#{endpoint('avatar_endpoint')}/#{subject}/#{wanted}#{query}"
68
+ end
69
+
70
+ def refresh!
71
+ @lock.synchronize { @cache = {} }
72
+ self
73
+ end
74
+
75
+ private
76
+
77
+ def fetch(key)
78
+ cached = @lock.synchronize { @cache[key] }
79
+ return cached[:value] if fresh?(cached)
80
+
81
+ value = yield
82
+
83
+ @lock.synchronize { @cache[key] = { value: value, at: Time.now.to_i } }
84
+ value
85
+ end
86
+
87
+ def fresh?(entry)
88
+ entry && entry[:at] + @ttl > Time.now.to_i
89
+ end
90
+ end
91
+ end
92
+ 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
@@ -0,0 +1,62 @@
1
+ module Masks
2
+ module Client
3
+ class Rack
4
+ CLAIMS = "masks.claims".freeze
5
+ ERROR = "masks.error".freeze
6
+
7
+ def initialize(app, resource:, scope: nil, only: nil, optional: false)
8
+ @app = app
9
+ @resource = resource
10
+ @scope = scope
11
+ @only = only
12
+ @optional = optional
13
+ end
14
+
15
+ def call(env)
16
+ return @app.call(env) unless guards?(env)
17
+
18
+ env[CLAIMS] = resource(env).authenticate(env["HTTP_AUTHORIZATION"], scope: @scope)
19
+
20
+ @app.call(env)
21
+ rescue Challenge => e
22
+ return passthrough(env, e) if @optional && e.is_a?(Unauthenticated)
23
+
24
+ refuse(resource(env), e)
25
+ end
26
+
27
+ private
28
+
29
+ def guards?(env)
30
+ return true if @only.nil?
31
+
32
+ @only.call(env)
33
+ end
34
+
35
+ def passthrough(env, error)
36
+ env[ERROR] = error
37
+
38
+ @app.call(env)
39
+ end
40
+
41
+ def resource(env)
42
+ @resource.respond_to?(:call) ? @resource.call(env) : @resource
43
+ end
44
+
45
+ def refuse(resource, error)
46
+ body = JSON.generate(
47
+ { "error" => error.code, "error_description" => error.description }.compact
48
+ )
49
+
50
+ [
51
+ error.status,
52
+ {
53
+ "content-type" => "application/json",
54
+ "cache-control" => "no-store",
55
+ "www-authenticate" => resource.challenge(error)
56
+ },
57
+ [ body ]
58
+ ]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,115 @@
1
+ module Masks
2
+ module Client
3
+ class Registration
4
+ attr_reader :issuer, :metadata, :access_token
5
+
6
+ def self.create(issuer, token: nil, **attributes)
7
+ issuer = Issuer.resolve(issuer)
8
+ headers = token ? { "Authorization" => "Bearer #{token}" } : {}
9
+ body = HTTP.post_json(
10
+ issuer.endpoint("registration_endpoint"), stringify(attributes), headers
11
+ )
12
+
13
+ new(issuer, body)
14
+ end
15
+
16
+ def self.stringify(attributes)
17
+ {
18
+ "client_name" => attributes[:name] || attributes[:client_name],
19
+ "redirect_uris" => Array(attributes[:redirect_uris]),
20
+ "grant_types" => fallback(attributes[:grant_types], %w[authorization_code]),
21
+ "response_types" => fallback(attributes[:response_types], %w[code]),
22
+ "scope" => Array(attributes[:scope]).join(" "),
23
+ "token_endpoint_auth_method" => attributes[:token_endpoint_auth_method],
24
+ "application_type" => attributes[:application_type],
25
+ "client_uri" => attributes[:client_uri],
26
+ "logo_uri" => attributes[:logo_uri]
27
+ }.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
28
+ end
29
+
30
+ def self.fallback(value, default)
31
+ list = Array(value)
32
+ list.empty? ? default : list
33
+ end
34
+
35
+ # Rebuild a registration from what a consumer stored, so RFC 7592's read,
36
+ # update and delete are reachable without having just created it.
37
+ def self.held(issuer, credentials)
38
+ held = credentials.to_h.transform_keys(&:to_s)
39
+ return nil if held["registration_client_uri"].blank? || held["registration_access_token"].blank?
40
+
41
+ new(Issuer.resolve(issuer), held)
42
+ end
43
+
44
+ def initialize(issuer, body)
45
+ @issuer = issuer
46
+ @metadata = body
47
+ @access_token = body["registration_access_token"]
48
+ end
49
+
50
+ def client_id
51
+ metadata["client_id"]
52
+ end
53
+
54
+ def client_secret
55
+ metadata["client_secret"]
56
+ end
57
+
58
+ def uri
59
+ metadata["registration_client_uri"]
60
+ end
61
+
62
+ REFUSED = [ 401, 403 ].freeze
63
+ GONE = (REFUSED + [ 404 ]).freeze
64
+
65
+ def read
66
+ still_ours(REFUSED) { HTTP.get(uri, authorization) }
67
+ end
68
+
69
+ def update(**attributes)
70
+ @metadata = metadata.merge(
71
+ still_ours(REFUSED) { HTTP.put_json(uri, self.class.stringify(attributes), authorization) }
72
+ )
73
+ self
74
+ end
75
+
76
+ def delete
77
+ still_ours(GONE) { HTTP.delete(uri, authorization) }
78
+ true
79
+ end
80
+
81
+ def known?
82
+ read
83
+ true
84
+ rescue Unregistered
85
+ false
86
+ end
87
+
88
+ def authorization
89
+ { "Authorization" => "Bearer #{access_token}" }
90
+ end
91
+
92
+ def session(redirect_uri:, scope: Session::DEFAULT_SCOPE)
93
+ Session.new(
94
+ issuer: issuer,
95
+ client_id: client_id,
96
+ client_secret: client_secret,
97
+ redirect_uri: redirect_uri,
98
+ scope: scope
99
+ )
100
+ end
101
+
102
+ private
103
+
104
+ def still_ours(statuses)
105
+ yield
106
+ rescue Unregistered
107
+ raise
108
+ rescue Rejected => e
109
+ raise unless statuses.include?(e.status)
110
+
111
+ raise Unregistered.new(Unregistered::CODE, e.description, status: e.status)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,25 @@
1
+ module Masks
2
+ module Client
3
+ class Registry
4
+ def initialize
5
+ @issuers = {}
6
+ @lock = Mutex.new
7
+ end
8
+
9
+ def [](url, ttl: Issuer::TTL)
10
+ key = Issuer.normalize(url)
11
+
12
+ @lock.synchronize { @issuers[key] ||= Issuer.new(key, ttl: ttl) }
13
+ end
14
+
15
+ def clear!
16
+ @lock.synchronize { @issuers.clear }
17
+ self
18
+ end
19
+
20
+ def urls
21
+ @lock.synchronize { @issuers.keys }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,92 @@
1
+ module Masks
2
+ module Client
3
+ class Resource
4
+ BEARER = /\ABearer[ \t]+([^\s,]+)[ \t]*\z/i.freeze
5
+ METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
6
+ REQUIRED = %w[iss sub exp].freeze
7
+
8
+ attr_reader :issuer, :url, :scopes
9
+
10
+ def initialize(issuer:, url:, scopes: [], metadata_url: nil,
11
+ algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil)
12
+ @issuer = Issuer.resolve(issuer)
13
+ @url = url.to_s
14
+ @descriptions = describe(scopes)
15
+ @scopes = (@descriptions.any? ? @descriptions.keys : Array(scopes).map(&:to_s)).freeze
16
+ @metadata_url = metadata_url
17
+ @required = Array(required)
18
+ @verifier = verifier || Verifier.new(@issuer, audience: @url, algorithms: algorithms)
19
+ end
20
+
21
+ def authenticate(authorization, scope: nil)
22
+ claims = Claims.new(@verifier.verify(token!(authorization), required: @required))
23
+
24
+ Array(scope).each { |name| claims.permit!(name) }
25
+
26
+ claims
27
+ rescue InvalidToken => e
28
+ raise Unauthorized.new(e.message)
29
+ end
30
+
31
+ def token(authorization)
32
+ authorization.to_s[BEARER, 1]
33
+ end
34
+
35
+ def metadata_url
36
+ @metadata_url ||= "#{origin}#{METADATA_PATH}"
37
+ end
38
+
39
+ def metadata
40
+ {
41
+ "resource" => url,
42
+ "authorization_servers" => [ issuer.url ],
43
+ "scopes_supported" => scopes,
44
+ "scope_descriptions" => @descriptions,
45
+ "bearer_methods_supported" => [ "header" ]
46
+ }.reject { |_, value| value.respond_to?(:empty?) && value.empty? }
47
+ end
48
+
49
+ def challenge(error = nil)
50
+ error = Unauthorized.new(error.to_s) unless error.is_a?(Challenge)
51
+
52
+ parameters = [
53
+ [ "error", error.code ],
54
+ [ "error_description", error.description ],
55
+ [ "scope", error.scope || (scopes.join(" ") if scopes.any?) ],
56
+ [ "resource_metadata", metadata_url ]
57
+ ]
58
+
59
+ "Bearer " + parameters.filter_map { |name, value|
60
+ %(#{name}="#{quote(value)}") if value && !value.to_s.empty?
61
+ }.join(", ")
62
+ end
63
+
64
+ private
65
+
66
+ def describe(scopes)
67
+ return {} unless scopes.is_a?(Hash)
68
+
69
+ scopes.each_with_object({}) do |(scope, description), held|
70
+ held[scope.to_s] = description.to_s
71
+ end.freeze
72
+ end
73
+
74
+ def token!(authorization)
75
+ token(authorization) || raise(Unauthenticated.new)
76
+ end
77
+
78
+ def origin
79
+ uri = URI.parse(url)
80
+ port = ":#{uri.port}" unless uri.port.nil? || uri.default_port == uri.port
81
+
82
+ "#{uri.scheme}://#{uri.host}#{port}"
83
+ rescue URI::InvalidURIError
84
+ url
85
+ end
86
+
87
+ def quote(value)
88
+ value.to_s.gsub(/[\\"]/, " ").gsub(/[[:cntrl:]]/, " ").strip
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,146 @@
1
+ module Masks
2
+ module Client
3
+ class Session
4
+ DEFAULT_SCOPE = %w[openid profile email].freeze
5
+
6
+ attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope
7
+
8
+ def initialize(issuer:, client_id:, redirect_uri:, client_secret: nil, scope: DEFAULT_SCOPE)
9
+ @issuer = Issuer.resolve(issuer)
10
+ @client_id = client_id
11
+ @client_secret = client_secret
12
+ @redirect_uri = redirect_uri
13
+ @scope = Array(scope)
14
+ end
15
+
16
+ def start(resource: nil, prompt: nil, scope: nil, state: SecureRandom.urlsafe_base64(24),
17
+ nonce: SecureRandom.urlsafe_base64(24))
18
+ pkce = Pkce.generate
19
+ scopes = Array(scope || self.scope)
20
+ nonce = nil unless scopes.include?("openid")
21
+
22
+ pairs = [
23
+ [ "response_type", "code" ],
24
+ [ "client_id", client_id ],
25
+ [ "redirect_uri", redirect_uri ],
26
+ [ "scope", scopes.join(" ") ],
27
+ [ "state", state ],
28
+ [ "code_challenge", pkce.challenge ],
29
+ [ "code_challenge_method", pkce.method ]
30
+ ]
31
+
32
+ pairs << [ "nonce", nonce ] if nonce
33
+
34
+ Array(resource).each { |value| pairs << [ "resource", value ] }
35
+ pairs << [ "prompt", prompt ] if prompt
36
+
37
+ {
38
+ url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
39
+ state: state,
40
+ nonce: nonce,
41
+ verifier: pkce.verifier
42
+ }
43
+ end
44
+
45
+ def complete(code:, verifier:, resource: nil)
46
+ form = [
47
+ [ "grant_type", "authorization_code" ],
48
+ [ "code", code ],
49
+ [ "client_id", client_id ],
50
+ [ "redirect_uri", redirect_uri ],
51
+ [ "code_verifier", verifier ]
52
+ ]
53
+
54
+ Array(resource).each { |value| form << [ "resource", value ] }
55
+
56
+ Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
57
+ end
58
+
59
+ def refresh(refresh_token, resource: nil, scope: nil)
60
+ form = [
61
+ [ "grant_type", "refresh_token" ],
62
+ [ "refresh_token", refresh_token ],
63
+ [ "client_id", client_id ]
64
+ ]
65
+
66
+ form << [ "scope", Array(scope).join(" ") ] if scope
67
+ Array(resource).each { |value| form << [ "resource", value ] }
68
+
69
+ Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
70
+ end
71
+
72
+ def exchange(subject_token, scope: nil, resource: nil, lifetime: nil)
73
+ form = [
74
+ [ "grant_type", Tokens::EXCHANGE ],
75
+ [ "client_id", client_id ],
76
+ [ "subject_token", subject_token ],
77
+ [ "subject_token_type", Tokens::ACCESS_TOKEN ]
78
+ ]
79
+
80
+ form << [ "scope", Array(scope).join(" ") ] if scope
81
+ form << [ "requested_lifetime", lifetime.to_i ] if lifetime
82
+ Array(resource).each { |value| form << [ "resource", value ] }
83
+
84
+ Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
85
+ end
86
+
87
+ def revoke(token, hint: nil)
88
+ form = [ [ "token", token ], [ "client_id", client_id ] ]
89
+ form << [ "token_type_hint", hint ] if hint
90
+
91
+ HTTP.post_form(issuer.endpoint("revocation_endpoint"), form, authorization)
92
+ true
93
+ end
94
+
95
+ def end_session_url(post_logout_redirect_uri: nil, state: nil, id_token_hint: nil)
96
+ pairs = [ [ "client_id", client_id ] ]
97
+ pairs << [ "id_token_hint", id_token_hint ] if id_token_hint
98
+ pairs << [ "post_logout_redirect_uri", post_logout_redirect_uri ] if post_logout_redirect_uri
99
+ pairs << [ "state", state ] if state
100
+
101
+ "#{issuer.endpoint('end_session_endpoint')}?#{URI.encode_www_form(pairs)}"
102
+ end
103
+
104
+ def introspect(token, hint: nil)
105
+ form = [ [ "token", token ], [ "client_id", client_id ] ]
106
+ form << [ "token_type_hint", hint ] if hint
107
+
108
+ Introspection.new(
109
+ HTTP.post_form(issuer.endpoint("introspection_endpoint"), form, authorization)
110
+ )
111
+ end
112
+
113
+ def userinfo(access_token)
114
+ HTTP.get(issuer.endpoint("userinfo_endpoint"), "Authorization" => "Bearer #{access_token}")
115
+ end
116
+
117
+ def identity(tokens)
118
+ return nil if tokens.id_token.nil?
119
+
120
+ claims = Verifier.new(issuer, audience: client_id).verify(tokens.id_token)
121
+
122
+ profile(tokens).merge(claims)
123
+ end
124
+
125
+ def profile(tokens)
126
+ return {} if tokens.access_token.nil?
127
+
128
+ userinfo(tokens.access_token)
129
+ rescue Masks::Client::Error
130
+ {}
131
+ end
132
+
133
+ private
134
+
135
+ def authorization
136
+ return {} if client_secret.nil?
137
+
138
+ encoded = Base64.strict_encode64(
139
+ "#{URI.encode_www_form_component(client_id)}:#{URI.encode_www_form_component(client_secret)}"
140
+ )
141
+
142
+ { "Authorization" => "Basic #{encoded}" }
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,40 @@
1
+ module Masks
2
+ module Client
3
+ module Stores
4
+ class Memory
5
+ attr_reader :held
6
+
7
+ def initialize(held = {})
8
+ @held = held
9
+ end
10
+
11
+ def read
12
+ held
13
+ end
14
+
15
+ def write(value)
16
+ @held = value
17
+ end
18
+ end
19
+
20
+ class Session
21
+ def initialize(session, key)
22
+ @session = session
23
+ @key = key
24
+ end
25
+
26
+ def read
27
+ @session[@key]
28
+ end
29
+
30
+ def write(value)
31
+ if value.empty?
32
+ @session.delete(@key)
33
+ else
34
+ @session[@key] = value
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ module Masks
2
+ module Client
3
+ class Tokens
4
+ EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
5
+ ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token".freeze
6
+
7
+ attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at
8
+
9
+ def self.granted(body)
10
+ token = new(body)
11
+
12
+ if token.access_token.to_s.empty?
13
+ raise Rejected.new(
14
+ body["error"] || "invalid_token_response",
15
+ body["error_description"] || "the token endpoint answered without an access_token"
16
+ )
17
+ end
18
+
19
+ token
20
+ end
21
+
22
+ def initialize(body)
23
+ @access_token = body["access_token"]
24
+ @id_token = body["id_token"]
25
+ @refresh_token = body["refresh_token"]
26
+ @token_type = body["token_type"] || "Bearer"
27
+ @scope = body["scope"].to_s
28
+ @expires_in = body["expires_in"].to_i
29
+ @obtained_at = Time.now.to_i
30
+ end
31
+
32
+ def expires_at
33
+ obtained_at + expires_in
34
+ end
35
+
36
+ def expired?(leeway: 30)
37
+ Time.now.to_i + leeway >= expires_at
38
+ end
39
+
40
+ def scopes
41
+ scope.split(/\s+/).reject(&:empty?)
42
+ end
43
+
44
+ def authorization
45
+ "#{token_type} #{access_token}"
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ "access_token" => access_token,
51
+ "id_token" => id_token,
52
+ "refresh_token" => refresh_token,
53
+ "token_type" => token_type,
54
+ "scope" => scope,
55
+ "expires_in" => expires_in,
56
+ "obtained_at" => obtained_at
57
+ }.compact
58
+ end
59
+
60
+ def self.from_h(data)
61
+ return nil if data.nil? || data.empty?
62
+
63
+ tokens = allocate
64
+ tokens.send(:initialize, data)
65
+ tokens.instance_variable_set(:@obtained_at, data["obtained_at"] || Time.now.to_i)
66
+ tokens
67
+ end
68
+ end
69
+ end
70
+ end