standard_id 0.1.7 → 0.2.0

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: 313792f07d188ad560c11e667abdcb3b7fbfb8d15738d892152beae42b287d65
4
+ data.tar.gz: 4b1811518c974b8e467987fce6abc153ad05f46dd876a1cfe2f40c88c768516c
5
5
  SHA512:
6
- metadata.gz: 8073e2e1f0208261525be8218960ef6481e8a5558f281a56baf2316b4750f9557b37365831b9aa530706e2d52d6c088e3cc3e9c23c4a4ef722d641f2041ea357
7
- data.tar.gz: a803c19a19f5fbcc3acae0511f629c6d4b1520d67a54cfa16d062fcc60333083d28cd5149a6658bfb16e37ae3a2ff1d7cdf06d657d16130010a08c67b8c851ff
6
+ metadata.gz: 2a59a7565ac2a591fef01ff9aa90edc505e8383f3b9b3d2ac5e4ae82a3d777a9902934bd63683d1adff0211fa0dbc0687e0fd3c6c64428b3e0833c512b8bf9c5
7
+ data.tar.gz: 6313c08cd9f375e737563324e2c20a16e9ed3f5f4e25fca793f19c2bd18ad4dff06cba49437b914a5776f164d95d95d33cc880d1b332b74cdaf493e661400e2b
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!(
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.1.7"
2
+ VERSION = "0.2.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -182,9 +182,7 @@ files:
182
182
  - lib/standard_id/passwordless/email_strategy.rb
183
183
  - lib/standard_id/passwordless/sms_strategy.rb
184
184
  - lib/standard_id/provider_registry.rb
185
- - lib/standard_id/providers/apple.rb
186
185
  - lib/standard_id/providers/base.rb
187
- - lib/standard_id/providers/google.rb
188
186
  - lib/standard_id/utils/callable_parameter_filter.rb
189
187
  - lib/standard_id/version.rb
190
188
  - 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)