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.
- checksums.yaml +4 -4
- data/README.md +59 -16
- data/app/controllers/concerns/standard_id/audience_verification.rb +97 -0
- data/app/controllers/concerns/standard_id/passwordless_strategy.rb +18 -0
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/concerns/standard_id/web_authentication.rb +11 -1
- data/app/controllers/standard_id/api/passwordless_controller.rb +1 -10
- data/app/controllers/standard_id/web/login_controller.rb +47 -4
- data/app/controllers/standard_id/web/login_verify_controller.rb +170 -0
- data/app/forms/standard_id/web/signup_form.rb +2 -2
- data/app/jobs/standard_id/cleanup_expired_sessions_job.rb +15 -0
- data/app/views/standard_id/web/login_verify/show.html.erb +35 -0
- data/config/brakeman.ignore +30 -0
- data/config/routes/web.rb +1 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +2 -0
- data/lib/standard_id/api/session_manager.rb +3 -1
- data/lib/standard_id/api/token_manager.rb +2 -5
- 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/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +1 -1
- data/lib/standard_id.rb +1 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a164420e3b36d754be94711721ead8cb4d29efed42fd6e75b0060e1c8c7c3bce
|
|
4
|
+
data.tar.gz: cded0bf7e847cc77ed0f6aa7c69647435c847d62ae02c2af1466778a507f88f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
528
|
+
gem "standard_id"
|
|
529
|
+
gem "standard_audit"
|
|
530
|
+
```
|
|
525
531
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
@@ -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
|
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
|
|
@@ -34,7 +34,7 @@ module StandardId
|
|
|
34
34
|
channel: connection_type,
|
|
35
35
|
target: username,
|
|
36
36
|
code: code,
|
|
37
|
-
expires_at:
|
|
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
|
)
|
data/lib/standard_id/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|