masks 0.4.0 → 0.6.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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +21 -0
  4. data/README.md +235 -29
  5. data/app/controllers/masks/rails/avatars_controller.rb +51 -0
  6. data/app/controllers/masks/rails/base_controller.rb +13 -0
  7. data/app/controllers/masks/rails/handshakes_controller.rb +86 -0
  8. data/app/controllers/masks/rails/sessions_controller.rb +118 -0
  9. data/app/views/layouts/masks/rails/plain.html.erb +47 -0
  10. data/app/views/masks/rails/handshakes/refused.html.erb +12 -0
  11. data/app/views/masks/rails/handshakes/show.html.erb +40 -0
  12. data/config/routes.rb +11 -82
  13. data/lib/generators/masks/install/install_generator.rb +35 -22
  14. data/lib/generators/masks/install/templates/masks.rb.tt +38 -0
  15. data/lib/masks/client/claims.rb +144 -0
  16. data/lib/masks/client/errors.rb +60 -0
  17. data/lib/masks/client/handshake.rb +104 -0
  18. data/lib/masks/client/http.rb +96 -0
  19. data/lib/masks/client/introspection.rb +29 -0
  20. data/lib/masks/client/issuer.rb +92 -0
  21. data/lib/masks/client/pkce.rb +27 -0
  22. data/lib/masks/client/rack.rb +62 -0
  23. data/lib/masks/client/registration.rb +115 -0
  24. data/lib/masks/client/registry.rb +25 -0
  25. data/lib/masks/client/resource.rb +92 -0
  26. data/lib/masks/client/session.rb +146 -0
  27. data/lib/masks/client/stores.rb +40 -0
  28. data/lib/masks/client/tokens.rb +70 -0
  29. data/lib/masks/client/tracker.rb +122 -0
  30. data/lib/masks/client/verifier.rb +42 -0
  31. data/lib/masks/client.rb +51 -0
  32. data/lib/masks/rails/authentication.rb +260 -0
  33. data/lib/masks/rails/configurable.rb +11 -0
  34. data/lib/masks/rails/configuration.rb +184 -0
  35. data/lib/masks/rails/credentials.rb +60 -0
  36. data/lib/masks/rails/engine.rb +20 -0
  37. data/lib/masks/rails/protected_resource.rb +51 -0
  38. data/lib/masks/rails.rb +10 -0
  39. data/lib/masks/version.rb +1 -3
  40. data/lib/masks.rb +3 -183
  41. metadata +48 -416
  42. data/MIT-LICENSE +0 -20
  43. data/Rakefile +0 -11
  44. data/app/assets/builds/masks/application.css +0 -1
  45. data/app/assets/builds/masks/application.js +0 -8549
  46. data/app/assets/builds/masks/application.js.map +0 -7
  47. data/app/assets/images/masks.png +0 -0
  48. data/app/assets/javascripts/application.js +0 -2
  49. data/app/assets/javascripts/controllers/application.js +0 -9
  50. data/app/assets/javascripts/controllers/emails_controller.js +0 -28
  51. data/app/assets/javascripts/controllers/index.js +0 -21
  52. data/app/assets/javascripts/controllers/keys_controller.js +0 -20
  53. data/app/assets/javascripts/controllers/recover_controller.js +0 -21
  54. data/app/assets/javascripts/controllers/recover_password_controller.js +0 -21
  55. data/app/assets/javascripts/controllers/session_controller.js +0 -94
  56. data/app/assets/javascripts/controllers/table_controller.js +0 -15
  57. data/app/assets/manifest.js +0 -2
  58. data/app/assets/masks_manifest.js +0 -2
  59. data/app/assets/stylesheets/application.css +0 -34
  60. data/app/controllers/concerns/masks/controller.rb +0 -114
  61. data/app/controllers/masks/actors_controller.rb +0 -15
  62. data/app/controllers/masks/application_controller.rb +0 -35
  63. data/app/controllers/masks/backup_codes_controller.rb +0 -34
  64. data/app/controllers/masks/debug_controller.rb +0 -9
  65. data/app/controllers/masks/devices_controller.rb +0 -20
  66. data/app/controllers/masks/emails_controller.rb +0 -60
  67. data/app/controllers/masks/error_controller.rb +0 -14
  68. data/app/controllers/masks/keys_controller.rb +0 -45
  69. data/app/controllers/masks/manage/actors_controller.rb +0 -83
  70. data/app/controllers/masks/manage/base_controller.rb +0 -20
  71. data/app/controllers/masks/manage/clients_controller.rb +0 -84
  72. data/app/controllers/masks/manage/dashboard_controller.rb +0 -15
  73. data/app/controllers/masks/manage/devices_controller.rb +0 -19
  74. data/app/controllers/masks/one_time_code_controller.rb +0 -49
  75. data/app/controllers/masks/openid/authorizations_controller.rb +0 -45
  76. data/app/controllers/masks/openid/discoveries_controller.rb +0 -55
  77. data/app/controllers/masks/openid/tokens_controller.rb +0 -45
  78. data/app/controllers/masks/openid/userinfo_controller.rb +0 -28
  79. data/app/controllers/masks/passwords_controller.rb +0 -33
  80. data/app/controllers/masks/recoveries_controller.rb +0 -43
  81. data/app/controllers/masks/sessions_controller.rb +0 -53
  82. data/app/helpers/masks/application_helper.rb +0 -49
  83. data/app/jobs/masks/application_job.rb +0 -7
  84. data/app/jobs/masks/expire_actors_job.rb +0 -15
  85. data/app/jobs/masks/expire_recoveries_job.rb +0 -15
  86. data/app/mailers/masks/actor_mailer.rb +0 -22
  87. data/app/mailers/masks/application_mailer.rb +0 -15
  88. data/app/models/concerns/masks/access.rb +0 -162
  89. data/app/models/concerns/masks/actor.rb +0 -132
  90. data/app/models/concerns/masks/adapter.rb +0 -68
  91. data/app/models/concerns/masks/role.rb +0 -9
  92. data/app/models/concerns/masks/scoped.rb +0 -54
  93. data/app/models/masks/access/actor_password.rb +0 -21
  94. data/app/models/masks/access/actor_scopes.rb +0 -18
  95. data/app/models/masks/access/actor_signup.rb +0 -21
  96. data/app/models/masks/actors/anonymous.rb +0 -40
  97. data/app/models/masks/actors/system.rb +0 -24
  98. data/app/models/masks/adapters/active_record.rb +0 -85
  99. data/app/models/masks/application_model.rb +0 -15
  100. data/app/models/masks/application_record.rb +0 -8
  101. data/app/models/masks/check.rb +0 -192
  102. data/app/models/masks/credential.rb +0 -166
  103. data/app/models/masks/credentials/access_token.rb +0 -60
  104. data/app/models/masks/credentials/backup_code.rb +0 -30
  105. data/app/models/masks/credentials/device.rb +0 -59
  106. data/app/models/masks/credentials/email.rb +0 -48
  107. data/app/models/masks/credentials/factor2.rb +0 -71
  108. data/app/models/masks/credentials/key.rb +0 -38
  109. data/app/models/masks/credentials/last_login.rb +0 -12
  110. data/app/models/masks/credentials/masquerade.rb +0 -32
  111. data/app/models/masks/credentials/nickname.rb +0 -63
  112. data/app/models/masks/credentials/one_time_code.rb +0 -34
  113. data/app/models/masks/credentials/password.rb +0 -28
  114. data/app/models/masks/credentials/recovery.rb +0 -71
  115. data/app/models/masks/credentials/return_to.rb +0 -27
  116. data/app/models/masks/credentials/session.rb +0 -67
  117. data/app/models/masks/device.rb +0 -30
  118. data/app/models/masks/error.rb +0 -51
  119. data/app/models/masks/event.rb +0 -14
  120. data/app/models/masks/mask.rb +0 -266
  121. data/app/models/masks/openid/authorization.rb +0 -116
  122. data/app/models/masks/openid/token.rb +0 -56
  123. data/app/models/masks/rails/actor.rb +0 -212
  124. data/app/models/masks/rails/actor_role.rb +0 -12
  125. data/app/models/masks/rails/device.rb +0 -47
  126. data/app/models/masks/rails/email.rb +0 -96
  127. data/app/models/masks/rails/key.rb +0 -61
  128. data/app/models/masks/rails/openid/access_token.rb +0 -55
  129. data/app/models/masks/rails/openid/authorization.rb +0 -45
  130. data/app/models/masks/rails/openid/client.rb +0 -186
  131. data/app/models/masks/rails/openid/id_token.rb +0 -43
  132. data/app/models/masks/rails/recovery.rb +0 -116
  133. data/app/models/masks/rails/role.rb +0 -20
  134. data/app/models/masks/rails/scope.rb +0 -15
  135. data/app/models/masks/session.rb +0 -447
  136. data/app/models/masks/sessions/access.rb +0 -27
  137. data/app/models/masks/sessions/inline.rb +0 -16
  138. data/app/models/masks/sessions/request.rb +0 -42
  139. data/app/resources/masks/actor_resource.rb +0 -9
  140. data/app/resources/masks/session_resource.rb +0 -15
  141. data/app/views/layouts/masks/application.html.erb +0 -17
  142. data/app/views/layouts/masks/mailer.html.erb +0 -17
  143. data/app/views/layouts/masks/mailer.text.erb +0 -1
  144. data/app/views/layouts/masks/manage.html.erb +0 -42
  145. data/app/views/masks/actor_mailer/recover_credentials.html.erb +0 -32
  146. data/app/views/masks/actor_mailer/recover_credentials.text.erb +0 -1
  147. data/app/views/masks/actor_mailer/verify_email.html.erb +0 -33
  148. data/app/views/masks/actor_mailer/verify_email.text.erb +0 -8
  149. data/app/views/masks/actors/current.html.erb +0 -145
  150. data/app/views/masks/application/_header.html.erb +0 -30
  151. data/app/views/masks/backup_codes/new.html.erb +0 -117
  152. data/app/views/masks/emails/new.html.erb +0 -109
  153. data/app/views/masks/emails/verify.html.erb +0 -51
  154. data/app/views/masks/keys/new.html.erb +0 -127
  155. data/app/views/masks/manage/actors/index.html.erb +0 -104
  156. data/app/views/masks/manage/actors/show.html.erb +0 -172
  157. data/app/views/masks/manage/clients/index.html.erb +0 -102
  158. data/app/views/masks/manage/clients/show.html.erb +0 -156
  159. data/app/views/masks/manage/dashboard/index.html.erb +0 -10
  160. data/app/views/masks/manage/devices/index.html.erb +0 -47
  161. data/app/views/masks/one_time_code/new.html.erb +0 -167
  162. data/app/views/masks/openid/authorizations/error.html.erb +0 -23
  163. data/app/views/masks/openid/authorizations/new.html.erb +0 -46
  164. data/app/views/masks/passwords/edit.html.erb +0 -71
  165. data/app/views/masks/recoveries/new.html.erb +0 -69
  166. data/app/views/masks/recoveries/password.html.erb +0 -63
  167. data/app/views/masks/sessions/new.html.erb +0 -152
  168. data/config/brakeman.ignore +0 -28
  169. data/config/initializers/inflections.rb +0 -5
  170. data/config/locales/en.yml +0 -307
  171. data/db/migrate/20231205173845_create_actors.rb +0 -94
  172. data/db/migrate/20240329182422_support_openid.rb +0 -64
  173. data/lib/generators/masks/install/USAGE +0 -8
  174. data/lib/generators/masks/install/templates/initializer.rb +0 -5
  175. data/lib/generators/masks/install/templates/masks.json +0 -9
  176. data/lib/masks/configuration.rb +0 -249
  177. data/lib/masks/engine.rb +0 -25
  178. data/lib/masks/middleware.rb +0 -70
  179. data/lib/tasks/masks_tasks.rake +0 -72
  180. data/masks.json +0 -315
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ae9f82c2ea49310531a78988041b88fc46c35dfeb8d4e1beb1ad293c0845b0c
4
- data.tar.gz: d17941873e5162c547e93f1ff18f38eb6ab7594846ea44d1de85d8f3410957ec
3
+ metadata.gz: d4455d2e731a770253802292bce1dd21a9b97c05489e65ba4a1f310c24873b79
4
+ data.tar.gz: df05e39f40a1f08fbb77897549c584653bf17676c3e6b13b64220e1751360e64
5
5
  SHA512:
6
- metadata.gz: 5f639e855aa5d28fc184c816b360fb97e7df03da827a6537451bc3e7bf7e4a3759e7d5c5f162c0b5e4c8e45939c7cb6c6bfde3ef08749e26299ddea137f4be84
7
- data.tar.gz: c61938382dea4c955e7c207e9d889b5db3d23c843b10e7783c18b81886f3f5c38a7f55d30d47d3f15689164356ba64816ac3e3321c65569dcb0b01dba66ef57d
6
+ metadata.gz: 39e55aecad7e80a3cef669b9bcbe16f6f942c326bf98a9f9da954663365740d9e31a814a3da644ef68bd0090f040f853ff4b3bdda34f2f8fe6dd4b749190d138
7
+ data.tar.gz: 4cca8c0d431151c0a0865969dcc38767388ec4f93c1ab5e2b824e7dd875f8a38135969d7b8ed5a1966eb9b0f51b3fb60e8cff887c67d180561b3474e47653d84
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **server:** a namespace is a grant, so an app can grow its own scopes ([4e816e4](https://github.com/masksrb/masks/commit/4e816e4456de9cc05601b8eaa03773788e3178bb))
9
+ * **server:** people, avatars, and one page instead of three ([d50c444](https://github.com/masksrb/masks/commit/d50c4440812fc1c3a92e926ff6d533b9a69dae2a))
10
+
11
+
12
+ ### Fixes
13
+
14
+ * **client:** an app whose issuer forgot it asks to be reconnected ([6956972](https://github.com/masksrb/masks/commit/695697233ca1ce35fa8b29ea74e58dd0d762b0a5))
15
+
16
+ ## 0.5.0 — unreleased
17
+
18
+ First release carrying the consumer half in full. 0.4.0 predates the split described in
19
+ `plans/019` and should not be used.
20
+
21
+ - Discovery, PKCE authorization, token exchange, and token verification against a masks issuer.
22
+ - Rack middleware for a resource server.
23
+ - A Rails engine, mounted by the consuming app, covering the consumer half of the code flow.
24
+ - `rails generate masks:install`.
25
+ - `config.store` falls back to a default credential store instead of raising.
26
+
27
+ ## 0.4.0
28
+
29
+ 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,256 @@
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.
6
16
 
7
- To use it, add `masks` to your Gemfile or run:
17
+ ## Rails
18
+
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`.
29
+
30
+ ### The handshake
14
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.
66
+
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.
22
69
 
23
- ## Documentation
70
+ ### An SPA in front of it
24
71
 
25
- Full documentation is available at [masks.geiger.to](https://masks.geiger.to).
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:
26
74
 
27
- ## How it works
75
+ ```js
76
+ import { createSession } from "@masks/client"
28
77
 
29
- At the heart of masks are a few key concepts:
78
+ const session = createSession()
79
+ const status = await session.status()
80
+ ```
81
+
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
+ ### Avatars
104
+
105
+ Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
106
+ `initials`. All three arrive on the id token, so drawing one costs no request.
107
+
108
+ ```erb
109
+ <img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
110
+ ```
111
+
112
+ A photo is a likeness of a person and needs a token, which an `<img>` cannot carry, so the engine
113
+ proxies it with the token this app already holds:
114
+
115
+ ```erb
116
+ <img src="/auth/avatar" width="64" height="64" alt="">
117
+ ```
118
+
119
+ `masks_claims.picture` resolves the standard OIDC claim — an offsite `picture_url` if the actor set
120
+ one, then the photo, then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a
121
+ subject this app holds no token for. Sizes are 32, 64, 128, 256 or 512.
122
+
123
+ ### Signing out
124
+
125
+ `DELETE /auth/logout` ends this app's session. Add `?everywhere=1` and the response
126
+ carries `logout_url` — the issuer's end-session endpoint — so signing out can mean
127
+ signing out. `config.sign_out_of_issuer = true` makes that the default for every
128
+ sign-out.
129
+
130
+ ### Configuration
131
+
132
+ Every value that varies per request accepts a callable taking the request, which is what
133
+ a subdomain-per-tenant host needs.
134
+
135
+ | | |
136
+ |---|---|
137
+ | `issuer` | the masks issuer this app signs in against |
138
+ | `resource` | this app's own identifier, when it also accepts tokens |
139
+ | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
140
+ | `scope` | what to ask the issuer for; defaults to `openid profile email` |
141
+ | `credentials` / `store` | where the handshake's result lives |
142
+ | `credentials_path` | where the default store writes; `config/masks.json` |
143
+ | `after_sign_in` / `after_sign_out` | paths on this host |
144
+ | `parent_controller` | what the engine's pages inherit, for your layout |
145
+ | `authenticate_everything` | include `Authentication` on every controller |
146
+ | `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
147
+ | `session_key` | the session key the tokens live under |
148
+
149
+ ## Any Ruby app
150
+
151
+ Everything above is `Masks::Client` underneath, and it is usable directly — the engine
152
+ holds no protocol of its own.
153
+
154
+ ### The consumer half
155
+
156
+ ```ruby
157
+ session = Masks::Client::Session.new(
158
+ issuer: "https://jons.auth.example.com",
159
+ client_id: id, client_secret: secret,
160
+ redirect_uri: "https://app.example.com/auth/callback"
161
+ )
162
+
163
+ started = session.start(resource: "https://app.example.com/mcp")
164
+ # hold started[:state], started[:nonce] and started[:verifier]; send the
165
+ # browser to started[:url]
166
+
167
+ tokens = session.complete(code: params[:code], verifier: held[:verifier])
168
+ identity = session.identity(tokens)
169
+ ```
170
+
171
+ `start` sends a nonce only when `openid` was asked for, so holding one means an id token
172
+ is owed — which is what lets the check on the way back be exact rather than vacuously
173
+ true.
174
+
175
+ Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
176
+
177
+ Discovery and JWKS are cached for five minutes, with invalidate-and-refetch on an unknown
178
+ `kid`, so a key rotation is picked up without a restart. The cache is real only if the
179
+ `Issuer` is: use `Masks::Client::Issuer.resolve`, or the registry behind it, rather than
180
+ constructing one per request.
181
+
182
+ ### The handshake
183
+
184
+ The flow that connects a first-party app, rather than two helpers and sixty lines of
185
+ state in every consumer:
186
+
187
+ ```ruby
188
+ handshake = Masks::Client::Handshake.new(
189
+ issuer, name: "things", resource: "https://app.example.com/mcp",
190
+ redirect_uris: [ "https://app.example.com/auth/callback" ],
191
+ return_to: "https://app.example.com/"
192
+ )
193
+
194
+ started = handshake.start # hold started[:state]; send the browser on
195
+ registration = handshake.complete(params, state: held)
196
+ ```
197
+
198
+ `complete` refuses an `error`, a `state` that does not match this browser, and an `iss`
199
+ that is not the issuer it asked — each **before** anything is redeemed.
200
+
201
+ ### The resource-server half
202
+
203
+ ```ruby
204
+ resource = Masks::Client::Resource.new(
205
+ issuer: "https://jons.auth.example.com",
206
+ url: "https://app.example.com/mcp",
207
+ scopes: { "things:read" => "Search your catalog" }
208
+ )
209
+
210
+ claims = resource.authenticate(request.authorization, scope: "things:read")
211
+ claims.subject
212
+ claims.tenant.subdomain
213
+ ```
214
+
215
+ `authenticate` answers `Claims` or raises. A refusal builds the RFC 6750 challenge
216
+ carrying `resource_metadata` and the scopes it would have accepted:
217
+
218
+ ```ruby
219
+ response.headers["WWW-Authenticate"] = resource.challenge(error)
220
+ ```
221
+
222
+ `resource.metadata` is the RFC 9728 document to serve at
223
+ `/.well-known/oauth-protected-resource`. The `scope_descriptions` extension in it is how
224
+ an auth server renders your scopes as sentences on its consent screen — it has no other
225
+ way to know what `things:read` means.
226
+
227
+ There is a Rack middleware for consumers that want the challenge below the framework:
228
+
229
+ ```ruby
230
+ use Masks::Client::Rack, resource: resource, scope: "things:read"
231
+ ```
232
+
233
+ ### Introspection
234
+
235
+ A resource server doing JWT-only validation cannot see a revocation until the token
236
+ expires. Asking is the honest answer:
237
+
238
+ ```ruby
239
+ found = session.introspect(token)
240
+ found.active? && found.permits?("things:read")
241
+ ```
242
+
243
+ `Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
244
+ not active, so switching from local verification to asking does not mean remembering to
245
+ check a boolean.
246
+
247
+ ## Which issuers this speaks to
248
+
249
+ masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
250
+ least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
251
+ it. An older issuer is refused with a sentence saying so, rather than at the first screen
252
+ anybody touches.
253
+
254
+ ## License
255
+
256
+ MIT.
@@ -0,0 +1,51 @@
1
+ module Masks
2
+ module Rails
3
+ class AvatarsController < BaseController
4
+ SIZES = [ 32, 64, 128, 256, 512 ].freeze
5
+
6
+ def show
7
+ return head :not_found unless masks_signed_in?
8
+
9
+ url = photo_url
10
+
11
+ return head :not_found if url.nil?
12
+
13
+ deliver(
14
+ Masks::Client::HTTP.fetch(
15
+ url, "Authorization" => masks_tokens.authorization, "Accept" => "image/*"
16
+ )
17
+ )
18
+ rescue Masks::Client::Error
19
+ head :bad_gateway
20
+ end
21
+
22
+ private
23
+
24
+ def photo_url
25
+ held = masks_claims.avatars.photo
26
+
27
+ return nil if held.nil?
28
+ return nil unless held.start_with?("#{masks_config.issuer_for(request)}/")
29
+
30
+ asked = params[:size].presence
31
+
32
+ return held if asked.nil?
33
+
34
+ "#{held}?size=#{SIZES.find { |allowed| allowed >= asked.to_i } || SIZES.last}"
35
+ end
36
+
37
+ def deliver(answer)
38
+ case answer
39
+ when Net::HTTPRedirection
40
+ redirect_to answer["Location"], allow_other_host: true
41
+ when Net::HTTPSuccess
42
+ expires_in 5.minutes, public: false
43
+
44
+ send_data answer.body, type: answer["Content-Type"], disposition: "inline"
45
+ else
46
+ head :not_found
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -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,86 @@
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
+ forget_upstream
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 forget_upstream
55
+ masks_registration&.delete
56
+ rescue Masks::Client::Unregistered
57
+ false
58
+ end
59
+
60
+ def returned
61
+ params.permit(:initial_access_token, :iss, :state, :error, :error_description).to_h
62
+ end
63
+
64
+ def require_unconfigured_or_signed_in
65
+ return unless masks_config.configured?(request)
66
+ return if masks_signed_in?
67
+
68
+ redirect_to masks_config.after_sign_out
69
+ end
70
+
71
+ def stale
72
+ @code = "invalid_state"
73
+ @description = "that connection request is not one this browser started, or it expired"
74
+
75
+ render :refused, status: :bad_request
76
+ end
77
+
78
+ def refuse(error)
79
+ @code = error.try(:code).presence || error.class.name.demodulize.underscore
80
+ @description = error.try(:description).presence || error.message
81
+
82
+ render :refused, status: :bad_request
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,118 @@
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
+ return redirect_to(masks_handshake_path) if masks_reconnect!
18
+
19
+ pending = masks_requests.open(
20
+ return_to: requested_return_to || session.delete(:masks_return_to)
21
+ )
22
+ started = masks_session.start(
23
+ state: pending.id,
24
+ resource: masks_config.resource_for(request)
25
+ )
26
+
27
+ masks_requests.amend(
28
+ pending.id, nonce: started[:nonce], verifier: started[:verifier]
29
+ )
30
+
31
+ redirect_to started[:url], allow_other_host: true
32
+ end
33
+
34
+ def callback
35
+ pending = masks_requests.claim(params[:state])
36
+
37
+ return stale if pending.nil?
38
+ return refuse(params[:error], params[:error_description], pending) if params[:error].present?
39
+
40
+ tokens = masks_session.complete(
41
+ code: params[:code],
42
+ verifier: pending[:verifier],
43
+ resource: masks_config.resource_for(request)
44
+ )
45
+
46
+ identity = begin
47
+ masks_identity_from(tokens)
48
+ rescue Masks::Client::InvalidToken
49
+ :unverified
50
+ end
51
+
52
+ unless nonce_matches?(identity, pending[:nonce])
53
+ return refuse("invalid_nonce", "the id token was issued for another request", pending)
54
+ end
55
+
56
+ masks_store(tokens, identity: identity)
57
+
58
+ redirect_to pending[:return_to] || masks_config.after_sign_in
59
+ rescue Masks::Client::Unregistered => e
60
+ return redirect_to(masks_handshake_path) if masks_disconnect!
61
+
62
+ refuse(e.code, e.description, pending)
63
+ rescue Masks::Client::Error => e
64
+ refuse(e.class.name.demodulize.underscore, e.message, pending)
65
+ end
66
+
67
+ def destroy
68
+ everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
69
+ upstream = everywhere ? masks_logout_url : nil
70
+
71
+ masks_forget
72
+
73
+ if masks_wants_json?
74
+ render json: { "signed_in" => false, "logout_url" => upstream }.compact
75
+ else
76
+ redirect_to upstream || masks_config.after_sign_out, allow_other_host: upstream.present?
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def requested_return_to
83
+ masks_local_path(params[:return_to])
84
+ end
85
+
86
+ def nonce_matches?(identity, sent)
87
+ return false if identity == :unverified
88
+ return true if sent.blank?
89
+ return false unless identity.is_a?(Hash)
90
+
91
+ held = identity["nonce"].to_s
92
+ held.present? && ActiveSupport::SecurityUtils.secure_compare(held, sent)
93
+ end
94
+
95
+ def stale
96
+ answer(
97
+ "invalid_state",
98
+ "that sign-in request is not one this browser started, or it expired",
99
+ :bad_request
100
+ )
101
+ end
102
+
103
+ def refuse(code, description, pending = nil)
104
+ masks_forget if pending
105
+
106
+ answer(code, description, :bad_request)
107
+ end
108
+
109
+ def answer(code, description, status)
110
+ if masks_wants_json?
111
+ render json: { "error" => code, "error_description" => description }, status: status
112
+ else
113
+ render plain: "sign-in failed — #{code}: #{description}", status: status
114
+ end
115
+ end
116
+ end
117
+ end
118
+ 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>