hitch-rails 0.2.0 → 0.4.0

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +281 -7
  4. data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
  5. data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
  6. data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
  7. data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
  8. data/app/controllers/concerns/hitch/request_admission.rb +22 -0
  9. data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
  10. data/app/controllers/hitch/activations_controller.rb +162 -0
  11. data/app/controllers/hitch/application_controller.rb +30 -0
  12. data/app/controllers/hitch/authorizations_controller.rb +4 -26
  13. data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
  14. data/app/controllers/hitch/metadata_controller.rb +13 -2
  15. data/app/controllers/hitch/registrations_controller.rb +7 -1
  16. data/app/controllers/hitch/revocations_controller.rb +13 -4
  17. data/app/controllers/hitch/tokens_controller.rb +81 -16
  18. data/app/models/hitch/access_token.rb +238 -28
  19. data/app/models/hitch/authorization_request.rb +9 -26
  20. data/app/models/hitch/client.rb +19 -3
  21. data/app/models/hitch/client_authentication.rb +18 -2
  22. data/app/models/hitch/device_activation.rb +113 -0
  23. data/app/models/hitch/device_grant.rb +244 -0
  24. data/app/views/hitch/activations/confirm.html.erb +68 -0
  25. data/app/views/hitch/activations/done.html.erb +18 -0
  26. data/app/views/hitch/activations/new.html.erb +36 -0
  27. data/config/routes.rb +9 -0
  28. data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
  29. data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
  30. data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
  31. data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
  32. data/docs/adr/0006-device-authorization-grant.md +153 -0
  33. data/docs/operator/doctor.md +11 -7
  34. data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
  35. data/docs/upgrading/0.2-to-0.3.md +180 -0
  36. data/docs/upgrading/0.3-to-0.4.md +195 -0
  37. data/lib/generators/hitch/install/templates/initializer.rb +20 -0
  38. data/lib/hitch/configuration.rb +243 -11
  39. data/lib/hitch/device_authorization_rate_limit.rb +45 -0
  40. data/lib/hitch/doctor.rb +76 -18
  41. data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
  42. data/lib/hitch/engine.rb +22 -5
  43. data/lib/hitch/grant_types.rb +23 -0
  44. data/lib/hitch/mcp/configuration.rb +4 -1
  45. data/lib/hitch/rack_form_guard.rb +9 -2
  46. data/lib/hitch/rate_limit_store.rb +103 -5
  47. data/lib/hitch/version.rb +1 -1
  48. data/lib/hitch.rb +2 -0
  49. data/lib/tasks/hitch.rake +8 -1
  50. metadata +21 -3
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # GET /activate — enter a device code (RFC 8628 §3.3)
5
+ # POST /activate — verify the code, then approve or deny
6
+ #
7
+ # The one browser piece of the device flow. Session-authenticated through
8
+ # the host's ApplicationController like the consent screen: the host's
9
+ # sign-in gates it, and current_principal is who the approval binds.
10
+ #
11
+ # The page's words are part of the security boundary (§5.4): the flow is
12
+ # "a stranger asks you to approve a code", so the screen says plainly that
13
+ # a code should only be entered by the person who asked a device for it,
14
+ # and a ?user_code= prefill only fills the field — approving always takes
15
+ # the person's own submit.
16
+ class ActivationsController < Hitch::ApplicationController
17
+ include Hitch::OauthFormAdmission
18
+ include Hitch::DeviceAuthorizationGate
19
+
20
+ # Same declaration and reasoning as the consent POST: state-changing,
21
+ # session-authenticated, so it must not depend on the host having
22
+ # forgery protection enabled. Guarded for API-only host bases.
23
+ protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
24
+
25
+ # This is a page a person is looking at: every malformed input — a
26
+ # blank code, a doctored decision — answers with the form and a plain
27
+ # sentence, never the JSON dialect the inherited rescue speaks.
28
+ rescue_from Hitch::OauthRequestParameters::Invalid do
29
+ render_new alert: "Enter the code your device is showing."
30
+ end
31
+
32
+ before_action do
33
+ # A page for a person, in exactly one dialect: a .json path or a
34
+ # JSON Accept header must render the HTML answer, not a
35
+ # MissingTemplate 500.
36
+ request.format = :html
37
+ @brand_name = Hitch.configuration.brand_name
38
+ end
39
+
40
+ def new
41
+ return unless admit_oauth_endpoint!
42
+ return require_principal! unless current_principal
43
+
44
+ # Prefill from verification_uri_complete. Display only — §5.4 wants
45
+ # the person to see the code and confirm it matches their device, so
46
+ # a prefilled form still submits by hand.
47
+ @user_code = DeviceGrant.display_user_code(params[:user_code])
48
+ end
49
+
50
+ def create
51
+ return require_principal! unless current_principal
52
+
53
+ oauth = oauth_parameters(:user_code, :decision, form_only: true)
54
+ # Absent field, same answer as a blank one — and before the quota:
55
+ # neither is a guess.
56
+ return render_new(alert: "Enter the code your device is showing.") if oauth[:user_code].blank?
57
+ return unless admit_verification_rate!
58
+
59
+ grant = DeviceGrant.find_pending_by_user_code(oauth[:user_code])
60
+ return unknown_code unless grant
61
+
62
+ case oauth[:decision]
63
+ when nil then confirm(grant, oauth[:user_code])
64
+ when "approve" then approve(grant, oauth[:user_code])
65
+ when "deny" then deny(oauth[:user_code])
66
+ else
67
+ render_new alert: "Something went wrong with that submission. Enter the code again."
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # A signed-out visitor following verification_uri_complete must not
74
+ # leave the live user code sitting in the host's session store for the
75
+ # code's whole life. They retype it after signing in — it is on the
76
+ # device screen in front of them, which §5.4 wants them checking
77
+ # anyway; a signed-in visitor keeps the prefill.
78
+ def return_to_after_authenticating_url
79
+ request.base_url + request.path
80
+ end
81
+
82
+ # Verification is the brute-force surface RFC 8628 §5.1 rate-limits, and
83
+ # the decision POST carries the code too, so every branch of create sits
84
+ # behind this. Checked per signed-in principal, after authentication —
85
+ # anonymous traffic cannot drain a person's budget — and after parsing:
86
+ # a blank or malformed submission is not a guess and spends none either.
87
+ # Not hitch_admit_rate!: this refusal is a page for a person, not an
88
+ # OAuth error body.
89
+ def admit_verification_rate!
90
+ Hitch::DeviceAuthorizationRateLimit.check_verification!(principal: current_principal)
91
+ true
92
+ rescue Hitch::RateLimitStore::Exceeded => error
93
+ response.headers["Retry-After"] = error.retry_after.to_s
94
+ render_new alert: "Too many attempts. Wait a minute and try again.", status: :too_many_requests
95
+ false
96
+ rescue Hitch::RateLimitStore::Unavailable
97
+ render_new alert: "Code entry is temporarily unavailable. Try again shortly.",
98
+ status: :service_unavailable
99
+ false
100
+ end
101
+
102
+ def confirm(grant, user_code, activation: nil)
103
+ @activation = activation || DeviceActivation.new(grant, principal: current_principal)
104
+ @user_code = DeviceGrant.display_user_code(user_code)
105
+ render :confirm
106
+ end
107
+
108
+ def approve(grant, user_code)
109
+ activation = DeviceActivation.new(grant, principal: current_principal)
110
+ # No Approve for a client that cannot be verified right now. The
111
+ # grant is live and pending, so answer with the honest confirm
112
+ # screen — "used or expired" would be false, and the failure may be
113
+ # transient (a rate-limited or momentarily unreachable document).
114
+ # The same activation is threaded through, so the screen shows the
115
+ # state this decision was refused on.
116
+ return confirm(grant, user_code, activation: activation) if activation.unverified?
117
+
118
+ decided = DeviceGrant.approve!(
119
+ user_code: user_code,
120
+ principal: current_principal,
121
+ client_name: activation.audit_client_name
122
+ )
123
+ return unknown_code unless decided
124
+
125
+ @decision = :approved
126
+ render :done
127
+ end
128
+
129
+ def deny(user_code)
130
+ return unknown_code unless DeviceGrant.deny!(user_code: user_code)
131
+
132
+ @decision = :denied
133
+ render :done
134
+ end
135
+
136
+ # One message for never-existed, expired, and already-decided alike: a
137
+ # more specific answer would tell a guesser which codes are live.
138
+ def unknown_code
139
+ render_new alert: "That code isn't waiting for approval. " \
140
+ "It may have expired or already been used — ask your device for a fresh one."
141
+ end
142
+
143
+ # Assigns everything the template needs itself: some callers run from
144
+ # admission, before the before_action.
145
+ def render_new(alert:, status: :unprocessable_entity)
146
+ request.format = :html
147
+ @alert = alert
148
+ @user_code = nil
149
+ @brand_name = Hitch.configuration.brand_name
150
+ render :new, status: status
151
+ end
152
+
153
+ def preserve_oauth_authenticity_token?
154
+ true
155
+ end
156
+
157
+ def reject_oversized_oauth_form_body!
158
+ render_new alert: "That submission was too large. Enter just the short code your device is showing.",
159
+ status: :content_too_large
160
+ end
161
+ end
162
+ end
@@ -55,5 +55,35 @@ module Hitch
55
55
 
