masks 0.9.1 → 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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +57 -29
- data/app/controllers/masks/rails/avatars_controller.rb +1 -1
- data/app/controllers/masks/rails/handshakes_controller.rb +1 -1
- data/app/controllers/masks/rails/sessions_controller.rb +3 -2
- data/lib/masks/rails/authentication.rb +62 -86
- data/lib/masks/rails/configuration.rb +35 -8
- data/lib/masks/rails/masked.rb +126 -0
- data/lib/masks/rails/privileged.rb +29 -0
- data/lib/masks/rails/protected_resource.rb +26 -15
- data/lib/masks/rails.rb +2 -0
- data/lib/masks/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0b13e1f370033e4c4512becb39b4d96e4b9d28b39b7f1e773592138626d1151
|
|
4
|
+
data.tar.gz: 8540eeecb1b448047198d5da57e5978d0e4ec249138334e7c39d41a719854a99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc1b4981446fdc5282e21c00c93a63cbc124d3c675512172a849cdd698f0a52839fce8098fdde6443814131f198eef5829d1dd52416e02bb51ef95f7d5fb634a
|
|
7
|
+
data.tar.gz: e40f6519f1c22fefa134217cff31701617551feed13e374f1d801307c2f021cb21eba5b85adc4eb9d28e133e855c4aad4cc05caa4749d34fadd9d7e833493cc1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
13
|
+
## [0.9.2](https://github.com/masksrb/masks/compare/gem/v0.9.1...gem/v0.9.2) (2026-10-10)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* the examples name xixo, the resource server once called uris ([ca80f20](https://github.com/masksrb/masks/commit/ca80f20c9aea4ee6a3e7cd6c76c69425e4c800e3))
|
|
19
|
+
|
|
3
20
|
## [0.9.1](https://github.com/masksrb/masks/compare/gem/v0.9.0...gem/v0.9.1) (2026-10-04)
|
|
4
21
|
|
|
5
22
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
58
|
+
masked!
|
|
58
59
|
|
|
59
60
|
def index
|
|
60
|
-
@who =
|
|
61
|
+
@who = masked.name
|
|
61
62
|
end
|
|
62
63
|
end
|
|
63
64
|
```
|
|
64
65
|
|
|
65
|
-
`
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
103
|
-
`
|
|
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
|
-
|
|
154
|
+
masked! :billing
|
|
124
155
|
end
|
|
125
156
|
```
|
|
126
157
|
|
|
127
|
-
`
|
|
128
|
-
`nil`, and `
|
|
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
|
-
`
|
|
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
|
-
<%
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
token
|
|
145
|
-
|
|
146
|
-
```ruby
|
|
147
|
-
masks_protect! scope: "uris: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="<%=
|
|
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
|
-
`
|
|
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 |
|
|
@@ -235,7 +263,7 @@ The flow that connects a first-party app:
|
|
|
235
263
|
|
|
236
264
|
```ruby
|
|
237
265
|
handshake = Masks::Client::Handshake.new(
|
|
238
|
-
issuer, name: "
|
|
266
|
+
issuer, name: "xixo", resource: "https://app.example.com/mcp",
|
|
239
267
|
redirect_uris: [ "https://app.example.com/auth/callback" ],
|
|
240
268
|
return_to: "https://app.example.com/"
|
|
241
269
|
)
|
|
@@ -254,11 +282,11 @@ the issuer it asked.
|
|
|
254
282
|
resource = Masks::Client::Resource.new(
|
|
255
283
|
issuer: "https://demo.auth.example.com",
|
|
256
284
|
url: "https://app.example.com/mcp",
|
|
257
|
-
scopes: { "
|
|
285
|
+
scopes: { "xixo:catalog:read" => "Search your catalog" }
|
|
258
286
|
)
|
|
259
287
|
|
|
260
288
|
claims = resource.authenticate(
|
|
261
|
-
request.authorization, scope: "
|
|
289
|
+
request.authorization, scope: "xixo:catalog:read",
|
|
262
290
|
proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
|
|
263
291
|
)
|
|
264
292
|
claims.subject
|
|
@@ -286,7 +314,7 @@ scope.
|
|
|
286
314
|
A Rack middleware does the same below the framework, proof included:
|
|
287
315
|
|
|
288
316
|
```ruby
|
|
289
|
-
use Masks::Client::Rack, resource: resource, scope: "
|
|
317
|
+
use Masks::Client::Rack, resource: resource, scope: "xixo:catalog:read"
|
|
290
318
|
```
|
|
291
319
|
|
|
292
320
|
### Introspection
|
|
@@ -296,7 +324,7 @@ asks the issuer:
|
|
|
296
324
|
|
|
297
325
|
```ruby
|
|
298
326
|
found = session.introspect(token)
|
|
299
|
-
found.active? && found.permits?("
|
|
327
|
+
found.active? && found.permits?("xixo:catalog:read")
|
|
300
328
|
```
|
|
301
329
|
|
|
302
330
|
`Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
|
|
@@ -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,
|
|
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:
|
|
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 :
|
|
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
|
-
@
|
|
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
|
-
@
|
|
77
|
-
|
|
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
|
-
|
|
251
|
-
return false unless authenticate_masks!
|
|
194
|
+
private
|
|
252
195
|
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
183
|
+
asked = (Array(scope) + privileges.values.flat_map(&:scopes)).uniq
|
|
184
|
+
return asked + delegated if namespace.blank?
|
|
158
185
|
|
|
159
|
-
outside =
|
|
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
|
|
18
|
-
|
|
19
|
-
end
|
|
12
|
+
def masked
|
|
13
|
+
return (defined?(super) ? super : Masked.nobody(masks_config)) unless defined?(@masks_token_claims)
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
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.
|
|
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
|