masks 0.7.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: 5da19b0cb3099ebe46f2b4509615a75422fa0b2820abb799e8f182ce11f456ad
4
- data.tar.gz: 4d5d6b573cccbca63439587cd3363d22be28dd0d8ff95051f32dcc8252494185
3
+ metadata.gz: 3ec26aa454cf249dd26ce7e15444b43890e4e75954968c76cba3297b63f9819e
4
+ data.tar.gz: 19609ef3a180ab7d6c675cac13cef843adb3ffc742d83b7fd64d7120bbcc93b5
5
5
  SHA512:
6
- metadata.gz: 130ccd4e8a1ca7ddfe996f7439af4dc800501ea0a81ec0a18561475447931af40affb0cd0e2a66aa191d28d93f677046f49b7d5db6a913426fa21114d1bcbb68
7
- data.tar.gz: bd818590edc19a344daf59e755d8657da71e10f2017fc3b8ffe95ed6f8452f804a6ccdc7da621425b288c5934b25b5c39bd41923d13983e16fab9ab144bdfd78
6
+ metadata.gz: 4a02a532dc018b73c27b350da38335b5ec27c5665d8b498564fd11b220e386919df8ecc9a24b80fb62f21d193c7a66166e99d2dc41c86fef3cb515f5aacc2b0d
7
+ data.tar.gz: fbbb524a91dda55fc6ccf631d1ef8b270b9ff9835b894855dcd0dd07a054fcfb380c6fdea75930b93950909ba1e3618940215418c9fca7f0a5f32e30b33c96b4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
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
+
3
29
  ## [0.7.0](https://github.com/masksrb/masks/compare/gem/v0.6.0...gem/v0.7.0) (2026-09-13)
4
30
 
5
31
 
data/README.md CHANGED
@@ -1,4 +1,4 @@
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>
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
2
 
3
3
  # masks
4
4
 
@@ -108,6 +108,51 @@ end
108
108
  `resource_metadata`, so a client handed nothing but a URL can find its way to the issuer
109
109
  and back.
110
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
+
111
156
  ### Avatars
112
157
 
113
158
  Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
@@ -146,6 +191,7 @@ a subdomain-per-tenant host needs.
146
191
  | `resource` | this app's own identifier, when it also accepts tokens |
147
192
  | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
148
193
  | `scope` | what to ask the issuer for; defaults to `openid profile email` |
194
+ | `organization` | the organization every sign-in names, by key |
149
195
  | `credentials` / `store` | where the handshake's result lives |
150
196
  | `credentials_path` | where the default store writes; `config/masks.json` |
151
197
  | `after_sign_in` / `after_sign_out` | paths on this host |
@@ -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(
@@ -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
@@ -24,6 +24,12 @@ module Masks
24
24
 
25
25
  super
26
26
  end
27
+
28
+ def member!(*roles, organization: nil)
29
+ raise Unauthorized.new("the issuer reports this token is not active") unless active?
30
+
31
+ super
32
+ end
27
33
  end
28
34
  end
29
35
  end
@@ -9,7 +9,7 @@ module Masks
9
9
  def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
10
10
  held = Verifier
11
11
  .new(issuer, audience: audience, algorithms: algorithms)
12
- .verify(token, required: %w[iss aud iat jti events])
12
+ .verify(token, required: %w[iss aud iat jti events], typ: Verifier::LOGOUT_TOKEN)
13
13
 
14
14
  new(held).validate!
15
15
  end
@@ -18,10 +18,11 @@ module Masks
18
18
  @verifier = verifier || Verifier.new(@issuer, audience: @url, algorithms: algorithms)
19
19
  end
20
20
 
21
- def authenticate(authorization, scope: nil)
22
- claims = Claims.new(@verifier.verify(token!(authorization), required: @required))
21
+ def authenticate(authorization, scope: nil, role: nil, organization: nil)
22
+ claims = Claims.new(@verifier.verify(token!(authorization), required: @required, typ: Verifier::ACCESS_TOKEN))
23
23
 
24
24
  Array(scope).each { |name| claims.permit!(name) }
25
+ claims.member!(*Array(role), organization: organization) if role || organization
25
26
 
26
27
  claims
27
28
  rescue InvalidToken => e
@@ -2,21 +2,37 @@ module Masks
2
2
  module Client
3
3
  class Session
4
4
  DEFAULT_SCOPE = %w[openid profile email].freeze
5
+ ORGANIZATION = "organization".freeze
6
+ ORGANIZATION_KEY = /\A[a-z0-9][a-z0-9-]*\z/
5
7
 
6
- attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope
8
+ def self.organization_key(value)
9
+ key = value.to_s.strip.downcase
10
+
11
+ key.match?(ORGANIZATION_KEY) ? key : nil
12
+ end
13
+ ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".freeze
14
+ ASSERTION_LIFETIME = 60
15
+
16
+ attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope, :private_key, :key_id
17
+
18
+ def initialize(issuer:, client_id:, redirect_uri: nil, client_secret: nil, private_key: nil, key_id: nil,
19
+ scope: DEFAULT_SCOPE)
20
+ raise ArgumentError, "a client authenticates with a secret or a private key, not both" if client_secret && private_key
7
21
 
8
- def initialize(issuer:, client_id:, redirect_uri:, client_secret: nil, scope: DEFAULT_SCOPE)
9
22
  @issuer = Issuer.resolve(issuer)
10
23
  @client_id = client_id
11
24
  @client_secret = client_secret
25
+ @private_key = private_key.is_a?(String) ? OpenSSL::PKey.read(private_key) : private_key
26
+ @key_id = key_id
12
27
  @redirect_uri = redirect_uri
13
28
  @scope = Array(scope)
14
29
  end
15
30
 
16
31
  def start(resource: nil, prompt: nil, scope: nil, state: SecureRandom.urlsafe_base64(24),
17
- nonce: SecureRandom.urlsafe_base64(24), max_age: nil)
32
+ nonce: SecureRandom.urlsafe_base64(24), max_age: nil, organization: nil)
18
33
  pkce = Pkce.generate
