Class: StandardId::ClientApplication
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- StandardId::ClientApplication
- Defined in:
- app/models/standard_id/client_application.rb
Constant Summary collapse
- LOOPBACK_HOSTS =
Loopback interface hosts per RFC 8252 §7.3 (native apps). "localhost" is included for compatibility but RFC 8252 §8.3 recommends clients use 127.0.0.1/::1 instead, since "localhost" can be remapped by the OS.
%w[127.0.0.1 ::1 localhost].freeze
Class Method Summary collapse
-
.loopback_redirect_uri?(parsed_uri) ⇒ Boolean
True when the parsed URI is an http URI targeting a loopback interface literal (RFC 8252 §7.3).
-
.parse_redirect_uri(value) ⇒ Object
Parse a redirect URI string into a URI object suitable for comparison.
Instance Method Summary collapse
- #activate! ⇒ Object
- #active? ⇒ Boolean
-
#authenticate_client_secret(secret) ⇒ Object
Check if client can authenticate with given secret.
- #code_challenge_methods_array ⇒ Object
- #confidential? ⇒ Boolean
-
#create_client_secret!(name: "Default Secret", **options) ⇒ Object
Generate a new client secret credential.
- #deactivate! ⇒ Object
- #grant_types_array ⇒ Object
-
#primary_client_secret ⇒ Object
Get the primary (first active) client secret.
- #public? ⇒ Boolean
-
#redirect_uris_array ⇒ Object
OAuth configuration helpers.
- #response_types_array ⇒ Object
-
#rotate_client_secret!(new_secret_name: "Rotated Secret #{Time.current.strftime('%Y%m%d')}", client_secret: SecureRandom.hex(32)) ⇒ Object
Client secret rotation support.
- #scopes_array ⇒ Object
- #supports_grant_type?(grant_type) ⇒ Boolean
- #supports_pkce_method?(method) ⇒ Boolean
- #supports_response_type?(response_type) ⇒ Boolean
-
#valid_redirect_uri?(uri) ⇒ Boolean
Validates a redirect_uri presented in an OAuth request against this client's registered URIs.
Class Method Details
.loopback_redirect_uri?(parsed_uri) ⇒ Boolean
True when the parsed URI is an http URI targeting a loopback interface literal (RFC 8252 §7.3). IPv6 loopback hosts are normalized: URI.parse yields "[::1]" on some Ruby versions and "::1" on others, so surrounding brackets are stripped before comparison.
149 150 151 152 153 154 |
# File 'app/models/standard_id/client_application.rb', line 149 def self.loopback_redirect_uri?(parsed_uri) return false unless parsed_uri.scheme == "http" host = parsed_uri.host.to_s.delete_prefix("[").delete_suffix("]") LOOPBACK_HOSTS.include?(host) end |
.parse_redirect_uri(value) ⇒ Object
Parse a redirect URI string into a URI object suitable for comparison. Returns nil for unparseable, relative, or scheme-less URIs.
158 159 160 161 162 163 164 165 166 167 |
# File 'app/models/standard_id/client_application.rb', line 158 def self.parse_redirect_uri(value) return nil if value.to_s.strip.empty? parsed = URI.parse(value.to_s.strip) return nil if parsed.scheme.blank? || parsed.host.blank? parsed rescue URI::InvalidURIError nil end |
Instance Method Details
#activate! ⇒ Object
50 51 52 |
# File 'app/models/standard_id/client_application.rb', line 50 def activate! update!(active: true, deactivated_at: nil) end |
#active? ⇒ Boolean
54 55 56 |
# File 'app/models/standard_id/client_application.rb', line 54 def active? active && deactivated_at.nil? end |
#authenticate_client_secret(secret) ⇒ Object
Check if client can authenticate with given secret
208 209 210 |
# File 'app/models/standard_id/client_application.rb', line 208 def authenticate_client_secret(secret) client_secret_credentials.active.find { |cred| cred.authenticate_client_secret(secret) } end |
#code_challenge_methods_array ⇒ Object
75 76 77 |
# File 'app/models/standard_id/client_application.rb', line 75 def code_challenge_methods_array code_challenge_methods.to_s.split(/\s+/).map(&:strip).reject(&:blank?) end |
#confidential? ⇒ Boolean
169 170 171 |
# File 'app/models/standard_id/client_application.rb', line 169 def confidential? client_type == "confidential" end |
#create_client_secret!(name: "Default Secret", **options) ⇒ Object
Generate a new client secret credential
178 179 180 181 182 183 184 |
# File 'app/models/standard_id/client_application.rb', line 178 def create_client_secret!(name: "Default Secret", **) client_secret_credentials.create!({ name: name, client_id: client_id, scopes: scopes }.merge()) end |
#deactivate! ⇒ Object
46 47 48 |
# File 'app/models/standard_id/client_application.rb', line 46 def deactivate! update!(active: false, deactivated_at: Time.current) end |
#grant_types_array ⇒ Object
67 68 69 |
# File 'app/models/standard_id/client_application.rb', line 67 def grant_types_array grant_types.to_s.split(/\s+/).map(&:strip).reject(&:blank?) end |
#primary_client_secret ⇒ Object
Get the primary (first active) client secret
187 188 189 |
# File 'app/models/standard_id/client_application.rb', line 187 def primary_client_secret client_secret_credentials.active.first end |
#public? ⇒ Boolean
173 174 175 |
# File 'app/models/standard_id/client_application.rb', line 173 def public? client_type == "public" end |
#redirect_uris_array ⇒ Object
OAuth configuration helpers
59 60 61 |
# File 'app/models/standard_id/client_application.rb', line 59 def redirect_uris_array redirect_uris.to_s.split(/\s+/).map(&:strip).reject(&:blank?) end |
#response_types_array ⇒ Object
71 72 73 |
# File 'app/models/standard_id/client_application.rb', line 71 def response_types_array response_types.to_s.split(/\s+/).map(&:strip).reject(&:blank?) end |
#rotate_client_secret!(new_secret_name: "Rotated Secret #{Time.current.strftime('%Y%m%d')}", client_secret: SecureRandom.hex(32)) ⇒ Object
Client secret rotation support
192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'app/models/standard_id/client_application.rb', line 192 def rotate_client_secret!(new_secret_name: "Rotated Secret #{Time.current.strftime('%Y%m%d')}", client_secret: SecureRandom.hex(32)) transaction do # Create new secret new_secret = create_client_secret!(name: new_secret_name, client_secret: client_secret) # Deactivate old secrets (but don't delete for audit trail) client_secret_credentials.where.not(id: new_secret.id).update_all( active: false, revoked_at: Time.current ) new_secret end end |
#scopes_array ⇒ Object
63 64 65 |
# File 'app/models/standard_id/client_application.rb', line 63 def scopes_array scopes.to_s.split(/\s+/).map(&:strip).reject(&:blank?) end |
#supports_grant_type?(grant_type) ⇒ Boolean
79 80 81 |
# File 'app/models/standard_id/client_application.rb', line 79 def supports_grant_type?(grant_type) grant_types_array.include?(grant_type.to_s) end |
#supports_pkce_method?(method) ⇒ Boolean
87 88 89 90 91 |
# File 'app/models/standard_id/client_application.rb', line 87 def supports_pkce_method?(method) return false unless require_pkce? normalized = method.to_s.downcase code_challenge_methods_array.any? { |m| m.downcase == normalized } end |
#supports_response_type?(response_type) ⇒ Boolean
83 84 85 |
# File 'app/models/standard_id/client_application.rb', line 83 def supports_response_type?(response_type) response_types_array.include?(response_type.to_s) end |
#valid_redirect_uri?(uri) ⇒ Boolean
Validates a redirect_uri presented in an OAuth request against this client's registered URIs.
OAuth 2.0 (RFC 6749 §3.1.2) requires the authorization server to compare the registered redirect URI and the request redirect URI using simple string comparison, with the exception that the authorization server may redirect with additional query parameters. We implement a stricter scheme+host+port+path match: the request URI may add query or fragment segments, but the scheme, host, port, and path must exactly match a registered URI. This prevents a class of "query-string piggyback" attacks where a registered callback at /cb is abused with a crafted query string (or, worse, a different path segment like /cb/evil).
Subdomain wildcards are NOT supported — host must match exactly.
Exception — loopback redirects for native apps (RFC 8252 §7.3): when this client is public + PKCE-required and BOTH the registered and requested URIs are http loopback URIs, the port is ignored (native apps bind an ephemeral port on a local listener at authorization time, so it cannot be known at registration). See #loopback_redirect_uri? below.
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'app/models/standard_id/client_application.rb', line 113 def valid_redirect_uri?(uri) requested = self.class.parse_redirect_uri(uri) return false unless requested redirect_uris_array.any? do |registered_uri| registered = self.class.parse_redirect_uri(registered_uri) next false unless registered # RFC 8252 §7.3: for loopback interface redirects, "the authorization # server MUST allow any port to be specified at the time of the request". # Only host + path are compared; scheme is already pinned to "http" by # the loopback predicate. Host equality is still required, so a client # registered with 127.0.0.1 does not match localhost (or vice versa) — # per §8.3, "localhost" is less trustworthy than the literal loopback # IPs because the OS can remap it. This relaxation is gated to public # PKCE clients: the redirect lands on an ephemeral listener on the # user's own machine and PKCE binds the code to the initiating client, # whereas confidential clients have stable callback URLs and keep # strict port matching. if public? && require_pkce? && self.class.loopback_redirect_uri?(registered) && self.class.loopback_redirect_uri?(requested) next registered.host == requested.host && registered.path == requested.path end registered.scheme == requested.scheme && registered.host == requested.host && registered.port == requested.port && registered.path == requested.path end end |