masks 0.6.0 → 0.8.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.
data/lib/masks/client.rb CHANGED
@@ -19,33 +19,79 @@ require_relative "client/claims"
19
19
  require_relative "client/introspection"
20
20
  require_relative "client/session"
21
21
  require_relative "client/verifier"
22
+ require_relative "client/logout"
22
23
  require_relative "client/resource"
23
24
  require_relative "client/rack"
24
25
  require_relative "client/registration"
25
26
  require_relative "client/handshake"
27
+ require_relative "client/delegations"
26
28
 
27
29
  module Masks
30
+ # = Masks::Client
31
+ #
32
+ # The protocol half of the gem: plain Ruby, no \Rails, no database. Every
33
+ # entry point here is a class method that builds one of the objects below.
34
+ #
35
+ # Which one you want depends on what the application is doing:
36
+ #
37
+ # [issuer] an app signing people in — discovery, PKCE, the code exchange
38
+ # [verifier] an API checking a bearer it was handed
39
+ # [resource] an API publishing what it is and which scopes it takes
40
+ # [handshake] an app registering itself, once, without a copied secret
41
+ #
42
+ # Everything reachable from here talks HTTP to a masks issuer and holds no
43
+ # state of its own beyond the discovery cache in ::registry.
28
44
  module Client
29
45
  class << self
46
+ # The process-wide cache of resolved issuers, so discovery is fetched
47
+ # once rather than per request.
48
+ #
49
+ # @return [Masks::Client::Registry]
30
50
  def registry
31
51
  @registry ||= Registry.new
32
52
  end
33
53
 
54
+ # Resolves +url+ through its discovery document and returns the issuer
55
+ # it describes. Cached in ::registry, so calling this per request is
56
+ # cheap after the first.
57
+ #
58
+ # issuer = Masks::Client.issuer("https://auth.example")
59
+ # issuer.authorization_url(client_id: id, redirect_uri: uri)
60
+ #
61
+ # @return [Masks::Client::Issuer]
34
62
  def issuer(url, **options)
35
63
  Issuer.resolve(url, **options)
36
64
  end
37
65
 
66
+ # Checks tokens minted by the issuer at +url+ against +audience+ — the
67
+ # API's own URL, which the token names in +aud+. A token issued for
68
+ # anything else is refused rather than merely noted.
69
+ #
70
+ # @return [Masks::Client::Verifier]
38
71
  def verifier(url, audience:, **options)
39
72
  Verifier.new(issuer(url), audience: audience, **options)
40
73
  end
41
74
 
75
+ # Describes this API to callers: the scopes it accepts and the issuer
76
+ # that may mint tokens for it, served as RFC 9728 metadata.
77
+ #
78
+ # @return [Masks::Client::Resource]
42
79
  def resource(url, issuer:, **options)
43
80
  Resource.new(issuer: issuer, url: url, **options)
44
81
  end
45
82
 
83
+ # Registers this application against a masks issuer. A person approves
84
+ # it in their browser and the credentials come back server to server, so
85
+ # no secret is pasted between the two.
86
+ #
87
+ # @return [Masks::Client::Handshake]
46
88
  def handshake(url, **options)
47
89
  Handshake.new(url, **options)
48
90
  end
91
+
92
+ def delegations(issuer, **options)
93
+ Delegations.new(issuer: issuer, **options)
94
+ end
49
95
  end
50
96
  end
51
97
  end
@@ -7,7 +7,15 @@ module Masks
7
7
  included do
8
8
  if respond_to?(:helper_method)
9
9
  helper_method :masks_signed_in?, :masks_identity, :masks_tenant, :masks_scopes,
10
- :masks_claims
10
+ :masks_claims, :masks_organization, :masks_organizations, :masks_role?,
11
+ :masks_login_url
12
+ end
13
+ end
14
+
15
+ class_methods do
16
+ def masks_members_only!(role: nil, organization: nil, **options)
17
+ before_action(-> { authorize_masks_member!(*Array(role), organization: organization) },
18
+ **options.slice(:only, :except))
11
19
  end
12
20
  end
13
21
 
@@ -43,6 +51,7 @@ module Masks
43
51
  session[masks_config.session_key] = held.compact
