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 +4 -4
- data/README.md +53 -13
- data/app/controllers/concerns/standard_id/social_authentication.rb +8 -6
- data/app/controllers/concerns/standard_id/web/social_login_params.rb +87 -0
- data/app/controllers/concerns/standard_id/web_authentication.rb +10 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +5 -19
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +15 -11
- data/app/controllers/standard_id/web/login_controller.rb +42 -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/oauth/social_flow.rb +3 -8
- data/lib/standard_id/provider_registry.rb +12 -6
- data/lib/standard_id/providers/base.rb +16 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +7 -4
- metadata +2 -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: acbf22ea3a73945fedbcc5d26da84954f4b4e04de00cf2bac51eb59374231a09
|
|
4
|
+
data.tar.gz: 063d9c263aa7ca6910602a1a570676ee96348a88b1265a2c2ac5d8c11dacf076
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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!(
|
|
@@ -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
|
|
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
|
|
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
|
|
28
|
-
provider_tokens
|
|
29
|
-
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
|
|
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:
|
|
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
|
|
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
|
|
77
|
-
|
|
78
|
-
raise StandardId::InvalidRequestError, "Missing state parameter" if
|
|
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
|
-
|
|
81
|
-
state
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
23
|
+
nil
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
def token_scope
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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:
|
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.1
|
|
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)
|