Class: StandardId::ClientApplication

Inherits:
ApplicationRecord show all
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

Instance Method Summary collapse

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.

Returns:

  • (Boolean)


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

Returns:

  • (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_arrayObject



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

Returns:

  • (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", **options)
  client_secret_credentials.create!({
    name: name,
    client_id: client_id,
    scopes: scopes
  }.merge(options))
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_arrayObject



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_secretObject

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

Returns:

  • (Boolean)


173
174
175
# File 'app/models/standard_id/client_application.rb', line 173

def public?
  client_type == "public"
end

#redirect_uris_arrayObject

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_arrayObject



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_arrayObject



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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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.

Returns:

  • (Boolean)


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