standard_id 0.1.1 → 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: c7150dd77b059e80767d262bc8a8b507399491977208404f3bd2d04209f9f45d
4
- data.tar.gz: e5157110e538a9e6cf625cee184fb423747235eac74c65821fb1b62ba0201452
3
+ metadata.gz: 70f439b35a454065b4a2e45dfedc85b7316b26b0140d6f005930fc909ac78efd
4
+ data.tar.gz: bbf731529fdb2c0cb979e6fecfa711e83daaa1fddfd111263d44618b38bd8fa4
5
5
  SHA512:
6
- metadata.gz: 6c06a909ce1cca77763f8a9c23a01e2703a84c4b4b99435c5f63dc0a9f3ccf54c3f55c98ea9147b51a9b8ce538be6be57003a4f7c744f608c2a4fe40bf04df46
7
- data.tar.gz: b6dc47dc00160c2487c8611b73c21330cd6cbec0865036e235628fb744151c4f946afd0db17f02fa0ca31f3df7261cf94adad09d8c9b2963ed32806dcaf19e63
6
+ metadata.gz: f1d1c0de85827c32f47680adad39da9457528b24376079c154aced6bd46fe40fbe72918df27a9290f54ef6df58e862b251dad7568d16d1abe5dd45c2b477bd45
7
+ data.tar.gz: 8d3a5b5c14e42b1850969b6268728ff47c0b78eb5e482f94b0ad4e2b752e98addc52267588adac384d82d01a207047b1e15b25bc9640c56282d5f67c0f297bd0
data/README.md CHANGED
@@ -87,12 +87,12 @@ end
87
87
  ```ruby
88
88
  # For web controllers
89
89
  class ApplicationController < ActionController::Base
90
- include StandardId::Web::WebAuthentication
90
+ include StandardId::WebAuthentication
91
91
  end
92
92
 
93
93
  # For API controllers
94
94
  class ApiController < ActionController::API
95
- include StandardId::Api::ApiAuthentication
95
+ include StandardId::ApiAuthentication
96
96
  end
97
97
  ```
98
98
 
@@ -123,9 +123,37 @@ StandardId.configure do |config|
123
123
  # config.password.require_special_chars = true
124
124
  # config.passwordless.code_ttl = 600
125
125
  # config.oauth.default_token_lifetime = 3600
126
+ # config.oauth.refresh_token_lifetime = 2_592_000
127
+ # config.oauth.token_lifetimes = {
128
+ # password: 8.hours.to_i,
129
+ # implicit: 15.minutes.to_i
130
+ # }
126
131
  end
127
132
  ```
128
133
 
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
+
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
+
129
157
  ### Social Login Setup
130
158
 
131
159
  ```ruby
@@ -274,7 +302,7 @@ StandardId creates the following tables:
274
302
 
