Skip to content

feat: add POST /{domain}/auth endpoint for token handoff#224

Open
notque wants to merge 7 commits into
masterfrom
feature/post-auth-token-handoff
Open

feat: add POST /{domain}/auth endpoint for token handoff#224
notque wants to merge 7 commits into
masterfrom
feature/post-auth-token-handoff

Conversation

@notque
Copy link
Copy Markdown
Contributor

@notque notque commented May 14, 2026

Summary

  • Adds POST /{domain}/auth endpoint that accepts tokens in the request body instead of URL query parameters
  • After authentication (handled by existing authorize middleware), sets HttpOnly cookie and 303-redirects to the dashboard
  • Adds deprecation log for the old ?x-auth-token= query parameter path (still functional, no breaking change)
  • Includes MaxBytesReader (16KB) protection on POST body parsing

Motivation

URL query parameters are not appropriate for passing authentication tokens — they persist in browser history, server access logs, proxy caches, and Referrer headers. This moves token handoff to a POST body, which is the standard approach for compliance with credential handling best practices.

The old ?x-auth-token= path continues to work unchanged. A follow-up PR will remove it once downstream consumers (Elektra) have migrated to the new endpoint.

Changes

File Change
pkg/api/server.go New route + tokenLogin handler
pkg/keystone/keystone.go POST body token extraction with method guard
pkg/api/api_test.go Three new test cases (success, auth failure, missing token)

Backward Compatibility

  • No breaking changes. The existing ?x-auth-token= query parameter path still works identically.
  • Downstream consumers can migrate at their own pace — no coordinated deployment required.
  • Deprecation log (DEPRECATION: token passed via URL query parameter) tracks migration progress.

Follow-up

  • Remove ?x-auth-token= query parameter support once deprecation logs confirm zero usage
  • Elektra-side change to use the new POST endpoint (separate repo/team)

Test plan

  • make check passes (tests + golangci-lint + typos)
  • New unit tests cover: successful auth → redirect, invalid token → 401, missing token → 401
  • Manual verification with Elektra integration (post-merge)

Elektra currently passes Keystone tokens via URL query parameters when
linking to the Maia dashboard. Tokens in URLs leak through browser
history, server logs, proxy caches, and Referrer headers.

Add a POST endpoint that accepts the token in the request body instead.
After the existing authorize middleware validates and sets the HttpOnly
cookie, the handler 303-redirects to /{domain}/graph. The old
?x-auth-token= path remains for backward compatibility but now logs a
deprecation notice.

- MaxBytesReader (16KB) prevents memory exhaustion via large POST bodies
- Method guard ensures PostFormValue only runs on POST requests
- observeDuration wrapper maintains metrics parity with other endpoints
notque added 2 commits May 14, 2026 11:19
- Replace PostFormValue with explicit ParseForm + error handling to
  surface MaxBytesReader failures instead of silently swallowing them
- Fix deprecation log format (was producing double-slash path)
- Add debug logging in POST body path and tokenLogin handler
- Add CHANGELOG.md with Added/Deprecated entries
- Only parse POST body when Content-Type is application/x-www-form-urlencoded
  (prevents body consumption for other POST endpoints, gives clear failure for
  wrong Content-Type)
- Differentiate MaxBytesError (logged at Error) from generic parse failures
  (logged at Info) for operator visibility into potential abuse vs client bugs
- Add debug log for invalid domain in tokenLogin (consistent with other handlers)
- Add TestTokenLogin_bodyTooLarge: verifies >16KB body → 401
- Add TestTokenLogin_wrongContentType: verifies JSON body → 401
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a POST-based token handoff flow so clients can submit auth tokens in a request body, authenticate via existing middleware, receive the existing auth cookie, and redirect to the graph UI.

Changes:

  • Adds POST /{domain}/auth route and redirect handler.
  • Extends Keystone auth extraction to read x-auth-token from form-encoded POST bodies with a 16KB limit.
  • Adds API tests and changelog entries for the new endpoint and query-token deprecation.

Reviewed changes

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

File Description
pkg/api/server.go Registers the new auth route and redirects successful logins to /graph.
pkg/keystone/keystone.go Adds POST form token extraction and deprecation logging for URL query tokens.
pkg/api/api_test.go Adds tests around token login success and failure cases.
CHANGELOG.md Documents the new endpoint and deprecated query-token path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/api/server.go Outdated
Comment thread pkg/keystone/keystone.go Outdated
Comment thread pkg/api/server.go
Comment thread pkg/keystone/keystone.go Outdated
Four review-driven fixes plus the test coverage that exposes them:

- tokenLogin redirect now preserves the global-region selector (URL param or
  X-Global-Region header) so the follow-up dashboard GET re-binds to the same
  Keystone backend instead of silently falling back to regional.
- Content-Type recognition uses mime.ParseMediaType, accepting case variants
  per RFC 9110 §8.3.1 and tolerating parameters such as "; charset=utf-8".
- authorizeRules skips the cookie -> X-Auth-Token header promotion when the
  request is a POST form-token handoff (POST /{domain}/auth with form-encoded
  body). A stale session cookie no longer shadows a fresh token in the body.
  This is the path-specific Approach A; non-handoff POSTs are unaffected.
- New keystone-level tests exercise authOptionsFromRequest directly (the
  api-package tests previously mocked AuthenticateRequest, leaving the POST
  form branch uncovered): success, case-insensitive Content-Type, missing
  field, non-POST method, wrong Content-Type, header-beats-body precedence,
  oversize body, and the isFormURLEncoded helper.
- New api-level tests cover the redirect-with-global propagation (param and
  header), the case-insensitive Content-Type wired end-to-end, and the
  stale-cookie + fresh-form-token scenario asserting the body token wins.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread pkg/keystone/keystone.go Outdated
Comment thread pkg/keystone/keystone.go
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should we handle the error?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The silent false is intentional. mime.ParseMediaType only errors on malformed Content-Type headers (e.g. ";; charset=utf-8"); legitimate-but-different types like application/json parse fine and fall through the equality check. Treating a malformed header as "not form-encoded" is the safe default — the alternative is parsing a body whose framing we don't trust.

The error doesn't get swallowed in practice: when this returns false, control falls through the else if chain in authOptionsFromRequest, and a request with no other auth signal fails with a normal AuthenticationError (401). The user-visible signal is the 401, not a debug log.

This matches net/http convention — r.ParseForm() and r.FormValue() use the same mime.ParseMediaType pattern and silently treat malformed headers as non-form. Logging here would mostly capture probes and scanners sending garbage headers, which buries the signal we actually want.

Happy to add a logg.Debug if you'd prefer it for ops grepping, but I'd lean against — let me know.

Comment thread pkg/api/util.go
}
mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil {
return false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

error handler?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same answer as the matching helper in pkg/keystone — silent false is intentional. mime.ParseMediaType only errors on malformed Content-Type; well-formed-but-different types parse fine and fall through the equality check. Treating a malformed header as "not a form handoff" is the safe default.

Errors aren't lost: a request that fails this predicate falls through to the regular authorize path. If the user expected a form handoff and the header was malformed, they'll see a 401 — the actionable signal — rather than a silently-promoted stale cookie.

Matches net/http convention (ParseForm/FormValue use the same idiom). Whatever we decide for the matching helper in pkg/keystone should land here too — the two are symmetric.

authOptionsFromRequest called query.Del on the copy returned by
r.URL.Query() but never wrote the result back to r.URL.RawQuery. As a
result, downstream consumers that read r.URL — request logging, metrics
labelers, reverse-proxy paths — observed the original query string with
x-auth-token, project_id, and domain_id still present, even though the
function had logically consumed and removed them.

Track a queryDirty flag at each Del site and flush via
r.URL.RawQuery = query.Encode() before the success return. The flush is
gated on queryDirty so URLs with no consumed params keep their original
RawQuery byte-for-byte (url.Values.Encode sorts keys, which would
otherwise reorder unrelated params on every request).

Adds TestAuthOptionsFromRequest_ScrubsRawQuery covering all three
deletion sites plus a no-op fast-path assertion that pins original key
ordering.

Addresses PR #224 review feedback.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread pkg/keystone/keystone.go Outdated
Comment on lines +440 to +449
if token := r.Header.Get("X-Auth-Token"); token != "" {
// perfect: we have a token and thus a authorization scope
ba.TokenID = token
} else if token := query.Get("x-auth-token"); token != "" {
// perfect: we have a token and thus a authorization scope (albeit in lower-case)
logg.Info("DEPRECATION: token passed via URL query parameter on %s; migrate to POST /{domain}/auth or X-Auth-Token header", r.URL.Path)
ba.TokenID = token
// relocate to header
query.Del("x-auth-token")
queryDirty = true
Comment thread CHANGELOG.md Outdated
Comment on lines +18 to +30

### Changed

- POST /{domain}/auth now accepts Content-Type values per RFC 9110 §8.3.1, so
case variants such as `Application/X-WWW-Form-Urlencoded; charset=utf-8` are
recognised as form-encoded.
- POST /{domain}/auth redirects now preserve `?global=true` (and the
`X-Global-Region` header equivalent) so the dashboard load binds to the same
Keystone backend that authenticated the handoff.
- POST /{domain}/auth no longer promotes a pre-existing X-Auth-Token cookie
into the request header before the form body is parsed, so a fresh token in
the body takes precedence over a stale session cookie.

LeoZhangZXZ
LeoZhangZXZ previously approved these changes May 21, 2026
Hoist the ?x-auth-token= scrub above the credential dispatch and flush
r.URL.RawQuery via defer so all five early-return paths (app-cred header,
basic-auth *appcred, guessScope failure, missing Authorization, missing
scope) drop the deprecated query token consistently.

Add subtests covering the two CRITICAL leak paths (app-cred header +
query token; basic-auth *appcred + query token).

Collapse CHANGELOG entries to one-line bullets under Added.
Untrack build/coverprofile.out and add to .gitignore.
@github-actions
Copy link
Copy Markdown

Merging this branch will increase overall coverage

Impacted Packages Coverage Δ 🤖
github.com/SAP-cloud-infrastructure/maia/pkg/api 75.27% (+0.16%) 👍
github.com/SAP-cloud-infrastructure/maia/pkg/keystone 73.16% (+2.88%) 👍

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/SAP-cloud-infrastructure/maia/pkg/api/server.go 65.55% (+1.74%) 595 (+70) 390 (+55) 205 (+15) 👍
github.com/SAP-cloud-infrastructure/maia/pkg/api/util.go 81.36% (-0.15%) 1100 (+45) 895 (+35) 205 (+10) 👎
github.com/SAP-cloud-infrastructure/maia/pkg/keystone/keystone.go 73.99% (+3.34%) 1980 (+140) 1465 (+165) 515 (-25) 👍

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

Changed unit test files

  • github.com/SAP-cloud-infrastructure/maia/pkg/api/api_test.go
  • github.com/SAP-cloud-infrastructure/maia/pkg/keystone/keystone_test.go

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.

3 participants