44
52
  @masks_tokens = tokens
45
53
  @masks_identity = held["identity"]
54
+ @masks_claims = nil
46
55
  end
47
56
 
48
57
  def masks_held
@@ -51,7 +60,8 @@ module Masks
51
60
 
52
61
  IDENTITY = [
53
62
  "sub", "name", "preferred_username", "email", "email_verified", "tenant",
54
- "picture", Masks::Client::Claims::AVATARS
63
+ "picture", Masks::Client::Claims::AVATARS, Masks::Client::Claims::ORGANIZATION,
64
+ Masks::Client::Claims::ORGANIZATIONS
55
65
  ].freeze
56
66
 
57
67
  def masks_identity_from(tokens)
@@ -64,6 +74,7 @@ module Masks
64
74
  session.delete(masks_config.session_key)
65
75
  @masks_tokens = nil
66
76
  @masks_identity = nil
77
+ @masks_claims = nil
67
78
  end
68
79
 
69
80
  def masks_signed_in?
@@ -122,6 +133,20 @@ module Masks
122
133
  @masks_claims ||= Masks::Client::Claims.new(masks_identity || {})
123
134
  end
124
135
 
136
+ def masks_organization
137
+ held = masks_claims.organization
138
+
139
+ held.present? ? held : nil
140
+ end
141
+
142
+ def masks_organizations
143
+ masks_claims.organizations
144
+ end
145
+
146
+ def masks_role?(*roles)
147
+ masks_organization&.role?(*roles) || false
148
+ end
149
+
125
150
  def masks_scopes
126
151
  masks_tokens&.scopes || []
127
152
  end
@@ -138,12 +163,12 @@ module Masks
138
163
  return false unless masks_configured?
139
164
  return false if masks_tokens&.refresh_token.nil?
140
165
 
141
- masks_store(
142
- masks_session.refresh(
143
- masks_tokens.refresh_token,
144
- resource: masks_config.resource_for(request)
145
- )
166
+ refreshed = masks_session.refresh(
167
+ masks_tokens.refresh_token,
168
+ resource: masks_config.resource_for(request)
146
169
  )
170
+
171
+ masks_store(refreshed, identity: masks_refreshed_identity(refreshed))
147
172
  true
148
173
  rescue Masks::Client::Unregistered
149
174
  masks_disconnect!
@@ -153,6 +178,16 @@ module Masks
153
178
  false
154
179
  end
155
180
 
181
+ def masks_refreshed_identity(tokens)
182
+ held = masks_held["identity"]
183
+
184
+ return nil unless held.is_a?(Hash)
185
+
186
+ profile = masks_session.profile(tokens)
187
+
188
+ profile.empty? ? nil : held.merge(profile.slice(*IDENTITY))
189
+ end
190
+
156
191
  def masks_logout_url(return_to: nil)
157
192
  return nil unless masks_configured?
158
193
 
@@ -168,11 +203,14 @@ module Masks
168
203
  "#{request.base_url}#{masks_local_path(masks_config.after_sign_out) || '/'}"
169
204
  end
170
205
 
171
- def masks_login_url(return_to: nil)
206
+ def masks_login_url(return_to: nil, organization: nil)
172
207
  path = Masks::Rails::Engine.routes.url_helpers.start_path
173
- target = masks_local_path(return_to)
208
+ query = {
209
+ "return_to" => masks_local_path(return_to),
210
+ "organization" => Masks::Client::Session.organization_key(organization)
211
+ }.compact
174
212
 
175
- target ? "#{path}?return_to=#{CGI.escape(target)}" : path
213
+ query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
176
214
  end
177
215
 
178
216
  def masks_account
@@ -188,11 +226,20 @@ module Masks
188
226
  "picture" => masks_claims.picture,
189
227
  "avatars" => masks_claims.avatars.to_h.presence,
190
228
  "tenant" => masks_tenant,
229
+ "organization" => masks_organization&.to_h,
230
+ "organizations" => masks_organizations.map(&:to_h).presence,
191
231
  "scopes" => masks_scopes,
192
- "expires_at" => masks_tokens&.expires_at
232
+ "expires_at" => masks_tokens&.expires_at,
233
+ "account_url" => masks_account_url
193
234
  }.compact
