masks 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fab2f5044bc29a407688ac0c61ac703108849a24579a7aa2e30e18234d6e02f0
4
- data.tar.gz: 64572497372118a8ac2677634c26349c53cf93e4850188ceecaa2e2610d18546
3
+ metadata.gz: d4455d2e731a770253802292bce1dd21a9b97c05489e65ba4a1f310c24873b79
4
+ data.tar.gz: df05e39f40a1f08fbb77897549c584653bf17676c3e6b13b64220e1751360e64
5
5
  SHA512:
6
- metadata.gz: ea4c88b70e03f9feb1a98886c1a04ae0f913f4c5a7de609b95215b12901b7530d9017d2f154c21c5893e7a10282e3bf8cd7ca005a068bab0aac1fe9b45139440
7
- data.tar.gz: d4c0af7c25dbc3bc380b4793f82c5c172a4bfe3b2bff150310e4b9689d1e9c7f07a0be1833cd406c16dec0fce60f30032b95b4466dc93bd46059b0714ec0096d
6
+ metadata.gz: 39e55aecad7e80a3cef669b9bcbe16f6f942c326bf98a9f9da954663365740d9e31a814a3da644ef68bd0090f040f853ff4b3bdda34f2f8fe6dd4b749190d138
7
+ data.tar.gz: 4cca8c0d431151c0a0865969dcc38767388ec4f93c1ab5e2b824e7dd875f8a38135969d7b8ed5a1966eb9b0f51b3fb60e8cff887c67d180561b3474e47653d84
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/masksrb/masks/compare/gem-v0.5.0...gem/v0.6.0) (2026-09-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **server:** a namespace is a grant, so an app can grow its own scopes ([4e816e4](https://github.com/masksrb/masks/commit/4e816e4456de9cc05601b8eaa03773788e3178bb))
9
+ * **server:** people, avatars, and one page instead of three ([d50c444](https://github.com/masksrb/masks/commit/d50c4440812fc1c3a92e926ff6d533b9a69dae2a))
10
+
11
+
12
+ ### Fixes
13
+
14
+ * **client:** an app whose issuer forgot it asks to be reconnected ([6956972](https://github.com/masksrb/masks/commit/695697233ca1ce35fa8b29ea74e58dd0d762b0a5))
15
+
3
16
  ## 0.5.0 — unreleased
4
17
 
5
18
  First release carrying the consumer half in full. 0.4.0 predates the split described in
data/README.md CHANGED
@@ -100,6 +100,26 @@ end
100
100
  `resource_metadata`, so a client handed nothing but a URL can find its way to the issuer
101
101
  and back.
102
102
 
103
+ ### Avatars
104
+
105
+ Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
106
+ `initials`. All three arrive on the id token, so drawing one costs no request.
107
+
108
+ ```erb
109
+ <img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
110
+ ```
111
+
112
+ A photo is a likeness of a person and needs a token, which an `<img>` cannot carry, so the engine
113
+ proxies it with the token this app already holds:
114
+
115
+ ```erb
116
+ <img src="/auth/avatar" width="64" height="64" alt="">
117
+ ```
118
+
119
+ `masks_claims.picture` resolves the standard OIDC claim — an offsite `picture_url` if the actor set
120
+ one, then the photo, then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a
121
+ subject this app holds no token for. Sizes are 32, 64, 128, 256 or 512.
122
+
103
123
  ### Signing out
104
124
 
105
125
  `DELETE /auth/logout` ends this app's session. Add `?everywhere=1` and the response
@@ -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
@@ -39,7 +39,7 @@ module Masks
39
39
  return redirect_to(masks_config.after_sign_out) unless masks_signed_in?
40
40
  return redirect_to(masks_handshake_path) unless masks_config.can_forget?
41
41
 
42
- masks_registration&.delete
42
+ forget_upstream
43
43
 
44
44
  masks_config.forget!(request)
45
45
  masks_forget
@@ -51,11 +51,10 @@ module Masks
51
51
 
52
52
  private
53
53
 
54
- def masks_registration
55
- Masks::Client::Registration.held(
56
- masks_config.issuer_for(request),
57
- masks_config.credentials_for(request)
58
- )
54
+ def forget_upstream
55
+ masks_registration&.delete
56
+ rescue Masks::Client::Unregistered
57
+ false
59
58
  end
60
59
 
61
60
  def returned
@@ -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
data/config/routes.rb CHANGED
@@ -1,6 +1,7 @@
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
6
7
 
@@ -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
@@ -16,6 +16,14 @@ module Masks
16
16
  end
17
17
  end
18
18
 
19
+ class Unregistered < Rejected
20
+ CODE = "invalid_client".freeze
21
+
22
+ def self.raised_by?(body)
23
+ body["error"].to_s == CODE
24
+ end
25
+ end
26
+
19
27
  class InvalidToken < Error; end
20
28
 
21
29
  class Challenge < Error
@@ -31,6 +31,19 @@ module Masks
31
31
  request(Net::HTTP::Delete.new(URI.parse(url.to_s), default_headers.merge(headers)))
32
32
  end
33
33
 
34
+ def fetch(url, headers = {})
35
+ uri = URI.parse(url.to_s)
36
+
37
+ Net::HTTP.start(
38
+ uri.hostname, uri.port,
39
+ use_ssl: uri.scheme == "https",
40
+ open_timeout: OPEN_TIMEOUT,
41
+ read_timeout: READ_TIMEOUT
42
+ ) { |http| http.request(Net::HTTP::Get.new(uri, headers)) }
43
+ rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
44
+ raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
45
+ end
46
+
34
47
  def json(verb, url, body, headers)
35
48
  uri = URI.parse(url.to_s)
36
49
  request = verb.new(uri, default_headers.merge(headers))
@@ -67,7 +80,9 @@ module Masks
67
80
  end
68
81
 
69
82
  unless response.is_a?(Net::HTTPSuccess)
70
- raise Rejected.new(
83
+ refusal = Unregistered.raised_by?(body) ? Unregistered : Rejected
84
+
85
+ raise refusal.new(
71
86
  body["error"] || "http_#{response.code}",
72
87
  body["error_description"] || response.message,
73
88
  status: response.code.to_i
@@ -51,6 +51,22 @@ module Masks
51
51
  discovery.fetch(name) { raise Rejected.new("invalid_issuer", "#{url} publishes no #{name}") }
52
52
  end
53
53
 
54
+ def avatar_styles
55
+ discovery["avatar_styles_supported"] || Claims::Avatars::STYLES
56
+ end
57
+
58
+ def avatar_url(subject, style: nil, size: nil)
59
+ wanted = (style || Claims::Avatars::FALLBACK).to_s
60
+
61
+ unless avatar_styles.include?(wanted)
62
+ raise Rejected.new("invalid_style", "#{url} does not serve #{wanted} avatars")
63
+ end
64
+
65
+ query = size ? "?size=#{size.to_i}" : ""
66
+
67
+ "#{endpoint('avatar_endpoint')}/#{subject}/#{wanted}#{query}"
68
+ end
69
+
54
70
  def refresh!
55
71
  @lock.synchronize { @cache = {} }
56
72
  self
@@ -59,18 +59,30 @@ module Masks
59
59
  metadata["registration_client_uri"]
60
60
  end
61
61
 
62
+ REFUSED = [ 401, 403 ].freeze
63
+ GONE = (REFUSED + [ 404 ]).freeze
64
+
62
65
  def read
63
- HTTP.get(uri, authorization)
66
+ still_ours(REFUSED) { HTTP.get(uri, authorization) }
64
67
  end
65
68
 
66
69
  def update(**attributes)
67
- @metadata = metadata.merge(HTTP.put_json(uri, self.class.stringify(attributes), authorization))
70
+ @metadata = metadata.merge(
71
+ still_ours(REFUSED) { HTTP.put_json(uri, self.class.stringify(attributes), authorization) }
72
+ )
68
73
  self
69
74
  end
70
75
 
71
76
  def delete
72
- HTTP.delete(uri, authorization)
77
+ still_ours(GONE) { HTTP.delete(uri, authorization) }
78
+ true
79
+ end
80
+
81
+ def known?
82
+ read
73
83
  true
84
+ rescue Unregistered
85
+ false
74
86
  end
75
87
 
76
88
  def authorization
@@ -86,6 +98,18 @@ module Masks
86
98
  scope: scope
87
99
  )
88
100
  end
101
+
102
+ private
103
+
104
+ def still_ours(statuses)
105
+ yield
106
+ rescue Unregistered
107
+ raise
108
+ rescue Rejected => e
109
+ raise unless statuses.include?(e.status)
110
+
111
+ raise Unregistered.new(Unregistered::CODE, e.description, status: e.status)
112
+ end
89
113
  end
90
114
  end
91
115
  end
@@ -6,7 +6,8 @@ module Masks
6
6
 
7
7
  included do
8
8
  if respond_to?(:helper_method)
9
- helper_method :masks_signed_in?, :masks_identity, :masks_tenant, :masks_scopes
9
+ helper_method :masks_signed_in?, :masks_identity, :masks_tenant, :masks_scopes,
10
+ :masks_claims
10
11
  end
11
12
  end
12
13
 
@@ -48,7 +49,10 @@ module Masks
48
49
  session[masks_config.session_key] || {}
49
50
  end
50
51
 
51
- IDENTITY = %w[sub name preferred_username email email_verified tenant].freeze
52
+ IDENTITY = [
53
+ "sub", "name", "preferred_username", "email", "email_verified", "tenant",
54
+ "picture", Masks::Client::Claims::AVATARS
55
+ ].freeze
52
56
 
53
57
  def masks_identity_from(tokens)
54
58
  return nil if tokens.id_token.nil?
@@ -70,6 +74,36 @@ module Masks
70
74
  masks_config.configured?(request)
71
75
  end
72
76
 
77
+ def masks_registration
78
+ Masks::Client::Registration.held(
79
+ masks_config.issuer_for(request),
80
+ masks_config.credentials_for(request)
81
+ )
82
+ end
83
+
84
+ def masks_registered?
85
+ held = masks_registration
86
+
87
+ held.nil? || held.known?
88
+ rescue Masks::Client::Error
89
+ true
90
+ end
91
+
92
+ def masks_disconnect!
93
+ return false unless masks_configured? && masks_config.can_forget?
94
+
95
+ masks_config.forget!(request)
96
+ masks_forget
97
+
98
+ true
99
+ end
100
+
101
+ def masks_reconnect!
102
+ return false if masks_registered?
103
+
104
+ masks_disconnect!
105
+ end
106
+
73
107
  def masks_handshake_path
74
108
  Masks::Rails::Engine.routes.url_helpers.handshake_path
75
109
  end
@@ -84,6 +118,10 @@ module Masks
84
118
  masks_identity&.dig("tenant")
85
119
  end
86
120
 
121
+ def masks_claims
122
+ @masks_claims ||= Masks::Client::Claims.new(masks_identity || {})
123
+ end
124
+
87
125
  def masks_scopes
88
126
  masks_tokens&.scopes || []
89
127
  end
@@ -107,6 +145,9 @@ module Masks
107
145
  )
108
146
  )
109
147
  true
148
+ rescue Masks::Client::Unregistered
149
+ masks_disconnect!
150
+ false
110
151
  rescue Masks::Client::Rejected
111
152
  masks_forget
112
153
  false
@@ -144,6 +185,8 @@ module Masks
144
185
  "nickname" => identity["preferred_username"],
145
186
  "email" => identity["email"],
146
187
  "email_verified" => identity["email_verified"],
188
+ "picture" => masks_claims.picture,
189
+ "avatars" => masks_claims.avatars.to_h.presence,
147
190
  "tenant" => masks_tenant,
148
191
  "scopes" => masks_scopes,
149
192
  "expires_at" => masks_tokens&.expires_at
@@ -3,8 +3,8 @@ module Masks
3
3
  class Configuration
4
4
  class Unconfigured < Masks::Client::Error; end
5
5
 
6
- attr_accessor :scope, :resource, :resource_scopes, :after_sign_in, :after_sign_out,
7
- :session_key, :sign_out_of_issuer, :parent_controller,
6
+ attr_accessor :scope, :namespace, :resource, :resource_scopes, :after_sign_in,
7
+ :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
8
8
  :credentials_path, :authenticate_everything
9
9
  attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget
10
10
 
@@ -134,10 +134,22 @@ module Masks
134
134
  raise(Unconfigured, "Masks::Rails.config.resource is not set"),
135
135
  redirect_uris: [ redirect_uri_for(request) ],
136
136
  return_to: return_to_for(request),
137
- scope: scope
137
+ scope: approved_scope
138
138
  )
139
139
  end
140
140
 
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
+ def approved_scope
146
+ return scope if namespace.blank?
147
+
148
+ outside = Array(scope).reject { |name| name.to_s.start_with?(namespace) }
149
+
150
+ outside + [ namespace ]
151
+ end
152
+
141
153
  def return_to_for(request)
142
154
  uri = URI.parse(redirect_uri_for(request))
143
155
  port = ":#{uri.port}" unless uri.port == uri.default_port
data/lib/masks/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Masks
2
- VERSION = "0.5.0".freeze
2
+ VERSION = "0.6.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: masks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - geiger
@@ -33,6 +33,7 @@ files:
33
33
  - CHANGELOG.md
34
34
  - LICENSE
35
35
  - README.md
36
+ - app/controllers/masks/rails/avatars_controller.rb
36
37
  - app/controllers/masks/rails/base_controller.rb
37
38
  - app/controllers/masks/rails/handshakes_controller.rb
38
39
  - app/controllers/masks/rails/sessions_controller.rb