Merge remote-tracking branch 'origin/oidc-support'
Invidious CI / build - crystal: 1.14.1, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.15.1, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.16.3, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.17.1, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.18.2, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.19.2, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.20.1, stable: true (push) Has been cancelled
Invidious CI / build - crystal: nightly, stable: false (push) Has been cancelled
Invidious CI / Test AMD64 Docker build (push) Has been cancelled
Invidious CI / Test ARM64 Docker build (push) Has been cancelled
Invidious CI / lint (push) Has been cancelled
Build and release container directly from master / release (docker/Dockerfile, AMD64, ubuntu-latest, linux/amd64, ) (push) Has been cancelled
Build and release container directly from master / release (docker/Dockerfile.arm64, ARM64, ubuntu-24.04-arm, linux/arm64/v8, -arm64) (push) Has been cancelled

This commit is contained in:
Alexandru Macocian
2026-05-22 14:24:30 +02:00
10 changed files with 519 additions and 11 deletions
+28
View File
@@ -8,6 +8,21 @@ struct DBConfig
property dbname : String
end
struct OIDCConfig
include YAML::Serializable
property issuer : String
property client_id : String
property client_secret : String
property field : String = "email"
property scopes : Array(String) = ["openid", "email", "profile"]
property discovery_endpoint : String? = nil
property auth_endpoint : String? = nil
property token_endpoint : String? = nil
property userinfo_endpoint : String? = nil
property jwks_uri : String? = nil
end
struct SocketBindingConfig
include YAML::Serializable
@@ -177,6 +192,11 @@ class Config
@[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new
# Authentication configuration
property auth_type : Array(String) = ["invidious"]
property auth_enforce_source : Bool = true
property oidc = {} of String => OIDCConfig
# Playlist length limit
property playlist_length_limit : Int32 = 500
@@ -195,6 +215,14 @@ class Config
end
end
def auth_oidc_enabled?
return (@auth_type.find(&.== "oidc") && @oidc.size > 0)
end
def auth_internal_enabled?
return (@auth_type.find(&.== "invidious"))
end
def self.load
# Load config from file or YAML string env var
env_config_file = "INVIDIOUS_CONFIG_FILE"
+235
View File
@@ -0,0 +1,235 @@
require "jwt"
require "json"
require "http/client"
module Invidious::OIDCHelper
extend self
DISCOVERY_CACHE = {} of String => JSON::Any
def get_provider(key)
if provider = CONFIG.oidc[key]?
provider
else
raise Exception.new("Invalid OIDC Provider: " + key)
end
end
def get_host_url
if CONFIG.domain && CONFIG.external_port
port_part = CONFIG.external_port == 443 || CONFIG.external_port == 80 ? "" : ":#{CONFIG.external_port}"
scheme = CONFIG.https_only || CONFIG.external_port == 443 ? "https" : "http"
"#{scheme}://#{CONFIG.domain}#{port_part}"
elsif CONFIG.domain
scheme = CONFIG.https_only ? "https" : "http"
"#{scheme}://#{CONFIG.domain}"
else
raise Exception.new("Missing domain configuration for OIDC")
end
end
def get_discovery_document(provider : OIDCConfig)
cache_key = provider.issuer
if cached = DISCOVERY_CACHE[cache_key]?
return cached
end
discovery_url = provider.discovery_endpoint || "#{provider.issuer}/.well-known/openid-configuration"
uri = URI.parse(discovery_url)
client = HTTP::Client.new(uri.host.not_nil!, port: uri.port, tls: uri.scheme == "https")
# Follow redirects manually (up to 3 redirects)
current_path = uri.path || "/"
if query = uri.query
current_path += "?" + query
end
3.times do
response = client.get(current_path)
case response.status_code
when 200
discovery_doc = JSON.parse(response.body)
DISCOVERY_CACHE[cache_key] = discovery_doc
client.close
return discovery_doc
when 301, 302, 303, 307, 308
location = response.headers["Location"]?
if location
if location.starts_with?("http")
# Absolute URL redirect - need new client
client.close
new_uri = URI.parse(location)
client = HTTP::Client.new(new_uri.host.not_nil!, port: new_uri.port, tls: new_uri.scheme == "https")
current_path = new_uri.path || "/"
if query = new_uri.query
current_path += "?" + query
end
else
# Relative redirect
current_path = location
end
else
client.close
raise Exception.new("Redirect without Location header")
end
else
LOGGER.error("OIDC Discovery: Failed to fetch from #{discovery_url}")
LOGGER.error("OIDC Discovery: Response code: #{response.status_code}")
LOGGER.error("OIDC Discovery: Response body: #{response.body}")
client.close
raise Exception.new("Failed to fetch OIDC discovery document from #{discovery_url}: #{response.status_code}")
end
end
client.close
raise Exception.new("Too many redirects when fetching OIDC discovery document")
end
def get_authorization_url(key : String, state : String, nonce : String)
provider = get_provider(key)
discovery_doc = get_discovery_document(provider)
auth_endpoint = provider.auth_endpoint || discovery_doc["authorization_endpoint"].as_s
redirect_uri = "#{get_host_url}/login/oidc/#{key}/callback"
params = HTTP::Params.build do |form|
form.add "response_type", "code"
form.add "client_id", provider.client_id
form.add "redirect_uri", redirect_uri
form.add "scope", provider.scopes.join(" ")
form.add "state", state
form.add "nonce", nonce
end
"#{auth_endpoint}?#{params}"
end
def exchange_code_for_tokens(key : String, authorization_code : String)
provider = get_provider(key)
discovery_doc = get_discovery_document(provider)
token_endpoint = provider.token_endpoint || discovery_doc["token_endpoint"].as_s
redirect_uri = "#{get_host_url}/login/oidc/#{key}/callback"
uri = URI.parse(token_endpoint)
client = HTTP::Client.new(uri.host.not_nil!, port: uri.port, tls: uri.scheme == "https")
form_data = HTTP::Params.build do |form|
form.add "grant_type", "authorization_code"
form.add "code", authorization_code
form.add "redirect_uri", redirect_uri
form.add "client_id", provider.client_id
form.add "client_secret", provider.client_secret
end
headers = HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}
path = uri.path || "/"
response = client.post(path, headers: headers, body: form_data.to_s)
client.close
if response.status_code != 200
raise Exception.new("Token exchange failed: #{response.status_code} - #{response.body}")
end
JSON.parse(response.body)
end
def get_jwks(provider : OIDCConfig)
discovery_doc = get_discovery_document(provider)
jwks_uri = provider.jwks_uri || discovery_doc["jwks_uri"].as_s
uri = URI.parse(jwks_uri)
client = HTTP::Client.new(uri.host.not_nil!, port: uri.port, tls: uri.scheme == "https")
response = client.get(uri.path || "/")
client.close
if response.status_code != 200
raise Exception.new("Failed to fetch JWKS: #{response.status_code}")
end
JSON.parse(response.body)
end
def verify_id_token(key : String, id_token : String, nonce : String)
provider = get_provider(key)
# TODO: validate jks signature
token_parts = id_token.split(".")
if token_parts.size != 3
raise Exception.new("Invalid JWT format")
end
payload_base64 = token_parts[1]
while payload_base64.size % 4 != 0
payload_base64 += "="
end
payload_json = Base64.decode_string(payload_base64)
payload = JSON.parse(payload_json)
now = Time.utc.to_unix
if payload["exp"]? && payload["exp"].as_i64 < now
raise Exception.new("Token expired")
end
if payload["iss"]?
token_issuer = payload["iss"].as_s
if token_issuer != provider.issuer
LOGGER.error("OIDC Token Validation: Issuer mismatch")
LOGGER.error("OIDC Token Validation: Expected issuer: #{provider.issuer}")
LOGGER.error("OIDC Token Validation: Token issuer: #{token_issuer}")
raise Exception.new("Invalid issuer - expected: #{provider.issuer}, got: #{token_issuer}")
end
end
if payload["aud"]? && payload["aud"].as_s != provider.client_id
raise Exception.new("Invalid audience")
end
if payload["nonce"]? && payload["nonce"].as_s != nonce
raise Exception.new("Invalid nonce")
end
payload
end
def get_userinfo(key : String, access_token : String)
provider = get_provider(key)
discovery_doc = get_discovery_document(provider)
userinfo_endpoint = provider.userinfo_endpoint || discovery_doc["userinfo_endpoint"]?.try(&.as_s)
return nil unless userinfo_endpoint
uri = URI.parse(userinfo_endpoint)
client = HTTP::Client.new(uri.host.not_nil!, port: uri.port, tls: uri.scheme == "https")
headers = HTTP::Headers{"Authorization" => "Bearer #{access_token}"}
response = client.get(uri.path || "/", headers: headers)
client.close
if response.status_code != 200
raise Exception.new("Failed to fetch userinfo: #{response.status_code}")
end
JSON.parse(response.body)
end
def extract_user_email(key : String, id_token_payload : JSON::Any, userinfo : JSON::Any?)
provider = get_provider(key)
field = provider.field
# First try to get configured field from ID token and fallback to userinfo
if email = id_token_payload[field]?
return email.as_s
end
if userinfo && (email = userinfo[field]?)
return email.as_s
end
raise Exception.new("Could not extract email from OIDC response")
end
end
+159 -1
View File
@@ -19,7 +19,16 @@ module Invidious::Routes::Login
captcha = nil
account_type = env.params.query["type"]?
account_type ||= "invidious"
account_type ||= ""
if CONFIG.auth_type.size == 0
return error_template(401, "No authentication backend enabled.")
elsif CONFIG.auth_type.find(&.== account_type).nil? && CONFIG.auth_type.size == 1
account_type = CONFIG.auth_type[0]
end
captcha_type = env.params.query["captcha"]?
captcha_type ||= "image"
templated "user/login"
end
@@ -41,6 +50,22 @@ module Invidious::Routes::Login
account_type ||= "invidious"
case account_type
when "oidc"
provider_k = env.params.body["provider"]
state = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
nonce = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
env.response.cookies["OIDC_STATE"] = HTTP::Cookie.new(
name: "OIDC_STATE",
value: "#{state}:#{nonce}:#{provider_k}",
path: "/",
expires: Time.utc + 10.minutes,
secure: CONFIG.https_only == true,
http_only: true
)
authorization_url = OIDCHelper.get_authorization_url(provider_k, state, nonce)
env.redirect authorization_url
when "invidious"
if email.nil? || email.empty?
return error_template(401, "User ID is a required field")
@@ -171,4 +196,137 @@ module Invidious::Routes::Login
env.redirect referer
end
def self.oidc_callback(env)
locale = env.get("preferences").as(Preferences).locale
referer = get_referer(env, "/feed/subscriptions")
authorization_code = env.params.query["code"]?
state = env.params.query["state"]?
provider_k = env.params.url["provider"]
if authorization_code.nil?
return error_template(403, "Missing authorization code")
end
if state.nil?
return error_template(403, "Missing state parameter")
end
state_cookie = env.request.cookies["OIDC_STATE"]?
if !state_cookie
return error_template(403, "Missing state cookie")
end
state_parts = state_cookie.value.split(":")
if state_parts.size != 3 || state_parts[0] != state || state_parts[2] != provider_k
return error_template(403, "Invalid state")
end
stored_state, nonce, stored_provider = state_parts
env.response.cookies["OIDC_STATE"] = HTTP::Cookie.new(
name: "OIDC_STATE",
value: "",
path: "/",
expires: Time.utc(1990, 1, 1)
)
begin
token_response = OIDCHelper.exchange_code_for_tokens(provider_k, authorization_code)
id_token = token_response["id_token"]?.try(&.as_s)
access_token = token_response["access_token"]?.try(&.as_s)
if !id_token
return error_template(500, "No ID token received")
end
id_token_payload = OIDCHelper.verify_id_token(provider_k, id_token, nonce)
userinfo = nil
if access_token
userinfo = OIDCHelper.get_userinfo(provider_k, access_token)
end
email = OIDCHelper.extract_user_email(provider_k, id_token_payload, userinfo)
if user = Invidious::Database::Users.select(email: email)
if CONFIG.auth_enforce_source && user.password != ("oidc:" + provider_k)
return error_template(401, "Wrong authentication provider")
else
user_flow_existing(env, email)
end
else
user_flow_new(env, email, nil, "oidc:" + provider_k)
end
rescue ex
LOGGER.error("OIDC authentication error: #{ex.message}")
return error_template(500, "Authentication failed: #{ex.message}")
end
env.redirect referer
end
def self.user_flow_existing(env, email)
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email)
# Create SID cookie with correct path for all domains
env.response.cookies["SID"] = HTTP::Cookie.new(
name: "SID",
domain: CONFIG.domain == "localhost" ? nil : CONFIG.domain,
value: sid,
expires: Time.utc + 2.years,
secure: CONFIG.https_only || CONFIG.domain != "localhost",
http_only: true,
samesite: HTTP::Cookie::SameSite::Lax,
path: "/"
)
if env.request.cookies["PREFS"]?
cookie = env.request.cookies["PREFS"]
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
end
def self.user_flow_new(env, email, password, provider)
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
if provider.starts_with?("oidc:")
user, sid = create_oidc_user(sid, email, provider)
else
user, sid = create_user(sid, email, password)
end
if language_header = env.request.headers["Accept-Language"]?
if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
user.preferences.locale = language.header
end
end
Invidious::Database::Users.insert(user)
Invidious::Database::SessionIDs.insert(sid, email)
view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
# Create SID cookie with correct path for all domains
env.response.cookies["SID"] = HTTP::Cookie.new(
name: "SID",
domain: CONFIG.domain == "localhost" ? nil : CONFIG.domain,
value: sid,
expires: Time.utc + 2.years,
secure: CONFIG.https_only || CONFIG.domain != "localhost",
http_only: true,
samesite: HTTP::Cookie::SameSite::Lax,
path: "/"
)
if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences)
Invidious::Database::Users.update_preferences(user)
cookie = env.request.cookies["PREFS"]
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
end
end
+1
View File
@@ -56,6 +56,7 @@ module Invidious::Routing
def register_user_routes
# User login/out
get "/login", Routes::Login, :login_page
get "/login/oidc/:provider/callback", Routes::Login, :oidc_callback
post "/login", Routes::Login, :login
post "/signout", Routes::Login, :signout
+6 -1
View File
@@ -4,7 +4,7 @@ require "crypto/bcrypt/password"
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10)
password = Crypto::Bcrypt::Password.create(password.not_nil!, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = Invidious::User.new({
@@ -22,6 +22,11 @@ def create_user(sid, email, password)
return user, sid
end
def create_oidc_user(sid, email, provider)
password = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
create_user(sid, email, password)
end
def get_subscription_feed(user, max_results = 40, page = 1)
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
offset = (page - 1) * limit
+27 -1
View File
@@ -6,7 +6,32 @@
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<% case account_type when %>
<% if CONFIG.auth_type.size > 1 && account_type.empty? %>
<h3><%= translate(locale, "Log in") %></h3>
<p>Choose authentication method:</p>
<% if CONFIG.auth_internal_enabled? %>
<a href="/login?type=invidious&referer=<%= URI.encode_www_form(referer) %>" class="pure-button pure-button-primary" style="width: 100%; margin-bottom: 10px; display: block; text-align: center; text-decoration: none;">
Login with Invidious Account
</a>
<% end %>
<% if CONFIG.auth_oidc_enabled? %>
<a href="/login?type=oidc&referer=<%= URI.encode_www_form(referer) %>" class="pure-button pure-button-primary" style="width: 100%; margin-bottom: 10px; display: block; text-align: center; text-decoration: none;">
Login with OIDC
</a>
<% end %>
<% else %>
<% case account_type when %>
<% when "oidc" %>
<h3><%= translate(locale, "Log in") %></h3>
<p>Select an OIDC provider to log in:</p>
<% CONFIG.oidc.each do |provider_key, provider| %>
<form class="pure-form" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=oidc" method="post">
<input type="hidden" name="provider" value="<%= provider_key %>">
<button type="submit" class="pure-button pure-button-primary" style="width: 100%; margin-bottom: 10px;">
Login with <%= provider_key.capitalize %>
</button>
</form>
<% end %>
<% else # "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<fieldset>
@@ -43,6 +68,7 @@
<% end %>
</fieldset>
</form>
<% end %>
<% end %>
</div>
</div>