194
235
  end
195
236
 
237
+ def masks_account_url
238
+ "#{masks_config.issuer_for(request).to_s.chomp('/')}/"
239
+ rescue Masks::Rails::Configuration::Unconfigured
240
+ nil
241
+ end
242
+
196
243
  def authenticate_masks!
197
244
  return true if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
198
245
 
@@ -200,8 +247,28 @@ module Masks
200
247
  false
201
248
  end
202
249
 
250
+ def authorize_masks_member!(*roles, organization: nil)
251
+ return false unless authenticate_masks!
252
+
253
+ masks_claims.member!(*roles, organization: organization)
254
+ true
255
+ rescue Masks::Client::Forbidden => e
256
+ masks_forbid(e)
257
+ false
258
+ end
259
+
203
260
  private
204
261
 
262
+ def masks_forbid(error)
263
+ response.headers["Cache-Control"] = "no-store"
264
+
265
+ if masks_wants_json?
266
+ render json: { "error" => error.code, "error_description" => error.description }, status: :forbidden
267
+ else
268
+ render plain: error.description, status: :forbidden
269
+ end
270
+ end
271
+
205
272
  def masks_refuse
206
273
  return masks_refuse_json if masks_wants_json?
207
274
  return redirect_to(masks_handshake_path) unless masks_configured?
@@ -5,8 +5,9 @@ module Masks
5
5
 
6
6
  attr_accessor :scope, :namespace, :resource, :resource_scopes, :after_sign_in,
7
7
  :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
8
- :credentials_path, :authenticate_everything
9
- attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget
8
+ :credentials_path, :authenticate_everything, :delegates
9
+ attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
10
+ :delegation_redirect_uri, :organization
10
11
 
11
12
  def initialize
12
13
  @scope = Masks::Client::Session::DEFAULT_SCOPE
@@ -17,12 +18,9 @@ module Masks
17
18
  @sign_out_of_issuer = false
18
19
  @parent_controller = "ActionController::Base"
19
20
  @authenticate_everything = false
21
+ @delegates = false
20
22
  end
21
23
 
22
- # An app that has not said where to keep its credentials gets one file
23
- # under the Rails root, so the handshake works before anybody writes a
24
- # `store` lambda. `things` overrides both because it is multi-tenant,
25
- # which is the interesting case rather than the common one.
26
24
  def default_credentials
27
25
  @default_credentials ||= Credentials.new(
28
26
  credentials_path || ::Rails.root.join("config", "masks.json")
@@ -43,10 +41,20 @@ module Masks
43
41
  "#{request.base_url}#{routes.callback_path}"
44
42
  end
45
43
 
44
+ def delegation_redirect_uri_for(request)
45
+ return nil unless delegates
46
+
47
+ resolve(@delegation_redirect_uri, request)
48
+ end
49
+
46
50
  def resource_for(request)
47
51
  resolve(@resource, request)
48
52
  end
49
53
 
54
+ def organization_for(request)
55
+ resolve(@organization, request)
56
+ end
57
+
50
58
  def credentials_for(request)
51
59
  held = @credentials ? resolve(@credentials, request) : default_credentials.read
52
60
  held ||= {}
@@ -66,8 +74,21 @@ module Masks
66
74
  client_id_for(request).present?
67
75
  end
68
76
 
69
- # An app that keeps its own credentials has to say how to drop them, and
70
- # until it does the engine does not offer a button it cannot honour.
77
+ def backchannel_logout_uri_for(request)
78
+ return nil unless @logged_out.respond_to?(:call)
79
+ return nil unless Masks::Client::Issuer.resolve(issuer_for(request)).backchannel_logout?
80
+
81
+ "#{request.base_url}#{routes.backchannel_logout_path}"
82
+ end
83
+
84
+ def logged_out!(request, logout)
85
+ return false unless @logged_out.respond_to?(:call)
86
+
87
+ @logged_out.arity == 1 ? @logged_out.call(logout) : @logged_out.call(request, logout)
88
+
89
+ true
90
+ end
91
+
71
92
  def can_forget?
72
93
  @forget.respond_to?(:call) || !@store.respond_to?(:call)
73
94
  end
@@ -85,10 +106,6 @@ module Masks
85
106
  @store.call(request, registration)
86
107
  end
87
108
 
88
- # The engine depends on `handshake_endpoint` being in the discovery
89
- # document, and on the approval flow behind it. An issuer that predates
90
- # both should say so here rather than at the one screen that exists to
91
- # be the first thing anybody touches.
92
109
  MINIMUM_ISSUER = 1
93
110
 
94
111
  def issuer_speaks!(request)
@@ -132,22 +149,21 @@ module Masks
132
149
  name: name_for(request),
133
150
  resource: Array(resource_for(request)).first ||
134
151
  raise(Unconfigured, "Masks::Rails.config.resource is not set"),
135
- redirect_uris: [ redirect_uri_for(request) ],
152
+ redirect_uris: [ redirect_uri_for(request), delegation_redirect_uri_for(request) ].compact.uniq,
136
153
  return_to: return_to_for(request),
137
- scope: approved_scope
154
+ scope: approved_scope,
155
+ backchannel_logout_uri: backchannel_logout_uri_for(request)
138
156
  )
