masks 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +145 -100
- data/app/controllers/masks/rails/handshakes_controller.rb +11 -0
- data/app/controllers/masks/rails/sessions_controller.rb +39 -2
- data/lib/masks/client/claims.rb +69 -0
- data/lib/masks/client/errors.rb +8 -3
- data/lib/masks/client/introspection.rb +6 -0
- data/lib/masks/client/logout.rb +1 -1
- data/lib/masks/client/pkce.rb +1 -3
- data/lib/masks/client/proof.rb +153 -0
- data/lib/masks/client/rack.rb +15 -1
- data/lib/masks/client/registry.rb +0 -4
- data/lib/masks/client/resource.rb +31 -9
- data/lib/masks/client/session.rb +69 -10
- data/lib/masks/client/tokens.rb +1 -0
- data/lib/masks/client/verifier.rb +18 -7
- data/lib/masks/client.rb +1 -0
- data/lib/masks/rails/authentication.rb +86 -13
- data/lib/masks/rails/configuration.rb +13 -14
- data/lib/masks/rails/protected_resource.rb +10 -4
- data/lib/masks/rails.rb +2 -2
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +2 -2
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62fc7998cf31199eaa81f63d82063e3e20ab995c74c782c675e91628df7dcee3
|
|
4
|
+
data.tar.gz: 6d83a9474d9289c375acfcacd6c5c4e45a66a92d22e1683abba9780de3c63d43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97164bf9ab0ee6820e353a9dad5d97141ddf0cd038dd3a6348e03e46eea46313643e13f8d49c17bd414c16420e4b25c5c29072bd4efea114f3483e4174c0e599
|
|
7
|
+
data.tar.gz: 505cca23a8b1b0f439365eea100780b64157700ab9995df53fa46df640a0efc1da9a64e95281925279233ea4e4036563e41aebd727d8b7f2795991dac3604931
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.1](https://github.com/masksrb/masks/compare/gem/v0.9.0...gem/v0.9.1) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixes
|
|
7
|
+
|
|
8
|
+
* **client:** an error the issuer sends back is shown rather than retried, and the engine tests hold the new callback behaviour ([36adb9b](https://github.com/masksrb/masks/commit/36adb9b57a2379b8d7ce1d16f2e04e8c244baa7d))
|
|
9
|
+
|
|
10
|
+
## [0.9.0](https://github.com/masksrb/masks/compare/gem/v0.8.0...gem/v0.9.0) (2026-10-04)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### ⚠ BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* **client:** a connected app refuses reconnecting and disconnecting until config.manages is set, for example config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }.
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
|
|
19
|
+
* **client:** a resource server refuses a DPoP-bound token presented as Bearer, and checks the proof one presented as DPoP ([9a3fe8a](https://github.com/masksrb/masks/commit/9a3fe8adac09fd70a7059674d8c34333c8f3ff92))
|
|
20
|
+
* **client:** a return_to a browser would read as another host is refused, and signing out revokes the refresh token ([de27168](https://github.com/masksrb/masks/commit/de2716872955ba124a8a3ec1dbe07cb043fd0260))
|
|
21
|
+
* **client:** a sign-in callback this browser no longer holds goes on to the app or starts sign-in again, instead of ending on an error ([a1ba84c](https://github.com/masksrb/masks/commit/a1ba84c4a974897bdb80b1f32da5c0b72f83ca79))
|
|
22
|
+
* **client:** every resource in a process shares one store of seen DPoP proofs, and a refused proof is challenged with the DPoP scheme ([f8d1fe2](https://github.com/masksrb/masks/commit/f8d1fe2487e0705c914ea3d3ae7f686152cad6b3))
|
|
23
|
+
* **client:** only somebody config.manages approves can reconnect or disconnect a connected app ([5665129](https://github.com/masksrb/masks/commit/566512979dc00a36148de57b4ea9954fc68db10b))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
* every guide, the reference prose, and the READMEs are checked against the code, shortened, and corrected ([94782d1](https://github.com/masksrb/masks/commit/94782d1fbfe363e90c40f0c728a241de9738e138))
|
|
29
|
+
* the plan holds only what is left, and the READMEs match the engine layout and the code ([1615722](https://github.com/masksrb/masks/commit/1615722f88ae5f7d19955ff686dcef9f64a6326f))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Refactoring
|
|
33
|
+
|
|
34
|
+
* **client:** the issuer version check nothing called, Registry#urls and Verifier#tenant go ([6b8cf05](https://github.com/masksrb/masks/commit/6b8cf0550b96f6b1b3b415ff27f655f90614bdfe))
|
|
35
|
+
* **engine:** deleting and downloading an account share one check for a fresh sign-in, and PKCE uses the client's one base64url digest ([6c08fcf](https://github.com/masksrb/masks/commit/6c08fcf709a08dbafd0a551801eaf43a4c325b75))
|
|
36
|
+
* **engine:** one partial carries a tenant's email wording, one query loads it, and authorization_details narrow in one place ([1b2df29](https://github.com/masksrb/masks/commit/1b2df29380b2c2c8dc6b87d45d60b4ef23255b26))
|
|
37
|
+
|
|
38
|
+
## [0.8.0](https://github.com/masksrb/masks/compare/gem/v0.7.0...gem/v0.8.0) (2026-09-28)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### ⚠ BREAKING CHANGES
|
|
42
|
+
|
|
43
|
+
* 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.
|
|
44
|
+
|
|
45
|
+
### Features
|
|
46
|
+
|
|
47
|
+
* 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))
|
|
48
|
+
* **client:** a Rails app lists the organizations a person belongs to and links them to another ([7f0d223](https://github.com/masksrb/masks/commit/7f0d2237799e04551d2c403de695c704e5455db8))
|
|
49
|
+
* **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))
|
|
50
|
+
* **client:** a session signs its own client assertions, and asks for a token of its own ([614248b](https://github.com/masksrb/masks/commit/614248b70a3ee517fbd3c1f7151b66ec6406b487))
|
|
51
|
+
* **client:** an exchange hands over an ID token or an actor token ([6b67766](https://github.com/masksrb/masks/commit/6b67766ce275b9578d575349256638adddd5a599))
|
|
52
|
+
* **client:** the session says where the person manages their account, as account_url ([1d36e1d](https://github.com/masksrb/masks/commit/1d36e1d4daa8c5acfc9133a1896aa9384771dbd6))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
|
|
57
|
+
* Rails apps covers client, server, and engine mode ([41454a2](https://github.com/masksrb/masks/commit/41454a242438e4df246dcbc4dce94b6d8a022239))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
### Refactoring
|
|
61
|
+
|
|
62
|
+
* **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))
|
|
63
|
+
|
|
3
64
|
## [0.7.0](https://github.com/masksrb/masks/compare/gem/v0.6.0...gem/v0.7.0) (2026-09-13)
|
|
4
65
|
|
|
5
66
|
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<p align="center"><img src="https://raw.githubusercontent.com/masksrb/masks/main/
|
|
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
2
|
|
|
3
3
|
# masks
|
|
4
4
|
|
|
@@ -8,53 +8,50 @@ Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks)
|
|
|
8
8
|
gem "masks"
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
The engine loads only when Rails does. A Sinatra, Hanami or plain-Rack app requiring
|
|
17
|
-
this gem pulls in `jwt` and nothing else.
|
|
11
|
+
The gem signs people in to an app, and accepts the tokens masks issues when the app is also a
|
|
12
|
+
resource server. Its Rails engine loads only when Rails does, so a Sinatra, Hanami, or plain Rack
|
|
13
|
+
app pulls in `jwt` and nothing else. See
|
|
14
|
+
[Connecting via SDK](https://masks.pages.dev/guides/connecting-via-sdk/) for what it covers beside
|
|
15
|
+
the browser package.
|
|
18
16
|
|
|
19
17
|
## Rails
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
bin/rails generate masks:install
|
|
19
|
+
```sh
|
|
20
|
+
bin/rails generate masks:install --resource https://app.example.com
|
|
26
21
|
```
|
|
27
22
|
|
|
28
23
|
That mounts the engine at `/auth`, writes `config/initializers/masks.rb`, and gitignores
|
|
29
24
|
the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
|
|
30
|
-
`/auth/handshake`.
|
|
25
|
+
`/auth/handshake`. [Rails apps](https://masks.pages.dev/guides/rails/) walks through the rest.
|
|
31
26
|
|
|
32
27
|
### The handshake
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
An app connects to its issuer by somebody approving it. An unconnected app sends the browser to
|
|
30
|
+
the issuer's approval screen, and the server redeems the one-time token that comes back, so the
|
|
31
|
+
client secret never passes through the browser. The handshake registers the app for
|
|
32
|
+
`config.resource`, an absolute URL on the app's own origin, and refuses without one. A connected app
|
|
33
|
+
asks before it rotates, because reconnecting takes it offline for a moment.
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
Once an app is connected, only somebody `config.manages` approves can reconnect or disconnect it.
|
|
36
|
+
The callable takes the request, or the request and the signed-in identity, and the engine refuses
|
|
37
|
+
everyone when it is unset:
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
```ruby
|
|
40
|
+
config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The engine writes what comes back to `config/masks.json`, mode 600. A multi-tenant app keeps it
|
|
44
|
+
somewhere else with two callables:
|
|
46
45
|
|
|
47
46
|
```ruby
|
|
48
47
|
config.credentials = ->(request) { Tenant.for(request).masks_credentials }
|
|
49
48
|
config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
Those two lambdas are the whole integration for a multi-tenant app.
|
|
53
|
-
|
|
54
51
|
### Signing in
|
|
55
52
|
|
|
56
53
|
```ruby
|
|
57
|
-
class
|
|
54
|
+
class DashboardController < ApplicationController
|
|
58
55
|
include Masks::Rails::Authentication
|
|
59
56
|
|
|
60
57
|
before_action :authenticate_masks!
|
|
@@ -65,18 +62,16 @@ class UrisController < ApplicationController
|
|
|
65
62
|
end
|
|
66
63
|
```
|
|
67
64
|
|
|
68
|
-
`masks_identity`, `masks_tenant
|
|
69
|
-
|
|
70
|
-
backend-for-frontend pattern, and it is the default because an SPA holding a token is an
|
|
71
|
-
SPA where XSS lifts one.
|
|
65
|
+
`masks_identity`, `masks_tenant`, and `masks_scopes` describe the signed-in request. Tokens live in
|
|
66
|
+
the encrypted Rails session and never reach JavaScript.
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
`config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
|
|
69
|
+
include it where it applies.
|
|
75
70
|
|
|
76
|
-
###
|
|
71
|
+
### A single-page app in front of it
|
|
77
72
|
|
|
78
|
-
`GET /auth/session` answers identity, tenant and scopes as JSON, or `401` with
|
|
79
|
-
|
|
73
|
+
`GET /auth/session` answers identity, tenant, and scopes as JSON, or `401` with where to send the
|
|
74
|
+
browser. [`@masks/client`](https://masks.pages.dev/reference/browser/) reads it:
|
|
80
75
|
|
|
81
76
|
```js
|
|
82
77
|
import { createSession } from "@masks/client"
|
|
@@ -85,9 +80,8 @@ const session = createSession()
|
|
|
85
80
|
const status = await session.status()
|
|
86
81
|
```
|
|
87
82
|
|
|
88
|
-
`status()` answers
|
|
89
|
-
|
|
90
|
-
has connected must not offer a sign-in button that leads to an error page at the issuer.
|
|
83
|
+
`status()` answers `signed_in`, `signed_out` with where to sign in, or `handshake_required` with
|
|
84
|
+
where to connect the app first.
|
|
91
85
|
|
|
92
86
|
### Accepting tokens
|
|
93
87
|
|
|
@@ -97,43 +91,90 @@ An app that is also a resource server:
|
|
|
97
91
|
class ApiController < ApplicationController
|
|
98
92
|
include Masks::Rails::ProtectedResource
|
|
99
93
|
|
|
100
|
-
masks_protect! scope: "uris:catalog:read"
|
|
94
|
+
masks_protect! scope: "uris:catalog:read", except: :metadata
|
|
95
|
+
|
|
96
|
+
def metadata
|
|
97
|
+
render json: masks_resource_metadata
|
|
98
|
+
end
|
|
101
99
|
end
|
|
102
100
|
```
|
|
103
101
|
|
|
104
|
-
`masks_protect!`
|
|
105
|
-
`:except` through.
|
|
106
|
-
|
|
102
|
+
`masks_protect!` installs the `before_action` and passes `:only` and `:except` through.
|
|
107
103
|
`masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
|
|
108
|
-
`resource_metadata`,
|
|
109
|
-
|
|
104
|
+
`resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
|
|
105
|
+
does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
|
|
106
|
+
accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
|
|
107
|
+
|
|
108
|
+
### Organizations
|
|
109
|
+
|
|
110
|
+
A person signs in to an app as a member of one organization when the app asks for the
|
|
111
|
+
`organization` scope. Someone in several organizations picks one, and an app can pick for them by
|
|
112
|
+
naming it, with `/auth?organization=acme` or with `config.organization` for every sign-in.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Masks::Rails.configure do |config|
|
|
116
|
+
config.scope = %w[openid profile email offline_access organization]
|
|
117
|
+
config.organization = ->(request) { request.subdomain }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class BillingController < ApplicationController
|
|
121
|
+
include Masks::Rails::Authentication
|
|
122
|
+
|
|
123
|
+
masks_members_only! role: %w[owner billing]
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`masks_organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
|
|
128
|
+
`nil`, and `masks_role?("owner")` asks about the role held in it. A refresh reads the person's
|
|
129
|
+
profile again, so a promotion or demotion reaches the app within an access token's lifetime.
|
|
130
|
+
`/auth/session` answers the same thing as `organization`.
|
|
131
|
+
|
|
132
|
+
`masks_organizations` lists every organization the person has joined, each with the role held in
|
|
133
|
+
it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
|
|
134
|
+
again naming the other one, which asks nothing more when they are already a member:
|
|
135
|
+
|
|
136
|
+
```erb
|
|
137
|
+
<% masks_organizations.each do |held| %>
|
|
138
|
+
<%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
|
|
139
|
+
<% end %>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`masks_members_only!` refuses with 403, as `insufficient_organization` when the person signed
|
|
143
|
+
in to none and `insufficient_role` when they hold another role. A resource server asks the same of a
|
|
144
|
+
token:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
masks_protect! scope: "uris:catalog:write", role: "owner", organization: "acme"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Naming an organization without asking for the scope still holds the sign-in to its members and its
|
|
151
|
+
sign-in policy, and the app learns nothing about the role.
|
|
110
152
|
|
|
111
153
|
### Avatars
|
|
112
154
|
|
|
113
|
-
Every
|
|
114
|
-
|
|
155
|
+
Every account has three avatars: an uploaded `photo`, an `identicon`, and two-letter `initials`.
|
|
156
|
+
All three arrive on the ID token.
|
|
115
157
|
|
|
116
158
|
```erb
|
|
117
159
|
<img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
|
|
118
160
|
```
|
|
119
161
|
|
|
120
|
-
A photo
|
|
121
|
-
|
|
162
|
+
A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
|
|
163
|
+
app holds:
|
|
122
164
|
|
|
123
165
|
```erb
|
|
124
166
|
<img src="/auth/avatar" width="64" height="64" alt="">
|
|
125
167
|
```
|
|
126
168
|
|
|
127
|
-
`masks_claims.picture`
|
|
128
|
-
|
|
129
|
-
|
|
169
|
+
`masks_claims.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
|
|
170
|
+
then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
|
|
171
|
+
no token for. Sizes are 32, 64, 128, 256, or 512.
|
|
130
172
|
|
|
131
173
|
### Signing out
|
|
132
174
|
|
|
133
|
-
`DELETE /auth/logout` ends this app's session.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
sign-out.
|
|
175
|
+
`DELETE /auth/logout` ends this app's session and revokes its refresh token at the issuer. With `?everywhere=1` the response also carries
|
|
176
|
+
`logout_url`, the issuer's end-session endpoint. `config.sign_out_of_issuer = true` makes that the
|
|
177
|
+
default for every sign-out.
|
|
137
178
|
|
|
138
179
|
### Configuration
|
|
139
180
|
|
|
@@ -143,21 +184,23 @@ a subdomain-per-tenant host needs.
|
|
|
143
184
|
| | |
|
|
144
185
|
|---|---|
|
|
145
186
|
| `issuer` | the masks issuer this app signs in against |
|
|
146
|
-
| `resource` | this app's own identifier,
|
|
187
|
+
| `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
|
|
147
188
|
| `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
|
|
148
|
-
| `scope` | what to ask the issuer for
|
|
189
|
+
| `scope` | what to ask the issuer for, `openid profile email` by default |
|
|
190
|
+
| `organization` | the organization every sign-in names, by key |
|
|
149
191
|
| `credentials` / `store` | where the handshake's result lives |
|
|
150
|
-
| `
|
|
192
|
+
| `manages` | who may reconnect or disconnect a connected app |
|
|
193
|
+
| `credentials_path` | where the default store writes, `config/masks.json` by default |
|
|
151
194
|
| `after_sign_in` / `after_sign_out` | paths on this host |
|
|
152
195
|
| `parent_controller` | what the engine's pages inherit, for your layout |
|
|
153
196
|
| `authenticate_everything` | include `Authentication` on every controller |
|
|
154
197
|
| `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
|
|
155
198
|
| `session_key` | the session key the tokens live under |
|
|
199
|
+
| `delegates` / `delegation_redirect_uri` | ask for delegation in the handshake, and where connecting an account returns |
|
|
156
200
|
|
|
157
201
|
## Any Ruby app
|
|
158
202
|
|
|
159
|
-
|
|
160
|
-
holds no protocol of its own.
|
|
203
|
+
The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
|
|
161
204
|
|
|
162
205
|
### The consumer half
|
|
163
206
|
|
|
@@ -169,22 +212,22 @@ session = Masks::Client::Session.new(
|
|
|
169
212
|
)
|
|
170
213
|
|
|
171
214
|
started = session.start(resource: "https://app.example.com/mcp")
|
|
172
|
-
# hold started[:state], started[:nonce] and started[:verifier]; send the
|
|
173
|
-
# browser to started[:url]
|
|
174
215
|
|
|
175
216
|
tokens = session.complete(code: params[:code], verifier: held[:verifier])
|
|
176
217
|
identity = session.identity(tokens)
|
|
177
218
|
```
|
|
178
219
|
|
|
179
|
-
`
|
|
180
|
-
|
|
220
|
+
Hold `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
|
|
221
|
+
browser to `started[:url]`. `start` sends a nonce only when the scope includes `openid`. Check the
|
|
222
|
+
returned `state` yourself, and compare `identity["nonce"]` with the one you held.
|
|
181
223
|
|
|
182
|
-
|
|
224
|
+
`Session` also has `refresh`, `exchange`, `client_credentials`, `revoke`, `introspect`, `userinfo`,
|
|
225
|
+
and `end_session_url`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
|
|
226
|
+
`private_key_jwt`.
|
|
183
227
|
|
|
184
|
-
Discovery and JWKS are cached for five minutes
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
constructing one per request.
|
|
228
|
+
Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
|
|
229
|
+
needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
|
|
230
|
+
of constructing one per request.
|
|
188
231
|
|
|
189
232
|
### The handshake
|
|
190
233
|
|
|
@@ -197,12 +240,13 @@ handshake = Masks::Client::Handshake.new(
|
|
|
197
240
|
return_to: "https://app.example.com/"
|
|
198
241
|
)
|
|
199
242
|
|
|
200
|
-
started = handshake.start
|
|
243
|
+
started = handshake.start
|
|
201
244
|
registration = handshake.complete(params, state: held)
|
|
202
245
|
```
|
|
203
246
|
|
|
204
|
-
`
|
|
205
|
-
that
|
|
247
|
+
Hold `started[:state]` and send the browser to `started[:url]`. Before it redeems anything,
|
|
248
|
+
`complete` refuses an `error`, a `state` that does not match this browser, and an `iss` other than
|
|
249
|
+
the issuer it asked.
|
|
206
250
|
|
|
207
251
|
### The resource-server half
|
|
208
252
|
|
|
@@ -213,24 +257,33 @@ resource = Masks::Client::Resource.new(
|
|
|
213
257
|
scopes: { "uris:catalog:read" => "Search your catalog" }
|
|
214
258
|
)
|
|
215
259
|
|
|
216
|
-
claims = resource.authenticate(
|
|
260
|
+
claims = resource.authenticate(
|
|
261
|
+
request.authorization, scope: "uris:catalog:read",
|
|
262
|
+
proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
|
|
263
|
+
)
|
|
217
264
|
claims.subject
|
|
218
265
|
claims.tenant.subdomain
|
|
219
266
|
```
|
|
220
267
|
|
|
221
|
-
`authenticate` answers `Claims` or raises. A
|
|
222
|
-
|
|
268
|
+
`authenticate` answers `Claims` or raises. A token bound to a key with DPoP carries `cnf.jkt`, and is
|
|
269
|
+
accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
|
|
270
|
+
token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
|
|
271
|
+
checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
|
|
272
|
+
processes passes `replay:` to `Resource.new`, any object whose `first?(key, expires_in:)` answers true
|
|
273
|
+
only once for each key.
|
|
274
|
+
|
|
275
|
+
A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
|
|
276
|
+
accepted:
|
|
223
277
|
|
|
224
278
|
```ruby
|
|
225
279
|
response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
226
280
|
```
|
|
227
281
|
|
|
228
|
-
`resource.metadata` is the RFC 9728 document to serve at
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
way to know what `uris:catalog:read` means.
|
|
282
|
+
`resource.metadata` is the RFC 9728 document to serve at `/.well-known/oauth-protected-resource`.
|
|
283
|
+
Its `scope_descriptions` extension gives masks the sentences its consent screen shows for each
|
|
284
|
+
scope.
|
|
232
285
|
|
|
233
|
-
|
|
286
|
+
A Rack middleware does the same below the framework, proof included:
|
|
234
287
|
|
|
235
288
|
```ruby
|
|
236
289
|
use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
|
|
@@ -238,23 +291,21 @@ use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
|
|
|
238
291
|
|
|
239
292
|
### Introspection
|
|
240
293
|
|
|
241
|
-
A resource server
|
|
242
|
-
|
|
294
|
+
A resource server that only verifies the JWT sees a revocation when the token expires. Introspection
|
|
295
|
+
asks the issuer:
|
|
243
296
|
|
|
244
297
|
```ruby
|
|
245
298
|
found = session.introspect(token)
|
|
246
299
|
found.active? && found.permits?("uris:catalog:read")
|
|
247
300
|
```
|
|
248
301
|
|
|
249
|
-
`Introspection` is a `Claims` whose `permit!` raises when the
|
|
250
|
-
not active, so moving from local verification to introspection needs no extra boolean
|
|
251
|
-
check.
|
|
302
|
+
`Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
|
|
252
303
|
|
|
253
304
|
### Somebody else's account
|
|
254
305
|
|
|
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/
|
|
257
|
-
provider's tokens
|
|
306
|
+
An app that acts as somebody at Google, Microsoft, or an MCP server while they are away asks masks for
|
|
307
|
+
a [delegation](https://masks.pages.dev/guides/connecting-via-sdk/#delegation). masks keeps and refreshes the
|
|
308
|
+
provider's tokens. The app keeps one secret per connection and trades it for a live access token when
|
|
258
309
|
the last one runs out.
|
|
259
310
|
|
|
260
311
|
```ruby
|
|
@@ -280,9 +331,9 @@ A Rails app sets `config.delegates = true`, so its handshake asks for `masks:del
|
|
|
280
331
|
exchange with it.
|
|
281
332
|
|
|
282
333
|
`upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
|
|
283
|
-
has to connect again
|
|
284
|
-
already rotated it, so keep it. Spend a secret from one place at a time
|
|
285
|
-
it.
|
|
334
|
+
has to connect again, and `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks
|
|
335
|
+
had already rotated it, so keep it. Spend a secret from one place at a time, because spending one
|
|
336
|
+
twice revokes it.
|
|
286
337
|
|
|
287
338
|
Tests use the fake, which needs no issuer:
|
|
288
339
|
|
|
@@ -298,16 +349,10 @@ fake.revoke(held.connection)
|
|
|
298
349
|
fake.unavailable(held.connection)
|
|
299
350
|
```
|
|
300
351
|
|
|
301
|
-
## Which issuers this speaks to
|
|
302
|
-
|
|
303
|
-
masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
|
|
304
|
-
least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
|
|
305
|
-
it. An older issuer is refused at configuration time, with a sentence saying so.
|
|
306
|
-
|
|
307
352
|
## Documentation
|
|
308
353
|
|
|
309
|
-
|
|
310
|
-
|
|
354
|
+
The full reference is generated from this gem at [masks.pages.dev](https://masks.pages.dev): `ruby`
|
|
355
|
+
for `Masks::Client` and `rails` for the engine.
|
|
311
356
|
|
|
312
357
|
## License
|
|
313
358
|
|
|
@@ -2,6 +2,7 @@ module Masks
|
|
|
2
2
|
module Rails
|
|
3
3
|
class HandshakesController < BaseController
|
|
4
4
|
before_action :require_unconfigured_or_signed_in
|
|
5
|
+
before_action :require_unconfigured_or_manager
|
|
5
6
|
|
|
6
7
|
def show
|
|
7
8
|
return begin! unless masks_config.configured?(request)
|
|
@@ -69,6 +70,16 @@ module Masks
|
|
|
69
70
|
redirect_to masks_config.after_sign_out
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
def require_unconfigured_or_manager
|
|
74
|
+
return unless masks_config.configured?(request)
|
|
75
|
+
return if masks_config.manages?(request, masks_identity)
|
|
76
|
+
|
|
77
|
+
@code = "forbidden"
|
|
78
|
+
@description = "only somebody this app lets manage its connection can reconnect or disconnect it"
|
|
79
|
+
|
|
80
|
+
render :refused, status: :forbidden
|
|
81
|
+
end
|
|
82
|
+
|
|
72
83
|
def stale
|
|
73
84
|
@code = "invalid_state"
|
|
74
85
|
@description = "that connection request is not one this browser started, or it expired"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Rails
|
|
3
3
|
class SessionsController < BaseController
|
|
4
|
+
RESTARTED = "masks_restarted_at".freeze
|
|
5
|
+
RESTART_WITHIN = 60
|
|
6
|
+
|
|
4
7
|
def show
|
|
5
8
|
if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
|
|
6
9
|
response.headers["Cache-Control"] = "no-store"
|
|
@@ -21,7 +24,9 @@ module Masks
|
|
|
21
24
|
)
|
|
22
25
|
started = masks_session.start(
|
|
23
26
|
state: pending.id,
|
|
24
|
-
resource: masks_config.resource_for(request)
|
|
27
|
+
resource: masks_config.resource_for(request),
|
|
28
|
+
organization: Masks::Client::Session.organization_key(params[:organization]) ||
|
|
29
|
+
masks_config.organization_for(request)
|
|
25
30
|
)
|
|
26
31
|
|
|
27
32
|
masks_requests.amend(
|
|
@@ -34,8 +39,8 @@ module Masks
|
|
|
34
39
|
def callback
|
|
35
40
|
pending = masks_requests.claim(params[:state])
|
|
36
41
|
|
|
37
|
-
return stale if pending.nil?
|
|
38
42
|
return refuse(params[:error], params[:error_description], pending) if params[:error].present?
|
|
43
|
+
return stale if pending.nil?
|
|
39
44
|
|
|
40
45
|
tokens = masks_session.complete(
|
|
41
46
|
code: params[:code],
|
|
@@ -53,6 +58,7 @@ module Masks
|
|
|
53
58
|
return refuse("invalid_nonce", "the id token was issued for another request", pending)
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
renew_session
|
|
56
62
|
masks_store(tokens, identity: identity)
|
|
57
63
|
|
|
58
64
|
redirect_to pending[:return_to] || masks_config.after_sign_in
|
|
@@ -68,7 +74,9 @@ module Masks
|
|
|
68
74
|
everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
|
|
69
75
|
upstream = everywhere ? masks_logout_url : nil
|
|
70
76
|
|
|
77
|
+
revoke_refresh_token
|
|
71
78
|
masks_forget
|
|
79
|
+
reset_session
|
|
72
80
|
|
|
73
81
|
if masks_wants_json?
|
|
74
82
|
render json: { "signed_in" => false, "logout_url" => upstream }.compact
|
|
@@ -79,6 +87,23 @@ module Masks
|
|
|
79
87
|
|
|
80
88
|
private
|
|
81
89
|
|
|
90
|
+
def renew_session
|
|
91
|
+
carried = session.to_hash.slice(REQUESTS, HANDSHAKES)
|
|
92
|
+
|
|
93
|
+
reset_session
|
|
94
|
+
carried.each { |key, value| session[key] = value }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def revoke_refresh_token
|
|
98
|
+
held = masks_tokens&.refresh_token
|
|
99
|
+
|
|
100
|
+
return if held.nil? || !masks_configured?
|
|
101
|
+
|
|
102
|
+
masks_session.revoke(held, hint: "refresh_token")
|
|
103
|
+
rescue Masks::Client::Error
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
82
107
|
def requested_return_to
|
|
83
108
|
masks_local_path(params[:return_to])
|
|
84
109
|
end
|
|
@@ -93,6 +118,9 @@ module Masks
|
|
|
93
118
|
end
|
|
94
119
|
|
|
95
120
|
def stale
|
|
121
|
+
return redirect_to(masks_config.after_sign_in) if masks_signed_in? || (masks_tokens && masks_refresh!)
|
|
122
|
+
return restart unless masks_wants_json? || restarted_lately?
|
|
123
|
+
|
|
96
124
|
answer(
|
|
97
125
|
"invalid_state",
|
|
98
126
|
"that sign-in request is not one this browser started, or it expired",
|
|
@@ -100,6 +128,15 @@ module Masks
|
|
|
100
128
|
)
|
|
101
129
|
end
|
|
102
130
|
|
|
131
|
+
def restart
|
|
132
|
+
session[RESTARTED] = Time.now.to_i
|
|
133
|
+
redirect_to start_path
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def restarted_lately?
|
|
137
|
+
Time.now.to_i - session[RESTARTED].to_i < RESTART_WITHIN
|
|
138
|
+
end
|
|
139
|
+
|
|
103
140
|
def refuse(code, description, pending = nil)
|
|
104
141
|
masks_forget if pending
|
|
105
142
|
|