56
56
  nil
57
57
  end
58
+
59
+ private
60
+
61
+ def require_principal!
62
+ # Remember where the user was headed so the host's auth flow returns
63
+ # them here after login. Rails 8's built-in authentication reads
64
+ # session[:return_to_after_authenticating] in after_authentication_url;
65
+ # normally its own require_authentication callback sets this, but
66
+ # these controllers skip that callback (see above) and redirect to
67
+ # login_path themselves, so the return location is set here. Harmless
68
+ # for hosts that never read the key. Only meaningful on a GET render —
69
+ # a POST without a session isn't a real flow.
70
+ session[:return_to_after_authenticating] = return_to_after_authenticating_url if request.get?
71
+
72
+ path = Hitch.configuration.login_path
73
+ target = path.respond_to?(:call) ? path.call(request) : path
74
+
75
+ if target.present?
76
+ redirect_to target, allow_other_host: true
77
+ else
78
+ render plain: "Authentication required", status: :unauthorized
79
+ end
80
+ end
81
+
82
+ # What the host's login flow returns the visitor to. The activation
83
+ # screen overrides this to drop its query string: the prefill link
84
+ # carries a live user code, and the session store must not keep one.
85
+ def return_to_after_authenticating_url
86
+ request.url
87
+ end
58
88
  end