139
157
  end
140
158
 
141
- # What the handshake asks to be approved for, which is not what a sign-in
142
- # requests. An app that owns a namespace asks for the namespace once, so
143
- # that adding a capability later is a deployment rather than an approval
144
- # round; the authorize request still names the scopes it actually wants.
145
159
  def approved_scope
146
- return scope if namespace.blank?
160
+ delegated = delegates ? [ Masks::Client::Delegations::SCOPE ] : []
161
+
162
+ return Array(scope) + delegated if namespace.blank?
147
163
 
148
164
  outside = Array(scope).reject { |name| name.to_s.start_with?(namespace) }
149
165
 
150
- outside + [ namespace ]
166
+ outside + [ namespace ] + delegated
151
167
  end
152
168
 
153
169
  def return_to_for(request)
@@ -1,9 +1,5 @@
1
1
  module Masks
2
2
  module Rails
3
- # The default place a single-tenant app keeps what the handshake gives it,
4
- # so `config.store` has something to be before a consumer writes one. An
5
- # app with more than one issuer wants its own — `things` keeps these on
6
- # `tenants`, because a handshake is per tenant there.
7
3
  class Credentials
8
4
  KEYS = %w[client_id client_secret registration_access_token registration_client_uri].freeze
9
5
 
@@ -5,11 +5,6 @@ module Masks
5
5
 
6
6
  config.masks = Masks::Rails.config
7
7
 
8
- # Opt-in, not blanket. This used to `include Masks::Rails::Authentication`
9
- # into every controller in the host app, which lands twenty-five `masks_*`
10
- # methods and four helper_methods on code the consumer owns, with no way
11
- # to say no — and a name collision found that way is the kind of thing
12
- # `grants:` versus `scope:` already cost this project once.
13
8
  initializer "masks.authentication" do
14
9
  ActiveSupport.on_load(:action_controller) do
15
10
  include Masks::Rails::Authentication if Masks::Rails.config.authenticate_everything
@@ -18,15 +18,16 @@ module Masks
18
18
  @masks_claims if defined?(@masks_claims)
19
19
  end
20
20
 
21
- def masks_authenticate!(scope: nil, **)
22
- @masks_claims = masks_resource.authenticate(request.authorization, scope: scope)
21
+ def masks_authenticate!(scope: nil, role: nil, organization: nil, **)
22
+ @masks_claims = masks_resource.authenticate(request.authorization, scope: scope, role: role,
23
+ organization: organization)
23
24
  rescue Masks::Client::Challenge => e
24
25
  masks_challenge(e)
25
26
  false
26
27
  end
27
28
 
28
- def masks_authenticate(scope: nil)
29
- masks_resource.authenticate(request.authorization, scope: scope)
29
+ def masks_authenticate(scope: nil, role: nil, organization: nil)
30
+ masks_resource.authenticate(request.authorization, scope: scope, role: role, organization: organization)
30
31
  rescue Masks::Client::Unauthenticated
31
32
  nil
32
33
  end
data/lib/masks/rails.rb CHANGED
@@ -1,5 +1,31 @@
1
1
  require "rails"
