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-options-shb.authelia.enable": [
"blocks-authelia.html#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-options-shb.authelia.ldapHostname": [
"blocks-authelia.html#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-nextcloudserver-options-shb.nextcloud.apps.sso": [
"services-nextcloud.html#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-nextcloudserver-options-shb.nextcloud.apps.sso.authorization_policy": [
"services-nextcloud.html#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 { oidcClients = lib.mkOption {
description = "OIDC clients"; description = "OIDC clients";
default = [ default = [
@ -205,13 +223,13 @@ in
scopes = lib.mkOption { scopes = lib.mkOption {
type = lib.types.listOf lib.types.str; 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" ]; example = [ "openid" "profile" "email" "groups" ];
default = []; default = [];
}; };
claims_policy = lib.mkOption { claims_policy = lib.mkOption {
type = lib.types.str; type = lib.types.nullOr lib.types.str;
description = '' description = ''
Claim policy. Claim policy.
@ -387,11 +405,6 @@ in
user = "uid=admin,ou=people,${cfg.dcdomain}"; 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 = { totp = {
disable = "false"; disable = "false";
issuer = fqdnWithPort; issuer = fqdnWithPort;
@ -465,6 +478,17 @@ in
}; };
log.level = if cfg.debug then "debug" else "info"; 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" ]; settingsFiles = [ "/var/lib/authelia-${fqdn}/oidc_clients.yaml" ];

View file

@ -414,7 +414,6 @@ in
}; };
}; };
userGroup = lib.mkOption { userGroup = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Group users must belong to to be able to login to Nextcloud."; description = "Group users must belong to to be able to login to Nextcloud.";
@ -424,8 +423,8 @@ in
configID = lib.mkOption { configID = lib.mkOption {
type = lib.types.int; type = lib.types.int;
description = '' description = ''
Multiple LDAP configs can co-exist with only one active at a time.This option Multiple LDAP configs can co-exist with only one active at a time.
sets the config ID used by Self Host Blocks. This option sets the config ID used by Self Host Blocks.
''; '';
default = 50; default = 50;
}; };
@ -475,6 +474,12 @@ in
default = "one_factor"; 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 { secret = lib.mkOption {
description = "OIDC shared secret."; description = "OIDC shared secret.";
type = lib.types.submodule { type = lib.types.submodule {
@ -1022,7 +1027,7 @@ in
${occ} ldap:set-config "${cID}" 'ldapLoginFilterAttributes' \ ${occ} ldap:set-config "${cID}" 'ldapLoginFilterAttributes' \
'mail;objectclass' 'mail;objectclass'
${occ} ldap:set-config "${cID}" 'ldapUserDisplayName' \ ${occ} ldap:set-config "${cID}" 'ldapUserDisplayName' \
'displayname' 'givenname'
${occ} ldap:set-config "${cID}" 'ldapUserFilter' \ ${occ} ldap:set-config "${cID}" 'ldapUserFilter' \
'(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))' '(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))'
${occ} ldap:set-config "${cID}" 'ldapUserFilterMode' \ ${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 # Nextcloud is compatible with the one returned by a (possibly added in the future) SSO
# provider. # provider.
${occ} ldap:set-config "${cID}" 'ldapExpertUsernameAttr' \ ${occ} ldap:set-config "${cID}" 'ldapExpertUsernameAttr' \
'user_id' 'uid'
${occ} ldap:test-config -- "${cID}" ${occ} ldap:test-config -- "${cID}"
@ -1057,6 +1062,7 @@ in
"profile" "profile"
"email" "email"
"groups" "groups"
"nextcloud_userinfo"
]; ];
in lib.mkIf (cfg.enable && cfg.apps.sso.enable) { in lib.mkIf (cfg.enable && cfg.apps.sso.enable) {
assertions = [ assertions = [
@ -1070,11 +1076,9 @@ in
} }
]; ];
systemd.services.nextcloud-setup.script = services.nextcloud.extraApps = {
'' inherit (nextcloudApps) oidc_login;
${occ} app:install oidc_login || : };
${occ} app:enable oidc_login
'';
systemd.services.nextcloud-setup-pre = { systemd.services.nextcloud-setup-pre = {
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@ -1096,6 +1100,12 @@ in
secretFile = "${cfg.dataDir}/config/secretFile"; secretFile = "${cfg.dataDir}/config/secretFile";
# See all options at https://github.com/pulsejet/nextcloud-oidc-login # 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 = { settings = {
allow_user_to_change_display_name = false; allow_user_to_change_display_name = false;
lost_password_link = "disabled"; lost_password_link = "disabled";
@ -1110,12 +1120,14 @@ in
oidc_login_logout_url = ssoFqdnWithPort; oidc_login_logout_url = ssoFqdnWithPort;
oidc_login_button_text = "Log in with ${cfg.apps.sso.provider}"; oidc_login_button_text = "Log in with ${cfg.apps.sso.provider}";
oidc_login_hide_password_form = false; 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 = { oidc_login_attributes = {
id = "preferred_username"; id = "preferred_username";
name = "name"; name = "name";
mail = "email"; mail = "email";
groups = "groups"; groups = "groups";
is_admin = "is_nextcloud_admin";
}; };
oidc_login_default_group = "oidc"; oidc_login_default_group = "oidc";
oidc_login_use_external_storage = false; 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 # 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. # the user is associated with in LDAP. It's too much.
oidc_create_groups = false; oidc_create_groups = false;
# Enable use of WebDAV via OIDC bearer token. oidc_login_webdav_enabled = false;
oidc_login_webdav_enabled = true;
oidc_login_password_authentication = false; oidc_login_password_authentication = false;
oidc_login_public_key_caching_time = 86400; oidc_login_public_key_caching_time = 86400;
oidc_login_min_time_between_jwks_requests = 10; 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 # 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. # as the server does not control which URLs will be requested. Use with care.
oidc_login_update_avatar = false; 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") [ shb.authelia.oidcClients = lib.mkIf (cfg.apps.sso.provider == "Authelia") [
{ {
client_id = cfg.apps.sso.clientID; client_id = cfg.apps.sso.clientID;
client_name = "Nextcloud"; client_name = "Nextcloud";
client_secret.source = cfg.apps.sso.secretForAuthelia.result.path; client_secret.source = cfg.apps.sso.secretForAuthelia.result.path;
claims_policy = "nextcloud_userinfo";
public = false; public = false;
authorization_policy = cfg.apps.sso.authorization_policy; authorization_policy = cfg.apps.sso.authorization_policy;
require_pkce = "true";
pkce_challenge_method = "S256";
redirect_uris = [ "${protocol}://${fqdnWithPort}/apps/oidc_login/oidc" ]; redirect_uris = [ "${protocol}://${fqdnWithPort}/apps/oidc_login/oidc" ];
inherit scopes; 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"` and `sops.secrets."nextcloud/ldapUserPassword"`
secrets have the same content. secrets have the same content.
Creating LDAP users and groups is not declarative yet, The LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) is created automatically.
so go to the LDAP server at `http://ldap.example.com`, Add your user to it by going to `http://ldap.example.com`,
create the `nextcloud_user` group, create a user if needed and add it to the group.
create a user and add it to the group.
When that's done, go back to the Nextcloud server at When that's done, go back to the Nextcloud server at
`https://nextcloud.example.com` and login with that user. `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, so you need to create a normal user like above,
login with it once so it is known to Nextcloud, then logout, 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. 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} ### 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"` and `sops.secrets."nextcloud/sso/secretForAuthelia"`
secrets have the same content. 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. 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`. 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. # HTTP(s) server port.
networking.firewall.allowedTCPPorts = [ 80 443 ]; networking.firewall.allowedTCPPorts = [ 80 443 ];
shb.nginx.accessLog = true; shb.nginx.accessLog = true;
networking.hosts = {
"192.168.1.2" = [ config.test.fqdn ];
};
}; };
}; };
@ -415,6 +419,11 @@ in
groups = [ "user_group" "admin_group" ]; groups = [ "user_group" "admin_group" ];
password.result.path = pkgs.writeText "bobPassword" "BobPassword"; password.result.path = pkgs.writeText "bobPassword" "BobPassword";
}; };
# charlie = {
# email = "charlie@example.com";
# groups = [ ];
# password.result.path = pkgs.writeText "charliePassword" "CharliePassword";
# };
}; };
ensureGroups = { ensureGroups = {
@ -430,7 +439,7 @@ in
]; ];
networking.hosts = { networking.hosts = {
"127.0.0.1" = [ "auth.${config.test.domain}" ]; "127.0.0.1" = [ "${config.shb.authelia.subdomain}.${config.shb.authelia.domain}" ];
}; };
shb.authelia = { 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, ...}: { https = { config, ...}: {
shb.nextcloud = { shb.nextcloud = {
ssl = config.shb.certs.certs.selfsigned.n; ssl = config.shb.certs.certs.selfsigned.n;
@ -168,7 +253,7 @@ let
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminName = "admin"; adminName = "admin";
adminPassword.result = config.shb.hardcodedsecret.nextcloudLdapUserPassword.result; adminPassword.result = config.shb.hardcodedsecret.nextcloudLdapUserPassword.result;
userGroup = "nextcloud_user"; userGroup = "user_group";
}; };
}; };
shb.hardcodedsecret.nextcloudLdapUserPassword = { shb.hardcodedsecret.nextcloudLdapUserPassword = {
@ -184,7 +269,7 @@ let
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "nextcloud"; clientID = "nextcloud";
# adminUserGroup = "nextcloud_admin"; adminGroup = "admin_group";
secret.result = config.shb.hardcodedsecret.oidcSecret.result; secret.result = config.shb.hardcodedsecret.oidcSecret.result;
secretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; secretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;
@ -192,6 +277,10 @@ let
fallbackDefaultAuth = false; 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 = { shb.hardcodedsecret.oidcSecret = {
request = config.shb.nextcloud.apps.sso.secret.request; request = config.shb.nextcloud.apps.sso.secret.request;
@ -436,13 +525,19 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = {
imports = [
clientLdapLogin
];
};
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
sso = pkgs.testers.runNixOSTest { sso = pkgs.testers.runNixOSTest {
name = "nextcloud_sso"; name = "nextcloud_sso";
interactive.sshBackdoor.enable = true;
nodes.server = { config, ... }: { nodes.server = { config, ... }: {
imports = [ imports = [
@ -453,10 +548,24 @@ in
ldap ldap
(testLib.sso config.shb.certs.certs.selfsigned.n) (testLib.sso config.shb.certs.certs.selfsigned.n)
sso 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; testScript = commonTestScript.access;
}; };