59
89
  end
@@ -36,8 +36,9 @@ module Hitch
36
36
  # protection enabled — an API-only host, or one that disables it
37
37
  # app-wide, would otherwise leave Approve forgeable (an attacker
38
38
  # auto-approving an authorization in a logged-in victim's session).
39
- # The rendered consent form (form_with) carries the token, so
40
- # legitimate submits are unaffected. Guarded: an
39
+ # The rendered consent form (form_with) carries the token, and
40
+ # browsers send Sec-Fetch-Site — whichever the host's verification
41
+ # strategy consults, legitimate submits are unaffected. Guarded: an
41
42
  # ActionController::API-derived host base doesn't define the macro,
42
43
  # and such a host can't serve the HTML consent screen anyway.
43
44
  protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
@@ -121,32 +122,9 @@ module Hitch
121
122
  # already passed exact registered-URI validation, so construct the 302
122
123
  # directly and keep the credential out of redirect instrumentation.
123
124
  def redirect_to_client(location)
124
- response.headers["Cache-Control"] = "no-store"
125
- response.headers["Pragma"] = "no-cache"
125
+ hitch_no_store!
126
126
  response.headers["Location"] = location
127
127
  head :found
128
128
  end
129
-
130
- def require_principal!
131
- # Remember where the user was headed so the host's auth flow returns
132
- # them to the consent screen after login. Rails 8's built-in
133
- # authentication reads session[:return_to_after_authenticating] in
134
- # after_authentication_url; normally its own require_authentication
135
- # callback sets this, but the consent controller skips that callback
136
- # (see ApplicationController) and redirects to login_path itself, so
137
- # we set the return location here. Harmless for hosts that never read
138
- # the key. Only meaningful on the GET consent render — a POST without
139
- # a session isn't a real flow.
140
- session[:return_to_after_authenticating] = request.url if request.get?
141
-
142
- path = Hitch.configuration.login_path
143
- target = path.respond_to?(:call) ? path.call(request) : path
144
-
145
- if target.present?
146
- redirect_to target, allow_other_host: true
147
- else
148
- render plain: "Authentication required", status: :unauthorized
149
- end
150
- end
151
129
  end
