masks 0.6.0 → 0.8.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 +65 -0
- data/LICENSE +1 -1
- data/README.md +136 -32
- data/app/controllers/masks/rails/handshakes_controller.rb +13 -12
- data/app/controllers/masks/rails/logouts_controller.rb +32 -0
- data/app/controllers/masks/rails/sessions_controller.rb +3 -1
- data/app/views/masks/rails/handshakes/show.html.erb +17 -33
- data/config/routes.rb +1 -0
- data/lib/masks/client/claims.rb +69 -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 +7 -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/resource.rb +3 -2
- data/lib/masks/client/session.rb +77 -10
- data/lib/masks/client/tokens.rb +4 -1
- data/lib/masks/client/verifier.rb +18 -3
- data/lib/masks/client.rb +46 -0
- data/lib/masks/rails/authentication.rb +78 -11
- data/lib/masks/rails/configuration.rb +36 -20
- data/lib/masks/rails/credentials.rb +0 -4
- data/lib/masks/rails/engine.rb +0 -5
- data/lib/masks/rails/protected_resource.rb +5 -4
- data/lib/masks/rails.rb +26 -0
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +17 -0
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ec26aa454cf249dd26ce7e15444b43890e4e75954968c76cba3297b63f9819e
|
|
4
|
+
data.tar.gz: 19609ef3a180ab7d6c675cac13cef843adb3ffc742d83b7fd64d7120bbcc93b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a02a532dc018b73c27b350da38335b5ec27c5665d8b498564fd11b220e386919df8ecc9a24b80fb62f21d193c7a66166e99d2dc41c86fef3cb515f5aacc2b0d
|
|
7
|
+
data.tar.gz: fbbb524a91dda55fc6ccf631d1ef8b270b9ff9835b894855dcd0dd07a054fcfb380c6fdea75930b93950909ba1e3618940215418c9fca7f0a5f32e30b33c96b4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/masksrb/masks/compare/gem/v0.7.0...gem/v0.8.0) (2026-09-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* a resource server on an older gem still accepts the new tokens, but this gem refuses access tokens from a server that does not type them.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* an access token is typed at+jwt, and nothing takes a token of another type in its place ([d0b4cfd](https://github.com/masksrb/masks/commit/d0b4cfdf802053359695495d417225e3a1578302))
|
|
13
|
+
* **client:** a Rails app lists the organizations a person belongs to and links them to another ([7f0d223](https://github.com/masksrb/masks/commit/7f0d2237799e04551d2c403de695c704e5455db8))
|
|
14
|
+
* **client:** a Rails app names the organization a person signs in to, reads their role, and keeps pages and APIs to members in a role ([579cecc](https://github.com/masksrb/masks/commit/579ceccac1c0137dfa1ed7a3894dea61e5cdd67e))
|
|
15
|
+
* **client:** a session signs its own client assertions, and asks for a token of its own ([614248b](https://github.com/masksrb/masks/commit/614248b70a3ee517fbd3c1f7151b66ec6406b487))
|
|
16
|
+
* **client:** an exchange hands over an ID token or an actor token ([6b67766](https://github.com/masksrb/masks/commit/6b67766ce275b9578d575349256638adddd5a599))
|
|
17
|
+
* **client:** the session says where the person manages their account, as account_url ([1d36e1d](https://github.com/masksrb/masks/commit/1d36e1d4daa8c5acfc9133a1896aa9384771dbd6))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* Rails apps covers client, server, and engine mode ([41454a2](https://github.com/masksrb/masks/commit/41454a242438e4df246dcbc4dce94b6d8a022239))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Refactoring
|
|
26
|
+
|
|
27
|
+
* **client:** an organization key is checked in one place, and a refresh renews the whole identity from userinfo ([b349aae](https://github.com/masksrb/masks/commit/b349aaec4dd5c9790c72dd1dc8f91016a2adceea))
|
|
28
|
+
|
|
29
|
+
## [0.7.0](https://github.com/masksrb/masks/compare/gem/v0.6.0...gem/v0.7.0) (2026-09-13)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### ⚠ BREAKING CHANGES
|
|
33
|
+
|
|
34
|
+
* **client:** Masks::Client::Introspection#username is now #nickname.
|
|
35
|
+
|
|
36
|
+
### Features
|
|
37
|
+
|
|
38
|
+
* **client:** an app can act on a logout the issuer tells it about ([4d0b7f7](https://github.com/masksrb/masks/commit/4d0b7f77a74b0e416dda3e90413ad989a909f344))
|
|
39
|
+
* **client:** an app that delegates asks its handshake for masks:delegate: and the token exchange ([912e856](https://github.com/masksrb/masks/commit/912e856b00a9f27d59af861cdb35da518fa862bc))
|
|
40
|
+
* **client:** an app that delegates registers where connecting a person's account comes back to ([738fba6](https://github.com/masksrb/masks/commit/738fba67e544f7c8a9a15cd55befbd66696b707b))
|
|
41
|
+
* **client:** an unconnected app walks straight into the handshake ([040388e](https://github.com/masksrb/masks/commit/040388e188653f67ef7a59bc0aa9aef02ec351b2))
|
|
42
|
+
* **server:** an application is let use somebody's account elsewhere through a delegation ([4b7048c](https://github.com/masksrb/masks/commit/4b7048cb8ac4e174b9359a070b7a2d942a1c410d))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
### Fixes
|
|
46
|
+
|
|
47
|
+
* **client:** back-channel logout says 501 when the app does nothing about it ([60e1125](https://github.com/masksrb/masks/commit/60e1125c02adec7c822c6d3757210e825bb66cfc))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
### Documentation
|
|
51
|
+
|
|
52
|
+
* hold the copyright as the masks authors ([cffdeb5](https://github.com/masksrb/masks/commit/cffdeb59f14eb02f6a9513d5f7cda21ebaee2171))
|
|
53
|
+
* READMEs that match the code, and a server one that is not the scaffold ([e0148e1](https://github.com/masksrb/masks/commit/e0148e14e0092731bcd095bc0fc22a1589c3acc0))
|
|
54
|
+
* references are generated from the code that defines them ([ba74550](https://github.com/masksrb/masks/commit/ba745501e651b797948e5abd5640a1825616100c))
|
|
55
|
+
* the gem reference survives rdoc 8, and lists what a Concern adds ([36df12e](https://github.com/masksrb/masks/commit/36df12e72cc52f5db650d16778ecc501bf9db14a))
|
|
56
|
+
* the rose window is the site's logo, favicon and hero, and heads every README ([72176e4](https://github.com/masksrb/masks/commit/72176e49c81bc0a5d6730f3b9e5eaa693db8a659))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
### Refactoring
|
|
60
|
+
|
|
61
|
+
* **client:** an introspection answers a nickname, not a username ([f23dff5](https://github.com/masksrb/masks/commit/f23dff537320982d42a8e4823bea784a3d80ec33))
|
|
62
|
+
* comments are gone; the code says what it does ([0fc8e6a](https://github.com/masksrb/masks/commit/0fc8e6a4692e3f09831514b6d167c3e23d7db419))
|
|
63
|
+
* every suite is named for what it proves, under one test directory ([315b371](https://github.com/masksrb/masks/commit/315b371e64baa4441014669744a61c88341d27c7))
|
|
64
|
+
* rename the example tenant from jons to demo ([aef32a6](https://github.com/masksrb/masks/commit/aef32a670b4b3c696089c07bf15103e1d981eb00))
|
|
65
|
+
* **server:** delegation shares what sign-in and linking already had, and asks the database less ([44b8c4d](https://github.com/masksrb/masks/commit/44b8c4d303dfcc6de60f98d7824c544bd67e347c))
|
|
66
|
+
* take the owner's domain out of the boundary check ([df580ee](https://github.com/masksrb/masks/commit/df580ee1fb5820d30a57a88f3aa9825c97682725))
|
|
67
|
+
|
|
3
68
|
## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
|
|
4
69
|
|
|
5
70
|
|
data/LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/masksrb/masks/main/engine/public/masks-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,62 @@ 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
|
+
### Organizations
|
|
112
|
+
|
|
113
|
+
A person signs in to an app as a member of one organization when the app asks for the
|
|
114
|
+
`organization` scope. Someone in several organizations picks one, and an app can pick for them by
|
|
115
|
+
naming it: `/auth?organization=acme`, or `config.organization` for every sign-in.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
Masks::Rails.configure do |config|
|
|
119
|
+
config.scope = %w[openid profile email offline_access organization]
|
|
120
|
+
config.organization = ->(request) { request.subdomain }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class BillingController < ApplicationController
|
|
124
|
+
include Masks::Rails::Authentication
|
|
125
|
+
|
|
126
|
+
masks_members_only! role: %w[owner billing]
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`masks_organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
|
|
131
|
+
`nil`, and `masks_role?("owner")` asks about the role held in it. A refresh reads the person's
|
|
132
|
+
profile again, so a promotion or demotion reaches the app within an access token's lifetime.
|
|
133
|
+
`/auth/session` answers the same thing as `organization`.
|
|
134
|
+
|
|
135
|
+
`masks_organizations` lists every organization the person has joined, each with the role held in
|
|
136
|
+
it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
|
|
137
|
+
again naming the other one, which asks nothing more when they are already a member:
|
|
138
|
+
|
|
139
|
+
```erb
|
|
140
|
+
<% masks_organizations.each do |held| %>
|
|
141
|
+
<%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
|
|
142
|
+
<% end %>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`masks_members_only!` refuses with 403, as `insufficient_organization` when the person signed
|
|
146
|
+
in to none and `insufficient_role` when they hold another role. A resource server asks the same of a
|
|
147
|
+
token:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
masks_protect! scope: "uris:catalog:write", role: "owner", organization: "acme"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Naming an organization without asking for the scope still holds the sign-in to its members and its
|
|
154
|
+
sign-in policy; the app just learns nothing about the role.
|
|
155
|
+
|
|
103
156
|
### Avatars
|
|
104
157
|
|
|
105
158
|
Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
|
|
@@ -138,6 +191,7 @@ a subdomain-per-tenant host needs.
|
|
|
138
191
|
| `resource` | this app's own identifier, when it also accepts tokens |
|
|
139
192
|
| `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
|
|
140
193
|
| `scope` | what to ask the issuer for; defaults to `openid profile email` |
|
|
194
|
+
| `organization` | the organization every sign-in names, by key |
|
|
141
195
|
| `credentials` / `store` | where the handshake's result lives |
|
|
142
196
|
| `credentials_path` | where the default store writes; `config/masks.json` |
|
|
143
197
|
| `after_sign_in` / `after_sign_out` | paths on this host |
|
|
@@ -155,7 +209,7 @@ holds no protocol of its own.
|
|
|
155
209
|
|
|
156
210
|
```ruby
|
|
157
211
|
session = Masks::Client::Session.new(
|
|
158
|
-
issuer: "https://
|
|
212
|
+
issuer: "https://demo.auth.example.com",
|
|
159
213
|
client_id: id, client_secret: secret,
|
|
160
214
|
redirect_uri: "https://app.example.com/auth/callback"
|
|
161
215
|
)
|
|
@@ -169,8 +223,7 @@ identity = session.identity(tokens)
|
|
|
169
223
|
```
|
|
170
224
|
|
|
171
225
|
`start` sends a nonce only when `openid` was asked for, so holding one means an id token
|
|
172
|
-
is owed
|
|
173
|
-
true.
|
|
226
|
+
is owed, and the check on the way back is exact.
|
|
174
227
|
|
|
175
228
|
Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
|
|
176
229
|
|
|
@@ -181,12 +234,11 @@ constructing one per request.
|
|
|
181
234
|
|
|
182
235
|
### The handshake
|
|
183
236
|
|
|
184
|
-
The flow that connects a first-party app
|
|
185
|
-
state in every consumer:
|
|
237
|
+
The flow that connects a first-party app:
|
|
186
238
|
|
|
187
239
|
```ruby
|
|
188
240
|
handshake = Masks::Client::Handshake.new(
|
|
189
|
-
issuer, name: "
|
|
241
|
+
issuer, name: "uris", resource: "https://app.example.com/mcp",
|
|
190
242
|
redirect_uris: [ "https://app.example.com/auth/callback" ],
|
|
191
243
|
return_to: "https://app.example.com/"
|
|
192
244
|
)
|
|
@@ -202,12 +254,12 @@ that is not the issuer it asked — each **before** anything is redeemed.
|
|
|
202
254
|
|
|
203
255
|
```ruby
|
|
204
256
|
resource = Masks::Client::Resource.new(
|
|
205
|
-
issuer: "https://
|
|
257
|
+
issuer: "https://demo.auth.example.com",
|
|
206
258
|
url: "https://app.example.com/mcp",
|
|
207
|
-
scopes: { "
|
|
259
|
+
scopes: { "uris:catalog:read" => "Search your catalog" }
|
|
208
260
|
)
|
|
209
261
|
|
|
210
|
-
claims = resource.authenticate(request.authorization, scope: "
|
|
262
|
+
claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
|
|
211
263
|
claims.subject
|
|
212
264
|
claims.tenant.subdomain
|
|
213
265
|
```
|
|
@@ -221,35 +273,87 @@ response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
|
221
273
|
|
|
222
274
|
`resource.metadata` is the RFC 9728 document to serve at
|
|
223
275
|
`/.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 `
|
|
276
|
+
an auth server renders your scopes as sentences on its consent screen; it has no other
|
|
277
|
+
way to know what `uris:catalog:read` means.
|
|
226
278
|
|
|
227
279
|
There is a Rack middleware for consumers that want the challenge below the framework:
|
|
228
280
|
|
|
229
281
|
```ruby
|
|
230
|
-
use Masks::Client::Rack, resource: resource, scope: "
|
|
282
|
+
use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
|
|
231
283
|
```
|
|
232
284
|
|
|
233
285
|
### Introspection
|
|
234
286
|
|
|
235
287
|
A resource server doing JWT-only validation cannot see a revocation until the token
|
|
236
|
-
expires.
|
|
288
|
+
expires. Ask the issuer instead:
|
|
237
289
|
|
|
238
290
|
```ruby
|
|
239
291
|
found = session.introspect(token)
|
|
240
|
-
found.active? && found.permits?("
|
|
292
|
+
found.active? && found.permits?("uris:catalog:read")
|
|
241
293
|
```
|
|
242
294
|
|
|
243
295
|
`Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
|
|
244
|
-
not active, so
|
|
245
|
-
check
|
|
296
|
+
not active, so moving from local verification to introspection needs no extra boolean
|
|
297
|
+
check.
|
|
298
|
+
|
|
299
|
+
### Somebody else's account
|
|
300
|
+
|
|
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/concepts/delegation/). masks keeps and refreshes the
|
|
303
|
+
provider's tokens; the app keeps one secret per connection and trades it for a live access token when
|
|
304
|
+
the last one runs out.
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
delegations = Masks::Client.delegations(
|
|
308
|
+
ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
started = delegations.start(provider: "google")
|
|
312
|
+
session[:connecting] = started
|
|
313
|
+
redirect_to started["url"]
|
|
314
|
+
|
|
315
|
+
held = delegations.finish(params: params, started: session.delete(:connecting))
|
|
316
|
+
held.connection
|
|
317
|
+
held.secret
|
|
318
|
+
|
|
319
|
+
upstream = delegations.token(held.secret, connection: held.connection)
|
|
320
|
+
upstream.access_token
|
|
321
|
+
upstream.expires_at
|
|
322
|
+
upstream.secret
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
|
|
326
|
+
exchange with it.
|
|
327
|
+
|
|
328
|
+
`upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
|
|
329
|
+
has to connect again; `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks had
|
|
330
|
+
already rotated it, so keep it. Spend a secret from one place at a time: spending one twice revokes
|
|
331
|
+
it.
|
|
332
|
+
|
|
333
|
+
Tests use the fake, which needs no issuer:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
require "masks/client/delegations/fake"
|
|
337
|
+
|
|
338
|
+
fake = Masks::Client::Delegations::Fake.new
|
|
339
|
+
started = fake.start(provider: "notion")
|
|
340
|
+
held = fake.finish(params: fake.approve(started), started: started)
|
|
341
|
+
|
|
342
|
+
fake.token(held.secret, connection: held.connection)
|
|
343
|
+
fake.revoke(held.connection)
|
|
344
|
+
fake.unavailable(held.connection)
|
|
345
|
+
```
|
|
246
346
|
|
|
247
347
|
## Which issuers this speaks to
|
|
248
348
|
|
|
249
349
|
masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
|
|
250
350
|
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
|
-
|
|
351
|
+
it. An older issuer is refused at configuration time, with a sentence saying so.
|
|
352
|
+
|
|
353
|
+
## Documentation
|
|
354
|
+
|
|
355
|
+
This README is the reference for the gem. [masks.pages.dev](https://masks.pages.dev) carries what is
|
|
356
|
+
generated from the server's own code — the `/manage` GraphQL schema, and the design system.
|
|
253
357
|
|
|
254
358
|
## License
|
|
255
359
|
|
|
@@ -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
|
|
@@ -21,7 +21,9 @@ module Masks
|
|
|
21
21
|
)
|
|
22
22
|
started = masks_session.start(
|
|
23
23
|
state: pending.id,
|
|
24
|
-
resource: masks_config.resource_for(request)
|
|
24
|
+
resource: masks_config.resource_for(request),
|
|
25
|
+
organization: Masks::Client::Session.organization_key(params[:organization]) ||
|
|
26
|
+
masks_config.organization_for(request)
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
masks_requests.amend(
|
|
@@ -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"
|
data/lib/masks/client/claims.rb
CHANGED
|
@@ -60,7 +60,51 @@ module Masks
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
class Organization
|
|
64
|
+
OWNER = "owner".freeze
|
|
65
|
+
|
|
66
|
+
attr_reader :to_h
|
|
67
|
+
|
|
68
|
+
def initialize(hash)
|
|
69
|
+
@to_h = hash.is_a?(Hash) ? hash.transform_keys(&:to_s) : {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def id
|
|
73
|
+
to_h["id"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def key
|
|
77
|
+
to_h["key"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def name
|
|
81
|
+
to_h["name"]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def role
|
|
85
|
+
to_h["role"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def role?(*roles)
|
|
89
|
+
roles.flatten.map(&:to_s).include?(role)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def owner?
|
|
93
|
+
role?(OWNER)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def present?
|
|
97
|
+
!id.nil? && !key.nil? && !role.nil?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ==(other)
|
|
101
|
+
other.is_a?(Organization) ? to_h == other.to_h : false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
63
105
|
AVATARS = "masks:avatars".freeze
|
|
106
|
+
ORGANIZATION = "org".freeze
|
|
107
|
+
ORGANIZATIONS = "orgs".freeze
|
|
64
108
|
|
|
65
109
|
attr_reader :to_h
|
|
66
110
|
|
|
@@ -104,6 +148,31 @@ module Masks
|
|
|
104
148
|
@avatars ||= Avatars.new(self[AVATARS])
|
|
105
149
|
end
|
|
106
150
|
|
|
151
|
+
def organization
|
|
152
|
+
@organization ||= Organization.new(self[ORGANIZATION])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def organizations
|
|
156
|
+
@organizations ||= Array(self[ORGANIZATIONS]).map { |held| Organization.new(held) }.select(&:present?).freeze
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def member!(*roles, organization: nil)
|
|
160
|
+
held = self.organization
|
|
161
|
+
|
|
162
|
+
unless held.present?
|
|
163
|
+
raise Forbidden.new("insufficient_organization", "this token names no organization",
|
|
164
|
+
scope: Session::ORGANIZATION)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if organization && held.key != organization.to_s && held.id != organization.to_s
|
|
168
|
+
raise Forbidden.new("insufficient_organization", "this token was issued for #{held.key}, not #{organization}")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
return held if roles.flatten.empty? || held.role?(*roles)
|
|
172
|
+
|
|
173
|
+
raise Forbidden.new("insufficient_role", "#{held.role} in #{held.key} is not #{roles.flatten.join(' or ')}")
|
|
174
|
+
end
|
|
175
|
+
|
|
107
176
|
def picture
|
|
108
177
|
self["picture"] || avatars.photo || avatars.identicon
|
|
109
178
|
end
|