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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +281 -7
- data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
- data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
- data/app/controllers/concerns/hitch/request_admission.rb +22 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
- data/app/controllers/hitch/activations_controller.rb +162 -0
- data/app/controllers/hitch/application_controller.rb +30 -0
- data/app/controllers/hitch/authorizations_controller.rb +4 -26
- data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
- data/app/controllers/hitch/metadata_controller.rb +13 -2
- data/app/controllers/hitch/registrations_controller.rb +7 -1
- data/app/controllers/hitch/revocations_controller.rb +13 -4
- data/app/controllers/hitch/tokens_controller.rb +81 -16
- data/app/models/hitch/access_token.rb +238 -28
- data/app/models/hitch/authorization_request.rb +9 -26
- data/app/models/hitch/client.rb +19 -3
- data/app/models/hitch/client_authentication.rb +18 -2
- data/app/models/hitch/device_activation.rb +113 -0
- data/app/models/hitch/device_grant.rb +244 -0
- data/app/views/hitch/activations/confirm.html.erb +68 -0
- data/app/views/hitch/activations/done.html.erb +18 -0
- data/app/views/hitch/activations/new.html.erb +36 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
- data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
- data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
- data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
- data/docs/adr/0006-device-authorization-grant.md +153 -0
- data/docs/operator/doctor.md +11 -7
- data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
- data/docs/upgrading/0.2-to-0.3.md +180 -0
- data/docs/upgrading/0.3-to-0.4.md +195 -0
- data/lib/generators/hitch/install/templates/initializer.rb +20 -0
- data/lib/hitch/configuration.rb +243 -11
- data/lib/hitch/device_authorization_rate_limit.rb +45 -0
- data/lib/hitch/doctor.rb +76 -18
- data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
- data/lib/hitch/engine.rb +22 -5
- data/lib/hitch/grant_types.rb +23 -0
- data/lib/hitch/mcp/configuration.rb +4 -1
- data/lib/hitch/rack_form_guard.rb +9 -2
- data/lib/hitch/rate_limit_store.rb +103 -5
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +2 -0
- data/lib/tasks/hitch.rake +8 -1
- 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,
|
|
40
|
-
#
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
78
|
-
response.headers["Pragma"] = "no-cache"
|
|
143
|
+
hitch_no_store!
|
|
79
144
|
end
|
|
80
145
|
|
|
81
146
|
def reject_oversized_oauth_form_body!
|