masks 0.10.0 → 0.12.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 +63 -0
- data/README.md +28 -341
- data/app/controllers/masks/rails/handshakes_controller.rb +2 -2
- data/app/controllers/masks/rails/logouts_controller.rb +11 -8
- data/app/controllers/masks/rails/sessions_controller.rb +48 -6
- data/config/routes.rb +1 -0
- data/lib/masks/client/device.rb +47 -0
- data/lib/masks/client/embedded.rb +125 -0
- data/lib/masks/client/enrollment.rb +33 -0
- data/lib/masks/client/errors.rb +6 -0
- data/lib/masks/client/handshake.rb +12 -4
- data/lib/masks/client/http.rb +87 -24
- data/lib/masks/client/issuer.rb +2 -1
- data/lib/masks/client/key.rb +100 -0
- data/lib/masks/client/logout.rb +5 -1
- data/lib/masks/client/nonces.rb +31 -0
- data/lib/masks/client/proof.rb +7 -1
- data/lib/masks/client/rack.rb +8 -0
- data/lib/masks/client/registration.rb +32 -15
- data/lib/masks/client/resource.rb +1 -1
- data/lib/masks/client/session.rb +120 -11
- data/lib/masks/client/tokens.rb +4 -0
- data/lib/masks/client/transport.rb +114 -0
- data/lib/masks/client/verifier.rb +10 -2
- data/lib/masks/client.rb +21 -4
- data/lib/masks/rails/authentication.rb +115 -35
- data/lib/masks/rails/cache.rb +173 -0
- data/lib/masks/rails/configuration.rb +27 -12
- data/lib/masks/rails/protected_resource.rb +13 -0
- data/lib/masks/rails/renewal.rb +54 -0
- data/lib/masks/rails/session_cookies.rb +145 -0
- data/lib/masks/rails.rb +5 -4
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +0 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22811283549c975e2550ab8546760831275db1efa68ddfd16ebe1dcfb96278a5
|
|
4
|
+
data.tar.gz: 888decf6ea332f751cb446a4e7535313527141ed64086cbf62acc2787d320a9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33275896c9010d94818885f7a0a3f71b83f6782af7384cf29fb70d482b03492b8e662f27d6f748e1d9a05f453f5294dc910ca5d1492395c7bc3e0bc57aaaea6f
|
|
7
|
+
data.tar.gz: 5fb2c1d80a01a0723c3533bc8861cca4c2d0761e9ef5d4ef21f46de41d611835bf9fefe28ac0bab860b65bb0ee166cf8288fbda1a178644f58dc3a850edaea96
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0](https://github.com/masksrb/masks/compare/gem/v0.11.0...gem/v0.12.0) (2026-10-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **client:** config.cache shares refreshes, back-channel logouts, and DPoP proofs across a Rails app's processes ([1019324](https://github.com/masksrb/masks/commit/1019324f74d44aa9df62d860082c529f5effda44))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Fixes
|
|
12
|
+
|
|
13
|
+
* **client:** a back-channel logout token must carry exp, and its jti is remembered until two minutes after it expires ([ffbf534](https://github.com/masksrb/masks/commit/ffbf53485583d342f0d6e147077b0c7df05c911f))
|
|
14
|
+
* **client:** a cache outage is told apart from a repeat, so masks and DPoP clients get 503 instead of a refusal ([8884cb6](https://github.com/masksrb/masks/commit/8884cb680e802521a413ea17a96eedfec58cae35))
|
|
15
|
+
* **client:** a page on another site can no longer sign somebody out of a Rails app ([96a084f](https://github.com/masksrb/masks/commit/96a084fe0172b27c1affe9461199335fe2b1bbba))
|
|
16
|
+
* **client:** a Rails sign-in keeps its tokens in its own cookies, split across up to eight, so a large access token no longer overflows the session ([88325fb](https://github.com/masksrb/masks/commit/88325fbee0d54bd90ffe3e7286dfa1553582ff2d))
|
|
17
|
+
* **client:** a sign-in callback this browser did not start no longer repeats the error text in its URL ([60574f2](https://github.com/masksrb/masks/commit/60574f263f11420933956354f3e1cae222cf0418))
|
|
18
|
+
* **client:** HTTP.fetch reads at most 8 MB, so the avatar proxy cannot be made to hold an endless answer ([541c0b5](https://github.com/masksrb/masks/commit/541c0b56d648d8bee2eb7cb82ca5ac061adfc350))
|
|
19
|
+
* **client:** signing out of masks returns to /auth/logout/return, which the handshake registers as the app's post-logout address ([d1dbc05](https://github.com/masksrb/masks/commit/d1dbc05e6bcbe41a4143b1efde901495dd6dbcae))
|
|
20
|
+
* **client:** the challenge for a request with no token carries no error description ([b030959](https://github.com/masksrb/masks/commit/b030959d6fd1b2e32be7f8173b17cde4282d43cc))
|
|
21
|
+
* **engine:** a handshake registers only the post-logout address it names, and keeps its return_to apart, so a new return_to is still asked about ([47a8988](https://github.com/masksrb/masks/commit/47a898874ee2c00f9763a57624cc3cb207a7e48f))
|
|
22
|
+
* **engine:** a refused token is described in masks' own words, not the JWT library's ([dae249d](https://github.com/masksrb/masks/commit/dae249d348c65e83bf96f680746d60ba4642c58d))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* **client:** the to-do list records how close a Rails session sits to the cookie limit ([deedb14](https://github.com/masksrb/masks/commit/deedb144ba9458904348fa7482303d21691aefbf))
|
|
28
|
+
* comments and pages no longer claim what masks stopped doing ([e0b1ba0](https://github.com/masksrb/masks/commit/e0b1ba0b71ccae8683b605f9442ee16c4afb5dd4))
|
|
29
|
+
* every guide, SDK reference, and README says what masks does now, in fewer words ([7046f86](https://github.com/masksrb/masks/commit/7046f862c774f05407d92cd19779f53bf0b62595))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Refactoring
|
|
33
|
+
|
|
34
|
+
* **client:** HTTP.fetch is a GET through deliver with no read limit, and an embedded request's Host comes from Nonces.origin ([0398e13](https://github.com/masksrb/masks/commit/0398e135ba1a41fd56007709bb35aef900f14e57))
|
|
35
|
+
|
|
36
|
+
## [0.11.0](https://github.com/masksrb/masks/compare/gem/v0.10.0...gem/v0.11.0) (2026-10-13)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
* **client:** a session holds its tokens to a DPoP key and proves it on every token request ([be3732c](https://github.com/masksrb/masks/commit/be3732cec2873fed75d8c70591f9bb1a2dcfb93c))
|
|
42
|
+
* **client:** a session runs the device flow and polls until somebody approves the code ([cd96836](https://github.com/masksrb/masks/commit/cd96836a9d97049f80b9efcf6eec8335e7650a6a))
|
|
43
|
+
* **client:** config.replay names one store of seen DPoP proofs for every process of a Rails app ([e3baf5a](https://github.com/masksrb/masks/commit/e3baf5a04b652ec2205bbc88004cb37b78ac9b0e))
|
|
44
|
+
* **client:** Masks::Client.embed answers an issuer's requests inside the process, for an app that mounts masks and signs in through it ([7473031](https://github.com/masksrb/masks/commit/74730318cd335a74207b1fea0156b646e61d74c1))
|
|
45
|
+
* **client:** Session#enroll asks masks for an enrollment token that a machine spends once to register its own key ([be52734](https://github.com/masksrb/masks/commit/be52734f7d470140749bf2f2d208d81ca85bc42d))
|
|
46
|
+
* **client:** Session#unenroll archives a machine the app enrolled and revokes its tokens ([2743890](https://github.com/masksrb/masks/commit/2743890063f1e3818f8acb29048bb549f1b94220))
|
|
47
|
+
* **server:** /manage, the sign-in pages, and the docs share one palette and one font stack ([17efe87](https://github.com/masksrb/masks/commit/17efe87c81c14d7541a37292bf58ba242836eca5))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
### Fixes
|
|
51
|
+
|
|
52
|
+
* **client:** signing out everywhere returns through the handshake callback masks registered, so masks no longer refuses it ([6eb6e70](https://github.com/masksrb/masks/commit/6eb6e7067d43803f0ac8dcfbc04e2d8057ac2ca0))
|
|
53
|
+
* **client:** the gem speaks only https off loopback, reads at most 1 MB, and answers 503 when its replay store is down ([6c03a43](https://github.com/masksrb/masks/commit/6c03a430a500d3476e018cf650bf093ad2aca102))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
### Documentation
|
|
57
|
+
|
|
58
|
+
* each language has a group under Reference, with an overview, a page per framework, and its API reference ([64b7837](https://github.com/masksrb/masks/commit/64b7837e0484d11ddd5b4d926fc4aa800c06ed81))
|
|
59
|
+
* each SDK keeps a TODO of the cleanups, security follow-ups, and testing left from the framework integrations ([4c4353b](https://github.com/masksrb/masks/commit/4c4353b63713700034c78f3d3c0b3b45ac7b50b3))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
### Refactoring
|
|
63
|
+
|
|
64
|
+
* **server:** /manage derives its surfaces from the shared palette, and --label folds into --brass ([c00d32b](https://github.com/masksrb/masks/commit/c00d32b821cc99b8cbc2903a7a4c0c08aa2770bd))
|
|
65
|
+
|
|
3
66
|
## [0.10.0](https://github.com/masksrb/masks/compare/gem/v0.9.2...gem/v0.10.0) (2026-10-11)
|
|
4
67
|
|
|
5
68
|
|
data/README.md
CHANGED
|
@@ -2,54 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
# masks
|
|
4
4
|
|
|
5
|
-
Sign a Ruby or Rails app
|
|
5
|
+
Sign people in to a Ruby or Rails app with a [masks](https://github.com/masksrb/masks) server, and
|
|
6
|
+
accept the tokens it gives out.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
gem "masks"
|
|
9
|
-
```
|
|
8
|
+
The gem has two parts:
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
10
|
+
- `Masks::Rails`, a Rails engine that adds sign-in to your app. It loads only when Rails does.
|
|
11
|
+
- `Masks::Client`, plain Ruby for any app. It depends on `jwt` and nothing else.
|
|
16
12
|
|
|
17
|
-
##
|
|
13
|
+
## Install
|
|
18
14
|
|
|
19
|
-
```
|
|
20
|
-
|
|
15
|
+
```ruby
|
|
16
|
+
gem "masks"
|
|
21
17
|
```
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
|
|
25
|
-
`/auth/handshake`. [Rails apps](https://masks.pages.dev/guides/rails/) walks through the rest.
|
|
26
|
-
|
|
27
|
-
### The handshake
|
|
28
|
-
|
|
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.
|
|
19
|
+
The gem needs Ruby 3.2 or later.
|
|
34
20
|
|
|
35
|
-
|
|
36
|
-
It names a [privilege](#privileges), or is a callable taking the request, or the request and
|
|
37
|
-
`masked`. The engine refuses everyone when it is unset:
|
|
21
|
+
## Rails
|
|
38
22
|
|
|
39
|
-
|
|
40
|
-
config.manages = :admin
|
|
41
|
-
config.manages = ->(request, masked) { masked.subject == ENV["OWNER_SUB"] }
|
|
42
|
-
```
|
|
23
|
+
1. Run the install generator:
|
|
43
24
|
|
|
44
|
-
|
|
45
|
-
|
|
25
|
+
```sh
|
|
26
|
+
bin/rails generate masks:install --resource https://app.example.com
|
|
27
|
+
```
|
|
46
28
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```
|
|
29
|
+
2. Set `MASKS_ISSUER` to the masks server and tenant you sign in against.
|
|
30
|
+
3. Start the app and open `/auth/handshake`.
|
|
31
|
+
4. Approve the app on the masks server.
|
|
51
32
|
|
|
52
|
-
|
|
33
|
+
Then require a sign-in in a controller:
|
|
53
34
|
|
|
54
35
|
```ruby
|
|
55
36
|
class DashboardController < ApplicationController
|
|
@@ -58,180 +39,13 @@ class DashboardController < ApplicationController
|
|
|
58
39
|
masked!
|
|
59
40
|
|
|
60
41
|
def index
|
|
61
|
-
@
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
`masked` is everything about the signed-in request in one object: `subject`, `name`, `nickname`,
|
|
67
|
-
`email`, `picture`, `avatars`, `tenant`, `organization`, `organizations`, `role?`, `scopes`,
|
|
68
|
-
`privileges`, `privilege?`, `access_token`, and `signed_in?`, with `masked["claim"]` for any other
|
|
69
|
-
claim. In a view it is the same helper. Tokens live in the encrypted Rails session and never reach
|
|
70
|
-
JavaScript.
|
|
71
|
-
|
|
72
|
-
### Privileges
|
|
73
|
-
|
|
74
|
-
A privilege names what an action needs: scopes the person holds in masks, a role in the organization
|
|
75
|
-
they signed in to, or both. A list is shorthand for `{ scope: [...] }`.
|
|
76
|
-
|
|
77
|
-
```ruby
|
|
78
|
-
Masks::Rails.configure do |config|
|
|
79
|
-
config.privileges = {
|
|
80
|
-
admin: %w[catalog:admin],
|
|
81
|
-
billing: { role: %w[owner billing] },
|
|
82
|
-
acme_admin: { scope: %w[catalog:admin], role: "owner", organization: "acme" }
|
|
83
|
-
}
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
class SettingsController < ApplicationController
|
|
87
|
-
masked!
|
|
88
|
-
masked! :admin, only: %i[create update destroy]
|
|
89
|
-
end
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
`masked!` with no privilege needs only a sign-in. A privilege's scopes stay out of every ordinary
|
|
93
|
-
sign-in. When a signed-in person lacks one, the browser goes through masks once to ask for it and
|
|
94
|
-
comes back, and the action answers `403` when masks does not grant it. A missing role or organization
|
|
95
|
-
answers `403` at once. `masked.privilege?(:admin)` and `masked.privileges` tell a view what the sign-in
|
|
96
|
-
holds, and `/auth/session` answers `privileges`. See
|
|
97
|
-
[privileges](https://masks.pages.dev/guides/rails/#privileges).
|
|
98
|
-
|
|
99
|
-
`config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
|
|
100
|
-
include it where it applies.
|
|
101
|
-
|
|
102
|
-
### A single-page app in front of it
|
|
103
|
-
|
|
104
|
-
`GET /auth/session` answers identity, tenant, and scopes as JSON, or `401` with where to send the
|
|
105
|
-
browser. [`@masks/client`](https://masks.pages.dev/reference/browser/) reads it:
|
|
106
|
-
|
|
107
|
-
```js
|
|
108
|
-
import { createSession } from "@masks/client"
|
|
109
|
-
|
|
110
|
-
const session = createSession()
|
|
111
|
-
const status = await session.status()
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
`status()` answers `signed_in`, `signed_out` with where to sign in, or `handshake_required` with
|
|
115
|
-
where to connect the app first.
|
|
116
|
-
|
|
117
|
-
### Accepting tokens
|
|
118
|
-
|
|
119
|
-
An app that is also a resource server:
|
|
120
|
-
|
|
121
|
-
```ruby
|
|
122
|
-
class ApiController < ApplicationController
|
|
123
|
-
include Masks::Rails::ProtectedResource
|
|
124
|
-
|
|
125
|
-
masked! :read, except: :metadata
|
|
126
|
-
|
|
127
|
-
def metadata
|
|
128
|
-
render json: masks_resource_metadata
|
|
42
|
+
@name = masked.name
|
|
129
43
|
end
|
|
130
44
|
end
|
|
131
45
|
```
|
|
132
46
|
|
|
133
|
-
`masked!` checks a [privilege](#privileges) against the bearer token, here
|
|
134
|
-
`config.privileges = { read: %w[xixo:catalog:read] }`. `masked` reads the verified token. A refusal carries the RFC 6750 challenge with
|
|
135
|
-
`resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
|
|
136
|
-
does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
|
|
137
|
-
accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
|
|
138
|
-
|
|
139
|
-
### Organizations
|
|
140
|
-
|
|
141
|
-
A person signs in to an app as a member of one organization when the app asks for the
|
|
142
|
-
`organization` scope. Someone in several organizations picks one, and an app can pick for them by
|
|
143
|
-
naming it, with `/auth?organization=acme` or with `config.organization` for every sign-in.
|
|
144
|
-
|
|
145
|
-
```ruby
|
|
146
|
-
Masks::Rails.configure do |config|
|
|
147
|
-
config.scope = %w[openid profile email offline_access organization]
|
|
148
|
-
config.organization = ->(request) { request.subdomain }
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
class BillingController < ApplicationController
|
|
152
|
-
include Masks::Rails::Authentication
|
|
153
|
-
|
|
154
|
-
masked! :billing
|
|
155
|
-
end
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
`masked.organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
|
|
159
|
-
`nil`, and `masked.role?("owner")` asks about the role held in it. A refresh reads the person's
|
|
160
|
-
profile again, so a promotion or demotion reaches the app within an access token's lifetime.
|
|
161
|
-
`/auth/session` answers the same thing as `organization`.
|
|
162
|
-
|
|
163
|
-
`masked.organizations` lists every organization the person has joined, each with the role held in
|
|
164
|
-
it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
|
|
165
|
-
again naming the other one, which asks nothing more when they are already a member:
|
|
166
|
-
|
|
167
|
-
```erb
|
|
168
|
-
<% masked.organizations.each do |held| %>
|
|
169
|
-
<%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
|
|
170
|
-
<% end %>
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
A privilege with a role refuses with 403, as `insufficient_organization` when the person signed in
|
|
174
|
-
to none and `insufficient_role` when they hold another role. A resource server asks the same of a
|
|
175
|
-
token with `masked!`.
|
|
176
|
-
|
|
177
|
-
Naming an organization without asking for the scope still holds the sign-in to its members and its
|
|
178
|
-
sign-in policy, and the app learns nothing about the role.
|
|
179
|
-
|
|
180
|
-
### Avatars
|
|
181
|
-
|
|
182
|
-
Every account has three avatars: an uploaded `photo`, an `identicon`, and two-letter `initials`.
|
|
183
|
-
All three arrive on the ID token.
|
|
184
|
-
|
|
185
|
-
```erb
|
|
186
|
-
<img src="<%= masked.avatars.identicon %>?size=64" width="64" height="64" alt="">
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
|
|
190
|
-
app holds:
|
|
191
|
-
|
|
192
|
-
```erb
|
|
193
|
-
<img src="/auth/avatar" width="64" height="64" alt="">
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
`masked.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
|
|
197
|
-
then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
|
|
198
|
-
no token for. Sizes are 32, 64, 128, 256, or 512.
|
|
199
|
-
|
|
200
|
-
### Signing out
|
|
201
|
-
|
|
202
|
-
`DELETE /auth/logout` ends this app's session and revokes its refresh token at the issuer. With `?everywhere=1` the response also carries
|
|
203
|
-
`logout_url`, the issuer's end-session endpoint. `config.sign_out_of_issuer = true` makes that the
|
|
204
|
-
default for every sign-out.
|
|
205
|
-
|
|
206
|
-
### Configuration
|
|
207
|
-
|
|
208
|
-
Every value that varies per request accepts a callable taking the request, which is what
|
|
209
|
-
a subdomain-per-tenant host needs.
|
|
210
|
-
|
|
211
|
-
| | |
|
|
212
|
-
|---|---|
|
|
213
|
-
| `issuer` | the masks issuer this app signs in against |
|
|
214
|
-
| `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
|
|
215
|
-
| `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
|
|
216
|
-
| `scope` | what to ask the issuer for, `openid profile email` by default |
|
|
217
|
-
| `privileges` | named scopes, roles, and organizations that `masked!` checks, none by default |
|
|
218
|
-
| `organization` | the organization every sign-in names, by key |
|
|
219
|
-
| `credentials` / `store` | where the handshake's result lives |
|
|
220
|
-
| `manages` | who may reconnect or disconnect a connected app |
|
|
221
|
-
| `credentials_path` | where the default store writes, `config/masks.json` by default |
|
|
222
|
-
| `after_sign_in` / `after_sign_out` | paths on this host |
|
|
223
|
-
| `parent_controller` | what the engine's pages inherit, for your layout |
|
|
224
|
-
| `authenticate_everything` | include `Authentication` on every controller |
|
|
225
|
-
| `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
|
|
226
|
-
| `session_key` | the session key the tokens live under |
|
|
227
|
-
| `delegates` / `delegation_redirect_uri` | ask for delegation in the handshake, and where connecting an account returns |
|
|
228
|
-
|
|
229
47
|
## Any Ruby app
|
|
230
48
|
|
|
231
|
-
The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
|
|
232
|
-
|
|
233
|
-
### The consumer half
|
|
234
|
-
|
|
235
49
|
```ruby
|
|
236
50
|
session = Masks::Client::Session.new(
|
|
237
51
|
issuer: "https://demo.auth.example.com",
|
|
@@ -239,148 +53,21 @@ session = Masks::Client::Session.new(
|
|
|
239
53
|
redirect_uri: "https://app.example.com/auth/callback"
|
|
240
54
|
)
|
|
241
55
|
|
|
242
|
-
started = session.start
|
|
243
|
-
|
|
244
|
-
tokens = session.complete(code: params[:code], verifier: held[:verifier])
|
|
56
|
+
started = session.start
|
|
57
|
+
tokens = session.complete(code: params[:code], verifier: started[:verifier])
|
|
245
58
|
identity = session.identity(tokens)
|
|
246
59
|
```
|
|
247
60
|
|
|
248
|
-
|
|
249
|
-
browser to `started[:url]`.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
`Session` also has `refresh`, `exchange`, `client_credentials`, `revoke`, `introspect`, `userinfo`,
|
|
253
|
-
and `end_session_url`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
|
|
254
|
-
`private_key_jwt`.
|
|
255
|
-
|
|
256
|
-
Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
|
|
257
|
-
needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
|
|
258
|
-
of constructing one per request.
|
|
259
|
-
|
|
260
|
-
### The handshake
|
|
261
|
-
|
|
262
|
-
The flow that connects a first-party app:
|
|
263
|
-
|
|
264
|
-
```ruby
|
|
265
|
-
handshake = Masks::Client::Handshake.new(
|
|
266
|
-
issuer, name: "xixo", resource: "https://app.example.com/mcp",
|
|
267
|
-
redirect_uris: [ "https://app.example.com/auth/callback" ],
|
|
268
|
-
return_to: "https://app.example.com/"
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
started = handshake.start
|
|
272
|
-
registration = handshake.complete(params, state: held)
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Hold `started[:state]` and send the browser to `started[:url]`. Before it redeems anything,
|
|
276
|
-
`complete` refuses an `error`, a `state` that does not match this browser, and an `iss` other than
|
|
277
|
-
the issuer it asked.
|
|
278
|
-
|
|
279
|
-
### The resource-server half
|
|
280
|
-
|
|
281
|
-
```ruby
|
|
282
|
-
resource = Masks::Client::Resource.new(
|
|
283
|
-
issuer: "https://demo.auth.example.com",
|
|
284
|
-
url: "https://app.example.com/mcp",
|
|
285
|
-
scopes: { "xixo:catalog:read" => "Search your catalog" }
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
claims = resource.authenticate(
|
|
289
|
-
request.authorization, scope: "xixo:catalog:read",
|
|
290
|
-
proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
|
|
291
|
-
)
|
|
292
|
-
claims.subject
|
|
293
|
-
claims.tenant.subdomain
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
`authenticate` answers `Claims` or raises. A token bound to a key with DPoP carries `cnf.jkt`, and is
|
|
297
|
-
accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
|
|
298
|
-
token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
|
|
299
|
-
checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
|
|
300
|
-
processes passes `replay:` to `Resource.new`, any object whose `first?(key, expires_in:)` answers true
|
|
301
|
-
only once for each key.
|
|
302
|
-
|
|
303
|
-
A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
|
|
304
|
-
accepted:
|
|
305
|
-
|
|
306
|
-
```ruby
|
|
307
|
-
response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
`resource.metadata` is the RFC 9728 document to serve at `/.well-known/oauth-protected-resource`.
|
|
311
|
-
Its `scope_descriptions` extension gives masks the sentences its consent screen shows for each
|
|
312
|
-
scope.
|
|
313
|
-
|
|
314
|
-
A Rack middleware does the same below the framework, proof included:
|
|
315
|
-
|
|
316
|
-
```ruby
|
|
317
|
-
use Masks::Client::Rack, resource: resource, scope: "xixo:catalog:read"
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
### Introspection
|
|
321
|
-
|
|
322
|
-
A resource server that only verifies the JWT sees a revocation when the token expires. Introspection
|
|
323
|
-
asks the issuer:
|
|
324
|
-
|
|
325
|
-
```ruby
|
|
326
|
-
found = session.introspect(token)
|
|
327
|
-
found.active? && found.permits?("xixo:catalog:read")
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
`Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
|
|
331
|
-
|
|
332
|
-
### Somebody else's account
|
|
333
|
-
|
|
334
|
-
An app that acts as somebody at Google, Microsoft, or an MCP server while they are away asks masks for
|
|
335
|
-
a [delegation](https://masks.pages.dev/guides/connecting-via-sdk/#delegation). masks keeps and refreshes the
|
|
336
|
-
provider's tokens. The app keeps one secret per connection and trades it for a live access token when
|
|
337
|
-
the last one runs out.
|
|
338
|
-
|
|
339
|
-
```ruby
|
|
340
|
-
delegations = Masks::Client.delegations(
|
|
341
|
-
ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
started = delegations.start(provider: "google")
|
|
345
|
-
session[:connecting] = started
|
|
346
|
-
redirect_to started["url"]
|
|
347
|
-
|
|
348
|
-
held = delegations.finish(params: params, started: session.delete(:connecting))
|
|
349
|
-
held.connection
|
|
350
|
-
held.secret
|
|
351
|
-
|
|
352
|
-
upstream = delegations.token(held.secret, connection: held.connection)
|
|
353
|
-
upstream.access_token
|
|
354
|
-
upstream.expires_at
|
|
355
|
-
upstream.secret
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
|
|
359
|
-
exchange with it.
|
|
360
|
-
|
|
361
|
-
`upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
|
|
362
|
-
has to connect again, and `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks
|
|
363
|
-
had already rotated it, so keep it. Spend a secret from one place at a time, because spending one
|
|
364
|
-
twice revokes it.
|
|
365
|
-
|
|
366
|
-
Tests use the fake, which needs no issuer:
|
|
367
|
-
|
|
368
|
-
```ruby
|
|
369
|
-
require "masks/client/delegations/fake"
|
|
370
|
-
|
|
371
|
-
fake = Masks::Client::Delegations::Fake.new
|
|
372
|
-
started = fake.start(provider: "notion")
|
|
373
|
-
held = fake.finish(params: fake.approve(started), started: started)
|
|
374
|
-
|
|
375
|
-
fake.token(held.secret, connection: held.connection)
|
|
376
|
-
fake.revoke(held.connection)
|
|
377
|
-
fake.unavailable(held.connection)
|
|
378
|
-
```
|
|
61
|
+
Keep `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
|
|
62
|
+
browser to `started[:url]`. When it comes back, check `state` and `identity["nonce"]` against the
|
|
63
|
+
values you kept.
|
|
379
64
|
|
|
380
65
|
## Documentation
|
|
381
66
|
|
|
382
|
-
|
|
383
|
-
|
|
67
|
+
- [Rails apps](https://masks.pages.dev/reference/ruby/) covers the handshake, privileges,
|
|
68
|
+
organizations, accepting tokens, and running several processes.
|
|
69
|
+
- [Masks::Client](https://masks.pages.dev/reference/ruby/client/) and
|
|
70
|
+
[Masks::Rails](https://masks.pages.dev/reference/ruby/rails/) list every class and setting.
|
|
384
71
|
|
|
385
72
|
## License
|
|
386
73
|
|
|
@@ -29,7 +29,7 @@ module Masks
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def destroy
|
|
32
|
-
return redirect_to(
|
|
32
|
+
return redirect_to(masks_after_sign_out_path) unless masks_signed_in?
|
|
33
33
|
return redirect_to(masks_handshake_path) unless masks_config.can_forget?
|
|
34
34
|
|
|
35
35
|
forget_upstream
|
|
@@ -67,7 +67,7 @@ module Masks
|
|
|
67
67
|
return unless masks_config.configured?(request)
|
|
68
68
|
return if masks_signed_in?
|
|
69
69
|
|
|
70
|
-
redirect_to
|
|
70
|
+
redirect_to masks_after_sign_out_path
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def require_unconfigured_or_manager
|
|
@@ -7,25 +7,28 @@ module Masks
|
|
|
7
7
|
return refuse("invalid_request", "logout_token is required") if params[:logout_token].blank?
|
|
8
8
|
|
|
9
9
|
logout = masks_session.logout_token(params[:logout_token])
|
|
10
|
+
issuer = masks_config.issuer_for(request).to_s
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
return refuse("invalid_request", "that logout token was already used") unless masks_cache.first_logout?(issuer, logout)
|
|
13
|
+
|
|
14
|
+
masks_cache.depart!(issuer, logout)
|
|
15
|
+
masks_config.logged_out!(request, logout)
|
|
12
16
|
|
|
13
17
|
response.headers["Cache-Control"] = "no-store"
|
|
14
18
|
|
|
15
|
-
head
|
|
16
|
-
rescue Masks::Client::InvalidToken => e
|
|
17
|
-
refuse("invalid_request", e.message)
|
|
18
|
-
rescue Masks::Rails::Configuration::Unconfigured => e
|
|
19
|
+
head :ok
|
|
20
|
+
rescue Masks::Client::InvalidToken, Masks::Rails::Configuration::Unconfigured => e
|
|
19
21
|
refuse("invalid_request", e.message)
|
|
22
|
+
rescue Masks::Client::Unreachable, Masks::Rails::Cache::Unavailable => e
|
|
23
|
+
refuse("temporarily_unavailable", e.message, status: :service_unavailable)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
private
|
|
23
27
|
|
|
24
|
-
def refuse(code, description)
|
|
28
|
+
def refuse(code, description, status: :bad_request)
|
|
25
29
|
response.headers["Cache-Control"] = "no-store"
|
|
26
30
|
|
|
27
|
-
render json: { "error" => code, "error_description" => description },
|
|
28
|
-
status: :bad_request
|
|
31
|
+
render json: { "error" => code, "error_description" => description }, status: status
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
end
|
|
@@ -3,6 +3,9 @@ module Masks
|
|
|
3
3
|
class SessionsController < BaseController
|
|
4
4
|
RESTARTED = "masks_restarted_at".freeze
|
|
5
5
|
RESTART_WITHIN = 60
|
|
6
|
+
SAME_ORIGIN = %w[same-origin none].freeze
|
|
7
|
+
|
|
8
|
+
before_action :refuse_cross_site, only: :destroy
|
|
6
9
|
|
|
7
10
|
def show
|
|
8
11
|
if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
|
|
@@ -10,7 +13,7 @@ module Masks
|
|
|
10
13
|
|
|
11
14
|
render json: masked.to_h
|
|
12
15
|
else
|
|
13
|
-
masks_forget
|
|
16
|
+
masks_forget unless masks_configured?
|
|
14
17
|
masks_refuse_json
|
|
15
18
|
end
|
|
16
19
|
end
|
|
@@ -40,7 +43,8 @@ module Masks
|
|
|
40
43
|
def callback
|
|
41
44
|
pending = masks_requests.claim(params[:state])
|
|
42
45
|
|
|
43
|
-
return refuse(params[:error], params[:error_description], pending) if params[:error].present?
|
|
46
|
+
return refuse(params[:error], params[:error_description], pending) if params[:error].present? && pending
|
|
47
|
+
return unstarted if params[:error].present?
|
|
44
48
|
return stale if pending.nil?
|
|
45
49
|
|
|
46
50
|
tokens = masks_session.complete(
|
|
@@ -73,24 +77,52 @@ module Masks
|
|
|
73
77
|
|
|
74
78
|
def destroy
|
|
75
79
|
everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
|
|
76
|
-
upstream = everywhere ? masks_logout_url : nil
|
|
77
80
|
|
|
78
81
|
revoke_refresh_token
|
|
79
82
|
masks_forget
|
|
80
83
|
reset_session
|
|
81
84
|
|
|
85
|
+
upstream = everywhere ? masks_sign_out_upstream : nil
|
|
86
|
+
|
|
82
87
|
if masks_wants_json?
|
|
83
88
|
render json: { "signed_in" => false, "logout_url" => upstream }.compact
|
|
84
89
|
else
|
|
85
|
-
redirect_to upstream ||
|
|
90
|
+
redirect_to upstream || masks_after_sign_out_path, allow_other_host: upstream.present?
|
|
86
91
|
end
|
|
87
92
|
end
|
|
88
93
|
|
|
94
|
+
def signed_out
|
|
95
|
+
return stale_sign_out if masks_farewells.claim(params[:state].to_s).nil?
|
|
96
|
+
|
|
97
|
+
redirect_to masks_after_sign_out_path
|
|
98
|
+
end
|
|
99
|
+
|
|
89
100
|
private
|
|
90
101
|
|
|
102
|
+
def refuse_cross_site
|
|
103
|
+
return if same_origin?
|
|
104
|
+
|
|
105
|
+
answer("invalid_request", "that request came from another site", :forbidden, failed: "sign-out")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def same_origin?
|
|
109
|
+
site = request.headers["Sec-Fetch-Site"].to_s
|
|
110
|
+
|
|
111
|
+
return false unless site.empty? || SAME_ORIGIN.include?(site)
|
|
112
|
+
|
|
113
|
+
sent = request.headers["Origin"].to_s
|
|
114
|
+
|
|
115
|
+
sent.empty? || [ request.base_url, own_origin ].include?(sent)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def own_origin
|
|
119
|
+
URI.join(masks_config.redirect_uri_for(request), "/").to_s.chomp("/")
|
|
120
|
+
end
|
|
121
|
+
|
|
91
122
|
def renew_session
|
|
92
123
|
carried = session.to_hash.slice(REQUESTS, HANDSHAKES, Masks::Rails::Authentication::STEPPED_UP)
|
|
93
124
|
|
|
125
|
+
masks_forget
|
|
94
126
|
reset_session
|
|
95
127
|
carried.each { |key, value| session[key] = value }
|
|
96
128
|
end
|
|
@@ -129,6 +161,16 @@ module Masks
|
|
|
129
161
|
)
|
|
130
162
|
end
|
|
131
163
|
|
|
164
|
+
def unstarted
|
|
165
|
+
answer("invalid_state", "masks refused a sign-in that this browser did not start, or one that expired",
|
|
166
|
+
:bad_request)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def stale_sign_out
|
|
170
|
+
answer("invalid_state", "that sign-out is not one this browser started, or it expired", :bad_request,
|
|
171
|
+
failed: "sign-out")
|
|
172
|
+
end
|
|
173
|
+
|
|
132
174
|
def restart
|
|
133
175
|
session[RESTARTED] = Time.now.to_i
|
|
134
176
|
redirect_to start_path
|
|
@@ -144,11 +186,11 @@ module Masks
|
|
|
144
186
|
answer(code, description, :bad_request)
|
|
145
187
|
end
|
|
146
188
|
|
|
147
|
-
def answer(code, description, status)
|
|
189
|
+
def answer(code, description, status, failed: "sign-in")
|
|
148
190
|
if masks_wants_json?
|
|
149
191
|
render json: { "error" => code, "error_description" => description }, status: status
|
|
150
192
|
else
|
|
151
|
-
render plain: "
|
|
193
|
+
render plain: "#{failed} failed — #{code}: #{description}", status: status
|
|
152
194
|
end
|
|
153
195
|
end
|
|
154
196
|
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
|
+
get "/logout/return", to: "sessions#signed_out", as: :logout_return
|
|
7
8
|
post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
|
|
8
9
|
|
|
9
10
|
get "/handshake", to: "handshakes#show", as: :handshake
|