Skip to content

fix(auth): resolve OIDC sub claim to the Nextcloud canonical UID - #1357

Open
AmirF194 wants to merge 1 commit into
cbcoutinho:masterfrom
AmirF194:fix/1326-oidc-canonical-uid
Open

fix(auth): resolve OIDC sub claim to the Nextcloud canonical UID#1357
AmirF194 wants to merge 1 commit into
cbcoutinho:masterfrom
AmirF194:fix/1326-oidc-canonical-uid

Conversation

@AmirF194

Copy link
Copy Markdown

Problem

External-IdP deployments (Keycloak, Authentik, Entra) leave user_oidc on its default UID mapping, which is a Nextcloud-generated account UID, not the IdP's sub claim. UnifiedTokenVerifier stores the raw sub/preferred_username claim directly as AccessToken.resource, and every consumer downstream (Qdrant document filtering, api/management.py's user_id, app-password lookups) treats that as the Nextcloud UID. When the two differ, the failure is silent: a lookup keyed on the raw claim returns zero results with HTTP 200, not an error.

api/passwords.py's _validate_nextcloud_credentials already solves this on the app-password path, since Nextcloud's loginName can also differ from its UID: it resolves the canonical UID via OCS GET /ocs/v2.php/cloud/user (v2, not v1, since v1 returns 200 even on auth failure). unified_verifier.py never applied that pattern to the OIDC path.

Fix

