Class: Hitch::AuthorizationRequest

Inherits:
Object
  • Object
show all
Includes:
IssuerUrl, UriValidation
Defined in:
app/models/hitch/authorization_request.rb

Overview

The HTTP-free core of one authorize request: parameter and PKCE validation, client and redirect_uri resolution against whichever registration scheme the client_id belongs to, scope clamping, and response-redirect construction. The controller renders what this object decides.

Defined Under Namespace

Classes: Error

Constant Summary collapse

RESPONSE_PARAMS =

Response parameters are stripped from the registered query before the response is appended. Defense in depth plus one real gap: a client that legitimately registered a query containing a response parameter would otherwise receive it twice, and first-wins query parsers (URLSearchParams, Go's Query().Get, Python's parse_qs) would read the registered value — the issuer mix-up RFC 9207 exists to prevent. The error parameters don't even need that gap: registration is unauthenticated, so an attacker can point their own client's redirect_uri at a legitimate client's callback carrying ?error=…, and §4.1.2 makes clients branch on error first — attacker-written UI copy inside the real client's trusted error surface.

%w[code state iss error error_description error_uri].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(params, principal:) ⇒ AuthorizationRequest

Returns a new instance of AuthorizationRequest.



29
30
31
32
# File 'app/models/hitch/authorization_request.rb', line 29

def initialize(params, principal:)
  @params = params
  @principal = principal
end

Instance Attribute Details

#paramsObject (readonly)

Returns the value of attribute params.



27
28
29
# File 'app/models/hitch/authorization_request.rb', line 27

def params
  @params
end

#principalObject (readonly)

Returns the value of attribute principal.



27
28
29
# File 'app/models/hitch/authorization_request.rb', line 27

def principal
  @principal
end

#resourceObject (readonly)

The canonical resource (RFC 8707 audience), available once valid.



57
58
59
# File 'app/models/hitch/authorization_request.rb', line 57

def resource
  @resource
end

Instance Method Details

#audit_client_nameObject

The name the client claims for itself. Attacker-controllable in both schemes; persisted on the token for audit fidelity only.



118
119
120
# File 'app/models/hitch/authorization_request.rb', line 118

def audit_client_name
  client&.client_name || friendly_client_name || "Unknown"
end

#clientObject

The resolved client, from whichever registration scheme the client_id belongs to: an https client_id is a Client ID Metadata Document reference (MCP 2026-07-28); anything else is an opaque DCR client_id. The two cannot collide. Resolved once — each question the flow asks (redirect validation, consent warning, audit name) would otherwise repeat the DB lookup or, for CIMD without a shared cache, the outbound fetch. nil (no such client) memoizes too.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'app/models/hitch/authorization_request.rb', line 66

def client
  return @client if defined?(@client)

  @client =
    if ClientIdMetadata.reference?(client_id)
      # A principal that cannot be counted must not drive outbound
      # fetches: the per-actor limit is the bound on amplification, so
      # no actor means no fetch and the client reads as unknown.
      actor = rate_limit_actor
      actor && ClientIdMetadata.resolve(client_id, actor: actor)
    else
      Client.find_by(client_id: client_id)
    end
end

#client_idObject



50
# File 'app/models/hitch/authorization_request.rb', line 50

def client_id = params[:client_id]

#code_challengeObject



53
# File 'app/models/hitch/authorization_request.rb', line 53

def code_challenge = params[:code_challenge]

#code_challenge_methodObject



54
# File 'app/models/hitch/authorization_request.rb', line 54

def code_challenge_method = params[:code_challenge_method]

#deny?Boolean

:decision is accepted on the consent POST but never echoed by the consent screen — a crafted authorize link must not pre-press Deny.

Returns:

  • (Boolean)


46
47
48
# File 'app/models/hitch/authorization_request.rb', line 46

def deny?
  params[:decision] == "deny"
end

#display_client_nameObject

The consent screen's display name. Never the client's declared name — that is attacker-controllable in both registration schemes — but a label derived from the verified redirect_uri host.



112
113
114
# File 'app/models/hitch/authorization_request.rb', line 112

def display_client_name
  friendly_client_name || redirect_host || "An application"
end

#errorObject



39
40
41
42
# File 'app/models/hitch/authorization_request.rb', line 39

def error
  valid?
  @error
end

#granted_scopesObject

Intersect the requested scope with the server's supported_scopes allowlist. A client can only ever receive scopes the server actually supports (RFC 6749 §3.3 — the AS MAY narrow). An empty intersection falls back to the default scope so the token is never scopeless.



85
86
87
# File 'app/models/hitch/authorization_request.rb', line 85

def granted_scopes
  Hitch.configuration.clamp_scopes(params[:scope])
end

#localhost_only_client?Boolean

MCP 2026-07-28 security considerations: a metadata document "cannot prevent localhost URL impersonation by itself" — anyone can host a document claiming any name and point it at a loopback port, and nothing proves which program is listening there. The consent screen warns.

Returns:

  • (Boolean)


126
127
128
129
130
131
132
133
# File 'app/models/hitch/authorization_request.rb', line 126

def localhost_only_client?
  return false unless ClientIdMetadata.reference?(client_id)

  declared = registered_redirect_uris
  return false if declared.blank?

  declared.all? { |candidate| loopback_http_uri?(candidate) }
end

#redirect_hostObject



103
104
105
106
107
# File 'app/models/hitch/authorization_request.rb', line 103

def redirect_host
  URI.parse(redirect_uri.to_s).host
rescue URI::InvalidURIError
  nil
end

#redirect_uriObject



51
# File 'app/models/hitch/authorization_request.rb', line 51

def redirect_uri = params[:redirect_uri]

#redirect_uri_for(**response) ⇒ Object

The redirect back to the validated redirect_uri, carrying iss unconditionally (RFC 9207 — byte-identical to the discovery issuer, which is why both come from the shared IssuerUrl derivation).



92
93
94
95
96
97
98
99
100
101
# File 'app/models/hitch/authorization_request.rb', line 92

def redirect_uri_for(**response)
  uri = URI.parse(redirect_uri)
  query_params = URI.decode_www_form(uri.query || "")
                    .reject { |key, _| RESPONSE_PARAMS.include?(key) }
  response.merge(iss: issuer_url).each do |key, value|
    query_params << [ key.to_s, value ] if value.present?
  end
  uri.query = URI.encode_www_form(query_params)
  uri.to_s
end

#stateObject



52
# File 'app/models/hitch/authorization_request.rb', line 52

def state = params[:state]

#valid?Boolean

Returns:

  • (Boolean)


34
35
36
37
# File 'app/models/hitch/authorization_request.rb', line 34

def valid?
  @error = validate unless defined?(@error)
  @error.nil?
end