masks 0.8.0 → 0.9.1
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 +35 -0
- data/README.md +100 -101
- data/app/controllers/masks/rails/handshakes_controller.rb +11 -0
- data/app/controllers/masks/rails/sessions_controller.rb +36 -1
- data/lib/masks/client/errors.rb +8 -3
- data/lib/masks/client/pkce.rb +1 -3
- data/lib/masks/client/proof.rb +153 -0
- data/lib/masks/client/rack.rb +15 -1
- data/lib/masks/client/registry.rb +0 -4
- data/lib/masks/client/resource.rb +30 -9
- data/lib/masks/client/verifier.rb +0 -4
- data/lib/masks/client.rb +1 -0
- data/lib/masks/rails/authentication.rb +8 -2
- data/lib/masks/rails/configuration.rb +9 -14
- data/lib/masks/rails/protected_resource.rb +7 -2
- data/lib/masks/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62fc7998cf31199eaa81f63d82063e3e20ab995c74c782c675e91628df7dcee3
|
|
4
|
+
data.tar.gz: 6d83a9474d9289c375acfcacd6c5c4e45a66a92d22e1683abba9780de3c63d43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97164bf9ab0ee6820e353a9dad5d97141ddf0cd038dd3a6348e03e46eea46313643e13f8d49c17bd414c16420e4b25c5c29072bd4efea114f3483e4174c0e599
|
|
7
|
+
data.tar.gz: 505cca23a8b1b0f439365eea100780b64157700ab9995df53fa46df640a0efc1da9a64e95281925279233ea4e4036563e41aebd727d8b7f2795991dac3604931
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.1](https://github.com/masksrb/masks/compare/gem/v0.9.0...gem/v0.9.1) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixes
|
|
7
|
+
|
|
8
|
+
* **client:** an error the issuer sends back is shown rather than retried, and the engine tests hold the new callback behaviour ([36adb9b](https://github.com/masksrb/masks/commit/36adb9b57a2379b8d7ce1d16f2e04e8c244baa7d))
|
|
9
|
+
|
|
10
|
+
## [0.9.0](https://github.com/masksrb/masks/compare/gem/v0.8.0...gem/v0.9.0) (2026-10-04)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### ⚠ BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* **client:** a connected app refuses reconnecting and disconnecting until config.manages is set, for example config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }.
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
|
|
19
|
+
* **client:** a resource server refuses a DPoP-bound token presented as Bearer, and checks the proof one presented as DPoP ([9a3fe8a](https://github.com/masksrb/masks/commit/9a3fe8adac09fd70a7059674d8c34333c8f3ff92))
|
|
20
|
+
* **client:** a return_to a browser would read as another host is refused, and signing out revokes the refresh token ([de27168](https://github.com/masksrb/masks/commit/de2716872955ba124a8a3ec1dbe07cb043fd0260))
|
|
21
|
+
* **client:** a sign-in callback this browser no longer holds goes on to the app or starts sign-in again, instead of ending on an error ([a1ba84c](https://github.com/masksrb/masks/commit/a1ba84c4a974897bdb80b1f32da5c0b72f83ca79))
|
|
22
|
+
* **client:** every resource in a process shares one store of seen DPoP proofs, and a refused proof is challenged with the DPoP scheme ([f8d1fe2](https://github.com/masksrb/masks/commit/f8d1fe2487e0705c914ea3d3ae7f686152cad6b3))
|
|
23
|
+
* **client:** only somebody config.manages approves can reconnect or disconnect a connected app ([5665129](https://github.com/masksrb/masks/commit/566512979dc00a36148de57b4ea9954fc68db10b))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
* every guide, the reference prose, and the READMEs are checked against the code, shortened, and corrected ([94782d1](https://github.com/masksrb/masks/commit/94782d1fbfe363e90c40f0c728a241de9738e138))
|
|
29
|
+
* the plan holds only what is left, and the READMEs match the engine layout and the code ([1615722](https://github.com/masksrb/masks/commit/1615722f88ae5f7d19955ff686dcef9f64a6326f))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Refactoring
|
|
33
|
+
|
|
34
|
+
* **client:** the issuer version check nothing called, Registry#urls and Verifier#tenant go ([6b8cf05](https://github.com/masksrb/masks/commit/6b8cf0550b96f6b1b3b415ff27f655f90614bdfe))
|
|
35
|
+
* **engine:** deleting and downloading an account share one check for a fresh sign-in, and PKCE uses the client's one base64url digest ([6c08fcf](https://github.com/masksrb/masks/commit/6c08fcf709a08dbafd0a551801eaf43a4c325b75))
|
|
36
|
+
* **engine:** one partial carries a tenant's email wording, one query loads it, and authorization_details narrow in one place ([1b2df29](https://github.com/masksrb/masks/commit/1b2df29380b2c2c8dc6b87d45d60b4ef23255b26))
|
|
37
|
+
|
|
3
38
|
## [0.8.0](https://github.com/masksrb/masks/compare/gem/v0.7.0...gem/v0.8.0) (2026-09-28)
|
|
4
39
|
|
|
5
40
|
|
data/README.md
CHANGED
|
@@ -8,53 +8,50 @@ Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks)
|
|
|
8
8
|
gem "masks"
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
The engine loads only when Rails does. A Sinatra, Hanami or plain-Rack app requiring
|
|
17
|
-
this gem pulls in `jwt` and nothing else.
|
|
11
|
+
The gem signs people in to an app, and accepts the tokens masks issues when the app is also a
|
|
12
|
+
resource server. Its Rails engine loads only when Rails does, so a Sinatra, Hanami, or plain Rack
|
|
13
|
+
app pulls in `jwt` and nothing else. See
|
|
14
|
+
[Connecting via SDK](https://masks.pages.dev/guides/connecting-via-sdk/) for what it covers beside
|
|
15
|
+
the browser package.
|
|
18
16
|
|
|
19
17
|
## Rails
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
bin/rails generate masks:install
|
|
19
|
+
```sh
|
|
20
|
+
bin/rails generate masks:install --resource https://app.example.com
|
|
26
21
|
```
|
|
27
22
|
|
|
28
23
|
That mounts the engine at `/auth`, writes `config/initializers/masks.rb`, and gitignores
|
|
29
24
|
the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
|
|
30
|
-
`/auth/handshake`.
|
|
25
|
+
`/auth/handshake`. [Rails apps](https://masks.pages.dev/guides/rails/) walks through the rest.
|
|
31
26
|
|
|
32
27
|
### The handshake
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
An app connects to its issuer by somebody approving it. An unconnected app sends the browser to
|
|
30
|
+
the issuer's approval screen, and the server redeems the one-time token that comes back, so the
|
|
31
|
+
client secret never passes through the browser. The handshake registers the app for
|
|
32
|
+
`config.resource`, an absolute URL on the app's own origin, and refuses without one. A connected app
|
|
33
|
+
asks before it rotates, because reconnecting takes it offline for a moment.
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
```ruby
|
|
40
|
+
config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The engine writes what comes back to `config/masks.json`, mode 600. A multi-tenant app keeps it
|
|
44
|
+
somewhere else with two callables:
|
|
46
45
|
|
|
47
46
|
```ruby
|
|
48
47
|
config.credentials = ->(request) { Tenant.for(request).masks_credentials }
|
|
49
48
|
config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
Those two lambdas are the whole integration for a multi-tenant app.
|
|
53
|
-
|
|
54
51
|
### Signing in
|
|
55
52
|
|
|
56
53
|
```ruby
|
|
57
|
-
class
|
|
54
|
+
class DashboardController < ApplicationController
|
|
58
55
|
include Masks::Rails::Authentication
|
|
59
56
|
|
|
60
57
|
before_action :authenticate_masks!
|
|
@@ -65,18 +62,16 @@ class UrisController < ApplicationController
|
|
|
65
62
|
end
|
|
66
63
|
```
|
|
67
64
|
|
|
68
|
-
`masks_identity`, `masks_tenant
|
|
69
|
-
|
|
70
|
-
backend-for-frontend pattern, and it is the default because an SPA holding a token is an
|
|
71
|
-
SPA where XSS lifts one.
|
|
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.
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
`config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
|
|
69
|
+
include it where it applies.
|
|
75
70
|
|
|
76
|
-
###
|
|
71
|
+
### A single-page app in front of it
|
|
77
72
|
|
|
78
|
-
`GET /auth/session` answers identity, tenant and scopes as JSON, or `401` with
|
|
79
|
-
|
|
73
|
+
`GET /auth/session` answers identity, tenant, and scopes as JSON, or `401` with where to send the
|
|
74
|
+
browser. [`@masks/client`](https://masks.pages.dev/reference/browser/) reads it:
|
|
80
75
|
|
|
81
76
|
```js
|
|
82
77
|
import { createSession } from "@masks/client"
|
|
@@ -85,9 +80,8 @@ const session = createSession()
|
|
|
85
80
|
const status = await session.status()
|
|
86
81
|
```
|
|
87
82
|
|
|
88
|
-
`status()` answers
|
|
89
|
-
|
|
90
|
-
has connected must not offer a sign-in button that leads to an error page at the issuer.
|
|
83
|
+
`status()` answers `signed_in`, `signed_out` with where to sign in, or `handshake_required` with
|
|
84
|
+
where to connect the app first.
|
|
91
85
|
|
|
92
86
|
### Accepting tokens
|
|
93
87
|
|
|
@@ -97,22 +91,25 @@ An app that is also a resource server:
|
|
|
97
91
|
class ApiController < ApplicationController
|
|
98
92
|
include Masks::Rails::ProtectedResource
|
|
99
93
|
|
|
100
|
-
masks_protect! scope: "uris:catalog:read"
|
|
94
|
+
masks_protect! scope: "uris:catalog:read", except: :metadata
|
|
95
|
+
|
|
96
|
+
def metadata
|
|
97
|
+
render json: masks_resource_metadata
|
|
98
|
+
end
|
|
101
99
|
end
|
|
102
100
|
```
|
|
103
101
|
|
|
104
|
-
`masks_protect!`
|
|
105
|
-
`:except` through.
|
|
106
|
-
|
|
102
|
+
`masks_protect!` installs the `before_action` and passes `:only` and `:except` through.
|
|
107
103
|
`masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
|
|
108
|
-
`resource_metadata`,
|
|
109
|
-
|
|
104
|
+
`resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
|
|
105
|
+
does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
|
|
106
|
+
accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
|
|
110
107
|
|
|
111
108
|
### Organizations
|
|
112
109
|
|
|
113
110
|
A person signs in to an app as a member of one organization when the app asks for the
|
|
114
111
|
`organization` scope. Someone in several organizations picks one, and an app can pick for them by
|
|
115
|
-
naming it
|
|
112
|
+
naming it, with `/auth?organization=acme` or with `config.organization` for every sign-in.
|
|
116
113
|
|
|
117
114
|
```ruby
|
|
118
115
|
Masks::Rails.configure do |config|
|
|
@@ -151,34 +148,33 @@ masks_protect! scope: "uris:catalog:write", role: "owner", organization: "acme"
|
|
|
151
148
|
```
|
|
152
149
|
|
|
153
150
|
Naming an organization without asking for the scope still holds the sign-in to its members and its
|
|
154
|
-
sign-in policy
|
|
151
|
+
sign-in policy, and the app learns nothing about the role.
|
|
155
152
|
|
|
156
153
|
### Avatars
|
|
157
154
|
|
|
158
|
-
Every
|
|
159
|
-
|
|
155
|
+
Every account has three avatars: an uploaded `photo`, an `identicon`, and two-letter `initials`.
|
|
156
|
+
All three arrive on the ID token.
|
|
160
157
|
|
|
161
158
|
```erb
|
|
162
159
|
<img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
|
|
163
160
|
```
|
|
164
161
|
|
|
165
|
-
A photo
|
|
166
|
-
|
|
162
|
+
A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
|
|
163
|
+
app holds:
|
|
167
164
|
|
|
168
165
|
```erb
|
|
169
166
|
<img src="/auth/avatar" width="64" height="64" alt="">
|
|
170
167
|
```
|
|
171
168
|
|
|
172
|
-
`masks_claims.picture`
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
`masks_claims.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
|
|
170
|
+
then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
|
|
171
|
+
no token for. Sizes are 32, 64, 128, 256, or 512.
|
|
175
172
|
|
|
176
173
|
### Signing out
|
|
177
174
|
|
|
178
|
-
`DELETE /auth/logout` ends this app's session.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
sign-out.
|
|
175
|
+
`DELETE /auth/logout` ends this app's session and revokes its refresh token at the issuer. With `?everywhere=1` the response also carries
|
|
176
|
+
`logout_url`, the issuer's end-session endpoint. `config.sign_out_of_issuer = true` makes that the
|
|
177
|
+
default for every sign-out.
|
|
182
178
|
|
|
183
179
|
### Configuration
|
|
184
180
|
|
|
@@ -188,22 +184,23 @@ a subdomain-per-tenant host needs.
|
|
|
188
184
|
| | |
|
|
189
185
|
|---|---|
|
|
190
186
|
| `issuer` | the masks issuer this app signs in against |
|
|
191
|
-
| `resource` | this app's own identifier,
|
|
187
|
+
| `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
|
|
192
188
|
| `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
|
|
193
|
-
| `scope` | what to ask the issuer for
|
|
189
|
+
| `scope` | what to ask the issuer for, `openid profile email` by default |
|
|
194
190
|
| `organization` | the organization every sign-in names, by key |
|
|
195
191
|
| `credentials` / `store` | where the handshake's result lives |
|
|
196
|
-
| `
|
|
192
|
+
| `manages` | who may reconnect or disconnect a connected app |
|
|
193
|
+
| `credentials_path` | where the default store writes, `config/masks.json` by default |
|
|
197
194
|
| `after_sign_in` / `after_sign_out` | paths on this host |
|
|
198
195
|
| `parent_controller` | what the engine's pages inherit, for your layout |
|
|
199
196
|
| `authenticate_everything` | include `Authentication` on every controller |
|
|
200
197
|
| `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
|
|
201
198
|
| `session_key` | the session key the tokens live under |
|
|
199
|
+
| `delegates` / `delegation_redirect_uri` | ask for delegation in the handshake, and where connecting an account returns |
|
|
202
200
|
|
|
203
201
|
## Any Ruby app
|
|
204
202
|
|
|
205
|
-
|
|
206
|
-
holds no protocol of its own.
|
|
203
|
+
The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
|
|
207
204
|
|
|
208
205
|
### The consumer half
|
|
209
206
|
|
|
@@ -215,22 +212,22 @@ session = Masks::Client::Session.new(
|
|
|
215
212
|
)
|
|
216
213
|
|
|
217
214
|
started = session.start(resource: "https://app.example.com/mcp")
|
|
218
|
-
# hold started[:state], started[:nonce] and started[:verifier]; send the
|
|
219
|
-
# browser to started[:url]
|
|
220
215
|
|
|
221
216
|
tokens = session.complete(code: params[:code], verifier: held[:verifier])
|
|
222
217
|
identity = session.identity(tokens)
|
|
223
218
|
```
|
|
224
219
|
|
|
225
|
-
`
|
|
226
|
-
|
|
220
|
+
Hold `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
|
|
221
|
+
browser to `started[:url]`. `start` sends a nonce only when the scope includes `openid`. Check the
|
|
222
|
+
returned `state` yourself, and compare `identity["nonce"]` with the one you held.
|
|
227
223
|
|
|
228
|
-
|
|
224
|
+
`Session` also has `refresh`, `exchange`, `client_credentials`, `revoke`, `introspect`, `userinfo`,
|
|
225
|
+
and `end_session_url`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
|
|
226
|
+
`private_key_jwt`.
|
|
229
227
|
|
|
230
|
-
Discovery and JWKS are cached for five minutes
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
constructing one per request.
|
|
228
|
+
Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
|
|
229
|
+
needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
|
|
230
|
+
of constructing one per request.
|
|
234
231
|
|
|
235
232
|
### The handshake
|
|
236
233
|
|
|
@@ -243,12 +240,13 @@ handshake = Masks::Client::Handshake.new(
|
|
|
243
240
|
return_to: "https://app.example.com/"
|
|
244
241
|
)
|
|
245
242
|
|
|
246
|
-
started = handshake.start
|
|
243
|
+
started = handshake.start
|
|
247
244
|
registration = handshake.complete(params, state: held)
|
|
248
245
|
```
|
|
249
246
|
|
|
250
|
-
`
|
|
251
|
-
that
|
|
247
|
+
Hold `started[:state]` and send the browser to `started[:url]`. Before it redeems anything,
|
|
248
|
+
`complete` refuses an `error`, a `state` that does not match this browser, and an `iss` other than
|
|
249
|
+
the issuer it asked.
|
|
252
250
|
|
|
253
251
|
### The resource-server half
|
|
254
252
|
|
|
@@ -259,24 +257,33 @@ resource = Masks::Client::Resource.new(
|
|
|
259
257
|
scopes: { "uris:catalog:read" => "Search your catalog" }
|
|
260
258
|
)
|
|
261
259
|
|
|
262
|
-
claims = resource.authenticate(
|
|
260
|
+
claims = resource.authenticate(
|
|
261
|
+
request.authorization, scope: "uris:catalog:read",
|
|
262
|
+
proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
|
|
263
|
+
)
|
|
263
264
|
claims.subject
|
|
264
265
|
claims.tenant.subdomain
|
|
265
266
|
```
|
|
266
267
|
|
|
267
|
-
`authenticate` answers `Claims` or raises. A
|
|
268
|
-
|
|
268
|
+
`authenticate` answers `Claims` or raises. A token bound to a key with DPoP carries `cnf.jkt`, and is
|
|
269
|
+
accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
|
|
270
|
+
token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
|
|
271
|
+
checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
|
|
272
|
+
processes passes `replay:` to `Resource.new`, any object whose `first?(key, expires_in:)` answers true
|
|
273
|
+
only once for each key.
|
|
274
|
+
|
|
275
|
+
A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
|
|
276
|
+
accepted:
|
|
269
277
|
|
|
270
278
|
```ruby
|
|
271
279
|
response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
272
280
|
```
|
|
273
281
|
|
|
274
|
-
`resource.metadata` is the RFC 9728 document to serve at
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
way to know what `uris:catalog:read` means.
|
|
282
|
+
`resource.metadata` is the RFC 9728 document to serve at `/.well-known/oauth-protected-resource`.
|
|
283
|
+
Its `scope_descriptions` extension gives masks the sentences its consent screen shows for each
|
|
284
|
+
scope.
|
|
278
285
|
|
|
279
|
-
|
|
286
|
+
A Rack middleware does the same below the framework, proof included:
|
|
280
287
|
|
|
281
288
|
```ruby
|
|
282
289
|
use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
|
|
@@ -284,23 +291,21 @@ use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
|
|
|
284
291
|
|
|
285
292
|
### Introspection
|
|
286
293
|
|
|
287
|
-
A resource server
|
|
288
|
-
|
|
294
|
+
A resource server that only verifies the JWT sees a revocation when the token expires. Introspection
|
|
295
|
+
asks the issuer:
|
|
289
296
|
|
|
290
297
|
```ruby
|
|
291
298
|
found = session.introspect(token)
|
|
292
299
|
found.active? && found.permits?("uris:catalog:read")
|
|
293
300
|
```
|
|
294
301
|
|
|
295
|
-
`Introspection` is a `Claims` whose `permit!` raises when the
|
|
296
|
-
not active, so moving from local verification to introspection needs no extra boolean
|
|
297
|
-
check.
|
|
302
|
+
`Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
|
|
298
303
|
|
|
299
304
|
### Somebody else's account
|
|
300
305
|
|
|
301
|
-
An app that acts as somebody at Google, Microsoft or an MCP server while they are away asks masks for
|
|
302
|
-
a [delegation](https://masks.pages.dev/
|
|
303
|
-
provider's tokens
|
|
306
|
+
An app that acts as somebody at Google, Microsoft, or an MCP server while they are away asks masks for
|
|
307
|
+
a [delegation](https://masks.pages.dev/guides/connecting-via-sdk/#delegation). masks keeps and refreshes the
|
|
308
|
+
provider's tokens. The app keeps one secret per connection and trades it for a live access token when
|
|
304
309
|
the last one runs out.
|
|
305
310
|
|
|
306
311
|
```ruby
|
|
@@ -326,9 +331,9 @@ A Rails app sets `config.delegates = true`, so its handshake asks for `masks:del
|
|
|
326
331
|
exchange with it.
|
|
327
332
|
|
|
328
333
|
`upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
|
|
329
|
-
has to connect again
|
|
330
|
-
already rotated it, so keep it. Spend a secret from one place at a time
|
|
331
|
-
it.
|
|
334
|
+
has to connect again, and `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks
|
|
335
|
+
had already rotated it, so keep it. Spend a secret from one place at a time, because spending one
|
|
336
|
+
twice revokes it.
|
|
332
337
|
|
|
333
338
|
Tests use the fake, which needs no issuer:
|
|
334
339
|
|
|
@@ -344,16 +349,10 @@ fake.revoke(held.connection)
|
|
|
344
349
|
fake.unavailable(held.connection)
|
|
345
350
|
```
|
|
346
351
|
|
|
347
|
-
## Which issuers this speaks to
|
|
348
|
-
|
|
349
|
-
masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
|
|
350
|
-
least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
|
|
351
|
-
it. An older issuer is refused at configuration time, with a sentence saying so.
|
|
352
|
-
|
|
353
352
|
## Documentation
|
|
354
353
|
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
The full reference is generated from this gem at [masks.pages.dev](https://masks.pages.dev): `ruby`
|
|
355
|
+
for `Masks::Client` and `rails` for the engine.
|
|
357
356
|
|
|
358
357
|
## License
|
|
359
358
|
|
|
@@ -2,6 +2,7 @@ module Masks
|
|
|
2
2
|
module Rails
|
|
3
3
|
class HandshakesController < BaseController
|
|
4
4
|
before_action :require_unconfigured_or_signed_in
|
|
5
|
+
before_action :require_unconfigured_or_manager
|
|
5
6
|
|
|
6
7
|
def show
|
|
7
8
|
return begin! unless masks_config.configured?(request)
|
|
@@ -69,6 +70,16 @@ module Masks
|
|
|
69
70
|
redirect_to masks_config.after_sign_out
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
def require_unconfigured_or_manager
|
|
74
|
+
return unless masks_config.configured?(request)
|
|
75
|
+
return if masks_config.manages?(request, masks_identity)
|
|
76
|
+
|
|
77
|
+
@code = "forbidden"
|
|
78
|
+
@description = "only somebody this app lets manage its connection can reconnect or disconnect it"
|
|
79
|
+
|
|
80
|
+
render :refused, status: :forbidden
|
|
81
|
+
end
|
|
82
|
+
|
|
72
83
|
def stale
|
|
73
84
|
@code = "invalid_state"
|
|
74
85
|
@description = "that connection request is not one this browser started, or it expired"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Rails
|
|
3
3
|
class SessionsController < BaseController
|
|
4
|
+
RESTARTED = "masks_restarted_at".freeze
|
|
5
|
+
RESTART_WITHIN = 60
|
|
6
|
+
|
|
4
7
|
def show
|
|
5
8
|
if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
|
|
6
9
|
response.headers["Cache-Control"] = "no-store"
|
|
@@ -36,8 +39,8 @@ module Masks
|
|
|
36
39
|
def callback
|
|
37
40
|
pending = masks_requests.claim(params[:state])
|
|
38
41
|
|
|
39
|
-
return stale if pending.nil?
|
|
40
42
|
return refuse(params[:error], params[:error_description], pending) if params[:error].present?
|
|
43
|
+
return stale if pending.nil?
|
|
41
44
|
|
|
42
45
|
tokens = masks_session.complete(
|
|
43
46
|
code: params[:code],
|
|
@@ -55,6 +58,7 @@ module Masks
|
|
|
55
58
|
return refuse("invalid_nonce", "the id token was issued for another request", pending)
|
|
56
59
|
end
|
|
57
60
|
|
|
61
|
+
renew_session
|
|
58
62
|
masks_store(tokens, identity: identity)
|
|
59
63
|
|
|
60
64
|
redirect_to pending[:return_to] || masks_config.after_sign_in
|
|
@@ -70,7 +74,9 @@ module Masks
|
|
|
70
74
|
everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
|
|
71
75
|
upstream = everywhere ? masks_logout_url : nil
|
|
72
76
|
|
|
77
|
+
revoke_refresh_token
|
|
73
78
|
masks_forget
|
|
79
|
+
reset_session
|
|
74
80
|
|
|
75
81
|
if masks_wants_json?
|
|
76
82
|
render json: { "signed_in" => false, "logout_url" => upstream }.compact
|
|
@@ -81,6 +87,23 @@ module Masks
|
|
|
81
87
|
|
|
82
88
|
private
|
|
83
89
|
|
|
90
|
+
def renew_session
|
|
91
|
+
carried = session.to_hash.slice(REQUESTS, HANDSHAKES)
|
|
92
|
+
|
|
93
|
+
reset_session
|
|
94
|
+
carried.each { |key, value| session[key] = value }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def revoke_refresh_token
|
|
98
|
+
held = masks_tokens&.refresh_token
|
|
99
|
+
|
|
100
|
+
return if held.nil? || !masks_configured?
|
|
101
|
+
|
|
102
|
+
masks_session.revoke(held, hint: "refresh_token")
|
|
103
|
+
rescue Masks::Client::Error
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
84
107
|
def requested_return_to
|
|
85
108
|
masks_local_path(params[:return_to])
|
|
86
109
|
end
|
|
@@ -95,6 +118,9 @@ module Masks
|
|
|
95
118
|
end
|
|
96
119
|
|
|
97
120
|
def stale
|
|
121
|
+
return redirect_to(masks_config.after_sign_in) if masks_signed_in? || (masks_tokens && masks_refresh!)
|
|
122
|
+
return restart unless masks_wants_json? || restarted_lately?
|
|
123
|
+
|
|
98
124
|
answer(
|
|
99
125
|
"invalid_state",
|
|
100
126
|
"that sign-in request is not one this browser started, or it expired",
|
|
@@ -102,6 +128,15 @@ module Masks
|
|
|
102
128
|
)
|
|
103
129
|
end
|
|
104
130
|
|
|
131
|
+
def restart
|
|
132
|
+
session[RESTARTED] = Time.now.to_i
|
|
133
|
+
redirect_to start_path
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def restarted_lately?
|
|
137
|
+
Time.now.to_i - session[RESTARTED].to_i < RESTART_WITHIN
|
|
138
|
+
end
|
|
139
|
+
|
|
105
140
|
def refuse(code, description, pending = nil)
|
|
106
141
|
masks_forget if pending
|
|
107
142
|
|
data/lib/masks/client/errors.rb
CHANGED
|
@@ -29,13 +29,18 @@ module Masks
|
|
|
29
29
|
class Challenge < Error
|
|
30
30
|
attr_reader :code, :description, :status, :scope
|
|
31
31
|
|
|
32
|
-
def initialize(code, description, status:, scope: nil)
|
|
32
|
+
def initialize(code, description, status:, scope: nil, dpop: false)
|
|
33
33
|
super([ code, description ].compact.join(": "))
|
|
34
34
|
|
|
35
35
|
@code = code
|
|
36
36
|
@description = description
|
|
37
37
|
@status = status
|
|
38
38
|
@scope = scope
|
|
39
|
+
@dpop = dpop
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dpop?
|
|
43
|
+
@dpop
|
|
39
44
|
end
|
|
40
45
|
end
|
|
41
46
|
|
|
@@ -46,8 +51,8 @@ module Masks
|
|
|
46
51
|
end
|
|
47
52
|
|
|
48
53
|
class Unauthorized < Challenge
|
|
49
|
-
def initialize(description, code: "invalid_token")
|
|
50
|
-
super(code, description, status: 401)
|
|
54
|
+
def initialize(description, code: "invalid_token", dpop: false)
|
|
55
|
+
super(code, description, status: 401, dpop: dpop)
|
|
51
56
|
end
|
|
52
57
|
end
|
|
53
58
|
|
data/lib/masks/client/pkce.rb
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Proof
|
|
4
|
+
class Invalid < Error; end
|
|
5
|
+
|
|
6
|
+
SCHEME = "DPoP".freeze
|
|
7
|
+
TYPE = "dpop+jwt".freeze
|
|
8
|
+
ALGORITHMS = %w[ES256 ES384 ES512 PS256 PS384 PS512 RS256].freeze
|
|
9
|
+
SECRET = %w[d p q dp dq qi k].freeze
|
|
10
|
+
THUMBED = { "EC" => %w[crv kty x y], "RSA" => %w[e kty n] }.freeze
|
|
11
|
+
LEEWAY = 30
|
|
12
|
+
WINDOW = 60
|
|
13
|
+
MEMORY = WINDOW + (LEEWAY * 2)
|
|
14
|
+
|
|
15
|
+
class Memory
|
|
16
|
+
def initialize
|
|
17
|
+
@held = {}
|
|
18
|
+
@lock = Mutex.new
|
|
19
|
+
@swept = 0.0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def first?(key, expires_in:)
|
|
23
|
+
now = Time.now.to_f
|
|
24
|
+
|
|
25
|
+
@lock.synchronize do
|
|
26
|
+
sweep(now) if now - @swept > 1
|
|
27
|
+
return false if @held.key?(key) && @held[key] >= now
|
|
28
|
+
|
|
29
|
+
@held[key] = now + expires_in
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def sweep(now)
|
|
37
|
+
@held.delete_if { |_, until_at| until_at < now }
|
|
38
|
+
@swept = now
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
MEMORY_LOCK = Mutex.new
|
|
43
|
+
|
|
44
|
+
def self.memory
|
|
45
|
+
MEMORY_LOCK.synchronize { @memory ||= Memory.new }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.thumbprint(jwk)
|
|
49
|
+
named = THUMBED[jwk["kty"]] || raise(Invalid, "the proof key is of a kind this resource does not read")
|
|
50
|
+
held = named.to_h { |field| [ field, jwk[field] ] }
|
|
51
|
+
|
|
52
|
+
raise Invalid, "the proof key is missing part of itself" if held.any? { |_, value| value.to_s.empty? }
|
|
53
|
+
|
|
54
|
+
digest(JSON.generate(held))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.digest(value)
|
|
58
|
+
Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(value.to_s), padding: false)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(proof, method:, url:, replay: nil)
|
|
62
|
+
held = proof.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
63
|
+
|
|
64
|
+
raise Invalid, "exactly one DPoP proof is required" unless held.size == 1
|
|
65
|
+
|
|
66
|
+
@proof = held.first
|
|
67
|
+
@method = method.to_s.upcase
|
|
68
|
+
@url = url.to_s
|
|
69
|
+
@replay = replay
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check!(access_token:, jkt:)
|
|
73
|
+
header = keyed
|
|
74
|
+
claims = verified(header)
|
|
75
|
+
thumbprint = self.class.thumbprint(header["jwk"])
|
|
76
|
+
|
|
77
|
+
raise Invalid, "that proof was made with another key than the token is bound to" unless same?(thumbprint, jkt)
|
|
78
|
+
|
|
79
|
+
matches!(claims)
|
|
80
|
+
timely!(claims)
|
|
81
|
+
raise Invalid, "that proof was made for another token" unless same?(claims["ath"].to_s, self.class.digest(access_token))
|
|
82
|
+
|
|
83
|
+
once!(claims, thumbprint)
|
|
84
|
+
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def keyed
|
|
91
|
+
header = JWT.decode(@proof, nil, false).last
|
|
92
|
+
|
|
93
|
+
raise Invalid, "a proof must be typed #{TYPE}" unless header["typ"].to_s.casecmp?(TYPE)
|
|
94
|
+
raise Invalid, "#{header['alg'] || 'that'} is not an algorithm a proof may use" unless ALGORITHMS.include?(header["alg"].to_s)
|
|
95
|
+
|
|
96
|
+
jwk = header["jwk"]
|
|
97
|
+
|
|
98
|
+
raise Invalid, "a proof must carry the public key that signed it" unless jwk.is_a?(Hash)
|
|
99
|
+
raise Invalid, "a proof key must be public" if jwk["kty"].to_s == "oct" || jwk.keys.any? { |field| SECRET.include?(field.to_s) }
|
|
100
|
+
|
|
101
|
+
header
|
|
102
|
+
rescue JWT::DecodeError
|
|
103
|
+
raise Invalid, "that proof is not a readable JWT"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def verified(header)
|
|
107
|
+
JWT.decode(@proof, JWT::JWK.new(header["jwk"]).verify_key, true,
|
|
108
|
+
algorithms: [ header["alg"].to_s ], verify_expiration: false).first
|
|
109
|
+
rescue JWT::DecodeError, JWT::JWKError, OpenSSL::PKey::PKeyError, ArgumentError
|
|
110
|
+
raise Invalid, "that proof was not signed by the key it carries"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def matches!(claims)
|
|
114
|
+
raise Invalid, "that proof was made for another method" unless claims["htm"].to_s.upcase == @method
|
|
115
|
+
raise Invalid, "that proof was made for another URL" unless tidied(claims["htu"]) == tidied(@url)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def tidied(value)
|
|
119
|
+
uri = URI.parse(value.to_s)
|
|
120
|
+
raise Invalid, "that proof names no URL" if uri.host.nil?
|
|
121
|
+
|
|
122
|
+
port = ":#{uri.port}" unless uri.port.nil? || uri.port == uri.default_port
|
|
123
|
+
path = uri.path.to_s == "/" ? "" : uri.path.to_s
|
|
124
|
+
|
|
125
|
+
"#{uri.scheme.to_s.downcase}://#{uri.host.downcase}#{port}#{path}"
|
|
126
|
+
rescue URI::InvalidURIError
|
|
127
|
+
raise Invalid, "that proof names a URL this resource cannot read"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def timely!(claims)
|
|
131
|
+
made = claims["iat"]
|
|
132
|
+
|
|
133
|
+
raise Invalid, "a proof must say when it was made" unless made.is_a?(Numeric)
|
|
134
|
+
raise Invalid, "that proof was made too long ago" if made < Time.now.to_i - WINDOW - LEEWAY
|
|
135
|
+
raise Invalid, "that proof was made in the future" if made > Time.now.to_i + LEEWAY
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def once!(claims, thumbprint)
|
|
139
|
+
jti = claims["jti"].to_s
|
|
140
|
+
|
|
141
|
+
raise Invalid, "a proof must carry a jti" if jti.empty?
|
|
142
|
+
return if @replay.nil? || @replay.first?("#{thumbprint}:#{jti}", expires_in: MEMORY)
|
|
143
|
+
|
|
144
|
+
raise Invalid, "that proof has already been used"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def same?(held, expected)
|
|
148
|
+
!held.to_s.empty? && held.bytesize == expected.to_s.bytesize &&
|
|
149
|
+
OpenSSL.fixed_length_secure_compare(held, expected.to_s)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/masks/client/rack.rb
CHANGED
|
@@ -15,7 +15,10 @@ module Masks
|
|
|
15
15
|
def call(env)
|
|
16
16
|
return @app.call(env) unless guards?(env)
|
|
17
17
|
|
|
18
|
-
env[CLAIMS] = resource(env).authenticate(
|
|
18
|
+
env[CLAIMS] = resource(env).authenticate(
|
|
19
|
+
env["HTTP_AUTHORIZATION"], scope: @scope,
|
|
20
|
+
proof: env["HTTP_DPOP"], method: env["REQUEST_METHOD"], url: url(env)
|
|
21
|
+
)
|
|
19
22
|
|
|
20
23
|
@app.call(env)
|
|
21
24
|
rescue Challenge => e
|
|
@@ -32,6 +35,17 @@ module Masks
|
|
|
32
35
|
@only.call(env)
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
def url(env)
|
|
39
|
+
if defined?(::Rack::Request)
|
|
40
|
+
request = ::Rack::Request.new(env)
|
|
41
|
+
return "#{request.base_url}#{request.path}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
host = env["HTTP_HOST"] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"
|
|
45
|
+
|
|
46
|
+
"#{env['rack.url_scheme'] || 'http'}://#{host}#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
47
|
+
end
|
|
48
|
+
|
|
35
49
|
def passthrough(env, error)
|
|
36
50
|
env[ERROR] = error
|
|
37
51
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Client
|
|
3
3
|
class Resource
|
|
4
|
-
|
|
4
|
+
PRESENTED = /\A(Bearer|DPoP)[ \t]+([^\s,]+)[ \t]*\z/i.freeze
|
|
5
5
|
METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
|
|
6
6
|
REQUIRED = %w[iss sub exp].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :issuer, :url, :scopes
|
|
9
9
|
|
|
10
10
|
def initialize(issuer:, url:, scopes: [], metadata_url: nil,
|
|
11
|
-
algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil)
|
|
11
|
+
algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil, replay: Proof.memory)
|
|
12
12
|
@issuer = Issuer.resolve(issuer)
|
|
13
13
|
@url = url.to_s
|
|
14
14
|
@descriptions = describe(scopes)
|
|
@@ -16,10 +16,14 @@ module Masks
|
|
|
16
16
|
@metadata_url = metadata_url
|
|
17
17
|
@required = Array(required)
|
|
18
18
|
@verifier = verifier || Verifier.new(@issuer, audience: @url, algorithms: algorithms)
|
|
19
|
+
@replay = replay
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def authenticate(authorization, scope: nil, role: nil, organization: nil)
|
|
22
|
-
|
|
22
|
+
def authenticate(authorization, scope: nil, role: nil, organization: nil, proof: nil, method: nil, url: nil)
|
|
23
|
+
scheme, token = presented!(authorization)
|
|
24
|
+
held = @verifier.verify(token, required: @required, typ: Verifier::ACCESS_TOKEN)
|
|
25
|
+
bound!(held, scheme, token, proof: proof, method: method, url: url)
|
|
26
|
+
claims = Claims.new(held)
|
|
23
27
|
|
|
24
28
|
Array(scope).each { |name| claims.permit!(name) }
|
|
25
29
|
claims.member!(*Array(role), organization: organization) if role || organization
|
|
@@ -30,7 +34,7 @@ module Masks
|
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
def token(authorization)
|
|
33
|
-
authorization.to_s[
|
|
37
|
+
authorization.to_s[PRESENTED, 2]
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def metadata_url
|
|
@@ -43,7 +47,8 @@ module Masks
|
|
|
43
47
|
"authorization_servers" => [ issuer.url ],
|
|
44
48
|
"scopes_supported" => scopes,
|
|
45
49
|
"scope_descriptions" => @descriptions,
|
|
46
|
-
"bearer_methods_supported" => [ "header" ]
|
|
50
|
+
"bearer_methods_supported" => [ "header" ],
|
|
51
|
+
"dpop_signing_alg_values_supported" => Proof::ALGORITHMS
|
|
47
52
|
}.reject { |_, value| value.respond_to?(:empty?) && value.empty? }
|
|
48
53
|
end
|
|
49
54
|
|
|
@@ -57,7 +62,9 @@ module Masks
|
|
|
57
62
|
[ "resource_metadata", metadata_url ]
|
|
58
63
|
]
|
|
59
64
|
|
|
60
|
-
|
|
65
|
+
scheme = error.dpop? ? "#{Proof::SCHEME} algs=\"#{Proof::ALGORITHMS.join(' ')}\", " : "Bearer "
|
|
66
|
+
|
|
67
|
+
scheme + parameters.filter_map { |name, value|
|
|
61
68
|
%(#{name}="#{quote(value)}") if value && !value.to_s.empty?
|
|
62
69
|
}.join(", ")
|
|
63
70
|
end
|
|
@@ -72,8 +79,22 @@ module Masks
|
|
|
72
79
|
end.freeze
|
|
73
80
|
end
|
|
74
81
|
|
|
75
|
-
def
|
|
76
|
-
|
|
82
|
+
def presented!(authorization)
|
|
83
|
+
match = authorization.to_s.match(PRESENTED) || raise(Unauthenticated.new)
|
|
84
|
+
|
|
85
|
+
[ match[1].casecmp?(Proof::SCHEME) ? :dpop : :bearer, match[2] ]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def bound!(claims, scheme, token, proof:, method:, url:)
|
|
89
|
+
jkt = claims.dig("cnf", "jkt")
|
|
90
|
+
|
|
91
|
+
raise Unauthorized.new("that token is not bound to a key, so it is presented as Bearer") if jkt.nil? && scheme == :dpop
|
|
92
|
+
raise Unauthorized.new("that token is bound to a key, so it is presented as DPoP with a proof", dpop: true) if jkt && scheme != :dpop
|
|
93
|
+
return if jkt.nil?
|
|
94
|
+
|
|
95
|
+
Proof.new(proof, method: method, url: url || self.url, replay: @replay).check!(access_token: token, jkt: jkt)
|
|
96
|
+
rescue Proof::Invalid => e
|
|
97
|
+
raise Unauthorized.new(e.message, code: "invalid_dpop_proof", dpop: true)
|
|
77
98
|
end
|
|
78
99
|
|
|
79
100
|
def origin
|
data/lib/masks/client.rb
CHANGED
|
@@ -19,6 +19,7 @@ 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/proof"
|
|
22
23
|
require_relative "client/logout"
|
|
23
24
|
require_relative "client/resource"
|
|
24
25
|
require_relative "client/rack"
|
|
@@ -318,9 +318,15 @@ module Masks
|
|
|
318
318
|
path = value.to_s
|
|
319
319
|
|
|
320
320
|
return nil unless path.start_with?("/")
|
|
321
|
-
return nil if path.
|
|
321
|
+
return nil if path.match?(/[\x00-\x20\x7f\\]/)
|
|
322
322
|
|
|
323
|
-
path
|
|
323
|
+
uri = URI.parse(path)
|
|
324
|
+
|
|
325
|
+
return nil if uri.scheme || uri.host || uri.userinfo || !uri.path.start_with?("/") || uri.path.start_with?("//")
|
|
326
|
+
|
|
327
|
+
[ uri.path, uri.query ].compact.join("?")
|
|
328
|
+
rescue URI::InvalidURIError
|
|
329
|
+
nil
|
|
324
330
|
end
|
|
325
331
|
end
|
|
326
332
|
end
|
|
@@ -7,7 +7,7 @@ module Masks
|
|
|
7
7
|
:after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
|
|
8
8
|
:credentials_path, :authenticate_everything, :delegates
|
|
9
9
|
attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
|
|
10
|
-
:delegation_redirect_uri, :organization
|
|
10
|
+
:delegation_redirect_uri, :organization, :manages
|
|
11
11
|
|
|
12
12
|
def initialize
|
|
13
13
|
@scope = Masks::Client::Session::DEFAULT_SCOPE
|
|
@@ -89,6 +89,14 @@ module Masks
|
|
|
89
89
|
true
|
|
90
90
|
end
|
|
91
91
|
|
|
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
|
|
98
|
+
end
|
|
99
|
+
|
|
92
100
|
def can_forget?
|
|
93
101
|
@forget.respond_to?(:call) || !@store.respond_to?(:call)
|
|
94
102
|
end
|
|
@@ -106,19 +114,6 @@ module Masks
|
|
|
106
114
|
@store.call(request, registration)
|
|
107
115
|
end
|
|
108
116
|
|
|
109
|
-
MINIMUM_ISSUER = 1
|
|
110
|
-
|
|
111
|
-
def issuer_speaks!(request)
|
|
112
|
-
spoken = Masks::Client::Issuer.resolve(issuer_for(request))
|
|
113
|
-
.discovery["masks_protocol_version"].to_i
|
|
114
|
-
|
|
115
|
-
return true if spoken >= MINIMUM_ISSUER
|
|
116
|
-
|
|
117
|
-
raise Unconfigured,
|
|
118
|
-
"#{issuer_for(request)} speaks masks protocol #{spoken}, and masks " \
|
|
119
|
-
"#{Masks::VERSION} needs at least #{MINIMUM_ISSUER}"
|
|
120
|
-
end
|
|
121
|
-
|
|
122
117
|
def session_for(request)
|
|
123
118
|
raise Unconfigured, "this app has not shaken hands with #{issuer_for(request)}" unless configured?(request)
|
|
124
119
|
|
|
@@ -20,18 +20,23 @@ module Masks
|
|
|
20
20
|
|
|
21
21
|
def masks_authenticate!(scope: nil, role: nil, organization: nil, **)
|
|
22
22
|
@masks_claims = masks_resource.authenticate(request.authorization, scope: scope, role: role,
|
|
23
|
-
organization: organization)
|
|
23
|
+
organization: organization, **masks_proof)
|
|
24
24
|
rescue Masks::Client::Challenge => e
|
|
25
25
|
masks_challenge(e)
|
|
26
26
|
false
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def masks_authenticate(scope: nil, role: nil, organization: nil)
|
|
30
|
-
masks_resource.authenticate(request.authorization, scope: scope, role: role, organization: organization
|
|
30
|
+
masks_resource.authenticate(request.authorization, scope: scope, role: role, organization: organization,
|
|
31
|
+
**masks_proof)
|
|
31
32
|
rescue Masks::Client::Unauthenticated
|
|
32
33
|
nil
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
def masks_proof
|
|
37
|
+
{ proof: request.headers["DPoP"], method: request.request_method, url: "#{request.base_url}#{request.path}" }
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
def masks_challenge(error)
|
|
36
41
|
error = Masks::Client::Unauthorized.new(error.to_s) unless error.is_a?(Masks::Client::Challenge)
|
|
37
42
|
|
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.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- the masks authors
|
|
@@ -57,6 +57,7 @@ files:
|
|
|
57
57
|
- lib/masks/client/issuer.rb
|
|
58
58
|
- lib/masks/client/logout.rb
|
|
59
59
|
- lib/masks/client/pkce.rb
|
|
60
|
+
- lib/masks/client/proof.rb
|
|
60
61
|
- lib/masks/client/rack.rb
|
|
61
62
|
- lib/masks/client/registration.rb
|
|
62
63
|
- lib/masks/client/registry.rb
|