standard_id 0.1.5 → 0.1.7
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 +529 -20
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +49 -0
- data/app/controllers/concerns/standard_id/inertia_support.rb +31 -0
- data/app/controllers/concerns/standard_id/set_current_request_details.rb +19 -0
- data/app/controllers/concerns/standard_id/social_authentication.rb +86 -37
- data/app/controllers/concerns/standard_id/web_authentication.rb +50 -1
- data/app/controllers/standard_id/api/base_controller.rb +1 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +7 -18
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +33 -37
- data/app/controllers/standard_id/web/base_controller.rb +1 -0
- data/app/controllers/standard_id/web/login_controller.rb +12 -21
- data/app/controllers/standard_id/web/signup_controller.rb +11 -8
- data/app/forms/standard_id/web/signup_form.rb +32 -1
- data/app/models/standard_id/browser_session.rb +8 -0
- data/app/models/standard_id/client_secret_credential.rb +11 -0
- data/app/models/standard_id/device_session.rb +4 -0
- data/app/models/standard_id/identifier.rb +28 -0
- data/app/models/standard_id/service_session.rb +1 -1
- data/app/models/standard_id/session.rb +16 -2
- data/app/views/standard_id/web/auth/callback/providers/{apple_mobile.html.erb → mobile_callback.html.erb} +1 -1
- data/config/routes/api.rb +1 -2
- data/config/routes/web.rb +4 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +19 -8
- data/lib/standard_config/config.rb +13 -12
- data/lib/standard_config/config_provider.rb +6 -6
- data/lib/standard_config/schema.rb +2 -2
- data/lib/standard_id/account_locking.rb +86 -0
- data/lib/standard_id/account_status.rb +45 -0
- data/lib/standard_id/api/authentication_guard.rb +40 -1
- data/lib/standard_id/api/token_manager.rb +1 -1
- data/lib/standard_id/config/schema.rb +13 -9
- data/lib/standard_id/current_attributes.rb +9 -0
- data/lib/standard_id/engine.rb +9 -0
- data/lib/standard_id/errors.rb +12 -0
- data/lib/standard_id/events/definitions.rb +157 -0
- data/lib/standard_id/events/event.rb +123 -0
- data/lib/standard_id/events/subscribers/account_locking_subscriber.rb +17 -0
- data/lib/standard_id/events/subscribers/account_status_subscriber.rb +17 -0
- data/lib/standard_id/events/subscribers/base.rb +165 -0
- data/lib/standard_id/events/subscribers/logging_subscriber.rb +122 -0
- data/lib/standard_id/events.rb +137 -0
- data/lib/standard_id/oauth/authorization_code_flow.rb +10 -0
- data/lib/standard_id/oauth/client_credentials_flow.rb +31 -0
- data/lib/standard_id/oauth/password_flow.rb +36 -4
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +38 -2
- data/lib/standard_id/oauth/subflows/social_login_grant.rb +11 -22
- data/lib/standard_id/oauth/token_grant_flow.rb +22 -1
- data/lib/standard_id/passwordless/base_strategy.rb +32 -0
- data/lib/standard_id/provider_registry.rb +73 -0
- data/lib/standard_id/{social_providers → providers}/apple.rb +46 -7
- data/lib/standard_id/providers/base.rb +242 -0
- data/lib/standard_id/{social_providers → providers}/google.rb +26 -7
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/authentication_guard.rb +29 -0
- data/lib/standard_id/web/session_manager.rb +39 -1
- data/lib/standard_id/web/token_manager.rb +2 -2
- data/lib/standard_id.rb +13 -2
- metadata +20 -6
- data/lib/standard_id/social_providers/response_builder.rb +0 -18
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require_relative "events/definitions"
|
|
2
|
+
require_relative "events/event"
|
|
3
|
+
|
|
4
|
+
module StandardId
|
|
5
|
+
module Events
|
|
6
|
+
# Event namespace prefix for all StandardId events
|
|
7
|
+
NAMESPACE = "standard_id"
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# Publish an event with the given name and payload
|
|
11
|
+
#
|
|
12
|
+
# @param event_name [String, Symbol] The event name (use constants from Definitions)
|
|
13
|
+
# @param payload [Hash] The event payload data
|
|
14
|
+
# @yield [Hash] Optional block that receives the payload, useful for lazy evaluation
|
|
15
|
+
# @return [void]
|
|
16
|
+
#
|
|
17
|
+
# @example Simple publish
|
|
18
|
+
# StandardId::Events.publish(:authentication_succeeded, account: user)
|
|
19
|
+
#
|
|
20
|
+
# @example With block for lazy payload
|
|
21
|
+
# StandardId::Events.publish(:authentication_succeeded) do
|
|
22
|
+
# { account: expensive_lookup, duration_ms: calculate_duration }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
def publish(event_name, payload = {}, &block)
|
|
26
|
+
event_payload = block ? block.call.merge(payload) : payload
|
|
27
|
+
full_event_name = namespaced_event_name(event_name)
|
|
28
|
+
|
|
29
|
+
# Add standard metadata to all events
|
|
30
|
+
enriched_payload = enrich_payload(event_payload, event_name)
|
|
31
|
+
|
|
32
|
+
ActiveSupport::Notifications.instrument(full_event_name, enriched_payload)
|
|
33
|
+
rescue ActiveSupport::Notifications::InstrumentationSubscriberError => e
|
|
34
|
+
# Re-raise the first exception only (stop at first failure)
|
|
35
|
+
# This prevents confusing "multiple exceptions" messages when
|
|
36
|
+
# multiple guards (e.g., AccountStatus + AccountLocking) both fail
|
|
37
|
+
raise e.exceptions.first if e.exceptions.any?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Subscribe to an event with a block or callable
|
|
41
|
+
#
|
|
42
|
+
# @param event_names [String, Symbol, Array<String, Symbol>] The event name(s) to subscribe to
|
|
43
|
+
# @yield [StandardId::Events::Event] The event object with name, payload, and timing
|
|
44
|
+
# @return [ActiveSupport::Notifications::Fanout::Subscribers::Evented, Array] The subscription(s)
|
|
45
|
+
#
|
|
46
|
+
# @example Block subscription
|
|
47
|
+
# StandardId::Events.subscribe(:authentication_succeeded) do |event|
|
|
48
|
+
# puts "Login from #{event.payload[:ip_address]}"
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# @example Multiple events subscription
|
|
52
|
+
# StandardId::Events.subscribe(:session_creating, :session_validating) do |event|
|
|
53
|
+
# check_account_status(event)
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# @example Pattern subscription (subscribe to all authentication events)
|
|
57
|
+
# StandardId::Events.subscribe(/authentication/) do |event|
|
|
58
|
+
# audit_log(event)
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
def subscribe(*event_names, &block)
|
|
62
|
+
event_names = event_names.flatten
|
|
63
|
+
|
|
64
|
+
if event_names.size == 1
|
|
65
|
+
subscribe_single(event_names.first, &block)
|
|
66
|
+
else
|
|
67
|
+
event_names.map { |event_name| subscribe_single(event_name, &block) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Subscribe to an event pattern using a regex
|
|
72
|
+
#
|
|
73
|
+
# @param pattern [Regexp] The pattern to match event names
|
|
74
|
+
# @yield [StandardId::Events::Event] The event object
|
|
75
|
+
# @return [ActiveSupport::Notifications::Fanout::Subscribers::Evented] The subscription
|
|
76
|
+
#
|
|
77
|
+
def subscribe_to_pattern(pattern, &block)
|
|
78
|
+
subscribe_single(pattern, &block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Unsubscribe from events
|
|
82
|
+
#
|
|
83
|
+
# @param subscribers [Object, Array<Object>] The subscriber(s) returned from subscribe()
|
|
84
|
+
# @return [void]
|
|
85
|
+
#
|
|
86
|
+
def unsubscribe(*subscribers)
|
|
87
|
+
subscribers.flatten.each do |subscriber|
|
|
88
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get the full namespaced event name
|
|
93
|
+
#
|
|
94
|
+
# @param event_name [String, Symbol] The short event name
|
|
95
|
+
# @return [String] The full namespaced event name
|
|
96
|
+
#
|
|
97
|
+
def namespaced_event_name(event_name)
|
|
98
|
+
return event_name.to_s if event_name.to_s.start_with?("#{NAMESPACE}.")
|
|
99
|
+
|
|
100
|
+
"#{NAMESPACE}.#{event_name}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def subscribe_single(event_name, &block)
|
|
106
|
+
pattern = event_name.is_a?(Regexp) ? event_name : namespaced_event_name(event_name)
|
|
107
|
+
|
|
108
|
+
ActiveSupport::Notifications.subscribe(pattern) do |name, start, finish, id, payload|
|
|
109
|
+
event = Event.new(
|
|
110
|
+
name: name,
|
|
111
|
+
payload: payload,
|
|
112
|
+
started_at: start,
|
|
113
|
+
finished_at: finish,
|
|
114
|
+
transaction_id: id
|
|
115
|
+
)
|
|
116
|
+
block.call(event)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def enrich_payload(payload, event_name)
|
|
121
|
+
enriched = {
|
|
122
|
+
event_type: event_name.to_s,
|
|
123
|
+
event_id: SecureRandom.uuid,
|
|
124
|
+
timestamp: Time.current.iso8601
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if defined?(::Current) && ::Current.respond_to?(:request_id)
|
|
128
|
+
enriched[:request_id] = ::Current.request_id if ::Current.request_id.present?
|
|
129
|
+
enriched[:ip_address] ||= ::Current.ip_address if ::Current.respond_to?(:ip_address) && ::Current.ip_address.present?
|
|
130
|
+
enriched[:user_agent] ||= ::Current.user_agent if ::Current.respond_to?(:user_agent) && ::Current.user_agent.present?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
enriched.merge(payload)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -21,10 +21,20 @@ module StandardId
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
@authorization_code.mark_as_used!
|
|
24
|
+
emit_code_consumed
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
private
|
|
27
28
|
|
|
29
|
+
def emit_code_consumed
|
|
30
|
+
StandardId::Events.publish(
|
|
31
|
+
StandardId::Events::OAUTH_CODE_CONSUMED,
|
|
32
|
+
authorization_code: @authorization_code,
|
|
33
|
+
client_id: @credential.client_id,
|
|
34
|
+
account: @authorization_code.account
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
28
38
|
def subject_id
|
|
29
39
|
@authorization_code.account_id
|
|
30
40
|
end
|
|
@@ -5,7 +5,12 @@ module StandardId
|
|
|
5
5
|
permit_params :organization
|
|
6
6
|
|
|
7
7
|
def authenticate!
|
|
8
|
+
emit_authentication_started
|
|
8
9
|
@credential = validate_client_secret!(params[:client_id], params[:client_secret])
|
|
10
|
+
emit_authentication_succeeded
|
|
11
|
+
rescue StandardId::InvalidClientError => e
|
|
12
|
+
emit_authentication_failed(e.message)
|
|
13
|
+
raise
|
|
9
14
|
end
|
|
10
15
|
|
|
11
16
|
private
|
|
@@ -37,6 +42,32 @@ module StandardId
|
|
|
37
42
|
def token_account
|
|
38
43
|
nil
|
|
39
44
|
end
|
|
45
|
+
|
|
46
|
+
def emit_authentication_started
|
|
47
|
+
StandardId::Events.publish(
|
|
48
|
+
StandardId::Events::AUTHENTICATION_ATTEMPT_STARTED,
|
|
49
|
+
account_lookup: params[:client_id],
|
|
50
|
+
auth_method: "client_credentials"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def emit_authentication_succeeded
|
|
55
|
+
StandardId::Events.publish(
|
|
56
|
+
StandardId::Events::AUTHENTICATION_SUCCEEDED,
|
|
57
|
+
client_application: @credential&.client_application,
|
|
58
|
+
auth_method: "client_credentials"
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def emit_authentication_failed(error_message)
|
|
63
|
+
StandardId::Events.publish(
|
|
64
|
+
StandardId::Events::AUTHENTICATION_FAILED,
|
|
65
|
+
account_lookup: params[:client_id],
|
|
66
|
+
auth_method: "client_credentials",
|
|
67
|
+
error_code: "invalid_client",
|
|
68
|
+
error_message: error_message
|
|
69
|
+
)
|
|
70
|
+
end
|
|
40
71
|
end
|
|
41
72
|
end
|
|
42
73
|
end
|
|
@@ -6,15 +6,47 @@ module StandardId
|
|
|
6
6
|
|
|
7
7
|
def authenticate!
|
|
8
8
|
validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
|
|
9
|
+
emit_authentication_started
|
|
9
10
|
|
|
10
11
|
@account = authenticate_account(params[:username], params[:password])
|
|
11
|
-
raise StandardId::InvalidGrantError, "Invalid username or password" if @account.blank?
|
|
12
12
|
|
|
13
|
+
if @account.blank?
|
|
14
|
+
emit_authentication_failed
|
|
15
|
+
raise StandardId::InvalidGrantError, "Invalid username or password"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
emit_password_validated
|
|
13
19
|
validate_requested_scope!
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
private
|
|
17
23
|
|
|
24
|
+
def emit_authentication_started
|
|
25
|
+
StandardId::Events.publish(
|
|
26
|
+
StandardId::Events::AUTHENTICATION_ATTEMPT_STARTED,
|
|
27
|
+
account_lookup: params[:username],
|
|
28
|
+
auth_method: "password"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def emit_authentication_failed
|
|
33
|
+
StandardId::Events.publish(
|
|
34
|
+
StandardId::Events::AUTHENTICATION_FAILED,
|
|
35
|
+
account_lookup: params[:username],
|
|
36
|
+
auth_method: "password",
|
|
37
|
+
error_code: "invalid_credentials",
|
|
38
|
+
error_message: "Invalid username or password"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def emit_password_validated
|
|
43
|
+
StandardId::Events.publish(
|
|
44
|
+
StandardId::Events::PASSWORD_VALIDATED,
|
|
45
|
+
account: @account,
|
|
46
|
+
credential_id: @credential&.id
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
18
50
|
def subject_id
|
|
19
51
|
@account.id
|
|
20
52
|
end
|
|
@@ -40,11 +72,11 @@ module StandardId
|
|
|
40
72
|
end
|
|
41
73
|
|
|
42
74
|
def authenticate_account(username, password)
|
|
43
|
-
StandardId::PasswordCredential
|
|
75
|
+
@credential = StandardId::PasswordCredential
|
|
44
76
|
.includes(credential: :account)
|
|
45
77
|
.find_by(login: username)
|
|
46
|
-
|
|
47
|
-
|
|
78
|
+
|
|
79
|
+
@credential&.authenticate(password)&.account
|
|
48
80
|
end
|
|
49
81
|
|
|
50
82
|
def validate_requested_scope!
|
|
@@ -7,16 +7,52 @@ module StandardId
|
|
|
7
7
|
def authenticate!
|
|
8
8
|
validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
if code_challenge.blank?
|
|
11
|
+
emit_otp_validation_failed
|
|
12
|
+
raise StandardId::InvalidGrantError, "Invalid or expired verification code"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
if account.blank?
|
|
16
|
+
raise StandardId::InvalidGrantError, "Unable to authenticate user"
|
|
17
|
+
end
|
|
12
18
|
|
|
13
19
|
validate_requested_scope!
|
|
14
20
|
|
|
15
21
|
code_challenge.use!
|
|
22
|
+
emit_otp_validated
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
private
|
|
19
26
|
|
|
27
|
+
def emit_otp_validated
|
|
28
|
+
StandardId::Events.publish(
|
|
29
|
+
StandardId::Events::OTP_VALIDATED,
|
|
30
|
+
account: account,
|
|
31
|
+
channel: params[:connection]
|
|
32
|
+
)
|
|
33
|
+
StandardId::Events.publish(
|
|
34
|
+
StandardId::Events::PASSWORDLESS_CODE_VERIFIED,
|
|
35
|
+
code_challenge: code_challenge,
|
|
36
|
+
account: account,
|
|
37
|
+
channel: params[:connection]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def emit_otp_validation_failed
|
|
42
|
+
StandardId::Events.publish(
|
|
43
|
+
StandardId::Events::OTP_VALIDATION_FAILED,
|
|
44
|
+
identifier: params[:username],
|
|
45
|
+
channel: params[:connection],
|
|
46
|
+
attempts: nil
|
|
47
|
+
)
|
|
48
|
+
StandardId::Events.publish(
|
|
49
|
+
StandardId::Events::PASSWORDLESS_CODE_FAILED,
|
|
50
|
+
identifier: params[:username],
|
|
51
|
+
channel: params[:connection],
|
|
52
|
+
attempts: nil
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
20
56
|
def subject_id
|
|
21
57
|
account.id
|
|
22
58
|
end
|
|
@@ -9,29 +9,18 @@ module StandardId
|
|
|
9
9
|
private
|
|
10
10
|
|
|
11
11
|
def social_provider_url
|
|
12
|
-
@social_provider_url ||=
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
when "apple"
|
|
16
|
-
build_apple_oauth_url
|
|
17
|
-
else
|
|
18
|
-
raise StandardId::InvalidRequestError, "Unsupported connection: #{params[:connection]}"
|
|
19
|
-
end
|
|
20
|
-
end
|
|
12
|
+
@social_provider_url ||= begin
|
|
13
|
+
connection = params[:connection]
|
|
14
|
+
provider = StandardId::ProviderRegistry.get(connection)
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def build_apple_oauth_url
|
|
31
|
-
StandardId::SocialProviders::Apple.authorization_url(
|
|
32
|
-
state: encode_state_with_original_params,
|
|
33
|
-
redirect_uri: "#{params[:base_url]}/api/oauth/callback/apple"
|
|
34
|
-
)
|
|
16
|
+
provider.authorization_url(
|
|
17
|
+
state: encode_state_with_original_params,
|
|
18
|
+
redirect_uri: "#{params[:base_url]}/api/oauth/callback/#{connection}",
|
|
19
|
+
scope: provider.default_scope
|
|
20
|
+
)
|
|
21
|
+
rescue StandardId::ProviderRegistry::ProviderNotFoundError => e
|
|
22
|
+
raise StandardId::InvalidRequestError, e.message
|
|
23
|
+
end
|
|
35
24
|
end
|
|
36
25
|
|
|
37
26
|
def encode_state_with_original_params
|
|
@@ -35,6 +35,7 @@ module StandardId
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def generate_token_response
|
|
38
|
+
emit_token_issuing
|
|
38
39
|
expires_in = token_expiry
|
|
39
40
|
payload = build_jwt_payload(expires_in)
|
|
40
41
|
access_token = StandardId::JwtService.encode(payload, expires_in: expires_in)
|
|
@@ -47,7 +48,7 @@ module StandardId
|
|
|
47
48
|
|
|
48
49
|
response[:scope] = token_scope if token_scope.present?
|
|
49
50
|
response[:refresh_token] = generate_refresh_token if supports_refresh_token?
|
|
50
|
-
|
|
51
|
+
emit_token_issued(expires_in)
|
|
51
52
|
response.compact
|
|
52
53
|
end
|
|
53
54
|
|
|
@@ -159,6 +160,26 @@ module StandardId
|
|
|
159
160
|
filtered_context = StandardId::Utils::CallableParameterFilter.filter(resolver, claim_resolvers_context)
|
|
160
161
|
resolver.call(**filtered_context)
|
|
161
162
|
end
|
|
163
|
+
|
|
164
|
+
def emit_token_issuing
|
|
165
|
+
StandardId::Events.publish(
|
|
166
|
+
StandardId::Events::OAUTH_TOKEN_ISSUING,
|
|
167
|
+
grant_type: grant_type,
|
|
168
|
+
client_id: client_id,
|
|
169
|
+
account: token_account,
|
|
170
|
+
scope: token_scope
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def emit_token_issued(expires_in)
|
|
175
|
+
StandardId::Events.publish(
|
|
176
|
+
StandardId::Events::OAUTH_TOKEN_ISSUED,
|
|
177
|
+
grant_type: grant_type,
|
|
178
|
+
client_id: client_id,
|
|
179
|
+
account: token_account,
|
|
180
|
+
expires_in: expires_in
|
|
181
|
+
)
|
|
182
|
+
end
|
|
162
183
|
end
|
|
163
184
|
end
|
|
164
185
|
end
|
|
@@ -16,8 +16,11 @@ module StandardId
|
|
|
16
16
|
def start!(attrs)
|
|
17
17
|
username = attrs[:username]
|
|
18
18
|
validate_username!(username)
|
|
19
|
+
emit_code_requested(username)
|
|
19
20
|
challenge = create_challenge!(username)
|
|
21
|
+
emit_code_generated(challenge, username)
|
|
20
22
|
sender_callback&.call(username, challenge.code)
|
|
23
|
+
emit_code_sent(username)
|
|
21
24
|
challenge
|
|
22
25
|
end
|
|
23
26
|
|
|
@@ -66,6 +69,35 @@ module StandardId
|
|
|
66
69
|
# Implement in subclasses
|
|
67
70
|
nil
|
|
68
71
|
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def emit_code_requested(username)
|
|
76
|
+
StandardId::Events.publish(
|
|
77
|
+
StandardId::Events::PASSWORDLESS_CODE_REQUESTED,
|
|
78
|
+
identifier: username,
|
|
79
|
+
channel: connection_type
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def emit_code_generated(challenge, username)
|
|
84
|
+
StandardId::Events.publish(
|
|
85
|
+
StandardId::Events::PASSWORDLESS_CODE_GENERATED,
|
|
86
|
+
code_challenge: challenge,
|
|
87
|
+
identifier: username,
|
|
88
|
+
channel: connection_type,
|
|
89
|
+
expires_at: challenge.expires_at
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def emit_code_sent(username)
|
|
94
|
+
StandardId::Events.publish(
|
|
95
|
+
StandardId::Events::PASSWORDLESS_CODE_SENT,
|
|
96
|
+
identifier: username,
|
|
97
|
+
channel: connection_type,
|
|
98
|
+
delivery_status: "sent"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
69
101
|
end
|
|
70
102
|
end
|
|
71
103
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class ProviderRegistry
|
|
3
|
+
class ProviderNotFoundError < StandardError; end
|
|
4
|
+
class InvalidProviderError < StandardError; end
|
|
5
|
+
|
|
6
|
+
@providers = {}
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Register a provider
|
|
10
|
+
# @param name [Symbol, String] Provider identifier
|
|
11
|
+
# @param provider_class [Class] Provider implementation class
|
|
12
|
+
def register(name, provider_class)
|
|
13
|
+
validate_provider!(provider_class)
|
|
14
|
+
@providers[name.to_s] = provider_class
|
|
15
|
+
register_config_schema(provider_class)
|
|
16
|
+
provider_class.setup if provider_class.respond_to?(:setup)
|
|
17
|
+
provider_class
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Get provider by name
|
|
21
|
+
# @param name [Symbol, String] Provider identifier
|
|
22
|
+
# @return [Class] Provider class
|
|
23
|
+
# @raise [ProviderNotFoundError] if provider not found
|
|
24
|
+
def get(name)
|
|
25
|
+
@providers[name.to_s] || raise(
|
|
26
|
+
ProviderNotFoundError,
|
|
27
|
+
"Unknown provider: #{name}. Available providers: #{@providers.keys.join(', ')}"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Get all registered providers
|
|
33
|
+
# @return [Hash] Provider name => class mapping
|
|
34
|
+
def all
|
|
35
|
+
@providers.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if provider is registered
|
|
39
|
+
# @param name [Symbol, String] Provider identifier
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def registered?(name)
|
|
42
|
+
@providers.key?(name.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Register provider's config schema fields with StandardConfig
|
|
48
|
+
# @param provider_class [Class] Provider implementation class
|
|
49
|
+
def register_config_schema(provider_class)
|
|
50
|
+
schema = provider_class.config_schema
|
|
51
|
+
return if schema.nil? || schema.empty?
|
|
52
|
+
|
|
53
|
+
StandardConfig.schema.scope(:social) do
|
|
54
|
+
schema.each do |field_name, options|
|
|
55
|
+
field field_name, **options
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_provider!(provider_class)
|
|
61
|
+
unless provider_class.is_a?(Class)
|
|
62
|
+
raise InvalidProviderError,
|
|
63
|
+
"Provider must be a class, got #{provider_class.class.name}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless provider_class < StandardId::Providers::Base
|
|
67
|
+
raise InvalidProviderError,
|
|
68
|
+
"Provider #{provider_class.name} must inherit from StandardId::Providers::Base"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -2,13 +2,11 @@ require "uri"
|
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "json"
|
|
4
4
|
require "jwt"
|
|
5
|
-
require_relative "
|
|
5
|
+
require_relative "base"
|
|
6
6
|
|
|
7
7
|
module StandardId
|
|
8
|
-
module
|
|
9
|
-
class Apple
|
|
10
|
-
include ResponseBuilder
|
|
11
|
-
|
|
8
|
+
module Providers
|
|
9
|
+
class Apple < Base
|
|
12
10
|
ISSUER = "https://appleid.apple.com".freeze
|
|
13
11
|
AUTH_ENDPOINT = "#{ISSUER}/auth/authorize".freeze
|
|
14
12
|
TOKEN_ENDPOINT = "#{ISSUER}/auth/token".freeze
|
|
@@ -17,7 +15,14 @@ module StandardId
|
|
|
17
15
|
DEFAULT_RESPONSE_MODE = "form_post".freeze
|
|
18
16
|
|
|
19
17
|
class << self
|
|
20
|
-
def
|
|
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
|
+
|
|
21
26
|
ensure_basic_credentials!
|
|
22
27
|
|
|
23
28
|
query = {
|
|
@@ -32,7 +37,9 @@ module StandardId
|
|
|
32
37
|
"#{AUTH_ENDPOINT}?#{URI.encode_www_form(query)}"
|
|
33
38
|
end
|
|
34
39
|
|
|
35
|
-
def get_user_info(code: nil, id_token: nil,
|
|
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
|
+
|
|
36
43
|
if id_token.present?
|
|
37
44
|
build_response(
|
|
38
45
|
verify_id_token(id_token: id_token, client_id: client_id),
|
|
@@ -45,6 +52,35 @@ module StandardId
|
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
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
|
+
|
|
48
84
|
def exchange_code_for_user_info(code:, redirect_uri:, client_id: StandardId.config.apple_client_id)
|
|
49
85
|
ensure_full_credentials!(client_id: client_id)
|
|
50
86
|
raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
|
|
@@ -182,3 +218,6 @@ module StandardId
|
|
|
182
218
|
end
|
|
183
219
|
end
|
|
184
220
|
end
|
|
221
|
+
|
|
222
|
+
# Auto-register with the provider registry
|
|
223
|
+
StandardId::ProviderRegistry.register(:apple, StandardId::Providers::Apple)
|