19
34
  scopes = Array(scope || self.scope)
35
+ organization = Session.organization_key(organization)
20
36
  nonce = nil unless scopes.include?("openid")
21
37
 
22
38
  pairs = [
@@ -34,6 +50,7 @@ module Masks
34
50
  Array(resource).each { |value| pairs << [ "resource", value ] }
35
51
  pairs << [ "prompt", prompt ] if prompt
36
52
  pairs << [ "max_age", max_age.to_i ] if max_age
53
+ pairs << [ "organization", organization ] if organization
37
54
 
38
55
  {
39
56
  url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
@@ -54,7 +71,16 @@ module Masks
54
71
 
55
72
  Array(resource).each { |value| form << [ "resource", value ] }
56
73
 
57
- Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
74
+ Tokens.granted(post("token_endpoint", form))
75
+ end
76
+
77
+ def client_credentials(scope: nil, resource: nil)
78
+ form = [ [ "grant_type", "client_credentials" ], [ "client_id", client_id ] ]
79
+
80
+ form << [ "scope", Array(scope).join(" ") ] if scope
81
+ Array(resource).each { |value| form << [ "resource", value ] }
82
+
83
+ Tokens.granted(post("token_endpoint", form))
58
84
  end
59
85
 
60
86
  def refresh(refresh_token, resource: nil, scope: nil)
@@ -67,17 +93,20 @@ module Masks
67
93
  form << [ "scope", Array(scope).join(" ") ] if scope
68
94
  Array(resource).each { |value| form << [ "resource", value ] }
69
95
 
70
- Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
96
+ Tokens.granted(post("token_endpoint", form))
71
97
  end
72
98
 
73
- def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil)
99
+ def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil,
100
+ subject_token_type: Tokens::ACCESS_TOKEN, actor_token: nil, actor_token_type: Tokens::ACCESS_TOKEN)
74
101
  form = [
75
102
  [ "grant_type", Tokens::EXCHANGE ],
76
103
  [ "client_id", client_id ],
77
104
  [ "subject_token", subject_token ],
78
- [ "subject_token_type", Tokens::ACCESS_TOKEN ]
105
+ [ "subject_token_type", subject_token_type ]
79
106
  ]
80
107
 
108
+ form.push([ "actor_token", actor_token ], [ "actor_token_type", actor_token_type ]) if actor_token
109
+
81
110
  form << [ "requested_token_type", requested_token_type ] if requested_token_type
82
111
  Array(audience).each { |value| form << [ "audience", value ] }
83
112
 
@@ -85,14 +114,14 @@ module Masks
85
114
  form << [ "requested_lifetime", lifetime.to_i ] if lifetime
86
115
  Array(resource).each { |value| form << [ "resource", value ] }
87
116
 
88
- Tokens.granted(HTTP.post_form(issuer.endpoint("token_endpoint"), form, authorization))
117
+ Tokens.granted(post("token_endpoint", form))
89
118
  end
90
119
 
91
120
  def revoke(token, hint: nil)
92
121
  form = [ [ "token", token ], [ "client_id", client_id ] ]
93
122
  form << [ "token_type_hint", hint ] if hint