_resolve_canonical_uid calls the same OCS endpoint, authenticated with the bearer token itself rather than a login/password pair (the module's own docstring already establishes that token reuse against Nextcloud is safe: RFC 8707, and context_helper.py reuses this exact token the same way once verification succeeds). It runs in both _verify_mcp_audience (MCP tool calls) and _verify_without_audience_check (management API), right before the claim is written into _token_cache/AccessToken.resource. _get_cached_token and the management-API cache hit reconstruct the token from that same cache entry, so they inherit the resolved value without a separate change.

On any lookup failure (Nextcloud unreachable, non-200, malformed body) it falls back to the raw claim, so an OCS outage degrades to the current behavior rather than rejecting an otherwise-valid token.

Verification

  • MCP path: a token with a Keycloak-shaped sub resolves to the mocked canonical UID in AccessToken.resource, not the raw claim. On main this assertion fails (resource comes back as the raw UUID).
  • Management-API path: same assertion, plus a second call proving the cache hit also returns the resolved value rather than re-deriving it.
  • OCS-lookup-failure fallback: a network error during the lookup still returns a valid token, with the raw claim.
  • tests/unit (3610 cases) passes; ruff format/ruff check/ty check/deptry are clean. Not run: the live integration suite against a real Nextcloud + external IdP, so this verifies the mechanism against a mocked OCS response, not an end-to-end deployment.

Fixes #1326

Copilot AI lite review requested due to automatic review settings August 19, 2026 18:47
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes external-IdP deployments where the OIDC sub (or preferred_username) claim is incorrectly treated as the Nextcloud canonical UID, causing downstream lookups (e.g., Qdrant filtering, management API user_id, app-password storage keys) to silently miss and return empty results.

Changes:

  • Add canonical UID resolution in UnifiedTokenVerifier by calling OCS v2 /cloud/user with the bearer token and storing the returned UID into AccessToken.resource.
  • Apply the resolution consistently for both MCP tool verification (_verify_mcp_audience) and the management API verification path (_verify_without_audience_check).
  • Add unit regression tests covering success, fallback behavior, and cache-hit behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
nextcloud_mcp_server/auth/unified_verifier.py Resolves identity claims to Nextcloud’s canonical UID via OCS v2 before caching/issuing AccessToken, ensuring downstream components key on the correct UID.
tests/unit/test_unified_verifier.py Adds regression tests for canonical UID resolution, including fallback cases and management-API cache-hit behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1109 to +1119
# Parsed defensively, same shape as _validate_nextcloud_credentials:
# a malformed OCS body must degrade, never raise.
ocs = payload.get("ocs") if isinstance(payload, dict) else None
ocs_data = ocs.get("data") if isinstance(ocs, dict) else None
canonical_uid = ocs_data.get("id") if isinstance(ocs_data, dict) else None
if not canonical_uid:
logger.warning(
"Canonical-UID lookup returned no id for %s, using claimed value",
claimed_uid,
)
return claimed_uid
Comment on lines +1121 to +1127
if canonical_uid != claimed_uid:
logger.info(
"Resolved OIDC claim %s to canonical Nextcloud UID %s",
claimed_uid,
canonical_uid,
)
return canonical_uid
@cbcoutinho cbcoutinho self-assigned this Aug 19, 2026

@cbcoutinho cbcoutinho left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the approach is right, it's what the issue itself prescribes, and it lands in both verification paths. I checked the claim about cache reconstruction rather than taking it on trust: because _create_access_token_with_cache_key rewrites sub in the cache entry, both _get_cached_token and the management-API cache-hit branch do inherit the resolved value without a separate change. Good.

Ran it locally on the branch: ruff check, ruff format --check, ty check and the full unit suite (3610 passed) are green. Worth noting CI gave this almost nothing — only SonarCloud (pass) and license/cla (pending, unsigned) ran; no test lane triggered, so my local run is the only test signal on it.

One blocking item, then a few smaller ones.

🔴 Blocking

Nextcloud session cookies accumulate on the shared client — see the inline comment on the OCS call. This one is a cross-user identity-assignment risk, and it's introduced by this PR: before it, self.http_client never talked to Nextcloud in an external-IdP deployment.

🟡 Worth a round

The docstring's fallback contract isn't actually held — inline on the except clause.

Silent re-keying of already-provisioned app passwords. AccessToken.resource is the write key too, not just the read key: server/auth_tools.py:314 and auth/provision_routes.py:114 both store app passwords under a user_id that traces back to it (extract_user_id_from_tokenauth/token_utils.py:282, and validate_token_and_get_userapi/management.py:208). On an existing external-IdP deployment, rows written under the raw sub become unreachable the moment this ships, and the user gets NotProvisionedError until they re-provision.

That's a defensible outcome — the old key was wrong — but it's currently silent. Per CLAUDE.md's breaking-change convention this wants a BREAKING CHANGE: footer on the commit naming the version and the re-provision step, so it lands in CHANGELOG.md instead of living only in a PR description.

No coverage above the unit tier. The unit tests are genuinely good — I confirmed the mocked-OCS assertions fail on master, and each fallback branch (network error / non-200 / malformed / non-JSON / no host) is covered. But changed auth behaviour on the /api/v1/* provider surface is exactly what the repo's e2e + contract gate is aimed at. The real reproduction lane is external-idp over in astrolabe; a follow-up card on Deck board 11 referencing it would close this honestly rather than leaving the gap implicit. Your PR body already discloses "not run", which is the right instinct — this is just one step further.

🟢 Smaller

  • ocs.meta.statuscode unchecked, as Copilot noted. Low value on v2, which maps the OCS status onto HTTP — but it's one isinstance line if you want the mirror of _validate_nextcloud_credentials to be exact.
  • INFO log volume, also raised by Copilot — I'd push back on that one and keep it at INFO. It fires per token validation, not per request, so the rate is bounded by the cache TTL, and a one-time-per-token identity remap is precisely what an operator wants to see when diagnosing this class of bug.
  • Docs: this is a silent no-op unless user_oidc runs with --check-bearer=1 (docs/keycloak-multi-client-validation.md:191). One line saying so saves someone an afternoon of "the fix doesn't do anything".
  • A cache-entry consistency nit inline.

return claimed_uid

try:
response = await self.http_client.get(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: OCS session cookies accumulate on this shared client.

self.http_client is a long-lived httpx.AsyncClient built once in __init__ and shared across every token verification for every user. Nextcloud sets a session cookie on every OCS response, including failures — verified against the local stack:

$ curl -D- -H 'OCS-APIRequest: true' -H 'Authorization: Bearer bogus' \
    'http://localhost:8080/ocs/v2.php/cloud/user?format=json'
HTTP/1.1 401 Unauthorized
Set-Cookie: ocj699c4ri4i=b03aaeb8e4b5ac9148dfc9cdc37a1c4e; path=/; HttpOnly; SameSite=Lax
Set-Cookie: oc_sessionPassphrase=b0BGsgLiUIsplIp3P3V6WL6BFKtahUtkdAmjZOf1TCZ0XG8bDHqm...

httpx extracts those into client.cookies and replays them on the next request. So a successful lookup for user A leaves an authenticated Nextcloud session in the jar, and user B's lookup arrives carrying it. If B's bearer isn't accepted by OCS — a token minted for a different OIDC client, or a provider without --check-bearer=1 — the stale session can answer instead: HTTP 200 with A's ocs.data.id, which this method then treats as authoritative and writes into B's AccessToken.resource. That's cross-user identity assignment for Qdrant filtering and app-password lookup, arriving through the code path meant to make identity correct.

This isn't hypothetical repo-lore — client/__init__.py:104 (AsyncDisableCookieTransport) exists solely to stop this, and both siblings this method says it mirrors avoid it structurally by opening a fresh client per call: api/passwords.py:229 and auth/storage.py:2050. Matching them is the smallest fix:

async with nextcloud_httpx_client(timeout=10.0) as client:
    response = await client.get(
        f"{nextcloud_host.rstrip('/')}/ocs/v2.php/cloud/user",
        headers={"Authorization": f"Bearer {token}", "OCS-APIRequest": "true"},
        params={"format": "json"},
    )

Before this PR, self.http_client only ever talked to the IdP, so in the external-IdP deployment this change targets, the jar never held Nextcloud cookies at all. The PR is what introduces them.

headers={"Authorization": f"Bearer {token}", "OCS-APIRequest": "true"},
params={"format": "json"},
)
except httpx.RequestError as e:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The docstring promises "falls back to claimed_uid on any lookup failure ... rather than rejecting an otherwise-valid token", but only httpx.RequestError is caught here.

Anything else escapes into the caller's except Exception in _verify_mcp_audience / _verify_without_audience_check and becomes _reject(...) — so a token that already passed JWT verification or introspection gets refused because a best-effort UID lookup misbehaved. Reachable cases: httpx.InvalidURL from a malformed NEXTCLOUD_HOST (not a RequestError), a RuntimeError if the client has been closed, or anything unexpected from the response object.

Widen it to except Exception — the fallback value is safe by construction, so there's no reason to be selective about what triggers it.

# Extract username (sub claim, with fallback to preferred_username)
username = payload.get("sub") or payload.get("preferred_username")
username = (
resolved_username or payload.get("sub") or payload.get("preferred_username")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit: the cache entry built just below writes "sub": username (now the canonical UID) but spreads the rest of payload through unchanged, so preferred_username stays as the raw claim. The entry ends up internally inconsistent — two identity fields disagreeing about who the token belongs to.

Harmless today, since both readers do sub or preferred_username and sub wins. But it's a live trap for whoever next reads a cache entry, or adds a third reader that prefers preferred_username. Adding it to the exclusion set alongside sub and scope costs nothing.

@AmirF194
AmirF194 force-pushed the fix/1326-oidc-canonical-uid branch from 3fb5b5f to de1d464 Compare August 19, 2026 21:13
Copilot AI review requested due to automatic review settings August 19, 2026 21:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AmirF194

Copy link
Copy Markdown
Author

Thanks for the thorough pass, especially catching the cookie-jar issue: I hadn't thought through what a shared client means once it starts talking to Nextcloud instead of just the IdP.

Addressed in the new commit:

  • Blocking: _resolve_canonical_uid now opens a dedicated client via nextcloud_httpx_client per call, matching api/passwords.py/auth/storage.py. Rewrote the OCS-lookup tests to mock that instead of self.http_client.get, since they no longer exercise the shared client.
  • Widened the except clause to Exception, per your point about httpx.InvalidURL and friends escaping the narrower catch.
  • Dropped preferred_username from the cache spread so it can't disagree with the resolved sub, with a regression test.
  • Added a BREAKING CHANGE: footer naming the re-provision impact, and a line in the Keycloak doc about --check-bearer=1 being load-bearing for this fix, not just for user_oidc itself.

Left alone:

  • The ocs.meta.statuscode check and the INFO log level, since you called both optional/settled.
  • The e2e coverage gap: I don't have access to the astrolabe external-idp lane or the Deck board, so I can't open that follow-up card myself. Flagging it here in case you'd rather track it than have it drop.

Full suite (3359 unit tests), ruff check/format, and ty check all clean on the branch in a fresh container. Re-requesting review.

@AmirF194

Copy link
Copy Markdown
Author

No rush, just checking in: I addressed the cross-user session-cookie issue and the three other points from your review a week ago (dedicated client per call, widened except clause, preferred_username cache fix, breaking-change note). Let me know if there's anything else you'd like changed.

@cbcoutinho

Copy link
Copy Markdown
Owner

Hi @AmirF194 thanks for your contribution. Please submit a response to the cla.

I'll run the unit/integration tests and provide another review shortly

@AmirF194

Copy link
Copy Markdown
Author

Thanks, will get that signed. The test/review round whenever you get to it is appreciated, no rush on my end.

user_oidc maps an external IdP's sub to its own account UID; the two
are equal only under the non-default --mapping-uid=sub --unique-uid=0.
UnifiedTokenVerifier stored the raw claim directly as
AccessToken.resource, so every downstream consumer keyed on it (Qdrant
filters, api/management.py's user_id, app-password lookups) silently
used the wrong identity: a lookup under the wrong key returns zero
results, not an error.

Add _resolve_canonical_uid, mirroring
_validate_nextcloud_credentials's OCS v2 canonical-UID lookup in
api/passwords.py but authenticated with the bearer token itself. Runs
in both _verify_mcp_audience and _verify_without_audience_check, right
before the claim is cached; the cache-read paths inherit the resolved
value without a separate change. Falls back to the raw claim on any
lookup failure.

Address review from cbcoutinho:
- Blocking: the OCS lookup now opens a dedicated short-lived client via
  nextcloud_httpx_client instead of the shared self.http_client.
  Nextcloud sets a session cookie on every OCS response, including
  failures, and the shared client would replay one user's authenticated
  session onto the next user's lookup, letting a rejected bearer token
  answer with a stale session's identity instead.
- Widen the except clause from httpx.RequestError to Exception: the
  fallback to the claimed UID is safe by construction, so an unexpected
  error (a malformed NEXTCLOUD_HOST raising httpx.InvalidURL, a closed
  client) should degrade the same way a network error does rather than
  reject a token JWT/introspection already validated.
- Exclude preferred_username from the cached payload alongside sub: the
  entry previously kept the raw preferred_username claim next to the
  resolved sub, two identity fields disagreeing about who the token
  belongs to.
- Note in docs/keycloak-multi-client-validation.md that canonical-UID
  resolution is a silent no-op without user_oidc's --check-bearer=1.
- Add regression tests for the widened except clause and the cache
  consistency fix; rework the existing OCS-lookup tests to mock
  nextcloud_httpx_client (matching api/passwords.py's own test pattern)
  instead of self.http_client.get, since the fix no longer calls that
  shared client for this lookup.

Fixes cbcoutinho#1326

BREAKING CHANGE: AccessToken.resource (and any app password stored
under it) now key on the Nextcloud canonical UID instead of the raw
OIDC sub/preferred_username claim on external-IdP deployments where
they differ. Rows already provisioned under the raw claim (server/
auth_tools.py, auth/provision_routes.py) become unreachable under the
new key; affected users see NotProvisionedError until they
re-provision their app password.
@AmirF194
AmirF194 force-pushed the fix/1326-oidc-canonical-uid branch from de1d464 to 0c60447 Compare September 2, 2026 09:00
Copilot AI review requested due to automatic review settings September 2, 2026 09:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants