Status: Done — 2026-04-30 / 2026-05-01. Nextcloud 33.0.3 on VM-SL-03 now authenticates against Authentik via two independent paths:
user_oidc (auto-provisioning disabled — pre-mapped users only)user_ldap, talking to the Authentik LDAP outpost at 10.200.0.200:389 (no more direct AD bind)All 10 migrated users carry an Authentik UUID as oc_users.uid. Existing files, shares, calendars, contacts, Talk history, OTP secrets, password-app keychain, and 2FA TOTP secrets were preserved.
| Field | Value |
|---|---|
| Host | VM-SL-03 |
| NC version | 33.0.3 |
| PHP | 8.4 (upgraded from 8.3 via Sury repo on 2026-04-30) |
| Apache | mod_php switched to libapache2-mod-php8.4 |
| DB backup (pre-cutover) | /var/www/_nc-bak-pre-oidc-2026-04-30-194203/nc-db-pre-oidc.sql.gz (165 MB gz) |
| EnvironmentService patch backup | apps/passwords/lib/Services/EnvironmentService.php.bak-pre-patch2 |
| Inventory artifacts | inventory/vm-sl-03/ (audit, rename plan, SQL, migration scripts, OTP debug) |
+-------------------------+
| Authentik (vm-sl-00) |
| auth.blackreset.com |
+-------------------------+
| |
| OIDC | LDAP outpost
| (svc-nextcloud-oidc)
| | (blackreset-ldap-outpost)
v v
+-------------+ +---------------------+
| user_oidc | | user_ldap (s02) |
+-------------+ +---------------------+
\ /
\ /
v v
+---------------------+
| oc_users.uid |
| = Authentik UUID |
| (5d9caf1d-...) |
+---------------------+
oc_users.uid is the canonical join key. Both backends agree on the
Authentik User UUID as the Nextcloud uid:
sub claim, which Authentik emits fromsub_mode = user_uuid on provider svc-nextcloud-oidc (pk=14).uid attribute that the Authentik LDAP outpostUser.uid, hex-encoded 64-char string).oc_ldap_user_mapping translate that hex valueoc_users.uid.Backup taken first:
mkdir -p /var/www/_nc-bak-pre-oidc-2026-04-30-194203
sudo -u www-data php /var/www/www.cloud.blackreset.com/occ maintenance:mode --on
mysqldump -h 10.200.0.205 -u nextcloud -p$NCDB_PASS nextcloud \
| gzip > /var/www/_nc-bak-pre-oidc-2026-04-30-194203/nc-db-pre-oidc.sql.gz
Renames touched 60+ tables. Round 1 covered the standard NC/Talk/Deck/Share
core. Round 2 (inventory/vm-sl-03/nc-uid-rename-round2.sql) extended to
every user-keyed table NC 33 / installed apps actually use:
oc_users (+uid_lower), oc_accounts, oc_preferences, oc_share, oc_authtoken,
oc_user_status, oc_group_user, oc_dav_shares, oc_calendars, oc_calendarsubscriptions,
oc_addressbooks, oc_storages, oc_notifications, oc_activity, oc_activity_mq,
oc_user_transfer_owner,
oc_otpmanager_accounts, oc_otpmanager_settings,
oc_passwords_*,
oc_twofactor_totp_secrets, oc_twofactor_totp_backupcodes, oc_twofactor_providers,
oc_webauthn,
oc_mail_*, oc_mounts, oc_storages_credentials,
oc_share_external, oc_files_trash, oc_files_reminders,
oc_known_users, oc_calendars_federated, oc_calendar_appt_configs,
oc_appointments_*, oc_schedulingobjects, oc_dav_cal_proxy, oc_dav_absence,
oc_richdocuments_*, oc_talk_* (actor_type='users'), oc_deck_*,
oc_notes_meta, oc_forms_*, oc_polls_*, oc_photos_*, oc_cospend_*,
oc_cookbook_*, oc_gpxpod_*,
oc_comments (actor_type='users'), oc_reactions, oc_comments_read_markers,
oc_recent_contact, oc_webhook_*, oc_vcategory, oc_profile_config,
oc_properties, oc_preferences_ex, oc_directlink, oc_direct_edit,
oc_open_local_editor, oc_federated_invites,
oc_notifications_pushhash, oc_notifications_settings,
oc_health_persons, oc_recognize_face_*, oc_taskprocessing_tasks,
oc_text2image_tasks, oc_textprocessing_tasks, oc_text_sessions,
oc_backup_*, oc_analytics_*, oc_circles_*, oc_collres_accesscache,
oc_user_oidc, oc_ldap_group_membership
Physical home directories renamed via 10 mv operations under the
NC datadir. Cleanup:
home::*** storages purged from oc_storages_collision-backup-2026-04-30/marc maier deleted (legacy duplicate)outdoor (uid 7AF7F61B-..., 389 GB) converted into a local NC userocc user:resetpassword outdoorMapping table (the 10 OIDC users):
| Old (AD objectGUID) | New (Authentik UUID) | Authentik user | Size |
|---|---|---|---|
| 1FB80502- | 48cfecbd-6e33-4fc9-921d-4dc9c27d7bfc | Console | 1.2 TB |
| 473D9D74- | 5d9caf1d-4826-44d1-acaf-a5221abc03b4 | A.Korff | 593 GB |
| 5D88A46D- | d1fc7cc2-337b-4717-b4d8-dba81bdcb948 | H.Korff | 211 MB |
| 607E1611- | 2c115b84-2f8c-4287-b059-76b89da65be4 | M.Korff | 115 GB |
| 647D65FF- | d18b5b02-02ea-41ba-9392-b61a7a7ab4b1 | MA3 | 33 MB |
| 711391BD- | da48e8da-bfc5-4fa4-a1fc-e66afcf0d2ce | J.Placzek | empty |
| A337E696- | 548b12fe-1c33-4217-a0a5-044a3896b2c4 | R.Korff | 14 GB |
| C1F3ED8A- | 7480d82f-fc64-4379-bf41-080287c4f97a | K.Ekelt | empty |
| E344BC90- | 2e9aaab4-3980-48b8-81a1-3b6fdd4474cc | F.Korff | 211 GB |
| F9AA5FBE- | 3e698e78-3837-4fd7-9ed8-95bd74ebce09 | L.Korff | empty |
sudo -u www-data php /var/www/www.cloud.blackreset.com/occ \
user_oidc:provider:add authentik \
--clientid='rXh9cMnZjOLN3qd3B3e6GCz592YuGQI71TB2uGQB' \
--clientsecret-from-env=NC_OIDC_CLIENTSECRET \
--discoveryuri='https://auth.blackreset.com/application/o/svc-nextcloud/.well-known/openid-configuration' \
--scope='openid profile email groups' \
--mapping-uid=sub \
--mapping-display-name=name \
--mapping-email=email \
--unique-uid=0 \
--check-bearer=0 \
--send-id-token-hint=1
Provider id is 1, name authentik. auto_provision = 0 is enforced —
unknown OIDC sub values are rejected, never auto-created. oc_user_oidc
is pre-populated with one row per migrated user (sub = Authentik UUID,
provider_id = 1) so that the OIDC login path matches an existing NC uid
on first sign-in.
The original AD configuration s01 is deactivated, not deleted:
sudo -u www-data php occ ldap:set-config s01 ldapConfigurationActive 0
It still lives in oc_appconfig as a one-flip rollback path.
The active configuration s02 points at the Authentik LDAP outpost:
| Key | Value |
|---|---|
ldapHost |
10.200.0.200 |
ldapPort |
389 |
ldapBase |
dc=ldap,dc=blackreset,dc=com |
ldapBaseUsers |
ou=users,dc=ldap,dc=blackreset,dc=com |
ldapBaseGroups |
(empty — group sync intentionally disabled) |
ldapAgentName |
cn=svc-ldap-bind,ou=users,dc=ldap,dc=blackreset,dc=com |
ldapAgentPassword |
from .secrets/.ldap-bind-password |
ldapLoginFilter |
(&(objectClass=user)(cn=%uid)) |
ldapUserFilter |
(objectClass=user) |
ldapExpertUUIDUserAttr |
uid (Authentik User.uid, 64-char hex) |
ldapExpertUsernameAttr |
cn (login name = A.Korff) |
ldapEmailAttribute |
mail |
ldapConfigurationActive |
1 |
Group sync is off because Authentik's LDAP outpost answers
members_by_username=<lowercase> lookups with HTTP 400. Groups are owned
by Authentik anyway; NC doesn't need a second copy.
oc_ldap_user_mapping is pre-populated for the 10 users:
| owncloud_name | directory_uuid | ldap_dn |
|---|---|---|
5d9caf1d-4826-... (NC uid, dashed) |
<64-char hex> (User.uid) |
cn=A.Korff,ou=users,dc=ldap,dc=blackreset,dc=com |
| ... | ... | ... |
Pre-populating the mapping is what prevents NC from creating a second
user with uid=<hex> on the first LDAP login.
Symptom: every Passwords UI request returned HTTP 500
Unable to verify user, frontend looped on Sitzungs-token nicht mehr valide.
Root cause in apps/passwords/lib/Services/EnvironmentService::getUserInfoFromUserId:
the method looks up the user via $loginName, which in the new setup is
the LDAP cn (e.g. A.Korff). But oc_users.uid is the Authentik UUID
(e.g. 5d9caf1d-...). userManager->get($loginName) returns null,
which propagates up as 500.
Tested both stable (2026.3.21) and nightly (2026.5.20-build5898) —
both have the same bug. Nightly additionally requires PHP 8.4. We took
both jumps:
# 1. Sury repo + PHP 8.4
sudo curl -sSLo /etc/apt/keyrings/php.gpg https://packages.sury.org/php/apt.gpg
echo 'deb [signed-by=/etc/apt/keyrings/php.gpg] https://packages.sury.org/php/ bookworm main' \
| sudo tee /etc/apt/sources.list.d/sury-php.list
sudo apt-get update
sudo apt-get install -y php8.4 php8.4-{cli,common,curl,gd,gmp,intl,mbstring,bcmath,xml,zip,mysql,redis,apcu,imagick,bz2,opcache,fpm} \
libapache2-mod-php8.4
# 2. Switch Apache to mod_php 8.4
sudo a2dismod php8.3 && sudo a2enmod php8.4
sudo systemctl restart apache2
# 3. Patch backup
cp -a apps/passwords/lib/Services/EnvironmentService.php \
apps/passwords/lib/Services/EnvironmentService.php.bak-pre-patch2
Patch (added at the top of getUserInfoFromUserId):
protected function getUserInfoFromUserId(?string $userId, IRequest $request, string $loginName): bool {
$loginUser = $this->userManager->get($loginName);
// PATCH 2026-05-01: fallback to userId lookup when loginName != uid
// (LDAP with cn-as-loginName + UUID-as-uid).
if ($loginUser === null && $userId !== null) {
$loginUser = $this->userManager->get($userId);
}
if ($loginUser !== null && ($userId === null || $loginUser->getUID() === $userId)) {
$this->user = $loginUser;
$this->userLogin = $loginName;
$this->client = $this->getClientFromRequest($request, $loginName);
$this->loginType = self::LOGIN_EXTERNAL;
return true;
}
return false;
}
Reference copies of the file (orig + patched) at:
inventory/vm-sl-03/EnvironmentService.php.originventory/vm-sl-03/EnvironmentService.php.patchedPatch must be re-applied after every Passwords app upgrade until merged
upstream.
The Round-2 rename SQL UPDATE oc_otpmanager_settings SET user_id = new WHERE user_id = old raised a duplicate-key error for
5d9caf1d-4826-44d1-acaf-a5221abc03b4: an empty ghost row had been
auto-created earlier by the OTP-Manager status() endpoint when the new
uid was first hit on a logged-in session.
The follow-up cleanup DELETE FROM oc_otpmanager_settings WHERE user_id IN (<10 old uids>) accidentally also matched the good row that had been
renamed in-place (its user_id was already the new UUID, not in the
original list — but the conflict-row keep/discard logic was the wrong
way around in the script). Net effect: the row with the original
password (bcrypt of master password) and iv was gone.
Alex set a new master password later, which produced a fresh bcrypt + IV.
Decrypting any of the 17 stored OTP secrets with the new key/IV failed
with Malformed UTF-8 in WordArray.toString — those secrets are encrypted
with the original key derivation and cannot be recovered with a new
master password.
Recovery from nc-db-pre-oidc.sql.gz:
UPDATE oc_otpmanager_settings
SET password = '$2y$10$Q4F.b13rNqhITFjGAXvPSuI2MKcfdm7a79CyS9NG2ycuYga7Gk29i',
iv = '9f255827406b83968e9aaac67ee77f52'
WHERE user_id = '5d9caf1d-4826-44d1-acaf-a5221abc03b4';
With the original master password, decryption now works and all 17
OTP secrets are accessible again.
Symptom: users were logged out roughly every 5 minutes, regardless of
activity. Reported on both LDAP and OIDC sessions.
Root cause: user_oidc TokenService::checkLoginToken calls the
Authentik token endpoint on every request when
user_oidc.store_login_token = 1. If the access_token has expired and the
refresh-token exchange fails, TokenService calls
userSession->logout() directly. In our setup, refresh failed because:
svc-nextcloud-oidc had access_token_validity = 10min,default-authentication-login hadsession_duration = seconds=0 (i.e. session ended at flow exit),offline_access, so AuthentikThe symptom was visible on both LDAP and OIDC sessions because the
same browser cookie was reused across sessions; once user_oidc killed
the session, the LDAP-authenticated tab also lost its login.
Final workaround applied:
| Setting | Where | Old | New |
|---|---|---|---|
user_oidc.store_login_token |
NC oc_appconfig |
1 |
0 (TokenService bails immediately) |
appid=user_oidc, configkey=had_token_once |
NC oc_preferences |
present | rows cleared (doubly bails the check) |
auth.token_auth_check_interval |
NC config.php |
default 5min | 3600 (re-validate hourly) |
access_token_validity |
Authentik provider svc-nextcloud-oidc and svc-gitlab-oidc |
minutes=10 |
minutes=15 |
refresh_token_validity |
same | unset | days=30 |
include_claims_in_id_token |
same | false |
true |
session_duration |
Authentik flow stage default-authentication-login |
seconds=0 |
days=30 |
OIDC scopes on NC user_oidc provider |
occ user_oidc:provider:update |
openid profile email groups |
openid profile email groups offline_access |
offline_access is required for Authentik to mint a refresh_token. The
combination of store_login_token=0 + 1h re-validation interval makes the
symptom disappear without disabling token validation entirely.
Trade-off: Authentik-side revocation (deactivate user, force password
reset) propagates to Nextcloud within 1 hour max, not instantly,
because auth.token_auth_check_interval = 3600. This is acceptable for
SOHO; lower the interval if a stricter revocation SLA is needed.
Never touch encryption-key columns when renaming user ids.
The following columns hold per-user keys/IVs/secrets that are derived
from the user's master password (separate from their NC login
password). If you delete or overwrite them, all encrypted-at-rest data
becomes irrecoverable — no master-password reset will get it back:
| Table | Column(s) |
|---|---|
oc_otpmanager_settings |
password, iv |
oc_passwords_keychain |
data |
oc_twofactor_totp_secrets |
secret |
Touch only the user-identifier column (user_id / uid), never the
payload columns. Every rename SQL in this project carries an explicit
WHERE user_id = <old> and a SET user_id = <new> — never a
SET data = ... or SET secret = ....
On rename collision: keep the row with non-NULL crypto fields.
Apps with a status() or getInfo() endpoint may have auto-created
an empty row keyed on the new uid before the migration runs. The
resolution rule is fixed: the row with password IS NOT NULL (or
the equivalent payload column) is the good one. Discard the
empty row, never the populated one.
Always backup before any DELETE on app-internal tables.
mysqldump of just the affected tables takes seconds. Do it
even when the SELECT preview looks safe.
Pre-populate oc_user_oidc and oc_ldap_user_mapping before
first login — never rely on auto-provisioning to land on the right
uid. With auto-provisioning off, mismatched mappings reject the login
instead of creating a new ghost user.
PHP 8.4 + Sury is the supported NC 33+ baseline now. Subsequent
NC apps may quietly drop 8.3 support without raising it as a release
note.
occ user:list prints OIDC-uid users twice — once via user_ldap
(s02) and once via user_oidc. This is harmless: both backends agree
on the uid, so it's the same NC user reported through two different
identity sources. Fixes itself when one of the backends is removed.
We will keep both for now: OIDC is the primary login path; LDAP keeps
display-name / email sync working when OIDC isn't carrying the right
claims.
| Purpose | Path |
|---|---|
| Pre-cutover SQL backup | /var/www/_nc-bak-pre-oidc-2026-04-30-194203/nc-db-pre-oidc.sql.gz |
| Audit / rename plan | inventory/vm-sl-03/nc-uid-rename-plan.csv, nc-uid-rename-map.json |
| Round-1 rename SQL | inventory/vm-sl-03/nc-uid-rename.sql |
| Round-2 rename SQL | inventory/vm-sl-03/nc-uid-rename-round2.sql |
| Migration runner | inventory/vm-sl-03/nc-uid-migration-execute.sh, nc-uid-migration-resume.sh |
| Audit scripts | inventory/vm-sl-03/audit-nc-users.sh, nc-deep-audit.{sh,php,json} |
| Passwords-app patch source | inventory/vm-sl-03/EnvironmentService.php.{orig,patched} |
| OTP debug helpers | inventory/vm-sl-03/debug-otp-bcrypt.php, debug-passwords-verify.php, debug-tokenpw.php |
Nextcloud zog am 2026-05-03 als Postgres-backed Docker-Stack auf vm-rz-svc-prod-01 (10.200.0.101) um. DB-Backend: PG 17.6 auf vm-rz-db-01 (DB nextcloud).
Migrationsschritte:
db:convert-type mysql → pgsql mit OCS-Workarounds (sequence-Reset SQL nach convert; oc_jobs.id BIGINT explicit cast).data/-Verzeichnis via rsync nach /opt/nextcloud/data/ auf .101.oc_users.uid = Authentik UUID blieb erhalten (über convert-type + rsync transferiert).OIDC + LDAP-Wiring blieb identisch — siehe Original-Sektion oben. Detail-Doku: /migration/2026-05-03-nc-to-svc-prod-01.