From 06ca52c261299b1189fbd9419ab54d3742046b53 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Wed, 19 Aug 2026 22:00:14 -0500 Subject: [PATCH] add OIDC --- INSTALL.md | 3 +- approot/admin-internet-radio.xml | 49 -------------------- approot/main.xml | 2 - approot/messages.xml | 4 ++ approot/oidc-login.xml | 14 ++++++ approot/playqueue.xml | 1 - approot/radio.xml | 22 --------- conf/lms.conf | 14 +++++- src/lms/main.cpp | 37 +++++++++++++++- src/lms/ui/Auth.cpp | 76 ++++++++++++++++++++++++++++++++ src/lms/ui/Auth.hpp | 37 ++++++++++++++++ src/lms/ui/LmsApplication.cpp | 19 ++++++-- src/lms/ui/LmsApplication.hpp | 6 ++- 13 files changed, 201 insertions(+), 83 deletions(-) delete mode 100644 approot/admin-internet-radio.xml create mode 100644 approot/oidc-login.xml delete mode 100644 approot/radio.xml diff --git a/INSTALL.md b/INSTALL.md index 387a9dac..e46df88c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -108,6 +108,7 @@ You can define which authentication backend to be used thanks to the `authentica * `internal` (default): _LMS_ uses an internal database to store users and their associated passwords (salted and hashed using [Bcrypt](https://en.wikipedia.org/wiki/Bcrypt)). Only the admin user can create, edit or remove other users. * `PAM`: the user/password authentication request is forwarded to PAM. A default example PAM configuration file is provided at [`conf/pam/lms`](conf/pam/lms), which demonstrates how to authenticate using system passwords. **Note:** this file is just a base example; depending on your PAM setup, you may need to adjust the service file or PAM rules to ensure proper privileges. * `http-headers`: _LMS_ uses a configurable HTTP header field, typically set by a reverse proxy to handle [SSO](https://en.wikipedia.org/wiki/Single_sign-on), to extract the login name. You can customize the field to be used using the `http-headers-login-field` option. +* `oidc`: _LMS_ authenticates users through an OpenID Connect provider using the authorization code flow. Configure the client and provider endpoints with the `oidc-*` options in `lms.conf`, set `public-url`, and register `/oidc/callback` as the client redirect URI. Users are created automatically on first login; the first user is an administrator. By default the stable `sub` claim is used as the LMS login name; `email` (verified addresses only) and `name` are also supported through `oidc-login-name-claim`. __Note__: the first created user is the admin user. ### `internal` backend: reset admin password Open the the database file located in `/var/lms/lms.db` using `sqlite3`: @@ -179,4 +180,4 @@ __Note__: If you are using `PAM` or `http-header` authentication, this setup ass Once logged in as an administrator, you will need to define your libraries—that is, select the music folders you want to scan. Then, check that the scan settings meet your needs (scan frequency, tag delimiters, etc.). -You can now initiate the initial scan to import your music into _LMS_. \ No newline at end of file +You can now initiate the initial scan to import your music into _LMS_. diff --git a/approot/admin-internet-radio.xml b/approot/admin-internet-radio.xml deleted file mode 100644 index 8e2e0946..00000000 --- a/approot/admin-internet-radio.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - ${tr:Lms.Admin.InternetRadio.title} -

${tr:Lms.Admin.InternetRadio.help}

-
-
-
${name class="form-control"}
-
${stream-url class="form-control"}
-
${homepage-url class="form-control"}
-
${add-btn class="btn btn-primary"}
-
-
- ${stations class="d-grid gap-1 mb-3 Lms-row-container"} -
- - -
-
-
${name}
-
${stream-url}
-
${homepage-url}
-
-
${edit-btn class="btn btn-sm btn-outline-secondary border-0"}${delete-btn class="btn btn-sm btn-outline-danger border-0"}
-
-
- - - - - - - - - -
diff --git a/approot/main.xml b/approot/main.xml index 1a59816d..1f1b7d4d 100644 --- a/approot/main.xml +++ b/approot/main.xml @@ -35,7 +35,6 @@ ${podcasts class="nav-link"} ${filters class="d-flex align-items-center me-auto mb-2 mb-md-0"} @@ -49,7 +48,6 @@
  • ${media-libraries class="dropdown-item"}
  • ${admin-podcasts class="dropdown-item"}
  • ${admin-internet-radio class="dropdown-item"}
  • -
  • ${scan-settings class="dropdown-item"}
  • ${scanner class="dropdown-item"}
  • ${users class="dropdown-item"}
  • ${about class="dropdown-item"}
  • diff --git a/approot/messages.xml b/approot/messages.xml index fec2a695..eda2e605 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -213,6 +213,10 @@ Log in Remember me Welcome! +Log in with {1} +OpenID Connect authentication failed. +The identity provider did not verify your email address. +The identity provider did not return the configured login name. Add filter diff --git a/approot/oidc-login.xml b/approot/oidc-login.xml new file mode 100644 index 00000000..62959e8c --- /dev/null +++ b/approot/oidc-login.xml @@ -0,0 +1,14 @@ + + + +
    +
    +
    +

    ${tr:Lms.Auth.welcome}

    +
    ${login-btn class="btn btn-primary"}
    +
    ${error}
    +
    +
    +
    +
    +
    diff --git a/approot/playqueue.xml b/approot/playqueue.xml index 7400e9d1..abc0e612 100644 --- a/approot/playqueue.xml +++ b/approot/playqueue.xml @@ -14,7 +14,6 @@
    ${repeat-btn class="btn-check"} - ${radio-btn class="btn-check"}
    diff --git a/approot/radio.xml b/approot/radio.xml deleted file mode 100644 index fd624335..00000000 --- a/approot/radio.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ${tr:Lms.Radio.radio} -

    ${tr:Lms.Radio.description}

    - ${stations class="d-grid gap-2"} -
    - - -
    -
    -
    -
    ${name}
    - ${homepage class="small"} -
    - ${play-btn class="btn btn-primary flex-shrink-0"} -
    -
    -
    - -
    diff --git a/conf/lms.conf b/conf/lms.conf index 1dbd1dfd..a4adbb00 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -74,12 +74,24 @@ lastfm-submit-period-hours = 1; acousticbrainz-api-base-url = "https://acousticbrainz.org"; # Authentication -# Available backends: "internal", "PAM", "http-headers" +# Available backends: "internal", "PAM", "http-headers", "oidc" authentication-backend = "internal"; # The number of bcrypt rounds to be used when backend is set to "internal". The higher the more secure internal-password-bcrypt-round = 12; # The header to be used to read the authentication user when backend is set to "http-headers" http-headers-login-field = "X-Forwarded-User"; +# OpenID Connect settings (used when authentication-backend is "oidc"). +# public-url must also be set, and /oidc/callback must be registered +# as an allowed redirect URI at the identity provider. +oidc-client-id = ""; +oidc-client-secret = ""; +oidc-authorization-endpoint = ""; +oidc-token-endpoint = ""; +oidc-userinfo-endpoint = ""; +oidc-scope = "openid profile email"; +oidc-provider-name = "OpenID Connect"; +# Login name source: "sub" (stable provider subject), "email" (must be verified), or "name". +oidc-login-name-claim = "sub"; # Max entries in the login throttler (1 entry per IP address. For IPv6, the whole /64 block is used) login-throttler-max-entries = 10000; diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 9ddc665b..67614d60 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -21,6 +21,8 @@ #include #include +#include +#include #include #include #include @@ -83,6 +85,8 @@ namespace lms return ui::AuthenticationBackend::PAM; if (backend == "http-headers") return ui::AuthenticationBackend::Env; + if (backend == "oidc") + return ui::AuthenticationBackend::OIDC; throw core::LmsException{ "Invalid config value for 'authentication-backend'" }; } @@ -471,6 +475,9 @@ namespace lms core::Service authTokenService{ auth::createAuthTokenService(*database, config->getULong("login-throttler-max-entriees", 10'000)) }; core::Service authPasswordService; core::Service authEnvService; + Wt::Auth::AuthService oidcBaseAuthService; + std::unique_ptr oidcService; + std::string oidcLoginNameClaim{ config->getString("oidc-login-name-claim", "sub") }; authTokenService->registerDomain("ui", auth::IAuthTokenService::DomainParameters{ .tokenMaxUseCount = 1, @@ -493,6 +500,32 @@ namespace lms case ui::AuthenticationBackend::Env: authEnvService.assign(auth::createEnvService("http-headers", *database)); break; + case ui::AuthenticationBackend::OIDC: + { + if (oidcLoginNameClaim != "sub" && oidcLoginNameClaim != "email" && oidcLoginNameClaim != "name") + throw core::LmsException{ "Invalid config value for 'oidc-login-name-claim'" }; + + std::string publicUrl{ config->getString("public-url", "") }; + if (publicUrl.empty()) + throw core::LmsException{ "'public-url' must be configured when using the OIDC authentication backend" }; + while (publicUrl.size() > 1 && publicUrl.back() == '/') + publicUrl.pop_back(); + + ui::OIDCConfig oidcConfig{ + .clientId = std::string{ config->getString("oidc-client-id", "") }, + .clientSecret = std::string{ config->getString("oidc-client-secret", "") }, + .authorizationEndpoint = std::string{ config->getString("oidc-authorization-endpoint", "") }, + .tokenEndpoint = std::string{ config->getString("oidc-token-endpoint", "") }, + .userInfoEndpoint = std::string{ config->getString("oidc-userinfo-endpoint", "") }, + .redirectEndpoint = publicUrl + "/oidc/callback", + .scope = std::string{ config->getString("oidc-scope", "openid profile email") }, + .name = std::string{ config->getString("oidc-provider-name", "OpenID Connect") }, + }; + if (oidcConfig.clientId.empty() || oidcConfig.clientSecret.empty() || oidcConfig.authorizationEndpoint.empty() || oidcConfig.tokenEndpoint.empty() || oidcConfig.userInfoEndpoint.empty()) + throw core::LmsException{ "Incomplete OIDC configuration" }; + oidcService = ui::createOIDCService(oidcBaseAuthService, oidcConfig); + } + break; } image::init(argv[0]); @@ -534,8 +567,8 @@ namespace lms // bind UI entry point server.addEntryPoint(Wt::EntryPointType::Application, - [&database, &appManager, uiAuthenticationBackend, serverInstanceId](const Wt::WEnvironment& env) { - return ui::LmsApplication::create(env, *database, appManager, uiAuthenticationBackend, serverInstanceId); + [&database, &appManager, uiAuthenticationBackend, serverInstanceId, &oidcService, oidcLoginNameClaim](const Wt::WEnvironment& env) { + return ui::LmsApplication::create(env, *database, appManager, uiAuthenticationBackend, serverInstanceId, oidcService.get(), oidcLoginNameClaim); }); proxyScannerEventsToApplication(*scannerService, server); diff --git a/src/lms/ui/Auth.cpp b/src/lms/ui/Auth.cpp index 9715241f..5e4734de 100644 --- a/src/lms/ui/Auth.cpp +++ b/src/lms/ui/Auth.cpp @@ -20,6 +20,9 @@ #include "Auth.hpp" #include +#include +#include +#include #include #include @@ -185,6 +188,22 @@ namespace lms::ui core::Service::get()->clearAuthTokens(authTokenDomain, userId); } + std::unique_ptr createOIDCService(const Wt::Auth::AuthService& authService, const OIDCConfig& config) + { + auto service{ std::make_unique(authService) }; + service->setClientId(config.clientId); + service->setClientSecret(config.clientSecret); + service->setAuthEndpoint(config.authorizationEndpoint); + service->setTokenEndpoint(config.tokenEndpoint); + service->setUserInfoEndpoint(config.userInfoEndpoint); + service->setRedirectEndpoint(config.redirectEndpoint); + service->setAuthenticationScope(config.scope); + service->setName("oidc"); + service->setDescription(config.name); + service->setPopupEnabled(false); + return service; + } + PasswordAuth::PasswordAuth(auth::IPasswordService& passwordService) : Wt::WTemplateFormView{ Wt::WString::tr("Lms.Auth.template") } { @@ -234,4 +253,61 @@ namespace lms::ui updateView(model.get()); } + + OIDCAuth::OIDCAuth(const Wt::Auth::OidcService& service, std::string loginNameClaim) + : Wt::WTemplateFormView{ Wt::WString::tr("Lms.OIDCAuth.template") } + , _process{ service.createProcess(service.authenticationScope()) } + { + Wt::WPushButton* loginBtn{ bindNew("login-btn", Wt::WString::tr("Lms.OIDCAuth.login").arg(service.description())) }; + loginBtn->clicked().connect(_process.get(), &Wt::Auth::OAuthProcess::startAuthenticate); + + _process->authenticated().connect(this, [this, loginNameClaim = std::move(loginNameClaim)](const Wt::Auth::Identity& identity) { + if (!identity.isValid()) + { + bindString("error", Wt::WString::tr("Lms.OIDCAuth.failed")); + return; + } + + std::string loginName; + if (loginNameClaim == "email") + { + if (!identity.emailVerified()) + { + bindString("error", Wt::WString::tr("Lms.OIDCAuth.unverified-email")); + return; + } + loginName = identity.email(); + } + else if (loginNameClaim == "name") + loginName = identity.name().toUTF8(); + else + loginName = identity.id(); + + if (loginName.empty()) + { + bindString("error", Wt::WString::tr("Lms.OIDCAuth.missing-claim")); + return; + } + + db::User::pointer user; + { + auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; + user = db::User::find(LmsApp->getDbSession(), loginName); + if (!user) + { + const db::UserType type{ db::User::getCount(LmsApp->getDbSession()) == 0 ? db::UserType::ADMIN : db::UserType::REGULAR }; + user = LmsApp->getDbSession().create(loginName); + user.modify()->setType(type); + if (!identity.name().empty()) + user.modify()->setDisplayName(identity.name().toUTF8()); + } + user.modify()->setLastLogin(Wt::WDateTime::currentDateTime()); + } + userLoggedIn.emit(user->getId()); + }); + + bindEmpty("error"); + } + + OIDCAuth::~OIDCAuth() = default; } // namespace lms::ui diff --git a/src/lms/ui/Auth.hpp b/src/lms/ui/Auth.hpp index a0496d78..1f286add 100644 --- a/src/lms/ui/Auth.hpp +++ b/src/lms/ui/Auth.hpp @@ -21,6 +21,9 @@ #include +#include +#include + #include "database/objects/UserId.hpp" namespace lms::auth @@ -28,15 +31,37 @@ namespace lms::auth class IPasswordService; } +namespace Wt::Auth +{ + class AuthService; + class OAuthProcess; + class OidcService; +} + namespace lms::ui { enum class AuthenticationBackend { Internal, Env, + OIDC, PAM, }; + struct OIDCConfig + { + std::string clientId; + std::string clientSecret; + std::string authorizationEndpoint; + std::string tokenEndpoint; + std::string userInfoEndpoint; + std::string redirectEndpoint; + std::string scope; + std::string name; + }; + + std::unique_ptr createOIDCService(const Wt::Auth::AuthService& authService, const OIDCConfig& config); + db::UserId processAuthToken(const Wt::WEnvironment& env); class PasswordAuth : public Wt::WTemplateFormView @@ -46,4 +71,16 @@ namespace lms::ui Wt::Signal userLoggedIn; }; + + class OIDCAuth : public Wt::WTemplateFormView + { + public: + OIDCAuth(const Wt::Auth::OidcService& service, std::string loginNameClaim); + ~OIDCAuth() override; + + Wt::Signal userLoggedIn; + + private: + std::unique_ptr _process; + }; } // namespace lms::ui diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index f4758d38..09439a70 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -96,6 +96,7 @@ namespace lms::ui res->use(appRoot + "error"); res->use(appRoot + "explore"); res->use(appRoot + "login"); + res->use(appRoot + "oidc-login"); res->use(appRoot + "main"); res->use(appRoot + "mediaplayer"); res->use(appRoot + "messages"); @@ -151,9 +152,9 @@ namespace lms::ui } // namespace - std::unique_ptr LmsApplication::create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId) + std::unique_ptr LmsApplication::create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId, const Wt::Auth::OidcService* oidcService, std::string oidcLoginNameClaim) { - return std::make_unique(env, db, appManager, authBackend, serverInstanceId); + return std::make_unique(env, db, appManager, authBackend, serverInstanceId, oidcService, std::move(oidcLoginNameClaim)); } LmsApplication* LmsApplication::instance() @@ -161,11 +162,13 @@ namespace lms::ui return static_cast(Wt::WApplication::instance()); } - LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId) + LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId, const Wt::Auth::OidcService* oidcService, std::string oidcLoginNameClaim) : Wt::WApplication{ env } , _db{ db } , _appManager{ appManager } , _authBackend{ authBackend } + , _oidcService{ oidcService } + , _oidcLoginNameClaim{ std::move(oidcLoginNameClaim) } , _serverInstanceId{ serverInstanceId } , _areDownloadsEnabled(core::Service::get()->getBool("ui-allow-downloads", true)) { @@ -272,10 +275,20 @@ namespace lms::ui // Try to authenticate using auth token ("remember me" checkbox), may fail userId = processAuthToken(environment()); break; + case AuthenticationBackend::OIDC: + break; } if (userId.isValid()) onUserLoggedIn(userId, false /* strongAuth */); + else if (_authBackend == AuthenticationBackend::OIDC) + { + assert(_oidcService); + OIDCAuth* auth{ root()->addNew(*_oidcService, _oidcLoginNameClaim) }; + auth->userLoggedIn.connect(this, [this](db::UserId authenticatedUserId) { + onUserLoggedIn(authenticatedUserId, true /* strongAuth */); + }); + } else processPasswordAuth(); } diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index 54762743..ae04895a 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -55,10 +55,10 @@ namespace lms::ui class LmsApplication : public Wt::WApplication { public: - LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId); + LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId, const Wt::Auth::OidcService* oidcService = nullptr, std::string oidcLoginNameClaim = "sub"); ~LmsApplication() override; - static std::unique_ptr create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId); + static std::unique_ptr create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId, const Wt::Auth::OidcService* oidcService = nullptr, std::string oidcLoginNameClaim = "sub"); static LmsApplication* instance(); // Session application data @@ -115,6 +115,8 @@ namespace lms::ui Wt::Signal<> _preQuit; LmsApplicationManager& _appManager; const AuthenticationBackend _authBackend; + const Wt::Auth::OidcService* const _oidcService; + const std::string _oidcLoginNameClaim; const core::UUID _serverInstanceId; const bool _areDownloadsEnabled; scanner::Events _scannerEvents;