nextcloud: fix ldap and sso integration and add playwright tests

This commit is contained in:
ibizaman 2025-08-22 23:09:08 +02:00 committed by Pierre Penninckx
parent 7717e39671
commit ebd39bbe73
6 changed files with 221 additions and 30 deletions

View file

@ -77,6 +77,15 @@
"blocks-authelia-options-shb.authelia.enable": [
"blocks-authelia.html#blocks-authelia-options-shb.authelia.enable"
],
"blocks-authelia-options-shb.authelia.extraDefinitions": [
"blocks-authelia.html#blocks-authelia-options-shb.authelia.extraDefinitions"
],
"blocks-authelia-options-shb.authelia.extraOidcClaimsPolicies": [
"blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcClaimsPolicies"
],
"blocks-authelia-options-shb.authelia.extraOidcScopes": [
"blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcScopes"
],
"blocks-authelia-options-shb.authelia.ldapHostname": [
"blocks-authelia.html#blocks-authelia-options-shb.authelia.ldapHostname"
],
@ -3041,6 +3050,9 @@
"services-nextcloudserver-options-shb.nextcloud.apps.sso": [
"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso"
],
"services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup": [
"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup"
],
"services-nextcloudserver-options-shb.nextcloud.apps.sso.authorization_policy": [
"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.authorization_policy"
],

View file

@ -140,6 +140,24 @@ in
};
};
extraOidcClaimsPolicies = lib.mkOption {
description = "Extra OIDC claims policies.";
type = lib.types.attrsOf lib.types.attrs;
default = {};
};
extraOidcScopes = lib.mkOption {
description = "Extra OIDC scopes.";
type = lib.types.attrsOf lib.types.attrs;
default = {};
};
extraDefinitions = lib.mkOption {
description = "Extra definitions.";
type = lib.types.attrsOf lib.types.attrs;
default = {};
};
oidcClients = lib.mkOption {
description = "OIDC clients";
default = [
@ -205,13 +223,13 @@ in
scopes = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Scopes to ask for";
description = "Scopes to ask for. See https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims";
example = [ "openid" "profile" "email" "groups" ];
default = [];
};
claims_policy = lib.mkOption {
type = lib.types.str;
type = lib.types.nullOr lib.types.str;
description = ''
Claim policy.
@ -387,11 +405,6 @@ in
user = "uid=admin,ou=people,${cfg.dcdomain}";
};
};
# This should go away at some point.
# https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter
identity_providers.oidc = {
claims_policies.default.id_token = [ "email" "preferred_username" "name" "groups" ];
};
totp = {
disable = "false";
issuer = fqdnWithPort;
@ -465,6 +478,17 @@ in
};
log.level = if cfg.debug then "debug" else "info";
} // {
identity_providers.oidc = {
claims_policies = {
# This default claim should go away at some point.
# https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter
default.id_token = [ "email" "preferred_username" "name" "groups" ];
} // cfg.extraOidcClaimsPolicies;
scopes = cfg.extraOidcScopes;
};
} // lib.optionalAttrs (cfg.extraDefinitions != {}) {
definitions = cfg.extraDefinitions;
};
settingsFiles = [ "/var/lib/authelia-${fqdn}/oidc_clients.yaml" ];

View file

