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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8da5350b060ff2f0494489d5cc6718cc08b2dbca835a9c28927e228b712e39d
4
- data.tar.gz: 96ac14f364fd07b8d6750d31bd015b7bc2717d692206a203b3fe8161a6a1008f
3
+ metadata.gz: 70f439b35a454065b4a2e45dfedc85b7316b26b0140d6f005930fc909ac78efd
4
+ data.tar.gz: bbf731529fdb2c0cb979e6fecfa711e83daaa1fddfd111263d44618b38bd8fa4
5
5
  SHA512:
6
- metadata.gz: 195c91598a768df91279b0c6bda4d8b312f73a94d4c52c68b2864fd75453f2c474059c4f7d740e0d8c0b4086471ea193c9ded4bb7b492f3c098c950f941f6140
7
- data.tar.gz: 80d64202a7b69456e241952b79f0a65ea9a88ed8b242ff31015e11854d141bd32ccbeaccc2f60ccddf78d188dbbec74e48f839c9181a2b4cdce01917001e4125
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
- Session = Struct.new(:account_id, :client_id, :scopes, :grant_type, keyword_init: true) do
7
- def active?
8
- true
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
- Session.new(
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
@@ -29,6 +29,14 @@ module StandardId
29
29
  def audience
30
30
  params[:audience]
31
31
  end
32
+
33
+ def token_client
34
+ @credential&.client_application
35
+ end
36
+
37
+ def token_account
38
+ nil
39
+ end
32
40
  end
33
41
  end
34
42
  end
@@ -61,6 +61,10 @@ module StandardId
61
61
  def default_scope
62
62
  "read"
63
63
  end
64
+
65
+ def token_account
66
+ @account
67
+ end
64
68
  end
65
69
  end
66
70
  end
@@ -79,6 +79,10 @@ module StandardId
79
79
  def default_scope
80
80
  "read"
81
81
  end
82
+
83
+ def token_account
84
+ account
85
+ end
82
86
  end
83
87
  end
84
88
  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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim