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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE +21 -0
  4. data/README.md +215 -29
  5. data/app/controllers/masks/rails/base_controller.rb +13 -0
  6. data/app/controllers/masks/rails/handshakes_controller.rb +87 -0
  7. data/app/controllers/masks/rails/sessions_controller.rb +113 -0
  8. data/app/views/layouts/masks/rails/plain.html.erb +47 -0
  9. data/app/views/masks/rails/handshakes/refused.html.erb +12 -0
  10. data/app/views/masks/rails/handshakes/show.html.erb +40 -0
  11. data/config/routes.rb +10 -45
  12. data/lib/generators/masks/install/install_generator.rb +35 -22
  13. data/lib/generators/masks/install/templates/masks.rb.tt +38 -0
  14. data/lib/masks/client/claims.rb +103 -0
  15. data/lib/masks/client/errors.rb +52 -0
  16. data/lib/masks/client/handshake.rb +104 -0
  17. data/lib/masks/client/http.rb +81 -0
  18. data/lib/masks/client/introspection.rb +29 -0
  19. data/lib/masks/client/issuer.rb +76 -0
  20. data/lib/masks/client/pkce.rb +27 -0
  21. data/lib/masks/client/rack.rb +62 -0
  22. data/lib/masks/client/registration.rb +91 -0
  23. data/lib/masks/client/registry.rb +25 -0
  24. data/lib/masks/client/resource.rb +92 -0
  25. data/lib/masks/client/session.rb +146 -0
  26. data/lib/masks/client/stores.rb +40 -0
  27. data/lib/masks/client/tokens.rb +70 -0
  28. data/lib/masks/client/tracker.rb +122 -0
  29. data/lib/masks/client/verifier.rb +42 -0
  30. data/lib/masks/client.rb +51 -0
  31. data/lib/masks/rails/authentication.rb +217 -0
  32. data/lib/masks/rails/configurable.rb +11 -0
  33. data/lib/masks/rails/configuration.rb +172 -0
  34. data/lib/masks/rails/credentials.rb +60 -0
  35. data/lib/masks/rails/engine.rb +20 -0
  36. data/lib/masks/rails/protected_resource.rb +51 -0
  37. data/lib/masks/rails.rb +10 -0
  38. data/lib/masks/version.rb +1 -3
  39. data/lib/masks.rb +3 -182
  40. metadata +47 -368
  41. data/MIT-LICENSE +0 -20
  42. data/Rakefile +0 -11
  43. data/app/assets/builds/application.css +0 -4764
  44. data/app/assets/builds/application.js +0 -8236
  45. data/app/assets/builds/application.js.map +0 -7
  46. data/app/assets/builds/masks/application.css +0 -1
  47. data/app/assets/builds/masks/application.js +0 -8462
  48. data/app/assets/builds/masks/application.js.map +0 -7
  49. data/app/assets/images/masks.png +0 -0
  50. data/app/assets/javascripts/application.js +0 -2
  51. data/app/assets/javascripts/controllers/application.js +0 -9
  52. data/app/assets/javascripts/controllers/emails_controller.js +0 -28
  53. data/app/assets/javascripts/controllers/index.js +0 -12
  54. data/app/assets/javascripts/controllers/keys_controller.js +0 -20
  55. data/app/assets/javascripts/controllers/recover_controller.js +0 -21
  56. data/app/assets/javascripts/controllers/recover_password_controller.js +0 -21
  57. data/app/assets/javascripts/controllers/session_controller.js +0 -94
  58. data/app/assets/manifest.js +0 -2
  59. data/app/assets/masks_manifest.js +0 -2
  60. data/app/assets/stylesheets/application.css +0 -26
  61. data/app/controllers/concerns/masks/controller.rb +0 -114
  62. data/app/controllers/masks/actors_controller.rb +0 -15
  63. data/app/controllers/masks/application_controller.rb +0 -35
  64. data/app/controllers/masks/backup_codes_controller.rb +0 -34
  65. data/app/controllers/masks/debug_controller.rb +0 -9
  66. data/app/controllers/masks/devices_controller.rb +0 -20
  67. data/app/controllers/masks/emails_controller.rb +0 -60
  68. data/app/controllers/masks/error_controller.rb +0 -14
  69. data/app/controllers/masks/keys_controller.rb +0 -45
  70. data/app/controllers/masks/manage/actor_controller.rb +0 -35
  71. data/app/controllers/masks/manage/actors_controller.rb +0 -12
  72. data/app/controllers/masks/manage/base_controller.rb +0 -12
  73. data/app/controllers/masks/one_time_code_controller.rb +0 -49
  74. data/app/controllers/masks/passwords_controller.rb +0 -33
  75. data/app/controllers/masks/recoveries_controller.rb +0 -43
  76. data/app/controllers/masks/sessions_controller.rb +0 -53
  77. data/app/helpers/masks/application_helper.rb +0 -49
  78. data/app/jobs/masks/application_job.rb +0 -7
  79. data/app/jobs/masks/expire_actors_job.rb +0 -15
  80. data/app/jobs/masks/expire_recoveries_job.rb +0 -15
  81. data/app/mailers/masks/actor_mailer.rb +0 -22
  82. data/app/mailers/masks/application_mailer.rb +0 -15
  83. data/app/models/concerns/masks/access.rb +0 -162
  84. data/app/models/concerns/masks/actor.rb +0 -132
  85. data/app/models/concerns/masks/adapter.rb +0 -68
  86. data/app/models/concerns/masks/role.rb +0 -9
  87. data/app/models/concerns/masks/scoped.rb +0 -54
  88. data/app/models/masks/access/actor_password.rb +0 -20
  89. data/app/models/masks/access/actor_scopes.rb +0 -18
  90. data/app/models/masks/access/actor_signup.rb +0 -22
  91. data/app/models/masks/actors/anonymous.rb +0 -40
  92. data/app/models/masks/actors/system.rb +0 -24
  93. data/app/models/masks/adapters/active_record.rb +0 -85
  94. data/app/models/masks/application_model.rb +0 -15
  95. data/app/models/masks/application_record.rb +0 -8
  96. data/app/models/masks/check.rb +0 -192
  97. data/app/models/masks/credential.rb +0 -166
  98. data/app/models/masks/credentials/backup_code.rb +0 -30
  99. data/app/models/masks/credentials/device.rb +0 -59
  100. data/app/models/masks/credentials/email.rb +0 -48
  101. data/app/models/masks/credentials/factor2.rb +0 -71
  102. data/app/models/masks/credentials/key.rb +0 -38
  103. data/app/models/masks/credentials/last_login.rb +0 -12
  104. data/app/models/masks/credentials/masquerade.rb +0 -32
  105. data/app/models/masks/credentials/nickname.rb +0 -63
  106. data/app/models/masks/credentials/one_time_code.rb +0 -34
  107. data/app/models/masks/credentials/password.rb +0 -28
  108. data/app/models/masks/credentials/recovery.rb +0 -71
  109. data/app/models/masks/credentials/session.rb +0 -67
  110. data/app/models/masks/device.rb +0 -30
  111. data/app/models/masks/error.rb +0 -51
  112. data/app/models/masks/event.rb +0 -14
  113. data/app/models/masks/mask.rb +0 -255
  114. data/app/models/masks/rails/actor.rb +0 -190
  115. data/app/models/masks/rails/actor_role.rb +0 -12
  116. data/app/models/masks/rails/device.rb +0 -47
  117. data/app/models/masks/rails/email.rb +0 -96
  118. data/app/models/masks/rails/key.rb +0 -61
  119. data/app/models/masks/rails/recovery.rb +0 -116
  120. data/app/models/masks/rails/role.rb +0 -20
  121. data/app/models/masks/rails/scope.rb +0 -15
  122. data/app/models/masks/session.rb +0 -447
  123. data/app/models/masks/sessions/access.rb +0 -26
  124. data/app/models/masks/sessions/inline.rb +0 -16
  125. data/app/models/masks/sessions/request.rb +0 -42
  126. data/app/resources/masks/actor_resource.rb +0 -9
  127. data/app/resources/masks/session_resource.rb +0 -15
  128. data/app/views/layouts/masks/application.html.erb +0 -17
  129. data/app/views/layouts/masks/mailer.html.erb +0 -17
  130. data/app/views/layouts/masks/mailer.text.erb +0 -1
  131. data/app/views/layouts/masks/manage.html.erb +0 -25
  132. data/app/views/masks/actor_mailer/recover_credentials.html.erb +0 -33
  133. data/app/views/masks/actor_mailer/recover_credentials.text.erb +0 -1
  134. data/app/views/masks/actor_mailer/verify_email.html.erb +0 -34
  135. data/app/views/masks/actor_mailer/verify_email.text.erb +0 -8
  136. data/app/views/masks/actors/current.html.erb +0 -152
  137. data/app/views/masks/application/_header.html.erb +0 -31
  138. data/app/views/masks/backup_codes/new.html.erb +0 -103
  139. data/app/views/masks/emails/new.html.erb +0 -103
  140. data/app/views/masks/emails/verify.html.erb +0 -51
  141. data/app/views/masks/keys/new.html.erb +0 -127
  142. data/app/views/masks/manage/actor/show.html.erb +0 -126
  143. data/app/views/masks/manage/actors/index.html.erb +0 -40
  144. data/app/views/masks/one_time_code/new.html.erb +0 -150
  145. data/app/views/masks/passwords/edit.html.erb +0 -58
  146. data/app/views/masks/recoveries/new.html.erb +0 -71
  147. data/app/views/masks/recoveries/password.html.erb +0 -64
  148. data/app/views/masks/sessions/new.html.erb +0 -153
  149. data/config/brakeman.ignore +0 -28
  150. data/config/locales/en.yml +0 -286
  151. data/db/migrate/20231205173845_create_actors.rb +0 -94
  152. data/lib/generators/masks/install/USAGE +0 -8
  153. data/lib/generators/masks/install/templates/initializer.rb +0 -5
  154. data/lib/generators/masks/install/templates/masks.json +0 -6
  155. data/lib/masks/configuration.rb +0 -236
  156. data/lib/masks/engine.rb +0 -25
  157. data/lib/masks/middleware.rb +0 -70
  158. data/lib/tasks/masks_tasks.rake +0 -72
  159. data/masks.json +0 -277
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbdad2d43a1f7948d3f3126e9e1d2126a38bcf2c3480db342b2fd09ece185869
4
- data.tar.gz: 816478ae377ebc0e41c11eee01e1a7664adf86f7bdb7d58d532b80e414873425
3
+ metadata.gz: fab2f5044bc29a407688ac0c61ac703108849a24579a7aa2e30e18234d6e02f0
4
+ data.tar.gz: 64572497372118a8ac2677634c26349c53cf93e4850188ceecaa2e2610d18546
5
5
  SHA512:
