masks 0.5.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 +52 -0
- data/LICENSE +1 -1
- data/README.md +110 -32
- data/app/controllers/masks/rails/avatars_controller.rb +51 -0
- data/app/controllers/masks/rails/handshakes_controller.rb +18 -18
- data/app/controllers/masks/rails/logouts_controller.rb +32 -0
- data/app/controllers/masks/rails/sessions_controller.rb +5 -0
- data/app/views/masks/rails/handshakes/show.html.erb +17 -33
- data/config/routes.rb +2 -0
- data/lib/masks/client/claims.rb +41 -0
- data/lib/masks/client/delegations/fake.rb +91 -0
- data/lib/masks/client/delegations.rb +117 -0
- data/lib/masks/client/errors.rb +8 -0
- data/lib/masks/client/handshake.rb +7 -3
- data/lib/masks/client/http.rb +16 -1
- data/lib/masks/client/introspection.rb +1 -1
- data/lib/masks/client/issuer.rb +20 -0
- data/lib/masks/client/logout.rb +60 -0
- data/lib/masks/client/registration.rb +29 -6
- 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/authentication.rb +45 -2
- data/lib/masks/rails/configuration.rb +40 -16
- 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 +7 -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,57 @@
|
|
|
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
|
+
|
|
42
|
+
## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
### Features
|
|
46
|
+
|
|
47
|
+
* **server:** a namespace is a grant, so an app can grow its own scopes ([4e816e4](https://github.com/masksrb/masks/commit/4e816e4456de9cc05601b8eaa03773788e3178bb))
|
|
48
|
+
* **server:** people, avatars, and one page instead of three ([d50c444](https://github.com/masksrb/masks/commit/d50c4440812fc1c3a92e926ff6d533b9a69dae2a))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
### Fixes
|
|
52
|
+
|
|
53
|
+
* **client:** an app whose issuer forgot it asks to be reconnected ([6956972](https://github.com/masksrb/masks/commit/695697233ca1ce35fa8b29ea74e58dd0d762b0a5))
|
|
54
|
+
|
|
3
55
|
## 0.5.0 — unreleased
|
|
4
56
|
|
|
5
57
|
First release carrying the consumer half in full. 0.4.0 predates the split described in
|
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,14 +97,37 @@ 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.
|
|
102
110
|
|
|
111
|
+
### Avatars
|
|
112
|
+
|
|
113
|
+
Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
|
|
114
|
+
`initials`. All three arrive on the id token, so drawing one costs no request.
|
|
115
|
+
|
|
116
|
+
```erb
|
|
117
|
+
<img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A photo is a likeness of a person and needs a token, which an `<img>` cannot carry, so the engine
|
|
121
|
+
proxies it with the token this app already holds:
|
|
122
|
+
|
|
123
|
+
```erb
|
|
124
|
+
<img src="/auth/avatar" width="64" height="64" alt="">
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`masks_claims.picture` resolves the standard OIDC claim — an offsite `picture_url` if the actor set
|
|
128
|
+
one, then the photo, then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a
|
|
129
|
+
subject this app holds no token for. Sizes are 32, 64, 128, 256 or 512.
|
|
130
|
+
|
|
103
131
|
### Signing out
|
|
104
132
|
|
|
105
133
|
`DELETE /auth/logout` ends this app's session. Add `?everywhere=1` and the response
|
|
@@ -135,7 +163,7 @@ holds no protocol of its own.
|
|
|
135
163
|
|
|
136
164
|
```ruby
|
|
137
165
|
session = Masks::Client::Session.new(
|
|
138
|
-
issuer: "https://
|
|
166
|
+
issuer: "https://demo.auth.example.com",
|
|
139
167
|
client_id: id, client_secret: secret,
|
|
140
168
|
redirect_uri: "https://app.example.com/auth/callback"
|
|
141
169
|
)
|
|
@@ -149,8 +177,7 @@ identity = session.identity(tokens)
|
|
|
149
177
|
```
|
|
150
178
|
|
|
151
179
|
`start` sends a nonce only when `openid` was asked for, so holding one means an id token
|
|
152
|
-
is owed
|
|
153
|
-
true.
|
|
180
|
+
is owed, and the check on the way back is exact.
|
|
154
181
|
|
|
155
182
|
Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
|
|
156
183
|
|
|
@@ -161,12 +188,11 @@ constructing one per request.
|
|
|
161
188
|
|
|
162
189
|
### The handshake
|
|
163
190
|
|
|
164
|
-
The flow that connects a first-party app
|
|
165
|
-
state in every consumer:
|
|
191
|
+
The flow that connects a first-party app:
|
|
166
192
|
|
|
167
193
|
```ruby
|
|
168
194
|
handshake = Masks::Client::Handshake.new(
|
|
169
|
-
issuer, name: "
|
|
195
|
+
issuer, name: "uris", resource: "https://app.example.com/mcp",
|
|
170
196
|
redirect_uris: [ "https://app.example.com/auth/callback" ],
|
|
171
197
|
return_to: "https://app.example.com/"
|
|
172
198
|
)
|
|
@@ -182,12 +208,12 @@ that is not the issuer it asked — each **before** anything is redeemed.
|
|
|
182
208
|
|
|
183
209
|
```ruby
|
|
184
210
|
resource = Masks::Client::Resource.new(
|
|
185
|
-
issuer: "https://
|
|
211
|
+
issuer: "https://demo.auth.example.com",
|
|
186
212
|
url: "https://app.example.com/mcp",
|
|
187
|
-
scopes: { "
|
|
213
|
+
scopes: { "uris:catalog:read" => "Search your catalog" }
|
|
188
214
|
)
|
|
189
215
|
|
|
190
|
-
claims = resource.authenticate(request.authorization, scope: "
|
|
216
|
+
claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
|
|
191
217
|
claims.subject
|
|
192
218
|
claims.tenant.subdomain
|
|
193
219
|
```
|
|
@@ -201,35 +227,87 @@ response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
|
201
227
|
|
|
202
228
|
`resource.metadata` is the RFC 9728 document to serve at
|
|
203
229
|
`/.well-known/oauth-protected-resource`. The `scope_descriptions` extension in it is how
|
|
204
|
-
an auth server renders your scopes as sentences on its consent screen
|
|
205
|
-
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.
|
|
206
232
|
|
|
207
233
|
There is a Rack middleware for consumers that want the challenge below the framework:
|
|
208
234
|
|
|
209
235
|
```ruby
|
|
210
|
-
use Masks::Client::Rack, resource: resource, scope: "
|
|
236
|
+
use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
|
|
211
237
|
```
|
|
212
238
|
|
|
213
239
|
### Introspection
|
|
214
240
|
|
|
215
241
|
A resource server doing JWT-only validation cannot see a revocation until the token
|
|
216
|
-
expires.
|
|
242
|
+
expires. Ask the issuer instead:
|
|
217
243
|
|
|
218
244
|
```ruby
|
|
219
245
|
found = session.introspect(token)
|
|
220
|
-
found.active? && found.permits?("
|
|
246
|
+
found.active? && found.permits?("uris:catalog:read")
|
|
221
247
|
```
|
|
222
248
|
|
|
223
249
|
`Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
|
|
224
|
-
not active, so
|
|
225
|
-
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
|
+
```
|
|
226
300
|
|
|
227
301
|
## Which issuers this speaks to
|
|
228
302
|
|
|
229
303
|
masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
|
|
230
304
|
least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
|
|
231
|
-
it. An older issuer is refused with a sentence saying so
|
|
232
|
-
|
|
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.
|
|
233
311
|
|
|
234
312
|
## License
|
|
235
313
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Rails
|
|
3
|
+
class AvatarsController < BaseController
|
|
4
|
+
SIZES = [ 32, 64, 128, 256, 512 ].freeze
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
return head :not_found unless masks_signed_in?
|
|
8
|
+
|
|
9
|
+
url = photo_url
|
|
10
|
+
|
|
11
|
+
return head :not_found if url.nil?
|
|
12
|
+
|
|
13
|
+
deliver(
|
|
14
|
+
Masks::Client::HTTP.fetch(
|
|
15
|
+
url, "Authorization" => masks_tokens.authorization, "Accept" => "image/*"
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
rescue Masks::Client::Error
|
|
19
|
+
head :bad_gateway
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def photo_url
|
|
25
|
+
held = masks_claims.avatars.photo
|
|
26
|
+
|
|
27
|
+
return nil if held.nil?
|
|
28
|
+
return nil unless held.start_with?("#{masks_config.issuer_for(request)}/")
|
|
29
|
+
|
|
30
|
+
asked = params[:size].presence
|
|
31
|
+
|
|
32
|
+
return held if asked.nil?
|
|
33
|
+
|
|
34
|
+
"#{held}?size=#{SIZES.find { |allowed| allowed >= asked.to_i } || SIZES.last}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def deliver(answer)
|
|
38
|
+
case answer
|
|
39
|
+
when Net::HTTPRedirection
|
|
40
|
+
redirect_to answer["Location"], allow_other_host: true
|
|
41
|
+
when Net::HTTPSuccess
|
|
42
|
+
expires_in 5.minutes, public: false
|
|
43
|
+
|
|
44
|
+
send_data answer.body, type: answer["Content-Type"], disposition: "inline"
|
|
45
|
+
else
|
|
46
|
+
head :not_found
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -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,15 +27,11 @@ 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?
|
|
41
33
|
|
|
42
|
-
|
|
34
|
+
forget_upstream
|
|
43
35
|
|
|
44
36
|
masks_config.forget!(request)
|
|
45
37
|
masks_forget
|
|
@@ -51,11 +43,19 @@ module Masks
|
|
|
51
43
|
|
|
52
44
|
private
|
|
53
45
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
|
|
55
|
+
def forget_upstream
|
|
56
|
+
masks_registration&.delete
|
|
57
|
+
rescue Masks::Client::Unregistered
|
|
58
|
+
false
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def returned
|
|
@@ -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
|
|
@@ -14,6 +14,7 @@ module Masks
|
|
|
14
14
|
|
|
15
15
|
def start
|
|
16
16
|
return redirect_to(masks_handshake_path) unless masks_configured?
|
|
17
|
+
return redirect_to(masks_handshake_path) if masks_reconnect!
|
|
17
18
|
|
|
18
19
|
pending = masks_requests.open(
|
|
19
20
|
return_to: requested_return_to || session.delete(:masks_return_to)
|
|
@@ -55,6 +56,10 @@ module Masks
|
|
|
55
56
|
masks_store(tokens, identity: identity)
|
|
56
57
|
|
|
57
58
|
redirect_to pending[:return_to] || masks_config.after_sign_in
|
|
59
|
+
rescue Masks::Client::Unregistered => e
|
|
60
|
+
return redirect_to(masks_handshake_path) if masks_disconnect!
|
|
61
|
+
|
|
62
|
+
refuse(e.code, e.description, pending)
|
|
58
63
|
rescue Masks::Client::Error => e
|
|
59
64
|
refuse(e.class.name.demodulize.underscore, e.message, pending)
|
|
60
65
|
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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
Masks::Rails::Engine.routes.draw do
|
|
2
2
|
get "/", to: "sessions#start", as: :start
|
|
3
3
|
get "/session", to: "sessions#show", as: :session
|
|
4
|
+
get "/avatar", to: "avatars#show", as: :avatar
|
|
4
5
|
get "/callback", to: "sessions#callback", as: :callback
|
|
5
6
|
match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
|
|
7
|
+
post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
|
|
6
8
|
|
|
7
9
|
get "/handshake", to: "handshakes#show", as: :handshake
|
|
8
10
|
post "/handshake", to: "handshakes#create"
|
data/lib/masks/client/claims.rb
CHANGED
|
@@ -29,6 +29,39 @@ module Masks
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
class Avatars
|
|
33
|
+
STYLES = %w[photo identicon initials].freeze
|
|
34
|
+
FALLBACK = "identicon".freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :to_h
|
|
37
|
+
|
|
38
|
+
def initialize(hash)
|
|
39
|
+
@to_h = hash.is_a?(Hash) ? hash : {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
STYLES.each do |style|
|
|
43
|
+
define_method(style) { to_h[style] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def [](style)
|
|
47
|
+
to_h[style.to_s]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def photo?
|
|
51
|
+
!photo.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def present?
|
|
55
|
+
to_h.any?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ==(other)
|
|
59
|
+
other.is_a?(Avatars) ? to_h == other.to_h : false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
AVATARS = "masks:avatars".freeze
|
|
64
|
+
|
|
32
65
|
attr_reader :to_h
|
|
33
66
|
|
|
34
67
|
def initialize(claims)
|
|
@@ -67,6 +100,14 @@ module Masks
|
|
|
67
100
|
@tenant ||= Tenant.new(self["tenant"])
|
|
68
101
|
end
|
|
69
102
|
|
|
103
|
+
def avatars
|
|
104
|
+
@avatars ||= Avatars.new(self[AVATARS])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def picture
|
|
108
|
+
self["picture"] || avatars.photo || avatars.identicon
|
|
109
|
+
end
|
|
110
|
+
|
|
70
111
|
def scopes
|
|
71
112
|
@scopes ||= self["scope"].to_s.split(/\s+/).reject(&:empty?).freeze
|
|
72
113
|
end
|