152
130
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # POST /oauth/device_authorization — RFC 8628 §3.1: mint a device grant
5
+ # and hand back the code pair a headless client shows its human.
6
+ #
7
+ # Public endpoint, like tokens: the caller is a machine with no session.
8
+ # One thing it deliberately never does is fetch client metadata — every
9
+ # CIMD fetch in this gem has a signed-in, rate-limited actor, and this
10
+ # endpoint has neither, so a CIMD-shaped client_id is accepted as a
11
+ # string here and resolved on /activate, where the approving person is
12
+ # the actor.
13
+ class DeviceAuthorizationsController < Hitch::PublicEndpointController
14
+ include Hitch::CorsSupport
15
+ include Hitch::OauthFormAdmission
16
+ include Hitch::UriValidation
17
+ include Hitch::ClientResolution
18
+ include Hitch::DeviceAuthorizationGate
19
+
20
+ DEVICE_PARAMETER_NAMES = %i[client_id client_secret scope resource].freeze
21
+
22
+ def create
23
+ unless request.media_type == Hitch::OauthRequestParameters::FORM_MEDIA_TYPE
24
+ return oauth_error("invalid_request", "device authorization requests must use application/x-www-form-urlencoded")
25
+ end
26
+ return unless admit_mint_rate!
27
+
28
+ oauth = oauth_parameters(*DEVICE_PARAMETER_NAMES, form_only: true)
29
+ resource = require_canonical_resource(oauth[:resource])
30
+ return unless resource
31
+
32
+ authentication = resolved_client_authentication(oauth)
33
+ client_id = authentication.client_id
34
+ # A device grant needs a client somebody real vouches for: a
35
+ # metadata document (its host is earned by serving it), or a
36
+ # confidential client the operator registered — whose row records that
37
+ # provenance and which just authenticated by its secret. A client that
38
+ # only vouched for itself via open registration is the §5.4 phishing
39
+ # shape, whether or not DCR gave it a secret, and cannot mint. A
40
+ # registered row decides first, exactly as /activate classifies, so
41
+ # nothing can mint a grant the screen would never approve.
42
+ live_client = Hitch::Client.find_by(client_id: client_id)
43
+ voucher_auth_method = case authentication.token_endpoint_auth_method
44
+ when "client_secret_basic"
45
+ if authentication.operator_registered &&
46
+ live_client&.operator_registered_confidential_client?
47
+ "client_secret_basic"
48
+ end
49
+ when "none"
50
+ if !authentication.registered_client && live_client.nil? &&
51
+ Hitch::ClientIdMetadata.reference?(client_id)
52
+ "none"
53
+ end
54
+ end
55
+ unless voucher_auth_method == authentication.token_endpoint_auth_method
56
+ return oauth_error(
57
+ "invalid_client",
58
+ "device authorization requires a client metadata document or an operator-registered client",
59
+ :unauthorized
60
+ )
61
+ end
62
+
63
+ grant = Hitch::DeviceGrant.mint!(
64
+ client_id: client_id,
65
+ scopes: oauth[:scope],
66
+ resource_uri: resource,
67
+ token_endpoint_auth_method: authentication.token_endpoint_auth_method
68
+ )
69
+ render_device_authorization(grant)
70
+ end
71
+
72
+ private
73
+
74
+ def client_authentication_realm
75
+ "oauth/device_authorization"
76
+ end
77
+
78
+ def admit_mint_rate!
79
+ hitch_admit_rate!("Device authorization") do
80
+ Hitch::DeviceAuthorizationRateLimit.check_mint!(remote_ip: request.remote_ip)
81
+ end
82
+ end
83
+
84
+ # §3.2. verification_uri comes from the configured issuer, never the
85
+ # request: a Host-header alias must not leak into a URI a person types.
86
+ def render_device_authorization(grant)
87
+ user_code = Hitch::DeviceGrant.display_user_code(grant.raw_user_code)
88
+ activate = "#{issuer_url}/activate"
89
+ render json: {
90
+ device_code: grant.raw_device_code,
91
+ user_code: user_code,
92
+ verification_uri: activate,
93
+ verification_uri_complete: "#{activate}?user_code=#{user_code}",
94
+ expires_in: Hitch.configuration.device_code_lifetime_seconds,
95
+ interval: Hitch.configuration.device_authorization_interval_seconds
96
+ }
97
+ end
98
+
99
+ # RFC 6749 §5.1's non-cacheable rule, applied to the code pair for the
100
+ # same reason it applies to tokens.
101
+ def prepare_oauth_form_response!
102
+ hitch_no_store!
103
+ end
104
+
105
+ def reject_oversized_oauth_form_body!
106
+ oauth_error(
107
+ "invalid_request",
108
+ "device authorization request body exceeds #{MAX_REQUEST_BODY_BYTES} bytes",
109
+ :content_too_large
110
+ )
111
+ end
112
+ end
113
+ end
@@ -16,7 +16,7 @@ module Hitch
16
16
  token_endpoint: canonical_endpoint("/oauth/token"),
