masks 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a915522b23f370321d7eca71a6d4965804d6988e441f6f019b1b1817bab7f0c3
4
- data.tar.gz: 05f68f68ed521f6d378393cadda81bec7b984802f10ab651a5eaa8c9b31d4a7c
3
+ metadata.gz: c0b13e1f370033e4c4512becb39b4d96e4b9d28b39b7f1e773592138626d1151
4
+ data.tar.gz: 8540eeecb1b448047198d5da57e5978d0e4ec249138334e7c39d41a719854a99
5
5
  SHA512:
6
- metadata.gz: 13467dcc61e51edbb9a7493feb38ccb2cf0f1f42db9a127cb37ccb89c0bf3187de211f928bce1dd854d711f3418a0b504bb3a2b0e645ee8ac7d864b47fd8d4c5
7
- data.tar.gz: 4b0b6abea05f55b54b9280de48c8096a8406fdd82e7dcb575584b56679ccb91fc3af11c2766cda26cee44ae8ace19f384a5714a0e3a81b029bcead957d8ba008
6
+ metadata.gz: fc1b4981446fdc5282e21c00c93a63cbc124d3c675512172a849cdd698f0a52839fce8098fdde6443814131f198eef5829d1dd52416e02bb51ef95f7d5fb634a
7
+ data.tar.gz: e40f6519f1c22fefa134217cff31701617551feed13e374f1d801307c2f021cb21eba5b85adc4eb9d28e133e855c4aad4cc05caa4749d34fadd9d7e833493cc1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0](https://github.com/masksrb/masks/compare/gem/v0.9.2...gem/v0.10.0) (2026-10-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **client:** a sign-in can ask for a step-up scope the app offers, and masks grants it only to people whose scopes cover it ([84b43d8](https://github.com/masksrb/masks/commit/84b43d8c091440a5a9b75721b56f9ced27c9f82f))
9
+ * **client:** masked is the signed-in person in one object, read the same way from a session or a bearer token ([c2ad60c](https://github.com/masksrb/masks/commit/c2ad60cb5d2c795f2bf711e8650af5e1893aa0d2))
10
+ * **client:** masks_require_scope! guards an action with a step-up scope, sending its holder through masks once and refusing everyone else ([fce327e](https://github.com/masksrb/masks/commit/fce327edae9d6166e8bc163d85c4ca8e6548e1eb))
11
+ * **client:** privileges name what an action needs, and masked! checks them for a browser session or a bearer token alike ([97e998a](https://github.com/masksrb/masks/commit/97e998a68bf3365fc5ba72a99ca21cf66c6e500a))
12
+
3
13
  ## [0.9.2](https://github.com/masksrb/masks/compare/gem/v0.9.1...gem/v0.9.2) (2026-10-10)
4
14
 
5
15
 
data/README.md CHANGED
@@ -33,11 +33,12 @@ client secret never passes through the browser. The handshake registers the app
33
33
  asks before it rotates, because reconnecting takes it offline for a moment.
34
34
 
35
35
  Once an app is connected, only somebody `config.manages` approves can reconnect or disconnect it.
36
- The callable takes the request, or the request and the signed-in identity, and the engine refuses
37
- everyone when it is unset:
36
+ It names a [privilege](#privileges), or is a callable taking the request, or the request and
37
+ `masked`. The engine refuses everyone when it is unset:
38
38
 
39
39
  ```ruby
40
- config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }
40
+ config.manages = :admin
41
+ config.manages = ->(request, masked) { masked.subject == ENV["OWNER_SUB"] }
41
42
  ```
42
43
 
43
44
  The engine writes what comes back to `config/masks.json`, mode 600. A multi-tenant app keeps it
@@ -54,16 +55,46 @@ config.store = ->(request, registration) { Tenant.for(request).connect!(registra
54
55
  class DashboardController < ApplicationController
55
56
  include Masks::Rails::Authentication
56
57
 
57
- before_action :authenticate_masks!
58
+ masked!
58
59
 
59
60
  def index
60
- @who = masks_identity
61
+ @who = masked.name
61
62
  end
62
63
  end
63
64
  ```
64
65
 
65
- `masks_identity`, `masks_tenant`, and `masks_scopes` describe the signed-in request. Tokens live in
66
- the encrypted Rails session and never reach JavaScript.
66
+ `masked` is everything about the signed-in request in one object: `subject`, `name`, `nickname`,
67
+ `email`, `picture`, `avatars`, `tenant`, `organization`, `organizations`, `role?`, `scopes`,
68
+ `privileges`, `privilege?`, `access_token`, and `signed_in?`, with `masked["claim"]` for any other
69
+ claim. In a view it is the same helper. Tokens live in the encrypted Rails session and never reach
70
+ JavaScript.
71
+
72
+ ### Privileges
73
+
74
+ A privilege names what an action needs: scopes the person holds in masks, a role in the organization
75
+ they signed in to, or both. A list is shorthand for `{ scope: [...] }`.
76
+
77
+ ```ruby
78
+ Masks::Rails.configure do |config|
79
+ config.privileges = {
80
+ admin: %w[catalog:admin],
81
+ billing: { role: %w[owner billing] },
82
+ acme_admin: { scope: %w[catalog:admin], role: "owner", organization: "acme" }
83
+ }
84
+ end
85
+
86
+ class SettingsController < ApplicationController
87
+ masked!
88
+ masked! :admin, only: %i[create update destroy]
89
+ end
90
+ ```
91
+
92
+ `masked!` with no privilege needs only a sign-in. A privilege's scopes stay out of every ordinary
93
+ sign-in. When a signed-in person lacks one, the browser goes through masks once to ask for it and
94
+ comes back, and the action answers `403` when masks does not grant it. A missing role or organization
95
+ answers `403` at once. `masked.privilege?(:admin)` and `masked.privileges` tell a view what the sign-in
96
+ holds, and `/auth/session` answers `privileges`. See
97
+ [privileges](https://masks.pages.dev/guides/rails/#privileges).
67
98
 
68
99
  `config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
69
100
  include it where it applies.
@@ -91,7 +122,7 @@ An app that is also a resource server:
91
122
  class ApiController < ApplicationController
92
123
  include Masks::Rails::ProtectedResource
93
124
 
94
- masks_protect! scope: "xixo:catalog:read", except: :metadata
125
+ masked! :read, except: :metadata
95
126
 
96
127
  def metadata
97
128
  render json: masks_resource_metadata
@@ -99,8 +130,8 @@ class ApiController < ApplicationController
99
130
  end
100
131
  ```
101
132
 
102
- `masks_protect!` installs the `before_action` and passes `:only` and `:except` through.
103
- `masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
133
+ `masked!` checks a [privilege](#privileges) against the bearer token, here
134
+ `config.privileges = { read: %w[xixo:catalog:read] }`. `masked` reads the verified token. A refusal carries the RFC 6750 challenge with
104
135
  `resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
105
136
  does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
106
137
  accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
@@ -120,32 +151,28 @@ end
120
151
  class BillingController < ApplicationController
121
152
  include Masks::Rails::Authentication
122
153
 
123
- masks_members_only! role: %w[owner billing]
154
+ masked! :billing
124
155
  end
125
156
  ```
126
157
 
127
- `masks_organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
128
- `nil`, and `masks_role?("owner")` asks about the role held in it. A refresh reads the person's
158
+ `masked.organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
159
+ `nil`, and `masked.role?("owner")` asks about the role held in it. A refresh reads the person's
129
160
  profile again, so a promotion or demotion reaches the app within an access token's lifetime.
130
161
  `/auth/session` answers the same thing as `organization`.
131
162
 
132
- `masks_organizations` lists every organization the person has joined, each with the role held in
163
+ `masked.organizations` lists every organization the person has joined, each with the role held in
133
164
  it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
134
165
  again naming the other one, which asks nothing more when they are already a member:
135
166
 
136
167
  ```erb
137
- <% masks_organizations.each do |held| %>
168
+ <% masked.organizations.each do |held| %>
138
169
  <%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
139
170
  <% end %>
140
171
  ```
141
172
 
142
- `masks_members_only!` refuses with 403, as `insufficient_organization` when the person signed
143
- in to none and `insufficient_role` when they hold another role. A resource server asks the same of a
144
- token:
145
-
146
- ```ruby
147
- masks_protect! scope: "xixo:catalog:write", role: "owner", organization: "acme"
148
- ```
173
+ A privilege with a role refuses with 403, as `insufficient_organization` when the person signed in
174
+ to none and `insufficient_role` when they hold another role. A resource server asks the same of a
175
+ token with `masked!`.
149
176
 
150
177
  Naming an organization without asking for the scope still holds the sign-in to its members and its
151
178
  sign-in policy, and the app learns nothing about the role.
@@ -156,7 +183,7 @@ Every account has three avatars: an uploaded `photo`, an `identicon`, and two-le
156
183
  All three arrive on the ID token.
157
184
 
158
185
  ```erb
159
- <img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
186
+ <img src="<%= masked.avatars.identicon %>?size=64" width="64" height="64" alt="">
160
187
  ```
161
188
 
162
189
  A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
@@ -166,7 +193,7 @@ app holds:
166
193
  <img src="/auth/avatar" width="64" height="64" alt="">
167
194
  ```
168
195
 
169
- `masks_claims.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
196
+ `masked.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
170
197
  then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
171
198
  no token for. Sizes are 32, 64, 128, 256, or 512.
172
199
 
@@ -187,6 +214,7 @@ a subdomain-per-tenant host needs.
187
214
  | `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
188
215
  | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
189
216
  | `scope` | what to ask the issuer for, `openid profile email` by default |
217
+ | `privileges` | named scopes, roles, and organizations that `masked!` checks, none by default |
190
218
  | `organization` | the organization every sign-in names, by key |
191
219
  | `credentials` / `store` | where the handshake's result lives |
192
220
  | `manages` | who may reconnect or disconnect a connected app |
@@ -22,7 +22,7 @@ module Masks
22
22
  private
23
23
 
24
24
  def photo_url
25
- held = masks_claims.avatars.photo
25
+ held = masked.avatars.photo
26
26
 
27
27
  return nil if held.nil?
28
28
  return nil unless held.start_with?("#{masks_config.issuer_for(request)}/")
@@ -72,7 +72,7 @@ module Masks
72
72
 
73
73
  def require_unconfigured_or_manager
74
74
  return unless masks_config.configured?(request)
75
- return if masks_config.manages?(request, masks_identity)
75
+ return if masks_config.manages?(request, masked)
76
76
 
77
77
  @code = "forbidden"
78
78
  @description = "only somebody this app lets manage its connection can reconnect or disconnect it"
@@ -8,7 +8,7 @@ module Masks
8
8
  if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
9
9
  response.headers["Cache-Control"] = "no-store"
10
10
 
11
- render json: masks_account
11
+ render json: masked.to_h
12
12
  else
13
13
  masks_forget
14
14
  masks_refuse_json
@@ -25,6 +25,7 @@ module Masks
25
25
  started = masks_session.start(
26
26
  state: pending.id,
27
27
  resource: masks_config.resource_for(request),
28
+ scope: masks_config.scope_for(params[:privilege]),
28
29
  organization: Masks::Client::Session.organization_key(params[:organization]) ||
29
30
  masks_config.organization_for(request)
30
31
  )
@@ -88,7 +89,7 @@ module Masks
88
89
  private
89
90
 
90
91
  def renew_session
91
- carried = session.to_hash.slice(REQUESTS, HANDSHAKES)
92
+ carried = session.to_hash.slice(REQUESTS, HANDSHAKES, Masks::Rails::Authentication::STEPPED_UP)
92
93
 
93
94
  reset_session
94
95
  carried.each { |key, value| session[key] = value }
@@ -3,24 +3,18 @@ module Masks
3
3
  module Authentication
4
4
  extend ActiveSupport::Concern
5
5
  include Masks::Rails::Configurable
6
+ include Masks::Rails::Privileged
6
7
 
7
8
  included do
8
9
  if respond_to?(:helper_method)
9
- helper_method :masks_signed_in?, :masks_identity, :masks_tenant, :masks_scopes,
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))
10
+ helper_method :masked, :masks_login_url, :masks_logout_url
19
11
  end
20
12
  end
21
13
 
22
14
  REQUESTS = "masks_requests".freeze
23
15
  HANDSHAKES = "masks_handshakes".freeze
16
+ STEPPED_UP = "masks_stepped_up".freeze
17
+ STEP_UP_WITHIN = 300
24
18
 
25
19
  def masks_session
26
20
  @masks_session ||= masks_config.session_for(request)
@@ -50,8 +44,7 @@ module Masks
50
44
 
51
45
  session[masks_config.session_key] = held.compact
52
46
  @masks_tokens = tokens
53
- @masks_identity = held["identity"]
54
- @masks_claims = nil
47
+ @masked = nil
55
48
  end
56
49
 
57
50
  def masks_held
@@ -73,8 +66,19 @@ module Masks
73
66
  def masks_forget
74
67
  session.delete(masks_config.session_key)
75
68
  @masks_tokens = nil
76
- @masks_identity = nil
77
- @masks_claims = nil
69
+ @masked = nil
70
+ end
71
+
72
+ def masked
73
+ @masked ||= if masks_tokens
74
+ Masked.new(
75
+ claims: Masks::Client::Claims.new(masks_held["identity"] || {}), scopes: masks_tokens.scopes,
76
+ config: masks_config, signed_in: masks_signed_in?, access_token: masks_tokens.access_token,
77
+ expires_at: masks_tokens.expires_at, account_url: masks_account_url
78
+ )
79
+ else
80
+ Masked.nobody(masks_config)
81
+ end
78
82
  end
79
83
 
80
84
  def masks_signed_in?
@@ -119,46 +123,6 @@ module Masks
119
123
  Masks::Rails::Engine.routes.url_helpers.handshake_path
120
124
  end
121
125
 
122
- def masks_identity
123
- return @masks_identity if defined?(@masks_identity)
124
-
125
- @masks_identity = masks_held["identity"]
126
- end
127
-
128
- def masks_tenant
129
- masks_identity&.dig("tenant")
130
- end
131
-
132
- def masks_claims
133
- @masks_claims ||= Masks::Client::Claims.new(masks_identity || {})
134
- end
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
-
150
- def masks_scopes
151
- masks_tokens&.scopes || []
152
- end
153
-
154
- def masks_permits?(scope)
155
- masks_scopes.include?(scope.to_s)
156
- end
157
-
158
- def masks_access_token
159
- masks_tokens&.access_token
160
- end
161
-
162
126
  def masks_refresh!
163
127
  return false unless masks_configured?
164
128
  return false if masks_tokens&.refresh_token.nil?
@@ -203,37 +167,17 @@ module Masks
203
167
  "#{request.base_url}#{masks_local_path(masks_config.after_sign_out) || '/'}"
204
168
  end
205
169
 
206
- def masks_login_url(return_to: nil, organization: nil)
170
+ def masks_login_url(return_to: nil, organization: nil, privilege: nil)
207
171
  path = Masks::Rails::Engine.routes.url_helpers.start_path
208
172
  query = {
209
173
  "return_to" => masks_local_path(return_to),
210
- "organization" => Masks::Client::Session.organization_key(organization)
174
+ "organization" => Masks::Client::Session.organization_key(organization),
175
+ "privilege" => Array(privilege).join(" ").presence
211
176
  }.compact
212
177
 
213
178
  query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
214
179
  end
215
180
 
216
- def masks_account
217
- identity = masks_identity || {}
218
-
219
- {
220
- "signed_in" => masks_signed_in?,
221
- "subject" => identity["sub"],
222
- "name" => identity["name"],
223
- "nickname" => identity["preferred_username"],
224
- "email" => identity["email"],
225
- "email_verified" => identity["email_verified"],
226
- "picture" => masks_claims.picture,
227
- "avatars" => masks_claims.avatars.to_h.presence,
228
- "tenant" => masks_tenant,
229
- "organization" => masks_organization&.to_h,
230
- "organizations" => masks_organizations.map(&:to_h).presence,
231
- "scopes" => masks_scopes,
232
- "expires_at" => masks_tokens&.expires_at,
233
- "account_url" => masks_account_url
234
- }.compact
235
- end
236
-
237
181
  def masks_account_url
238
182
  "#{masks_config.issuer_for(request).to_s.chomp('/')}/"
239
183
  rescue Masks::Rails::Configuration::Unconfigured
@@ -247,17 +191,49 @@ module Masks
247
191
  false
248
192
  end
249
193
 
250
- def authorize_masks_member!(*roles, organization: nil)
251
- return false unless authenticate_masks!
194
+ private
252
195
 
253
- masks_claims.member!(*roles, organization: organization)
254
- true
255
- rescue Masks::Client::Forbidden => e
256
- masks_forbid(e)
257
- false
258
- end
196
+ def masks_authorize_session!(wanted)
197
+ return false unless authenticate_masks!
259
198
 
260
- private
199
+ wanted.each { |privilege| masked.member!(privilege) }
200
+
201
+ missing = wanted.reject { |privilege| (privilege.scopes - masked.scopes).empty? }.map(&:name)
202
+ return true if missing.empty?
203
+
204
+ if masks_asked_already?(session.delete(STEPPED_UP), missing)
205
+ raise Masks::Client::Forbidden.new(
206
+ "insufficient_scope", "this account does not hold the #{missing.join(' and ')} privilege",
207
+ scope: missing.flat_map { |name| masks_config.privilege(name).scopes }.join(" ")
208
+ )
209
+ end
210
+
211
+ masks_ask_again(missing)
212
+ false
213
+ rescue Masks::Client::Forbidden => e
214
+ masks_forbid(e)
215
+ false
216
+ end
217
+
218
+ def masks_asked_already?(asked, missing)
219
+ asked.is_a?(Hash) && (missing - Array(asked["privileges"])).empty? &&
220
+ asked["at"].to_i > Time.now.to_i - STEP_UP_WITHIN
221
+ end
222
+
223
+ def masks_ask_again(missing)
224
+ return_to = request.get? ? request.fullpath : masks_referring_path
225
+ url = masks_login_url(return_to: return_to, privilege: missing)
226
+
227
+ session[STEPPED_UP] = { "privileges" => missing, "at" => Time.now.to_i }
228
+
229
+ if masks_wants_json?
230
+ response.headers["Cache-Control"] = "no-store"
231
+ render json: { "error" => "privilege_required", "privileges" => missing, "login_url" => url },
232
+ status: :forbidden
233
+ else
234
+ redirect_to url
235
+ end
236
+ end
261
237
 
262
238
  def masks_forbid(error)
263
239
  response.headers["Cache-Control"] = "no-store"
@@ -3,6 +3,10 @@ module Masks
3
3
  class Configuration
4
4
  class Unconfigured < Masks::Client::Error; end
5
5
 
6
+ Privilege = Struct.new(:name, :scopes, :roles, :organization, keyword_init: true)
7
+ PRIVILEGE_KEYS = %w[scope role organization].freeze
8
+
9
+ attr_reader :privileges
6
10
  attr_accessor :scope, :namespace, :resource, :resource_scopes, :after_sign_in,
7
11
  :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
8
12
  :credentials_path, :authenticate_everything, :delegates
@@ -11,6 +15,7 @@ module Masks
11
15
 
12
16
  def initialize
13
17
  @scope = Masks::Client::Session::DEFAULT_SCOPE
18
+ @privileges = {}
14
19
  @resource_scopes = []
15
20
  @after_sign_in = "/"
16
21
  @after_sign_out = "/"
@@ -89,12 +94,12 @@ module Masks
89
94
  true
90
95
  end
91
96
 
92
- def manages?(request, identity = nil)
93
- return false unless @manages.respond_to?(:call)
94
-
95
- held = @manages.arity == 1 ? @manages.call(request) : @manages.call(request, identity)
96
-
97
- held ? true : false
97
+ def manages?(request, masked = nil)
98
+ case @manages
99
+ when Symbol, String then masked&.privilege?(@manages) || false
100
+ when nil then false
101
+ else (@manages.arity == 1 ? @manages.call(request) : @manages.call(request, masked)) ? true : false
102
+ end
98
103
  end
99
104
 
100
105
  def can_forget?
@@ -151,12 +156,34 @@ module Masks
151
156
  )
152
157
  end
153
158
 
159
+ def privileges=(value)
160
+ @privileges = value.to_h.to_h do |name, spec|
161
+ held = spec.is_a?(Hash) ? spec.transform_keys(&:to_s) : { "scope" => spec }
162
+ unknown = held.keys - PRIVILEGE_KEYS
163
+
164
+ raise ArgumentError, "the #{name} privilege names #{unknown.join(', ')}, which is not #{PRIVILEGE_KEYS.join(', ')}" if unknown.any?
165
+
166
+ [ name.to_s, Privilege.new(name: name.to_s, scopes: Array(held["scope"]).map(&:to_s),
167
+ roles: Array(held["role"]).map(&:to_s), organization: held["organization"]&.to_s) ]
168
+ end
169
+ end
170
+
171
+ def privilege(name)
172
+ privileges.fetch(name.to_s) { raise ArgumentError, "Masks::Rails.config.privileges has no #{name}" }
173
+ end
174
+
175
+ def scope_for(requested)
176
+ wanted = (requested.to_s.split & privileges.keys).flat_map { |name| privileges[name].scopes }
177
+ wanted.empty? ? nil : (Array(scope) + wanted).uniq
178
+ end
179
+
154
180
  def approved_scope
155
181
  delegated = delegates ? [ Masks::Client::Delegations::SCOPE ] : []
156
182
 
157
- return Array(scope) + delegated if namespace.blank?
183
+ asked = (Array(scope) + privileges.values.flat_map(&:scopes)).uniq
184
+ return asked + delegated if namespace.blank?
158
185
 
159
- outside = Array(scope).reject { |name| name.to_s.start_with?(namespace) }
186
+ outside = asked.reject { |name| name.to_s.start_with?(namespace) }
160
187
 
161
188
  outside + [ namespace ] + delegated
162
189
  end
@@ -0,0 +1,126 @@
1
+ module Masks
2
+ module Rails
3
+ class Masked
4
+ attr_reader :claims, :scopes, :access_token, :expires_at, :account_url
5
+
6
+ def self.nobody(config)
7
+ new(claims: Masks::Client::Claims.new({}), scopes: [], config: config, signed_in: false)
8
+ end
9
+
10
+ def initialize(claims:, scopes:, config:, signed_in:, access_token: nil, expires_at: nil, account_url: nil)
11
+ @claims = claims
12
+ @scopes = Array(scopes).map(&:to_s).freeze
13
+ @config = config
14
+ @signed_in = signed_in
15
+ @access_token = access_token
16
+ @expires_at = expires_at
17
+ @account_url = account_url
18
+ end
19
+
20
+ def signed_in?
21
+ @signed_in
22
+ end
23
+
24
+ def [](claim)
25
+ claims[claim]
26
+ end
27
+
28
+ def subject
29
+ claims.subject
30
+ end
31
+
32
+ def name
33
+ claims["name"]
34
+ end
35
+
36
+ def nickname
37
+ claims["preferred_username"]
38
+ end
39
+
40
+ def email
41
+ claims["email"]
42
+ end
43
+
44
+ def email_verified?
45
+ claims["email_verified"] == true
46
+ end
47
+
48
+ def picture
49
+ claims.picture
50
+ end
51
+
52
+ def avatars
53
+ claims.avatars
54
+ end
55
+
56
+ def tenant
57
+ claims.tenant
58
+ end
59
+
60
+ def organization
61
+ held = claims.organization
62
+
63
+ held.present? ? held : nil
64
+ end
65
+
66
+ def organizations
67
+ claims.organizations
68
+ end
69
+
70
+ def role?(*roles)
71
+ organization&.role?(*roles) || false
72
+ end
73
+
74
+ def permits?(scope)
75
+ scopes.include?(scope.to_s)
76
+ end
77
+
78
+ def privileges
79
+ return [] unless signed_in?
80
+
81
+ @config.privileges.values.select { |privilege| holds?(privilege) }.map(&:name)
82
+ end
83
+
84
+ def privilege?(*names)
85
+ wanted = names.flatten.map { |name| @config.privilege(name) }
86
+
87
+ signed_in? && wanted.all? { |privilege| holds?(privilege) }
88
+ end
89
+
90
+ def holds?(privilege)
91
+ return false unless (privilege.scopes - scopes).empty?
92
+
93
+ member!(privilege)
94
+ true
95
+ rescue Masks::Client::Forbidden
96
+ false
97
+ end
98
+
99
+ def member!(privilege)
100
+ return if privilege.roles.empty? && privilege.organization.nil?
101
+
102
+ claims.member!(*privilege.roles, organization: privilege.organization)
103
+ end
104
+
105
+ def to_h
106
+ {
107
+ "signed_in" => signed_in?,
108
+ "subject" => subject,
109
+ "name" => name,
110
+ "nickname" => nickname,
111
+ "email" => email,
112
+ "email_verified" => claims["email_verified"],
113
+ "picture" => picture,
114
+ "avatars" => avatars.to_h.presence,
115
+ "tenant" => claims["tenant"],
116
+ "organization" => organization&.to_h,
117
+ "organizations" => organizations.map(&:to_h).presence,
118
+ "scopes" => scopes,
119
+ "privileges" => privileges,
120
+ "expires_at" => expires_at,
121
+ "account_url" => account_url
122
+ }.compact
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,29 @@
1
+ module Masks
2
+ module Rails
3
+ module Privileged
4
+ extend ActiveSupport::Concern
5
+ include Masks::Rails::Configurable
6
+
7
+ class_methods do
8
+ def masked!(*privileges, **options)
9
+ before_action(-> { authorize_masks!(*privileges) }, **options.slice(:only, :except))
10
+ end
11
+ end
12
+
13
+ def authorize_masks!(*names)
14
+ privileges = names.flatten.map { |name| masks_config.privilege(name) }
15
+
16
+ masks_by_token? ? masks_authorize_token!(privileges) : masks_authorize_session!(privileges)
17
+ end
18
+
19
+ private
20
+
21
+ def masks_by_token?
22
+ return false unless respond_to?(:masks_authorize_token!, true)
23
+ return true if request.authorization.present? || !respond_to?(:masks_authorize_session!, true)
24
+
25
+ masks_tokens.nil?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -3,27 +3,19 @@ module Masks
3
3
  module ProtectedResource
4
4
  extend ActiveSupport::Concern
5
5
  include Masks::Rails::Configurable
6
-
7
- class_methods do
8
- def masks_protect!(**options)
9
- before_action(-> { masks_authenticate!(**options) }, **options.slice(:only, :except))
10
- end
11
- end
6
+ include Masks::Rails::Privileged
12
7
 
13
8
  def masks_resource
14
9
  @masks_resource ||= masks_config.resource_server_for(request)
15
10
  end
16
11
 
17
- def masks_claims
18
- @masks_claims if defined?(@masks_claims)
19
- end
12
+ def masked
13
+ return (defined?(super) ? super : Masked.nobody(masks_config)) unless defined?(@masks_token_claims)
20
14
 
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, **masks_proof)
24
- rescue Masks::Client::Challenge => e
25
- masks_challenge(e)
26
- false
15
+ @masked_by_token ||= Masked.new(
16
+ claims: @masks_token_claims, scopes: @masks_token_claims.scopes, config: masks_config, signed_in: true,
17
+ access_token: masks_resource.token(request.authorization), expires_at: @masks_token_claims.expires_at
18
+ )
27
19
  end
28
20
 
29
21
  def masks_authenticate(scope: nil, role: nil, organization: nil)
@@ -52,6 +44,25 @@ module Masks
52
44
  def masks_resource_metadata
53
45
  masks_resource.metadata
54
46
  end
47
+
48
+ private
49
+
50
+ def masks_authorize_token!(privileges)
51
+ claims = masks_resource.authenticate(request.authorization, scope: privileges.flat_map(&:scopes).uniq,
52
+ **masks_proof)
53
+
54
+ privileges.each do |privilege|
55
+ next if privilege.roles.empty? && privilege.organization.nil?
56
+
57
+ claims.member!(*privilege.roles, organization: privilege.organization)
58
+ end
59
+
60
+ @masks_token_claims = claims
61
+ true
62
+ rescue Masks::Client::Challenge => e
63
+ masks_challenge(e)
64
+ false
65
+ end
55
66
  end
56
67
  end
57
68
  end
data/lib/masks/rails.rb CHANGED
@@ -31,6 +31,8 @@ require_relative "client"
31
31
  require_relative "rails/credentials"
32
32
  require_relative "rails/configuration"
33
33
  require_relative "rails/configurable"
34
+ require_relative "rails/masked"
35
+ require_relative "rails/privileged"
34
36
  require_relative "rails/authentication"
35
37
  require_relative "rails/protected_resource"
36
38
  require_relative "rails/engine"
data/lib/masks/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Masks
2
- VERSION = "0.9.2".freeze
2
+ VERSION = "0.10.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: masks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - the masks authors
@@ -73,6 +73,8 @@ files:
73
73
  - lib/masks/rails/configuration.rb
74
74
  - lib/masks/rails/credentials.rb
75
75
  - lib/masks/rails/engine.rb
76
+ - lib/masks/rails/masked.rb
77
+ - lib/masks/rails/privileged.rb
76
78
  - lib/masks/rails/protected_resource.rb
77
79
  - lib/masks/version.rb
78
80
  homepage: https://github.com/masksrb/masks