275
303
  ```ruby
276
304
  # Create OAuth client
277
- client = StandardId::Client.create!(
305
+ client = StandardId::ClientApplication.create!(
278
306
  owner: current_account,
279
307
  name: "My Application",
280
308
  redirect_uris: "https://app.com/callback",
@@ -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
@@ -94,7 +94,8 @@ module StandardId
94
94
  def create_client_secret!(name: "Default Secret", **options)
95
95
  client_secret_credentials.create!({
96
96
  name: name,
97
- client_id: client_id
97
+ client_id: client_id,
98
+ scopes: scopes
98
99
  }.merge(options))
99
100
  end
100
101
 
@@ -16,6 +16,20 @@ StandardId.configure do |c|
16
16
  # c.password.minimum_length = 8
17
17
  # c.password.require_special_chars = true
18
18
  # c.oauth.default_token_lifetime = 3600 # 1 hour
19
+ # c.oauth.refresh_token_lifetime = 2_592_000 # 30 days
20
+ # c.oauth.token_lifetimes = {
21
+ # password: 8.hours,
22
+ # client_credentials: 24.hours
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
+ # }
19
33
 
20
34
  # Social login credentials (if enabled in your app)
21
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
@@ -32,8 +32,11 @@ StandardConfig.schema.draw do
32
32
  scope :oauth do
33
33
  field :default_token_lifetime, type: :integer, default: 3600 # 1 hour in seconds
34
34
  field :refresh_token_lifetime, type: :integer, default: 2592000 # 30 days in seconds
35
+ field :token_lifetimes, type: :hash, default: -> { {} }
35
36
  field :client_id, type: :string, default: nil
36
37
  field :client_secret, type: :string, default: nil
38
+ field :scope_claims, type: :hash, default: -> { {} }
39
+ field :claim_resolvers, type: :hash, default: -> { {} }
37
40
  end
38
41
 
39
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
@@ -11,7 +11,7 @@ module StandardId
11
11
  private
12
12
 
13
13
  def subject_id
14
- @credential.account_id
14
+ @credential.client_id
15
15
  end
16
16
 
17
17
  def client_id
@@ -30,8 +30,12 @@ module StandardId
30
30
  params[:audience]
31
31
  end
32
32
 
33
- def token_expiry
34
- 1.hour
33
+ def token_client
34
+ @credential&.client_application
35
+ end
36
+
37
+ def token_account
38
+ nil
35
39
  end
36
40
  end
37
41
  end
@@ -68,7 +68,7 @@ module StandardId
68
68
  end
69
69
 
70
70
  def token_expiry
71
- 1.hour
71
+ TokenLifetimeResolver.access_token_for(:implicit)
72
72
  end
73
73
 
74
74
  def subject_id
@@ -39,10 +39,6 @@ module StandardId
39
39
  true
40
40
  end
41
41
 
42
- def token_expiry
43
- 8.hours # Longer expiry for user sessions
44
- end
45
-
46
42
  def authenticate_account(username, password)
47
43
  StandardId::PasswordCredential
48
44
  .includes(credential: :account)
@@ -65,6 +61,10 @@ module StandardId
65
61
  def default_scope
66
62
  "read"
67
63
  end
64
+
65
+ def token_account
66
+ @account
67
+ end
68
68
  end
69
69
  end
70
70
  end
@@ -41,10 +41,6 @@ module StandardId
41
41
  true
42
42
  end
43
43
 
44
- def token_expiry
45
- 1.hour
46
- end
47
-
48
44
  def code_challenge
49
45
  @code_challenge ||= StandardId::CodeChallenge.active.find_by(
50
46
  realm: "authentication",
@@ -83,6 +79,10 @@ module StandardId
83
79
  def default_scope
84
80
  "read"
85
81
  end
82
+
83
+ def token_account
84
+ account
85
+ end
86
86
  end
87
87
  end
88
88
  end
@@ -52,17 +52,19 @@ 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
65
- 1.hour
67
+ TokenLifetimeResolver.access_token_for(token_lifetime_key)
66
68
  end
67
69
 
68
70
  def supports_refresh_token?
@@ -80,7 +82,11 @@ module StandardId
80
82
  end
81
83
 
82
84
  def refresh_token_expiry
83
- 30.days
85
+ TokenLifetimeResolver.refresh_token_lifetime
86
+ end
87
+
88
+ def token_lifetime_key
89
+ grant_type&.to_sym
84
90
  end
85
91
 
86
92
  def subject_id
@@ -102,6 +108,70 @@ module StandardId
102
108
  def audience
103
109
  params[:audience]
104
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
105
175
  end
106
176
  end
107
177
  end
@@ -0,0 +1,50 @@
1
+ module StandardId
2
+ module Oauth
3
+ class TokenLifetimeResolver
4
+ class << self
5
+ DEFAULT_ACCESS_TOKEN_LIFETIME = 1.hour.to_i
6
+ DEFAULT_REFRESH_TOKEN_LIFETIME = 30.days.to_i
7
+
8
+ def access_token_for(flow_key)
9
+ configured = lookup_token_lifetime(flow_key)
10
+ positive_seconds(configured, default_access_token_lifetime)
11
+ end
12
+
13
+ def refresh_token_lifetime
14
+ positive_seconds(oauth_config.refresh_token_lifetime, DEFAULT_REFRESH_TOKEN_LIFETIME)
15
+ end
16
+
17
+ private
18
+
19
+ def default_access_token_lifetime
20
+ positive_seconds(oauth_config.default_token_lifetime, DEFAULT_ACCESS_TOKEN_LIFETIME)
21
+ end
22
+
23
+ def lookup_token_lifetime(flow_key)
24
+ config = oauth_config
25
+ return nil unless config.respond_to?(:token_lifetimes)
26
+
27
+ lifetimes = config.token_lifetimes || {}
28
+ lifetimes[flow_key.to_sym] || lifetimes[flow_key.to_s] if flow_key
29
+ end
30
+
31
+ def positive_seconds(value, fallback_value)
32
+ normalized_value = case value
33
+ when ActiveSupport::Duration
34
+ value.to_i
35
+ when Numeric, String
36
+ value.to_i
37
+ else
38
+ 0
39
+ end
40
+
41
+ (normalized_value.positive? ? normalized_value : fallback_value).seconds
42
+ end
43
+
44
+ def oauth_config
45
+ StandardId.config.oauth
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.3"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -12,6 +12,7 @@ require "standard_id/api/session_manager"
12
12
  require "standard_id/api/token_manager"
13
13
  require "standard_id/api/authentication_guard"
14
14
  require "standard_id/oauth/base_request_flow"
15
+ require "standard_id/oauth/token_lifetime_resolver"
15
16
  require "standard_id/oauth/token_grant_flow"
16
17
  require "standard_id/oauth/client_credentials_flow"
17
18
  require "standard_id/oauth/authorization_code_flow"
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.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -160,6 +160,7 @@ files:
160
160
  - lib/standard_id/oauth/subflows/social_login_grant.rb
161
161
  - lib/standard_id/oauth/subflows/traditional_code_grant.rb
162
162
  - lib/standard_id/oauth/token_grant_flow.rb
163
+ - lib/standard_id/oauth/token_lifetime_resolver.rb
163
164
  - lib/standard_id/passwordless/base_strategy.rb
164
165
  - lib/standard_id/passwordless/email_strategy.rb
165
166
  - lib/standard_id/passwordless/sms_strategy.rb