masks 0.3.2 → 0.5.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 +16 -0
- data/LICENSE +21 -0
- data/README.md +215 -29
- data/app/controllers/masks/rails/base_controller.rb +13 -0
- data/app/controllers/masks/rails/handshakes_controller.rb +87 -0
- data/app/controllers/masks/rails/sessions_controller.rb +113 -0
- data/app/views/layouts/masks/rails/plain.html.erb +47 -0
- data/app/views/masks/rails/handshakes/refused.html.erb +12 -0
- data/app/views/masks/rails/handshakes/show.html.erb +40 -0
- data/config/routes.rb +10 -45
- data/lib/generators/masks/install/install_generator.rb +35 -22
- data/lib/generators/masks/install/templates/masks.rb.tt +38 -0
- data/lib/masks/client/claims.rb +103 -0
- data/lib/masks/client/errors.rb +52 -0
- data/lib/masks/client/handshake.rb +104 -0
- data/lib/masks/client/http.rb +81 -0
- data/lib/masks/client/introspection.rb +29 -0
- data/lib/masks/client/issuer.rb +76 -0
- data/lib/masks/client/pkce.rb +27 -0
- data/lib/masks/client/rack.rb +62 -0
- data/lib/masks/client/registration.rb +91 -0
- data/lib/masks/client/registry.rb +25 -0
- data/lib/masks/client/resource.rb +92 -0
- data/lib/masks/client/session.rb +146 -0
- data/lib/masks/client/stores.rb +40 -0
- data/lib/masks/client/tokens.rb +70 -0
- data/lib/masks/client/tracker.rb +122 -0
- data/lib/masks/client/verifier.rb +42 -0
- data/lib/masks/client.rb +51 -0
- data/lib/masks/rails/authentication.rb +217 -0
- data/lib/masks/rails/configurable.rb +11 -0
- data/lib/masks/rails/configuration.rb +172 -0
- data/lib/masks/rails/credentials.rb +60 -0
- data/lib/masks/rails/engine.rb +20 -0
- data/lib/masks/rails/protected_resource.rb +51 -0
- data/lib/masks/rails.rb +10 -0
- data/lib/masks/version.rb +1 -3
- data/lib/masks.rb +3 -182
- metadata +47 -368
- data/MIT-LICENSE +0 -20
- data/Rakefile +0 -11
- data/app/assets/builds/application.css +0 -4764
- data/app/assets/builds/application.js +0 -8236
- data/app/assets/builds/application.js.map +0 -7
- data/app/assets/builds/masks/application.css +0 -1
- data/app/assets/builds/masks/application.js +0 -8462
- data/app/assets/builds/masks/application.js.map +0 -7
- data/app/assets/images/masks.png +0 -0
- data/app/assets/javascripts/application.js +0 -2
- data/app/assets/javascripts/controllers/application.js +0 -9
- data/app/assets/javascripts/controllers/emails_controller.js +0 -28
- data/app/assets/javascripts/controllers/index.js +0 -12
- data/app/assets/javascripts/controllers/keys_controller.js +0 -20
- data/app/assets/javascripts/controllers/recover_controller.js +0 -21
- data/app/assets/javascripts/controllers/recover_password_controller.js +0 -21
- data/app/assets/javascripts/controllers/session_controller.js +0 -94
- data/app/assets/manifest.js +0 -2
- data/app/assets/masks_manifest.js +0 -2
- data/app/assets/stylesheets/application.css +0 -26
- data/app/controllers/concerns/masks/controller.rb +0 -114
- data/app/controllers/masks/actors_controller.rb +0 -15
- data/app/controllers/masks/application_controller.rb +0 -35
- data/app/controllers/masks/backup_codes_controller.rb +0 -34
- data/app/controllers/masks/debug_controller.rb +0 -9
- data/app/controllers/masks/devices_controller.rb +0 -20
- data/app/controllers/masks/emails_controller.rb +0 -60
- data/app/controllers/masks/error_controller.rb +0 -14
- data/app/controllers/masks/keys_controller.rb +0 -45
- data/app/controllers/masks/manage/actor_controller.rb +0 -35
- data/app/controllers/masks/manage/actors_controller.rb +0 -12
- data/app/controllers/masks/manage/base_controller.rb +0 -12
- data/app/controllers/masks/one_time_code_controller.rb +0 -49
- data/app/controllers/masks/passwords_controller.rb +0 -33
- data/app/controllers/masks/recoveries_controller.rb +0 -43
- data/app/controllers/masks/sessions_controller.rb +0 -53
- data/app/helpers/masks/application_helper.rb +0 -49
- data/app/jobs/masks/application_job.rb +0 -7
- data/app/jobs/masks/expire_actors_job.rb +0 -15
- data/app/jobs/masks/expire_recoveries_job.rb +0 -15
- data/app/mailers/masks/actor_mailer.rb +0 -22
- data/app/mailers/masks/application_mailer.rb +0 -15
- data/app/models/concerns/masks/access.rb +0 -162
- data/app/models/concerns/masks/actor.rb +0 -132
- data/app/models/concerns/masks/adapter.rb +0 -68
- data/app/models/concerns/masks/role.rb +0 -9
- data/app/models/concerns/masks/scoped.rb +0 -54
- data/app/models/masks/access/actor_password.rb +0 -20
- data/app/models/masks/access/actor_scopes.rb +0 -18
- data/app/models/masks/access/actor_signup.rb +0 -22
- data/app/models/masks/actors/anonymous.rb +0 -40
- data/app/models/masks/actors/system.rb +0 -24
- data/app/models/masks/adapters/active_record.rb +0 -85
- data/app/models/masks/application_model.rb +0 -15
- data/app/models/masks/application_record.rb +0 -8
- data/app/models/masks/check.rb +0 -192
- data/app/models/masks/credential.rb +0 -166
- data/app/models/masks/credentials/backup_code.rb +0 -30
- data/app/models/masks/credentials/device.rb +0 -59
- data/app/models/masks/credentials/email.rb +0 -48
- data/app/models/masks/credentials/factor2.rb +0 -71
- data/app/models/masks/credentials/key.rb +0 -38
- data/app/models/masks/credentials/last_login.rb +0 -12
- data/app/models/masks/credentials/masquerade.rb +0 -32
- data/app/models/masks/credentials/nickname.rb +0 -63
- data/app/models/masks/credentials/one_time_code.rb +0 -34
- data/app/models/masks/credentials/password.rb +0 -28
- data/app/models/masks/credentials/recovery.rb +0 -71
- data/app/models/masks/credentials/session.rb +0 -67
- data/app/models/masks/device.rb +0 -30
- data/app/models/masks/error.rb +0 -51
- data/app/models/masks/event.rb +0 -14
- data/app/models/masks/mask.rb +0 -255
- data/app/models/masks/rails/actor.rb +0 -190
- data/app/models/masks/rails/actor_role.rb +0 -12
- data/app/models/masks/rails/device.rb +0 -47
- data/app/models/masks/rails/email.rb +0 -96
- data/app/models/masks/rails/key.rb +0 -61
- data/app/models/masks/rails/recovery.rb +0 -116
- data/app/models/masks/rails/role.rb +0 -20
- data/app/models/masks/rails/scope.rb +0 -15
- data/app/models/masks/session.rb +0 -447
- data/app/models/masks/sessions/access.rb +0 -26
- data/app/models/masks/sessions/inline.rb +0 -16
- data/app/models/masks/sessions/request.rb +0 -42
- data/app/resources/masks/actor_resource.rb +0 -9
- data/app/resources/masks/session_resource.rb +0 -15
- data/app/views/layouts/masks/application.html.erb +0 -17
- data/app/views/layouts/masks/mailer.html.erb +0 -17
- data/app/views/layouts/masks/mailer.text.erb +0 -1
- data/app/views/layouts/masks/manage.html.erb +0 -25
- data/app/views/masks/actor_mailer/recover_credentials.html.erb +0 -33
- data/app/views/masks/actor_mailer/recover_credentials.text.erb +0 -1
- data/app/views/masks/actor_mailer/verify_email.html.erb +0 -34
- data/app/views/masks/actor_mailer/verify_email.text.erb +0 -8
- data/app/views/masks/actors/current.html.erb +0 -152
- data/app/views/masks/application/_header.html.erb +0 -31
- data/app/views/masks/backup_codes/new.html.erb +0 -103
- data/app/views/masks/emails/new.html.erb +0 -103
- data/app/views/masks/emails/verify.html.erb +0 -51
- data/app/views/masks/keys/new.html.erb +0 -127
- data/app/views/masks/manage/actor/show.html.erb +0 -126
- data/app/views/masks/manage/actors/index.html.erb +0 -40
- data/app/views/masks/one_time_code/new.html.erb +0 -150
- data/app/views/masks/passwords/edit.html.erb +0 -58
- data/app/views/masks/recoveries/new.html.erb +0 -71
- data/app/views/masks/recoveries/password.html.erb +0 -64
- data/app/views/masks/sessions/new.html.erb +0 -153
- data/config/brakeman.ignore +0 -28
- data/config/locales/en.yml +0 -286
- data/db/migrate/20231205173845_create_actors.rb +0 -94
- data/lib/generators/masks/install/USAGE +0 -8
- data/lib/generators/masks/install/templates/initializer.rb +0 -5
- data/lib/generators/masks/install/templates/masks.json +0 -6
- data/lib/masks/configuration.rb +0 -236
- data/lib/masks/engine.rb +0 -25
- data/lib/masks/middleware.rb +0 -70
- data/lib/tasks/masks_tasks.rake +0 -72
- data/masks.json +0 -277
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fab2f5044bc29a407688ac0c61ac703108849a24579a7aa2e30e18234d6e02f0
|
|
4
|
+
data.tar.gz: 64572497372118a8ac2677634c26349c53cf93e4850188ceecaa2e2610d18546
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea4c88b70e03f9feb1a98886c1a04ae0f913f4c5a7de609b95215b12901b7530d9017d2f154c21c5893e7a10282e3bf8cd7ca005a068bab0aac1fe9b45139440
|
|
7
|
+
data.tar.gz: d4c0af7c25dbc3bc380b4793f82c5c172a4bfe3b2bff150310e4b9689d1e9c7f07a0be1833cd406c16dec0fce60f30032b95b4466dc93bd46059b0714ec0096d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.5.0 — unreleased
|
|
4
|
+
|
|
5
|
+
First release carrying the consumer half in full. 0.4.0 predates the split described in
|
|
6
|
+
`plans/019` and should not be used.
|
|
7
|
+
|
|
8
|
+
- Discovery, PKCE authorization, token exchange, and token verification against a masks issuer.
|
|
9
|
+
- Rack middleware for a resource server.
|
|
10
|
+
- A Rails engine, mounted by the consuming app, covering the consumer half of the code flow.
|
|
11
|
+
- `rails generate masks:install`.
|
|
12
|
+
- `config.store` falls back to a default credential store instead of raising.
|
|
13
|
+
|
|
14
|
+
## 0.4.0
|
|
15
|
+
|
|
16
|
+
Withdrawn. An earlier design, published 2024-04-11 and marked "DO NOT USE".
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 geiger.to
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,50 +1,236 @@
|
|
|
1
1
|
# masks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks) issuer.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```ruby
|
|
6
|
+
gem "masks"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Three parts, in one gem. **Consumers spend tokens. Resource servers accept them.** Most
|
|
10
|
+
client libraries only build the first, so everything above the second gets written by
|
|
11
|
+
hand in each resource server — its scope check, its `WWW-Authenticate` header, its
|
|
12
|
+
metadata document. Both halves are here, and a Rails engine that mounts them.
|
|
13
|
+
|
|
14
|
+
The engine loads only when Rails does. A Sinatra, Hanami or plain-Rack app requiring
|
|
15
|
+
this gem pulls in `jwt` and nothing else.
|
|
16
|
+
|
|
17
|
+
## Rails
|
|
6
18
|
|
|
7
|
-
|
|
19
|
+
One command to install, and the app is connected by somebody approving it — no client
|
|
20
|
+
id to copy, no secret to paste anywhere.
|
|
8
21
|
|
|
9
22
|
```
|
|
10
|
-
|
|
23
|
+
bin/rails generate masks:install
|
|
11
24
|
```
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
That mounts the engine at `/auth`, writes `config/initializers/masks.rb`, and gitignores
|
|
27
|
+
the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
|
|
28
|
+
`/auth/handshake`.
|
|
14
29
|
|
|
30
|
+
### The handshake
|
|
31
|
+
|
|
32
|
+
A first-party app must not self-register anonymously — that is how a stranger's
|
|
33
|
+
connector also arrives. So an unconnected app is offered one button, the browser goes to
|
|
34
|
+
its own issuer's approval screen, and the one-time token that comes back is redeemed
|
|
35
|
+
server-side. **The secret never travels through the browser and nobody types it
|
|
36
|
+
anywhere.**
|
|
37
|
+
|
|
38
|
+
The engine writes what comes back to `config/masks.json`, mode 600. An app that wants
|
|
39
|
+
somewhere else says so:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
config.credentials = ->(request) { Tenant.for(request).masks_credentials }
|
|
43
|
+
config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
|
|
15
44
|
```
|
|
16
|
-
|
|
45
|
+
|
|
46
|
+
Those two lambdas are the whole integration for a multi-tenant app.
|
|
47
|
+
|
|
48
|
+
### Signing in
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class ThingsController < ApplicationController
|
|
52
|
+
include Masks::Rails::Authentication
|
|
53
|
+
|
|
54
|
+
before_action :authenticate_masks!
|
|
55
|
+
|
|
56
|
+
def index
|
|
57
|
+
@who = masks_identity
|
|
58
|
+
end
|
|
59
|
+
end
|
|
17
60
|
```
|
|
18
61
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
62
|
+
`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 — this is the
|
|
64
|
+
backend-for-frontend pattern, and it is the default because an SPA holding a token is an
|
|
65
|
+
SPA where XSS lifts one.
|
|
22
66
|
|
|
23
|
-
|
|
67
|
+
The include is **not** blanket. `config.authenticate_everything = true` puts it on every
|
|
68
|
+
controller if that is what you want; otherwise include it where you mean it.
|
|
24
69
|
|
|
25
|
-
|
|
70
|
+
### An SPA in front of it
|
|
26
71
|
|
|
27
|
-
|
|
72
|
+
`GET /auth/session` answers identity, tenant and scopes as JSON, or `401` with somewhere
|
|
73
|
+
to send the browser. [`@masks/client`](../web) speaks it:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { createSession } from "@masks/client"
|
|
77
|
+
|
|
78
|
+
const session = createSession()
|
|
79
|
+
const status = await session.status()
|
|
80
|
+
```
|
|
28
81
|
|
|
29
|
-
|
|
82
|
+
`status()` answers one of three things, and the third is why it exists: `signed_in`,
|
|
83
|
+
`signed_out` with where to sign in, and `handshake_required` with where to go instead —
|
|
84
|
+
because an app nobody has connected must not offer a sign-in button that leads to an
|
|
85
|
+
error page at the issuer.
|
|
30
86
|
|
|
31
|
-
|
|
32
|
-
- **Actors** access resources via sessions (if allowed by the mask)
|
|
33
|
-
- **Sessions** keep track of attempts to access resources
|
|
34
|
-
- **Credentials** identify and check actors' access using session data
|
|
87
|
+
### Accepting tokens
|
|
35
88
|
|
|
36
|
-
|
|
37
|
-
and check credentials, leaving your application to defining the _masks_ required
|
|
38
|
-
for it to function. typically all you need is a `masks.json` file, but you can
|
|
39
|
-
also build custom credentials, checks, models, and more...
|
|
89
|
+
An app that is also a resource server:
|
|
40
90
|
|
|
41
|
-
|
|
91
|
+
```ruby
|
|
92
|
+
class ApiController < ApplicationController
|
|
93
|
+
include Masks::Rails::ProtectedResource
|
|
42
94
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"name": "example",
|
|
46
|
-
"url": "http://example.com",
|
|
47
|
-
"logo": "...",
|
|
48
|
-
"extend": "masks"
|
|
49
|
-
}
|
|
95
|
+
before_action { masks_protect!(scope: "things:read") }
|
|
96
|
+
end
|
|
50
97
|
```
|
|
98
|
+
|
|
99
|
+
`masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
|
|
100
|
+
`resource_metadata`, so a client handed nothing but a URL can find its way to the issuer
|
|
101
|
+
and back.
|
|
102
|
+
|
|
103
|
+
### Signing out
|
|
104
|
+
|
|
105
|
+
`DELETE /auth/logout` ends this app's session. Add `?everywhere=1` and the response
|
|
106
|
+
carries `logout_url` — the issuer's end-session endpoint — so signing out can mean
|
|
107
|
+
signing out. `config.sign_out_of_issuer = true` makes that the default for every
|
|
108
|
+
sign-out.
|
|
109
|
+
|
|
110
|
+
### Configuration
|
|
111
|
+
|
|
112
|
+
Every value that varies per request accepts a callable taking the request, which is what
|
|
113
|
+
a subdomain-per-tenant host needs.
|
|
114
|
+
|
|
115
|
+
| | |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `issuer` | the masks issuer this app signs in against |
|
|
118
|
+
| `resource` | this app's own identifier, when it also accepts tokens |
|
|
119
|
+
| `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
|
|
120
|
+
| `scope` | what to ask the issuer for; defaults to `openid profile email` |
|
|
121
|
+
| `credentials` / `store` | where the handshake's result lives |
|
|
122
|
+
| `credentials_path` | where the default store writes; `config/masks.json` |
|
|
123
|
+
| `after_sign_in` / `after_sign_out` | paths on this host |
|
|
124
|
+
| `parent_controller` | what the engine's pages inherit, for your layout |
|
|
125
|
+
| `authenticate_everything` | include `Authentication` on every controller |
|
|
126
|
+
| `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
|
|
127
|
+
| `session_key` | the session key the tokens live under |
|
|
128
|
+
|
|
129
|
+
## Any Ruby app
|
|
130
|
+
|
|
131
|
+
Everything above is `Masks::Client` underneath, and it is usable directly — the engine
|
|
132
|
+
holds no protocol of its own.
|
|
133
|
+
|
|
134
|
+
### The consumer half
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
session = Masks::Client::Session.new(
|
|
138
|
+
issuer: "https://jons.auth.example.com",
|
|
139
|
+
client_id: id, client_secret: secret,
|
|
140
|
+
redirect_uri: "https://app.example.com/auth/callback"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
started = session.start(resource: "https://app.example.com/mcp")
|
|
144
|
+
# hold started[:state], started[:nonce] and started[:verifier]; send the
|
|
145
|
+
# browser to started[:url]
|
|
146
|
+
|
|
147
|
+
tokens = session.complete(code: params[:code], verifier: held[:verifier])
|
|
148
|
+
identity = session.identity(tokens)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`start` sends a nonce only when `openid` was asked for, so holding one means an id token
|
|
152
|
+
is owed — which is what lets the check on the way back be exact rather than vacuously
|
|
153
|
+
true.
|
|
154
|
+
|
|
155
|
+
Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
|
|
156
|
+
|
|
157
|
+
Discovery and JWKS are cached for five minutes, with invalidate-and-refetch on an unknown
|
|
158
|
+
`kid`, so a key rotation is picked up without a restart. The cache is real only if the
|
|
159
|
+
`Issuer` is: use `Masks::Client::Issuer.resolve`, or the registry behind it, rather than
|
|
160
|
+
constructing one per request.
|
|
161
|
+
|
|
162
|
+
### The handshake
|
|
163
|
+
|
|
164
|
+
The flow that connects a first-party app, rather than two helpers and sixty lines of
|
|
165
|
+
state in every consumer:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
handshake = Masks::Client::Handshake.new(
|
|
169
|
+
issuer, name: "things", resource: "https://app.example.com/mcp",
|
|
170
|
+
redirect_uris: [ "https://app.example.com/auth/callback" ],
|
|
171
|
+
return_to: "https://app.example.com/"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
started = handshake.start # hold started[:state]; send the browser on
|
|
175
|
+
registration = handshake.complete(params, state: held)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`complete` refuses an `error`, a `state` that does not match this browser, and an `iss`
|
|
179
|
+
that is not the issuer it asked — each **before** anything is redeemed.
|
|
180
|
+
|
|
181
|
+
### The resource-server half
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
resource = Masks::Client::Resource.new(
|
|
185
|
+
issuer: "https://jons.auth.example.com",
|
|
186
|
+
url: "https://app.example.com/mcp",
|
|
187
|
+
scopes: { "things:read" => "Search your catalog" }
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
claims = resource.authenticate(request.authorization, scope: "things:read")
|
|
191
|
+
claims.subject
|
|
192
|
+
claims.tenant.subdomain
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`authenticate` answers `Claims` or raises. A refusal builds the RFC 6750 challenge
|
|
196
|
+
carrying `resource_metadata` and the scopes it would have accepted:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`resource.metadata` is the RFC 9728 document to serve at
|
|
203
|
+
`/.well-known/oauth-protected-resource`. The `scope_descriptions` extension in it is how
|
|
204
|
+
an auth server renders your scopes as sentences on its consent screen — it has no other
|
|
205
|
+
way to know what `things:read` means.
|
|
206
|
+
|
|
207
|
+
There is a Rack middleware for consumers that want the challenge below the framework:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
use Masks::Client::Rack, resource: resource, scope: "things:read"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Introspection
|
|
214
|
+
|
|
215
|
+
A resource server doing JWT-only validation cannot see a revocation until the token
|
|
216
|
+
expires. Asking is the honest answer:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
found = session.introspect(token)
|
|
220
|
+
found.active? && found.permits?("things:read")
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
|
|
224
|
+
not active, so switching from local verification to asking does not mean remembering to
|
|
225
|
+
check a boolean.
|
|
226
|
+
|
|
227
|
+
## Which issuers this speaks to
|
|
228
|
+
|
|
229
|
+
masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
|
|
230
|
+
least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
|
|
231
|
+
it. An older issuer is refused with a sentence saying so, rather than at the first screen
|
|
232
|
+
anybody touches.
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Rails
|
|
3
|
+
class HandshakesController < BaseController
|
|
4
|
+
before_action :require_unconfigured_or_signed_in
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
@connected = masks_config.configured?(request)
|
|
8
|
+
@can_disconnect = @connected && masks_config.can_forget?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
pending = masks_handshakes.open
|
|
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)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def callback
|
|
21
|
+
pending = masks_handshakes.claim(params[:state])
|
|
22
|
+
|
|
23
|
+
return stale if pending.nil?
|
|
24
|
+
|
|
25
|
+
registration = masks_config.handshake_for(request).complete(returned, state: pending.id)
|
|
26
|
+
|
|
27
|
+
masks_config.store!(request, registration)
|
|
28
|
+
|
|
29
|
+
redirect_to Masks::Rails::Engine.routes.url_helpers.start_path
|
|
30
|
+
rescue Masks::Client::Error => e
|
|
31
|
+
refuse(e)
|
|
32
|
+
end
|
|
33
|
+
|
|
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
|
+
def destroy
|
|
39
|
+
return redirect_to(masks_config.after_sign_out) unless masks_signed_in?
|
|
40
|
+
return redirect_to(masks_handshake_path) unless masks_config.can_forget?
|
|
41
|
+
|
|
42
|
+
masks_registration&.delete
|
|
43
|
+
|
|
44
|
+
masks_config.forget!(request)
|
|
45
|
+
masks_forget
|
|
46
|
+
|
|
47
|
+
redirect_to masks_handshake_path
|
|
48
|
+
rescue Masks::Client::Error => e
|
|
49
|
+
refuse(e)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def masks_registration
|
|
55
|
+
Masks::Client::Registration.held(
|
|
56
|
+
masks_config.issuer_for(request),
|
|
57
|
+
masks_config.credentials_for(request)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def returned
|
|
62
|
+
params.permit(:initial_access_token, :iss, :state, :error, :error_description).to_h
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def require_unconfigured_or_signed_in
|
|
66
|
+
return unless masks_config.configured?(request)
|
|
67
|
+
return if masks_signed_in?
|
|
68
|
+
|
|
69
|
+
redirect_to masks_config.after_sign_out
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stale
|
|
73
|
+
@code = "invalid_state"
|
|
74
|
+
@description = "that connection request is not one this browser started, or it expired"
|
|
75
|
+
|
|
76
|
+
render :refused, status: :bad_request
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def refuse(error)
|
|
80
|
+
@code = error.try(:code).presence || error.class.name.demodulize.underscore
|
|
81
|
+
@description = error.try(:description).presence || error.message
|
|
82
|
+
|
|
83
|
+
render :refused, status: :bad_request
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Rails
|
|
3
|
+
class SessionsController < BaseController
|
|
4
|
+
def show
|
|
5
|
+
if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
|
|
6
|
+
response.headers["Cache-Control"] = "no-store"
|
|
7
|
+
|
|
8
|
+
render json: masks_account
|
|
9
|
+
else
|
|
10
|
+
masks_forget
|
|
11
|
+
masks_refuse_json
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start
|
|
16
|
+
return redirect_to(masks_handshake_path) unless masks_configured?
|
|
17
|
+
|
|
18
|
+
pending = masks_requests.open(
|
|
19
|
+
return_to: requested_return_to || session.delete(:masks_return_to)
|
|
20
|
+
)
|
|
21
|
+
started = masks_session.start(
|
|
22
|
+
state: pending.id,
|
|
23
|
+
resource: masks_config.resource_for(request)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
masks_requests.amend(
|
|
27
|
+
pending.id, nonce: started[:nonce], verifier: started[:verifier]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
redirect_to started[:url], allow_other_host: true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def callback
|
|
34
|
+
pending = masks_requests.claim(params[:state])
|
|
35
|
+
|
|
36
|
+
return stale if pending.nil?
|
|
37
|
+
return refuse(params[:error], params[:error_description], pending) if params[:error].present?
|
|
38
|
+
|
|
39
|
+
tokens = masks_session.complete(
|
|
40
|
+
code: params[:code],
|
|
41
|
+
verifier: pending[:verifier],
|
|
42
|
+
resource: masks_config.resource_for(request)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
identity = begin
|
|
46
|
+
masks_identity_from(tokens)
|
|
47
|
+
rescue Masks::Client::InvalidToken
|
|
48
|
+
:unverified
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unless nonce_matches?(identity, pending[:nonce])
|
|
52
|
+
return refuse("invalid_nonce", "the id token was issued for another request", pending)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
masks_store(tokens, identity: identity)
|
|
56
|
+
|
|
57
|
+
redirect_to pending[:return_to] || masks_config.after_sign_in
|
|
58
|
+
rescue Masks::Client::Error => e
|
|
59
|
+
refuse(e.class.name.demodulize.underscore, e.message, pending)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def destroy
|
|
63
|
+
everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
|
|
64
|
+
upstream = everywhere ? masks_logout_url : nil
|
|
65
|
+
|
|
66
|
+
masks_forget
|
|
67
|
+
|
|
68
|
+
if masks_wants_json?
|
|
69
|
+
render json: { "signed_in" => false, "logout_url" => upstream }.compact
|
|
70
|
+
else
|
|
71
|
+
redirect_to upstream || masks_config.after_sign_out, allow_other_host: upstream.present?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def requested_return_to
|
|
78
|
+
masks_local_path(params[:return_to])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def nonce_matches?(identity, sent)
|
|
82
|
+
return false if identity == :unverified
|
|
83
|
+
return true if sent.blank?
|
|
84
|
+
return false unless identity.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
held = identity["nonce"].to_s
|
|
87
|
+
held.present? && ActiveSupport::SecurityUtils.secure_compare(held, sent)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stale
|
|
91
|
+
answer(
|
|
92
|
+
"invalid_state",
|
|
93
|
+
"that sign-in request is not one this browser started, or it expired",
|
|
94
|
+
:bad_request
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def refuse(code, description, pending = nil)
|
|
99
|
+
masks_forget if pending
|
|
100
|
+
|
|
101
|
+
answer(code, description, :bad_request)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def answer(code, description, status)
|
|
105
|
+
if masks_wants_json?
|
|
106
|
+
render json: { "error" => code, "error_description" => description }, status: status
|
|
107
|
+
else
|
|
108
|
+
render plain: "sign-in failed — #{code}: #{description}", status: status
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= yield :title %></title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: light dark; }
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
display: grid;
|
|
14
|
+
place-items: center;
|
|
15
|
+
padding: 2rem 1rem;
|
|
16
|
+
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
+
background: Canvas;
|
|
18
|
+
color: CanvasText;
|
|
19
|
+
}
|
|
20
|
+
main { width: 100%; max-width: 34rem; display: flex; flex-direction: column; gap: 1rem; }
|
|
21
|
+
h1 { margin: 0; font-size: 1.5rem; line-height: 1.25; }
|
|
22
|
+
p { margin: 0; }
|
|
23
|
+
.muted { opacity: 0.75; font-size: 0.9375rem; }
|
|
24
|
+
.box {
|
|
25
|
+
border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
|
|
26
|
+
border-radius: 0.5rem;
|
|
27
|
+
padding: 0.5rem 0.75rem;
|
|
28
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
29
|
+
font-size: 0.8125rem;
|
|
30
|
+
word-break: break-all;
|
|
31
|
+
}
|
|
32
|
+
button {
|
|
33
|
+
font: inherit;
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
padding: 0.625rem 1rem;
|
|
36
|
+
border: 0;
|
|
37
|
+
border-radius: 0.5rem;
|
|
38
|
+
background: AccentColor;
|
|
39
|
+
color: AccentColorText;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<main><%= yield %></main>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<% content_for :title, "This app could not be connected" %>
|
|
2
|
+
|
|
3
|
+
<h1>This app could not be connected</h1>
|
|
4
|
+
|
|
5
|
+
<p class="muted">
|
|
6
|
+
Nothing was connected and nothing was shared. The server that signs people in
|
|
7
|
+
refused, or answered something this app will not accept.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p class="box"><%= @code %>: <%= @description %></p>
|
|
11
|
+
|
|
12
|
+
<p class="muted"><a href="<%= handshake_path %>">Try again</a></p>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<% if @connected %>
|
|
2
|
+
<% content_for :title, "Reconnect this app" %>
|
|
3
|
+
|
|
4
|
+
<h1>This app is connected</h1>
|
|
5
|
+
|
|
6
|
+
<p class="muted">
|
|
7
|
+
Reconnecting replaces the credentials it holds now with a fresh pair. It is
|
|
8
|
+
how you rotate them, and it is the only way to move this app to a different
|
|
9
|
+
account. Nothing is typed in and no secret travels through your browser.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p class="muted">
|
|
13
|
+
It will stop working between the approval and the moment it picks the new
|
|
14
|
+
credentials up.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<%= form_with url: handshake_path, method: :post do %>
|
|
18
|
+
<button type="submit">Reconnect it</button>
|
|
19
|
+
<% end %>
|
|
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>
|
|
36
|
+
|
|
37
|
+
<%= form_with url: handshake_path, method: :post do %>
|
|
38
|
+
<button type="submit">Connect it</button>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|