Setup SSO/OIDC
This commit is contained in:
@@ -79,6 +79,7 @@
|
|||||||
- Audio-only mode (with background play on mobile)
|
- Audio-only mode (with background play on mobile)
|
||||||
- Support for Reddit comments
|
- Support for Reddit comments
|
||||||
- [Available in many languages](locales/), thanks to [our translators](#contribute)
|
- [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**
|
**Data import/export**
|
||||||
- Import subscriptions from YouTube, NewPipe and FreeTube
|
- Import subscriptions from YouTube, NewPipe and FreeTube
|
||||||
|
|||||||
+44
-5
@@ -18,6 +18,8 @@ services:
|
|||||||
invidious-db:
|
invidious-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
|
companion:
|
||||||
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
# Please read the following file for a comprehensive list of all available
|
# Please read the following file for a comprehensive list of all available
|
||||||
# configuration options and their associated syntax:
|
# configuration options and their associated syntax:
|
||||||
@@ -30,17 +32,53 @@ services:
|
|||||||
host: invidious-db
|
host: invidious-db
|
||||||
port: 5432
|
port: 5432
|
||||||
check_tables: true
|
check_tables: true
|
||||||
# external_port:
|
domain: localhost
|
||||||
# domain:
|
external_port: 3000
|
||||||
# https_only: false
|
https_only: false
|
||||||
# statistics_enabled: false
|
hmac_key: "localhost-test-hmac-key-change-me-in-production"
|
||||||
hmac_key: "CHANGE_ME!!"
|
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:
|
healthcheck:
|
||||||
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/stats || exit 1
|
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/stats || exit 1
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 2
|
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:
|
invidious-db:
|
||||||
image: docker.io/library/postgres:14
|
image: docker.io/library/postgres:14
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -57,3 +95,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgresdata:
|
postgresdata:
|
||||||
|
companioncache:
|
||||||
|
|||||||
+15
-3
@@ -2,15 +2,19 @@ version: 2.0
|
|||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
git: https://github.com/crystal-ameba/ameba.git
|
git: https://github.com/crystal-ameba/ameba.git
|
||||||
version: 1.6.1
|
version: 1.6.4
|
||||||
|
|
||||||
athena-negotiation:
|
athena-negotiation:
|
||||||
git: https://github.com/athena-framework/negotiation.git
|
git: https://github.com/athena-framework/negotiation.git
|
||||||
version: 0.1.1
|
version: 0.1.5
|
||||||
|
|
||||||
backtracer:
|
backtracer:
|
||||||
git: https://github.com/sija/backtracer.cr.git
|
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:
|
db:
|
||||||
git: https://github.com/crystal-lang/crystal-db.git
|
git: https://github.com/crystal-lang/crystal-db.git
|
||||||
@@ -24,10 +28,18 @@ shards:
|
|||||||
git: https://github.com/mamantoha/http_proxy.git
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
version: 0.10.3
|
version: 0.10.3
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
git: https://github.com/crystal-community/jwt.git
|
||||||
|
version: 1.6.1
|
||||||
|
|
||||||
kemal:
|
kemal:
|
||||||
git: https://github.com/kemalcr/kemal.git
|
git: https://github.com/kemalcr/kemal.git
|
||||||
version: 1.6.0
|
version: 1.6.0
|
||||||
|
|
||||||
|
openssl_ext:
|
||||||
|
git: https://github.com/spider-gazelle/openssl_ext.git
|
||||||
|
version: 2.8.1
|
||||||
|
|
||||||
pg:
|
pg:
|
||||||
git: https://github.com/will/crystal-pg.git
|
git: https://github.com/will/crystal-pg.git
|
||||||
version: 0.28.0
|
version: 0.28.0
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ dependencies:
|
|||||||
http_proxy:
|
http_proxy:
|
||||||
github: mamantoha/http_proxy
|
github: mamantoha/http_proxy
|
||||||
version: ~> 0.10.3
|
version: ~> 0.10.3
|
||||||
|
jwt:
|
||||||
|
github: crystal-community/jwt
|
||||||
|
version: ~> 1.6.0
|
||||||
|
|
||||||
development_dependencies:
|
development_dependencies:
|
||||||
spectator:
|
spectator:
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ struct DBConfig
|
|||||||
property dbname : String
|
property dbname : String
|
||||||
end
|
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
|
struct SocketBindingConfig
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@@ -177,6 +192,11 @@ class Config
|
|||||||
@[YAML::Field(converter: Preferences::StringToCookies)]
|
@[YAML::Field(converter: Preferences::StringToCookies)]
|
||||||
property cookies : HTTP::Cookies = HTTP::Cookies.new
|
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
|
# Playlist length limit
|
||||||
property playlist_length_limit : Int32 = 500
|
property playlist_length_limit : Int32 = 500
|
||||||
|
|
||||||
@@ -195,6 +215,14 @@ class Config
|
|||||||
end
|
end
|
||||||
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
|
def self.load
|
||||||
# Load config from file or YAML string env var
|
# Load config from file or YAML string env var
|
||||||
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
env_config_file = "INVIDIOUS_CONFIG_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
|
||||||
@@ -19,7 +19,16 @@ module Invidious::Routes::Login
|
|||||||
captcha = nil
|
captcha = nil
|
||||||
|
|
||||||
account_type = env.params.query["type"]?
|
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"
|
templated "user/login"
|
||||||
end
|
end
|
||||||
@@ -41,6 +50,22 @@ module Invidious::Routes::Login
|
|||||||
account_type ||= "invidious"
|
account_type ||= "invidious"
|
||||||
|
|
||||||
case account_type
|
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"
|
when "invidious"
|
||||||
if email.nil? || email.empty?
|
if email.nil? || email.empty?
|
||||||
return error_template(401, "User ID is a required field")
|
return error_template(401, "User ID is a required field")
|
||||||
@@ -171,4 +196,137 @@ module Invidious::Routes::Login
|
|||||||
|
|
||||||
env.redirect referer
|
env.redirect referer
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ module Invidious::Routing
|
|||||||
def register_user_routes
|
def register_user_routes
|
||||||
# User login/out
|
# User login/out
|
||||||
get "/login", Routes::Login, :login_page
|
get "/login", Routes::Login, :login_page
|
||||||
|
get "/login/oidc/:provider/callback", Routes::Login, :oidc_callback
|
||||||
post "/login", Routes::Login, :login
|
post "/login", Routes::Login, :login
|
||||||
post "/signout", Routes::Login, :signout
|
post "/signout", Routes::Login, :signout
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
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)
|
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))
|
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||||
|
|
||||||
user = Invidious::User.new({
|
user = Invidious::User.new({
|
||||||
@@ -22,6 +22,11 @@ def create_user(sid, email, password)
|
|||||||
return user, sid
|
return user, sid
|
||||||
end
|
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)
|
def get_subscription_feed(user, max_results = 40, page = 1)
|
||||||
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
|
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|||||||
@@ -6,7 +6,32 @@
|
|||||||
<div class="pure-u-1 pure-u-lg-1-5"></div>
|
<div class="pure-u-1 pure-u-lg-1-5"></div>
|
||||||
<div class="pure-u-1 pure-u-lg-3-5">
|
<div class="pure-u-1 pure-u-lg-3-5">
|
||||||
<div class="h-box">
|
<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" %>
|
<% else # "invidious" %>
|
||||||
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
|
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -43,6 +68,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user