masks 0.5.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: fab2f5044bc29a407688ac0c61ac703108849a24579a7aa2e30e18234d6e02f0
4
- data.tar.gz: 64572497372118a8ac2677634c26349c53cf93e4850188ceecaa2e2610d18546
3
+ metadata.gz: 5da19b0cb3099ebe46f2b4509615a75422fa0b2820abb799e8f182ce11f456ad
4
+ data.tar.gz: 4d5d6b573cccbca63439587cd3363d22be28dd0d8ff95051f32dcc8252494185
5
5
  SHA512:
6
- metadata.gz: ea4c88b70e03f9feb1a98886c1a04ae0f913f4c5a7de609b95215b12901b7530d9017d2f154c21c5893e7a10282e3bf8cd7ca005a068bab0aac1fe9b45139440
7
- data.tar.gz: d4c0af7c25dbc3bc380b4793f82c5c172a4bfe3b2bff150310e4b9689d1e9c7f07a0be1833cd406c16dec0fce60f30032b95b4466dc93bd46059b0714ec0096d
6
+ metadata.gz: 130ccd4e8a1ca7ddfe996f7439af4dc800501ea0a81ec0a18561475447931af40affb0cd0e2a66aa191d28d93f677046f49b7d5db6a913426fa21114d1bcbb68
7
+ data.tar.gz: bd818590edc19a344daf59e755d8657da71e10f2017fc3b8ffe95ed6f8452f804a6ccdc7da621425b288c5934b25b5c39bd41923d13983e16fab9ab144bdfd78
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
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
+
42
+ ## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
43
+
44
+
45
+ ### Features
46
+
47
+ * **server:** a namespace is a grant, so an app can grow its own scopes ([4e816e4](https://github.com/masksrb/masks/commit/4e816e4456de9cc05601b8eaa03773788e3178bb))
48
+ * **server:** people, avatars, and one page instead of three ([d50c444](https://github.com/masksrb/masks/commit/d50c4440812fc1c3a92e926ff6d533b9a69dae2a))
49
+
50
+
51
+ ### Fixes
52
+
53
+ * **client:** an app whose issuer forgot it asks to be reconnected ([6956972](https://github.com/masksrb/masks/commit/695697233ca1ce35fa8b29ea74e58dd0d762b0a5))
54
+
3
55
  ## 0.5.0 — unreleased
4
56
 
5
57
  First release carrying the consumer half in full. 0.4.0 predates the split described in
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,14 +97,37 @@ 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
+ ### Avatars
112
+
113
+ Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
114
+ `initials`. All three arrive on the id token, so drawing one costs no request.
115
+
116
+ ```erb
117
+ <img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
118
+ ```
119
+
120
+ A photo is a likeness of a person and needs a token, which an `<img>` cannot carry, so the engine
121
+ proxies it with the token this app already holds:
122
+
123
+ ```erb
124
+ <img src="/auth/avatar" width="64" height="64" alt="">
125
+ ```
126
+
127
+ `masks_claims.picture` resolves the standard OIDC claim — an offsite `picture_url` if the actor set
128
+ one, then the photo, then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a
129
+ subject this app holds no token for. Sizes are 32, 64, 128, 256 or 512.
130
+
103
131
  ### Signing out
104
132
 
105
133
  `DELETE /auth/logout` ends this app's session. Add `?everywhere=1` and the response
@@ -135,7 +163,7 @@ holds no protocol of its own.
135
163
 
136
164
  ```ruby
137
165
  session = Masks::Client::Session.new(
138
- issuer: "https://jons.auth.example.com",
166
+ issuer: "https://demo.auth.example.com",
139
167
  client_id: id, client_secret: secret,
140
168
  redirect_uri: "https://app.example.com/auth/callback"
141
169
  )
@@ -149,8 +177,7 @@ identity = session.identity(tokens)
149
177
  ```
150
178
 
151
179
  `start` sends a nonce only when `openid` was asked for, so holding one means an id token
152
- is owed which is what lets the check on the way back be exact rather than vacuously
153
- true.
180
+ is owed, and the check on the way back is exact.
154
181
 
155
182
  Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
156
183
 
@@ -161,12 +188,11 @@ constructing one per request.
161
188
 
162
189
  ### The handshake
163
190
 
164
- The flow that connects a first-party app, rather than two helpers and sixty lines of
165
- state in every consumer:
191
+ The flow that connects a first-party app:
166
192
 
167
193
  ```ruby
168
194
  handshake = Masks::Client::Handshake.new(
169
- issuer, name: "things", resource: "https://app.example.com/mcp",
195
+ issuer, name: "uris", resource: "https://app.example.com/mcp",
170
196
  redirect_uris: [ "https://app.example.com/auth/callback" ],
171
197
  return_to: "https://app.example.com/"
172
198
  )
@@ -182,12 +208,12 @@ that is not the issuer it asked — each **before** anything is redeemed.
182
208
 
183
209
  ```ruby
184
210
  resource = Masks::Client::Resource.new(
185
- issuer: "https://jons.auth.example.com",
211
+ issuer: "https://demo.auth.example.com",
186
212
  url: "https://app.example.com/mcp",
187
- scopes: { "things:read" => "Search your catalog" }
213
+ scopes: { "uris:catalog:read" => "Search your catalog" }
188
214
  )
189
215
 
190
- claims = resource.authenticate(request.authorization, scope: "things:read")
216
+ claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
191
217
  claims.subject
192
218
  claims.tenant.subdomain
193
219
  ```
@@ -201,35 +227,87 @@ response.headers["WWW-Authenticate"] = resource.challenge(error)
201
227
 
202
228
  `resource.metadata` is the RFC 9728 document to serve at
203
229
  `/.well-known/oauth-protected-resource`. The `scope_descriptions` extension in it is how
204
- an auth server renders your scopes as sentences on its consent screen it has no other
205
- way to know what `things:read` means.
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.
206
232
 
207
233
  There is a Rack middleware for consumers that want the challenge below the framework:
208
234
 
209
235
  ```ruby
210
- use Masks::Client::Rack, resource: resource, scope: "things:read"
236
+ use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
211
237
  ```
212
238
 
213
239
  ### Introspection
214
240
 
215
241
  A resource server doing JWT-only validation cannot see a revocation until the token
216
- expires. Asking is the honest answer:
242
+ expires. Ask the issuer instead:
217
243
 
218
244
  ```ruby
219
245
  found = session.introspect(token)
220
- found.active? && found.permits?("things:read")
246
+ found.active? && found.permits?("uris:catalog:read")
221
247
  ```
222
248
 
223
249
  `Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
224
- not active, so switching from local verification to asking does not mean remembering to
225
- check a boolean.
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
+ ```
226
300
 
227
301
  ## Which issuers this speaks to
228
302
 
229
303
  masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
230
304
  least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
231
- it. An older issuer is refused with a sentence saying so, rather than at the first screen
232
- anybody touches.
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.
233
311
 
234
312
  ## License
235
313
 
@@ -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
@@ -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,15 +27,11 @@ 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?
41
33
 
42
- masks_registration&.delete
34
+ forget_upstream
43
35
 
44
36
  masks_config.forget!(request)
45
37
  masks_forget
@@ -51,11 +43,19 @@ module Masks
51
43
 
52
44
  private
53
45
 
54
- def masks_registration
55
- Masks::Client::Registration.held(
56
- masks_config.issuer_for(request),
57
- masks_config.credentials_for(request)
58
- )
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
+
55
+ def forget_upstream
56
+ masks_registration&.delete
57
+ rescue Masks::Client::Unregistered
58
+ false
59
59
  end
60
60
 
61
61
  def returned
@@ -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
@@ -14,6 +14,7 @@ module Masks
14
14
 
15
15
  def start
16
16
  return redirect_to(masks_handshake_path) unless masks_configured?
17
+ return redirect_to(masks_handshake_path) if masks_reconnect!
17
18
 
18
19
  pending = masks_requests.open(
19
20
  return_to: requested_return_to || session.delete(:masks_return_to)
@@ -55,6 +56,10 @@ module Masks
55
56
  masks_store(tokens, identity: identity)
56
57
 
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)
58
63
  rescue Masks::Client::Error => e
59
64
  refuse(e.class.name.demodulize.underscore, e.message, pending)
60
65
  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
@@ -1,8 +1,10 @@
1
1
  Masks::Rails::Engine.routes.draw do
2
2
  get "/", to: "sessions#start", as: :start
3
3
  get "/session", to: "sessions#show", as: :session
4
+ get "/avatar", to: "avatars#show", as: :avatar
4
5
  get "/callback", to: "sessions#callback", as: :callback
5
6
  match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
7
+ post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
6
8
 
7
9
  get "/handshake", to: "handshakes#show", as: :handshake
8
10
  post "/handshake", to: "handshakes#create"
@@ -29,6 +29,39 @@ module Masks
29
29
  end
30
30
  end
31
31
 
32
+ class Avatars
33
+ STYLES = %w[photo identicon initials].freeze
34
+ FALLBACK = "identicon".freeze
35
+
36
+ attr_reader :to_h
37
+
38
+ def initialize(hash)
39
+ @to_h = hash.is_a?(Hash) ? hash : {}
40
+ end
41
+
42
+ STYLES.each do |style|
43
+ define_method(style) { to_h[style] }
44
+ end
45
+
46
+ def [](style)
47
+ to_h[style.to_s]
48
+ end
49
+
50
+ def photo?
51
+ !photo.nil?
52
+ end
53
+
54
+ def present?
55
+ to_h.any?
56
+ end
57
+
58
+ def ==(other)
59
+ other.is_a?(Avatars) ? to_h == other.to_h : false
60
+ end
61
+ end
62
+
63
+ AVATARS = "masks:avatars".freeze
64
+
32
65
  attr_reader :to_h
33
66
 
34
67
  def initialize(claims)
@@ -67,6 +100,14 @@ module Masks
67
100
  @tenant ||= Tenant.new(self["tenant"])
68
101
  end
69
102
 
103
+ def avatars
104
+ @avatars ||= Avatars.new(self[AVATARS])
105
+ end
106
+
107
+ def picture
108
+ self["picture"] || avatars.photo || avatars.identicon
109
+ end
110
+
70
111
  def scopes
71
112
  @scopes ||= self["scope"].to_s.split(/\s+/).reject(&:empty?).freeze
72
113
  end