standard_id 0.2.9 → 0.3.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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -16
  3. data/app/controllers/concerns/standard_id/audience_verification.rb +97 -0
  4. data/app/controllers/concerns/standard_id/passwordless_strategy.rb +18 -0
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  6. data/app/controllers/concerns/standard_id/web_authentication.rb +11 -1
  7. data/app/controllers/standard_id/api/passwordless_controller.rb +1 -10
  8. data/app/controllers/standard_id/web/login_controller.rb +47 -4
  9. data/app/controllers/standard_id/web/login_verify_controller.rb +170 -0
  10. data/app/forms/standard_id/web/signup_form.rb +2 -2
  11. data/app/jobs/standard_id/cleanup_expired_sessions_job.rb +15 -0
  12. data/app/views/standard_id/web/login_verify/show.html.erb +35 -0
  13. data/config/brakeman.ignore +30 -0
  14. data/config/routes/web.rb +1 -0
  15. data/lib/generators/standard_id/install/templates/standard_id.rb +2 -0
  16. data/lib/standard_id/api/session_manager.rb +3 -1
  17. data/lib/standard_id/api/token_manager.rb +2 -5
  18. data/lib/standard_id/bearer_token_extraction.rb +65 -0
  19. data/lib/standard_id/config/schema.rb +2 -0
  20. data/lib/standard_id/engine.rb +5 -0
  21. data/lib/standard_id/errors.rb +19 -3
  22. data/lib/standard_id/http_client.rb +15 -4
  23. data/lib/standard_id/oauth/password_flow.rb +8 -0
  24. data/lib/standard_id/oauth/token_lifetime_resolver.rb +19 -5
  25. data/lib/standard_id/passwordless/base_strategy.rb +1 -1
  26. data/lib/standard_id/version.rb +1 -1
  27. data/lib/standard_id/web/session_manager.rb +1 -1
  28. data/lib/standard_id.rb +1 -0
  29. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2452ed8123165d861c0336f59c980b08aad25936f3a1aaee39a2b13f370148b8
4
- data.tar.gz: a1390102deff80b924ac9d6ce5f53e260a0daaf0001524486f909c9d64c422d9
3
+ metadata.gz: a164420e3b36d754be94711721ead8cb4d29efed42fd6e75b0060e1c8c7c3bce
4
+ data.tar.gz: cded0bf7e847cc77ed0f6aa7c69647435c847d62ae02c2af1466778a507f88f4
5
5
  SHA512:
6
- metadata.gz: 598e2740f693cc6e1f58c77f02897fe2f39c6bd2c2748c7f40bd729bafe1f15bc80fccc6755daeabe0a1ca2a08c0bb547b5f308f0596a233dc6e7ca81939c68f
7
- data.tar.gz: eae44ac9c2cb4d0226547519b8d67ff042b4e3395efd0ffb91cb41ba7b9d8789c2f1a6afbfc4e2f75dcb32aad27ec9414ccbe30906b28d83f5bac842c42fa482
6
+ metadata.gz: 0b9b506d8014bd8d8cd380b1f5300a87d2d6825e073e8b959c67cbfeac3eb577abe671c9dac330833c8d27ee010b87786f533ac4e247459d691dd956d5053faf
7
+ data.tar.gz: ec1a9973e61d08fff4579099dbcac25ad8355d140994c51c2621559d4aed39486d2fdd891b451ade775799f792c3db19916d555d3a50f3bc84d07ed77c1f0a52
data/README.md CHANGED
@@ -516,27 +516,71 @@ StandardId::Events.subscribe(/social/) do |event|
516
516
  end
517
517
  ```
518
518
 
519
- #### Class-based (complex logic)
519
+ ### Audit Logging
520
+
521
+ For production audit trails, use the [standard_audit](https://github.com/rarebit-one/standard_audit) gem. StandardId and StandardAudit have zero direct references to each other — the host application wires them together.
522
+
523
+ #### Setup
524
+
525
+ Add both gems to your Gemfile:
520
526
 
521
527
  ```ruby
