masks 0.6.0 → 0.8.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: 3ec26aa454cf249dd26ce7e15444b43890e4e75954968c76cba3297b63f9819e
4
+ data.tar.gz: 19609ef3a180ab7d6c675cac13cef843adb3ffc742d83b7fd64d7120bbcc93b5
5
5
  SHA512:
6
- metadata.gz: 39e55aecad7e80a3cef669b9bcbe16f6f942c326bf98a9f9da954663365740d9e31a814a3da644ef68bd0090f040f853ff4b3bdda34f2f8fe6dd4b749190d138
7
- data.tar.gz: 4cca8c0d431151c0a0865969dcc38767388ec4f93c1ab5e2b824e7dd875f8a38135969d7b8ed5a1966eb9b0f51b3fb60e8cff887c67d180561b3474e47653d84
6
+ metadata.gz: 4a02a532dc018b73c27b350da38335b5ec27c5665d8b498564fd11b220e386919df8ecc9a24b80fb62f21d193c7a66166e99d2dc41c86fef3cb515f5aacc2b0d
7
+ data.tar.gz: fbbb524a91dda55fc6ccf631d1ef8b270b9ff9835b894855dcd0dd07a054fcfb380c6fdea75930b93950909ba1e3618940215418c9fca7f0a5f32e30b33c96b4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/masksrb/masks/compare/gem/v0.7.0...gem/v0.8.0) (2026-09-28)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * a resource server on an older gem still accepts the new tokens, but this gem refuses access tokens from a server that does not type them.
9
+
10
+ ### Features
11
+
12
+ * an access token is typed at+jwt, and nothing takes a token of another type in its place ([d0b4cfd](https://github.com/masksrb/masks/commit/d0b4cfdf802053359695495d417225e3a1578302))
13
+ * **client:** a Rails app lists the organizations a person belongs to and links them to another ([7f0d223](https://github.com/masksrb/masks/commit/7f0d2237799e04551d2c403de695c704e5455db8))
14
+ * **client:** a Rails app names the organization a person signs in to, reads their role, and keeps pages and APIs to members in a role ([579cecc](https://github.com/masksrb/masks/commit/579ceccac1c0137dfa1ed7a3894dea61e5cdd67e))
15
+ * **client:** a session signs its own client assertions, and asks for a token of its own ([614248b](https://github.com/masksrb/masks/commit/614248b70a3ee517fbd3c1f7151b66ec6406b487))
16
+ * **client:** an exchange hands over an ID token or an actor token ([6b67766](https://github.com/masksrb/masks/commit/6b67766ce275b9578d575349256638adddd5a599))
17
+ * **client:** the session says where the person manages their account, as account_url ([1d36e1d](https://github.com/masksrb/masks/commit/1d36e1d4daa8c5acfc9133a1896aa9384771dbd6))
18
+
19
+
20
+ ### Documentation
21
+
22
+ * Rails apps covers client, server, and engine mode ([41454a2](https://github.com/masksrb/masks/commit/41454a242438e4df246dcbc4dce94b6d8a022239))
23
+
24
+
25
+ ### Refactoring
26
+
27
+ * **client:** an organization key is checked in one place, and a refresh renews the whole identity from userinfo ([b349aae](https://github.com/masksrb/masks/commit/b349aaec4dd5c9790c72dd1dc8f91016a2adceea))
28
+
29
+ ## [0.7.0](https://github.com/masksrb/masks/compare/gem/v0.6.0...gem/v0.7.0) (2026-09-13)
30
+
31
+
32
+ ### ⚠ BREAKING CHANGES
33
+
34
+ * **client:** Masks::Client::Introspection#username is now #nickname.
35
+
36
+ ### Features
37
+
38
+ * **client:** an app can act on a logout the issuer tells it about ([4d0b7f7](https://github.com/masksrb/masks/commit/4d0b7f77a74b0e416dda3e90413ad989a909f344))
39
+ * **client:** an app that delegates asks its handshake for masks:delegate: and the token exchange ([912e856](https://github.com/masksrb/masks/commit/912e856b00a9f27d59af861cdb35da518fa862bc))
40
+ * **client:** an app that delegates registers where connecting a person's account comes back to ([738fba6](https://github.com/masksrb/masks/commit/738fba67e544f7c8a9a15cd55befbd66696b707b))
41
+ * **client:** an unconnected app walks straight into the handshake ([040388e](https://github.com/masksrb/masks/commit/040388e188653f67ef7a59bc0aa9aef02ec351b2))
42
+ * **server:** an application is let use somebody's account elsewhere through a delegation ([4b7048c](https://github.com/masksrb/masks/commit/4b7048cb8ac4e174b9359a070b7a2d942a1c410d))
43
+
44
+
45
+ ### Fixes
46
+
47
+ * **client:** back-channel logout says 501 when the app does nothing about it ([60e1125](https://github.com/masksrb/masks/commit/60e1125c02adec7c822c6d3757210e825bb66cfc))
48
+
49
+
50
+ ### Documentation
51
+
52
+ * hold the copyright as the masks authors ([cffdeb5](https://github.com/masksrb/masks/commit/cffdeb59f14eb02f6a9513d5f7cda21ebaee2171))
53
+ * READMEs that match the code, and a server one that is not the scaffold ([e0148e1](https://github.com/masksrb/masks/commit/e0148e14e0092731bcd095bc0fc22a1589c3acc0))
54
+ * references are generated from the code that defines them ([ba74550](https://github.com/masksrb/masks/commit/ba745501e651b797948e5abd5640a1825616100c))
55
+ * the gem reference survives rdoc 8, and lists what a Concern adds ([36df12e](https://github.com/masksrb/masks/commit/36df12e72cc52f5db650d16778ecc501bf9db14a))
56
+ * the rose window is the site's logo, favicon and hero, and heads every README ([72176e4](https://github.com/masksrb/masks/commit/72176e49c81bc0a5d6730f3b9e5eaa693db8a659))
57
+
58
+
59
+ ### Refactoring
60
+
61
+ * **client:** an introspection answers a nickname, not a username ([f23dff5](https://github.com/masksrb/masks/commit/f23dff537320982d42a8e4823bea784a3d80ec33))
62
+ * comments are gone; the code says what it does ([0fc8e6a](https://github.com/masksrb/masks/commit/0fc8e6a4692e3f09831514b6d167c3e23d7db419))
63
+ * every suite is named for what it proves, under one test directory ([315b371](https://github.com/masksrb/masks/commit/315b371e64baa4441014669744a61c88341d27c7))
64
+ * rename the example tenant from jons to demo ([aef32a6](https://github.com/masksrb/masks/commit/aef32a670b4b3c696089c07bf15103e1d981eb00))
65
+ * **server:** delegation shares what sign-in and linking already had, and asks the database less ([44b8c4d](https://github.com/masksrb/masks/commit/44b8c4d303dfcc6de60f98d7824c544bd67e347c))
66
+ * take the owner's domain out of the boundary check ([df580ee](https://github.com/masksrb/masks/commit/df580ee1fb5820d30a57a88f3aa9825c97682725))
67
+
3
68
  ## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
4
69
 
5
70
 
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/engine/public/masks-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,14 +97,62 @@ 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.
102
110
 
111
+ ### Organizations
112
+
113
+ A person signs in to an app as a member of one organization when the app asks for the
114
+ `organization` scope. Someone in several organizations picks one, and an app can pick for them by
115
+ naming it: `/auth?organization=acme`, or `config.organization` for every sign-in.
116
+
117
+ ```ruby
118
+ Masks::Rails.configure do |config|
119
+ config.scope = %w[openid profile email offline_access organization]
120
+ config.organization = ->(request) { request.subdomain }
121
+ end
122
+
123
+ class BillingController < ApplicationController
124
+ include Masks::Rails::Authentication
125
+
126
+ masks_members_only! role: %w[owner billing]
127
+ end
128
+ ```
129
+
130
+ `masks_organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
131
+ `nil`, and `masks_role?("owner")` asks about the role held in it. A refresh reads the person's
132
+ profile again, so a promotion or demotion reaches the app within an access token's lifetime.
133
+ `/auth/session` answers the same thing as `organization`.
134
+
135
+ `masks_organizations` lists every organization the person has joined, each with the role held in
136
+ it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
137
+ again naming the other one, which asks nothing more when they are already a member:
138
+
139
+ ```erb
140
+ <% masks_organizations.each do |held| %>
141
+ <%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
142
+ <% end %>
143
+ ```
144
+
145
+ `masks_members_only!` refuses with 403, as `insufficient_organization` when the person signed
146
+ in to none and `insufficient_role` when they hold another role. A resource server asks the same of a
147
+ token:
148
+
149
+ ```ruby
150
+ masks_protect! scope: "uris:catalog:write", role: "owner", organization: "acme"
151
+ ```
152
+
153
+ Naming an organization without asking for the scope still holds the sign-in to its members and its
154
+ sign-in policy; the app just learns nothing about the role.
155
+
103
156
  ### Avatars
104
157
 
105
158
  Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
@@ -138,6 +191,7 @@ a subdomain-per-tenant host needs.
138
191
  | `resource` | this app's own identifier, when it also accepts tokens |
139
192
  | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
140
193
  | `scope` | what to ask the issuer for; defaults to `openid profile email` |
194
+ | `organization` | the organization every sign-in names, by key |
141
195
  | `credentials` / `store` | where the handshake's result lives |
142
196
  | `credentials_path` | where the default store writes; `config/masks.json` |
143
197
  | `after_sign_in` / `after_sign_out` | paths on this host |
@@ -155,7 +209,7 @@ holds no protocol of its own.
155
209
 
156
210
  ```ruby
157
211
  session = Masks::Client::Session.new(
158
- issuer: "https://jons.auth.example.com",
212
+ issuer: "https://demo.auth.example.com",
159
213
  client_id: id, client_secret: secret,
160
214
  redirect_uri: "https://app.example.com/auth/callback"
161
215
  )
@@ -169,8 +223,7 @@ identity = session.identity(tokens)
169
223
  ```
170
224
 
171
225
  `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.
226
+ is owed, and the check on the way back is exact.
174
227
 
175
228
  Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
176
229
 
@@ -181,12 +234,11 @@ constructing one per request.
181
234
 
182
235
  ### The handshake
183
236
 
184
- The flow that connects a first-party app, rather than two helpers and sixty lines of
185
- state in every consumer:
237
+ The flow that connects a first-party app:
186
238
 
187
239
  ```ruby
188
240
  handshake = Masks::Client::Handshake.new(
189
- issuer, name: "things", resource: "https://app.example.com/mcp",
241
+ issuer, name: "uris", resource: "https://app.example.com/mcp",
190
242
  redirect_uris: [ "https://app.example.com/auth/callback" ],
191
243
  return_to: "https://app.example.com/"
192
244
  )
@@ -202,12 +254,12 @@ that is not the issuer it asked — each **before** anything is redeemed.
202
254
 
203
255
  ```ruby
204
256
  resource = Masks::Client::Resource.new(
205
- issuer: "https://jons.auth.example.com",
257
+ issuer: "https://demo.auth.example.com",
206
258
  url: "https://app.example.com/mcp",
207
- scopes: { "things:read" => "Search your catalog" }
259
+ scopes: { "uris:catalog:read" => "Search your catalog" }
208
260
  )
209
261
 
210
- claims = resource.authenticate(request.authorization, scope: "things:read")
262
+ claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
211
263
  claims.subject
212
264
  claims.tenant.subdomain
213
265
  ```
@@ -221,35 +273,87 @@ response.headers["WWW-Authenticate"] = resource.challenge(error)
221
273
 
222
274
  `resource.metadata` is the RFC 9728 document to serve at
223
275
  `/.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.
276
+ an auth server renders your scopes as sentences on its consent screen; it has no other
277
+ way to know what `uris:catalog:read` means.
226
278
 
227
279
  There is a Rack middleware for consumers that want the challenge below the framework:
228
280
 
229
281
  ```ruby
230
- use Masks::Client::Rack, resource: resource, scope: "things:read"
282
+ use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
231
283
  ```
232
284
 
233
285
  ### Introspection
234
286
 
235
287
  A resource server doing JWT-only validation cannot see a revocation until the token
236
- expires. Asking is the honest answer:
288
+ expires. Ask the issuer instead:
237
289
 
238
290
  ```ruby
239
291
  found = session.introspect(token)
240
- found.active? && found.permits?("things:read")
292
+ found.active? && found.permits?("uris:catalog:read")
241
293
  ```
242
294
 
243
295
  `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.
296
+ not active, so moving from local verification to introspection needs no extra boolean
297
+ check.
298
+
299
+ ### Somebody else's account
300
+
301
+ An app that acts as somebody at Google, Microsoft or an MCP server while they are away asks masks for
302
+ a [delegation](https://masks.pages.dev/concepts/delegation/). masks keeps and refreshes the
303
+ provider's tokens; the app keeps one secret per connection and trades it for a live access token when
304
+ the last one runs out.
305
+
306
+ ```ruby
307
+ delegations = Masks::Client.delegations(
308
+ ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
309
+ )
310
+
311
+ started = delegations.start(provider: "google")
312
+ session[:connecting] = started
313
+ redirect_to started["url"]
314
+
315
+ held = delegations.finish(params: params, started: session.delete(:connecting))
316
+ held.connection
317
+ held.secret
318
+
319
+ upstream = delegations.token(held.secret, connection: held.connection)
320
+ upstream.access_token
321
+ upstream.expires_at
322
+ upstream.secret
323
+ ```
324
+
325
+ A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
326
+ exchange with it.
327
+
328
+ `upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
329
+ has to connect again; `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks had
330
+ already rotated it, so keep it. Spend a secret from one place at a time: spending one twice revokes
331
+ it.
332
+
333
+ Tests use the fake, which needs no issuer:
334
+
335
+ ```ruby
336
+ require "masks/client/delegations/fake"
337
+
338
+ fake = Masks::Client::Delegations::Fake.new
339
+ started = fake.start(provider: "notion")
340
+ held = fake.finish(params: fake.approve(started), started: started)
341
+
342
+ fake.token(held.secret, connection: held.connection)
343
+ fake.revoke(held.connection)
344
+ fake.unavailable(held.connection)
345
+ ```
246
346
 
247
347
  ## Which issuers this speaks to
248
348
 
249
349
  masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
250
350
  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.
351
+ it. An older issuer is refused at configuration time, with a sentence saying so.
352
+
353
+ ## Documentation
354
+
355
+ This README is the reference for the gem. [masks.pages.dev](https://masks.pages.dev) carries what is
356
+ generated from the server's own code — the `/manage` GraphQL schema, and the design system.
253
357
 
254
358
  ## License
255
359
 
@@ -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
@@ -21,7 +21,9 @@ module Masks
21
21
  )
22
22
  started = masks_session.start(
23
23
  state: pending.id,
24
- resource: masks_config.resource_for(request)
24
+ resource: masks_config.resource_for(request),
25
+ organization: Masks::Client::Session.organization_key(params[:organization]) ||
26
+ masks_config.organization_for(request)
25
27
  )
26
28
 
27
29
  masks_requests.amend(
@@ -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"
@@ -60,7 +60,51 @@ module Masks
60
60
  end
61
61
  end
62
62
 
63
+ class Organization
64
+ OWNER = "owner".freeze
65
+
66
+ attr_reader :to_h
67
+
68
+ def initialize(hash)
69
+ @to_h = hash.is_a?(Hash) ? hash.transform_keys(&:to_s) : {}
70
+ end
71
+
72
+ def id
73
+ to_h["id"]
74
+ end
75
+
76
+ def key
77
+ to_h["key"]
78
+ end
79
+
80
+ def name
81
+ to_h["name"]
82
+ end
83
+
84
+ def role
85
+ to_h["role"]
86
+ end
87
+
88
+ def role?(*roles)
89
+ roles.flatten.map(&:to_s).include?(role)
90
+ end
91
+
92
+ def owner?
93
+ role?(OWNER)
94
+ end
95
+
96
+ def present?
97
+ !id.nil? && !key.nil? && !role.nil?
98
+ end
99
+
100
+ def ==(other)
101
+ other.is_a?(Organization) ? to_h == other.to_h : false
102
+ end
103
+ end
104
+
63
105
  AVATARS = "masks:avatars".freeze
106
+ ORGANIZATION = "org".freeze
107
+ ORGANIZATIONS = "orgs".freeze
64
108
 
65
109
  attr_reader :to_h
66
110
 
@@ -104,6 +148,31 @@ module Masks
104
148
  @avatars ||= Avatars.new(self[AVATARS])
105
149
  end
106
150
 
151
+ def organization
152
+ @organization ||= Organization.new(self[ORGANIZATION])
153
+ end
154
+
155
+ def organizations
156
+ @organizations ||= Array(self[ORGANIZATIONS]).map { |held| Organization.new(held) }.select(&:present?).freeze
157
+ end
158
+
159
+ def member!(*roles, organization: nil)
160
+ held = self.organization
161
+
162
+ unless held.present?
163
+ raise Forbidden.new("insufficient_organization", "this token names no organization",
164
+ scope: Session::ORGANIZATION)
165
+ end
166
+
167
+ if organization && held.key != organization.to_s && held.id != organization.to_s
168
+ raise Forbidden.new("insufficient_organization", "this token was issued for #{held.key}, not #{organization}")
169
+ end
170
+
171
+ return held if roles.flatten.empty? || held.role?(*roles)
172
+
173
+ raise Forbidden.new("insufficient_role", "#{held.role} in #{held.key} is not #{roles.flatten.join(' or ')}")
174
+ end
175
+
107
176
  def picture
108
177
  self["picture"] || avatars.photo || avatars.identicon
109
178
  end