Files
summercms/wristband/token.go
Jakub Zych 4bd3b3db4f feat(08-04): implement atomic PKCE-bound code exchange in wristband
- Server.Token: JSON rejection before ParseForm, body-over-query precedence,
  Basic-over-form client auth, exact invalid_request/unsupported_grant_type/
  invalid_client/invalid_grant bodies, Cache-Control/Pragma on success only
- authenticateClient: public/confidential dispatch, constant-time secret
  compare (T-08-SECRET-TIMING)
- exchangeAuthorizationCode: single WithinTx lock/consume/mint/refresh-create
  covering code/client/redirect/resource/PKCE binding and single-use replay
  (T-08-CODE-REPLAY), sequential and concurrent proofs
- rotateRefreshToken: grant_type=refresh_token dispatches per PHP validity
  but is a deliberate invalid_grant placeholder; full rotation is 08-06
- full token_test.go behavior matrix appended alongside the RED anchor
2026-09-23 20:28:07 +02:00

290 lines
9.8 KiB
Go

// RFC 6749 token endpoint for MCP OAuth, ported from PHP
// OAuthTokenController::token / OAuthCodeManager::exchangeCode 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-04-PLAN.md ships the authorization_code grant only. grant_type=
// refresh_token is dispatched with the exact PHP-parity validity check (an
// unknown grant type is unsupported_grant_type; a known-but-not-yet-built
// grant is invalid_grant) but its full rotation/lineage-kill semantics
// (T-08-REFRESH-REPLAY) are ROADMAP.md Wave 6 (08-06-PLAN.md), not this
// plan's threat 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()
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",
"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 is 08-04's deliberate placeholder for grant_type=
// refresh_token: Token's own dispatch check already accepts this grant type
// exactly like PHP does, but rotation with lineage-kill replay detection
// (T-08-REFRESH-REPLAY) is 08-06's job (ROADMAP.md Wave 6). Every attempt in
// this plan's scope returns the same invalid_grant response PHP returns for
// a missing/unknown refresh token, never a minted credential.
func (s *Server) rotateRefreshToken(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) {
_ = ctx
_ = client
return tokenIssueResult{}, errInvalidGrant
}
func writeTokenError(w http.ResponseWriter, status int, code string) {
writeExactJSON(w, status, tokenErrorBody{Error: code}, nil)
}