standard_id 0.2.0 → 0.2.1

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: 313792f07d188ad560c11e667abdcb3b7fbfb8d15738d892152beae42b287d65
4
- data.tar.gz: 4b1811518c974b8e467987fce6abc153ad05f46dd876a1cfe2f40c88c768516c
3
+ metadata.gz: acbf22ea3a73945fedbcc5d26da84954f4b4e04de00cf2bac51eb59374231a09
4
+ data.tar.gz: 063d9c263aa7ca6910602a1a570676ee96348a88b1265a2c2ac5d8c11dacf076
5
5
  SHA512:
6
- metadata.gz: 2a59a7565ac2a591fef01ff9aa90edc505e8383f3b9b3d2ac5e4ae82a3d777a9902934bd63683d1adff0211fa0dbc0687e0fd3c6c64428b3e0833c512b8bf9c5
7
- data.tar.gz: 6313c08cd9f375e737563324e2c20a16e9ed3f5f4e25fca793f19c2bd18ad4dff06cba49437b914a5776f164d95d95d33cc880d1b332b74cdaf493e661400e2b
6
+ metadata.gz: 8a3e58978c5525de51c16ad46e563567a93790a7ad5a99aeea1d43d3068c01df9120ccc5d0a5694a5e3531fe916906bcc811f2a2dcbce25ec42228ca7ddfa4e5
7
+ data.tar.gz: 2f3d4beee53b0fa961ed8648eb433a65e751f63c8f20fa10153e10c16bfce50440a5714bbd9b8a8cc4fefe98ff806804c6f49e6e3a79362b10e9780a55d49fce
@@ -16,12 +16,13 @@ module StandardId
16
16
  raise StandardId::InvalidRequestError, e.message
17
17
  end
18
18
 
19
- def get_user_info_from_provider(redirect_uri: nil, flow: :web)
19
+ def get_user_info_from_provider(redirect_uri: nil, nonce: nil, flow: :web)
20
20
  provider_params = {
21
21
  code: params[:code],
22
22
  id_token: params[:id_token],
23
23
  access_token: params[:access_token],
24
- redirect_uri: redirect_uri
24
+ redirect_uri:,
25
+ nonce:
25
26
  }
26
27
 
27
28
  resolved_params = provider.resolve_params(provider_params, context: { flow: flow })
@@ -100,8 +101,8 @@ module StandardId
100
101
  end
101
102
  end
102
103
 
103
- def run_social_callback(provider:, social_info:, provider_tokens:, account:)
104
- emit_social_auth_completed(provider, social_info, provider_tokens, account)
104
+ def run_social_callback(provider:, social_info:, provider_tokens:, account:, original_request_params: {})
105
+ emit_social_auth_completed(provider, social_info, provider_tokens, account, original_request_params)
105
106
  end
106
107
 
107
108
  def emit_social_user_info_fetched(provider, social_info, email)
@@ -131,13 +132,14 @@ module StandardId
131
132
  )
132
133
  end
133
134
 
134
- def emit_social_auth_completed(provider, social_info, provider_tokens, account)
135
+ def emit_social_auth_completed(provider, social_info, provider_tokens, account, original_request_params)
135
136
  StandardId::Events.publish(
136
137
  StandardId::Events::SOCIAL_AUTH_COMPLETED,
137
138
  account: account,
138
139
  provider: provider,
139
140
  social_info: social_info,
140
- tokens: provider_tokens
141
+ tokens: provider_tokens,
142
+ original_request_params: original_request_params
141
143
  )
142
144
  end
143
145
 
