standard_id 0.2.0 → 0.2.2

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: ff3da0872b1957015180ea523a6ece07c7e5182415627edefd1b7cb204123555
4
+ data.tar.gz: 1b18e14597e237f2a6082df2210174f97b05f4febd02a729cad2509ffaf9dd60
5
5
  SHA512:
6
- metadata.gz: 2a59a7565ac2a591fef01ff9aa90edc505e8383f3b9b3d2ac5e4ae82a3d777a9902934bd63683d1adff0211fa0dbc0687e0fd3c6c64428b3e0833c512b8bf9c5
7
- data.tar.gz: 6313c08cd9f375e737563324e2c20a16e9ed3f5f4e25fca793f19c2bd18ad4dff06cba49437b914a5776f164d95d95d33cc880d1b332b74cdaf493e661400e2b
6
+ metadata.gz: 258469a8242bd871aef309f677077daca3c5e36cb6c62c748d698dc420b5997738adefe404da90928e2e98514d775f2fc1caf7f71ee1f40d3fa1228ac5346dc8
7
+ data.tar.gz: 41e96e5bf1aec93e33f36f3a97b81ba26609d2c854e3cc4887186c6cd4717dd8fb2fe73eaf0ac57c03a0097fc47381494bec33497cff8880e3a55ba822fc75ac
data/README.md CHANGED
@@ -101,6 +101,26 @@ class ApiController < ActionController::API
101
101
  end
102
102
  ```
103
103
 
104
+ ### 5. Action Cable Authentication
105
+
106
+ - Include in Your Connection Class
107
+ ```ruby
108
+ module ApplicationCable
109
+ class Connection < ActionCable::Connection::Base
110
+ include StandardId::CableAuthentication
111
+ end
112
+ end
113
+ ```
114
+
115
+ - Access Current Account in Channels
116
+ ```ruby
117
+ class ChatChannel < ApplicationCable::Channel
118
+ def subscribed
119
+ stream_for current_account
120
+ end
121
+ end
122
+ ```
123
+
104
124
  ## Configuration
105
125
 
106
126
  ### Basic Configuration
@@ -0,0 +1,33 @@
1
+ module StandardId
2
+ module CableAuthentication
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ identified_by :current_account
7
+ end
8
+
9
+ def connect
10
+ self.current_account = find_verified_account
11
+ end
12
+
13
+ private
14
+
15
+ def find_verified_account
16
+ if verified_account = find_account_from_session_token
17
+ verified_account
18
+ else
19
+ reject_unauthorized_connection
20
+ end
21
+ end
22
+
23
+ def find_account_from_session_token
24
+ session_token = cookies.encrypted[:session_token] || request.session[:session_token]
25
+ return nil if session_token.blank?
26
+
27
+ browser_session = StandardId::BrowserSession.eager_load(:account).by_token(session_token).first
28
+ return nil unless browser_session&.active?
29
+
30
+ browser_session.account
31
+ end
32
+ end
33
+ end
@@ -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.2"
3
3
  end
@@ -21,7 +21,10 @@ module StandardId
21
21
  def sign_in_account(account)
22
22
  emit_session_creating(account, "browser")
23
23
  token_manager.create_browser_session(account).tap do |browser_session|
24
+ # Store in both session and encrypted cookie for backward compatibility
25
+ # Action Cable will use the encrypted cookie
24
26
  session[:session_token] = browser_session.token
27
+ cookies.encrypted[:session_token] = browser_session.token
25
28
  Current.session = browser_session
26
29
  emit_session_created(browser_session, account, "browser")
27
30
  end
@@ -39,6 +42,7 @@ module StandardId
39
42
  def clear_session!
40
43
  # TODO: make token key names configurable
41
44
  session.delete(:session_token)
45
+ cookies.encrypted[:session_token] = nil
42
46
  cookies.delete(:remember_token)
43
47
 
44
48
  Current.session = nil
@@ -65,7 +69,9 @@ module StandardId
65
69
  end
66
70
 
67
71
  def load_session_from_session_token
68
- StandardId::BrowserSession.eager_load(:account).by_token(session[:session_token]).first
72
+ # Try encrypted cookie first (for Action Cable), then fall back to session (for backward compatibility)
73
+ session_token = cookies.encrypted[:session_token] || session[:session_token]
74
+ StandardId::BrowserSession.eager_load(:account).by_token(session_token).first
69
75
  end
70
76
 
71
77
  def load_session_from_remember_token
@@ -73,7 +79,9 @@ module StandardId
73
79
  return if password_credential.blank?
74
80
 
75
81
  token_manager.create_browser_session(password_credential.account, remember_me: true).tap do |browser_session|
82
+ # Store in both session and encrypted cookie for backward compatibility
76
83
  session[:session_token] = browser_session.token
84
+ cookies.encrypted[:session_token] = browser_session.token
77
85
  cookies[:remember_token] = token_manager.create_remember_token(password_credential)
78
86
  end
79
87
  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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -64,11 +64,13 @@ files:
64
64
  - README.md
65
65
  - Rakefile
66
66
  - app/assets/stylesheets/standard_id/application.css
67
+ - app/channels/concerns/standard_id/cable_authentication.rb
67
68
  - app/controllers/concerns/standard_id/api_authentication.rb
68
69
  - app/controllers/concerns/standard_id/inertia_rendering.rb
69
70
  - app/controllers/concerns/standard_id/inertia_support.rb
70
71
  - app/controllers/concerns/standard_id/set_current_request_details.rb
71
72
  - app/controllers/concerns/standard_id/social_authentication.rb
73
+ - app/controllers/concerns/standard_id/web/social_login_params.rb
72
74
  - app/controllers/concerns/standard_id/web_authentication.rb
73
75
  - app/controllers/standard_id/api/authorization_controller.rb
74
76
  - app/controllers/standard_id/api/base_controller.rb