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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +47 -1
- data/app/controllers/masks/rails/sessions_controller.rb +3 -1
- data/lib/masks/client/claims.rb +69 -0
- data/lib/masks/client/introspection.rb +6 -0
- data/lib/masks/client/logout.rb +1 -1
- data/lib/masks/client/resource.rb +3 -2
- data/lib/masks/client/session.rb +69 -10
- data/lib/masks/client/tokens.rb +1 -0
- data/lib/masks/client/verifier.rb +18 -3
- data/lib/masks/rails/authentication.rb +78 -11
- data/lib/masks/rails/configuration.rb +5 -1
- data/lib/masks/rails/protected_resource.rb +5 -4
- data/lib/masks/rails.rb +2 -2
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +2 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ec26aa454cf249dd26ce7e15444b43890e4e75954968c76cba3297b63f9819e
|
|
4
|
+
data.tar.gz: 19609ef3a180ab7d6c675cac13cef843adb3ffc742d83b7fd64d7120bbcc93b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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(
|
data/lib/masks/client/claims.rb
CHANGED
|
@@ -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
|
data/lib/masks/client/logout.rb
CHANGED
|
@@ -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
|
data/lib/masks/client/session.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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",
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/masks/client/tokens.rb
CHANGED
|
@@ -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
|
-
)
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
208
|
+
query = {
|
|
209
|
+
"return_to" => masks_local_path(return_to),
|
|
210
|
+
"organization" => Masks::Client::Session.organization_key(organization)
|
|
211
|
+
}.compact
|
|
174
212
|
|
|
175
|
-
|
|
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.
|
|
24
|
-
#
|
|
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
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
|
|
14
|
-
#
|
|
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.
|
|
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
|
|
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: []
|