17
17
  revocation_endpoint: canonical_endpoint("/oauth/revoke"),
18
18
  response_types_supported: [ "code" ],
19
- grant_types_supported: [ "authorization_code" ],
19
+ grant_types_supported: Hitch::GrantTypes.supported,
20
20
  code_challenge_methods_supported: [ "S256" ],
21
21
  scopes_supported: Hitch.configuration.supported_scopes,
22
22
  token_endpoint_auth_methods_supported: Hitch::Client::TOKEN_ENDPOINT_AUTH_METHODS,
@@ -27,7 +27,9 @@ module Hitch
27
27
  # AuthorizationsController#build_redirect_uri appends it
28
28
  # unconditionally; the two must never be separated.
29
29
  authorization_response_iss_parameter_supported: issuer_is_https?
30
- }.merge(dynamic_client_registration_advertisement).merge(client_id_metadata_advertisement)
30
+ }.merge(dynamic_client_registration_advertisement)
31
+ .merge(device_authorization_advertisement)
32
+ .merge(client_id_metadata_advertisement)
31
33
  end
32
34
 
33
35
  def resource
@@ -52,6 +54,15 @@ module Hitch
52
54
  { registration_endpoint: canonical_endpoint("/oauth/register") }
53
55
  end
54
56
 
57
+ # RFC 8628 §4. GrantTypes.supported adds the device grant urn under the
58
+ # same flag, so the endpoint and the grant advertise and disappear
59
+ # together.
60
+ def device_authorization_advertisement
61
+ return {} unless Hitch.configuration.device_authorization_enabled
62
+
63
+ { device_authorization_endpoint: canonical_endpoint("/oauth/device_authorization") }
64
+ end
65
+
55
66
  # RFC 9207 §2: the `iss` value "MUST be a URL that uses the 'https'
56
67
  # scheme". Over plain http the value this server emits is therefore
57
68
  # not a valid issuer identifier, and withholding the advertisement
@@ -125,7 +125,13 @@ module Hitch
125
125
  }
126
126
 
127
127
  if auth_method == "client_secret_basic"
128
- credentials = Hitch::Client.register_confidential!(**attributes)
128
+ # A secret proves continuity at the token endpoint; it does not turn
129
+ # anonymous DCR into an operator endorsement. Device authorization
130
+ # must be able to distinguish this row from one created at a console.
131
+ credentials = Hitch::Client.register_confidential!(
132
+ **attributes,
133
+ operator_registered: false
134
+ )
129
135
  [ credentials.client, credentials.client_secret ]
