// 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) }