standard_id 0.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9878cd708e3ea39bc8cad97946dd6509c2423a815ade09c726d80c591f72cf7f
4
- data.tar.gz: 4ee3cf785da092a4efd199dee423d214c0b99936928cd381e8167650474c9c7b
3
+ metadata.gz: a164420e3b36d754be94711721ead8cb4d29efed42fd6e75b0060e1c8c7c3bce
4
+ data.tar.gz: cded0bf7e847cc77ed0f6aa7c69647435c847d62ae02c2af1466778a507f88f4
5
5
  SHA512:
6
- metadata.gz: 68cdd5a479a507d7647055b06534744f458546ac81a0a1bf71b0464739316ade506a50303a811fefac56142f223d1d913af4a41fd53243a1678ec64a50f48d5e
7
- data.tar.gz: bb8137fb52314263901a3ed6640ef9b327f1397424b68776af4edd4b37be9d0da26e7974d5728252981823b9dd48fc4aec4825f960aec3a3769a20a7cef3ead4
6
+ metadata.gz: 0b9b506d8014bd8d8cd380b1f5300a87d2d6825e073e8b959c67cbfeac3eb577abe671c9dac330833c8d27ee010b87786f533ac4e247459d691dd956d5053faf
7
+ data.tar.gz: ec1a9973e61d08fff4579099dbcac25ad8355d140994c51c2621559d4aed39486d2fdd891b451ade775799f792c3db19916d555d3a50f3bc84d07ed77c1f0a52
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.account - The locked account
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
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.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -80,6 +80,7 @@ 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
@@ -117,6 +118,7 @@ files:
117
118
  - app/forms/standard_id/web/signup_form.rb
118
119
  - app/helpers/standard_id/application_helper.rb
119
120
  - app/jobs/standard_id/application_job.rb
121
+ - app/jobs/standard_id/cleanup_expired_sessions_job.rb
120
122
  - app/mailers/standard_id/application_mailer.rb
121
123
  - app/models/concerns/standard_id/account_associations.rb
122
124
  - app/models/concerns/standard_id/credentiable.rb
@@ -171,6 +173,7 @@ files:
171
173
  - lib/standard_id/api/session_manager.rb
172
174
  - lib/standard_id/api/token_manager.rb
173
175
  - lib/standard_id/api_engine.rb
176
+ - lib/standard_id/bearer_token_extraction.rb
174
177
  - lib/standard_id/config/schema.rb
175
178
  - lib/standard_id/current_attributes.rb
176
179
  - lib/standard_id/engine.rb