standard_id 0.3.0 → 0.3.2
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 +1 -2
- data/app/controllers/concerns/standard_id/api_authentication.rb +6 -0
- data/app/controllers/concerns/standard_id/audience_verification.rb +97 -0
- data/app/controllers/concerns/standard_id/sentry_context.rb +36 -0
- data/app/controllers/concerns/standard_id/set_current_request_details.rb +1 -1
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/concerns/standard_id/web_authentication.rb +16 -1
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -1
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -1
- data/app/forms/standard_id/web/signup_form.rb +2 -2
- data/app/jobs/standard_id/cleanup_expired_sessions_job.rb +15 -0
- data/lib/standard_id/api/session_manager.rb +7 -3
- data/lib/standard_id/api/token_manager.rb +4 -7
- data/lib/standard_id/bearer_token_extraction.rb +65 -0
- data/lib/standard_id/config/schema.rb +2 -0
- data/lib/standard_id/engine.rb +5 -0
- data/lib/standard_id/errors.rb +19 -3
- data/lib/standard_id/http_client.rb +15 -4
- data/lib/standard_id/oauth/password_flow.rb +8 -0
- data/lib/standard_id/oauth/token_lifetime_resolver.rb +19 -5
- data/lib/standard_id/passwordless/base_strategy.rb +1 -1
- data/lib/standard_id/utils/ip_normalizer.rb +16 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +14 -1
- data/lib/standard_id/web/token_manager.rb +1 -1
- data/lib/standard_id.rb +2 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f0a6bc5ee9c7ea15e8f9ea7bee06f7cb080da52fade31a85bb0d0ead5207552
|
|
4
|
+
data.tar.gz: 8dffbedfccd7f2434fbf77befe544ebb0c2dc55e4bbbbfb105c11254543e8103
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd96b17327a9468bd2128dea33826450009949e73b6579e36f7c422cd5f9e8aa36a1201de4019e523f79f709539a2cc2e4f8c92dabb15fdef0505488fb3c9cb1
|
|
7
|
+
data.tar.gz: 74e0c84b638ecb2ab7dbc7f7303617e8b2614fc62f9c8218f33da27136f267f41f6ca676afdff2197395eff5410b538fc186f3c9c9f1c7148cd43bc653b22b57
|
data/README.md
CHANGED
|
@@ -787,8 +787,7 @@ class ApplicationController < ActionController::Base
|
|
|
787
787
|
private
|
|
788
788
|
|
|
789
789
|
def handle_account_locked(error)
|
|
790
|
-
# error.
|
|
791
|
-
# error.lock_reason - Why the account was locked
|
|
790
|
+
# error.lock_reason - Why the account was locked (avoid exposing to end users)
|
|
792
791
|
# error.locked_at - When the account was locked
|
|
793
792
|
redirect_to login_path, alert: "Your account has been locked. Please contact support."
|
|
794
793
|
end
|
|
@@ -2,6 +2,12 @@ module StandardId
|
|
|
2
2
|
module ApiAuthentication
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
included do
|
|
6
|
+
if StandardId.config.alias_current_user
|
|
7
|
+
define_method(:current_user) { current_account }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
5
11
|
delegate :current_session, :current_account, :revoke_current_session!, to: :session_manager
|
|
6
12
|
|
|
7
13
|
private
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardId
|
|
4
|
+
# Per-controller audience verification for API endpoints.
|
|
5
|
+
#
|
|
6
|
+
# While StandardId validates that the JWT `aud` claim is in the global
|
|
7
|
+
# `allowed_audiences` list, this concern provides additional defense-in-depth
|
|
8
|
+
# by restricting which audiences are accepted by each controller.
|
|
9
|
+
#
|
|
10
|
+
# Requires StandardId::ApiAuthentication to be included before this concern
|
|
11
|
+
# (provides `verify_access_token!` and `current_session`). An error is raised
|
|
12
|
+
# at include time if ApiAuthentication is missing.
|
|
13
|
+
#
|
|
14
|
+
# The caller is responsible for registering `before_action :verify_access_token!`
|
|
15
|
+
# (typically via ApiAuthentication or a base controller). This concern only adds
|
|
16
|
+
# the `verify_audience!` callback, which must run after token verification so
|
|
17
|
+
# that `current_session` is populated. This is consistent with how
|
|
18
|
+
# `require_scopes!` works in ApiAuthentication.
|
|
19
|
+
#
|
|
20
|
+
# @example Single audience
|
|
21
|
+
# class AdminController < Api::BaseController
|
|
22
|
+
# include StandardId::AudienceVerification
|
|
23
|
+
# verify_audience "admin"
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example Multiple audiences
|
|
27
|
+
# class SharedController < Api::BaseController
|
|
28
|
+
# include StandardId::AudienceVerification
|
|
29
|
+
# verify_audience "admin", "mobile"
|
|
30
|
+
# end
|
|
31
|
+
module AudienceVerification
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
included do
|
|
35
|
+
unless ancestors.include?(StandardId::ApiAuthentication)
|
|
36
|
+
raise "#{name || 'Controller'} must include StandardId::ApiAuthentication before StandardId::AudienceVerification"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
before_action :verify_audience!
|
|
40
|
+
|
|
41
|
+
rescue_from StandardId::InvalidAudienceError, with: :handle_invalid_audience
|
|
42
|
+
|
|
43
|
+
# Underscore prefix follows Rails class_attribute convention to avoid
|
|
44
|
+
# collisions with application method names.
|
|
45
|
+
class_attribute :_required_audiences, instance_writer: false, default: []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class_methods do
|
|
49
|
+
# Declare the allowed audiences for this controller.
|
|
50
|
+
# The token's `aud` claim must include at least one of these values.
|
|
51
|
+
#
|
|
52
|
+
# @param audiences [Array<String>] allowed JWT `aud` claim values
|
|
53
|
+
def verify_audience(*audiences)
|
|
54
|
+
self._required_audiences = audiences.flatten.map(&:to_s)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Verifies the token's `aud` claim contains at least one of the required audiences.
|
|
61
|
+
# Supports both string and array `aud` claims.
|
|
62
|
+
#
|
|
63
|
+
# @raise [StandardId::InvalidAudienceError] when no audience matches
|
|
64
|
+
def verify_audience!
|
|
65
|
+
return if _required_audiences.empty?
|
|
66
|
+
|
|
67
|
+
# If authentication hasn't run (or token is invalid), let the auth
|
|
68
|
+
# layer handle 401 — don't mask it with a 403.
|
|
69
|
+
return unless current_session
|
|
70
|
+
|
|
71
|
+
token_audiences = Array(current_session.aud)
|
|
72
|
+
return if (token_audiences & _required_audiences).any?
|
|
73
|
+
|
|
74
|
+
raise StandardId::InvalidAudienceError.new(
|
|
75
|
+
required: _required_audiences,
|
|
76
|
+
actual: token_audiences
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns 403 Forbidden per RFC 6750 §3.1 (insufficient_scope).
|
|
81
|
+
# Includes WWW-Authenticate header per spec, consistent with the gem's
|
|
82
|
+
# 401 handling in Api::BaseController#render_bearer_unauthorized!.
|
|
83
|
+
#
|
|
84
|
+
# The header uses a static description rather than interpolating
|
|
85
|
+
# error.message (which contains raw aud values from the JWT) to
|
|
86
|
+
# avoid header injection via crafted audience strings.
|
|
87
|
+
#
|
|
88
|
+
# Override in your controller for custom error formatting.
|
|
89
|
+
def handle_invalid_audience(error)
|
|
90
|
+
response.set_header(
|
|
91
|
+
"WWW-Authenticate",
|
|
92
|
+
'Bearer error="insufficient_scope", error_description="The access token audience is not permitted for this resource"'
|
|
93
|
+
)
|
|
94
|
+
render json: { error: "insufficient_scope", error_description: error.message }, status: :forbidden
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Sets Sentry user context from the current authenticated account.
|
|
3
|
+
#
|
|
4
|
+
# This is a standalone concern that host apps can include in their
|
|
5
|
+
# ApplicationController to automatically set Sentry user context
|
|
6
|
+
# for each request. It eliminates the need for apps to write
|
|
7
|
+
# their own SentryContext boilerplate.
|
|
8
|
+
#
|
|
9
|
+
# Safe to include even when the Sentry gem is not installed -- the
|
|
10
|
+
# callback is a no-op if `Sentry` is not defined.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# class ApplicationController < ActionController::Base
|
|
14
|
+
# include StandardId::WebAuthentication
|
|
15
|
+
# include StandardId::SentryContext
|
|
16
|
+
# end
|
|
17
|
+
module SentryContext
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
included do
|
|
21
|
+
before_action :set_standard_id_sentry_context
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def set_standard_id_sentry_context
|
|
27
|
+
return unless defined?(Sentry)
|
|
28
|
+
return unless respond_to?(:current_account, true) && current_account.present?
|
|
29
|
+
|
|
30
|
+
context = { id: current_account.id }
|
|
31
|
+
context[:session_id] = current_session.id if respond_to?(:current_session, true) && current_session.present?
|
|
32
|
+
|
|
33
|
+
Sentry.set_user(context)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -12,7 +12,7 @@ module StandardId
|
|
|
12
12
|
return unless defined?(::Current)
|
|
13
13
|
|
|
14
14
|
::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
|
|
15
|
-
::Current.ip_address = request.remote_ip if ::Current.respond_to?(:ip_address=)
|
|
15
|
+
::Current.ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip) if ::Current.respond_to?(:ip_address=)
|
|
16
16
|
::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -47,7 +47,7 @@ module StandardId
|
|
|
47
47
|
account: account,
|
|
48
48
|
value: email
|
|
49
49
|
)
|
|
50
|
-
identifier.verify! if identifier.respond_to?(:verify!)
|
|
50
|
+
identifier.verify! if identifier.respond_to?(:verify!) && [true, "true"].include?(social_info[:email_verified])
|
|
51
51
|
emit_social_account_created(account, provider, social_info)
|
|
52
52
|
account
|
|
53
53
|
end
|
|
@@ -5,6 +5,11 @@ module StandardId
|
|
|
5
5
|
included do
|
|
6
6
|
include StandardId::InertiaSupport
|
|
7
7
|
helper_method :current_account, :authenticated?
|
|
8
|
+
|
|
9
|
+
if StandardId.config.alias_current_user
|
|
10
|
+
define_method(:current_user) { current_account }
|
|
11
|
+
helper_method :current_user
|
|
12
|
+
end
|
|
8
13
|
end
|
|
9
14
|
|
|
10
15
|
delegate :current_session, :current_account, :revoke_current_session!, to: :session_manager
|
|
@@ -66,7 +71,13 @@ module StandardId
|
|
|
66
71
|
)
|
|
67
72
|
|
|
68
73
|
StandardId::PasswordCredential.find_by(login:).tap do |password_credential|
|
|
69
|
-
|
|
74
|
+
authenticated = password_credential&.authenticate(password)
|
|
75
|
+
|
|
76
|
+
# Perform a dummy bcrypt comparison when the credential doesn't exist
|
|
77
|
+
# to prevent user enumeration via response timing differences.
|
|
78
|
+
BCrypt::Password.new(dummy_password_digest).is_password?(password) unless password_credential
|
|
79
|
+
|
|
80
|
+
unless authenticated
|
|
70
81
|
StandardId::Events.publish(
|
|
71
82
|
StandardId::Events::AUTHENTICATION_FAILED,
|
|
72
83
|
account_lookup: login,
|
|
@@ -103,6 +114,10 @@ module StandardId
|
|
|
103
114
|
@token_manager ||= StandardId::Web::TokenManager.new(request)
|
|
104
115
|
end
|
|
105
116
|
|
|
117
|
+
def dummy_password_digest
|
|
118
|
+
@dummy_password_digest ||= BCrypt::Password.create("").freeze
|
|
119
|
+
end
|
|
120
|
+
|
|
106
121
|
def authentication_guard
|
|
107
122
|
@authentication_guard ||= StandardId::Web::AuthenticationGuard.new
|
|
108
123
|
end
|
|
@@ -37,8 +37,8 @@ module StandardId
|
|
|
37
37
|
rescue ActiveRecord::RecordInvalid => e
|
|
38
38
|
errors.add(:base, e.record.errors.full_messages.join(", "))
|
|
39
39
|
false
|
|
40
|
-
rescue ActiveRecord::RecordNotUnique
|
|
41
|
-
errors.add(:base,
|
|
40
|
+
rescue ActiveRecord::RecordNotUnique
|
|
41
|
+
errors.add(:base, "Unable to complete registration. If you already have an account, please sign in.")
|
|
42
42
|
false
|
|
43
43
|
end
|
|
44
44
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class CleanupExpiredSessionsJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
# Delete sessions that expired more than `grace_period_seconds` ago.
|
|
6
|
+
# A grace period avoids deleting sessions that just expired and might
|
|
7
|
+
# still be referenced in in-flight requests.
|
|
8
|
+
# Accepts integer seconds for reliable ActiveJob serialization across all queue adapters.
|
|
9
|
+
def perform(grace_period_seconds: 7.days.to_i)
|
|
10
|
+
cutoff = grace_period_seconds.seconds.ago
|
|
11
|
+
deleted = StandardId::Session.where("expires_at < ?", cutoff).delete_all
|
|
12
|
+
Rails.logger.info("[StandardId] Cleaned up #{deleted} expired sessions older than #{cutoff}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -12,9 +12,7 @@ module StandardId
|
|
|
12
12
|
|
|
13
13
|
def current_account
|
|
14
14
|
return unless current_session
|
|
15
|
-
@current_account ||=
|
|
16
|
-
.find_by(id: current_session.account_id)
|
|
17
|
-
&.tap { |a| a.strict_loading!(false) }
|
|
15
|
+
@current_account ||= load_current_account
|
|
18
16
|
end
|
|
19
17
|
|
|
20
18
|
def revoke_current_session!
|
|
@@ -28,6 +26,12 @@ module StandardId
|
|
|
28
26
|
|
|
29
27
|
private
|
|
30
28
|
|
|
29
|
+
def load_current_account
|
|
30
|
+
scope = StandardId.account_class
|
|
31
|
+
scope = StandardId.config.account_scope.call(scope) if StandardId.config.account_scope
|
|
32
|
+
scope.find_by(id: current_session.account_id)&.tap { |a| a.strict_loading!(false) }
|
|
33
|
+
end
|
|
34
|
+
|
|
31
35
|
def load_current_session
|
|
32
36
|
return @current_session if @current_session.present?
|
|
33
37
|
|
|
@@ -10,7 +10,7 @@ module StandardId
|
|
|
10
10
|
def create_device_session(account, device_id: nil, device_agent: nil)
|
|
11
11
|
StandardId::DeviceSession.create!(
|
|
12
12
|
account:,
|
|
13
|
-
ip_address: @request.remote_ip,
|
|
13
|
+
ip_address: StandardId::Utils::IpNormalizer.normalize(@request.remote_ip),
|
|
14
14
|
device_id: device_id || SecureRandom.uuid,
|
|
15
15
|
device_agent: device_agent || @request.user_agent,
|
|
16
16
|
expires_at: StandardId::DeviceSession.expiry
|
|
@@ -21,7 +21,7 @@ module StandardId
|
|
|
21
21
|
StandardId::ServiceSession.create!(
|
|
22
22
|
account:,
|
|
23
23
|
owner:,
|
|
24
|
-
ip_address: @request.remote_ip,
|
|
24
|
+
ip_address: StandardId::Utils::IpNormalizer.normalize(@request.remote_ip),
|
|
25
25
|
service_name:,
|
|
26
26
|
service_version:,
|
|
27
27
|
metadata: metadata || {},
|
|
@@ -30,12 +30,9 @@ module StandardId
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def bearer_token
|
|
33
|
-
return @bearer_token if @bearer_token
|
|
33
|
+
return @bearer_token if defined?(@bearer_token)
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
return unless auth_header&.start_with?("Bearer ")
|
|
37
|
-
|
|
38
|
-
@bearer_token = auth_header.split(" ", 2).last
|
|
35
|
+
@bearer_token = StandardId::BearerTokenExtraction.extract(@request.headers["Authorization"])
|
|
39
36
|
end
|
|
40
37
|
|
|
41
38
|
def verify_jwt_token(token: bearer_token)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardId
|
|
4
|
+
# Bearer token extraction utility.
|
|
5
|
+
#
|
|
6
|
+
# This module serves two roles:
|
|
7
|
+
#
|
|
8
|
+
# 1. **Class method** (`BearerTokenExtraction.extract`) — pure extraction
|
|
9
|
+
# logic used by TokenManager in lib/. Lives in lib/ so there is no
|
|
10
|
+
# cross-layer dependency on app/ autoloading.
|
|
11
|
+
#
|
|
12
|
+
# 2. **Controller mixin** (`include StandardId::BearerTokenExtraction`) —
|
|
13
|
+
# provides `extract_bearer_token` as a private instance method.
|
|
14
|
+
# Conventionally, controller concerns live under app/controllers/concerns/,
|
|
15
|
+
# but this module is co-located with the utility to keep the extraction
|
|
16
|
+
# logic in a single file and avoid the same-constant-name conflict
|
|
17
|
+
# between lib/ and app/ autoloading.
|
|
18
|
+
#
|
|
19
|
+
# Does not use ActiveSupport::Concern because it has no `included` or
|
|
20
|
+
# `class_methods` blocks — it is a plain Ruby module.
|
|
21
|
+
#
|
|
22
|
+
# Controllers that include StandardId::ApiAuthentication do NOT need this —
|
|
23
|
+
# token extraction is handled internally by the TokenManager.
|
|
24
|
+
#
|
|
25
|
+
# @example As a controller concern
|
|
26
|
+
# class McpController < ActionController::API
|
|
27
|
+
# include StandardId::BearerTokenExtraction
|
|
28
|
+
#
|
|
29
|
+
# def authenticate!
|
|
30
|
+
# token = extract_bearer_token
|
|
31
|
+
# # validate token...
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @example Direct class method (used by TokenManager)
|
|
36
|
+
# StandardId::BearerTokenExtraction.extract(auth_header)
|
|
37
|
+
module BearerTokenExtraction
|
|
38
|
+
# Extracts the Bearer token from a raw Authorization header value.
|
|
39
|
+
#
|
|
40
|
+
# Note: prior to the introduction of this module, TokenManager#bearer_token
|
|
41
|
+
# returned "" for a bare "Bearer " header. This now returns nil via .presence,
|
|
42
|
+
# which is the correct behavior — downstream JWT parsing receives nil instead
|
|
43
|
+
# of attempting to decode an empty string.
|
|
44
|
+
#
|
|
45
|
+
# @param auth_header [String, nil] the raw Authorization header value
|
|
46
|
+
# @return [String, nil] the bearer token, or nil if not present/empty
|
|
47
|
+
def self.extract(auth_header)
|
|
48
|
+
return unless auth_header&.start_with?("Bearer ")
|
|
49
|
+
|
|
50
|
+
auth_header.split(" ", 2).last.presence
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Extracts the token from an "Authorization: Bearer <token>" header.
|
|
56
|
+
# Result is memoized for the lifetime of the controller instance.
|
|
57
|
+
#
|
|
58
|
+
# @return [String, nil] the bearer token, or nil if not present
|
|
59
|
+
def extract_bearer_token
|
|
60
|
+
return @_bearer_token if defined?(@_bearer_token)
|
|
61
|
+
|
|
62
|
+
@_bearer_token = StandardId::BearerTokenExtraction.extract(request.headers["Authorization"])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -14,8 +14,10 @@ StandardConfig.schema.draw do
|
|
|
14
14
|
field :issuer, type: :string, default: nil
|
|
15
15
|
field :login_url, type: :string, default: nil
|
|
16
16
|
field :allowed_post_logout_redirect_uris, type: :array, default: []
|
|
17
|
+
field :account_scope, type: :any, default: nil
|
|
17
18
|
field :use_inertia, type: :boolean, default: false
|
|
18
19
|
field :inertia_component_namespace, type: :string, default: "standard_id"
|
|
20
|
+
field :alias_current_user, type: :boolean, default: false
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
scope :events do
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -9,6 +9,11 @@ module StandardId
|
|
|
9
9
|
|
|
10
10
|
StandardId::Events::Subscribers::AccountStatusSubscriber.attach
|
|
11
11
|
StandardId::Events::Subscribers::AccountLockingSubscriber.attach
|
|
12
|
+
|
|
13
|
+
if StandardId.config.issuer.blank?
|
|
14
|
+
Rails.logger.warn("[StandardId] No issuer configured. JWT tokens will not include or verify the 'iss' claim. " \
|
|
15
|
+
"Set StandardId.config.issuer in your initializer for production use.")
|
|
16
|
+
end
|
|
12
17
|
end
|
|
13
18
|
end
|
|
14
19
|
end
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
module StandardId
|
|
2
|
+
# Session errors
|
|
2
3
|
class NotAuthenticatedError < StandardError; end
|
|
3
4
|
|
|
4
5
|
class InvalidSessionError < StandardError; end
|
|
5
6
|
class ExpiredSessionError < InvalidSessionError; end
|
|
6
7
|
class RevokedSessionError < InvalidSessionError; end
|
|
8
|
+
|
|
9
|
+
# Account errors
|
|
7
10
|
class AccountDeactivatedError < StandardError; end
|
|
8
11
|
|
|
9
12
|
class AccountLockedError < StandardError
|
|
10
|
-
|
|
13
|
+
# lock_reason and locked_at are available for logging and admin use.
|
|
14
|
+
# Avoid surfacing lock_reason in user-facing responses.
|
|
15
|
+
attr_reader :lock_reason, :locked_at
|
|
11
16
|
|
|
12
17
|
def initialize(account)
|
|
13
|
-
@account = account
|
|
14
18
|
@lock_reason = account.lock_reason
|
|
15
19
|
@locked_at = account.locked_at
|
|
16
|
-
super("Account has been locked
|
|
20
|
+
super("Account has been locked")
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
# OAuth errors
|
|
20
25
|
class OAuthError < StandardError
|
|
21
26
|
def oauth_error_code
|
|
22
27
|
:invalid_request
|
|
@@ -64,4 +69,15 @@ module StandardId
|
|
|
64
69
|
class UnsupportedResponseTypeError < OAuthError
|
|
65
70
|
def oauth_error_code = :unsupported_response_type
|
|
66
71
|
end
|
|
72
|
+
|
|
73
|
+
# Audience verification errors
|
|
74
|
+
class InvalidAudienceError < StandardError
|
|
75
|
+
attr_reader :required, :actual
|
|
76
|
+
|
|
77
|
+
def initialize(required:, actual:)
|
|
78
|
+
@required = required
|
|
79
|
+
@actual = actual
|
|
80
|
+
super("Token audience [#{actual.join(', ')}] does not match required audiences: #{required.join(', ')}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
67
83
|
end
|
|
@@ -3,19 +3,30 @@ require "uri"
|
|
|
3
3
|
|
|
4
4
|
module StandardId
|
|
5
5
|
class HttpClient
|
|
6
|
+
OPEN_TIMEOUT = 5
|
|
7
|
+
READ_TIMEOUT = 10
|
|
8
|
+
|
|
6
9
|
class << self
|
|
7
10
|
def post_form(endpoint, params)
|
|
8
11
|
uri = URI(endpoint)
|
|
9
|
-
Net::HTTP.
|
|
12
|
+
request = Net::HTTP::Post.new(uri)
|
|
13
|
+
request.set_form_data(params)
|
|
14
|
+
start_connection(uri) { |http| http.request(request) }
|
|
10
15
|
end
|
|
11
16
|
|
|
12
17
|
def get_with_bearer(endpoint, access_token)
|
|
13
18
|
uri = URI(endpoint)
|
|
14
19
|
request = Net::HTTP::Get.new(uri)
|
|
15
20
|
request["Authorization"] = "Bearer #{access_token}"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
start_connection(uri) { |http| http.request(request) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def start_connection(uri, &block)
|
|
27
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
28
|
+
open_timeout: OPEN_TIMEOUT,
|
|
29
|
+
read_timeout: READ_TIMEOUT, &block)
|
|
19
30
|
end
|
|
20
31
|
end
|
|
21
32
|
end
|
|
@@ -76,6 +76,10 @@ module StandardId
|
|
|
76
76
|
.includes(credential: :account)
|
|
77
77
|
.find_by(login: username)
|
|
78
78
|
|
|
79
|
+
# Perform a dummy bcrypt comparison when the credential doesn't exist
|
|
80
|
+
# to prevent user enumeration via response timing differences.
|
|
81
|
+
BCrypt::Password.new(dummy_password_digest).is_password?(password) unless @credential
|
|
82
|
+
|
|
79
83
|
@credential&.authenticate(password)&.account
|
|
80
84
|
end
|
|
81
85
|
|
|
@@ -90,6 +94,10 @@ module StandardId
|
|
|
90
94
|
end
|
|
91
95
|
end
|
|
92
96
|
|
|
97
|
+
def dummy_password_digest
|
|
98
|
+
@dummy_password_digest ||= BCrypt::Password.create("").freeze
|
|
99
|
+
end
|
|
100
|
+
|
|
93
101
|
def default_scope
|
|
94
102
|
"read"
|
|
95
103
|
end
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
module Oauth
|
|
3
3
|
class TokenLifetimeResolver
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
DEFAULT_ACCESS_TOKEN_LIFETIME = 1.hour.to_i
|
|
5
|
+
DEFAULT_REFRESH_TOKEN_LIFETIME = 30.days.to_i
|
|
6
|
+
MAX_ACCESS_TOKEN_LIFETIME = 24.hours.to_i
|
|
7
|
+
MAX_REFRESH_TOKEN_LIFETIME = 90.days.to_i
|
|
7
8
|
|
|
9
|
+
class << self
|
|
8
10
|
def access_token_for(flow_key)
|
|
9
11
|
configured = lookup_token_lifetime(flow_key)
|
|
10
|
-
positive_seconds(configured, default_access_token_lifetime)
|
|
12
|
+
lifetime = positive_seconds(configured, default_access_token_lifetime)
|
|
13
|
+
clamp_seconds(lifetime, MAX_ACCESS_TOKEN_LIFETIME)
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def refresh_token_lifetime
|
|
14
|
-
positive_seconds(oauth_config.refresh_token_lifetime, DEFAULT_REFRESH_TOKEN_LIFETIME)
|
|
17
|
+
lifetime = positive_seconds(oauth_config.refresh_token_lifetime, DEFAULT_REFRESH_TOKEN_LIFETIME)
|
|
18
|
+
clamp_seconds(lifetime, MAX_REFRESH_TOKEN_LIFETIME)
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
private
|
|
@@ -41,6 +45,16 @@ module StandardId
|
|
|
41
45
|
(normalized_value.positive? ? normalized_value : fallback_value).seconds
|
|
42
46
|
end
|
|
43
47
|
|
|
48
|
+
def clamp_seconds(duration, max)
|
|
49
|
+
seconds = duration.to_i
|
|
50
|
+
if seconds > max
|
|
51
|
+
Rails.logger.warn { "[StandardId] Token lifetime #{seconds}s exceeds maximum #{max}s, clamping to #{max}s" }
|
|
52
|
+
max.seconds
|
|
53
|
+
else
|
|
54
|
+
duration
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
def oauth_config
|
|
45
59
|
StandardId.config.oauth
|
|
46
60
|
end
|
|
@@ -35,7 +35,7 @@ module StandardId
|
|
|
35
35
|
target: username,
|
|
36
36
|
code: code,
|
|
37
37
|
expires_at: StandardId.config.passwordless.code_ttl.seconds.from_now,
|
|
38
|
-
ip_address: request.remote_ip,
|
|
38
|
+
ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
|
|
39
39
|
user_agent: request.user_agent
|
|
40
40
|
)
|
|
41
41
|
cc
|
data/lib/standard_id/version.rb
CHANGED
|
@@ -15,7 +15,7 @@ module StandardId
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def current_account
|
|
18
|
-
Current.account ||=
|
|
18
|
+
Current.account ||= load_current_account
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def sign_in_account(account)
|
|
@@ -50,6 +50,19 @@ module StandardId
|
|
|
50
50
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
|
+
def load_current_account
|
|
54
|
+
if StandardId.config.account_scope
|
|
55
|
+
account_id = current_session&.account_id
|
|
56
|
+
return unless account_id
|
|
57
|
+
|
|
58
|
+
scope = StandardId.account_class
|
|
59
|
+
scope = StandardId.config.account_scope.call(scope)
|
|
60
|
+
scope.find_by(id: account_id)&.tap { |a| a.strict_loading!(false) }
|
|
61
|
+
else
|
|
62
|
+
current_session&.account&.tap { |a| a.strict_loading!(false) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
def load_current_session
|
|
54
67
|
Current.session ||= load_session_from_session_token
|
|
55
68
|
Current.session ||= load_session_from_remember_token
|
|
@@ -10,7 +10,7 @@ module StandardId
|
|
|
10
10
|
def create_browser_session(account)
|
|
11
11
|
StandardId::BrowserSession.create!(
|
|
12
12
|
account: account,
|
|
13
|
-
ip_address: request.remote_ip,
|
|
13
|
+
ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
|
|
14
14
|
user_agent: request.user_agent,
|
|
15
15
|
expires_at: StandardId::BrowserSession.expiry
|
|
16
16
|
)
|
data/lib/standard_id.rb
CHANGED
|
@@ -13,6 +13,7 @@ require "standard_id/events/subscribers/account_locking_subscriber"
|
|
|
13
13
|
require "standard_id/account_status"
|
|
14
14
|
require "standard_id/account_locking"
|
|
15
15
|
require "standard_id/http_client"
|
|
16
|
+
require "standard_id/bearer_token_extraction"
|
|
16
17
|
require "standard_id/jwt_service"
|
|
17
18
|
require "standard_id/web/session_manager"
|
|
18
19
|
require "standard_id/web/token_manager"
|
|
@@ -39,6 +40,7 @@ require "standard_id/passwordless/base_strategy"
|
|
|
39
40
|
require "standard_id/passwordless/email_strategy"
|
|
40
41
|
require "standard_id/passwordless/sms_strategy"
|
|
41
42
|
require "standard_id/utils/callable_parameter_filter"
|
|
43
|
+
require "standard_id/utils/ip_normalizer"
|
|
42
44
|
|
|
43
45
|
require "concurrent/delay"
|
|
44
46
|
|
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.3.
|
|
4
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -80,9 +80,11 @@ files:
|
|
|
80
80
|
- app/assets/stylesheets/standard_id/application.css
|
|
81
81
|
- app/channels/concerns/standard_id/cable_authentication.rb
|
|
82
82
|
- app/controllers/concerns/standard_id/api_authentication.rb
|
|
83
|
+
- app/controllers/concerns/standard_id/audience_verification.rb
|
|
83
84
|
- app/controllers/concerns/standard_id/inertia_rendering.rb
|
|
84
85
|
- app/controllers/concerns/standard_id/inertia_support.rb
|
|
85
86
|
- app/controllers/concerns/standard_id/passwordless_strategy.rb
|
|
87
|
+
- app/controllers/concerns/standard_id/sentry_context.rb
|
|
86
88
|
- app/controllers/concerns/standard_id/set_current_request_details.rb
|
|
87
89
|
- app/controllers/concerns/standard_id/social_authentication.rb
|
|
88
90
|
- app/controllers/concerns/standard_id/web/social_login_params.rb
|
|
@@ -117,6 +119,7 @@ files:
|
|
|
117
119
|
- app/forms/standard_id/web/signup_form.rb
|
|
118
120
|
- app/helpers/standard_id/application_helper.rb
|
|
119
121
|
- app/jobs/standard_id/application_job.rb
|
|
122
|
+
- app/jobs/standard_id/cleanup_expired_sessions_job.rb
|
|
120
123
|
- app/mailers/standard_id/application_mailer.rb
|
|
121
124
|
- app/models/concerns/standard_id/account_associations.rb
|
|
122
125
|
- app/models/concerns/standard_id/credentiable.rb
|
|
@@ -171,6 +174,7 @@ files:
|
|
|
171
174
|
- lib/standard_id/api/session_manager.rb
|
|
172
175
|
- lib/standard_id/api/token_manager.rb
|
|
173
176
|
- lib/standard_id/api_engine.rb
|
|
177
|
+
- lib/standard_id/bearer_token_extraction.rb
|
|
174
178
|
- lib/standard_id/config/schema.rb
|
|
175
179
|
- lib/standard_id/current_attributes.rb
|
|
176
180
|
- lib/standard_id/engine.rb
|
|
@@ -205,6 +209,7 @@ files:
|
|
|
205
209
|
- lib/standard_id/provider_registry.rb
|
|
206
210
|
- lib/standard_id/providers/base.rb
|
|
207
211
|
- lib/standard_id/utils/callable_parameter_filter.rb
|
|
212
|
+
- lib/standard_id/utils/ip_normalizer.rb
|
|
208
213
|
- lib/standard_id/version.rb
|
|
209
214
|
- lib/standard_id/web/authentication_guard.rb
|
|
210
215
|
- lib/standard_id/web/session_manager.rb
|