Skip to content

External IdP: token sub is used as the Nextcloud UID, so search silently returns zero results #1326

Description

@cbcoutinho

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:

  1. Qdrant filtering keys on that user_id, so the caller sees none of their own documents.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions