masks 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4455d2e731a770253802292bce1dd21a9b97c05489e65ba4a1f310c24873b79
4
- data.tar.gz: df05e39f40a1f08fbb77897549c584653bf17676c3e6b13b64220e1751360e64
3
+ metadata.gz: 5da19b0cb3099ebe46f2b4509615a75422fa0b2820abb799e8f182ce11f456ad
4
+ data.tar.gz: 4d5d6b573cccbca63439587cd3363d22be28dd0d8ff95051f32dcc8252494185
5
5
  SHA512:
6
- metadata.gz: 39e55aecad7e80a3cef669b9bcbe16f6f942c326bf98a9f9da954663365740d9e31a814a3da644ef68bd0090f040f853ff4b3bdda34f2f8fe6dd4b749190d138
7
- data.tar.gz: 4cca8c0d431151c0a0865969dcc38767388ec4f93c1ab5e2b824e7dd875f8a38135969d7b8ed5a1966eb9b0f51b3fb60e8cff887c67d180561b3474e47653d84
6
+ metadata.gz: 130ccd4e8a1ca7ddfe996f7439af4dc800501ea0a81ec0a18561475447931af40affb0cd0e2a66aa191d28d93f677046f49b7d5db6a913426fa21114d1bcbb68
7
+ data.tar.gz: bd818590edc19a344daf59e755d8657da71e10f2017fc3b8ffe95ed6f8452f804a6ccdc7da621425b288c5934b25b5c39bd41923d13983e16fab9ab144bdfd78
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/masksrb/masks/compare/gem/v0.6.0...gem/v0.7.0) (2026-09-13)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **client:** Masks::Client::Introspection#username is now #nickname.
9
+
10
+ ### Features
11
+
12
+ * **client:** an app can act on a logout the issuer tells it about ([4d0b7f7](https://github.com/masksrb/masks/commit/4d0b7f77a74b0e416dda3e90413ad989a909f344))
13
+ * **client:** an app that delegates asks its handshake for masks:delegate: and the token exchange ([912e856](https://github.com/masksrb/masks/commit/912e856b00a9f27d59af861cdb35da518fa862bc))
14
+ * **client:** an app that delegates registers where connecting a person's account comes back to ([738fba6](https://github.com/masksrb/masks/commit/738fba67e544f7c8a9a15cd55befbd66696b707b))
15
+ * **client:** an unconnected app walks straight into the handshake ([040388e](https://github.com/masksrb/masks/commit/040388e188653f67ef7a59bc0aa9aef02ec351b2))
16
+ * **server:** an application is let use somebody's account elsewhere through a delegation ([4b7048c](https://github.com/masksrb/masks/commit/4b7048cb8ac4e174b9359a070b7a2d942a1c410d))
17
+
18
+
19
+ ### Fixes
20
+
21
+ * **client:** back-channel logout says 501 when the app does nothing about it ([60e1125](https://github.com/masksrb/masks/commit/60e1125c02adec7c822c6d3757210e825bb66cfc))
22
+
23
+
24
+ ### Documentation
25
+
26
+ * hold the copyright as the masks authors ([cffdeb5](https://github.com/masksrb/masks/commit/cffdeb59f14eb02f6a9513d5f7cda21ebaee2171))
27
+ * READMEs that match the code, and a server one that is not the scaffold ([e0148e1](https://github.com/masksrb/masks/commit/e0148e14e0092731bcd095bc0fc22a1589c3acc0))
28
+ * references are generated from the code that defines them ([ba74550](https://github.com/masksrb/masks/commit/ba745501e651b797948e5abd5640a1825616100c))
29
+ * the gem reference survives rdoc 8, and lists what a Concern adds ([36df12e](https://github.com/masksrb/masks/commit/36df12e72cc52f5db650d16778ecc501bf9db14a))
30
+ * the rose window is the site's logo, favicon and hero, and heads every README ([72176e4](https://github.com/masksrb/masks/commit/72176e49c81bc0a5d6730f3b9e5eaa693db8a659))
31
+
32
+
33
+ ### Refactoring
34
+
35
+ * **client:** an introspection answers a nickname, not a username ([f23dff5](https://github.com/masksrb/masks/commit/f23dff537320982d42a8e4823bea784a3d80ec33))
36
+ * comments are gone; the code says what it does ([0fc8e6a](https://github.com/masksrb/masks/commit/0fc8e6a4692e3f09831514b6d167c3e23d7db419))
37
+ * every suite is named for what it proves, under one test directory ([315b371](https://github.com/masksrb/masks/commit/315b371e64baa4441014669744a61c88341d27c7))
38
+ * rename the example tenant from jons to demo ([aef32a6](https://github.com/masksrb/masks/commit/aef32a670b4b3c696089c07bf15103e1d981eb00))
39
+ * **server:** delegation shares what sign-in and linking already had, and asks the database less ([44b8c4d](https://github.com/masksrb/masks/commit/44b8c4d303dfcc6de60f98d7824c544bd67e347c))
40
+ * take the owner's domain out of the boundary check ([df580ee](https://github.com/masksrb/masks/commit/df580ee1fb5820d30a57a88f3aa9825c97682725))
41
+
3
42
  ## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
4
43
 
5
44
 
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 geiger.to
3
+ Copyright (c) 2026 the masks authors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <p align="center"><img src="https://raw.githubusercontent.com/masksrb/masks/main/server/public/icon.svg" width="120" alt="The masks rose window"></p>
2
+
1
3
  # masks
2
4
 
3
5
  Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks) issuer.
@@ -7,9 +9,9 @@ gem "masks"
7
9
  ```
8
10
 
9
11
  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.
12
+ client libraries build only the first, leaving each resource server to write its own
13
+ scope check, its `WWW-Authenticate` header and its metadata document. Both halves are
14
+ here, with a Rails engine that mounts them.
13
15
 
14
16
  The engine loads only when Rails does. A Sinatra, Hanami or plain-Rack app requiring
15
17
  this gem pulls in `jwt` and nothing else.
@@ -29,12 +31,16 @@ the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
29
31
 
30
32
  ### The handshake
31
33
 
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
34
+ A first-party app must not self-register anonymously, since that is also how a
35
+ stranger's connector arrives. An unconnected app sends the browser straight on to its
36
+ own issuer's approval screen, and the one-time token that comes back is redeemed
35
37
  server-side. **The secret never travels through the browser and nobody types it
36
38
  anywhere.**
37
39
 
40
+ The only thing `/auth/handshake` stops a browser to say is that the app is configured
41
+ too little to shake hands at all, or that the issuer refused. A connected app asks
42
+ before it rotates, since reconnecting takes it offline for a moment.
43
+
38
44
  The engine writes what comes back to `config/masks.json`, mode 600. An app that wants
39
45
  somewhere else says so:
40
46
 
@@ -48,7 +54,7 @@ Those two lambdas are the whole integration for a multi-tenant app.
48
54
  ### Signing in
49
55
 
50
56
  ```ruby
51
- class ThingsController < ApplicationController
57
+ class UrisController < ApplicationController
52
58
  include Masks::Rails::Authentication
53
59
 
54
60
  before_action :authenticate_masks!
@@ -60,7 +66,7 @@ end
60
66
  ```
61
67
 
62
68
  `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
69
+ Tokens live in the encrypted Rails session and never reach JavaScript. This is the
64
70
  backend-for-frontend pattern, and it is the default because an SPA holding a token is an
65
71
  SPA where XSS lifts one.
66
72
 
@@ -70,7 +76,7 @@ controller if that is what you want; otherwise include it where you mean it.
70
76
  ### An SPA in front of it
71
77
 
72
78
  `GET /auth/session` answers identity, tenant and scopes as JSON, or `401` with somewhere
73
- to send the browser. [`@masks/client`](../web) speaks it:
79
+ to send the browser. [`@masks/client`](https://masks.pages.dev/reference/browser/) speaks it:
74
80
 
75
81
  ```js
76
82
  import { createSession } from "@masks/client"
@@ -79,10 +85,9 @@ const session = createSession()
79
85
  const status = await session.status()
80
86
  ```
81
87
 
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.
88
+ `status()` answers one of three states: `signed_in`, `signed_out` with where to sign in,
89
+ and `handshake_required` with where to go instead. The third exists because an app nobody
90
+ has connected must not offer a sign-in button that leads to an error page at the issuer.
86
91
 
87
92
  ### Accepting tokens
88
93
 
@@ -92,10 +97,13 @@ An app that is also a resource server:
92
97
  class ApiController < ApplicationController
93
98
  include Masks::Rails::ProtectedResource
94
99
 
95
- before_action { masks_protect!(scope: "things:read") }
100
+ masks_protect! scope: "uris:catalog:read"
96
101
  end
97
102
  ```
98
103
 
104
+ `masks_protect!` is a class method that installs the `before_action` itself, and passes `:only` and
105
+ `:except` through.
106
+
99
107
  `masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
100
108
  `resource_metadata`, so a client handed nothing but a URL can find its way to the issuer
101
109
  and back.
@@ -155,7 +163,7 @@ holds no protocol of its own.
155
163
 
156
164
  ```ruby
157
165
  session = Masks::Client::Session.new(
158
- issuer: "https://jons.auth.example.com",
166
+ issuer: "https://demo.auth.example.com",
159
167
  client_id: id, client_secret: secret,
160
168
  redirect_uri: "https://app.example.com/auth/callback"
161
169
  )
@@ -169,8 +177,7 @@ identity = session.identity(tokens)
169
177
  ```
170
178
 
171
179
  `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.
180
+ is owed, and the check on the way back is exact.
174
181
 
175
182
  Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
176
183
 
@@ -181,12 +188,11 @@ constructing one per request.
181
188
 
182
189
  ### The handshake
183
190
 
184
- The flow that connects a first-party app, rather than two helpers and sixty lines of
185
- state in every consumer:
191
+ The flow that connects a first-party app:
186
192
 
187
193
  ```ruby
188
194
  handshake = Masks::Client::Handshake.new(
189
- issuer, name: "things", resource: "https://app.example.com/mcp",
195
+ issuer, name: "uris", resource: "https://app.example.com/mcp",
190
196
  redirect_uris: [ "https://app.example.com/auth/callback" ],
191
197
  return_to: "https://app.example.com/"
192
198
  )
@@ -202,12 +208,12 @@ that is not the issuer it asked — each **before** anything is redeemed.
202
208
 
203
209
  ```ruby
204
210
  resource = Masks::Client::Resource.new(
205
- issuer: "https://jons.auth.example.com",
211
+ issuer: "https://demo.auth.example.com",
206
212
  url: "https://app.example.com/mcp",
207
- scopes: { "things:read" => "Search your catalog" }
213
+ scopes: { "uris:catalog:read" => "Search your catalog" }
208
214
  )
209
215
 
210
- claims = resource.authenticate(request.authorization, scope: "things:read")
216
+ claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
211
217
  claims.subject
212
218
  claims.tenant.subdomain
213
219
  ```
@@ -221,35 +227,87 @@ response.headers["WWW-Authenticate"] = resource.challenge(error)
221
227
 
222
228
  `resource.metadata` is the RFC 9728 document to serve at
223
229
  `/.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.
230
+ an auth server renders your scopes as sentences on its consent screen; it has no other
231
+ way to know what `uris:catalog:read` means.
226
232
 
227
233
  There is a Rack middleware for consumers that want the challenge below the framework:
228
234
 
229
235
  ```ruby
230
- use Masks::Client::Rack, resource: resource, scope: "things:read"
236
+ use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
231
237
  ```
232
238
 
233
239
  ### Introspection
234
240
 
235
241
  A resource server doing JWT-only validation cannot see a revocation until the token
236
- expires. Asking is the honest answer:
242
+ expires. Ask the issuer instead:
237
243
 
238
244
  ```ruby
239
245
  found = session.introspect(token)
240
- found.active? && found.permits?("things:read")
246
+ found.active? && found.permits?("uris:catalog:read")
241
247
  ```
242
248
 
243
249
  `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.
250
+ not active, so moving from local verification to introspection needs no extra boolean
251
+ check.
252
+
253
+ ### Somebody else's account
254
+
255
+ An app that acts as somebody at Google, Microsoft or an MCP server while they are away asks masks for
256
+ a [delegation](https://masks.pages.dev/concepts/delegation/). masks keeps and refreshes the
257
+ provider's tokens; the app keeps one secret per connection and trades it for a live access token when
258
+ the last one runs out.
259
+
260
+ ```ruby
261
+ delegations = Masks::Client.delegations(
262
+ ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
263
+ )
264
+
265
+ started = delegations.start(provider: "google")
266
+ session[:connecting] = started
267
+ redirect_to started["url"]
268
+
269
+ held = delegations.finish(params: params, started: session.delete(:connecting))
270
+ held.connection
271
+ held.secret
272
+
273
+ upstream = delegations.token(held.secret, connection: held.connection)
274
+ upstream.access_token
275
+ upstream.expires_at
276
+ upstream.secret
277
+ ```
278
+
279
+ A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
280
+ exchange with it.
281
+
282
+ `upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
283
+ has to connect again; `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks had
284
+ already rotated it, so keep it. Spend a secret from one place at a time: spending one twice revokes
285
+ it.
286
+
287
+ Tests use the fake, which needs no issuer:
288
+
289
+ ```ruby
290
+ require "masks/client/delegations/fake"
291
+
292
+ fake = Masks::Client::Delegations::Fake.new
293
+ started = fake.start(provider: "notion")
294
+ held = fake.finish(params: fake.approve(started), started: started)
295
+
296
+ fake.token(held.secret, connection: held.connection)
297
+ fake.revoke(held.connection)
298
+ fake.unavailable(held.connection)
299
+ ```
246
300
 
247
301
  ## Which issuers this speaks to
248
302
 
249
303
  masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
250
304
  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.
305
+ it. An older issuer is refused at configuration time, with a sentence saying so.
306
+
307
+ ## Documentation
308
+
309
+ This README is the reference for the gem. [masks.pages.dev](https://masks.pages.dev) carries what is
310
+ generated from the server's own code — the `/manage` GraphQL schema, and the design system.
253
311
 
254
312
  ## License
255
313
 
@@ -4,17 +4,13 @@ module Masks
4
4
  before_action :require_unconfigured_or_signed_in
5
5
 
6
6
  def show
7
- @connected = masks_config.configured?(request)
8
- @can_disconnect = @connected && masks_config.can_forget?
7
+ return begin! unless masks_config.configured?(request)
8
+
9
+ @can_disconnect = masks_config.can_forget?
9
10
  end
10
11
 
11
12
  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)
13
+ begin!
18
14
  end
19
15
 
20
16
  def callback
@@ -31,10 +27,6 @@ module Masks
31
27
  refuse(e)
32
28
  end
33
29
 
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
30
  def destroy
39
31
  return redirect_to(masks_config.after_sign_out) unless masks_signed_in?
40
32
  return redirect_to(masks_handshake_path) unless masks_config.can_forget?
@@ -51,6 +43,15 @@ module Masks
51
43
 
52
44
  private
53
45
 
46
+ def begin!
47
+ pending = masks_handshakes.open
48
+ started = masks_config.handshake_for(request).start(state: pending.id)
49
+
50
+ redirect_to started[:url], allow_other_host: true
51
+ rescue Masks::Client::Error => e
52
+ refuse(e)
53
+ end
54
+
54
55
  def forget_upstream
55
56
  masks_registration&.delete
56
57
  rescue Masks::Client::Unregistered
@@ -0,0 +1,32 @@
1
+ module Masks
2
+ module Rails
3
+ class LogoutsController < BaseController
4
+ skip_forgery_protection
5
+
6
+ def create
7
+ return refuse("invalid_request", "logout_token is required") if params[:logout_token].blank?
8
+
9
+ logout = masks_session.logout_token(params[:logout_token])
10
+
11
+ heard = masks_config.logged_out!(request, logout)
12
+
13
+ response.headers["Cache-Control"] = "no-store"
14
+
15
+ head(heard ? :ok : :not_implemented)
16
+ rescue Masks::Client::InvalidToken => e
17
+ refuse("invalid_request", e.message)
18
+ rescue Masks::Rails::Configuration::Unconfigured => e
19
+ refuse("invalid_request", e.message)
20
+ end
21
+
22
+ private
23
+
24
+ def refuse(code, description)
25
+ response.headers["Cache-Control"] = "no-store"
26
+
27
+ render json: { "error" => code, "error_description" => description },
28
+ status: :bad_request
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,40 +1,24 @@
1
- <% if @connected %>
2
- <% content_for :title, "Reconnect this app" %>
1
+ <% content_for :title, "Reconnect this app" %>
3
2
 
4
- <h1>This app is connected</h1>
3
+ <h1>This app is connected</h1>
5
4
 
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>
5
+ <p class="muted">
6
+ Reconnecting replaces the credentials it holds now with a fresh pair. It is
7
+ how you rotate them, and it is the only way to move this app to a different
8
+ account. Nothing is typed in and no secret travels through your browser.
9
+ </p>
11
10
 
12
- <p class="muted">
13
- It will stop working between the approval and the moment it picks the new
14
- credentials up.
15
- </p>
11
+ <p class="muted">
12
+ It will stop working between the approval and the moment it picks the new
13
+ credentials up.
14
+ </p>
16
15
 
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>
16
+ <%= form_with url: handshake_path, method: :post do %>
17
+ <button type="submit">Reconnect it</button>
18
+ <% end %>
36
19
 
37
- <%= form_with url: handshake_path, method: :post do %>
38
- <button type="submit">Connect it</button>
20
+ <% if @can_disconnect %>
21
+ <%= form_with url: handshake_path, method: :delete do %>
22
+ <button type="submit">Disconnect it</button>
39
23
  <% end %>
40
24
  <% end %>
data/config/routes.rb CHANGED
@@ -4,6 +4,7 @@ Masks::Rails::Engine.routes.draw do
4
4
  get "/avatar", to: "avatars#show", as: :avatar
5
5
  get "/callback", to: "sessions#callback", as: :callback
6
6
  match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
7
+ post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
7
8
 
8
9
  get "/handshake", to: "handshakes#show", as: :handshake
9
10
  post "/handshake", to: "handshakes#create"
@@ -0,0 +1,91 @@
1
+ require "masks/client"
2
+
3
+ module Masks
4
+ module Client
5
+ class Delegations
6
+ class Fake
7
+ Connected = Struct.new(:connection, :provider, :subject, :secret, :refused, :unavailable, keyword_init: true)
8
+
9
+ attr_reader :releases, :redirect_uri
10
+
11
+ def initialize(redirect_uri: "https://app.test/connect/callback", lifetime: 3600)
12
+ @redirect_uri = redirect_uri
13
+ @lifetime = lifetime
14
+ @codes = {}
15
+ @connections = {}
16
+ @releases = 0
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ def start(provider:, prompt: nil, max_age: nil, state: SecureRandom.urlsafe_base64(24))
21
+ query = URI.encode_www_form({ "provider" => provider, "state" => state, "prompt" => prompt, "max_age" => max_age }.compact)
22
+
23
+ { "url" => "https://masks.fake/authorize?#{query}", "state" => state, "verifier" => SecureRandom.hex(16), "provider" => provider.to_s }
24
+ end
25
+
26
+ def approve(started, subject: "fake-subject", connection: SecureRandom.uuid)
27
+ code = SecureRandom.hex(12)
28
+
29
+ @lock.synchronize do
30
+ @codes[code] = { "provider" => started["provider"], "subject" => subject, "connection" => connection }
31
+ end
32
+
33
+ { "code" => code, "state" => started["state"] }
34
+ end
35
+
36
+ def deny(started, error: "access_denied", description: "the person declined")
37
+ { "error" => error, "error_description" => description, "state" => started["state"] }
38
+ end
39
+
40
+ def finish(params:, started:)
41
+ params = params.to_h.transform_keys(&:to_s)
42
+
43
+ raise Refused.new(params["error"], params["error_description"]) if params["error"].to_s != ""
44
+ raise Refused.new("invalid_state", "the state did not match the one this connection started with") unless params["state"] == started["state"]
45
+
46
+ granted = @lock.synchronize { @codes.delete(params["code"]) }
47
+
48
+ raise Refused.new("invalid_grant", "that code is not valid") if granted.nil?
49
+
50
+ secret = SecureRandom.hex(16)
51
+
52
+ @lock.synchronize do
53
+ @connections[granted["connection"]] = Connected.new(
54
+ connection: granted["connection"], provider: granted["provider"], subject: granted["subject"], secret: secret
55
+ )
56
+ end
57
+
58
+ Held.new(connection: granted["connection"], provider: granted["provider"], provider_name: granted["provider"].to_s.capitalize,
59
+ label: nil, subject: granted["subject"], secret: secret)
60
+ end
61
+
62
+ def token(secret, connection:)
63
+ held = @lock.synchronize { @connections[connection.to_s] }
64
+
65
+ raise Refused.new("invalid_grant", "that connection is unknown") if held.nil?
66
+ raise Unavailable.new("masks is not answering", secret: secret) if held.unavailable
67
+ raise Refused.new("invalid_grant", held.refused, secret: secret) if held.refused
68
+ raise Refused.new("invalid_grant", "that refresh token is not valid or has expired") unless held.secret == secret.to_s
69
+
70
+ rotated = SecureRandom.hex(16)
71
+
72
+ @lock.synchronize do
73
+ held.secret = rotated
74
+ @releases += 1
75
+ end
76
+
77
+ Upstream.new(access_token: "#{held.provider}-access-#{@releases}", expires_at: Time.now.to_i + @lifetime,
78
+ scope: "", secret: rotated)
79
+ end
80
+
81
+ def revoke(connection, reason: "the person stopped this application using that account")
82
+ @lock.synchronize { @connections.fetch(connection.to_s).refused = reason }
83
+ end
84
+
85
+ def unavailable(connection, now: true)
86
+ @lock.synchronize { @connections.fetch(connection.to_s).unavailable = now }
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,117 @@
1
+ module Masks
2
+ module Client
3
+ class Delegations
4
+ SCOPE = "masks:delegate:".freeze
5
+ UPSTREAM_ACCESS_TOKEN = "urn:masks:params:oauth:token-type:upstream_access_token".freeze
6
+ REFUSALS = %w[invalid_grant insufficient_scope invalid_target unauthorized_client access_denied
7
+ login_required interaction_required consent_required invalid_scope].freeze
8
+
9
+ class Refused < Error
10
+ attr_reader :code, :description, :secret
11
+
12
+ def initialize(code, description, secret: nil)
13
+ super([ code, description ].compact.join(": "))
14
+
15
+ @code = code
16
+ @description = description
17
+ @secret = secret
18
+ end
19
+
20
+ def signed_in_again?
21
+ %w[login_required interaction_required].include?(code)
22
+ end
23
+ end
24
+
25
+ class Unavailable < Error
26
+ attr_reader :secret
27
+
28
+ def initialize(message, secret: nil)
29
+ super(message)
30
+
31
+ @secret = secret
32
+ end
33
+ end
34
+
35
+ Held = Struct.new(:connection, :provider, :provider_name, :label, :subject, :secret, keyword_init: true)
36
+
37
+ Upstream = Struct.new(:access_token, :expires_at, :scope, :secret, keyword_init: true) do
38
+ def expired?(leeway: 60)
39
+ Time.now.to_i + leeway >= expires_at.to_i
40
+ end
41
+ end
42
+
43
+ attr_reader :issuer, :client_id, :client_secret, :redirect_uri
44
+
45
+ def initialize(issuer:, client_id:, client_secret:, redirect_uri:)
46
+ @issuer = Issuer.resolve(issuer)
47
+ @client_id = client_id
48
+ @client_secret = client_secret
49
+ @redirect_uri = redirect_uri
50
+ end
51
+
52
+ def start(provider:, prompt: nil, max_age: nil, state: SecureRandom.urlsafe_base64(24))
53
+ started = session.start(scope: [ "openid", "offline_access", "#{SCOPE}#{provider}" ], prompt: prompt,
54
+ max_age: max_age, state: state)
55
+
56
+ { "url" => started[:url], "state" => started[:state], "verifier" => started[:verifier], "provider" => provider.to_s }
57
+ end
58
+
59
+ def finish(params:, started:)
60
+ params = params.to_h.transform_keys(&:to_s)
61
+ started = started.to_h.transform_keys(&:to_s)
62
+
63
+ raise Refused.new(params["error"], params["error_description"]) if params["error"].to_s != ""
64
+
65
+ unless params["state"].to_s != "" && OpenSSL.secure_compare(params["state"].to_s, started["state"].to_s)
66
+ raise Refused.new("invalid_state", "the state did not match the one this connection started with")
67
+ end
68
+
69
+ tokens = answered { session.complete(code: params["code"].to_s, verifier: started["verifier"].to_s) }
70
+ held = tokens.delegations.find { |one| one["provider"] == started["provider"] }
71
+
72
+ raise Refused.new("access_denied", "masks connected nothing for #{started['provider']}") if held.nil?
73
+ raise Refused.new("invalid_grant", "masks issued no refresh token to keep the connection with") if tokens.refresh_token.to_s == ""
74
+
75
+ Held.new(
76
+ connection: held["connection"], provider: held["provider"], provider_name: held["provider_name"],
77
+ label: held["label"], subject: held["subject"], secret: tokens.refresh_token
78
+ )
79
+ end
80
+
81
+ def token(secret, connection:)
82
+ refreshed = answered { session.refresh(secret.to_s) }
83
+ rotated = refreshed.refresh_token.to_s == "" ? secret : refreshed.refresh_token
84
+
85
+ released = answered(secret: rotated) do
86
+ session.exchange(refreshed.access_token, requested_token_type: UPSTREAM_ACCESS_TOKEN, audience: connection.to_s)
87
+ end
88
+
89
+ Upstream.new(access_token: released.access_token, expires_at: released.expires_at, scope: released.scope, secret: rotated)
90
+ end
91
+
92
+ private
93
+
94
+ def session
95
+ @session ||= Session.new(issuer: issuer, client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri)
96
+ end
97
+
98
+ def answered(secret: nil)
99
+ yield
100
+ rescue Unregistered
101
+ raise
102
+ rescue Rejected => e
103
+ raise Unavailable.new("masks answered #{e.status}: #{e.message}", secret: secret) if unavailable?(e)
104
+
105
+ raise Refused.new(e.code, e.description, secret: secret)
106
+ rescue Unreachable => e
107
+ raise Unavailable.new(e.message, secret: secret)
108
+ end
109
+
110
+ def unavailable?(rejection)
111
+ return false if REFUSALS.include?(rejection.code)
112
+
113
+ rejection.code == "temporarily_unavailable" || rejection.status.to_i >= 500 || rejection.status.to_i == 429
114
+ end
115
+ end
116
+ end
117
+ end
@@ -5,16 +5,18 @@ module Masks
5
5
  GRANT_TYPES = %w[authorization_code refresh_token].freeze
6
6
  AUTH_METHOD = "client_secret_basic".freeze
7
7
 
8
- attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to
8
+ attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to,
9
+ :backchannel_logout_uri
9
10
 
10
11
  def initialize(issuer, name:, resource:, redirect_uris:, return_to:,
11
- scope: Session::DEFAULT_SCOPE)
12
+ scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil)
12
13
  @issuer = Issuer.resolve(issuer)
13
14
  @name = name.to_s
14
15
  @resource = resource.to_s
15
16
  @redirect_uris = Array(redirect_uris).map(&:to_s)
16
17
  @scope = Array(scope).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?)
17
18
  @return_to = return_to.to_s
19
+ @backchannel_logout_uri = backchannel_logout_uri&.to_s
18
20
  end
19
21
 
20
22
  def endpoint
@@ -34,6 +36,7 @@ module Masks
34
36
  [ "state", state ]
35
37
  ]
36
38
 
39
+ query << [ "backchannel_logout_uri", backchannel_logout_uri ] if backchannel_logout_uri
37
40
  redirect_uris.each { |uri| query << [ "redirect_uris", uri ] }
38
41
 
39
42
  "#{endpoint}?#{URI.encode_www_form(query)}"
@@ -61,7 +64,8 @@ module Masks
61
64
  redirect_uris: redirect_uris,
62
65
  grant_types: GRANT_TYPES,
63
66
  scope: scope,
64
- token_endpoint_auth_method: AUTH_METHOD
67
+ token_endpoint_auth_method: AUTH_METHOD,
68
+ backchannel_logout_uri: backchannel_logout_uri
65
69
  )
66
70
  end
67
71
 
@@ -5,7 +5,7 @@ module Masks
5
5
  to_h["active"] == true
6
6
  end
7
7
 
8
- def username
8
+ def nickname
9
9
  self["username"]
10
10
  end
11
11
 
@@ -51,6 +51,10 @@ module Masks
51
51
  discovery.fetch(name) { raise Rejected.new("invalid_issuer", "#{url} publishes no #{name}") }
52
52
  end
53
53
 
54
+ def backchannel_logout?
55
+ discovery["backchannel_logout_supported"] == true
56
+ end
57
+
54
58
  def avatar_styles
55
59
  discovery["avatar_styles_supported"] || Claims::Avatars::STYLES
56
60
  end
@@ -0,0 +1,60 @@
1
+ module Masks
2
+ module Client
3
+ class Logout
4
+ EVENT = "http://schemas.openid.net/event/backchannel-logout".freeze
5
+ ALGORITHMS = Verifier::ALGORITHMS
6
+ LEEWAY = 60
7
+
8
+ class << self
9
+ def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
10
+ held = Verifier
11
+ .new(issuer, audience: audience, algorithms: algorithms)
12
+ .verify(token, required: %w[iss aud iat jti events])
13
+
14
+ new(held).validate!
15
+ end
16
+ end
17
+
18
+ attr_reader :claims
19
+
20
+ def initialize(claims)
21
+ @claims = claims
22
+ end
23
+
24
+ def subject
25
+ claims["sub"]
26
+ end
27
+
28
+ def sid
29
+ claims["sid"]
30
+ end
31
+
32
+ def jti
33
+ claims["jti"]
34
+ end
35
+
36
+ def issued_at
37
+ Time.at(claims["iat"].to_i).utc
38
+ end
39
+
40
+ def validate!
41
+ refuse!("logout token carries a nonce, so it is an id token") if claims.key?("nonce")
42
+ refuse!("logout token names neither a subject nor a session") if subject.nil? && sid.nil?
43
+ refuse!("logout token was issued in the future") if issued_at > Time.now.utc + LEEWAY
44
+
45
+ held = claims["events"]
46
+
47
+ refuse!("logout token has no events claim") unless held.is_a?(Hash)
48
+ refuse!("logout token is not about a logout") unless held[EVENT].is_a?(Hash)
49
+
50
+ self
51
+ end
52
+
53
+ private
54
+
55
+ def refuse!(said)
56
+ raise InvalidToken, said
57
+ end
58
+ end
59
+ end
60
+ end
@@ -23,7 +23,8 @@ module Masks
23
23
  "token_endpoint_auth_method" => attributes[:token_endpoint_auth_method],
24
24
  "application_type" => attributes[:application_type],
25
25
  "client_uri" => attributes[:client_uri],
26
- "logo_uri" => attributes[:logo_uri]
26
+ "logo_uri" => attributes[:logo_uri],
27
+ "backchannel_logout_uri" => attributes[:backchannel_logout_uri]
27
28
  }.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
28
29
  end
29
30
 
@@ -32,8 +33,6 @@ module Masks
32
33
  list.empty? ? default : list
33
34
  end
34
35
 
35
- # Rebuild a registration from what a consumer stored, so RFC 7592's read,
36
- # update and delete are reachable without having just created it.
37
36
  def self.held(issuer, credentials)
38
37
  held = credentials.to_h.transform_keys(&:to_s)
39
38
  return nil if held["registration_client_uri"].blank? || held["registration_access_token"].blank?
@@ -14,7 +14,7 @@ module Masks
14
14
  end
15
15
 
16
16
  def start(resource: nil, prompt: nil, scope: nil, state: SecureRandom.urlsafe_base64(24),
17
- nonce: SecureRandom.urlsafe_base64(24))
17
+ nonce: SecureRandom.urlsafe_base64(24), max_age: nil)
18
18
  pkce = Pkce.generate
19
19
  scopes = Array(scope || self.scope)
20
20
  nonce = nil unless scopes.include?("openid")
@@ -33,6 +33,7 @@ module Masks
33
33
 
34
34
  Array(resource).each { |value| pairs << [ "resource", value ] }
35
35
  pairs << [ "prompt", prompt ] if prompt
36
+ pairs << [ "max_age", max_age.to_i ] if max_age
36
37
 
37
38
  {
38
39
  url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
@@ -69,7 +70,7 @@ module Masks
69
70
  Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
70
71
  end
71
72
 
72
- def exchange(subject_token, scope: nil, resource: nil, lifetime: nil)
73
+ def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil)
73
74
  form = [
74
75
  [ "grant_type", Tokens::EXCHANGE ],
75
76
  [ "client_id", client_id ],
@@ -77,6 +78,9 @@ module Masks
77
78
  [ "subject_token_type", Tokens::ACCESS_TOKEN ]
78
79
  ]
79
80
 
81
+ form << [ "requested_token_type", requested_token_type ] if requested_token_type
82
+ Array(audience).each { |value| form << [ "audience", value ] }
83
+
80
84
  form << [ "scope", Array(scope).join(" ") ] if scope
81
85
  form << [ "requested_lifetime", lifetime.to_i ] if lifetime
82
86
  Array(resource).each { |value| form << [ "resource", value ] }
@@ -92,6 +96,10 @@ module Masks
92
96
  true
93
97
  end
94
98
 
99
+ def logout_token(token)
100
+ Logout.verify(token, issuer: issuer, audience: client_id)
101
+ end
102
+
95
103
  def end_session_url(post_logout_redirect_uri: nil, state: nil, id_token_hint: nil)
96
104
  pairs = [ [ "client_id", client_id ] ]
97
105
  pairs << [ "id_token_hint", id_token_hint ] if id_token_hint
@@ -4,7 +4,8 @@ module Masks
4
4
  EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
5
5
  ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token".freeze
6
6
 
7
- attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at
7
+ attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at,
8
+ :delegations
8
9
 
9
10
  def self.granted(body)
10
11
  token = new(body)
@@ -26,6 +27,7 @@ module Masks
26
27
  @token_type = body["token_type"] || "Bearer"
27
28
  @scope = body["scope"].to_s
28
29
  @expires_in = body["expires_in"].to_i
30
+ @delegations = Array(body["delegations"])
29
31
  @obtained_at = Time.now.to_i
30
32
  end
31
33
 
data/lib/masks/client.rb CHANGED
@@ -19,33 +19,79 @@ require_relative "client/claims"
19
19
  require_relative "client/introspection"
20
20
  require_relative "client/session"
21
21
  require_relative "client/verifier"
22
+ require_relative "client/logout"
22
23
  require_relative "client/resource"
23
24
  require_relative "client/rack"
24
25
  require_relative "client/registration"
25
26
  require_relative "client/handshake"
27
+ require_relative "client/delegations"
26
28
 
27
29
  module Masks
30
+ # = Masks::Client
31
+ #
32
+ # The protocol half of the gem: plain Ruby, no \Rails, no database. Every
33
+ # entry point here is a class method that builds one of the objects below.
34
+ #
35
+ # Which one you want depends on what the application is doing:
36
+ #
37
+ # [issuer] an app signing people in — discovery, PKCE, the code exchange
38
+ # [verifier] an API checking a bearer it was handed
39
+ # [resource] an API publishing what it is and which scopes it takes
40
+ # [handshake] an app registering itself, once, without a copied secret
41
+ #
42
+ # Everything reachable from here talks HTTP to a masks issuer and holds no
43
+ # state of its own beyond the discovery cache in ::registry.
28
44
  module Client
29
45
  class << self
46
+ # The process-wide cache of resolved issuers, so discovery is fetched
47
+ # once rather than per request.
48
+ #
49
+ # @return [Masks::Client::Registry]
30
50
  def registry
31
51
  @registry ||= Registry.new
32
52
  end
33
53
 
54
+ # Resolves +url+ through its discovery document and returns the issuer
55
+ # it describes. Cached in ::registry, so calling this per request is
56
+ # cheap after the first.
57
+ #
58
+ # issuer = Masks::Client.issuer("https://auth.example")
59
+ # issuer.authorization_url(client_id: id, redirect_uri: uri)
60
+ #
61
+ # @return [Masks::Client::Issuer]
34
62
  def issuer(url, **options)
35
63
  Issuer.resolve(url, **options)
36
64
  end
37
65
 
66
+ # Checks tokens minted by the issuer at +url+ against +audience+ — the
67
+ # API's own URL, which the token names in +aud+. A token issued for
68
+ # anything else is refused rather than merely noted.
69
+ #
70
+ # @return [Masks::Client::Verifier]
38
71
  def verifier(url, audience:, **options)
39
72
  Verifier.new(issuer(url), audience: audience, **options)
40
73
  end
41
74
 
75
+ # Describes this API to callers: the scopes it accepts and the issuer
76
+ # that may mint tokens for it, served as RFC 9728 metadata.
77
+ #
78
+ # @return [Masks::Client::Resource]
42
79
  def resource(url, issuer:, **options)
43
80
  Resource.new(issuer: issuer, url: url, **options)
44
81
  end
45
82
 
83
+ # Registers this application against a masks issuer. A person approves
84
+ # it in their browser and the credentials come back server to server, so
85
+ # no secret is pasted between the two.
86
+ #
87
+ # @return [Masks::Client::Handshake]
46
88
  def handshake(url, **options)
47
89
  Handshake.new(url, **options)
48
90
  end
91
+
92
+ def delegations(issuer, **options)
93
+ Delegations.new(issuer: issuer, **options)
94
+ end
49
95
  end
50
96
  end
51
97
  end
@@ -5,8 +5,9 @@ module Masks
5
5
 
6
6
  attr_accessor :scope, :namespace, :resource, :resource_scopes, :after_sign_in,
7
7
  :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
8
- :credentials_path, :authenticate_everything
9
- attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget
8
+ :credentials_path, :authenticate_everything, :delegates
9
+ attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
10
+ :delegation_redirect_uri
10
11
 
11
12
  def initialize
12
13
  @scope = Masks::Client::Session::DEFAULT_SCOPE
@@ -17,12 +18,9 @@ module Masks
17
18
  @sign_out_of_issuer = false
18
19
  @parent_controller = "ActionController::Base"
19
20
  @authenticate_everything = false
21
+ @delegates = false
20
22
  end
21
23
 
22
- # An app that has not said where to keep its credentials gets one file
23
- # under the Rails root, so the handshake works before anybody writes a
24
- # `store` lambda. `things` overrides both because it is multi-tenant,
25
- # which is the interesting case rather than the common one.
26
24
  def default_credentials
27
25
  @default_credentials ||= Credentials.new(
28
26
  credentials_path || ::Rails.root.join("config", "masks.json")
@@ -43,6 +41,12 @@ module Masks
43
41
  "#{request.base_url}#{routes.callback_path}"
44
42
  end
45
43
 
44
+ def delegation_redirect_uri_for(request)
45
+ return nil unless delegates
46
+
47
+ resolve(@delegation_redirect_uri, request)
48
+ end
49
+
46
50
  def resource_for(request)
47
51
  resolve(@resource, request)
48
52
  end
@@ -66,8 +70,21 @@ module Masks
66
70
  client_id_for(request).present?
67
71
  end
68
72
 
69
- # An app that keeps its own credentials has to say how to drop them, and
70
- # until it does the engine does not offer a button it cannot honour.
73
+ def backchannel_logout_uri_for(request)
74
+ return nil unless @logged_out.respond_to?(:call)
75
+ return nil unless Masks::Client::Issuer.resolve(issuer_for(request)).backchannel_logout?
76
+
77
+ "#{request.base_url}#{routes.backchannel_logout_path}"
78
+ end
79
+
80
+ def logged_out!(request, logout)
81
+ return false unless @logged_out.respond_to?(:call)
82
+
83
+ @logged_out.arity == 1 ? @logged_out.call(logout) : @logged_out.call(request, logout)
84
+
85
+ true
86
+ end
87
+
71
88
  def can_forget?
72
89
  @forget.respond_to?(:call) || !@store.respond_to?(:call)
73
90
  end
@@ -85,10 +102,6 @@ module Masks
85
102
  @store.call(request, registration)
86
103
  end
87
104
 
88
- # The engine depends on `handshake_endpoint` being in the discovery
89
- # document, and on the approval flow behind it. An issuer that predates
90
- # both should say so here rather than at the one screen that exists to
91
- # be the first thing anybody touches.
92
105
  MINIMUM_ISSUER = 1
93
106
 
94
107
  def issuer_speaks!(request)
@@ -132,22 +145,21 @@ module Masks
132
145
  name: name_for(request),
133
146
  resource: Array(resource_for(request)).first ||
134
147
  raise(Unconfigured, "Masks::Rails.config.resource is not set"),
135
- redirect_uris: [ redirect_uri_for(request) ],
148
+ redirect_uris: [ redirect_uri_for(request), delegation_redirect_uri_for(request) ].compact.uniq,
136
149
  return_to: return_to_for(request),
137
- scope: approved_scope
150
+ scope: approved_scope,
151
+ backchannel_logout_uri: backchannel_logout_uri_for(request)
138
152
  )
139
153
  end
140
154
 
141
- # What the handshake asks to be approved for, which is not what a sign-in
142
- # requests. An app that owns a namespace asks for the namespace once, so
143
- # that adding a capability later is a deployment rather than an approval
144
- # round; the authorize request still names the scopes it actually wants.
145
155
  def approved_scope
146
- return scope if namespace.blank?
156
+ delegated = delegates ? [ Masks::Client::Delegations::SCOPE ] : []
157
+
158
+ return Array(scope) + delegated if namespace.blank?
147
159
 
148
160
  outside = Array(scope).reject { |name| name.to_s.start_with?(namespace) }
149
161
 
150
- outside + [ namespace ]
162
+ outside + [ namespace ] + delegated
151
163
  end
152
164
 
153
165
  def return_to_for(request)
@@ -1,9 +1,5 @@
1
1
  module Masks
2
2
  module Rails
3
- # The default place a single-tenant app keeps what the handshake gives it,
4
- # so `config.store` has something to be before a consumer writes one. An
5
- # app with more than one issuer wants its own — `things` keeps these on
6
- # `tenants`, because a handshake is per tenant there.
7
3
  class Credentials
8
4
  KEYS = %w[client_id client_secret registration_access_token registration_client_uri].freeze
9
5
 
@@ -5,11 +5,6 @@ module Masks
5
5
 
6
6
  config.masks = Masks::Rails.config
7
7
 
8
- # Opt-in, not blanket. This used to `include Masks::Rails::Authentication`
9
- # into every controller in the host app, which lands twenty-five `masks_*`
10
- # methods and four helper_methods on code the consumer owns, with no way
11
- # to say no — and a name collision found that way is the kind of thing
12
- # `grants:` versus `scope:` already cost this project once.
13
8
  initializer "masks.authentication" do
14
9
  ActiveSupport.on_load(:action_controller) do
15
10
  include Masks::Rails::Authentication if Masks::Rails.config.authenticate_everything
data/lib/masks/rails.rb CHANGED
@@ -1,5 +1,31 @@
1
1
  require "rails"
2
2
 
3
+ module Masks
4
+ # = Masks::Rails
5
+ #
6
+ # The consumer half: a \Rails engine that mounts the code flow into an
7
+ # application, so signing in against a masks issuer is configuration rather
8
+ # than a controller you write.
9
+ #
10
+ # It loads only when +Rails::Engine+ is already defined. Requiring the gem
11
+ # from a plain Ruby process gets Masks::Client and nothing else.
12
+ #
13
+ # Three pieces do the work:
14
+ #
15
+ # [Configuration] the issuer, credentials and routes, set once in an
16
+ # initializer and validated on boot rather than on the
17
+ # first request that needs them
18
+ # [Authentication] +masks_login_url+, the callback, and the session the
19
+ # app reads +current_actor+ from
20
+ # [ProtectedResource] the other direction — checking a bearer this app was
21
+ # handed, for an API rather than a browser
22
+ #
23
+ # This engine is the client. It never runs in the same process as the
24
+ # provider, which is a standalone deployable holding its own database.
25
+ module Rails
26
+ end
27
+ end
28
+
3
29
  require_relative "version"
4
30
  require_relative "client"
5
31
  require_relative "rails/credentials"
data/lib/masks/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Masks
2
- VERSION = "0.6.0".freeze
2
+ VERSION = "0.7.0".freeze
3
3
  end
data/lib/masks.rb CHANGED
@@ -1,3 +1,20 @@
1
+ # = \Masks
2
+ #
3
+ # Signs an application in against a masks issuer.
4
+ #
5
+ # The gem is two halves. Masks::Client is plain Ruby and speaks the protocol —
6
+ # discovery, PKCE, the code exchange, token verification. Masks::Rails mounts
7
+ # that flow into a \Rails app, and loads only when +Rails::Engine+ is already
8
+ # defined, so requiring this gem outside \Rails costs nothing.
9
+ #
10
+ # issuer = Masks::Client.issuer("https://auth.example")
11
+ # issuer.authorization_url(client_id: id, redirect_uri: uri)
12
+ #
13
+ # The provider itself is not in here. It is a deployable that holds a database
14
+ # and the per-tenant signing keys, and it stays standalone.
15
+ module Masks
16
+ end
17
+
1
18
  require_relative "masks/version"
2
19
  require_relative "masks/client"
3
20
 
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: masks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
- - geiger
7
+ - the masks authors
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -36,6 +36,7 @@ files:
36
36
  - app/controllers/masks/rails/avatars_controller.rb
37
37
  - app/controllers/masks/rails/base_controller.rb
38
38
  - app/controllers/masks/rails/handshakes_controller.rb
39
+ - app/controllers/masks/rails/logouts_controller.rb
39
40
  - app/controllers/masks/rails/sessions_controller.rb
40
41
  - app/views/layouts/masks/rails/plain.html.erb
41
42
  - app/views/masks/rails/handshakes/refused.html.erb
@@ -46,11 +47,14 @@ files:
46
47
  - lib/masks.rb
47
48
  - lib/masks/client.rb
48
49
  - lib/masks/client/claims.rb
50
+ - lib/masks/client/delegations.rb
51
+ - lib/masks/client/delegations/fake.rb
49
52
  - lib/masks/client/errors.rb
50
53
  - lib/masks/client/handshake.rb
51
54
  - lib/masks/client/http.rb
52
55
  - lib/masks/client/introspection.rb
53
56
  - lib/masks/client/issuer.rb
57
+ - lib/masks/client/logout.rb
54
58
  - lib/masks/client/pkce.rb
55
59
  - lib/masks/client/rack.rb
56
60
  - lib/masks/client/registration.rb