@@ -0,0 +1,87 @@
1
+ module StandardId
2
+ module Web
3
+ module SocialLoginParams
4
+ extend ActiveSupport::Concern
5
+
6
+ OAUTH_PENDING_REQUESTS_COOKIE = "oauth_pending_requests".freeze
7
+ REQUEST_EXPIRY = 10.minutes
8
+
9
+ private
10
+
11
+ def store_oauth_request(state:, nonce: nil, params:)
12
+ pending_requests = load_pending_requests || {}
13
+
14
+ cleanup_expired_requests!(pending_requests)
15
+
16
+ pending_requests[state] = {
17
+ "params" => params,
18
+ "nonce" => nonce,
19
+ "expires_at" => REQUEST_EXPIRY.from_now.to_i
20
+ }
21
+
22
+ save_pending_requests(pending_requests)
23
+ end
24
+
25
+ def consume_oauth_request(state)
26
+ return nil if state.blank?
27
+
28
+ pending_requests = load_pending_requests
29
+ return nil if pending_requests.nil?
30
+
31
+ cleanup_expired_requests!(pending_requests)
32
+
33
+ request_data = pending_requests[state]
34
+ return nil if request_data.nil?
35
+
36
+ # Remove this specific request from pending requests
37
+ pending_requests.delete(state)
38
+
39
+ # Update the cookie with remaining requests
40
+ if pending_requests.empty?
41
+ cookies.delete(OAUTH_PENDING_REQUESTS_COOKIE)
42
+ else
43
+ save_pending_requests(pending_requests)
44
+ end
45
+
46
+ request_data.slice("params", "nonce")
47
+ rescue JSON::ParserError => e
48
+ StandardId.logger.error({
49
+ subject: "standard_id.consume_oauth_request.error",
50
+ error: e.message
51
+ })
52
+ nil
53
+ end
54
+
55
+ def load_pending_requests
56
+ cookie_value = cookies.encrypted[OAUTH_PENDING_REQUESTS_COOKIE]
57
+ return nil if cookie_value.nil?
58
+
59
+ JSON.parse(cookie_value)
60
+ rescue JSON::ParserError
61
+ nil
62
+ end
63
+
64
+ def save_pending_requests(pending_requests)
65
+ cookie_options = {
66
+ value: pending_requests.to_json,
67
+ expires: REQUEST_EXPIRY.from_now,
68
+ httponly: true
69
+ }
70
+
71
+ if request.ssl?
72
+ cookie_options[:secure] = true
73
+ cookie_options[:same_site] = :none
74
+ else
75
+ cookie_options[:same_site] = :lax
76
+ end
77
+
78
+ cookies.encrypted[OAUTH_PENDING_REQUESTS_COOKIE] = cookie_options
79
+ end
80
+
81
+ def cleanup_expired_requests!(pending_requests)
82
+ current_time = Time.now.to_i
83
+ pending_requests.delete_if { |_state, data| data["expires_at"] && data["expires_at"] < current_time }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -36,6 +36,16 @@ module StandardId
36
36
  # Redirect to login page, handling both Inertia and standard requests
37
37
  def redirect_to_login
38
38
  login_path = StandardId.config.login_url.presence || "/login"
39
+
40
+ # Add redirect_uri parameter to preserve the original destination
41
+ if request.get?
42
+ uri = URI.parse(login_path)
43
+ params = Rack::Utils.parse_nested_query(uri.query)
44
+ params["redirect_uri"] = request.fullpath
45
+ uri.query = params.to_query.presence
46
+ login_path = uri.to_s
47
+ end
48
+
39
49
  redirect_with_inertia login_path
40
50
  end
41
51
 
@@ -7,7 +7,6 @@ module StandardId
7
7
  skip_before_action :validate_content_type!
8
8
 
9
9
  def callback
10
- original_params = decode_state_params
11
10
  provider_response = get_user_info_from_provider(flow: resolve_flow_for(provider.provider_name))
12
11
  social_info = provider_response[:user_info]
13
12
  provider_tokens = provider_response[:tokens]
@@ -16,35 +15,22 @@ module StandardId
16
15
  flow = StandardId::Oauth::SocialFlow.new(
17
16
  params,
18
17
  request,
19
- account: account,
20
- connection: provider.provider_name,
21
- original_params: original_params
18
+ account:,
19
+ connection: provider.provider_name
22
20
  )
23
21
 
24
22
  token_response = flow.execute
25
23
  run_social_callback(
26
24
  provider: provider.provider_name,
27
- social_info: social_info,
28
- provider_tokens: provider_tokens,
29
- account: account,
25
+ social_info:,
26
+ provider_tokens:,
27
+ account:
30
28
  )
31
29
  render json: token_response, status: :ok
32
30
  end
33
31
 
34
32
  private
35
33
 