522
- # app/subscribers/audit_subscriber.rb
523
- class AuditSubscriber < StandardId::Events::Subscribers::Base
524
- subscribe_to StandardId::Events::SECURITY_EVENTS
528
+ gem "standard_id"
529
+ gem "standard_audit"
530
+ ```
525
531
 
526
- def call(event)
527
- AuditLog.create!(
528
- event_type: event.short_name,
529
- account_id: event[:account]&.id,
530
- ip_address: event[:ip_address],
531
- metadata: event.payload
532
- )
533
- end
532
+ Run the StandardAudit install generator:
533
+
534
+ ```bash
535
+ rails generate standard_audit:install
536
+ rails db:migrate
537
+ ```
538
+
539
+ #### Wiring StandardId events to StandardAudit
540
+
541
+ Configure StandardAudit to subscribe to StandardId's event namespace and map its payload conventions:
542
+
543
+ ```ruby
544
+ # config/initializers/standard_audit.rb
545
+ StandardAudit.configure do |config|
546
+ config.subscribe_to /\Astandard_id\./
547
+
548
+ # StandardId uses :account and :current_account rather than :actor/:target.
549
+ # Map them so StandardAudit extracts the right records.
550
+ config.actor_extractor = ->(payload) {
551
+ payload[:current_account] || payload[:account]
552
+ }
553
+
554
+ config.target_extractor = ->(payload) {
555
+ # Only set a target when the actor (current_account) differs from the
556
+ # account being acted upon — e.g. an admin locking another user.
557
+ if payload[:current_account]
558
+ target = payload[:account] || payload[:client_application]
559
+ target unless target == payload[:current_account]
560
+ end
561
+ }
534
562
  end
