Summary
The management API identifies the caller from the bearer token's sub claim and uses that value directly as the Nextcloud user id. That is correct only when Nextcloud is the IdP. Behind an external IdP (user_oidc + Keycloak/Authentik/Entra), sub is the IdP's subject identifier — typically a UUID — while the Nextcloud account has a different UID entirely.
The result is not an error. Search returns HTTP 200 with zero results, because documents were indexed under the Nextcloud UID and the query filters on sub. To an admin it looks like "indexing works, search finds nothing", which is a considerably worse failure mode than a 401.
Found while fixing cbcoutinho/astrolabe#324 (external-IdP token minting). That issue and #1325 are now fixed, so this is the next thing in the way of a working external-IdP deployment.
Reproduction
Astrolabe's external-idp e2e lane (Nextcloud + user_oidc + real Keycloak), with the provider configured the way a normal install would be — mapping the Nextcloud UID from preferred_username, no UID hashing:
occ user_oidc:provider keycloak --mapping-uid=preferred_username --unique-uid=0
Log in through Keycloak as alice and run a search from the Astrolabe UI:
# the Nextcloud account
$ occ user:list
- alice: Alice Example
# who the MCP server thinks is asking
mcp-1 | INFO nextcloud_mcp_server.search.bm25_hybrid -
bm25_hybrid_rrf: query='anything', user=11111111-1111-4111-8111-111111111111
11111111-… is the Keycloak sub. Anything indexed for alice is invisible to that query.
Flipping the same lane to --mapping-uid=sub --unique-uid=0 makes UID and sub identical and everything works — which is how the lane is configured today, and why its search test passes. That is a test-harness accommodation, not something a real deployment should have to do.
Root cause
sub is taken as the Nextcloud user id at three points in nextcloud_mcp_server/auth/unified_verifier.py:
username = payload.get("sub") or payload.get("preferred_username") # :1008
username = userinfo.get("sub") or userinfo.get("preferred_username") # :225, :1094
It lands in AccessToken.resource, and validate_token_and_get_user (api/management.py:208) returns it as user_id for every management endpoint.
Two things then break:
- Qdrant filtering keys on that
user_id, so the caller sees none of their own documents.
get_user_client_basic_auth(user_id, …) in api/visualization.py looks up the provisioned app password by the same id, misses, and raises NotProvisionedError. Search silently degrades to the self-only, unverified path — so share expansion and verify-on-read (ADR-019) are skipped too.
When the assumption holds vs. breaks:
| deployment |
NC UID == token sub? |
oidc app (Nextcloud is the IdP) |
yes |
user_oidc, --mapping-uid=sub --unique-uid=0 |
yes |
user_oidc, default --unique-uid=1 (hashes provider+sub) |
no |
user_oidc, --mapping-uid=preferred_username / email |
no |
| pre-existing local accounts later matched to an IdP |
no |
Only the first two work, and the second is not the default.
Suggested fix
Ask Nextcloud who the token belongs to instead of inferring it. This repo already does exactly that for the analogous loginName≠UID problem on the app-password path — api/passwords.py:196 authenticates against OCS v2 /cloud/user and reads the canonical UID back, with a comment noting the UID may differ "e.g. OIDC-provisioned users":
GET {nextcloud_host}/ocs/v2.php/cloud/user
Authorization: Bearer <token>
OCS-APIRequest: true
user_oidc validates the bearer (realm-level, per docs/keycloak-multi-client-validation.md) and Nextcloud returns its own canonical id. That is authoritative for every user_oidc configuration, including the hashed --unique-uid=1 case that no claim-picking scheme can recover.
Worth noting the OCS v1/v2 gotcha already documented at api/passwords.py:203 — v1 returns 200 even on auth failure.
Cost is one extra HTTP call per token validation, not per request: the result can be cached in the existing _token_cache entry alongside sub, so the hot cache-hit path stays local.
A cheaper partial alternative is a configurable claim (OIDC_UID_CLAIM=preferred_username). It covers the common mapping but not --unique-uid=1, and it silently produces the wrong user rather than failing if misconfigured — so I'd prefer the lookup.
Workaround (with a warning)
On a new install, configure user_oidc with --mapping-uid=sub --unique-uid=0 so the two agree.
Do not apply this to an existing install. Changing the UID mapping changes existing accounts' UIDs, orphaning their files, shares and indexed data.
Related
Summary
The management API identifies the caller from the bearer token's
subclaim and uses that value directly as the Nextcloud user id. That is correct only when Nextcloud is the IdP. Behind an external IdP (user_oidc+ Keycloak/Authentik/Entra),subis the IdP's subject identifier — typically a UUID — while the Nextcloud account has a different UID entirely.The result is not an error. Search returns HTTP 200 with zero results, because documents were indexed under the Nextcloud UID and the query filters on
sub. To an admin it looks like "indexing works, search finds nothing", which is a considerably worse failure mode than a 401.Found while fixing cbcoutinho/astrolabe#324 (external-IdP token minting). That issue and #1325 are now fixed, so this is the next thing in the way of a working external-IdP deployment.
Reproduction
Astrolabe's
external-idpe2e lane (Nextcloud +user_oidc+ real Keycloak), with the provider configured the way a normal install would be — mapping the Nextcloud UID frompreferred_username, no UID hashing:Log in through Keycloak as
aliceand run a search from the Astrolabe UI:11111111-…is the Keycloaksub. Anything indexed foraliceis invisible to that query.Flipping the same lane to
--mapping-uid=sub --unique-uid=0makes UID andsubidentical and everything works — which is how the lane is configured today, and why its search test passes. That is a test-harness accommodation, not something a real deployment should have to do.Root cause
subis taken as the Nextcloud user id at three points innextcloud_mcp_server/auth/unified_verifier.py:It lands in
AccessToken.resource, andvalidate_token_and_get_user(api/management.py:208) returns it asuser_idfor every management endpoint.Two things then break:
user_id, so the caller sees none of their own documents.get_user_client_basic_auth(user_id, …)inapi/visualization.pylooks up the provisioned app password by the same id, misses, and raisesNotProvisionedError. Search silently degrades to the self-only, unverified path — so share expansion and verify-on-read (ADR-019) are skipped too.When the assumption holds vs. breaks:
sub?oidcapp (Nextcloud is the IdP)user_oidc,--mapping-uid=sub --unique-uid=0user_oidc, default--unique-uid=1(hashes provider+sub)user_oidc,--mapping-uid=preferred_username/ emailOnly the first two work, and the second is not the default.
Suggested fix
Ask Nextcloud who the token belongs to instead of inferring it. This repo already does exactly that for the analogous loginName≠UID problem on the app-password path —
api/passwords.py:196authenticates against OCS v2/cloud/userand reads the canonical UID back, with a comment noting the UID may differ "e.g. OIDC-provisioned users":user_oidcvalidates the bearer (realm-level, perdocs/keycloak-multi-client-validation.md) and Nextcloud returns its own canonicalid. That is authoritative for everyuser_oidcconfiguration, including the hashed--unique-uid=1case that no claim-picking scheme can recover.Worth noting the OCS v1/v2 gotcha already documented at
api/passwords.py:203— v1 returns 200 even on auth failure.Cost is one extra HTTP call per token validation, not per request: the result can be cached in the existing
_token_cacheentry alongsidesub, so the hot cache-hit path stays local.A cheaper partial alternative is a configurable claim (
OIDC_UID_CLAIM=preferred_username). It covers the common mapping but not--unique-uid=1, and it silently produces the wrong user rather than failing if misconfigured — so I'd prefer the lookup.Workaround (with a warning)
On a new install, configure
user_oidcwith--mapping-uid=sub --unique-uid=0so the two agree.Do not apply this to an existing install. Changing the UID mapping changes existing accounts' UIDs, orphaning their files, shares and indexed data.
Related
azpas the client id when a token has noclient_id#1325 — theazpallowlist fix, same deployment shapeldaplane — same class of bug (canonical UID vs. login name)