Setup SSO/OIDC
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user