36
- def decode_state_params
37
- encoded_state = params[:state]
38
-
39
- return {} if encoded_state.blank?
40
-
41
- begin
42
- JSON.parse(Base64.urlsafe_decode64(encoded_state))
43
- rescue JSON::ParserError, ArgumentError
44
- raise StandardId::InvalidRequestError, "Invalid state parameter"
45
- end
46
- end
47
-
48
34
  def resolve_flow_for(connection)
49
35
  return :mobile unless connection == "apple"
50
36
 
@@ -5,6 +5,7 @@ module StandardId
5
5
  class ProvidersController < StandardId::Web::BaseController
6
6
  include StandardId::WebAuthentication
7
7
  include StandardId::SocialAuthentication
8
+ include StandardId::Web::SocialLoginParams
8
9
 
9
10
  # Social callbacks must be accessible without an existing browser session
10
11
  # because they create/sign-in the session upon successful callback.
@@ -20,9 +21,9 @@ module StandardId
20
21
  state_data = nil
21
22
 
22
23
  begin
23
- state_data = decode_state_params
24
+ extract_state_and_nonce => { state_data:, nonce: }
24
25
  redirect_uri = callback_url_for
25
- provider_response = get_user_info_from_provider(redirect_uri: redirect_uri)
26
+ provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
26
27
  social_info = provider_response[:user_info]
27
28
  provider_tokens = provider_response[:tokens]
28
29
  account = find_or_create_account_from_social(social_info)
@@ -33,6 +34,7 @@ module StandardId
33
34
  social_info: social_info,
34
35
  provider_tokens: provider_tokens,
35
36
  account: account,
37
+ original_request_params: state_data
36
38
  )
37
39
 
38
40
  destination = state_data["redirect_uri"]
@@ -49,7 +51,7 @@ module StandardId
49
51
  raise StandardId::InvalidRequestError, "Provider #{provider.provider_name} does not support mobile callback"
50
52
  end
51
53
 
52
- state_data = decode_state_params
54
+ extract_state_and_nonce => { state_data: }
53
55
  destination = state_data["redirect_uri"]
54
56
 
55
57
  unless allow_other_host_redirect?(destination)
@@ -73,15 +75,17 @@ module StandardId
73
75
  provider.skip_csrf?
74
76
  end
75
77
 
76
- def decode_state_params
77
- encoded_state = params[:state]
78
- raise StandardId::InvalidRequestError, "Missing state parameter" if encoded_state.blank?
78
+ def extract_state_and_nonce
79
+ state_token = params[:state]
80
+ raise StandardId::InvalidRequestError, "Missing state parameter" if state_token.blank?
79
81
 
80
- state = JSON.parse(Base64.urlsafe_decode64(encoded_state))
81
- state["redirect_uri"] ||= after_authentication_url
82
- state
83
- rescue JSON::ParserError, ArgumentError
84
- raise StandardId::InvalidRequestError, "Invalid state parameter"
82
+ oauth_state = consume_oauth_request(state_token)
83
+ raise StandardId::InvalidRequestError, "Invalid or expired state parameter" if oauth_state.nil?
84
+
85
+ {
86
+ state_data: oauth_state["params"],
87
+ nonce: oauth_state["nonce"]
88
+ }
85
89
  end
86
90
 
87
91
  def handle_callback_error
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  class LoginController < BaseController
4
4
  include StandardId::InertiaRendering
5
+ include StandardId::Web::SocialLoginParams
6
+
5
7
 
6
8
  layout "public"
7
9
 
@@ -33,26 +35,53 @@ module StandardId
33
35
  end
34
36
 
35
37
  def redirect_if_social_login
36
- redirect_with_inertia social_login_url, allow_other_host: true if params[:connection].present?
37
- end
38
+ return unless params[:connection].present?
39
+
40
+ provider = StandardId::ProviderRegistry.get(params[:connection].to_s)
41
+
42
+ state = generate_oauth_token
43
+ nonce = provider_supports_nonce?(provider) ? generate_oauth_token : nil
38
44
 
39
- def social_login_url
40
- connection = params[:connection]
41
- provider = StandardId::ProviderRegistry.get(connection)
45
+ store_oauth_request(
46
+ state:,
47
+ nonce:,
48
+ params: extract_social_login_params
49
+ )
50
+
51
+ callback_url = "#{request.base_url}#{provider.callback_path}"
52
+ extra_params = extract_oauth_params(provider)
42
53
 
43
- provider.authorization_url(
44
- state: encode_state,
45
- redirect_uri: "#{request.base_url}#{provider.callback_path}"
54
+ # Add nonce to OAuth params if provider supports it
55
+ extra_params[:nonce] = nonce if nonce.present?
56
+
57
+ url = provider.authorization_url(
58
+ state:,
59
+ redirect_uri: callback_url,
60
+ **extra_params.compact
46
61
  )
62
+
63
+ redirect_with_inertia url, allow_other_host: true
47
64
  rescue StandardId::ProviderRegistry::ProviderNotFoundError => e
48
65
  raise StandardId::InvalidRequestError, e.message
49
66
  end
50
67
 
51
- def encode_state
52
- Base64.urlsafe_encode64({
53
- redirect_uri: params[:redirect_uri] || after_authentication_url,
54
- timestamp: Time.current.to_i
55
- }.compact.to_json)
68
+ def extract_social_login_params
69
+ request.parameters.except("controller", "action", "format", "authenticity_token", "commit", "login").to_h.deep_dup
70
+ end
71
+
72
+ def extract_oauth_params(provider)
73
+ supported_params = provider.try(:supported_authorization_params)
74
+ return {} if supported_params.blank?
75
+
76
+ params.permit(*supported_params).to_h.compact.symbolize_keys
77
+ end
78
+
79
+ def generate_oauth_token
80
+ SecureRandom.urlsafe_base64(32)
81
+ end
82
+
83
+ def provider_supports_nonce?(provider)
84
+ provider.supported_authorization_params.include?(:nonce)
56
85
  end
57
86
 
58
87
  def login_params
@@ -3,11 +3,10 @@ module StandardId
3
3
  class SocialFlow < TokenGrantFlow
4
4
  attr_reader :account, :connection, :original_params
5
5
 
6
- def initialize(params, request, account:, connection:, original_params: {})
6
+ def initialize(params, request, account:, connection:)
7
7
  super(params, request)
8
8
  @account = account
9
9
  @connection = connection
10
- @original_params = original_params
11
10
  end
12
11
 
13
12
  def authenticate!
@@ -21,21 +20,17 @@ module StandardId
21
20
  end
22
21
 
23
22
  def client_id
24
- @original_params["client_id"]
23
+ nil
25
24
  end
26
25
 
27
26
  def token_scope
28
- @original_params["scope"]
27
+ nil
29
28
  end
30
29
 
31
30
  def grant_type
32
31
  "social"
33
32
  end
34
33
 
35
- def audience
36
- @original_params["audience"]
37
- end
38
-
39
34
  def supports_refresh_token?
40
35
  true
41
36
  end
@@ -209,6 +209,22 @@ module StandardId
209
209
  false
210
210
  end
211
211
 
212
+ # Returns list of supported authorization parameters for this provider.
213
+ #
214
+ # Include :nonce in this list for OIDC providers to enable nonce validation.
215
+ # Nonce provides replay attack protection for ID tokens.
216
+ #
217
+ # @return [Array<Symbol>] List of supported parameters
218
+ #
219
+ # @example
220
+ # def supported_authorization_params
221
+ # [:scope, :prompt, :nonce]
222
+ # end
223
+ #
224
+ def supported_authorization_params
225
+ []
226
+ end
227
+
212
228
  # Optional setup hook called when provider is registered.
213
229
  #
214
230
  # Override this method to perform initialization tasks like:
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -69,6 +69,7 @@ files:
69
69
  - app/controllers/concerns/standard_id/inertia_support.rb
70
70
  - app/controllers/concerns/standard_id/set_current_request_details.rb
71
71
  - app/controllers/concerns/standard_id/social_authentication.rb
72
+ - app/controllers/concerns/standard_id/web/social_login_params.rb
72
73
  - app/controllers/concerns/standard_id/web_authentication.rb
73
74
  - app/controllers/standard_id/api/authorization_controller.rb
74
75
  - app/controllers/standard_id/api/base_controller.rb