From 971cfc99d63fe6ed532d10db8a4a36fa534c60c6 Mon Sep 17 00:00:00 2001 From: Alex Macocian Date: Fri, 31 Oct 2025 18:05:52 +0100 Subject: [PATCH] Setup SSO/OIDC --- README.md | 1 + docker-compose.yml | 49 +++++- shard.lock | 18 ++- shard.yml | 3 + src/invidious/config.cr | 28 ++++ src/invidious/helpers/oidc.cr | 235 +++++++++++++++++++++++++++++ src/invidious/routes/login.cr | 160 +++++++++++++++++++- src/invidious/routing.cr | 1 + src/invidious/users.cr | 7 +- src/invidious/views/user/login.ecr | 28 +++- 10 files changed, 519 insertions(+), 11 deletions(-) create mode 100644 src/invidious/helpers/oidc.cr diff --git a/README.md b/README.md index 97d2109b..74065e31 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ - Audio-only mode (with background play on mobile) - Support for Reddit comments - [Available in many languages](locales/), thanks to [our translators](#contribute) +- Support for SSO/OIDC (check docker-compose.yml for an example on how to configure SSO flow) **Data import/export** - Import subscriptions from YouTube, NewPipe and FreeTube diff --git a/docker-compose.yml b/docker-compose.yml index cb53bdd6..b685ceb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,8 @@ services: invidious-db: condition: service_healthy restart: true + companion: + condition: service_started environment: # Please read the following file for a comprehensive list of all available # configuration options and their associated syntax: @@ -30,17 +32,53 @@ services: host: invidious-db port: 5432 check_tables: true - # external_port: - # domain: - # https_only: false - # statistics_enabled: false - hmac_key: "CHANGE_ME!!" + domain: localhost + external_port: 3000 + https_only: false + hmac_key: "localhost-test-hmac-key-change-me-in-production" + invidious_companion: + - private_url: "http://companion:8282/companion" + invidious_companion_key: "localhosttest123" + popular_enabled: false + top_enabled: false + feed_menu: [] + default_home: Search + auth_type: ["invidious"] + + # Below multiple authentication types can be defined + # The oidc authentication requires additional oidc configuration per oidc provider. OIDC provider names are specified in the configuration subtree + # Issuer url is the base url (without the .well-known/openid-configuration). + # OIDC handler expects your provider to have a redirect url in the format of {invidious_domain}/login/oidc/{oidc_name}/callback + + # auth_type: ["invidious", "oidc"] + # auth_enforce_source: true + # OIDC configuration + # oidc: + # my-oidc: + # issuer: "ISSUER_URL!!" + # client_id: "CLIENT_ID!!" + # client_secret: "CLIENT_SECRET!!" + # field: "email" + # scopes: ["openid", "email", "profile"] healthcheck: test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/stats || exit 1 interval: 30s timeout: 5s retries: 2 + companion: + image: quay.io/invidious/invidious-companion:latest + restart: unless-stopped + environment: + - SERVER_SECRET_KEY=localhosttest123 + cap_drop: + - ALL + read_only: true + security_opt: + - no-new-privileges:true + volumes: + - companioncache:/var/tmp/youtubei.js:rw + invidious-db: image: docker.io/library/postgres:14 restart: unless-stopped @@ -57,3 +95,4 @@ services: volumes: postgresdata: + companioncache: diff --git a/shard.lock b/shard.lock index 1265eda6..73080c65 100644 --- a/shard.lock +++ b/shard.lock @@ -2,15 +2,19 @@ version: 2.0 shards: ameba: git: https://github.com/crystal-ameba/ameba.git - version: 1.6.1 + version: 1.6.4 athena-negotiation: git: https://github.com/athena-framework/negotiation.git - version: 0.1.1 + version: 0.1.5 backtracer: git: https://github.com/sija/backtracer.cr.git - version: 1.2.2 + version: 1.2.4 + + bindata: + git: https://github.com/spider-gazelle/bindata.git + version: 2.1.1 db: git: https://github.com/crystal-lang/crystal-db.git @@ -24,10 +28,18 @@ shards: git: https://github.com/mamantoha/http_proxy.git version: 0.10.3 + jwt: + git: https://github.com/crystal-community/jwt.git + version: 1.6.1 + kemal: git: https://github.com/kemalcr/kemal.git version: 1.6.0 + openssl_ext: + git: https://github.com/spider-gazelle/openssl_ext.git + version: 2.8.1 + pg: git: https://github.com/will/crystal-pg.git version: 0.28.0 diff --git a/shard.yml b/shard.yml index bc6c4bf4..f3bc9dae 100644 --- a/shard.yml +++ b/shard.yml @@ -27,6 +27,9 @@ dependencies: http_proxy: github: mamantoha/http_proxy version: ~> 0.10.3 + jwt: + github: crystal-community/jwt + version: ~> 1.6.0 development_dependencies: spectator: diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 7853d9a3..2ab0504d 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -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" diff --git a/src/invidious/helpers/oidc.cr b/src/invidious/helpers/oidc.cr new file mode 100644 index 00000000..a97fb1f5 --- /dev/null +++ b/src/invidious/helpers/oidc.cr @@ -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 \ No newline at end of file diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 674f0a46..321ef1f3 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -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 diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index a51bb4b6..e777c6e7 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -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 diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 65566d20..af250efc 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -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 diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 7ac96bc6..dd8648b7 100644 --- a/src/invidious/views/user/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -6,7 +6,32 @@
- <% case account_type when %> + <% if CONFIG.auth_type.size > 1 && account_type.empty? %> +

<%= translate(locale, "Log in") %>

+

Choose authentication method:

+ <% if CONFIG.auth_internal_enabled? %> + + Login with Invidious Account + + <% end %> + <% if CONFIG.auth_oidc_enabled? %> + + Login with OIDC + + <% end %> + <% else %> + <% case account_type when %> + <% when "oidc" %> +

<%= translate(locale, "Log in") %>

+

Select an OIDC provider to log in:

+ <% CONFIG.oidc.each do |provider_key, provider| %> +
+ + +
+ <% end %> <% else # "invidious" %>
@@ -43,6 +68,7 @@ <% end %>
+ <% end %> <% end %>