130
136
  else
131
137
  [ Hitch::Client.register!(**attributes), nil ]
@@ -12,10 +12,7 @@ module Hitch
12
12
  return head :ok unless request.media_type == Hitch::OauthRequestParameters::FORM_MEDIA_TYPE
13
13
 
14
14
  token_value = oauth_parameters(:token, form_only: true)[:token]
15
- if token_value.present?
16
- access_token = Hitch::AccessToken.find_by_token(token_value)
17
- access_token&.revoke!
18
- end
15
+ revoke(token_value) if token_value.present?
19
16
 
20
17
  head :ok
21
18
  rescue Hitch::OauthRequestParameters::Invalid
@@ -24,6 +21,18 @@ module Hitch
24
21
 
25
22
  private
26
23
 
24
+ # RFC 7009 §2.1: the endpoint takes either token type. An access token
25
+ # revokes itself; a refresh token revokes the family it belongs to,
26
+ # because the trust a human granted at the consent screen is the family,
27
+ # and revoking one link would leave the rest of the chain usable.
28
+ def revoke(token_value)
29
+ access_token = Hitch::AccessToken.find_by_token(token_value)
30
+ return access_token.revoke! if access_token
31
+
32
+ refresh_token = Hitch::AccessToken.find_by_refresh_token(token_value)
33
+ Hitch::AccessToken.revoke_family!(refresh_token.family_id) if refresh_token
34
+ end
35
+
27
36
  def reject_oversized_oauth_form_body!
28
37
  head :ok
29
38
  end
@@ -10,6 +10,7 @@ module Hitch
10
10
  include Hitch::CorsSupport
11
11
  include Hitch::OauthFormAdmission
12
12
  include Hitch::UriValidation
13
+ include Hitch::ClientResolution
13
14
 
14
15
  TOKEN_PARAMETER_NAMES = %i[
15
16
  grant_type
@@ -17,8 +18,11 @@ module Hitch
17
18
  client_id
18
19
  client_secret
19
20
  code_verifier
21
+ device_code
20
22
  resource
21
23
  redirect_uri
24
+ refresh_token
25
+ scope
22
26
  ].freeze
23
27
 
24
28
  def create
@@ -27,7 +31,20 @@ module Hitch
27
31
  end
28
32
 
29
33
  oauth = oauth_parameters(*TOKEN_PARAMETER_NAMES, form_only: true)
30
- return oauth_error("invalid_request", "grant_type must be authorization_code") unless oauth[:grant_type] == "authorization_code"
34
+ case oauth[:grant_type]
35
+ when "authorization_code" then authorization_code_grant(oauth)
36
+ when "refresh_token" then refresh_token_grant(oauth)
37
+ when Hitch::GrantTypes::DEVICE_CODE then device_code_grant(oauth)
38
+ else
39
+ oauth_error("invalid_request", "grant_type must be #{Hitch::GrantTypes.supported.join(' or ')}")
40
+ end
41
+ rescue Hitch::AccessToken::OAuthError => e
42
+ oauth_error(e.oauth_code, e.description)
43
+ end
44
+
45
+ private
46
+
47
+ def authorization_code_grant(oauth)
31
48
  return oauth_error("invalid_request", "code is required") if oauth[:code].blank?
32
49
  return oauth_error("invalid_request", "code_verifier is required") if oauth[:code_verifier].blank?
33
50
  unless Hitch::Pkce.valid_verifier?(oauth[:code_verifier])
@@ -40,11 +57,7 @@ module Hitch
40
57
  resource = require_canonical_resource(oauth[:resource])
41
58
  return unless resource
42
59
 
