Auto-approve token consent for SSO sessions on listed origins

A client behind the same authenticating proxy - Materialious behind
Authelia - still had to click through the token consent page, although
the proxy had just authenticated the same person for the same origin.

trusted_header_auth.auto_approve_token_callbacks lists exact origins.
When /authorize_token is reached with a callback on one of them, and the
trusted header asserts the session user on that very request, the token
is issued with the requested scopes and the browser is sent back to the
callback exactly as the consent POST would have sent it.

Every rail is a whole-value check:

- the callback origin is normalized (scheme and host lowercased, default
  port dropped) and compared whole against normalized entries, so
  neither "https://yt.example.com.evil.tld" nor a path can match;
- a URL carrying credentials is refused outright, which is what stops
  "https://yt.example.com@evil.tld";
- a password-login session never qualifies, header auth must be on, and
  an empty list (the default) leaves the consent page exactly as it was.

The redirect URL is now built in one place, shared with the consent POST,
so both hand the client the identical URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
NeskireDK 2026-08-13 06:25:14 +02:00
parent fae22d037e
commit 437c6194d6
2 changed files with 49 additions and 16 deletions

View File

@ -219,12 +219,21 @@ module Invidious::Routes::Account
scopes ||= [] of String scopes ||= [] of String
callback_url = env.params.query["callback_url"]? callback_url = env.params.query["callback_url"]?
expire = env.params.query["expire"]?.try &.to_i?
# ArikTube: a client the admin listed, reached through a session the
# trusted-header proxy vouches for, is answered as the consent POST
# below would answer it. The proxy already authenticated this person
# for this origin, so the extra click proves nothing.
if callback_url && Invidious::TrustedHeaderAuth.auto_approve_token?(env, user, callback_url)
access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
return env.redirect self.token_callback_url(callback_url, user, access_token)
end
if callback_url if callback_url
callback_url = URI.parse(callback_url) callback_url = URI.parse(callback_url)
end end
expire = env.params.query["expire"]?.try &.to_i?
templated "user/authorize_token" templated "user/authorize_token"
end end
@ -257,20 +266,7 @@ module Invidious::Routes::Account
access_token = generate_token(user.email, scopes, expire, HMAC_KEY) access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
if callback_url if callback_url
access_token = URI.encode_www_form(access_token) env.redirect self.token_callback_url(callback_url, user, access_token)
url = URI.parse(callback_url)
if url.query
query = HTTP::Params.parse(url.query.not_nil!)
else
query = HTTP::Params.new
end
query["token"] = access_token
query["username"] = URI.encode_path_segment(user.email)
url.query = query.to_s
env.redirect url.to_s
else else
csrf_token = "" csrf_token = ""
env.set "access_token", access_token env.set "access_token", access_token
@ -278,6 +274,25 @@ module Invidious::Routes::Account
end end
end end
# The client's callback URL with the granted token and the user name
# appended. Shared by the consent POST and the ArikTube auto-approval, so
# an approved client is handed exactly the same URL either way.
private def token_callback_url(callback_url : String, user : User, access_token : String) : String
url = URI.parse(callback_url)
if url.query
query = HTTP::Params.parse(url.query.not_nil!)
else
query = HTTP::Params.new
end
query["token"] = URI.encode_www_form(access_token)
query["username"] = URI.encode_path_segment(user.email)
url.query = query.to_s
url.to_s
end
# ------------------- # -------------------
# Manage tokens # Manage tokens
# ------------------- # -------------------

View File

@ -45,6 +45,24 @@ module Invidious::TrustedHeaderAuth
email email
end end
# Whether a token authorization request may skip the consent page.
#
# Only for a session the proxy vouches for, and only when the callback
# lands on an origin the admin listed. The token still goes to that origin
# and nowhere else, so an attacker who talks the browser into this route
# hands the token to the admin's own client, not to themselves.
def auto_approve_token?(env, user, callback_url : String?) : Bool
config = CONFIG.trusted_header_auth
Invidious::ArikSettings.auto_approve_token?(
config.enabled,
config.auto_approve_token_callbacks,
asserted_email(env),
user.email,
callback_url
)
end
# Whether `user` may change their password without typing the current one. # Whether `user` may change their password without typing the current one.
# #
# The header has to assert this very user on this very request, so a # The header has to assert this very user on this very request, so a