Skip to content

fix(auth): resolve /api/auth/negotiate principals through the directory - #72

Open
alghanim wants to merge 1 commit into
bodaay:masterfrom
alghanim:fix/negotiate-directory-user
Open

alghanim wants to merge 1 commit into
bodaay:masterfrom
alghanim:fix/negotiate-directory-user

Conversation

@alghanim

@alghanim alghanim commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

GET /api/auth/negotiate cannot map a normal Active Directory user to a SimpleAuth user. Kerberos itself succeeds (the ticket verifies against the keytab), and then the lookup fails with 401 no user found for Kerberos principal: jdoe. /login/sso does not have this problem.

Why it fails

After verifying the AP-REQ, the handler looked the principal up two ways only:

  1. a kerberos identity mapping. Nothing in SimpleAuth creates these. LDAP login and /login/sso write ldap/local mappings.
  2. matchAutoProvisionUser, which matches when a user's display name or email equals the bare username. For a real AD account (John Doe, jdoe@green.org) that is never true.

So every directory user got a 401, including users who had already signed in with their AD password and had an ldap mapping the endpoint never consulted. Deployments have been working around this by creating a kerberos mapping per user in their bootstrap scripts.

/login/sso already solves the same problem in resolveKerberosUser: it reuses the ldap mapping, looks the user up in LDAP by sAMAccountName, provisions them on first sight, and checks AD-disabled state.

Change

A new resolveNegotiateUser resolves the principal in this order:

  1. an explicit kerberos mapping (full principal, then realm-less). Existing mappings and the per-user workaround keep working unchanged.
  2. the directory, via resolveKerberosUser, the same code /login/sso uses.
  3. the display-name/email match, kept as the fallback for deployments without a directory. The M12 rule (app-local users are never matched) is untouched.

A disabled directory account comes back as auth.ErrLDAPAccountDisabled → 403 account disabled and never falls through to step 3.

Tokens from this endpoint now carry the directory groups, as /login/sso tokens do. They were previously issued with groups: nil.

Verification

Unit tests (negotiate_resolve_test.go): explicit mapping wins; realm-less mapping resolves; without a directory the legacy match still resolves and records a mapping; an unknown principal fails; an app-local user is never bound (M12). Full go test ./... is green, go vet and gofmt are clean.

End to end against a Samba AD DC (nowsci/samba-domain, realm GREEN.ORG), SimpleAuth behind nginx with LDAP on plain 389 and the HTTP/<host> keytab. Tickets come from kinit; requests use curl --negotiate:

AD user before (v2.3.0) after
jdoe: signed in by password earlier, has an ldap mapping 401 no user found 200, groups: [Engineering]
salem: Arabic display name 401 200, name: سالم المري
newhire: never signed in 401 200, provisioned from AD
leaver: AD-disabled, still holding a valid service ticket 401 403 account disabled

The directory branch has no unit test because it needs a live LDAP server, and the handler package has no LDAP fake. If you'd like one, I can add a scenario to test/integration.

Not changed

Two related issues in the /login/sso failure path showed up while testing. They are left for a separate PR to keep this one focused:

  • The Negotiate fallback's meta-refresh retry URL keeps only redirect_uri and drops client_id, oidc, state, nonce and the PKCE challenge.
  • On failure, the browser is sent back to the app's redirect_uri with ?error=<message> but without state, so OIDC clients reject the callback as a state/CSRF mismatch instead of showing the error.

🤖 Generated with Claude Code

GET /api/auth/negotiate verified the SPNEGO token, then looked the
principal up only through a "kerberos" identity mapping or by matching
the bare username against a user's display name or email. Nothing
creates kerberos mappings, and a normal AD user's display name ("John
Doe") and email (jdoe@green.org) never equal the bare sAMAccountName,
so every directory user got 401 "no user found for Kerberos principal"
-- including users who had already signed in with their AD password and
had an "ldap" mapping. Deployments worked around it by creating a
kerberos mapping per user.

/login/sso already solves this with resolveKerberosUser (ldap mapping,
LDAP lookup by sAMAccountName, JIT provisioning, AD-disable check).
resolveNegotiateUser now uses it for the API endpoint too:

1. an explicit kerberos mapping still wins, so existing mappings and
   the per-user workaround keep working;
2. otherwise the directory, exactly as /login/sso;
3. the display-name/email match remains as the fallback for
   deployments without a directory.

A disabled directory account returns 403 and never falls through to
the fallback match. Tokens now carry the directory groups, as they do
on /login/sso; the endpoint previously issued them with no groups.

Unit tests cover the mapping, fallback, unknown-principal and app-local
branches. The directory branch needs a live LDAP server, so it was
verified against a Samba AD DC: a password-provisioned user, a user who
never signed in (JIT) and an Arabic-named user now get tokens with
their AD groups; an AD-disabled user presenting a still-valid ticket
gets 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant