Skip to content

feat(jwtauth): WithTenantClaim names the claim the tenant is read from - #86

Merged
ovander merged 1 commit into
mainfrom
feat/jwtauth-tenant-claim
Oct 4, 2026
Merged

ovander merged 1 commit into
mainfrom
feat/jwtauth-tenant-claim

Conversation

@ovander

@ovander ovander commented Oct 4, 2026

Copy link
Copy Markdown
Owner

What and why

jwtauth reads the tenant only from a plain tenant_id claim, and a stock Socrate never issues one. Socrate has no tenant model, so a tenant reaches tokens only through a client's claim mapping ("tenant_id": "user.attributes.tenant_id"). Every mapped claim carries Socrate's claims namespace (NormalizeClaimsNamespace always applies one; the default is https://socrate/), so the token carries https://socrate/tenant_id. The middleware ignored that claim, the tenant stayed uuid.Nil, and httpware.RequireTenant refused every Socrate token on tenant-scoped routes. Lakebridge reported it on go-oauth2 #308.

Changes:

  • New option jwtauth.WithTenantClaim(name) reads the tenant from the named claim of the verified token.
    • The claim's value replaces SocrateClaims.TenantID, so a RevocationChecker sees the same tenant as the request context.
    • A plain tenant_id is then ignored.
    • The value must be a UUID string. Any other value (non-UUID string, number, object, null) is a 401.
    • If the token doesn't carry the claim, no tenant is set and RequireTenant refuses the request, as before.
    • An empty or blank name rejects every token, and New logs an error. It does not fall back to tenant_id, matching WithAudiences().
  • How the claim is read: the token is parsed into SocrateClaims as before. The named claim is then decoded from the payload only after signature and claim validation have passed, since a struct can't declare a claim whose name is only known at run time.
  • Docs:
    • README: the jwtauth section and the troubleshooting row.
    • docs/CLIENT-INTEGRATION.md: the §5 claim table, and the tenant isolation section, now with the three steps (user attribute, client mapping, option).
    • CHANGELOG.

The tenant still only ever comes from the signed token. Socrate's user attributes are written by global admins only (PUT /api/admin/users/{id}/attributes, audited).

How it was tested

New jwtauth/tenant_claim_test.go, a table of 15 cases:

  • the default reads tenant_id and ignores the namespaced claim;
  • the named claim is read, and wins over tenant_id;
  • a plain tenant_id is ignored when another claim is named;
  • an absent claim sets no tenant;
  • spaces around the name are trimmed;
  • naming tenant_id behaves like the default;
  • a non-UUID string, a number, an object and null are each rejected;
  • an empty name and a blank name each fail closed;
  • the last option wins.

Separate tests check that:

  • the RevocationChecker sees the named tenant;
  • a token signed with another key and carrying the claim is refused, without the handler running;
  • New logs nothing for a named claim, and logs the fail-closed error for a blank name.

The null case caught a real gap during development: json.Unmarshal accepts null into a string, so it is now rejected explicitly.

  • go mod tidy && git diff --exit-code go.sum leaves go.sum unchanged
  • go build ./... passes
  • go vet ./... passes
  • go test -race -count=1 -timeout=120s ./... passes
  • golangci-lint run ./... (v2.14.0, built with Go 1.27.1) reports no issue
  • govulncheck ./...: couldn't run here, because the sandbox can't reach vuln.go.dev. No dependency changed; CI runs it.
  • A line is added under ## [Unreleased] in CHANGELOG.md

Compatibility

  • Exported-API change: yes, additive. New jwtauth.WithTenantClaim(name string) Option. No symbol was removed or renamed and no signature changed.
  • Behaviour change for existing callers: none without the option. The default still reads tenant_id, with the same validation.
  • Breaking change: none.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA


Generated by Claude Code

A stock Socrate has no tenant model: a tenant reaches tokens only through a
client's claim mapping, under Socrate's claims namespace, as
https://socrate/tenant_id. The middleware read only a plain tenant_id, so
httpware.RequireTenant refused every Socrate token. WithTenantClaim reads the
named claim from the verified token instead: it replaces SocrateClaims.TenantID
(so the revocation check sees it), a plain tenant_id is then ignored, a value
that is not a UUID string (null included) is a 401, and an empty name rejects
every token. The default is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA
@ovander
ovander merged commit a531d4a into main Oct 4, 2026
6 checks passed
@ovander ovander mentioned this pull request Oct 5, 2026
7 tasks done
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.

2 participants