standard_id 0.1.7 → 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: 2878f305d6dfe83c5a1c0851cde68602cbd17899b1a676981afd8166f56995e0
4
- data.tar.gz: 4c1802cc0bb54045165eb42289d75dda85db9a8838d61a6c1b4e7a7ff50e7828
3
+ metadata.gz: acbf22ea3a73945fedbcc5d26da84954f4b4e04de00cf2bac51eb59374231a09
4
+ data.tar.gz: 063d9c263aa7ca6910602a1a570676ee96348a88b1265a2c2ac5d8c11dacf076
5
5
  SHA512:
6
- metadata.gz: 8073e2e1f0208261525be8218960ef6481e8a5558f281a56baf2316b4750f9557b37365831b9aa530706e2d52d6c088e3cc3e9c23c4a4ef722d641f2041ea357
7
- data.tar.gz: a803c19a19f5fbcc3acae0511f629c6d4b1520d67a54cfa16d062fcc60333083d28cd5149a6658bfb16e37ae3a2ff1d7cdf06d657d16130010a08c67b8c851ff
6
+ metadata.gz: 8a3e58978c5525de51c16ad46e563567a93790a7ad5a99aeea1d43d3068c01df9120ccc5d0a5694a5e3531fe916906bcc811f2a2dcbce25ec42228ca7ddfa4e5
7
+ data.tar.gz: 2f3d4beee53b0fa961ed8648eb433a65e751f63c8f20fa10153e10c16bfce50440a5714bbd9b8a8cc4fefe98ff806804c6f49e6e3a79362b10e9780a55d49fce
data/README.md CHANGED
@@ -413,16 +413,58 @@ This outputs JSON-structured logs for all authentication events:
413
413
 
414
414
  ### Available Events
415
415
 
416
- | Category | Events |
417
- |----------|--------|
418
- | **Authentication** | `authentication.attempt.started`, `authentication.attempt.succeeded`, `authentication.attempt.failed`, `authentication.password.validated`, `authentication.password.failed`, `authentication.otp.validated`, `authentication.otp.failed` |
419
- | **Session** | `session.creating`, `session.created`, `session.validating`, `session.validated`, `session.expired`, `session.revoked`, `session.refreshed` |
420
- | **Account** | `account.creating`, `account.created`, `account.verified`, `account.status_changed`, `account.activated`, `account.deactivated`, `account.locked`, `account.unlocked` |
421
- | **Identifier** | `identifier.created`, `identifier.verification.started`, `identifier.verification.succeeded`, `identifier.verification.failed`, `identifier.linked` |
422
- | **OAuth** | `oauth.authorization.requested`, `oauth.authorization.granted`, `oauth.authorization.denied`, `oauth.token.issuing`, `oauth.token.issued`, `oauth.token.refreshed`, `oauth.code.consumed` |
423
- | **Passwordless** | `passwordless.code.requested`, `passwordless.code.generated`, `passwordless.code.sent`, `passwordless.code.verified`, `passwordless.code.failed`, `passwordless.account.created` |
424
- | **Social** | `social.auth.started`, `social.auth.callback_received`, `social.user_info.fetched`, `social.account.created`, `social.account.linked`, `social.auth.completed` |
425
- | **Credential** | `credential.password.created`, `credential.password.reset_initiated`, `credential.password.reset_completed`, `credential.password.changed`, `credential.client_secret.created`, `credential.client_secret.rotated` |
416
+ Every StandardId event automatically carries tracing metadata (`event_id`, `timestamp`, and request-scoped fields like `request_id`, `ip_address`, `user_agent`, `current_account` when available). The table below lists the domain-specific payload fields and when each event fires.
417
+
418
+ | Category | Event | Payload fields | When emitted |
419
+ |----------|-------|----------------|--------------|
420
+ | Authentication | `authentication.attempt.started` | `account_lookup`, `auth_method` | Before credential validation begins |
421
+ | | `authentication.attempt.succeeded` | `account`, `auth_method`, `session_type` | After authentication succeeds |
422
+ | | `authentication.attempt.failed` | `account_lookup`, `auth_method`, `error_code`, `error_message` | After authentication fails |
423
+ | | `authentication.password.failed` | `account_lookup`, `error_code`, `error_message` | After password verification fails |
424
+ | | `authentication.otp.failed` | `identifier`, `channel`, `error_code`, `error_message` | After OTP verification fails |
425
+ | Session | `session.creating` | `account`, `session_type`, `ip_address`, `user_agent` | Before a session record is created |
426
+ | | `session.created` | `session`, `account`, `session_type`, `token_issued`, `ip_address`, `user_agent` | After session persistence completes |
427
+ | | `session.validating` | `session` | Before validating an existing session |
428
+ | | `session.validated` | `session`, `account` | After a session passes validation |
429
+ | | `session.expired` | `session`, `account`, `expired_at` | When validation fails because the session expired |
430
+ | | `session.revoked` | `session`, `account`, `reason` | After a session is explicitly revoked |
431
+ | | `session.refreshed` | `session`, `account`, `old_expires_at`, `new_expires_at` | After a refresh operation extends a session |
432
+ | Account | `account.creating` | `account_params`, `auth_method` | Before an account record is created |
433
+ | | `account.created` | `account`, `auth_method`, `source` (signup/passwordless/social) | After an account record is created |
434
+ | | `account.verified` | `account`, `verified_via` (email/phone) | When an account is marked verified |
435
+ | | `account.status_changed` | `account`, `old_status`, `new_status`, `changed_by` | When account status transitions (Issue #16) |
436
+ | | `account.locked` | `account`, `lock_reason`, `locked_by` | When an account is administratively locked (Issue #17) |
437
+ | | `account.unlocked` | `account`, `unlocked_by` | When an account lock is lifted (Issue #17) |
438
+ | Identifier | `identifier.created` | `identifier`, `account` | After an identifier record is created |
439
+ | | `identifier.verification.started` | `identifier`, `channel` (email/sms), `code_sent` | After a verification code is issued |
440
+ | | `identifier.verification.succeeded` | `identifier`, `account`, `verified_at` | After identifier verification succeeds |
441
+ | | `identifier.verification.failed` | `identifier`, `error_code`, `attempts` | After identifier verification fails |
442
+ | | `identifier.linked` | `identifier`, `account`, `source` (social/manual) | When an identifier is associated to an account |
443
+ | OAuth | `oauth.authorization.requested` | `client_id`, `account`, `scope`, `redirect_uri` | Before issuing an authorization code |
444
+ | | `oauth.authorization.granted` | `authorization_code`, `client_id`, `account`, `scope` | After an authorization code is created |
445
+ | | `oauth.authorization.denied` | `client_id`, `account`, `reason` | When a user denies authorization |
446
+ | | `oauth.token.issuing` | `grant_type`, `client_id`, `account`, `scope` | Before generating access/refresh tokens |
447
+ | | `oauth.token.issued` | `access_token_id`, `grant_type`, `client_id`, `account`, `expires_in` | After tokens are generated |
448
+ | | `oauth.token.refreshed` | `old_token_id`, `new_token_id`, `client_id`, `account` | After a refresh token is redeemed |
449
+ | | `oauth.code.consumed` | `authorization_code`, `client_id`, `account` | After an authorization code is exchanged |
450
+ | Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms) | Before generating an OTP |
451
+ | | `passwordless.code.generated` | `code_challenge`, `identifier`, `channel`, `expires_at` | After an OTP is created |
452
+ | | `passwordless.code.sent` | `identifier`, `channel`, `delivery_status` | After an OTP is delivered |
453
+ | | `passwordless.code.verified` | `code_challenge`, `account`, `channel` | After OTP verification succeeds |
454
+ | | `passwordless.code.failed` | `identifier`, `channel`, `attempts` | After OTP verification fails |
455
+ | | `passwordless.account.created` | `account`, `channel`, `identifier` | When an account is created via passwordless flow |
456
+ | Social | `social.auth.started` | `provider`, `redirect_uri`, `state` | Before redirecting to a social provider |
457
+ | | `social.auth.callback_received` | `provider`, `code`, `state` | After the provider redirects back |
458
+ | | `social.user_info.fetched` | `provider`, `social_info`, `email` | After fetching user info from the provider |
459
+ | | `social.account.created` | `account`, `provider`, `social_info` | When a social login creates a new account |
460
+ | | `social.account.linked` | `account`, `provider`, `identifier` | When a social identity links to an existing account |
461
+ | | `social.auth.completed` | `account`, `provider`, `tokens` | After social login completes |
462
+ | Credential | `credential.password.created` | `credential`, `account` | After a password credential is created |
463
+ | | `credential.password.reset_initiated` | `credential`, `account`, `reset_token_expires_at` | After a password reset is initiated |
464
+ | | `credential.password.reset_completed` | `credential`, `account` | After a password reset is confirmed |
465
+ | | `credential.password.changed` | `credential`, `account`, `changed_by` | After a password is updated |
466
+ | | `credential.client_secret.created` | `credential`, `client_id` | After a client secret is created |
467
+ | | `credential.client_secret.rotated` | `credential`, `client_id`, `old_secret_revoked_at` | After a client secret rotation |
426
468
 
427
469
  ### Subscribing to Events
428
470
 
@@ -459,9 +501,7 @@ end
459
501
  ```ruby
460
502
  # app/subscribers/audit_subscriber.rb
461
503
  class AuditSubscriber < StandardId::Events::Subscribers::Base
462
- subscribe_to StandardId::Events::AUTHENTICATION_SUCCEEDED
463
- subscribe_to StandardId::Events::AUTHENTICATION_FAILED
464
- subscribe_to StandardId::Events::SESSION_REVOKED
504
+ subscribe_to StandardId::Events::SECURITY_EVENTS
465
505
 
466
506
  def call(event)
467
507
  AuditLog.create!(
@@ -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
@@ -1,11 +1,13 @@
1
1
  require "ostruct"
2
+ require "concurrent/map"
2
3
  require "standard_config/config_provider"
3
4
 
4
5
  module StandardConfig
5
6
  class Manager
6
7
  def initialize(schema)
7
8
  @schema = schema
8
- @providers = {}
9
+ @providers = Concurrent::Map.new
10
+ @static_configs = Concurrent::Map.new
9
11
  end
10
12
 
11
13
  # Register a configuration provider for a scope
@@ -36,8 +38,10 @@ module StandardConfig
36
38
  scopes = @schema.scopes_with_field(field)
37
39
  if scopes.size == 1
38
40
  s = scopes.first
39
- register(s, -> { create_static_config_for_scope(s) }) unless @providers.key?(s)
40
- @providers[s].public_send(method_name, *args)
41
+ provider = @providers.compute_if_absent(s) do
42
+ ConfigProvider.new(s, -> { create_static_config_for_scope(s) }, @schema)
43
+ end
44
+ provider.public_send(method_name, *args)
41
45
  return args.first
42
46
  end
43
47
  end
@@ -46,19 +50,21 @@ module StandardConfig
46
50
  scopes = @schema.scopes_with_field(scope_name)
47
51
  if scopes.size == 1
48
52
  s = scopes.first
49
- register(s, -> { create_static_config_for_scope(s) }) unless @providers.key?(s)
50
- return @providers[s].get_field(scope_name)
53
+ provider = @providers.compute_if_absent(s) do
54
+ ConfigProvider.new(s, -> { create_static_config_for_scope(s) }, @schema)
55
+ end
56
+ return provider.get_field(scope_name)
51
57
  end
52
58
 
53
59
  # Handle scope access
54
- if @providers.key?(scope_name)
55
- return @providers[scope_name]
56
- end
60
+ provider = @providers[scope_name]
61
+ return provider if provider
57
62
 
58
63
  # Create static provider for valid scopes on first access
59
64
  if @schema.valid_scope?(scope_name)
60
- register(scope_name, -> { create_static_config_for_scope(scope_name) })
61
- return @providers[scope_name]
65
+ return @providers.compute_if_absent(scope_name) do
66
+ ConfigProvider.new(scope_name, -> { create_static_config_for_scope(scope_name) }, @schema)
67
+ end
62
68
  end
63
69
 
64
70
  super
@@ -75,10 +81,11 @@ module StandardConfig
75
81
  private
76
82
 
77
83
  def create_static_config_for_scope(scope_name)
78
- @static_configs ||= {}
79
- @static_configs[scope_name] ||= OpenStruct.new.tap do |config|
80
- @schema.scopes[scope_name].fields.each do |field_name, field_def|
81
- config.send("#{field_name}=", field_def.default_value)
84
+ @static_configs.compute_if_absent(scope_name) do
85
+ OpenStruct.new.tap do |config|
86
+ @schema.scopes[scope_name].fields.each do |field_name, field_def|
87
+ config.send("#{field_name}=", field_def.default_value)
88
+ end
82
89
  end
83
90
  end
84
91
  end
@@ -1,7 +1,9 @@
1
+ require "concurrent/map"
2
+
1
3
  module StandardConfig
2
4
  class Schema
3
5
  def initialize
4
- @scopes = {}
6
+ @scopes = Concurrent::Map.new
5
7
  end
6
8
 
7
9
  # DSL entry
@@ -16,7 +18,7 @@ module StandardConfig
16
18
 
17
19
  def scope(name, &block)
18
20
  name_sym = name.to_sym
19
- builder = scopes[name_sym] ||= ScopeBuilder.new(name_sym)
21
+ builder = scopes.compute_if_absent(name_sym) { ScopeBuilder.new(name_sym) }
20
22
  builder.instance_eval(&block) if block_given?
21
23
  builder
22
24
  end
@@ -73,7 +75,7 @@ module StandardConfig
73
75
 
74
76
  def initialize(name)
75
77
  @name = name.to_sym
76
- @fields = {}
78
+ @fields = Concurrent::Map.new
77
79
  end
78
80
 
79
81
  def field(name, type: :string, default: nil, readonly: false)
@@ -3,14 +3,21 @@ require "standard_config/config_provider"
3
3
  require "standard_config/manager"
4
4
  require "standard_config/schema"
5
5
 
6
+ require "concurrent/delay"
7
+
6
8
  module StandardConfig
9
+ SCHEMA = Concurrent::Delay.new { Schema.new }
10
+ MANAGER = Concurrent::Delay.new { Manager.new(SCHEMA.value) }
11
+
7
12
  class << self
8
13
  def schema
9
- @schema ||= Schema.new
14
+ SCHEMA.value
10
15
  end
11
16
 
12
17
  def configure(&block)
13
- config.register(:base, block) unless config.registered?(:base) if block_given? && block.arity == 0
18
+ if block_given? && block.arity.zero? && !config.registered?(:base)
19
+ config.register(:base, block)
20
+ end
14
21
 
15
22
  yield config if block_given?
16
23
 
@@ -18,7 +25,7 @@ module StandardConfig
18
25
  end
19
26
 
20
27
  def config
21
- @manager ||= Manager.new(schema)
28
+ MANAGER.value
22
29
  end
23
30
 
24
31
  private
@@ -139,6 +139,47 @@ module StandardId
139
139
  CREDENTIAL_CLIENT_SECRET_REVOKED
140
140
  ].freeze
141
141
 
142
+ SECURITY_EVENTS = [
143
+ # Authentication
144
+ AUTHENTICATION_SUCCEEDED,
145
+ AUTHENTICATION_FAILED,
146
+ PASSWORD_VALIDATION_FAILED,
147
+ OTP_VALIDATION_FAILED,
148
+ # Session
149
+ SESSION_CREATED,
150
+ SESSION_REVOKED,
151
+ SESSION_EXPIRED,
152
+ # Account
153
+ ACCOUNT_CREATED,
154
+ ACCOUNT_VERIFIED,
155
+ ACCOUNT_STATUS_CHANGED,
156
+ ACCOUNT_ACTIVATED,
157
+ ACCOUNT_DEACTIVATED,
158
+ ACCOUNT_LOCKED,
159
+ ACCOUNT_UNLOCKED,
160
+ # Identifier
161
+ IDENTIFIER_VERIFICATION_FAILED,
162
+ # OAuth
163
+ OAUTH_AUTHORIZATION_GRANTED,
164
+ OAUTH_AUTHORIZATION_DENIED,
165
+ OAUTH_TOKEN_ISSUED,
166
+ OAUTH_TOKEN_REFRESHED,
167
+ # Passwordless
168
+ PASSWORDLESS_CODE_FAILED,
169
+ PASSWORDLESS_ACCOUNT_CREATED,
170
+ # Credential
171
+ CREDENTIAL_PASSWORD_CREATED,
172
+ CREDENTIAL_PASSWORD_RESET_INITIATED,
173
+ CREDENTIAL_PASSWORD_RESET_COMPLETED,
174
+ CREDENTIAL_PASSWORD_CHANGED,
175
+ CREDENTIAL_CLIENT_SECRET_CREATED,
176
+ CREDENTIAL_CLIENT_SECRET_ROTATED,
177
+ CREDENTIAL_CLIENT_SECRET_REVOKED,
178
+ # Social
179
+ SOCIAL_ACCOUNT_CREATED,
180
+ SOCIAL_ACCOUNT_LINKED
181
+ ].freeze
182
+
142
183
  ALL_EVENTS = (
143
184
  AUTHENTICATION_EVENTS +
144
185
  SESSION_EVENTS +
@@ -128,6 +128,7 @@ module StandardId
128
128
  enriched[:request_id] = ::Current.request_id if ::Current.request_id.present?
129
129
  enriched[:ip_address] ||= ::Current.ip_address if ::Current.respond_to?(:ip_address) && ::Current.ip_address.present?
130
130
  enriched[:user_agent] ||= ::Current.user_agent if ::Current.respond_to?(:user_agent) && ::Current.user_agent.present?
131
+ enriched[:current_account] ||= ::Current.account if ::Current.respond_to?(:account) && ::Current.account.present?
131
132
  end
132
133
 
133
134
  enriched.merge(payload)
@@ -1,4 +1,5 @@
1
1
  require "jwt"
2
+ require "concurrent/delay"
2
3
 
3
4
  module StandardId
4
5
  class JwtService
@@ -6,7 +7,7 @@ module StandardId
6
7
  RESERVED_JWT_KEYS = %i[sub client_id scope grant_type exp iat aud iss nbf jti]
7
8
  BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type]
8
9
 
9
- def self.session_class
10
+ SESSION_CLASS = Concurrent::Delay.new do
10
11
  Struct.new(*(BASE_SESSION_FIELDS + claim_resolver_keys), keyword_init: true) do
11
12
  def active?
12
13
  true
@@ -14,6 +15,10 @@ module StandardId
14
15
  end
15
16
  end
16
17
 
18
+ def self.session_class
19
+ SESSION_CLASS.value
20
+ end
21
+
17
22
  def self.encode(payload, expires_in: 1.hour)
18
23
  payload[:exp] = expires_in.from_now.to_i
19
24
  payload[:iat] = Time.current.to_i
@@ -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
@@ -1,17 +1,23 @@
1
+ require "concurrent/map"
2
+
1
3
  module StandardId
2
4
  class ProviderRegistry
3
5
  class ProviderNotFoundError < StandardError; end
4
6
  class InvalidProviderError < StandardError; end
5
7
 
6
- @providers = {}
8
+ @providers = Concurrent::Map.new
7
9
 
8
10
  class << self
11
+ def providers
12
+ @providers
13
+ end
14
+
9
15
  # Register a provider
10
16
  # @param name [Symbol, String] Provider identifier
11
17
  # @param provider_class [Class] Provider implementation class
12
18
  def register(name, provider_class)
13
19
  validate_provider!(provider_class)
14
- @providers[name.to_s] = provider_class
20
+ providers[name.to_s] = provider_class
15
21
  register_config_schema(provider_class)
16
22
  provider_class.setup if provider_class.respond_to?(:setup)
17
23
  provider_class
@@ -22,9 +28,9 @@ module StandardId
22
28
  # @return [Class] Provider class
23
29
  # @raise [ProviderNotFoundError] if provider not found
24
30
  def get(name)
25
- @providers[name.to_s] || raise(
31
+ providers[name.to_s] || raise(
26
32
  ProviderNotFoundError,
27
- "Unknown provider: #{name}. Available providers: #{@providers.keys.join(', ')}"
33
+ "Unknown provider: #{name}. Available providers: #{providers.keys.join(', ')}"
28
34
  )
29
35
  end
30
36
 
@@ -32,14 +38,14 @@ module StandardId
32
38
  # Get all registered providers
33
39
  # @return [Hash] Provider name => class mapping
34
40
  def all
35
- @providers.dup
41
+ providers.each_pair.to_h
36
42
  end
37
43
 
38
44
  # Check if provider is registered
39
45
  # @param name [Symbol, String] Provider identifier
40
46
  # @return [Boolean]
41
47
  def registered?(name)
42
- @providers.key?(name.to_s)
48
+ providers.key?(name.to_s)
43
49
  end
44
50
 
45
51
  private
@@ -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.1.7"
2
+ VERSION = "0.2.1"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -40,13 +40,16 @@ require "standard_id/passwordless/email_strategy"
40
40
  require "standard_id/passwordless/sms_strategy"
41
41
  require "standard_id/utils/callable_parameter_filter"
42
42
 
43
+ require "concurrent/delay"
44
+
43
45
  require "standard_id/providers/base"
44
46
  require "standard_id/provider_registry"
45
- require "standard_id/providers/google"
46
- require "standard_id/providers/apple"
47
47
 
48
48
  module StandardId
49
49
  class << self
50
+ CACHE_STORE = Concurrent::Delay.new { config.cache_store || Rails.cache }
51
+ LOGGER = Concurrent::Delay.new { config.logger || Rails.logger }
52
+
50
53
  def configure(&block)
51
54
  StandardConfig.configure(&block)
52
55
  end
@@ -60,11 +63,11 @@ module StandardId
60
63
  end
61
64
 
62
65
  def cache_store
63
- @cache_store ||= config.cache_store || Rails.cache
66
+ CACHE_STORE.value
64
67
  end
65
68
 
66
69
  def logger
67
- @logger ||= config.logger || Rails.logger
70
+ LOGGER.value
68
71
  end
69
72
 
70
73
  def account_class
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.7
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
@@ -182,9 +183,7 @@ files:
182
183
  - lib/standard_id/passwordless/email_strategy.rb
183
184
  - lib/standard_id/passwordless/sms_strategy.rb
184
185
  - lib/standard_id/provider_registry.rb
185
- - lib/standard_id/providers/apple.rb
186
186
  - lib/standard_id/providers/base.rb
187
- - lib/standard_id/providers/google.rb
188
187
  - lib/standard_id/utils/callable_parameter_filter.rb
189
188
  - lib/standard_id/version.rb
190
189
  - lib/standard_id/web/authentication_guard.rb
@@ -1,223 +0,0 @@
1
- require "uri"
2
- require "net/http"
3
- require "json"
4
- require "jwt"
5
- require_relative "base"
6
-
7
- module StandardId
8
- module Providers
9
- class Apple < Base
10
- ISSUER = "https://appleid.apple.com".freeze
11
- AUTH_ENDPOINT = "#{ISSUER}/auth/authorize".freeze
12
- TOKEN_ENDPOINT = "#{ISSUER}/auth/token".freeze
13
- JWKS_URI = "#{ISSUER}/auth/keys".freeze
14
- DEFAULT_SCOPE = "name email".freeze
15
- DEFAULT_RESPONSE_MODE = "form_post".freeze
16
-
17
- class << self
18
- def provider_name
19
- "apple"
20
- end
21
-
22
- def authorization_url(state:, redirect_uri:, **options)
23
- scope = options[:scope] || DEFAULT_SCOPE
24
- response_mode = options[:response_mode] || DEFAULT_RESPONSE_MODE
25
-
26
- ensure_basic_credentials!
27
-
28
- query = {
29
- client_id: StandardId.config.apple_client_id,
30
- redirect_uri: redirect_uri,
31
- response_type: "code",
32
- scope: scope,
33
- response_mode: response_mode,
34
- state: state
35
- }
36
-
37
- "#{AUTH_ENDPOINT}?#{URI.encode_www_form(query)}"
38
- end
39
-
40
- def get_user_info(code: nil, id_token: nil, access_token: nil, redirect_uri: nil, **options)
41
- client_id = options[:client_id] || StandardId.config.apple_client_id
42
-
43
- if id_token.present?
44
- build_response(
45
- verify_id_token(id_token: id_token, client_id: client_id),
46
- tokens: { id_token: id_token }
47
- )
48
- elsif code.present?
49
- exchange_code_for_user_info(code: code, redirect_uri: redirect_uri, client_id: client_id)
50
- else
51
- raise StandardId::InvalidRequestError, "Either code or id_token must be provided"
52
- end
53
- end
54
-
55
- def config_schema
56
- {
57
- apple_client_id: { type: :string, default: nil },
58
- apple_mobile_client_id: { type: :string, default: nil },
59
- apple_private_key: { type: :string, default: nil },
60
- apple_key_id: { type: :string, default: nil },
61
- apple_team_id: { type: :string, default: nil }
62
- }
63
- end
64
-
65
- def default_scope
66
- DEFAULT_SCOPE
67
- end
68
-
69
- def skip_csrf?
70
- true
71
- end
72
-
73
- def supports_mobile_callback?
74
- true
75
- end
76
-
77
- def resolve_params(params, context: {})
78
- flow = context[:flow] || :web
79
- client_id = flow == :mobile ? StandardId.config.apple_mobile_client_id : StandardId.config.apple_client_id
80
-
81
- params.merge(client_id: client_id)
82
- end
83
-
84
- def exchange_code_for_user_info(code:, redirect_uri:, client_id: StandardId.config.apple_client_id)
85
- ensure_full_credentials!(client_id: client_id)
86
- raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
87
-
88
- token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
89
- client_id: client_id,
90
- client_secret: generate_client_secret(client_id: client_id),
91
- code: code,
92
- grant_type: "authorization_code",
93
- redirect_uri: redirect_uri
94
- })
95
-
96
- unless token_response.is_a?(Net::HTTPSuccess)
97
- error_body = JSON.parse(token_response.body) rescue {}
98
- raise StandardId::InvalidRequestError, "Failed to exchange Apple authorization code: #{error_body['error']}"
99
- end
100
-
101
- parsed_token = JSON.parse(token_response.body)
102
- id_token = parsed_token["id_token"]
103
- raise StandardId::InvalidRequestError, "Apple response missing id_token" if id_token.blank?
104
-
105
- tokens = extract_token_payload(parsed_token)
106
- user_info = verify_id_token(id_token: id_token, client_id: client_id)
107
-
108
- build_response(user_info, tokens: tokens)
109
- rescue StandardError => e
110
- raise e if e.is_a?(StandardId::OAuthError)
111
- raise StandardId::OAuthError, e.message, cause: e
112
- end
113
-
114
- def verify_id_token(id_token:, client_id: StandardId.config.apple_client_id)
115
- raise StandardId::InvalidRequestError, "Missing id_token" if id_token.blank?
116
- if client_id.blank?
117
- raise StandardId::InvalidRequestError, "Apple client_id is not configured"
118
- end
119
-
120
- decoded_token = JWT.decode(id_token, nil, false)
121
- header = decoded_token[1]
122
-
123
- jwk = fetch_jwk(kid: header["kid"])
124
-
125
- verified_payload, = JWT.decode(
126
- id_token,
127
- jwk.public_key,
128
- true,
129
- algorithm: "RS256",
130
- iss: ISSUER,
131
- verify_iss: true,
132
- aud: client_id,
133
- verify_aud: true
134
- )
135
-
136
- {
137
- "sub" => verified_payload["sub"],
138
- "email" => verified_payload["email"],
139
- "email_verified" => verified_payload["email_verified"],
140
- "is_private_email" => verified_payload["is_private_email"]
141
- }.compact
142
- rescue JWT::InvalidAudError => e
143
- raise StandardId::InvalidRequestError, "Invalid Apple ID token audience: #{e.message}"
144
- rescue JWT::DecodeError => e
145
- raise StandardId::InvalidRequestError, "Invalid Apple ID token: #{e.message}"
146
- rescue StandardError => e
147
- raise e if e.is_a?(StandardId::OAuthError)
148
- raise StandardId::OAuthError, e.message, cause: e
149
- end
150
-
151
- private
152
-
153
- def ensure_basic_credentials!(client_id: StandardId.config.apple_client_id)
154
- if client_id.blank?
155
- raise StandardId::InvalidRequestError, "Apple OAuth is not configured"
156
- end
157
- end
158
-
159
- def ensure_full_credentials!(client_id: nil)
160
- ensure_basic_credentials!(client_id: client_id)
161
-
162
- required = [
163
- StandardId.config.apple_private_key,
164
- StandardId.config.apple_key_id,
165
- StandardId.config.apple_team_id
166
- ]
167
-
168
- if required.any?(&:blank?)
169
- raise StandardId::InvalidRequestError, "Apple OAuth credentials are incomplete"
170
- end
171
- end
172
-
173
- def generate_client_secret(client_id: StandardId.config.apple_client_id)
174
- header = {
175
- alg: "ES256",
176
- kid: StandardId.config.apple_key_id
177
- }
178
-
179
- payload = {
180
- iss: StandardId.config.apple_team_id,
181
- iat: Time.current.to_i,
182
- exp: Time.current.to_i + 3600,
183
- aud: ISSUER,
184
- sub: client_id
185
- }
186
-
187
- private_key = OpenSSL::PKey::EC.new(StandardId.config.apple_private_key)
188
- JWT.encode(payload, private_key, "ES256", header)
189
- end
190
-
191
- def fetch_jwk(kid:)
192
- uri = URI(JWKS_URI)
193
- jwks_response = Net::HTTP.get_response(uri)
194
-
195
- unless jwks_response.is_a?(Net::HTTPSuccess)
196
- raise StandardId::InvalidRequestError, "Failed to fetch Apple JWKS"
197
- end
198
-
199
- jwks_data = JSON.parse(jwks_response.body)
200
- jwk_data = jwks_data["keys"].find { |key| key["kid"] == kid }
201
-
202
- raise StandardId::InvalidRequestError, "JWK with kid '#{kid}' not found in Apple's JWKS" unless jwk_data
203
-
204
- JWT::JWK.import(jwk_data)
205
- rescue StandardError => e
206
- raise e if e.is_a?(StandardId::OAuthError)
207
- raise StandardId::OAuthError, "Failed to fetch JWK: #{e.message}"
208
- end
209
-
210
- def extract_token_payload(parsed_token)
211
- {
212
- access_token: parsed_token["access_token"],
213
- refresh_token: parsed_token["refresh_token"],
214
- id_token: parsed_token["id_token"]
215
- }.compact
216
- end
217
- end
218
- end
219
- end
220
- end
221
-
222
- # Auto-register with the provider registry
223
- StandardId::ProviderRegistry.register(:apple, StandardId::Providers::Apple)
@@ -1,187 +0,0 @@
1
- require_relative "base"
2
-
3
- module StandardId
4
- module Providers
5
- class Google < Base
6
- AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth".freeze
7
- TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token".freeze
8
- USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo".freeze
9
- TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo".freeze
10
- DEFAULT_SCOPE = "openid email profile".freeze
11
-
12
- class << self
13
- def provider_name
14
- "google"
15
- end
16
-
17
- def authorization_url(state:, redirect_uri:, **options)
18
- scope = options[:scope] || DEFAULT_SCOPE
19
- prompt = options[:prompt]
20
-
21
- query = {
22
- client_id: credentials[:client_id],
23
- redirect_uri: redirect_uri,
24
- response_type: "code",
25
- scope: scope,
26
- state: state
27
- }
28
-
29
- query[:prompt] = prompt if prompt.present?
30
-
31
- "#{AUTH_ENDPOINT}?#{URI.encode_www_form(query)}"
32
- end
33
-
34
- def get_user_info(code: nil, id_token: nil, access_token: nil, redirect_uri: nil, **options)
35
- if id_token.present?
36
- build_response(
37
- verify_id_token(id_token: id_token),
38
- tokens: { id_token: id_token }
39
- )
40
- elsif access_token.present?
41
- build_response(
42
- fetch_user_info(access_token: access_token),
43
- tokens: { access_token: access_token }
44
- )
45
- elsif code.present?
46
- exchange_code_for_user_info(code: code, redirect_uri: redirect_uri)
47
- else
48
- raise StandardId::InvalidRequestError, "Either code, id_token, or access_token must be provided"
49
- end
50
- end
51
-
52
- def config_schema
53
- {
54
- google_client_id: { type: :string, default: nil },
55
- google_client_secret: { type: :string, default: nil }
56
- }
57
- end
58
-
59
- def default_scope
60
- DEFAULT_SCOPE
61
- end
62
-
63
- def exchange_code_for_user_info(code:, redirect_uri:)
64
- raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
65
-
66
- token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
67
- client_id: credentials[:client_id],
68
- client_secret: credentials[:client_secret],
69
- code: code,
70
- grant_type: "authorization_code",
71
- redirect_uri: redirect_uri
72
- }.compact)
73
-
74
- unless token_response.is_a?(Net::HTTPSuccess)
75
- raise StandardId::InvalidRequestError, "Failed to exchange Google authorization code"
76
- end
77
-
78
- parsed_token = JSON.parse(token_response.body)
79
- access_token = parsed_token["access_token"]
80
- raise StandardId::InvalidRequestError, "Google response missing access token" if access_token.blank?
81
-
82
- tokens = extract_token_payload(parsed_token)
83
- user_info = fetch_user_info(access_token: access_token)
84
-
85
- build_response(user_info, tokens: tokens)
86
- rescue StandardError => e
87
- raise e if e.is_a?(StandardId::OAuthError)
88
- raise StandardId::OAuthError, e.message, cause: e
89
- end
90
-
91
- def verify_id_token(id_token:)
92
- raise StandardId::InvalidRequestError, "Missing id_token" if id_token.blank?
93
-
94
- response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
95
-
96
- unless response.is_a?(Net::HTTPSuccess)
97
- raise StandardId::InvalidRequestError, "Invalid or expired id_token"
98
- end
99
-
100
- token_info = JSON.parse(response.body)
101
-
102
- unless token_info["aud"] == credentials[:client_id]
103
- raise StandardId::InvalidRequestError, "ID token audience mismatch. Expected: #{credentials[:client_id]}, got: #{token_info['aud']}"
104
- end
105
-
106
- unless ["accounts.google.com", "https://accounts.google.com"].include?(token_info["iss"])
107
- raise StandardId::InvalidRequestError, "ID token issuer invalid. Expected Google, got: #{token_info['iss']}"
108
- end
109
-
110
- {
111
- "sub" => token_info["sub"],
112
- "email" => token_info["email"],
113
- "email_verified" => token_info["email_verified"],
114
- "name" => token_info["name"],
115
- "given_name" => token_info["given_name"],
116
- "family_name" => token_info["family_name"],
117
- "picture" => token_info["picture"],
118
- "locale" => token_info["locale"]
119
- }.compact
120
- rescue StandardError => e
121
- raise e if e.is_a?(StandardId::OAuthError)
122
- raise StandardId::OAuthError, e.message, cause: e
123
- end
124
-
125
- def fetch_user_info(access_token:)
126
- raise StandardId::InvalidRequestError, "Missing access token" if access_token.blank?
127
-
128
- verify_token(access_token)
129
- user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
130
-
131
- unless user_response.is_a?(Net::HTTPSuccess)
132
- raise StandardId::InvalidRequestError, "Failed to fetch Google user info"
133
- end
134
-
135
- JSON.parse(user_response.body)
136
- rescue StandardError => e
137
- raise e if e.is_a?(StandardId::OAuthError)
138
- raise StandardId::OAuthError, e.message, cause: e
139
- end
140
-
141
- private
142
-
143
- def credentials
144
- @credentials ||= begin
145
- if StandardId.config.google_client_id.blank? || StandardId.config.google_client_secret.blank?
146
- raise StandardId::InvalidRequestError, "Google provider is not configured"
147
- end
148
-
149
- {
150
- client_id: StandardId.config.google_client_id,
151
- client_secret: StandardId.config.google_client_secret
152
- }
153
- end
154
- end
155
-
156
- def verify_token(access_token)
157
- token_info_uri = "https://www.googleapis.com/oauth2/v3/tokeninfo"
158
-
159
- response = HttpClient.post_form(token_info_uri, access_token: access_token)
160
-
161
- unless response.is_a?(Net::HTTPSuccess)
162
- raise StandardId::InvalidRequestError, "Invalid or expired access token"
163
- end
164
-
165
- token_info = JSON.parse(response.body)
166
-
167
- unless token_info["aud"] == credentials[:client_id]
168
- raise StandardId::InvalidRequestError, "Access token audience mismatch. Expected: #{credentials[:client_id]}, got: #{token_info['aud']}"
169
- end
170
-
171
- token_info
172
- end
173
-
174
- def extract_token_payload(parsed_token)
175
- {
176
- access_token: parsed_token["access_token"],
177
- refresh_token: parsed_token["refresh_token"],
178
- id_token: parsed_token["id_token"]
179
- }.compact
180
- end
181
- end
182
- end
183
- end
184
- end
185
-
186
- # Auto-register with the provider registry
187
- StandardId::ProviderRegistry.register(:google, StandardId::Providers::Google)