94
123
 
95
- HTTP.post_form(issuer.endpoint("revocation_endpoint"), form, authorization)
124
+ post("revocation_endpoint", form)
96
125
  true
97
126
  end
98
127
 
@@ -114,7 +143,7 @@ module Masks
114
143
  form << [ "token_type_hint", hint ] if hint
115
144
 
116
145
  Introspection.new(
117
- HTTP.post_form(issuer.endpoint("introspection_endpoint"), form, authorization)
146
+ post("introspection_endpoint", form)
118
147
  )
119
148
  end
120
149
 
@@ -140,6 +169,36 @@ module Masks
140
169
 
141
170
  private
142
171
 
172
+ def post(endpoint, form)
173
+ url = issuer.endpoint(endpoint)
174
+
175
+ HTTP.post_form(url, form + assertion, authorization)
176
+ end
177
+
178
+ def assertion
179
+ return [] if private_key.nil?
180
+
181
+ now = Time.now.to_i
182
+ claims = {
183
+ "iss" => client_id, "sub" => client_id, "aud" => issuer.url,
184
+ "iat" => now, "exp" => now + ASSERTION_LIFETIME, "jti" => SecureRandom.uuid
185
+ }
186
+ header = key_id ? { kid: key_id } : {}
187
+
188
+ [
189
+ [ "client_assertion_type", ASSERTION_TYPE ],
190
+ [ "client_assertion", JWT.encode(claims, private_key, signing_algorithm, header) ]
191
+ ]
192
+ end
193
+
194
+ def signing_algorithm
195
+ return "RS256" if private_key.is_a?(OpenSSL::PKey::RSA)
196
+
197
+ { "prime256v1" => "ES256", "secp384r1" => "ES384", "secp521r1" => "ES512" }.fetch(private_key.group.curve_name) do
198
+ raise ArgumentError, "#{private_key.group.curve_name} is not a curve masks checks assertions for"
199
+ end
200
+ end
201
+
143
202
  def authorization
144
203
  return {} if client_secret.nil?
145
204
 
@@ -3,6 +3,7 @@ module Masks
3
3
  class Tokens
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
+ ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token".freeze
6
7
 
7
8
  attr_reader :access_token, :id_token, :refresh_token, :token_type, :scope, :expires_in, :obtained_at,
8
9
  :delegations
@@ -2,6 +2,9 @@ module Masks
2
2
  module Client
3
3
  class Verifier
4
4
  ALGORITHMS = %w[RS256 ES256].freeze
5
+ ACCESS_TOKEN = "at+jwt".freeze
6
+ LOGOUT_TOKEN = "logout+jwt".freeze
7
+ ID_TOKEN = [ "jwt", nil ].freeze
5
8
 
6
9
  attr_reader :issuer, :audience
7
10
 
@@ -11,8 +14,8 @@ module Masks
11
14
  @algorithms = algorithms
12
15
  end
13
16
 
14
- def verify(token, required: %w[iss sub exp])
15
- JWT.decode(
17
+ def verify(token, required: %w[iss sub exp], typ: ID_TOKEN)
18
+ claims, header = JWT.decode(
16
19
  token, nil, true,
17
20
  algorithms: @algorithms,
18
21
  jwks: keys,
@@ -20,7 +23,11 @@ module Masks
20
23
  aud: audience, verify_aud: true,
21
24
  verify_expiration: true,
22
25
  required_claims: required
23
- ).first
26
+ )
27
+
28
+ typed!(header["typ"], Array(typ))
29
+
30
+ claims
24
31
  rescue JWT::DecodeError => e
25
32
  raise InvalidToken, e.message
26
33
  end
@@ -31,6 +38,14 @@ module Masks
31
38
 
32
39
  private
33
40
 
41
+ def typed!(held, accepted)
42
+ named = held&.to_s&.downcase&.delete_prefix("application/")
43
+
44
+ return if accepted.include?(named)
45
+
46
+ raise InvalidToken, "that token is typed #{held.inspect}, not #{accepted.compact.join(' or ')}"
47
+ end
48
+
34
49
  def keys
35
50
  ->(options) do
36
51
  issuer.refresh! if options[:invalidate]
@@ -7,7 +7,15 @@ module Masks
7
7
  included do
8
8
  if respond_to?(:helper_method)
9
9
  helper_method :masks_signed_in?, :masks_identity, :masks_tenant, :masks_scopes,