@ -414,7 +414,6 @@ in
};
};
userGroup = lib.mkOption {
type = lib.types.str;
description = "Group users must belong to to be able to login to Nextcloud.";
@ -424,8 +423,8 @@ in
configID = lib.mkOption {
type = lib.types.int;
description = ''
Multiple LDAP configs can co-exist with only one active at a time.This option
sets the config ID used by Self Host Blocks.
Multiple LDAP configs can co-exist with only one active at a time.
This option sets the config ID used by Self Host Blocks.
'';
default = 50;
};
@ -475,6 +474,12 @@ in
default = "one_factor";
};
adminGroup = lib.mkOption {
type = lib.types.str;
description = "Group admins must belong to to be able to login to Nextcloud.";
default = "nextcloud_admin";
};
secret = lib.mkOption {
description = "OIDC shared secret.";
type = lib.types.submodule {
@ -1022,7 +1027,7 @@ in
${occ} ldap:set-config "${cID}" 'ldapLoginFilterAttributes' \
'mail;objectclass'
${occ} ldap:set-config "${cID}" 'ldapUserDisplayName' \
'displayname'
'givenname'
${occ} ldap:set-config "${cID}" 'ldapUserFilter' \
'(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))'
${occ} ldap:set-config "${cID}" 'ldapUserFilterMode' \
@ -1033,7 +1038,7 @@ in
# Nextcloud is compatible with the one returned by a (possibly added in the future) SSO
# provider.
${occ} ldap:set-config "${cID}" 'ldapExpertUsernameAttr' \
'user_id'
'uid'
${occ} ldap:test-config -- "${cID}"
@ -1057,6 +1062,7 @@ in
"profile"
"email"
"groups"
"nextcloud_userinfo"
];
in lib.mkIf (cfg.enable && cfg.apps.sso.enable) {
assertions = [
@ -1070,11 +1076,9 @@ in
}
];
systemd.services.nextcloud-setup.script =
''
${occ} app:install oidc_login || :
${occ} app:enable oidc_login
'';
services.nextcloud.extraApps = {
inherit (nextcloudApps) oidc_login;
};
systemd.services.nextcloud-setup-pre = {
wantedBy = [ "multi-user.target" ];
@ -1096,6 +1100,12 @@ in
secretFile = "${cfg.dataDir}/config/secretFile";
# See all options at https://github.com/pulsejet/nextcloud-oidc-login
# Other important url/links are:
# ${fqdn}/.well-known/openid-configuration
# https://www.authelia.com/reference/guides/attributes/#custom-attributes
# https://github.com/lldap/lldap/blob/main/example_configs/nextcloud_oidc_authelia.md
# https://www.authelia.com/integration/openid-connect/nextcloud/#authelia
# https://www.openidconnect.net/
settings = {
allow_user_to_change_display_name = false;
lost_password_link = "disabled";
@ -1110,12 +1120,14 @@ in
oidc_login_logout_url = ssoFqdnWithPort;
oidc_login_button_text = "Log in with ${cfg.apps.sso.provider}";
oidc_login_hide_password_form = false;
oidc_login_use_id_token = true;
# Now, Authelia provides the info using the UserInfo request.
oidc_login_use_id_token = false;
oidc_login_attributes = {
id = "preferred_username";
name = "name";
mail = "email";
groups = "groups";
is_admin = "is_nextcloud_admin";
};
oidc_login_default_group = "oidc";
oidc_login_use_external_storage = false;
@ -1136,8 +1148,7 @@ in
# they are not already existing, Default is `false`. This creates groups for all groups
# the user is associated with in LDAP. It's too much.
oidc_create_groups = false;
# Enable use of WebDAV via OIDC bearer token.
oidc_login_webdav_enabled = true;
oidc_login_webdav_enabled = false;
oidc_login_password_authentication = false;
oidc_login_public_key_caching_time = 86400;
oidc_login_min_time_between_jwks_requests = 10;
@ -1145,19 +1156,39 @@ in
# If true, nextcloud will download user avatars on login. This may lead to security issues
# as the server does not control which URLs will be requested. Use with care.
oidc_login_update_avatar = false;
oidc_login_code_challenge_method = "S256";
};
};
shb.authelia.extraDefinitions = {
user_attributes."is_nextcloud_admin".expression = ''type(groups) == list && "${cfg.apps.sso.adminGroup}" in groups'';
};
shb.authelia.extraOidcClaimsPolicies."nextcloud_userinfo" = {
custom_claims = {
is_nextcloud_admin = {};
};
};
shb.authelia.extraOidcScopes."nextcloud_userinfo" = {
claims = [ "is_nextcloud_admin" ];
};
shb.authelia.oidcClients = lib.mkIf (cfg.apps.sso.provider == "Authelia") [
{
client_id = cfg.apps.sso.clientID;
client_name = "Nextcloud";
client_secret.source = cfg.apps.sso.secretForAuthelia.result.path;
claims_policy = "nextcloud_userinfo";
public = false;
authorization_policy = cfg.apps.sso.authorization_policy;
require_pkce = "true";
pkce_challenge_method = "S256";
redirect_uris = [ "${protocol}://${fqdnWithPort}/apps/oidc_login/oidc" ];
inherit scopes;
userinfo_signing_algorithm = "none";
response_types = [ "code" ];
grant_types = [ "authorization_code" ];
access_token_signed_response_alg = "none";
userinfo_signed_response_alg = "none";
token_endpoint_auth_method = "client_secret_basic";
}
];
})

View file

@ -179,10 +179,9 @@ to do that with SOPS we use the `key` option so that both
and `sops.secrets."nextcloud/ldapUserPassword"`
secrets have the same content.
Creating LDAP users and groups is not declarative yet,
so go to the LDAP server at `http://ldap.example.com`,
create the `nextcloud_user` group,
create a user and add it to the group.
The LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) is created automatically.
Add your user to it by going to `http://ldap.example.com`,
create a user if needed and add it to the group.
When that's done, go back to the Nextcloud server at
`https://nextcloud.example.com` and login with that user.
@ -190,6 +189,7 @@ Note that we cannot create an admin user from the LDAP server,
so you need to create a normal user like above,
login with it once so it is known to Nextcloud, then logout,
login with the admin Nextcloud user and promote that new user to admin level.
This limitation does not exist with the [SSO integration](#services-nextcloudserver-usage-oidc).
### With SSO Support {#services-nextcloudserver-usage-oidc}
@ -231,6 +231,12 @@ to do that with SOPS we use the `key` option so that both
and `sops.secrets."nextcloud/sso/secretForAuthelia"`
secrets have the same content.
The LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) and [admin group](#services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup) are created automatically.
Add your user to one or both by going to `http://ldap.example.com`,
create a user if needed and add it to the groups.
When that's done, go back to the Nextcloud server at
`https://nextcloud.example.com` and login with that user.
Setting the `fallbackDefaultAuth` to `false` means the only way to login is through Authelia.
If this does not work for any reason, you can let users login through Nextcloud directly by setting this option to `true`.

View file

@ -198,6 +198,10 @@ in
# HTTP(s) server port.
networking.firewall.allowedTCPPorts = [ 80 443 ];
shb.nginx.accessLog = true;
networking.hosts = {
"192.168.1.2" = [ config.test.fqdn ];
};
};
};
@ -415,6 +419,11 @@ in
groups = [ "user_group" "admin_group" ];
password.result.path = pkgs.writeText "bobPassword" "BobPassword";
};
# charlie = {
# email = "charlie@example.com";
# groups = [ ];
# password.result.path = pkgs.writeText "charliePassword" "CharliePassword";
# };
};
ensureGroups = {
@ -430,7 +439,7 @@ in
];
networking.hosts = {
"127.0.0.1" = [ "auth.${config.test.domain}" ];
"127.0.0.1" = [ "${config.shb.authelia.subdomain}.${config.shb.authelia.domain}" ];
};
shb.authelia = {

View file

@ -151,6 +151,91 @@ let
};
};
clientLdapLogin = { config, ... }: {
imports = [
testLib.baseModule
testLib.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "n";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Account name";
passwordFieldLabelRegex = "^ *[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
]; }
];
};
};
clientSsoLogin = { config, ... }: {
imports = [
testLib.baseModule
testLib.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "n";
};
networking.hosts = {
"192.168.1.2" = [ "auth.example.com" ];
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Username";
passwordFieldSelector = "get_by_label(\"Password *\")";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page).to_have_title(re.compile('Dashboard'))"
"page.goto('https://${config.test.fqdn}/settings/admin')"
"expect(page.get_by_text('Access forbidden')).to_be_visible()"
]; }
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page).to_have_title(re.compile('Dashboard'))"
"page.goto('https://${config.test.fqdn}/settings/admin')"
"expect(page.get_by_text('Access forbidden')).not_to_be_visible()"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
]; }
# TODO: charlie has no groups, which makes lldap return a 'null' value instead of an empty array and Authelia does not like that.
# { username = "charlie"; password = "CharliePassword"; nextPageExpect = [
# "page.get_by_role('button', name=re.compile('Accept')).click()"
# "expect(page).to_have_title(re.compile('Dashboard'))"
# ]; }
];
};
};
https = { config, ...}: {
shb.nextcloud = {
ssl = config.shb.certs.certs.selfsigned.n;
@ -168,7 +253,7 @@ let
dcdomain = config.shb.lldap.dcdomain;
adminName = "admin";
adminPassword.result = config.shb.hardcodedsecret.nextcloudLdapUserPassword.result;
userGroup = "nextcloud_user";
userGroup = "user_group";
};
};
shb.hardcodedsecret.nextcloudLdapUserPassword = {
@ -184,7 +269,7 @@ let
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "nextcloud";
# adminUserGroup = "nextcloud_admin";
adminGroup = "admin_group";
secret.result = config.shb.hardcodedsecret.oidcSecret.result;
secretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;
@ -192,6 +277,10 @@ let
fallbackDefaultAuth = false;
};
};
# Needed because OIDC somehow does not like self-signed certificates
# which we do use in tests.
# See https://github.com/pulsejet/nextcloud-oidc-login/issues/267
services.nextcloud.settings.oidc_login_tls_verify = lib.mkForce false;
shb.hardcodedsecret.oidcSecret = {
request = config.shb.nextcloud.apps.sso.secret.request;
@ -436,13 +525,19 @@ in
];
};
nodes.client = {};
nodes.client = {
imports = [
clientLdapLogin
];
};
testScript = commonTestScript.access;
};
sso = pkgs.testers.runNixOSTest {
name = "nextcloud_sso";
interactive.sshBackdoor.enable = true;
nodes.server = { config, ... }: {
imports = [
@ -453,10 +548,24 @@ in
ldap
(testLib.sso config.shb.certs.certs.selfsigned.n)
sso
({ config, ... }: {
networking.hosts = {
"127.0.0.1" = [ config.test.fqdn ];
};
})
];
};
nodes.client = {};
nodes.client = {
imports = [
clientSsoLogin
({ config, ... }: {
networking.hosts = {
"192.168.1.2" = [ config.test.fqdn ];
};
})
];
};
testScript = commonTestScript.access;
};