standard_id 0.1.2 → 0.1.3
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/README.md +21 -0
- data/app/controllers/concerns/standard_id/api_authentication.rb +4 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +9 -0
- data/lib/standard_id/api/authentication_guard.rb +36 -0
- data/lib/standard_id/config/schema.rb +2 -0
- data/lib/standard_id/jwt_service.rb +19 -5
- data/lib/standard_id/oauth/authorization_code_flow.rb +8 -0
- data/lib/standard_id/oauth/client_credentials_flow.rb +8 -0
- data/lib/standard_id/oauth/password_flow.rb +4 -0
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +67 -1
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70f439b35a454065b4a2e45dfedc85b7316b26b0140d6f005930fc909ac78efd
|
|
4
|
+
data.tar.gz: bbf731529fdb2c0cb979e6fecfa711e83daaa1fddfd111263d44618b38bd8fa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f1d1c0de85827c32f47680adad39da9457528b24376079c154aced6bd46fe40fbe72918df27a9290f54ef6df58e862b251dad7568d16d1abe5dd45c2b477bd45
|
|
7
|
+
data.tar.gz: 8d3a5b5c14e42b1850969b6268728ff47c0b78eb5e482f94b0ad4e2b752e98addc52267588adac384d82d01a207047b1e15b25bc9640c56282d5f67c0f297bd0
|
data/README.md
CHANGED
|
@@ -133,6 +133,27 @@ end
|
|
|
133
133
|
|
|
134
134
|
`default_token_lifetime` is applied to every OAuth grant unless you override it in `oauth.token_lifetimes`. Keys map to OAuth grant types (for example `:password`, `:client_credentials`, `:refresh_token`) and should return durations in seconds. Non-token endpoint flows such as the implicit flow can be customized with their symbol key (e.g. `:implicit`). Refresh tokens can be tuned separately through `oauth.refresh_token_lifetime`.
|
|
135
135
|
|
|
136
|
+
### Custom Token Claims
|
|
137
|
+
|
|
138
|
+
You can add additional JWT claims for any token issued through the OAuth token endpoint by mapping scopes to claim names and providing callbacks to resolve each claim. Scopes listed in `oauth.scope_claims` are evaluated against the requested token scopes; when a scope matches, every claim listed for that scope is resolved via the callable defined in `oauth.claim_resolvers`.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
StandardId.configure do |config|
|
|
142
|
+
config.oauth.scope_claims = {
|
|
143
|
+
profile: %i[email display_name]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
config.oauth.claim_resolvers = {
|
|
147
|
+
email: ->(account:) { account.email },
|
|
148
|
+
display_name: ->(account:, client:) {
|
|
149
|
+
"#{account.name} for #{client.client_id}"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Resolvers receive keyword arguments with the context containing `client`, `account`, and `request`, so you can reference only what you need. This lets you, for example, pull organization info off the client application or decorate claims with account attributes.
|
|
156
|
+
|
|
136
157
|
### Social Login Setup
|
|
137
158
|
|
|
138
159
|
```ruby
|
|
@@ -14,6 +14,10 @@ module StandardId
|
|
|
14
14
|
authentication_guard.require_session!(session_manager)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def require_scopes!(*required_scopes)
|
|
18
|
+
authentication_guard.require_scopes!(session_manager, *required_scopes)
|
|
19
|
+
end
|
|
20
|
+
|
|
17
21
|
def session_manager
|
|
18
22
|
@session_manager ||= StandardId::Api::SessionManager.new(token_manager, request:)
|
|
19
23
|
end
|
|
@@ -21,6 +21,15 @@ StandardId.configure do |c|
|
|
|
21
21
|
# password: 8.hours,
|
|
22
22
|
# client_credentials: 24.hours
|
|
23
23
|
# }
|
|
24
|
+
# c.oauth.scope_claims = {
|
|
25
|
+
# profile: %i[email display_name]
|
|
26
|
+
# }
|
|
27
|
+
# c.oauth.claim_resolvers = {
|
|
28
|
+
# email: ->(account:) { account.email },
|
|
29
|
+
# display_name: ->(account:, client:) {
|
|
30
|
+
# "#{account.name} for #{client.client_id}"
|
|
31
|
+
# }
|
|
32
|
+
# }
|
|
24
33
|
|
|
25
34
|
# Social login credentials (if enabled in your app)
|
|
26
35
|
# c.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
@@ -15,6 +15,42 @@ module StandardId
|
|
|
15
15
|
|
|
16
16
|
api_session
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
def require_scopes!(session_manager, *required_scopes)
|
|
20
|
+
api_session = require_session!(session_manager)
|
|
21
|
+
|
|
22
|
+
expected_scopes = normalize_scopes(required_scopes)
|
|
23
|
+
return api_session if expected_scopes.empty?
|
|
24
|
+
|
|
25
|
+
token_scopes = extract_session_scopes(api_session)
|
|
26
|
+
unless (token_scopes & expected_scopes).any?
|
|
27
|
+
raise StandardId::InvalidScopeError,
|
|
28
|
+
"Access token missing required scope. Requires one of: #{expected_scopes.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
api_session
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def extract_session_scopes(api_session)
|
|
37
|
+
api_session&.scopes || []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize_scopes(required_scopes)
|
|
41
|
+
return [] if required_scopes.nil?
|
|
42
|
+
|
|
43
|
+
case required_scopes
|
|
44
|
+
when String
|
|
45
|
+
[required_scopes]
|
|
46
|
+
when Symbol
|
|
47
|
+
[required_scopes.to_s]
|
|
48
|
+
when Array
|
|
49
|
+
required_scopes.flat_map { |value| normalize_scopes(value) }.uniq
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, "Scopes must be provided as a String, Symbol, or Array"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
18
54
|
end
|
|
19
55
|
end
|
|
20
56
|
end
|
|
@@ -35,6 +35,8 @@ StandardConfig.schema.draw do
|
|
|
35
35
|
field :token_lifetimes, type: :hash, default: -> { {} }
|
|
36
36
|
field :client_id, type: :string, default: nil
|
|
37
37
|
field :client_secret, type: :string, default: nil
|
|
38
|
+
field :scope_claims, type: :hash, default: -> { {} }
|
|
39
|
+
field :claim_resolvers, type: :hash, default: -> { {} }
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
scope :social do
|
|
@@ -3,9 +3,14 @@ require "jwt"
|
|
|
3
3
|
module StandardId
|
|
4
4
|
class JwtService
|
|
5
5
|
ALGORITHM = "HS256"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
RESERVED_JWT_KEYS = %i[sub client_id scope grant_type exp iat aud iss nbf jti]
|
|
7
|
+
BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type]
|
|
8
|
+
|
|
9
|
+
def self.session_class
|
|
10
|
+
Struct.new(*(BASE_SESSION_FIELDS + claim_resolver_keys), keyword_init: true) do
|
|
11
|
+
def active?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
9
14
|
end
|
|
10
15
|
end
|
|
11
16
|
|
|
@@ -33,11 +38,12 @@ module StandardId
|
|
|
33
38
|
Array(payload[:scope]).compact
|
|
34
39
|
end
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
session_class.new(
|
|
42
|
+
**payload.slice(*claim_resolver_keys),
|
|
37
43
|
account_id: payload[:sub],
|
|
38
44
|
client_id: payload[:client_id],
|
|
39
45
|
scopes: scopes,
|
|
40
|
-
grant_type: payload[:grant_type]
|
|
46
|
+
grant_type: payload[:grant_type],
|
|
41
47
|
)
|
|
42
48
|
end
|
|
43
49
|
|
|
@@ -46,5 +52,13 @@ module StandardId
|
|
|
46
52
|
def self.secret_key
|
|
47
53
|
Rails.application.secret_key_base
|
|
48
54
|
end
|
|
55
|
+
|
|
56
|
+
def self.claim_resolver_keys
|
|
57
|
+
resolvers = StandardId.config.oauth.claim_resolvers
|
|
58
|
+
keys = Hash.try_convert(resolvers)&.keys
|
|
59
|
+
keys.compact.map(&:to_sym).uniq.excluding(*RESERVED_JWT_KEYS)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
49
63
|
end
|
|
50
64
|
end
|
|
@@ -48,6 +48,14 @@ module StandardId
|
|
|
48
48
|
def find_authorization_code(code)
|
|
49
49
|
StandardId::AuthorizationCode.lookup(code)
|
|
50
50
|
end
|
|
51
|
+
|
|
52
|
+
def token_client
|
|
53
|
+
@credential&.client_application
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def token_account
|
|
57
|
+
@authorization_code&.account
|
|
58
|
+
end
|
|
51
59
|
end
|
|
52
60
|
end
|
|
53
61
|
end
|
|
@@ -52,13 +52,15 @@ module StandardId
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def build_jwt_payload(expires_in)
|
|
55
|
-
{
|
|
55
|
+
base_payload = {
|
|
56
56
|
sub: subject_id,
|
|
57
57
|
client_id: client_id,
|
|
58
58
|
scope: token_scope,
|
|
59
59
|
grant_type: grant_type,
|
|
60
60
|
aud: audience
|
|
61
61
|
}.compact
|
|
62
|
+
|
|
63
|
+
base_payload.merge(claims_from_scope_mapping)
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def token_expiry
|
|
@@ -106,6 +108,70 @@ module StandardId
|
|
|
106
108
|
def audience
|
|
107
109
|
params[:audience]
|
|
108
110
|
end
|
|
111
|
+
|
|
112
|
+
def claims_from_scope_mapping
|
|
113
|
+
scope_claims = StandardId.config.oauth.scope_claims.with_indifferent_access
|
|
114
|
+
resolvers = StandardId.config.oauth.claim_resolvers.with_indifferent_access
|
|
115
|
+
return {} if scope_claims.empty? || resolvers.empty?
|
|
116
|
+
|
|
117
|
+
claims = {}
|
|
118
|
+
current_scopes.each do |scope|
|
|
119
|
+
Array(scope_claims[scope]).each do |claim_key|
|
|
120
|
+
next if claims.key?(claim_key)
|
|
121
|
+
|
|
122
|
+
value = resolve_claim_value(resolvers[claim_key])
|
|
123
|
+
claims[claim_key] = value unless value.nil?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
claims.compact.symbolize_keys
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def current_scopes
|
|
131
|
+
Array.wrap(token_scope)
|
|
132
|
+
.flat_map { |value| value.to_s.split(/\s+/) }
|
|
133
|
+
.reject(&:blank?)
|
|
134
|
+
.uniq
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def token_account
|
|
138
|
+
return nil if subject_id.blank?
|
|
139
|
+
|
|
140
|
+
account_class = StandardId.account_class
|
|
141
|
+
return nil unless account_class.respond_to?(:find_by)
|
|
142
|
+
|
|
143
|
+
account_class.find_by(id: subject_id)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def token_client
|
|
147
|
+
StandardId::ClientApplication.find_by(client_id: client_id)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def claim_resolvers_context
|
|
151
|
+
@claim_resolvers_context ||= {
|
|
152
|
+
client: token_client,
|
|
153
|
+
account: token_account,
|
|
154
|
+
request: request
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def callable_parameters(resolver)
|
|
159
|
+
parameters = if resolver.respond_to?(:parameters)
|
|
160
|
+
resolver.parameters
|
|
161
|
+
elsif resolver.respond_to?(:method) && resolver.respond_to?(:call)
|
|
162
|
+
resolver.method(:call).parameters
|
|
163
|
+
else
|
|
164
|
+
[]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
accepts_all = parameters.any? { |type, _| type == :keyrest }
|
|
168
|
+
|
|
169
|
+
accepts_all ? claim_resolvers_context.keys : parameters.map { |_, name| name.to_sym }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def resolve_claim_value(resolver)
|
|
173
|
+
resolver&.call(**claim_resolvers_context.slice(*callable_parameters(resolver)))
|
|
174
|
+
end
|
|
109
175
|
end
|
|
110
176
|
end
|
|
111
177
|
end
|
data/lib/standard_id/version.rb
CHANGED