10
- :masks_claims
10
+ :masks_claims, :masks_organization, :masks_organizations, :masks_role?,
11
+ :masks_login_url
12
+ end
13
+ end
14
+
15
+ class_methods do
16
+ def masks_members_only!(role: nil, organization: nil, **options)
17
+ before_action(-> { authorize_masks_member!(*Array(role), organization: organization) },
18
+ **options.slice(:only, :except))
11
19
  end
12
20
  end
13
21
 
@@ -43,6 +51,7 @@ module Masks
43
51
  session[masks_config.session_key] = held.compact
44
52
  @masks_tokens = tokens
45
53
  @masks_identity = held["identity"]
54
+ @masks_claims = nil
46
55
  end
47
56
 
48
57
  def masks_held
@@ -51,7 +60,8 @@ module Masks
51
60
 
52
61
  IDENTITY = [
53
62
  "sub", "name", "preferred_username", "email", "email_verified", "tenant",
54
- "picture", Masks::Client::Claims::AVATARS
63
+ "picture", Masks::Client::Claims::AVATARS, Masks::Client::Claims::ORGANIZATION,
64
+ Masks::Client::Claims::ORGANIZATIONS
55
65
  ].freeze
56
66
 
57
67
  def masks_identity_from(tokens)
@@ -64,6 +74,7 @@ module Masks
64
74
  session.delete(masks_config.session_key)
65
75
  @masks_tokens = nil
66
76
  @masks_identity = nil
77
+ @masks_claims = nil
67
78
  end
68
79
 
69
80
  def masks_signed_in?
@@ -122,6 +133,20 @@ module Masks
122
133
  @masks_claims ||= Masks::Client::Claims.new(masks_identity || {})
123
134
  end
124
135
 
136
+ def masks_organization
137
+ held = masks_claims.organization
138
+
139
+ held.present? ? held : nil
140
+ end
141
+
142
+ def masks_organizations
143
+ masks_claims.organizations
144
+ end
145
+
146
+ def masks_role?(*roles)
147
+ masks_organization&.role?(*roles) || false
148
+ end
149
+
125
150
  def masks_scopes
126
151
  masks_tokens&.scopes || []
127
152
  end
@@ -138,12 +163,12 @@ module Masks
138
163
  return false unless masks_configured?
139
164
  return false if masks_tokens&.refresh_token.nil?
140
165
 
141
- masks_store(
142
- masks_session.refresh(
143
- masks_tokens.refresh_token,
144
- resource: masks_config.resource_for(request)
145
- )
166
+ refreshed = masks_session.refresh(
167
+ masks_tokens.refresh_token,
168
+ resource: masks_config.resource_for(request)
146
169
  )
170
+
171
+ masks_store(refreshed, identity: masks_refreshed_identity(refreshed))
147
172
  true
148
173
  rescue Masks::Client::Unregistered
149
174
  masks_disconnect!
@@ -153,6 +178,16 @@ module Masks
153
178
  false
154
179
  end
155
180
 
181
+ def masks_refreshed_identity(tokens)
182
+ held = masks_held["identity"]
183
+
184
+ return nil unless held.is_a?(Hash)
185
+
186
+ profile = masks_session.profile(tokens)
187
+
188
+ profile.empty? ? nil : held.merge(profile.slice(*IDENTITY))
189
+ end
190
+
156
191
  def masks_logout_url(return_to: nil)
157
192
  return nil unless masks_configured?
158
193
 
@@ -168,11 +203,14 @@ module Masks
168
203
  "#{request.base_url}#{masks_local_path(masks_config.after_sign_out) || '/'}"
169
204
  end
170
205
 
171
- def masks_login_url(return_to: nil)
206
+ def masks_login_url(return_to: nil, organization: nil)
172
207
  path = Masks::Rails::Engine.routes.url_helpers.start_path
173
- target = masks_local_path(return_to)
208
+ query = {
209
+ "return_to" => masks_local_path(return_to),
210
+ "organization" => Masks::Client::Session.organization_key(organization)
211
+ }.compact
174
212
 
175
- target ? "#{path}?return_to=#{CGI.escape(target)}" : path
213
+ query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
176
214
  end
177
215
 
178
216
  def masks_account
@@ -188,11 +226,20 @@ module Masks
188
226
  "picture" => masks_claims.picture,
189
227
  "avatars" => masks_claims.avatars.to_h.presence,
190
228
  "tenant" => masks_tenant,
229
+ "organization" => masks_organization&.to_h,
230
+ "organizations" => masks_organizations.map(&:to_h).presence,
191
231
  "scopes" => masks_scopes,
192
- "expires_at" => masks_tokens&.expires_at
232
+ "expires_at" => masks_tokens&.expires_at,
233
+ "account_url" => masks_account_url
193
234
  }.compact
194
235
  end
195
236
 
