Recording the full mcp-lifecycle fixture against real isolated PHP (08-09-PLAN.md Task 2) uncovered three byte-level gaps between wristband's assumed contract and actual production PHP behavior: - Every explicit "Cache-Control: no-store" PHP sets is actually delivered as "no-store, private" (Laravel's session-cookie default merges "private" onto any explicit value); wristband's own default for unheadered JSON error responses is "no-cache, private" (matching the house convention already used elsewhere), not empty. - PHP's redirect responses (authorize success and every error redirect) render Symfony's default HTML redirect body with Content-Type "text/html; charset=utf-8"; Go's bare 302 with no body never matched. wristband/redirect_html.go ports that exact byte template, including PHP's htmlspecialchars(ENT_QUOTES) escaping (Go's html.EscapeString uses different quote entities). tide/normalize.go: isIDKey now also masks "_ids" plural array fields (e.g. collection_ids), a latent parity-corpus gap no prior fixture had exercised with a literal, non-empty, non-placeholder array value.
418 lines
14 KiB
Go
418 lines
14 KiB
Go
// RFC 6749 token endpoint for MCP OAuth, ported from PHP
|
|
// OAuthTokenController::token / OAuthCodeManager::exchangeCode/rotateRefresh
|
|
// byte-for-byte including their validation order (08-CONTEXT.md
|
|
// D-02/D-04/D-05/D-07; canonical PHP source: OAuthTokenController.php,
|
|
// OAuthCodeManager.php).
|
|
//
|
|
// 08-06-PLAN.md completes grant_type=refresh_token: rotation with
|
|
// lineage-kill replay detection (T-08-REFRESH-REPLAY) and the D-17 expiry
|
|
// sweep that also runs here (in addition to /register).
|
|
package wristband
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// errInvalidClient is Token's internal signal that client authentication
|
|
// failed (T-08-SECRET-TIMING). It never reaches a response body directly:
|
|
// Token translates it into the exact 401 invalid_client body plus
|
|
// WWW-Authenticate: Basic realm="OAuth" (D-04/D-06).
|
|
var errInvalidClient = errors.New("wristband: invalid client")
|
|
|
|
// errInvalidGrant is Token's internal signal for every exact PHP
|
|
// OAuthInvalidGrantException case (missing/wrong code, expired code, PKCE
|
|
// mismatch, client/redirect/resource binding failure, replay). Token
|
|
// translates it into the exact 400 invalid_grant body with no description.
|
|
var errInvalidGrant = errors.New("wristband: invalid grant")
|
|
|
|
// tokenIssueResult is what a successful grant produces: the two raw secrets
|
|
// plus the response's scope/offline_access ingredients.
|
|
type tokenIssueResult struct {
|
|
AccessToken string
|
|
RefreshToken string
|
|
Scopes []string
|
|
OfflineAccess bool
|
|
}
|
|
|
|
type tokenSuccessBody struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
type tokenErrorBody struct {
|
|
// Error is the sole field: PHP's rfcError() writes {"error": code} with
|
|
// no error_description, unlike Register's richer error body.
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// Token handles POST /oauth/mcp/token (D-09: raw route, no middleware).
|
|
//
|
|
// D-02: a JSON request body is rejected before any form parsing, so query
|
|
// parameters on a JSON call can never smuggle a grant through (Pitfall 4).
|
|
// Otherwise every parameter comes from the merged r.Form (net/http's own
|
|
// ParseForm precedence puts body values ahead of query values — verified
|
|
// against the Go 1.27 stdlib source, not assumed). Basic credentials, when
|
|
// present, always override client_id/client_secret form values.
|
|
func (s *Server) Token(w http.ResponseWriter, r *http.Request) {
|
|
if isJSONContentType(r.Header.Get("Content-Type")) {
|
|
writeTokenError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
if s.backend == nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Errors from ParseForm on a malformed urlencoded body are not
|
|
// distinguished from "no fields at all": the grant_type-empty check
|
|
// below already produces the exact invalid_request PHP would give for
|
|
// an unparseable/empty request.
|
|
_ = r.ParseForm()
|
|
|
|
grantType := r.Form.Get("grant_type")
|
|
if grantType == "" {
|
|
writeTokenError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
if grantType != "authorization_code" && grantType != "refresh_token" {
|
|
writeTokenError(w, http.StatusBadRequest, "unsupported_grant_type")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// D-17: wristband's expiry sweep also runs on /token (PHP has no sweep
|
|
// at all here). It deletes only rows already past ExpiresAt; unexpired
|
|
// rotated/revoked refresh rows and unexpired used codes stay as replay
|
|
// evidence. A sweep failure is treated as an opaque 500 like any other
|
|
// store failure -- it must never silently skip and must never leak a
|
|
// house-shaped body onto this raw RFC endpoint.
|
|
sweepAt := s.now()
|
|
if err := s.backend.WithinTx(ctx, func(tx Tx) error {
|
|
if err := tx.DeleteExpiredCodes(ctx, sweepAt); err != nil {
|
|
return err
|
|
}
|
|
return tx.DeleteExpiredRefreshTokens(ctx, sweepAt)
|
|
}); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
client, err := s.authenticateClient(ctx, r)
|
|
if err != nil {
|
|
if errors.Is(err, errInvalidClient) {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="OAuth"`)
|
|
writeTokenError(w, http.StatusUnauthorized, "invalid_client")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var issued tokenIssueResult
|
|
if grantType == "authorization_code" {
|
|
issued, err = s.exchangeAuthorizationCode(ctx, r, client)
|
|
} else {
|
|
issued, err = s.rotateRefreshToken(ctx, r, client)
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, errInvalidGrant) {
|
|
writeTokenError(w, http.StatusBadRequest, "invalid_grant")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
scope := strings.Join(issued.Scopes, " ")
|
|
if issued.OfflineAccess && !stringSliceContains(issued.Scopes, "offline_access") {
|
|
scope = strings.TrimSpace(scope + " offline_access")
|
|
}
|
|
body := tokenSuccessBody{
|
|
AccessToken: issued.AccessToken,
|
|
TokenType: "Bearer",
|
|
ExpiresIn: int64(s.opts.AccessTokenTTL.Seconds()),
|
|
RefreshToken: issued.RefreshToken,
|
|
Scope: scope,
|
|
}
|
|
writeExactJSON(w, http.StatusOK, body, map[string]string{
|
|
"Cache-Control": "no-store, private",
|
|
"Pragma": "no-cache",
|
|
})
|
|
}
|
|
|
|
// authenticateClient ports OAuthTokenController::authenticateClient. Basic
|
|
// credentials override form credentials; a public client (auth method
|
|
// "none") needs no secret at all, but a confidential client with a missing
|
|
// or wrong secret is errInvalidClient (T-08-SECRET-TIMING: the secret
|
|
// comparison is a fixed-length sha256-hex constant-time compare, never a
|
|
// direct string compare of variable-length secrets).
|
|
func (s *Server) authenticateClient(ctx context.Context, r *http.Request) (*ClientRecord, error) {
|
|
clientID := r.Form.Get("client_id")
|
|
secret := r.Form.Get("client_secret")
|
|
|
|
if authz := r.Header.Get("Authorization"); strings.HasPrefix(authz, "Basic ") {
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authz, "Basic "))
|
|
if err != nil {
|
|
return nil, errInvalidClient
|
|
}
|
|
idx := strings.IndexByte(string(decoded), ':')
|
|
if idx < 0 {
|
|
return nil, errInvalidClient
|
|
}
|
|
clientID = string(decoded[:idx])
|
|
secret = string(decoded[idx+1:])
|
|
}
|
|
if clientID == "" {
|
|
return nil, errInvalidClient
|
|
}
|
|
|
|
var client *ClientRecord
|
|
err := s.backend.WithinTx(ctx, func(tx Tx) error {
|
|
c, err := tx.ByClientID(ctx, clientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client = c
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if client == nil || client.RevokedAt != nil {
|
|
return nil, errInvalidClient
|
|
}
|
|
if client.TokenEndpointAuthMethod == "none" {
|
|
return client, nil
|
|
}
|
|
if secret == "" || client.ClientSecretHash == nil {
|
|
return nil, errInvalidClient
|
|
}
|
|
if !constantEqual(*client.ClientSecretHash, sha256Hex(secret)) {
|
|
return nil, errInvalidClient
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// verifyPkce ports OAuthCodeManager::verifyPkce: method must be S256, and
|
|
// the comparison between the stored challenge and the verifier's S256
|
|
// transform is constant-time (T-08-PKCE/D-04).
|
|
func verifyPkce(verifier, challenge, method string) bool {
|
|
if method != "S256" {
|
|
return false
|
|
}
|
|
return constantEqual(challenge, s256Challenge(verifier))
|
|
}
|
|
|
|
// exchangeAuthorizationCode ports OAuthCodeManager::exchangeCode inside one
|
|
// WithinTx callback: lock the code row, validate every binding, consume it,
|
|
// mint the inv_ access token, and create its refresh-token successor
|
|
// atomically (D-07/T-08-CODE-REPLAY). A validation failure returns
|
|
// errInvalidGrant before any mutation, so the surrounding transaction has
|
|
// nothing to roll back; a second exchange attempt against an already-used
|
|
// row always loses (single-use row lock via ByCodeHashForUpdate).
|
|
func (s *Server) exchangeAuthorizationCode(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) {
|
|
code := r.Form.Get("code")
|
|
redirectURI := r.Form.Get("redirect_uri")
|
|
verifier := r.Form.Get("code_verifier")
|
|
resource := r.Form.Get("resource")
|
|
|
|
if code == "" || redirectURI == "" || verifier == "" {
|
|
return tokenIssueResult{}, errInvalidGrant
|
|
}
|
|
|
|
var result tokenIssueResult
|
|
err := s.backend.WithinTx(ctx, func(tx Tx) error {
|
|
rec, err := tx.ByCodeHashForUpdate(ctx, sha256Hex(code))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil ||
|
|
rec.UsedAt != nil ||
|
|
!rec.ExpiresAt.After(s.now()) ||
|
|
rec.UserID == nil ||
|
|
rec.ClientID != client.ClientID ||
|
|
rec.RedirectURI != redirectURI ||
|
|
(resource != "" && rec.Resource != nil && resource != *rec.Resource) ||
|
|
!verifyPkce(verifier, rec.CodeChallenge, rec.CodeChallengeMethod) {
|
|
return errInvalidGrant
|
|
}
|
|
|
|
if err := tx.MarkUsed(ctx, rec.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
name := truncateRunes(client.ClientName, 120)
|
|
expiresAt := s.now().Add(s.opts.AccessTokenTTL)
|
|
minted, err := tx.Mint(ctx, *rec.UserID, name, rec.Scopes, expiresAt, rec.CollectionIDs, client.ClientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rawRefresh, err := s.randomBytes(32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
accessTokenID := minted.ID
|
|
refreshRec := &RefreshTokenRecord{
|
|
TokenHash: sha256Hex(rawRefresh),
|
|
APITokenID: &accessTokenID,
|
|
ClientID: client.ClientID,
|
|
UserID: *rec.UserID,
|
|
Scopes: rec.Scopes,
|
|
CollectionIDs: rec.CollectionIDs,
|
|
ExpiresAt: s.now().Add(s.opts.RefreshTokenTTL),
|
|
OfflineAccess: rec.OfflineAccess,
|
|
}
|
|
if err := tx.Create(ctx, refreshRec); err != nil {
|
|
return err
|
|
}
|
|
|
|
result = tokenIssueResult{
|
|
AccessToken: minted.Secret,
|
|
RefreshToken: rawRefresh,
|
|
Scopes: rec.Scopes,
|
|
OfflineAccess: rec.OfflineAccess,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return tokenIssueResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// rotateRefreshToken ports OAuthCodeManager::rotateRefresh inside one
|
|
// WithinTx callback (D-04/D-05/D-07): the presented refresh secret is row-
|
|
// locked, validated (not expired, not revoked, bound to the authenticated
|
|
// client), and then either rotated (mint a new access token, revoke the old
|
|
// one, create and link a successor refresh row) or -- if it was already
|
|
// rotated once before -- treated as a replay: the entire lineage (every
|
|
// rotated-to successor and each linked access token) is revoked and the
|
|
// callback still returns nil so that revocation commits (T-08-REFRESH-
|
|
// REPLAY). Token then maps the recorded "replayed" outcome to invalid_grant
|
|
// outside the transaction, exactly mirroring PHP's own commit-then-throw
|
|
// shape: the security-relevant kill must land even though the protocol
|
|
// response is an error.
|
|
func (s *Server) rotateRefreshToken(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) {
|
|
raw := r.Form.Get("refresh_token")
|
|
if raw == "" {
|
|
return tokenIssueResult{}, errInvalidGrant
|
|
}
|
|
|
|
var result tokenIssueResult
|
|
var replayed bool
|
|
err := s.backend.WithinTx(ctx, func(tx Tx) error {
|
|
rec, err := tx.ByTokenHashForUpdate(ctx, sha256Hex(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil ||
|
|
!rec.ExpiresAt.After(s.now()) ||
|
|
rec.RevokedAt != nil ||
|
|
rec.ClientID != client.ClientID {
|
|
return errInvalidGrant
|
|
}
|
|
|
|
if rec.RotatedToID != nil {
|
|
// T-08-REFRESH-REPLAY: a spent (already-rotated) refresh token
|
|
// was presented again. Kill the whole lineage and commit that
|
|
// kill; the caller maps replayed -> invalid_grant afterward.
|
|
if err := tx.RevokeLineage(ctx, rec.ID); err != nil {
|
|
return err
|
|
}
|
|
replayed = true
|
|
return nil
|
|
}
|
|
|
|
if rec.APITokenID != nil {
|
|
if err := tx.Revoke(ctx, *rec.APITokenID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
name := truncateRunes(client.ClientName, 120)
|
|
expiresAt := s.now().Add(s.opts.AccessTokenTTL)
|
|
minted, err := tx.Mint(ctx, rec.UserID, name, rec.Scopes, expiresAt, rec.CollectionIDs, client.ClientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rawNew, err := s.randomBytes(32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
accessTokenID := minted.ID
|
|
successor := &RefreshTokenRecord{
|
|
TokenHash: sha256Hex(rawNew),
|
|
APITokenID: &accessTokenID,
|
|
ClientID: client.ClientID,
|
|
UserID: rec.UserID,
|
|
Scopes: rec.Scopes,
|
|
CollectionIDs: rec.CollectionIDs,
|
|
ExpiresAt: s.now().Add(s.opts.RefreshTokenTTL),
|
|
OfflineAccess: rec.OfflineAccess,
|
|
}
|
|
if err := tx.Create(ctx, successor); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.MarkRotated(ctx, rec.ID, successor.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
result = tokenIssueResult{
|
|
AccessToken: minted.Secret,
|
|
RefreshToken: rawNew,
|
|
Scopes: rec.Scopes,
|
|
OfflineAccess: rec.OfflineAccess,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return tokenIssueResult{}, err
|
|
}
|
|
if replayed {
|
|
return tokenIssueResult{}, errInvalidGrant
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Revoke atomically kills an OAuth-issued access token and its entire
|
|
// refresh-token lineage (08-06-PLAN.md D-08; PHP
|
|
// ConnectedAppController::destroy + OAuthCodeManager::revokeChain). It is
|
|
// the cascade-revoke seam the app's connected-app controller calls instead
|
|
// of touching refresh rows directly: the access token is revoked
|
|
// unconditionally, and if a refresh row is still linked to it, the whole
|
|
// lineage it anchors is revoked too (a live connected-app token is always
|
|
// the terminal row of its chain, so this also protects a stale predecessor
|
|
// replay from ever reviving it).
|
|
func (s *Server) Revoke(ctx context.Context, apiTokenID uint) error {
|
|
return s.backend.WithinTx(ctx, func(tx Tx) error {
|
|
if err := tx.Revoke(ctx, apiTokenID); err != nil {
|
|
return err
|
|
}
|
|
rec, err := tx.ByAPITokenIDForUpdate(ctx, apiTokenID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil {
|
|
return nil
|
|
}
|
|
return tx.RevokeLineage(ctx, rec.ID)
|
|
})
|
|
}
|
|
|
|
func writeTokenError(w http.ResponseWriter, status int, code string) {
|
|
// PHP's rfcError() sets no explicit Cache-Control; Laravel's own
|
|
// session-cookie default for an otherwise-unheadered JSON response is
|
|
// "no-cache, private" (matches the live-recorded byte contract, same
|
|
// default the house wire.WriteJSON convention already uses elsewhere).
|
|
writeExactJSON(w, status, tokenErrorBody{Error: code}, map[string]string{"Cache-Control": "no-cache, private"})
|
|
}
|