43
- client_id = Hitch::ClientAuthentication.resolve(
44
- request: request,
45
- body_client_id: oauth[:client_id],
46
- body_secret_present: oauth[:client_secret].present?
47
- )
60
+ client_id = resolved_client_id(oauth)
48
61
  result = Hitch::AccessToken.exchange_authorization_code!(
49
62
  raw_code: oauth[:code],
50
63
  code_verifier: oauth[:code_verifier],
@@ -55,27 +68,79 @@ module Hitch
55
68
 
56
69
  return oauth_error("invalid_grant", "Invalid or expired authorization code") if result.nil?
57
70
 
58
- render json: {
71
+ render_token(result)
72
+ end
73
+
74
+ def refresh_token_grant(oauth)
75
+ return oauth_error("invalid_request", "refresh_token is required") if oauth[:refresh_token].blank?
76
+
77
+ resource = require_canonical_resource(oauth[:resource])
78
+ return unless resource
79
+
80
+ client_id = resolved_client_id(oauth)
81
+ result = Hitch::AccessToken.exchange_refresh_token!(
82
+ raw_refresh_token: oauth[:refresh_token],
83
+ client_id: client_id,
84
+ resource_uri: resource,
85
+ scopes: oauth[:scope]
86
+ )
87
+
88
+ return oauth_error("invalid_grant", "Invalid or expired refresh token") if result.nil?
89
+
90
+ render_token(result)
91
+ end
92
+
93
+ # RFC 8628 §3.4. The model raises the §3.5 polling responses
94
+ # (authorization_pending, slow_down, access_denied, expired_token) as
95
+ # OAuthError, so they flow through the same rescue as every other grant.
96
+ def device_code_grant(oauth)
97
+ # Before any parameter or client validation: while the feature is off
98
+ # this grant has exactly one answer (unsupported_grant_type, RFC 6749
99
+ # §5.2), and no validation — client authentication included — runs
100
+ # for it. The model repeats the gate for its own callers.
101
+ unless Hitch.configuration.device_authorization_enabled
102
+ return oauth_error("unsupported_grant_type", "Device authorization is not enabled")
103
+ end
104
+ return oauth_error("invalid_request", "device_code is required") if oauth[:device_code].blank?
105
+
106
+ resource = require_canonical_resource(oauth[:resource])
107
+ return unless resource
108
+
109
+ authentication = resolved_client_authentication(oauth)
110
+ result = Hitch::DeviceGrant.exchange_device_code!(
111
+ raw_device_code: oauth[:device_code],
112
+ client_id: authentication.client_id,
113
+ resource_uri: resource,
114
+ token_endpoint_auth_method: authentication.token_endpoint_auth_method
115
+ )
116
+
117
+ return oauth_error("invalid_grant", "Invalid or expired device code") if result.nil?
118
+
119
+ render_token(result)
120
+ end
121
+
122
+ def client_authentication_realm
123
+ "oauth/token"
124
+ end
125
+
126
+ # One shape for both grants. A refresh_token key is present only when the
127
+ # feature is on, so a client cannot read the absence as an error.
128
+ def render_token(result)
129
+ body = {
59
130
  access_token: result[:raw_token],
60
131
  token_type: "Bearer",
61
132
  expires_in: Hitch.configuration.access_token_lifetime_seconds,
62
133
  scope: result[:scope]
63
134
  }
64
- rescue Hitch::ClientAuthentication::Invalid => error
65
- response.headers["WWW-Authenticate"] = 'Basic realm="oauth/token"' if error.http_status == :unauthorized
66
- oauth_error(error.oauth_code, error.message, error.http_status)
67
- rescue Hitch::AccessToken::OAuthError => e
68
- oauth_error(e.oauth_code, e.description)
135
+ body[:refresh_token] = result[:raw_refresh_token] if result[:raw_refresh_token].present?
136
+ render json: body
69
137
  end
70
138
 
71
- private
72
-
73
139
  # RFC 6749 section 5.1 requires token responses to be non-cacheable.
74
140
  # This hook runs in OauthFormAdmission before Rails instrumentation, so
75
141
  # successful exchanges and early admission failures get the same policy.
76
142
  def prepare_oauth_form_response!
77
- response.headers["Cache-Control"] = "no-store"
78
- response.headers["Pragma"] = "no-cache"
143
+ hitch_no_store!
79
144
  end
80
145
 
81
146
  def reject_oversized_oauth_form_body!