563
+ ```
535
564
 
536
- # config/initializers/standard_id_events.rb
537
- AuditSubscriber.attach
565
+ That's it. Every StandardId authentication event will now be persisted as an audit log entry. No changes are needed inside StandardId itself.
566
+
567
+ #### Querying audit logs
568
+
569
+ ```ruby
570
+ # All auth events for a user
571
+ StandardAudit::AuditLog.for_actor(user).reverse_chronological
572
+
573
+ # Failed logins this week
574
+ StandardAudit::AuditLog
575
+ .by_event_type("standard_id.authentication.attempt.failed")
576
+ .this_week
577
+
578
+ # All activity from an IP address
579
+ StandardAudit::AuditLog.from_ip("192.168.1.1")
538
580
  ```
539
581
 
582
+ See the [StandardAudit README](https://github.com/rarebit-one/standard_audit) for the full query interface, async processing, GDPR compliance, and multi-tenancy support.
583
+
540
584
  ## Account Status (Activation/Deactivation)
541
585
 
542
586
  StandardId provides an optional `AccountStatus` concern for managing account activation and deactivation. This uses Rails enum with the event system to enforce status checks and handle side effects without modifying core authentication logic.
@@ -743,8 +787,7 @@ class ApplicationController < ActionController::Base
743
787
  private
744
788
 
745
789
  def handle_account_locked(error)
746
- # error.account - The locked account
747
- # error.lock_reason - Why the account was locked
790
+ # error.lock_reason - Why the account was locked (avoid exposing to end users)
748
791
  # error.locked_at - When the account was locked
749
792
  redirect_to login_path, alert: "Your account has been locked. Please contact support."
750
793
  end
@@ -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,18 @@
1
+ module StandardId
2
+ module PasswordlessStrategy
3
+ extend ActiveSupport::Concern
4
+
5
+ STRATEGY_MAP = {
6
+ "email" => StandardId::Passwordless::EmailStrategy,
7
+ "sms" => StandardId::Passwordless::SmsStrategy
8
+ }.freeze
9
+
10
+ private
11
+
12
+ def strategy_for(connection)
13
+ klass = STRATEGY_MAP[connection]
14
+ raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}" unless klass
15
+ klass.new(request)
16
+ end
17
+ end
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
@@ -66,7 +66,13 @@ module StandardId
66
66
  )
67
67
 
68
68
  StandardId::PasswordCredential.find_by(login:).tap do |password_credential|
69
- unless password_credential&.authenticate(password)
69
+ authenticated = password_credential&.authenticate(password)
70
+
71
+ # Perform a dummy bcrypt comparison when the credential doesn't exist
72
+ # to prevent user enumeration via response timing differences.
73
+ BCrypt::Password.new(dummy_password_digest).is_password?(password) unless password_credential
74
+
75
+ unless authenticated
70
76
  StandardId::Events.publish(
71
77
  StandardId::Events::AUTHENTICATION_FAILED,
72
78
  account_lookup: login,
@@ -103,6 +109,10 @@ module StandardId
103
109
  @token_manager ||= StandardId::Web::TokenManager.new(request)
104
110
  end
105
111
 
112
+ def dummy_password_digest
113
+ @dummy_password_digest ||= BCrypt::Password.create("").freeze
114
+ end
115
+
106
116
  def authentication_guard
107
117
  @authentication_guard ||= StandardId::Web::AuthenticationGuard.new
108
118
  end
@@ -1,10 +1,7 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class PasswordlessController < BaseController
4
- STRATEGY_MAP = {
5
- "email" => StandardId::Passwordless::EmailStrategy,
6
- "sms" => StandardId::Passwordless::SmsStrategy
7
- }.freeze
4
+ include StandardId::PasswordlessStrategy
8
5
 
9
6
  def start
10
7
  raise StandardId::InvalidRequestError, "username, email, or phone_number parameter is required" if start_params[:username].blank?
@@ -16,12 +13,6 @@ module StandardId
16
13
 
17
14
  private
18
15
 
19
- def strategy_for(connection)
20
- klass = STRATEGY_MAP[connection]
21
- raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}" unless klass
22
- klass.new(request)
23
- end
24
-
25
16
  def start_params
26
17
  return @start_params if @start_params.present?
27
18
 
@@ -3,7 +3,7 @@ module StandardId
3
3
  class LoginController < BaseController
4
4
  include StandardId::InertiaRendering
5
5
  include StandardId::Web::SocialLoginParams
6
-
6
+ include StandardId::PasswordlessStrategy
7
7
 
8
8
  layout "public"
9
9
 
@@ -16,19 +16,62 @@ module StandardId
16
16
  @redirect_uri = params[:redirect_uri] || after_authentication_url
17
17
  @connection = params[:connection]
18
18
 
19
- render_with_inertia props: auth_page_props
19
+ render_with_inertia props: auth_page_props(passwordless_enabled: passwordless_enabled?)
20
20
  end
21
21
 
22
22
  def create
23
+ if passwordless_enabled?
24
+ handle_passwordless_login
25
+ else
26
+ handle_password_login
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def passwordless_enabled?
33
+ StandardId.config.passwordless.enabled
34
+ end
35
+
36
+ def handle_password_login
23
37
  if sign_in_account(login_params)
24
38
  redirect_to params[:redirect_uri] || after_authentication_url, status: :see_other, notice: "Successfully signed in"
25
39
  else
26
40
  flash.now[:alert] = "Invalid email or password"
27
- render_with_inertia action: :show, props: auth_page_props, status: :unprocessable_content
41
+ render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
28
42
  end
29
43
  end
30
44
 
31
- private
45
+ def handle_passwordless_login
46
+ email = login_params[:email].to_s.strip.downcase
47
+ connection = StandardId.config.passwordless.connection
48
+
49
+ if email.blank?
50
+ flash.now[:alert] = "Please enter your email address"
51
+ render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
52
+ return
53
+ end
54
+
55
+ strategy = strategy_for(connection)
56
+
57
+ begin
58
+ strategy.start!(username: email, connection: connection)
59
+ rescue StandardId::InvalidRequestError => e
60
+ flash.now[:alert] = e.message
61
+ render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
62
+ return
63
+ end
64
+
65
+ code_ttl = StandardId.config.passwordless.code_ttl
66
+ signed_payload = Rails.application.message_verifier(:otp).generate(
67
+ { username: email, connection: connection },
68
+ expires_in: code_ttl.seconds
69
+ )
70
+ session[:standard_id_otp_payload] = signed_payload
71
+ session[:return_to_after_authenticating] = params[:redirect_uri] if params[:redirect_uri].present?
72
+
73
+ redirect_to login_verify_path, status: :see_other
74
+ end
32
75
 
33
76
  def redirect_if_authenticated
34
77
  redirect_to after_authentication_url, status: :see_other, notice: "You are already signed in" if authenticated?
@@ -0,0 +1,170 @@
1
+ module StandardId
2
+ module Web
3
+ class LoginVerifyController < BaseController
4
+ include StandardId::InertiaRendering
5
+ include StandardId::PasswordlessStrategy
6
+
7
+ class OtpVerificationFailed < StandardError; end
8
+
9
+ layout "public"
10
+
11
+ skip_before_action :require_browser_session!, only: [:show, :update]
12
+
13
+ before_action :ensure_passwordless_enabled!
14
+ before_action :redirect_if_authenticated, only: [:show]
15
+ before_action :require_otp_payload!
16
+
17
+ def show
18
+ render_with_inertia props: verify_page_props
19
+ end
20
+
21
+ def update
22
+ code = params[:code].to_s.strip
23
+
24
+ if code.blank?
25
+ flash.now[:alert] = "Please enter the verification code"
26
+ render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
27
+ return
28
+ end
29
+
30
+ # Record failed attempts outside the main transaction so they persist
31
+ challenge = find_active_challenge
32
+ attempts = record_attempt(challenge, code)
33
+
34
+ if challenge.blank? || !ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
35
+ emit_otp_validation_failed(attempts) if challenge.present?
36
+
37
+ flash.now[:alert] = "Invalid or expired verification code"
38
+ render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
39
+ return
40
+ end
41
+
42
+ strategy = strategy_for(@otp_data[:connection])
43
+
44
+ begin
45
+ ActiveRecord::Base.transaction do
46
+ # Re-fetch with lock inside transaction to prevent concurrent use
47
+ locked_challenge = StandardId::CodeChallenge.lock.find(challenge.id)
48
+ raise OtpVerificationFailed unless locked_challenge.active?
49
+
50
+ account = strategy.find_or_create_account(@otp_data[:username])
51
+ locked_challenge.use!
52
+
53
+ emit_otp_validated(account, locked_challenge)
54
+ session_manager.sign_in_account(account)
55
+ emit_authentication_succeeded(account)
56
+ end
57
+ rescue OtpVerificationFailed
58
+ flash.now[:alert] = "Invalid or expired verification code"
59
+ render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
60
+ return
61
+ rescue ActiveRecord::RecordInvalid => e
62
+ flash.now[:alert] = "Unable to complete sign in: #{e.record.errors.full_messages.to_sentence}"
63
+ render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
64
+ return
65
+ end
66
+
67
+ session.delete(:standard_id_otp_payload)
68
+
69
+ redirect_to after_authentication_url, status: :see_other, notice: "Successfully signed in"
70
+ end
71
+
72
+ private
73
+
74
+ def ensure_passwordless_enabled!
75
+ return if StandardId.config.passwordless.enabled
76
+
77
+ session.delete(:standard_id_otp_payload)
78
+ redirect_to login_path, alert: "Passwordless login is not available"
79
+ end
80
+
81
+ def redirect_if_authenticated
82
+ redirect_to after_authentication_url, status: :see_other if authenticated?
83
+ end
84
+
85
+ def require_otp_payload!
86
+ signed_payload = session[:standard_id_otp_payload]
87
+
88
+ if signed_payload.blank?
89
+ redirect_to login_path, alert: "Please start the login process"
90
+ return
91
+ end
92
+
93
+ begin
94
+ @otp_data = Rails.application.message_verifier(:otp).verify(signed_payload).symbolize_keys
95
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
96
+ session.delete(:standard_id_otp_payload)
97
+ redirect_to login_path, alert: "Your verification session has expired. Please try again."
98
+ end
99
+ end
100
+
101
+ def find_active_challenge
102
+ StandardId::CodeChallenge.active.find_by(
103
+ realm: "authentication",
104
+ channel: @otp_data[:connection],
105
+ target: @otp_data[:username]
106
+ )
107
+ end
108
+
109
+ def record_attempt(challenge, code)
110
+ return 0 if challenge.blank?
111
+ return 0 if ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
112
+
113
+ attempts = (challenge.metadata["attempts"] || 0) + 1
114
+ challenge.update!(metadata: challenge.metadata.merge("attempts" => attempts))
115
+
116
+ max_attempts = StandardId.config.passwordless.max_attempts
117
+ challenge.use! if attempts >= max_attempts
118
+
119
+ attempts
120
+ end
121
+
122
+ def emit_otp_validated(account, challenge)
123
+ StandardId::Events.publish(
124
+ StandardId::Events::OTP_VALIDATED,
125
+ account: account,
126
+ channel: @otp_data[:connection]
127
+ )
128
+ StandardId::Events.publish(
129
+ StandardId::Events::PASSWORDLESS_CODE_VERIFIED,
130
+ code_challenge: challenge,
131
+ account: account,
132
+ channel: @otp_data[:connection]
133
+ )
134
+ end
135
+
136
+ def emit_otp_validation_failed(attempts)
137
+ StandardId::Events.publish(
138
+ StandardId::Events::OTP_VALIDATION_FAILED,
139
+ identifier: @otp_data[:username],
140
+ channel: @otp_data[:connection],
141
+ attempts: attempts
142
+ )
143
+ StandardId::Events.publish(
144
+ StandardId::Events::PASSWORDLESS_CODE_FAILED,
145
+ identifier: @otp_data[:username],
146
+ channel: @otp_data[:connection],
147
+ attempts: attempts
148
+ )
149
+ end
150
+
151
+ def emit_authentication_succeeded(account)
152
+ StandardId::Events.publish(
153
+ StandardId::Events::AUTHENTICATION_SUCCEEDED,
154
+ account: account,
155
+ auth_method: "passwordless_otp",
156
+ session_type: "browser"
157
+ )
158
+ end
159
+
160
+ def verify_page_props
161
+ {
162
+ flash: {
163
+ notice: flash[:notice],
164
+ alert: flash[:alert]
165
+ }.compact
166
+ }
167
+ end
168
+ end
169
+ end
170
+ 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 => e
41
- errors.add(:base, e.message)
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
@@ -0,0 +1,35 @@
1
+ <%# ERB fallback for non-Inertia rendering. Flash is read directly here;
2
+ Inertia clients receive structured flash via verify_page_props instead. %>
3
+ <% content_for :title, "Verify Code" %>
4
+
5
+ <div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
6
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
7
+ <h2 class="mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900 dark:text-white">Enter verification code</h2>
8
+ <p class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">We sent you a verification code.</p>
9
+ </div>
10
+
11
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
12
+ <div class="bg-white px-6 py-12 shadow sm:rounded-lg sm:px-12 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:outline-1 dark:-outline-offset-1 dark:outline-white/10">
13
+
14
+ <% if flash[:alert].present? %>
15
+ <div class="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300"><%= flash[:alert] %></div>
16
+ <% end %>
17
+ <% if flash[:notice].present? %>
18
+ <div class="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-300"><%= flash[:notice] %></div>
19
+ <% end %>
20
+
21
+ <%= form_with url: login_verify_path, method: :patch, local: true, html: { class: "space-y-6" } do |form| %>
22
+ <div>
23
+ <%= form.label :code, "Verification code", class: "block text-sm/6 font-medium text-gray-900 dark:text-white" %>
24
+ <div class="mt-2">
25
+ <%= form.text_field :code, required: true, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric", maxlength: 6, class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500" %>
26
+ </div>
27
+ </div>
28
+
29
+ <div>
30
+ <%= form.submit "Verify", class: "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500" %>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+ </div>
@@ -0,0 +1,30 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "fingerprint": "24fc02735a2ad863d6bf1171a4a329b208e9e7c41841fa0149d8e6878d4ce299",
5
+ "note": "Auth engine intentionally redirects to params[:redirect_uri] after signup for OAuth/post-auth flow"
6
+ },
7
+ {
8
+ "fingerprint": "6b35e9906d62a9b9cd0dff9cf53924d40e74bc4f96cfccf27e67e93551113243",
9
+ "note": "Auth engine intentionally redirects to params[:redirect_uri] after logout for OAuth/post-auth flow"
10
+ },
11
+ {
12
+ "fingerprint": "8fddb7968577ac46fdda8e799f476c3eced60cc585da9c30fa61117f91e04ab9",
13
+ "note": "HEAD vs GET distinction is inconsequential here; redirect_to_login adding redirect_uri param on GET-only is safe"
14
+ },
15
+ {
16
+ "fingerprint": "bdbc72619da2ba771b1185ccf16acce801066689bf51adf116eab8c8714b39af",
17
+ "note": "HEAD vs GET distinction is inconsequential here; storing return URL on GET-only is safe"
18
+ },
19
+ {
20
+ "fingerprint": "16bd6ec7c3fa130eb80c15fc90c87f9859d89b37258807bfaffe4101366611a6",
21
+ "note": "Auth engine intentionally redirects to params[:redirect_uri] after login for OAuth/post-auth flow"
22
+ },
23
+ {
24
+ "fingerprint": "e4f96cb212c73c3165c3db6eaa6368c29d362b61264f034e80c9fa6705d72e5b",
25
+ "note": "Auth engine intentionally redirects to params[:redirect_uri] when user is not authenticated"
26
+ }
27
+ ],
28
+ "updated": "2026-02-27",
29
+ "brakeman_version": "8.0.2"
30
+ }
data/config/routes/web.rb CHANGED
@@ -2,6 +2,7 @@ StandardId::WebEngine.routes.draw do
2
2
  scope module: :web do
3
3
  # Authentication flows
4
4
  resource :login, only: [:show, :create], controller: :login
5
+ resource :login_verify, only: [:show, :update], controller: :login_verify
5
6
  resource :logout, only: [:create], controller: :logout
6
7
  resource :signup, only: [:show, :create], controller: :signup
7
8
 
@@ -101,6 +101,8 @@ StandardId.configure do |c|
101
101
  # Events
102
102
  # Enable or disable logging emitted via the internal event system
103
103
  # c.events.enable_logging = false
104
+ #
105
+ # For audit logging, use the standard_audit gem. See the README for wiring instructions.
104
106
 
105
107
  # Social login credentials (if enabled in your app)
106
108
  # c.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
@@ -12,7 +12,9 @@ module StandardId
12
12
 
13
13
  def current_account
14
14
  return unless current_session
15
- @current_account ||= StandardId.account_class.find_by(id: current_session.account_id)
15
+ @current_account ||= StandardId.account_class
16
+ .find_by(id: current_session.account_id)
17
+ &.tap { |a| a.strict_loading!(false) }
16
18
  end
17
19
 
18
20
  def revoke_current_session!
@@ -30,12 +30,9 @@ module StandardId
30
30
  end
31
31
 
32
32
  def bearer_token
33
- return @bearer_token if @bearer_token.present?
33
+ return @bearer_token if defined?(@bearer_token)
34
34
 
35
- auth_header = @request.headers["Authorization"]
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
@@ -23,6 +23,8 @@ StandardConfig.schema.draw do
23
23
  end
24
24
 
25
25
  scope :passwordless do
26
+ field :enabled, type: :boolean, default: false
27
+ field :connection, type: :string, default: "email"
26
28
  field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
27
29
  field :max_attempts, type: :integer, default: 3
28
30
  field :retry_delay, type: :integer, default: 30 # 30 seconds
@@ -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
@@ -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
- attr_reader :account, :lock_reason, :locked_at
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#{lock_reason ? ": #{lock_reason}" : ""}")
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.post_form(uri, params)
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
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
17
- http.request(request)
18
- end
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
- class << self
5
- DEFAULT_ACCESS_TOKEN_LIFETIME = 1.hour.to_i
6
- DEFAULT_REFRESH_TOKEN_LIFETIME = 30.days.to_i
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
@@ -34,7 +34,7 @@ module StandardId
34
34
  channel: connection_type,
35
35
  target: username,
36
36
  code: code,
37
- expires_at: 10.minutes.from_now,
37
+ expires_at: StandardId.config.passwordless.code_ttl.seconds.from_now,
38
38
  ip_address: request.remote_ip,
39
39
  user_agent: request.user_agent
40
40
  )
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.2.9"
2
+ VERSION = "0.3.1"
3
3
  end
@@ -15,7 +15,7 @@ module StandardId
15
15
  end
16
16
 
17
17
  def current_account
18
- Current.account ||= current_session&.account
18
+ Current.account ||= current_session&.account&.tap { |a| a.strict_loading!(false) }
19
19
  end
20
20
 
21
21
  def sign_in_account(account)
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"
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.2.9
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '2.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ostruct
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  description: StandardId is an authentication engine that provides a complete, secure-by-default
55
69
  solution for identity management, reducing boilerplate and eliminating common security
56
70
  pitfalls.
@@ -66,8 +80,10 @@ files:
66
80
  - app/assets/stylesheets/standard_id/application.css
67
81
  - app/channels/concerns/standard_id/cable_authentication.rb
68
82
  - app/controllers/concerns/standard_id/api_authentication.rb
83
+ - app/controllers/concerns/standard_id/audience_verification.rb
69
84
  - app/controllers/concerns/standard_id/inertia_rendering.rb
70
85
  - app/controllers/concerns/standard_id/inertia_support.rb
86
+ - app/controllers/concerns/standard_id/passwordless_strategy.rb
71
87
  - app/controllers/concerns/standard_id/set_current_request_details.rb
72
88
  - app/controllers/concerns/standard_id/social_authentication.rb
73
89
  - app/controllers/concerns/standard_id/web/social_login_params.rb
@@ -85,6 +101,7 @@ files:
85
101
  - app/controllers/standard_id/web/auth/callback/providers_controller.rb
86
102
  - app/controllers/standard_id/web/base_controller.rb
87
103
  - app/controllers/standard_id/web/login_controller.rb
104
+ - app/controllers/standard_id/web/login_verify_controller.rb
88
105
  - app/controllers/standard_id/web/logout_controller.rb
89
106
  - app/controllers/standard_id/web/reset_password/confirm_controller.rb
90
107
  - app/controllers/standard_id/web/reset_password/start_controller.rb
@@ -101,6 +118,7 @@ files:
101
118
  - app/forms/standard_id/web/signup_form.rb
102
119
  - app/helpers/standard_id/application_helper.rb
103
120
  - app/jobs/standard_id/application_job.rb
121
+ - app/jobs/standard_id/cleanup_expired_sessions_job.rb
104
122
  - app/mailers/standard_id/application_mailer.rb
105
123
  - app/models/concerns/standard_id/account_associations.rb
106
124
  - app/models/concerns/standard_id/credentiable.rb
@@ -123,10 +141,12 @@ files:
123
141
  - app/views/standard_id/web/account/show.html.erb
124
142
  - app/views/standard_id/web/auth/callback/providers/mobile_callback.html.erb
125
143
  - app/views/standard_id/web/login/show.html.erb
144
+ - app/views/standard_id/web/login_verify/show.html.erb
126
145
  - app/views/standard_id/web/reset_password/confirm/show.html.erb
127
146
  - app/views/standard_id/web/reset_password/start/show.html.erb
128
147
  - app/views/standard_id/web/sessions/index.html.erb
129
148
  - app/views/standard_id/web/signup/show.html.erb
149
+ - config/brakeman.ignore
130
150
  - config/initializers/generators.rb
131
151
  - config/initializers/migration_helpers.rb
132
152
  - config/routes/api.rb
@@ -153,6 +173,7 @@ files:
153
173
  - lib/standard_id/api/session_manager.rb
154
174
  - lib/standard_id/api/token_manager.rb
155
175
  - lib/standard_id/api_engine.rb
176
+ - lib/standard_id/bearer_token_extraction.rb
156
177
  - lib/standard_id/config/schema.rb
157
178
  - lib/standard_id/current_attributes.rb
158
179
  - lib/standard_id/engine.rb
@@ -214,7 +235,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
214
235
  - !ruby/object:Gem::Version
215
236
  version: '0'
216
237
  requirements: []
217
- rubygems_version: 3.6.7
238
+ rubygems_version: 4.0.3
218
239
  specification_version: 4
219
240
  summary: A comprehensive authentication engine for Rails, built on the security primitives
220
241
  introduced in Rails 7/8.