6
- metadata.gz: 7d9c773dc6aea440ea7fc36be2b4f2984fc8384def88750e602c50fb1319afeb5681299eef2f2ef26e06c4b4173d52e50c902c9fe748e1c3842f7a208c0fbca2
7
- data.tar.gz: 1fca95503ff8721d83c4e6cd3b3480d9bc67c5ac64d9bfcd19747c28b3580694d7c32408b5448b4ff0a9fae72618c21695322c234be2c1b8b24f32a769a7e7ae
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
- masks is a general purpose access control framework for ruby/rails.
3
+ Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks) issuer.
4
4
 
5
- ## Getting started
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
- To use it, add `masks` to your Gemfile or run:
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
- bundle add masks
23
+ bin/rails generate masks:install
11
24
  ```
12
25
 
13
- Then run the install generator:
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
- rails generate masks:install
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
- A typical install creates a `masks.json`, an initializer, and migrations. After
20
- running the generator your app will be able to use masks' signup, login, and account
21
- management features. Every route in your app will be protected by login by default.
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
- ## Documentation
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
- Full documentation is available at [masks.geiger.to](https://masks.geiger.to).
70
+ ### An SPA in front of it
26
71
 
27
- ## How it works
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
- At the heart of masks are a few key concepts:
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
- - **Masks** are rules that define access to resources in your application
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
- masks implements most of the glue code required to build actors, open sessions,
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
- #### an example `masks.json`
91
+ ```ruby
92
+ class ApiController < ApplicationController
93
+ include Masks::Rails::ProtectedResource
42
94
 
43
- ```json
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,13 @@
1
+ module Masks
2
+ module Rails
3
+ def self.parent_controller
4
+ config.parent_controller.to_s.constantize
5
+ end
6
+
7
+ class BaseController < parent_controller
8
+ include Masks::Rails::Authentication
9
+
10
+ layout "masks/rails/plain"
11
+ end
12
+ end
13
+ end
@@ -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 %>