masks 0.6.0 → 0.7.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 +39 -0
- data/LICENSE +1 -1
- data/README.md +90 -32
- data/app/controllers/masks/rails/handshakes_controller.rb +13 -12
- data/app/controllers/masks/rails/logouts_controller.rb +32 -0
- data/app/views/masks/rails/handshakes/show.html.erb +17 -33
- data/config/routes.rb +1 -0
- data/lib/masks/client/delegations/fake.rb +91 -0
- data/lib/masks/client/delegations.rb +117 -0
- data/lib/masks/client/handshake.rb +7 -3
- data/lib/masks/client/introspection.rb +1 -1
- data/lib/masks/client/issuer.rb +4 -0
- data/lib/masks/client/logout.rb +60 -0
- data/lib/masks/client/registration.rb +2 -3
- data/lib/masks/client/session.rb +10 -2
- data/lib/masks/client/tokens.rb +3 -1
- data/lib/masks/client.rb +46 -0
- data/lib/masks/rails/configuration.rb +32 -20
- data/lib/masks/rails/credentials.rb +0 -4
- data/lib/masks/rails/engine.rb +0 -5
- data/lib/masks/rails.rb +26 -0
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +17 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5da19b0cb3099ebe46f2b4509615a75422fa0b2820abb799e8f182ce11f456ad
|
|
4
|
+
data.tar.gz: 4d5d6b573cccbca63439587cd3363d22be28dd0d8ff95051f32dcc8252494185
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 130ccd4e8a1ca7ddfe996f7439af4dc800501ea0a81ec0a18561475447931af40affb0cd0e2a66aa191d28d93f677046f49b7d5db6a913426fa21114d1bcbb68
|
|
7
|
+
data.tar.gz: bd818590edc19a344daf59e755d8657da71e10f2017fc3b8ffe95ed6f8452f804a6ccdc7da621425b288c5934b25b5c39bd41923d13983e16fab9ab144bdfd78
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0](https://github.com/masksrb/masks/compare/gem/v0.6.0...gem/v0.7.0) (2026-09-13)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* **client:** Masks::Client::Introspection#username is now #nickname.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **client:** an app can act on a logout the issuer tells it about ([4d0b7f7](https://github.com/masksrb/masks/commit/4d0b7f77a74b0e416dda3e90413ad989a909f344))
|
|
13
|
+
* **client:** an app that delegates asks its handshake for masks:delegate: and the token exchange ([912e856](https://github.com/masksrb/masks/commit/912e856b00a9f27d59af861cdb35da518fa862bc))
|
|
14
|
+
* **client:** an app that delegates registers where connecting a person's account comes back to ([738fba6](https://github.com/masksrb/masks/commit/738fba67e544f7c8a9a15cd55befbd66696b707b))
|
|
15
|
+
* **client:** an unconnected app walks straight into the handshake ([040388e](https://github.com/masksrb/masks/commit/040388e188653f67ef7a59bc0aa9aef02ec351b2))
|
|
16
|
+
* **server:** an application is let use somebody's account elsewhere through a delegation ([4b7048c](https://github.com/masksrb/masks/commit/4b7048cb8ac4e174b9359a070b7a2d942a1c410d))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Fixes
|
|
20
|
+
|
|
21
|
+
* **client:** back-channel logout says 501 when the app does nothing about it ([60e1125](https://github.com/masksrb/masks/commit/60e1125c02adec7c822c6d3757210e825bb66cfc))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
* hold the copyright as the masks authors ([cffdeb5](https://github.com/masksrb/masks/commit/cffdeb59f14eb02f6a9513d5f7cda21ebaee2171))
|
|
27
|
+
* READMEs that match the code, and a server one that is not the scaffold ([e0148e1](https://github.com/masksrb/masks/commit/e0148e14e0092731bcd095bc0fc22a1589c3acc0))
|
|
28
|
+
* references are generated from the code that defines them ([ba74550](https://github.com/masksrb/masks/commit/ba745501e651b797948e5abd5640a1825616100c))
|
|
29
|
+
* the gem reference survives rdoc 8, and lists what a Concern adds ([36df12e](https://github.com/masksrb/masks/commit/36df12e72cc52f5db650d16778ecc501bf9db14a))
|
|
30
|
+
* the rose window is the site's logo, favicon and hero, and heads every README ([72176e4](https://github.com/masksrb/masks/commit/72176e49c81bc0a5d6730f3b9e5eaa693db8a659))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Refactoring
|
|
34
|
+
|
|
35
|
+
* **client:** an introspection answers a nickname, not a username ([f23dff5](https://github.com/masksrb/masks/commit/f23dff537320982d42a8e4823bea784a3d80ec33))
|
|
36
|
+
* comments are gone; the code says what it does ([0fc8e6a](https://github.com/masksrb/masks/commit/0fc8e6a4692e3f09831514b6d167c3e23d7db419))
|
|
37
|
+
* every suite is named for what it proves, under one test directory ([315b371](https://github.com/masksrb/masks/commit/315b371e64baa4441014669744a61c88341d27c7))
|
|
38
|
+
* rename the example tenant from jons to demo ([aef32a6](https://github.com/masksrb/masks/commit/aef32a670b4b3c696089c07bf15103e1d981eb00))
|
|
39
|
+
* **server:** delegation shares what sign-in and linking already had, and asks the database less ([44b8c4d](https://github.com/masksrb/masks/commit/44b8c4d303dfcc6de60f98d7824c544bd67e347c))
|
|
40
|
+
* take the owner's domain out of the boundary check ([df580ee](https://github.com/masksrb/masks/commit/df580ee1fb5820d30a57a88f3aa9825c97682725))
|
|
41
|
+
|
|
3
42
|
## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
|
|
4
43
|
|
|
5
44
|
|
data/LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/masksrb/masks/main/server/public/icon.svg" width="120" alt="The masks rose window"></p>
|
|
2
|
+
|
|
1
3
|
# masks
|
|
2
4
|
|
|
3
5
|
Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks) issuer.
|
|
@@ -7,9 +9,9 @@ gem "masks"
|
|
|
7
9
|
```
|
|
8
10
|
|
|
9
11
|
Three parts, in one gem. **Consumers spend tokens. Resource servers accept them.** Most
|
|
10
|
-
client libraries only
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
client libraries build only the first, leaving each resource server to write its own
|
|
13
|
+
scope check, its `WWW-Authenticate` header and its metadata document. Both halves are
|
|
14
|
+
here, with a Rails engine that mounts them.
|
|
13
15
|
|
|
14
16
|
The engine loads only when Rails does. A Sinatra, Hanami or plain-Rack app requiring
|
|
15
17
|
this gem pulls in `jwt` and nothing else.
|
|
@@ -29,12 +31,16 @@ the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
|
|
|
29
31
|
|
|
30
32
|
### The handshake
|
|
31
33
|
|
|
32
|
-
A first-party app must not self-register anonymously
|
|
33
|
-
connector
|
|
34
|
-
|
|
34
|
+
A first-party app must not self-register anonymously, since that is also how a
|
|
35
|
+
stranger's connector arrives. An unconnected app sends the browser straight on to its
|
|
36
|
+
own issuer's approval screen, and the one-time token that comes back is redeemed
|
|
35
37
|
server-side. **The secret never travels through the browser and nobody types it
|
|
36
38
|
anywhere.**
|
|
37
39
|
|
|
40
|
+
The only thing `/auth/handshake` stops a browser to say is that the app is configured
|
|
41
|
+
too little to shake hands at all, or that the issuer refused. A connected app asks
|
|
42
|
+
before it rotates, since reconnecting takes it offline for a moment.
|
|
43
|
+
|
|
38
44
|
The engine writes what comes back to `config/masks.json`, mode 600. An app that wants
|
|
39
45
|
somewhere else says so:
|
|
40
46
|
|
|
@@ -48,7 +54,7 @@ Those two lambdas are the whole integration for a multi-tenant app.
|
|
|
48
54
|
### Signing in
|
|
49
55
|
|
|
50
56
|
```ruby
|
|
51
|
-
class
|
|
57
|
+
class UrisController < ApplicationController
|
|
52
58
|
include Masks::Rails::Authentication
|
|
53
59
|
|
|
54
60
|
before_action :authenticate_masks!
|
|
@@ -60,7 +66,7 @@ end
|
|
|
60
66
|
```
|
|
61
67
|
|
|
62
68
|
`masks_identity`, `masks_tenant` and `masks_scopes` are what a signed-in request carries.
|
|
63
|
-
Tokens live in the encrypted Rails session and never reach JavaScript
|
|
69
|
+
Tokens live in the encrypted Rails session and never reach JavaScript. This is the
|
|
64
70
|
backend-for-frontend pattern, and it is the default because an SPA holding a token is an
|
|
65
71
|
SPA where XSS lifts one.
|
|
66
72
|
|
|
@@ -70,7 +76,7 @@ controller if that is what you want; otherwise include it where you mean it.
|
|
|
70
76
|
### An SPA in front of it
|
|
71
77
|
|
|
72
78
|
`GET /auth/session` answers identity, tenant and scopes as JSON, or `401` with somewhere
|
|
73
|
-
to send the browser. [`@masks/client`](
|
|
79
|
+
to send the browser. [`@masks/client`](https://masks.pages.dev/reference/browser/) speaks it:
|
|
74
80
|
|
|
75
81
|
```js
|
|
76
82
|
import { createSession } from "@masks/client"
|
|
@@ -79,10 +85,9 @@ const session = createSession()
|
|
|
79
85
|
const status = await session.status()
|
|
80
86
|
```
|
|
81
87
|
|
|
82
|
-
`status()` answers one of three
|
|
83
|
-
`
|
|
84
|
-
|
|
85
|
-
error page at the issuer.
|
|
88
|
+
`status()` answers one of three states: `signed_in`, `signed_out` with where to sign in,
|
|
89
|
+
and `handshake_required` with where to go instead. The third exists because an app nobody
|
|
90
|
+
has connected must not offer a sign-in button that leads to an error page at the issuer.
|
|
86
91
|
|
|
87
92
|
### Accepting tokens
|
|
88
93
|
|
|
@@ -92,10 +97,13 @@ An app that is also a resource server:
|
|
|
92
97
|
class ApiController < ApplicationController
|
|
93
98
|
include Masks::Rails::ProtectedResource
|
|
94
99
|
|
|
95
|
-
|
|
100
|
+
masks_protect! scope: "uris:catalog:read"
|
|
96
101
|
end
|
|
97
102
|
```
|
|
98
103
|
|
|
104
|
+
`masks_protect!` is a class method that installs the `before_action` itself, and passes `:only` and
|
|
105
|
+
`:except` through.
|
|
106
|
+
|
|
99
107
|
`masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
|
|
100
108
|
`resource_metadata`, so a client handed nothing but a URL can find its way to the issuer
|
|
101
109
|
and back.
|
|
@@ -155,7 +163,7 @@ holds no protocol of its own.
|
|
|
155
163
|
|
|
156
164
|
```ruby
|
|
157
165
|
session = Masks::Client::Session.new(
|
|
158
|
-
issuer: "https://
|
|
166
|
+
issuer: "https://demo.auth.example.com",
|
|
159
167
|
client_id: id, client_secret: secret,
|
|
160
168
|
redirect_uri: "https://app.example.com/auth/callback"
|
|
161
169
|
)
|
|
@@ -169,8 +177,7 @@ identity = session.identity(tokens)
|
|
|
169
177
|
```
|
|
170
178
|
|
|
171
179
|
`start` sends a nonce only when `openid` was asked for, so holding one means an id token
|
|
172
|
-
is owed
|
|
173
|
-
true.
|
|
180
|
+
is owed, and the check on the way back is exact.
|
|
174
181
|
|
|
175
182
|
Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
|
|
176
183
|
|
|
@@ -181,12 +188,11 @@ constructing one per request.
|
|
|
181
188
|
|
|
182
189
|
### The handshake
|
|
183
190
|
|
|
184
|
-
The flow that connects a first-party app
|
|
185
|
-
state in every consumer:
|
|
191
|
+
The flow that connects a first-party app:
|
|
186
192
|
|
|
187
193
|
```ruby
|
|
188
194
|
handshake = Masks::Client::Handshake.new(
|
|
189
|
-
issuer, name: "
|
|
195
|
+
issuer, name: "uris", resource: "https://app.example.com/mcp",
|
|
190
196
|
redirect_uris: [ "https://app.example.com/auth/callback" ],
|
|
191
197
|
return_to: "https://app.example.com/"
|
|
192
198
|
)
|
|
@@ -202,12 +208,12 @@ that is not the issuer it asked — each **before** anything is redeemed.
|
|
|
202
208
|
|
|
203
209
|
```ruby
|
|
204
210
|
resource = Masks::Client::Resource.new(
|
|
205
|
-
issuer: "https://
|
|
211
|
+
issuer: "https://demo.auth.example.com",
|
|
206
212
|
url: "https://app.example.com/mcp",
|
|
207
|
-
scopes: { "
|
|
213
|
+
scopes: { "uris:catalog:read" => "Search your catalog" }
|
|
208
214
|
)
|
|
209
215
|
|
|
210
|
-
claims = resource.authenticate(request.authorization, scope: "
|
|
216
|
+
claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
|
|
211
217
|
claims.subject
|
|
212
218
|
claims.tenant.subdomain
|
|
213
219
|
```
|
|
@@ -221,35 +227,87 @@ response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
|
221
227
|
|
|
222
228
|
`resource.metadata` is the RFC 9728 document to serve at
|
|
223
229
|
`/.well-known/oauth-protected-resource`. The `scope_descriptions` extension in it is how
|
|
224
|
-
an auth server renders your scopes as sentences on its consent screen
|
|
225
|
-
way to know what `
|
|
230
|
+
an auth server renders your scopes as sentences on its consent screen; it has no other
|
|
231
|
+
way to know what `uris:catalog:read` means.
|
|
226
232
|
|
|
227
233
|
There is a Rack middleware for consumers that want the challenge below the framework:
|
|
228
234
|
|
|
229
235
|
```ruby
|
|
230
|
-
use Masks::Client::Rack, resource: resource, scope: "
|
|
236
|
+
use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
|
|
231
237
|
```
|
|
232
238
|
|
|
233
239
|
### Introspection
|
|
234
240
|
|
|
235
241
|
A resource server doing JWT-only validation cannot see a revocation until the token
|
|
236
|
-
expires.
|
|
242
|
+
expires. Ask the issuer instead:
|
|
237
243
|
|
|
238
244
|
```ruby
|
|
239
245
|
found = session.introspect(token)
|
|
240
|
-
found.active? && found.permits?("
|
|
246
|
+
found.active? && found.permits?("uris:catalog:read")
|
|
241
247
|
```
|
|
242
248
|
|
|
243
249
|
`Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
|
|
244
|
-
not active, so
|
|
245
|
-
check
|
|
250
|
+
not active, so moving from local verification to introspection needs no extra boolean
|
|
251
|
+
check.
|
|
252
|
+
|
|
253
|
+
### Somebody else's account
|
|
254
|
+
|
|
255
|
+
An app that acts as somebody at Google, Microsoft or an MCP server while they are away asks masks for
|
|
256
|
+
a [delegation](https://masks.pages.dev/concepts/delegation/). masks keeps and refreshes the
|
|
257
|
+
provider's tokens; the app keeps one secret per connection and trades it for a live access token when
|
|
258
|
+
the last one runs out.
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
delegations = Masks::Client.delegations(
|
|
262
|
+
ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
started = delegations.start(provider: "google")
|
|
266
|
+
session[:connecting] = started
|
|
267
|
+
redirect_to started["url"]
|
|
268
|
+
|
|
269
|
+
held = delegations.finish(params: params, started: session.delete(:connecting))
|
|
270
|
+
held.connection
|
|
271
|
+
held.secret
|
|
272
|
+
|
|
273
|
+
upstream = delegations.token(held.secret, connection: held.connection)
|
|
274
|
+
upstream.access_token
|
|
275
|
+
upstream.expires_at
|
|
276
|
+
upstream.secret
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
|
|
280
|
+
exchange with it.
|
|
281
|
+
|
|
282
|
+
`upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
|
|
283
|
+
has to connect again; `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks had
|
|
284
|
+
already rotated it, so keep it. Spend a secret from one place at a time: spending one twice revokes
|
|
285
|
+
it.
|
|
286
|
+
|
|
287
|
+
Tests use the fake, which needs no issuer:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
require "masks/client/delegations/fake"
|
|
291
|
+
|
|
292
|
+
fake = Masks::Client::Delegations::Fake.new
|
|
293
|
+
started = fake.start(provider: "notion")
|
|
294
|
+
held = fake.finish(params: fake.approve(started), started: started)
|
|
295
|
+
|
|
296
|
+
fake.token(held.secret, connection: held.connection)
|
|
297
|
+
fake.revoke(held.connection)
|
|
298
|
+
fake.unavailable(held.connection)
|
|
299
|
+
```
|
|
246
300
|
|
|
247
301
|
## Which issuers this speaks to
|
|
248
302
|
|
|
249
303
|
masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
|
|
250
304
|
least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
|
|
251
|
-
it. An older issuer is refused with a sentence saying so
|
|
252
|
-
|
|
305
|
+
it. An older issuer is refused at configuration time, with a sentence saying so.
|
|
306
|
+
|
|
307
|
+
## Documentation
|
|
308
|
+
|
|
309
|
+
This README is the reference for the gem. [masks.pages.dev](https://masks.pages.dev) carries what is
|
|
310
|
+
generated from the server's own code — the `/manage` GraphQL schema, and the design system.
|
|
253
311
|
|
|
254
312
|
## License
|
|
255
313
|
|
|
@@ -4,17 +4,13 @@ module Masks
|
|
|
4
4
|
before_action :require_unconfigured_or_signed_in
|
|
5
5
|
|
|
6
6
|
def show
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
return begin! unless masks_config.configured?(request)
|
|
8
|
+
|
|
9
|
+
@can_disconnect = masks_config.can_forget?
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def create
|
|
12
|
-
|
|
13
|
-
started = masks_config.handshake_for(request).start(state: pending.id)
|
|
14
|
-
|
|
15
|
-
redirect_to started[:url], allow_other_host: true
|
|
16
|
-
rescue Masks::Client::Error => e
|
|
17
|
-
refuse(e)
|
|
13
|
+
begin!
|
|
18
14
|
end
|
|
19
15
|
|
|
20
16
|
def callback
|
|
@@ -31,10 +27,6 @@ module Masks
|
|
|
31
27
|
refuse(e)
|
|
32
28
|
end
|
|
33
29
|
|
|
34
|
-
# Rotating is what masks does with a second run — the client is keyed on
|
|
35
|
-
# the resource identifier, so approving again replaces the credentials
|
|
36
|
-
# rather than leaving a tenant with two and no way to tell which one the
|
|
37
|
-
# browser holds. Disconnecting is the other half, and it is RFC 7592.
|
|
38
30
|
def destroy
|
|
39
31
|
return redirect_to(masks_config.after_sign_out) unless masks_signed_in?
|
|
40
32
|
return redirect_to(masks_handshake_path) unless masks_config.can_forget?
|
|
@@ -51,6 +43,15 @@ module Masks
|
|
|
51
43
|
|
|
52
44
|
private
|
|
53
45
|
|
|
46
|
+
def begin!
|
|
47
|
+
pending = masks_handshakes.open
|
|
48
|
+
started = masks_config.handshake_for(request).start(state: pending.id)
|
|
49
|
+
|
|
50
|
+
redirect_to started[:url], allow_other_host: true
|
|
51
|
+
rescue Masks::Client::Error => e
|
|
52
|
+
refuse(e)
|
|
53
|
+
end
|
|
54
|
+
|
|
54
55
|
def forget_upstream
|
|
55
56
|
masks_registration&.delete
|
|
56
57
|
rescue Masks::Client::Unregistered
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Rails
|
|
3
|
+
class LogoutsController < BaseController
|
|
4
|
+
skip_forgery_protection
|
|
5
|
+
|
|
6
|
+
def create
|
|
7
|
+
return refuse("invalid_request", "logout_token is required") if params[:logout_token].blank?
|
|
8
|
+
|
|
9
|
+
logout = masks_session.logout_token(params[:logout_token])
|
|
10
|
+
|
|
11
|
+
heard = masks_config.logged_out!(request, logout)
|
|
12
|
+
|
|
13
|
+
response.headers["Cache-Control"] = "no-store"
|
|
14
|
+
|
|
15
|
+
head(heard ? :ok : :not_implemented)
|
|
16
|
+
rescue Masks::Client::InvalidToken => e
|
|
17
|
+
refuse("invalid_request", e.message)
|
|
18
|
+
rescue Masks::Rails::Configuration::Unconfigured => e
|
|
19
|
+
refuse("invalid_request", e.message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def refuse(code, description)
|
|
25
|
+
response.headers["Cache-Control"] = "no-store"
|
|
26
|
+
|
|
27
|
+
render json: { "error" => code, "error_description" => description },
|
|
28
|
+
status: :bad_request
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,40 +1,24 @@
|
|
|
1
|
-
<%
|
|
2
|
-
<% content_for :title, "Reconnect this app" %>
|
|
1
|
+
<% content_for :title, "Reconnect this app" %>
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
<h1>This app is connected</h1>
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
<p class="muted">
|
|
6
|
+
Reconnecting replaces the credentials it holds now with a fresh pair. It is
|
|
7
|
+
how you rotate them, and it is the only way to move this app to a different
|
|
8
|
+
account. Nothing is typed in and no secret travels through your browser.
|
|
9
|
+
</p>
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
<p class="muted">
|
|
12
|
+
It will stop working between the approval and the moment it picks the new
|
|
13
|
+
credentials up.
|
|
14
|
+
</p>
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<% if @can_disconnect %>
|
|
22
|
-
<%= form_with url: handshake_path, method: :delete do %>
|
|
23
|
-
<button type="submit">Disconnect it</button>
|
|
24
|
-
<% end %>
|
|
25
|
-
<% end %>
|
|
26
|
-
<% else %>
|
|
27
|
-
<% content_for :title, "Connect this app" %>
|
|
28
|
-
|
|
29
|
-
<h1>This app has not been connected yet</h1>
|
|
30
|
-
|
|
31
|
-
<p class="muted">
|
|
32
|
-
Nobody can sign in here until it holds credentials from the server that signs
|
|
33
|
-
people in. Getting them takes one approval, from whoever owns this tenant —
|
|
34
|
-
nothing is typed in, and no secret travels through your browser.
|
|
35
|
-
</p>
|
|
16
|
+
<%= form_with url: handshake_path, method: :post do %>
|
|
17
|
+
<button type="submit">Reconnect it</button>
|
|
18
|
+
<% end %>
|
|
36
19
|
|
|
37
|
-
|
|
38
|
-
|
|
20
|
+
<% if @can_disconnect %>
|
|
21
|
+
<%= form_with url: handshake_path, method: :delete do %>
|
|
22
|
+
<button type="submit">Disconnect it</button>
|
|
39
23
|
<% end %>
|
|
40
24
|
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -4,6 +4,7 @@ Masks::Rails::Engine.routes.draw do
|
|
|
4
4
|
get "/avatar", to: "avatars#show", as: :avatar
|
|
5
5
|
get "/callback", to: "sessions#callback", as: :callback
|
|
6
6
|
match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
|
|
7
|
+
post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
|
|
7
8
|
|
|
8
9
|
get "/handshake", to: "handshakes#show", as: :handshake
|
|
9
10
|
post "/handshake", to: "handshakes#create"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "masks/client"
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Client
|
|
5
|
+
class Delegations
|
|
6
|
+
class Fake
|
|
7
|
+
Connected = Struct.new(:connection, :provider, :subject, :secret, :refused, :unavailable, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
attr_reader :releases, :redirect_uri
|
|
10
|
+
|
|
11
|
+
def initialize(redirect_uri: "https://app.test/connect/callback", lifetime: 3600)
|
|
12
|
+
@redirect_uri = redirect_uri
|
|
13
|
+
@lifetime = lifetime
|
|
14
|
+
@codes = {}
|
|
15
|
+
@connections = {}
|
|
16
|
+
@releases = 0
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start(provider:, prompt: nil, max_age: nil, state: SecureRandom.urlsafe_base64(24))
|
|
21
|
+
query = URI.encode_www_form({ "provider" => provider, "state" => state, "prompt" => prompt, "max_age" => max_age }.compact)
|
|
22
|
+
|
|
23
|
+
{ "url" => "https://masks.fake/authorize?#{query}", "state" => state, "verifier" => SecureRandom.hex(16), "provider" => provider.to_s }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def approve(started, subject: "fake-subject", connection: SecureRandom.uuid)
|
|
27
|
+
code = SecureRandom.hex(12)
|
|
28
|
+
|
|
29
|
+
@lock.synchronize do
|
|
30
|
+
@codes[code] = { "provider" => started["provider"], "subject" => subject, "connection" => connection }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
{ "code" => code, "state" => started["state"] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def deny(started, error: "access_denied", description: "the person declined")
|
|
37
|
+
{ "error" => error, "error_description" => description, "state" => started["state"] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def finish(params:, started:)
|
|
41
|
+
params = params.to_h.transform_keys(&:to_s)
|
|
42
|
+
|
|
43
|
+
raise Refused.new(params["error"], params["error_description"]) if params["error"].to_s != ""
|
|
44
|
+
raise Refused.new("invalid_state", "the state did not match the one this connection started with") unless params["state"] == started["state"]
|
|
45
|
+
|
|
46
|
+
granted = @lock.synchronize { @codes.delete(params["code"]) }
|
|
47
|
+
|
|
48
|
+
raise Refused.new("invalid_grant", "that code is not valid") if granted.nil?
|
|
49
|
+
|
|
50
|
+
secret = SecureRandom.hex(16)
|
|
51
|
+
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
@connections[granted["connection"]] = Connected.new(
|
|
54
|
+
connection: granted["connection"], provider: granted["provider"], subject: granted["subject"], secret: secret
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Held.new(connection: granted["connection"], provider: granted["provider"], provider_name: granted["provider"].to_s.capitalize,
|
|
59
|
+
label: nil, subject: granted["subject"], secret: secret)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def token(secret, connection:)
|
|
63
|
+
held = @lock.synchronize { @connections[connection.to_s] }
|
|
64
|
+
|
|
65
|
+
raise Refused.new("invalid_grant", "that connection is unknown") if held.nil?
|
|
66
|
+
raise Unavailable.new("masks is not answering", secret: secret) if held.unavailable
|
|
67
|
+
raise Refused.new("invalid_grant", held.refused, secret: secret) if held.refused
|
|
68
|
+
raise Refused.new("invalid_grant", "that refresh token is not valid or has expired") unless held.secret == secret.to_s
|
|
69
|
+
|
|
70
|
+
rotated = SecureRandom.hex(16)
|
|
71
|
+
|
|
72
|
+
@lock.synchronize do
|
|
73
|
+
held.secret = rotated
|
|
74
|
+
@releases += 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Upstream.new(access_token: "#{held.provider}-access-#{@releases}", expires_at: Time.now.to_i + @lifetime,
|
|
78
|
+
scope: "", secret: rotated)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def revoke(connection, reason: "the person stopped this application using that account")
|
|
82
|
+
@lock.synchronize { @connections.fetch(connection.to_s).refused = reason }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def unavailable(connection, now: true)
|
|
86
|
+
@lock.synchronize { @connections.fetch(connection.to_s).unavailable = now }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Delegations
|
|
4
|
+
SCOPE = "masks:delegate:".freeze
|
|
5
|
+
UPSTREAM_ACCESS_TOKEN = "urn:masks:params:oauth:token-type:upstream_access_token".freeze
|
|
6
|
+
REFUSALS = %w[invalid_grant insufficient_scope invalid_target unauthorized_client access_denied
|
|
7
|
+
login_required interaction_required consent_required invalid_scope].freeze
|
|
8
|
+
|
|
9
|
+
class Refused < Error
|
|
10
|
+
attr_reader :code, :description, :secret
|
|
11
|
+
|
|
12
|
+
def initialize(code, description, secret: nil)
|
|
13
|
+
super([ code, description ].compact.join(": "))
|
|
14
|
+
|
|
15
|
+
@code = code
|
|
16
|
+
@description = description
|
|
17
|
+
@secret = secret
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def signed_in_again?
|
|
21
|
+
%w[login_required interaction_required].include?(code)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Unavailable < Error
|
|
26
|
+
attr_reader :secret
|
|
27
|
+
|
|
28
|
+
def initialize(message, secret: nil)
|
|
29
|
+
super(message)
|
|
30
|
+
|
|
31
|
+
@secret = secret
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Held = Struct.new(:connection, :provider, :provider_name, :label, :subject, :secret, keyword_init: true)
|
|
36
|
+
|
|
37
|
+
Upstream = Struct.new(:access_token, :expires_at, :scope, :secret, keyword_init: true) do
|
|
38
|
+
def expired?(leeway: 60)
|
|
39
|
+
Time.now.to_i + leeway >= expires_at.to_i
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :issuer, :client_id, :client_secret, :redirect_uri
|
|
44
|
+
|
|
45
|
+
def initialize(issuer:, client_id:, client_secret:, redirect_uri:)
|
|
46
|
+
@issuer = Issuer.resolve(issuer)
|
|
47
|
+
@client_id = client_id
|
|
48
|
+
@client_secret = client_secret
|
|
49
|
+
@redirect_uri = redirect_uri
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start(provider:, prompt: nil, max_age: nil, state: SecureRandom.urlsafe_base64(24))
|
|
53
|
+
started = session.start(scope: [ "openid", "offline_access", "#{SCOPE}#{provider}" ], prompt: prompt,
|
|
54
|
+
max_age: max_age, state: state)
|
|
55
|
+
|
|
56
|
+
{ "url" => started[:url], "state" => started[:state], "verifier" => started[:verifier], "provider" => provider.to_s }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def finish(params:, started:)
|
|
60
|
+
params = params.to_h.transform_keys(&:to_s)
|
|
61
|
+
started = started.to_h.transform_keys(&:to_s)
|
|
62
|
+
|
|
63
|
+
raise Refused.new(params["error"], params["error_description"]) if params["error"].to_s != ""
|
|
64
|
+
|
|
65
|
+
unless params["state"].to_s != "" && OpenSSL.secure_compare(params["state"].to_s, started["state"].to_s)
|
|
66
|
+
raise Refused.new("invalid_state", "the state did not match the one this connection started with")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
tokens = answered { session.complete(code: params["code"].to_s, verifier: started["verifier"].to_s) }
|
|
70
|
+
held = tokens.delegations.find { |one| one["provider"] == started["provider"] }
|
|
71
|
+
|
|
72
|
+
raise Refused.new("access_denied", "masks connected nothing for #{started['provider']}") if held.nil?
|
|
73
|
+
raise Refused.new("invalid_grant", "masks issued no refresh token to keep the connection with") if tokens.refresh_token.to_s == ""
|
|
74
|
+
|
|
75
|
+
Held.new(
|
|
76
|
+
connection: held["connection"], provider: held["provider"], provider_name: held["provider_name"],
|
|
77
|
+
label: held["label"], subject: held["subject"], secret: tokens.refresh_token
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def token(secret, connection:)
|
|
82
|
+
refreshed = answered { session.refresh(secret.to_s) }
|
|
83
|
+
rotated = refreshed.refresh_token.to_s == "" ? secret : refreshed.refresh_token
|
|
84
|
+
|
|
85
|
+
released = answered(secret: rotated) do
|
|
86
|
+
session.exchange(refreshed.access_token, requested_token_type: UPSTREAM_ACCESS_TOKEN, audience: connection.to_s)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
Upstream.new(access_token: released.access_token, expires_at: released.expires_at, scope: released.scope, secret: rotated)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def session
|
|
95
|
+
@session ||= Session.new(issuer: issuer, client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def answered(secret: nil)
|
|
99
|
+
yield
|
|
100
|
+
rescue Unregistered
|
|
101
|
+
raise
|
|
102
|
+
rescue Rejected => e
|
|
103
|
+
raise Unavailable.new("masks answered #{e.status}: #{e.message}", secret: secret) if unavailable?(e)
|
|
104
|
+
|
|
105
|
+
raise Refused.new(e.code, e.description, secret: secret)
|
|
106
|
+
rescue Unreachable => e
|
|
107
|
+
raise Unavailable.new(e.message, secret: secret)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def unavailable?(rejection)
|
|
111
|
+
return false if REFUSALS.include?(rejection.code)
|
|
112
|
+
|
|
113
|
+
rejection.code == "temporarily_unavailable" || rejection.status.to_i >= 500 || rejection.status.to_i == 429
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -5,16 +5,18 @@ module Masks
|
|
|
5
5
|
GRANT_TYPES = %w[authorization_code refresh_token].freeze
|
|
6
6
|
AUTH_METHOD = "client_secret_basic".freeze
|
|
7
7
|
|
|
8
|
-
attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to
|
|
8
|
+
attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to,
|
|
9
|
+
:backchannel_logout_uri
|
|
9
10
|
|
|
10
11
|
def initialize(issuer, name:, resource:, redirect_uris:, return_to:,
|
|
11
|
-
scope: Session::DEFAULT_SCOPE)
|
|
12
|
+
scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil)
|
|
12
13
|
@issuer = Issuer.resolve(issuer)
|
|
13
14
|
@name = name.to_s
|
|
14
15
|
@resource = resource.to_s
|
|
15
16
|
@redirect_uris = Array(redirect_uris).map(&:to_s)
|
|
16
17
|
@scope = Array(scope).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?)
|
|
17
18
|
@return_to = return_to.to_s
|
|
19
|
+
@backchannel_logout_uri = backchannel_logout_uri&.to_s
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def endpoint
|
|
@@ -34,6 +36,7 @@ module Masks
|
|
|
34
36
|
[ "state", state ]
|
|
35
37
|
]
|
|
36
38
|
|
|
39
|
+
query << [ "backchannel_logout_uri", backchannel_logout_uri ] if backchannel_logout_uri
|
|
37
40
|
redirect_uris.each { |uri| query << [ "redirect_uris", uri ] }
|
|
38
41
|
|
|
39
42
|
"#{endpoint}?#{URI.encode_www_form(query)}"
|
|
@@ -61,7 +64,8 @@ module Masks
|
|
|
61
64
|
redirect_uris: redirect_uris,
|
|
62
65
|
grant_types: GRANT_TYPES,
|
|
63
66
|
scope: scope,
|
|
64
|
-
token_endpoint_auth_method: AUTH_METHOD
|
|
67
|
+
token_endpoint_auth_method: AUTH_METHOD,
|
|
68
|
+
backchannel_logout_uri: backchannel_logout_uri
|
|
65
69
|
)
|
|
66
70
|
end
|
|
67
71
|
|
data/lib/masks/client/issuer.rb
CHANGED
|
@@ -51,6 +51,10 @@ module Masks
|
|
|
51
51
|
discovery.fetch(name) { raise Rejected.new("invalid_issuer", "#{url} publishes no #{name}") }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def backchannel_logout?
|
|
55
|
+
discovery["backchannel_logout_supported"] == true
|
|
56
|
+
end
|
|
57
|
+
|
|
54
58
|
def avatar_styles
|
|
55
59
|
discovery["avatar_styles_supported"] || Claims::Avatars::STYLES
|
|
56
60
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Logout
|
|
4
|
+
EVENT = "http://schemas.openid.net/event/backchannel-logout".freeze
|
|
5
|
+
ALGORITHMS = Verifier::ALGORITHMS
|
|
6
|
+
LEEWAY = 60
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
|
|
10
|
+
held = Verifier
|
|
11
|
+
.new(issuer, audience: audience, algorithms: algorithms)
|
|
12
|
+
.verify(token, required: %w[iss aud iat jti events])
|
|
13
|
+
|
|
14
|
+
new(held).validate!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :claims
|
|
19
|
+
|
|
20
|
+
def initialize(claims)
|
|
21
|
+
@claims = claims
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def subject
|
|
25
|
+
claims["sub"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sid
|
|
29
|
+
claims["sid"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def jti
|
|
33
|
+
claims["jti"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def issued_at
|
|
37
|
+
Time.at(claims["iat"].to_i).utc
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
refuse!("logout token carries a nonce, so it is an id token") if claims.key?("nonce")
|
|
42
|
+
refuse!("logout token names neither a subject nor a session") if subject.nil? && sid.nil?
|
|
43
|
+
refuse!("logout token was issued in the future") if issued_at > Time.now.utc + LEEWAY
|
|
44
|
+
|
|
45
|
+
held = claims["events"]
|
|
46
|
+
|
|
47
|
+
refuse!("logout token has no events claim") unless held.is_a?(Hash)
|
|
48
|
+
refuse!("logout token is not about a logout") unless held[EVENT].is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def refuse!(said)
|
|
56
|
+
raise InvalidToken, said
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -23,7 +23,8 @@ module Masks
|
|
|
23
23
|
"token_endpoint_auth_method" => attributes[:token_endpoint_auth_method],
|
|
24
24
|
"application_type" => attributes[:application_type],
|
|
25
25
|
"client_uri" => attributes[:client_uri],
|
|
26
|
-
"logo_uri" => attributes[:logo_uri]
|
|
26
|
+
"logo_uri" => attributes[:logo_uri],
|
|
27
|
+
"backchannel_logout_uri" => attributes[:backchannel_logout_uri]
|
|
27
28
|
}.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
28
29
|
end
|
|
29
30
|
|
|
@@ -32,8 +33,6 @@ module Masks
|
|
|
32
33
|
list.empty? ? default : list
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
# Rebuild a registration from what a consumer stored, so RFC 7592's read,
|
|
36
|
-
# update and delete are reachable without having just created it.
|
|
37
36
|
def self.held(issuer, credentials)
|
|
38
37
|
held = credentials.to_h.transform_keys(&:to_s)
|
|
39
38
|
return nil if held["registration_client_uri"].blank? || held["registration_access_token"].blank?
|
data/lib/masks/client/session.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Masks
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def start(resource: nil, prompt: nil, scope: nil, state: SecureRandom.urlsafe_base64(24),
|
|
17
|
-
nonce: SecureRandom.urlsafe_base64(24))
|
|
17
|
+
nonce: SecureRandom.urlsafe_base64(24), max_age: nil)
|
|
18
18
|
pkce = Pkce.generate
|
|
19
19
|
scopes = Array(scope || self.scope)
|
|
20
20
|
nonce = nil unless scopes.include?("openid")
|
|
@@ -33,6 +33,7 @@ module Masks
|
|
|
33
33
|
|
|
34
34
|
Array(resource).each { |value| pairs << [ "resource", value ] }
|
|
35
35
|
pairs << [ "prompt", prompt ] if prompt
|
|
36
|
+
pairs << [ "max_age", max_age.to_i ] if max_age
|
|
36
37
|
|
|
37
38
|
{
|
|
38
39
|
url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
|
|
@@ -69,7 +70,7 @@ module Masks
|
|
|
69
70
|
Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
def exchange(subject_token, scope: nil, resource: nil, lifetime: nil)
|
|
73
|
+
def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil)
|
|
73
74
|
form = [
|
|
74
75
|
[ "grant_type", Tokens::EXCHANGE ],
|
|
75
76
|
[ "client_id", client_id ],
|
|
@@ -77,6 +78,9 @@ module Masks
|
|
|
77
78
|
[ "subject_token_type", Tokens::ACCESS_TOKEN ]
|
|
78
79
|
]
|
|
79
80
|
|
|
81
|
+
form << [ "requested_token_type", requested_token_type ] if requested_token_type
|
|
82
|
+
Array(audience).each { |value| form << [ "audience", value ] }
|
|
83
|
+
|
|
80
84
|
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
81
85
|
form << [ "requested_lifetime", lifetime.to_i ] if lifetime
|
|
82
86
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
@@ -92,6 +96,10 @@ module Masks
|
|
|
92
96
|
true
|
|
93
97
|
end
|
|
94
98
|
|
|
99
|
+
def logout_token(token)
|
|
100
|
+
Logout.verify(token, issuer: issuer, audience: client_id)
|
|
101
|
+
end
|
|
102
|
+
|
|
95
103
|
def end_session_url(post_logout_redirect_uri: nil, state: nil, id_token_hint: nil)
|
|
96
104
|
pairs = [ [ "client_id", client_id ] ]
|
|
97
105
|
pairs << [ "id_token_hint", id_token_hint ] if id_token_hint
|
data/lib/masks/client/tokens.rb
CHANGED
|
@@ -4,7 +4,8 @@ module Masks
|
|
|
4
4
|
EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
|
|
5
5
|
ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token".freeze
|
|
6
6
|
|
|
7
|
-
attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at
|
|
7
|
+
attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at,
|
|
8
|
+
:delegations
|
|
8
9
|
|
|
9
10
|
def self.granted(body)
|
|
10
11
|
token = new(body)
|
|
@@ -26,6 +27,7 @@ module Masks
|
|
|
26
27
|
@token_type = body["token_type"] || "Bearer"
|
|
27
28
|
@scope = body["scope"].to_s
|
|
28
29
|
@expires_in = body["expires_in"].to_i
|
|
30
|
+
@delegations = Array(body["delegations"])
|
|
29
31
|
@obtained_at = Time.now.to_i
|
|
30
32
|
end
|
|
31
33
|
|
data/lib/masks/client.rb
CHANGED
|
@@ -19,33 +19,79 @@ require_relative "client/claims"
|
|
|
19
19
|
require_relative "client/introspection"
|
|
20
20
|
require_relative "client/session"
|
|
21
21
|
require_relative "client/verifier"
|
|
22
|
+
require_relative "client/logout"
|
|
22
23
|
require_relative "client/resource"
|
|
23
24
|
require_relative "client/rack"
|
|
24
25
|
require_relative "client/registration"
|
|
25
26
|
require_relative "client/handshake"
|
|
27
|
+
require_relative "client/delegations"
|
|
26
28
|
|
|
27
29
|
module Masks
|
|
30
|
+
# = Masks::Client
|
|
31
|
+
#
|
|
32
|
+
# The protocol half of the gem: plain Ruby, no \Rails, no database. Every
|
|
33
|
+
# entry point here is a class method that builds one of the objects below.
|
|
34
|
+
#
|
|
35
|
+
# Which one you want depends on what the application is doing:
|
|
36
|
+
#
|
|
37
|
+
# [issuer] an app signing people in — discovery, PKCE, the code exchange
|
|
38
|
+
# [verifier] an API checking a bearer it was handed
|
|
39
|
+
# [resource] an API publishing what it is and which scopes it takes
|
|
40
|
+
# [handshake] an app registering itself, once, without a copied secret
|
|
41
|
+
#
|
|
42
|
+
# Everything reachable from here talks HTTP to a masks issuer and holds no
|
|
43
|
+
# state of its own beyond the discovery cache in ::registry.
|
|
28
44
|
module Client
|
|
29
45
|
class << self
|
|
46
|
+
# The process-wide cache of resolved issuers, so discovery is fetched
|
|
47
|
+
# once rather than per request.
|
|
48
|
+
#
|
|
49
|
+
# @return [Masks::Client::Registry]
|
|
30
50
|
def registry
|
|
31
51
|
@registry ||= Registry.new
|
|
32
52
|
end
|
|
33
53
|
|
|
54
|
+
# Resolves +url+ through its discovery document and returns the issuer
|
|
55
|
+
# it describes. Cached in ::registry, so calling this per request is
|
|
56
|
+
# cheap after the first.
|
|
57
|
+
#
|
|
58
|
+
# issuer = Masks::Client.issuer("https://auth.example")
|
|
59
|
+
# issuer.authorization_url(client_id: id, redirect_uri: uri)
|
|
60
|
+
#
|
|
61
|
+
# @return [Masks::Client::Issuer]
|
|
34
62
|
def issuer(url, **options)
|
|
35
63
|
Issuer.resolve(url, **options)
|
|
36
64
|
end
|
|
37
65
|
|
|
66
|
+
# Checks tokens minted by the issuer at +url+ against +audience+ — the
|
|
67
|
+
# API's own URL, which the token names in +aud+. A token issued for
|
|
68
|
+
# anything else is refused rather than merely noted.
|
|
69
|
+
#
|
|
70
|
+
# @return [Masks::Client::Verifier]
|
|
38
71
|
def verifier(url, audience:, **options)
|
|
39
72
|
Verifier.new(issuer(url), audience: audience, **options)
|
|
40
73
|
end
|
|
41
74
|
|
|
75
|
+
# Describes this API to callers: the scopes it accepts and the issuer
|
|
76
|
+
# that may mint tokens for it, served as RFC 9728 metadata.
|
|
77
|
+
#
|
|
78
|
+
# @return [Masks::Client::Resource]
|
|
42
79
|
def resource(url, issuer:, **options)
|
|
43
80
|
Resource.new(issuer: issuer, url: url, **options)
|
|
44
81
|
end
|
|
45
82
|
|
|
83
|
+
# Registers this application against a masks issuer. A person approves
|
|
84
|
+
# it in their browser and the credentials come back server to server, so
|
|
85
|
+
# no secret is pasted between the two.
|
|
86
|
+
#
|
|
87
|
+
# @return [Masks::Client::Handshake]
|
|
46
88
|
def handshake(url, **options)
|
|
47
89
|
Handshake.new(url, **options)
|
|
48
90
|
end
|
|
91
|
+
|
|
92
|
+
def delegations(issuer, **options)
|
|
93
|
+
Delegations.new(issuer: issuer, **options)
|
|
94
|
+
end
|
|
49
95
|
end
|
|
50
96
|
end
|
|
51
97
|
end
|
|
@@ -5,8 +5,9 @@ module Masks
|
|
|
5
5
|
|
|
6
6
|
attr_accessor :scope, :namespace, :resource, :resource_scopes, :after_sign_in,
|
|
7
7
|
:after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
|
|
8
|
-
:credentials_path, :authenticate_everything
|
|
9
|
-
attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget
|
|
8
|
+
:credentials_path, :authenticate_everything, :delegates
|
|
9
|
+
attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
|
|
10
|
+
:delegation_redirect_uri
|
|
10
11
|
|
|
11
12
|
def initialize
|
|
12
13
|
@scope = Masks::Client::Session::DEFAULT_SCOPE
|
|
@@ -17,12 +18,9 @@ module Masks
|
|
|
17
18
|
@sign_out_of_issuer = false
|
|
18
19
|
@parent_controller = "ActionController::Base"
|
|
19
20
|
@authenticate_everything = false
|
|
21
|
+
@delegates = false
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
# An app that has not said where to keep its credentials gets one file
|
|
23
|
-
# under the Rails root, so the handshake works before anybody writes a
|
|
24
|
-
# `store` lambda. `things` overrides both because it is multi-tenant,
|
|
25
|
-
# which is the interesting case rather than the common one.
|
|
26
24
|
def default_credentials
|
|
27
25
|
@default_credentials ||= Credentials.new(
|
|
28
26
|
credentials_path || ::Rails.root.join("config", "masks.json")
|
|
@@ -43,6 +41,12 @@ module Masks
|
|
|
43
41
|
"#{request.base_url}#{routes.callback_path}"
|
|
44
42
|
end
|
|
45
43
|
|
|
44
|
+
def delegation_redirect_uri_for(request)
|
|
45
|
+
return nil unless delegates
|
|
46
|
+
|
|
47
|
+
resolve(@delegation_redirect_uri, request)
|
|
48
|
+
end
|
|
49
|
+
|
|
46
50
|
def resource_for(request)
|
|
47
51
|
resolve(@resource, request)
|
|
48
52
|
end
|
|
@@ -66,8 +70,21 @@ module Masks
|
|
|
66
70
|
client_id_for(request).present?
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
def backchannel_logout_uri_for(request)
|
|
74
|
+
return nil unless @logged_out.respond_to?(:call)
|
|
75
|
+
return nil unless Masks::Client::Issuer.resolve(issuer_for(request)).backchannel_logout?
|
|
76
|
+
|
|
77
|
+
"#{request.base_url}#{routes.backchannel_logout_path}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def logged_out!(request, logout)
|
|
81
|
+
return false unless @logged_out.respond_to?(:call)
|
|
82
|
+
|
|
83
|
+
@logged_out.arity == 1 ? @logged_out.call(logout) : @logged_out.call(request, logout)
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
71
88
|
def can_forget?
|
|
72
89
|
@forget.respond_to?(:call) || !@store.respond_to?(:call)
|
|
73
90
|
end
|
|
@@ -85,10 +102,6 @@ module Masks
|
|
|
85
102
|
@store.call(request, registration)
|
|
86
103
|
end
|
|
87
104
|
|
|
88
|
-
# The engine depends on `handshake_endpoint` being in the discovery
|
|
89
|
-
# document, and on the approval flow behind it. An issuer that predates
|
|
90
|
-
# both should say so here rather than at the one screen that exists to
|
|
91
|
-
# be the first thing anybody touches.
|
|
92
105
|
MINIMUM_ISSUER = 1
|
|
93
106
|
|
|
94
107
|
def issuer_speaks!(request)
|
|
@@ -132,22 +145,21 @@ module Masks
|
|
|
132
145
|
name: name_for(request),
|
|
133
146
|
resource: Array(resource_for(request)).first ||
|
|
134
147
|
raise(Unconfigured, "Masks::Rails.config.resource is not set"),
|
|
135
|
-
redirect_uris: [ redirect_uri_for(request) ],
|
|
148
|
+
redirect_uris: [ redirect_uri_for(request), delegation_redirect_uri_for(request) ].compact.uniq,
|
|
136
149
|
return_to: return_to_for(request),
|
|
137
|
-
scope: approved_scope
|
|
150
|
+
scope: approved_scope,
|
|
151
|
+
backchannel_logout_uri: backchannel_logout_uri_for(request)
|
|
138
152
|
)
|
|
139
153
|
end
|
|
140
154
|
|
|
141
|
-
# What the handshake asks to be approved for, which is not what a sign-in
|
|
142
|
-
# requests. An app that owns a namespace asks for the namespace once, so
|
|
143
|
-
# that adding a capability later is a deployment rather than an approval
|
|
144
|
-
# round; the authorize request still names the scopes it actually wants.
|
|
145
155
|
def approved_scope
|
|
146
|
-
|
|
156
|
+
delegated = delegates ? [ Masks::Client::Delegations::SCOPE ] : []
|
|
157
|
+
|
|
158
|
+
return Array(scope) + delegated if namespace.blank?
|
|
147
159
|
|
|
148
160
|
outside = Array(scope).reject { |name| name.to_s.start_with?(namespace) }
|
|
149
161
|
|
|
150
|
-
outside + [ namespace ]
|
|
162
|
+
outside + [ namespace ] + delegated
|
|
151
163
|
end
|
|
152
164
|
|
|
153
165
|
def return_to_for(request)
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Rails
|
|
3
|
-
# The default place a single-tenant app keeps what the handshake gives it,
|
|
4
|
-
# so `config.store` has something to be before a consumer writes one. An
|
|
5
|
-
# app with more than one issuer wants its own — `things` keeps these on
|
|
6
|
-
# `tenants`, because a handshake is per tenant there.
|
|
7
3
|
class Credentials
|
|
8
4
|
KEYS = %w[client_id client_secret registration_access_token registration_client_uri].freeze
|
|
9
5
|
|
data/lib/masks/rails/engine.rb
CHANGED
|
@@ -5,11 +5,6 @@ module Masks
|
|
|
5
5
|
|
|
6
6
|
config.masks = Masks::Rails.config
|
|
7
7
|
|
|
8
|
-
# Opt-in, not blanket. This used to `include Masks::Rails::Authentication`
|
|
9
|
-
# into every controller in the host app, which lands twenty-five `masks_*`
|
|
10
|
-
# methods and four helper_methods on code the consumer owns, with no way
|
|
11
|
-
# to say no — and a name collision found that way is the kind of thing
|
|
12
|
-
# `grants:` versus `scope:` already cost this project once.
|
|
13
8
|
initializer "masks.authentication" do
|
|
14
9
|
ActiveSupport.on_load(:action_controller) do
|
|
15
10
|
include Masks::Rails::Authentication if Masks::Rails.config.authenticate_everything
|
data/lib/masks/rails.rb
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
require "rails"
|
|
2
2
|
|
|
3
|
+
module Masks
|
|
4
|
+
# = Masks::Rails
|
|
5
|
+
#
|
|
6
|
+
# The consumer half: a \Rails engine that mounts the code flow into an
|
|
7
|
+
# application, so signing in against a masks issuer is configuration rather
|
|
8
|
+
# than a controller you write.
|
|
9
|
+
#
|
|
10
|
+
# It loads only when +Rails::Engine+ is already defined. Requiring the gem
|
|
11
|
+
# from a plain Ruby process gets Masks::Client and nothing else.
|
|
12
|
+
#
|
|
13
|
+
# Three pieces do the work:
|
|
14
|
+
#
|
|
15
|
+
# [Configuration] the issuer, credentials and routes, set once in an
|
|
16
|
+
# initializer and validated on boot rather than on the
|
|
17
|
+
# first request that needs them
|
|
18
|
+
# [Authentication] +masks_login_url+, the callback, and the session the
|
|
19
|
+
# app reads +current_actor+ from
|
|
20
|
+
# [ProtectedResource] the other direction — checking a bearer this app was
|
|
21
|
+
# handed, for an API rather than a browser
|
|
22
|
+
#
|
|
23
|
+
# This engine is the client. It never runs in the same process as the
|
|
24
|
+
# provider, which is a standalone deployable holding its own database.
|
|
25
|
+
module Rails
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
3
29
|
require_relative "version"
|
|
4
30
|
require_relative "client"
|
|
5
31
|
require_relative "rails/credentials"
|
data/lib/masks/version.rb
CHANGED
data/lib/masks.rb
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
# = \Masks
|
|
2
|
+
#
|
|
3
|
+
# Signs an application in against a masks issuer.
|
|
4
|
+
#
|
|
5
|
+
# The gem is two halves. Masks::Client is plain Ruby and speaks the protocol —
|
|
6
|
+
# discovery, PKCE, the code exchange, token verification. Masks::Rails mounts
|
|
7
|
+
# that flow into a \Rails app, and loads only when +Rails::Engine+ is already
|
|
8
|
+
# defined, so requiring this gem outside \Rails costs nothing.
|
|
9
|
+
#
|
|
10
|
+
# issuer = Masks::Client.issuer("https://auth.example")
|
|
11
|
+
# issuer.authorization_url(client_id: id, redirect_uri: uri)
|
|
12
|
+
#
|
|
13
|
+
# The provider itself is not in here. It is a deployable that holds a database
|
|
14
|
+
# and the per-tenant signing keys, and it stays standalone.
|
|
15
|
+
module Masks
|
|
16
|
+
end
|
|
17
|
+
|
|
1
18
|
require_relative "masks/version"
|
|
2
19
|
require_relative "masks/client"
|
|
3
20
|
|
metadata
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: masks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- the masks authors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
@@ -36,6 +36,7 @@ files:
|
|
|
36
36
|
- app/controllers/masks/rails/avatars_controller.rb
|
|
37
37
|
- app/controllers/masks/rails/base_controller.rb
|
|
38
38
|
- app/controllers/masks/rails/handshakes_controller.rb
|
|
39
|
+
- app/controllers/masks/rails/logouts_controller.rb
|
|
39
40
|
- app/controllers/masks/rails/sessions_controller.rb
|
|
40
41
|
- app/views/layouts/masks/rails/plain.html.erb
|
|
41
42
|
- app/views/masks/rails/handshakes/refused.html.erb
|
|
@@ -46,11 +47,14 @@ files:
|
|
|
46
47
|
- lib/masks.rb
|
|
47
48
|
- lib/masks/client.rb
|
|
48
49
|
- lib/masks/client/claims.rb
|
|
50
|
+
- lib/masks/client/delegations.rb
|
|
51
|
+
- lib/masks/client/delegations/fake.rb
|
|
49
52
|
- lib/masks/client/errors.rb
|
|
50
53
|
- lib/masks/client/handshake.rb
|
|
51
54
|
- lib/masks/client/http.rb
|
|
52
55
|
- lib/masks/client/introspection.rb
|
|
53
56
|
- lib/masks/client/issuer.rb
|
|
57
|
+
- lib/masks/client/logout.rb
|
|
54
58
|
- lib/masks/client/pkce.rb
|
|
55
59
|
- lib/masks/client/rack.rb
|
|
56
60
|
- lib/masks/client/registration.rb
|