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 +4 -4
- data/README.md +53 -13
- data/lib/standard_config/manager.rb +21 -14
- data/lib/standard_config/schema.rb +5 -3
- data/lib/standard_config.rb +10 -3
- data/lib/standard_id/events/definitions.rb +41 -0
- data/lib/standard_id/events.rb +1 -0
- data/lib/standard_id/jwt_service.rb +6 -1
- data/lib/standard_id/provider_registry.rb +12 -6
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +7 -4
- metadata +1 -3
- data/lib/standard_id/providers/apple.rb +0 -223
- data/lib/standard_id/providers/google.rb +0 -187
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 313792f07d188ad560c11e667abdcb3b7fbfb8d15738d892152beae42b287d65
|
|
4
|
+
data.tar.gz: 4b1811518c974b8e467987fce6abc153ad05f46dd876a1cfe2f40c88c768516c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
|
419
|
-
|
|
420
|
-
|
|
|
421
|
-
|
|
|
422
|
-
|
|
|
423
|
-
|
|
|
424
|
-
|
|
|
425
|
-
|
|
|
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::
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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)
|
data/lib/standard_config.rb
CHANGED
|
@@ -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
|
-
|
|
14
|
+
SCHEMA.value
|
|
10
15
|
end
|
|
11
16
|
|
|
12
17
|
def configure(&block)
|
|
13
|
-
|
|
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
|
-
|
|
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 +
|
data/lib/standard_id/events.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
providers[name.to_s] || raise(
|
|
26
32
|
ProviderNotFoundError,
|
|
27
|
-
"Unknown provider: #{name}. Available providers: #{
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
providers.key?(name.to_s)
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
private
|
data/lib/standard_id/version.rb
CHANGED
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
|
-
|
|
66
|
+
CACHE_STORE.value
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def logger
|
|
67
|
-
|
|
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.
|
|
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)
|