Setup SSO/OIDC

This commit is contained in:
2025-10-31 18:05:52 +01:00
committed by Alexandru Macocian
parent cf52a35366
commit 971cfc99d6
10 changed files with 519 additions and 11 deletions
+1
View File
@@ -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
+44 -5
View File
@@ -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:
+15 -3
View File
@@ -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
+3
View File
@@ -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:
+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>