2
2
 
3
+ module Masks
4
+ # = Masks::Rails
5
+ #
6
+ # The consumer half: a \Rails engine that mounts the code flow into an
7
+ # application, so signing in against a masks issuer is configuration rather
8
+ # than a controller you write.
9
+ #
10
+ # It loads only when +Rails::Engine+ is already defined. Requiring the gem
11
+ # from a plain Ruby process gets Masks::Client and nothing else.
12
+ #
13
+ # Three pieces do the work:
14
+ #
15
+ # [Configuration] the issuer, credentials and routes, set once in an
16
+ # initializer and validated on boot rather than on the
17
+ # first request that needs them
18
+ # [Authentication] +masks_login_url+, the callback, and the session the
19
+ # app reads +current_actor+ from
20
+ # [ProtectedResource] the other direction — checking a bearer this app was
21
+ # handed, for an API rather than a browser
22
+ #
23
+ # This engine is the client. The provider is the masks-server gem, whether
24
+ # it runs elsewhere or is mounted on a subdomain of this app.
25
+ module Rails
26
+ end
27
+ end
28
+
3
29
  require_relative "version"
4
30
  require_relative "client"
5
31
  require_relative "rails/credentials"
data/lib/masks/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Masks
2
- VERSION = "0.6.0".freeze
2
+ VERSION = "0.8.0".freeze
3
3
  end
data/lib/masks.rb CHANGED
@@ -1,3 +1,20 @@
1
+ # = \Masks
2
+ #
3
+ # Signs an application in against a masks issuer.
4
+ #
5
+ # The gem is two halves. Masks::Client is plain Ruby and speaks the protocol —
6
+ # discovery, PKCE, the code exchange, token verification. Masks::Rails mounts
7
+ # that flow into a \Rails app, and loads only when +Rails::Engine+ is already
8
+ # defined, so requiring this gem outside \Rails costs nothing.
9
+ #
10
+ # issuer = Masks::Client.issuer("https://auth.example")
11
+ # issuer.authorization_url(client_id: id, redirect_uri: uri)
12
+ #
13
+ # The provider itself is not in here. It is the masks-server gem, which runs
14
+ # as an app of its own or mounted inside another Rails app.
15
+ module Masks
16
+ end
17
+
1
18
  require_relative "masks/version"
2
19
  require_relative "masks/client"
3
20
 
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: masks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
- - geiger
7
+ - the masks authors
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -25,7 +25,8 @@ dependencies:
25
25
  version: '3.1'
26
26
  description: Discovery, PKCE authorization, token exchange, and token verification
27
27
  against a masks issuer, with Rack middleware for a resource server and a Rails engine
28
- that mounts the consumer half of the code flow. The auth server itself stays standalone.
28
+ that mounts the consumer half of the code flow. The auth server itself is the masks-server
29
+ gem.
29
30
  executables: []
30
31
  extensions: []
31
32
  extra_rdoc_files: []
@@ -36,6 +37,7 @@ files:
36
37
  - app/controllers/masks/rails/avatars_controller.rb
37
38
  - app/controllers/masks/rails/base_controller.rb
38
39
  - app/controllers/masks/rails/handshakes_controller.rb
40
+ - app/controllers/masks/rails/logouts_controller.rb
39
41
  - app/controllers/masks/rails/sessions_controller.rb
40
42
  - app/views/layouts/masks/rails/plain.html.erb
41
43
  - app/views/masks/rails/handshakes/refused.html.erb
@@ -46,11 +48,14 @@ files:
46
48
  - lib/masks.rb
47
49
  - lib/masks/client.rb
48
50
  - lib/masks/client/claims.rb
51
+ - lib/masks/client/delegations.rb
52
+ - lib/masks/client/delegations/fake.rb
49
53
  - lib/masks/client/errors.rb
50
54
  - lib/masks/client/handshake.rb
51
55
  - lib/masks/client/http.rb
52
56
  - lib/masks/client/introspection.rb
53
57
  - lib/masks/client/issuer.rb
58
+ - lib/masks/client/logout.rb
54
59
  - lib/masks/client/pkce.rb
55
60
  - lib/masks/client/rack.rb
56
61
  - lib/masks/client/registration.rb