add OIDC
Basic Build (Arch) / Build (push) Waiting to run
clang-format Check / Formatting Check (push) Waiting to run

This commit is contained in:
2026-08-19 22:00:14 -05:00
parent bf2fa21e95
commit 06ca52c261
13 changed files with 201 additions and 83 deletions
+2 -1
View File
@@ -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 `<public-url>/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_.
You can now initiate the initial scan to import your music into _LMS_.
-49
View File
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages>
<message id="Lms.Admin.InternetRadio.template">
<legend>${tr:Lms.Admin.InternetRadio.title}</legend>
<p class="text-body-secondary">${tr:Lms.Admin.InternetRadio.help}</p>
<div class="card mb-4"><div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-3"><label class="form-label">${tr:Lms.Admin.InternetRadio.name}</label>${name class="form-control"}</div>
<div class="col-12 col-lg-4"><label class="form-label">${tr:Lms.Admin.InternetRadio.stream-url}</label>${stream-url class="form-control"}</div>
<div class="col-12 col-lg-4"><label class="form-label">${tr:Lms.Admin.InternetRadio.homepage-url}</label>${homepage-url class="form-control"}</div>
<div class="col-12 col-lg-1 d-grid">${add-btn class="btn btn-primary"}</div>
</div>
</div></div>
${stations class="d-grid gap-1 mb-3 Lms-row-container"}
</message>
<message id="Lms.Admin.InternetRadio.template.entry">
<div class="d-flex align-items-center Lms-bg-dark-hover">
<div class="row align-items-center flex-fill overflow-hidden">
<div class="col-12 col-md-3"><div class="p-2 fw-semibold text-truncate">${name}</div></div>
<div class="col-12 col-md-5"><div class="p-2 text-truncate">${stream-url}</div></div>
<div class="col-12 col-md-4"><div class="p-2 text-body-secondary text-truncate">${homepage-url}</div></div>
</div>
<div class="p-2 d-flex gap-1">${edit-btn class="btn btn-sm btn-outline-secondary border-0"}${delete-btn class="btn btn-sm btn-outline-danger border-0"}</div>
</div>
</message>
<message id="Lms.Admin.InternetRadio.template.edit">
<div class="modal fade" tabindex="-1"><div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">${tr:Lms.Admin.InternetRadio.edit}</h5></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label">${tr:Lms.Admin.InternetRadio.name}</label>${name class="form-control"}</div>
<div class="mb-3"><label class="form-label">${tr:Lms.Admin.InternetRadio.stream-url}</label>${stream-url class="form-control"}</div>
<div><label class="form-label">${tr:Lms.Admin.InternetRadio.homepage-url}</label>${homepage-url class="form-control"}</div>
</div>
<div class="modal-footer">${save-btn class="btn btn-primary"}${cancel-btn class="btn btn-secondary"}</div>
</div></div></div>
</message>
<message id="Lms.Admin.InternetRadio.template.delete">
<div class="modal fade" tabindex="-1"><div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">${tr:Lms.Admin.InternetRadio.delete-confirm}</h5></div>
<div class="modal-body">${tr:Lms.Admin.InternetRadio.delete-help}</div>
<div class="modal-footer">${delete-btn class="btn btn-danger"}${cancel-btn class="btn btn-secondary"}</div>
</div></div></div>
</message>
</messages>
-2
View File
@@ -35,7 +35,6 @@
${podcasts class="nav-link"}
</li>
<li class="nav-item">
${radio class="nav-link"}
</li>
</ul>
${filters class="d-flex align-items-center me-auto mb-2 mb-md-0"}
@@ -49,7 +48,6 @@
<li>${media-libraries class="dropdown-item"}</li>
<li>${admin-podcasts class="dropdown-item"}</li>
<li>${admin-internet-radio class="dropdown-item"}</li>
<li>${scan-settings class="dropdown-item"}</li>
<li>${scanner class="dropdown-item"}</li>
<li>${users class="dropdown-item"}</li>
<li>${about class="dropdown-item"}</li>
+4
View File
@@ -213,6 +213,10 @@
<message id="Lms.Auth.login">Log in</message>
<message id="Lms.Auth.remember-me">Remember me</message>
<message id="Lms.Auth.welcome">Welcome!</message>
<message id="Lms.OIDCAuth.login">Log in with {1}</message>
<message id="Lms.OIDCAuth.failed">OpenID Connect authentication failed.</message>
<message id="Lms.OIDCAuth.unverified-email">The identity provider did not verify your email address.</message>
<message id="Lms.OIDCAuth.missing-claim">The identity provider did not return the configured login name.</message>
<!--Explore-->
<message id="Lms.Explore.add-filter">Add filter</message>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages>
<message id="Lms.OIDCAuth.template">
<div class="container">
<div class="row mt-3 justify-content-md-center">
<div class="col-lg-6">
<h2>${tr:Lms.Auth.welcome}</h2>
<div class="mt-3">${login-btn class="btn btn-primary"}</div>
<div class="text-danger mt-3">${error}</div>
</div>
</div>
</div>
</message>
</messages>
-1
View File
@@ -14,7 +14,6 @@
<div class="btn-group me-2">
${repeat-btn class="btn-check"}
<label class="btn btn-outline-info" title="${tr:Lms.PlayQueue.repeat}" for="${id:repeat-btn}">${tr:Lms.PlayQueue.template.repeat-btn}</label>
${radio-btn class="btn-check"}
<label class="btn btn-outline-info" title="${tr:Lms.PlayQueue.radio-mode}" for="${id:radio-btn}">${tr:Lms.PlayQueue.template.radio-btn}</label>
</div>
<div class="d-inline-block me-2" title="${tr:Lms.save}">
-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages>
<message id="Lms.Radio.template">
<legend class="mb-3">${tr:Lms.Radio.radio}</legend>
<p class="text-body-secondary">${tr:Lms.Radio.description}</p>
${stations class="d-grid gap-2"}
</message>
<message id="Lms.Radio.template.station">
<div class="card w-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="flex-fill min-width-0">
<h5 class="card-title text-truncate mb-1">${name}</h5>
${homepage class="small"}
</div>
${play-btn class="btn btn-primary flex-shrink-0"}
</div>
</div>
</message>
</messages>
+13 -1
View File
@@ -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 <public-url>/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;
+35 -2
View File
@@ -21,6 +21,8 @@
#include <thread>
#include <Wt/WApplication.h>
#include <Wt/Auth/AuthService.h>
#include <Wt/Auth/OidcService.h>
#include <Wt/WLogSink.h>
#include <Wt/WServer.h>
#include <boost/asio/io_context.hpp>
@@ -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<auth::IAuthTokenService> authTokenService{ auth::createAuthTokenService(*database, config->getULong("login-throttler-max-entriees", 10'000)) };
core::Service<auth::IPasswordService> authPasswordService;
core::Service<auth::IEnvService> authEnvService;
Wt::Auth::AuthService oidcBaseAuthService;
std::unique_ptr<Wt::Auth::OidcService> 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);
+76
View File
@@ -20,6 +20,9 @@
#include "Auth.hpp"
#include <Wt/Auth/HashFunction.h>
#include <Wt/Auth/AuthService.h>
#include <Wt/Auth/Identity.h>
#include <Wt/Auth/OidcService.h>
#include <Wt/WCheckBox.h>
#include <Wt/WEnvironment.h>
@@ -185,6 +188,22 @@ namespace lms::ui
core::Service<auth::IAuthTokenService>::get()->clearAuthTokens(authTokenDomain, userId);
}
std::unique_ptr<Wt::Auth::OidcService> createOIDCService(const Wt::Auth::AuthService& authService, const OIDCConfig& config)
{
auto service{ std::make_unique<Wt::Auth::OidcService>(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<Wt::WPushButton>("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<db::User>(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
+37
View File
@@ -21,6 +21,9 @@
#include <Wt/WTemplateFormView.h>
#include <memory>
#include <string>
#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<Wt::Auth::OidcService> 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<db::UserId> userLoggedIn;
};
class OIDCAuth : public Wt::WTemplateFormView
{
public:
OIDCAuth(const Wt::Auth::OidcService& service, std::string loginNameClaim);
~OIDCAuth() override;
Wt::Signal<db::UserId> userLoggedIn;
private:
std::unique_ptr<Wt::Auth::OAuthProcess> _process;
};
} // namespace lms::ui
+16 -3
View File
@@ -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<Wt::WApplication> LmsApplication::create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId)
std::unique_ptr<Wt::WApplication> 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<LmsApplication>(env, db, appManager, authBackend, serverInstanceId);
return std::make_unique<LmsApplication>(env, db, appManager, authBackend, serverInstanceId, oidcService, std::move(oidcLoginNameClaim));
}
LmsApplication* LmsApplication::instance()
@@ -161,11 +162,13 @@ namespace lms::ui
return static_cast<LmsApplication*>(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<core::IConfig>::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<OIDCAuth>(*_oidcService, _oidcLoginNameClaim) };
auth->userLoggedIn.connect(this, [this](db::UserId authenticatedUserId) {
onUserLoggedIn(authenticatedUserId, true /* strongAuth */);
});
}
else
processPasswordAuth();
}
+4 -2
View File
@@ -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<Wt::WApplication> create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId);
static std::unique_ptr<Wt::WApplication> 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;