237
+ def masks_account_url
238
+ "#{masks_config.issuer_for(request).to_s.chomp('/')}/"
239
+ rescue Masks::Rails::Configuration::Unconfigured
240
+ nil
241
+ end
242
+
196
243
  def authenticate_masks!
197
244
  return true if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
198
245
 
@@ -200,8 +247,28 @@ module Masks
200
247
  false
201
248
  end
202
249
 
250
+ def authorize_masks_member!(*roles, organization: nil)
251
+ return false unless authenticate_masks!
252
+
253
+ masks_claims.member!(*roles, organization: organization)
254
+ true
255
+ rescue Masks::Client::Forbidden => e
256
+ masks_forbid(e)
257
+ false
258
+ end
259
+
203
260
  private
204
261
 
262
+ def masks_forbid(error)
263
+ response.headers["Cache-Control"] = "no-store"
264
+
265
+ if masks_wants_json?
266
+ render json: { "error" => error.code, "error_description" => error.description }, status: :forbidden
267
+ else
268
+ render plain: error.description, status: :forbidden
269
+ end
270
+ end
271
+
205
272
  def masks_refuse
206
273
  return masks_refuse_json if masks_wants_json?
207
274
  return redirect_to(masks_handshake_path) unless masks_configured?
@@ -7,7 +7,7 @@ module Masks
7
7
  :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
8
8
  :credentials_path, :authenticate_everything, :delegates
9
9
  attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
10
- :delegation_redirect_uri
10
+ :delegation_redirect_uri, :organization
11
11
 
12
12
  def initialize
13
13
  @scope = Masks::Client::Session::DEFAULT_SCOPE
@@ -51,6 +51,10 @@ module Masks
51
51
  resolve(@resource, request)
52
52
  end
53
53
 
54
+ def organization_for(request)
55
+ resolve(@organization, request)
56
+ end
57
+
54
58
  def credentials_for(request)
55
59
  held = @credentials ? resolve(@credentials, request) : default_credentials.read
56
60
  held ||= {}
@@ -18,15 +18,16 @@ module Masks
18
18
  @masks_claims if defined?(@masks_claims)
19
19
  end
20
20
 
21
- def masks_authenticate!(scope: nil, **)
22
- @masks_claims = masks_resource.authenticate(request.authorization, scope: scope)
21
+ def masks_authenticate!(scope: nil, role: nil, organization: nil, **)
22
+ @masks_claims = masks_resource.authenticate(request.authorization, scope: scope, role: role,
23
+ organization: organization)
23
24
  rescue Masks::Client::Challenge => e
24
25
  masks_challenge(e)
25
26
  false
26
27
  end
27
28
 
28
- def masks_authenticate(scope: nil)
29
- masks_resource.authenticate(request.authorization, scope: scope)
29
+ def masks_authenticate(scope: nil, role: nil, organization: nil)
30
+ masks_resource.authenticate(request.authorization, scope: scope, role: role, organization: organization)
30
31
  rescue Masks::Client::Unauthenticated
31
32
  nil
32
33
  end
data/lib/masks/rails.rb CHANGED
@@ -20,8 +20,8 @@ module Masks
20
20
  # [ProtectedResource] the other direction — checking a bearer this app was
21
21
  # handed, for an API rather than a browser
22
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.
23
+ # This engine is the client. The provider is the masks-server gem, whether
24
+ # it runs elsewhere or is mounted on a subdomain of this app.
25
25
  module Rails
26
26
  end
27
27
  end
data/lib/masks/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Masks
2
- VERSION = "0.7.0".freeze
2
+ VERSION = "0.8.0".freeze
3
3
  end
data/lib/masks.rb CHANGED
@@ -10,8 +10,8 @@
10
10
  # issuer = Masks::Client.issuer("https://auth.example")
11
11
  # issuer.authorization_url(client_id: id, redirect_uri: uri)
12
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.
13
+ # The provider itself is not in here. It is the masks-server gem, which runs
14
+ # as an app of its own or mounted inside another Rails app.
15
15
  module Masks
16
16
  end
17
17
 
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - the masks authors
@@ -25,7 +25,8 @@ dependencies:
25
25
  version: '3.1'
26
26
  description: Discovery, PKCE authorization, token exchange, and token verification
27
27
  against a masks issuer, with Rack middleware for a resource server and a Rails engine
28
- that mounts the consumer half of the code flow. The auth server itself stays standalone.
28
+ that mounts the consumer half of the code flow. The auth server itself is the masks-server
29
+ gem.
29
30
  executables: []
30
31
  extensions: []
31
32
  extra_rdoc_files: []