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