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
This commit is contained in:
@@ -11,11 +11,279 @@
|
||||
// plan's threat register.
|
||||
package wristband
|
||||
|
||||
import "net/http"
|
||||
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).
|
||||
// This is the Phase 8 Wave 4 RED stub (08-04-PLAN.md Task 1): it always
|
||||
// responds 501 until Task 2 implements the real handler.
|
||||
//
|
||||
// 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) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -125,3 +125,471 @@ func TestPhase8RedCodeExchange(t *testing.T) {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store\"", cc)
|
||||
}
|
||||
}
|
||||
|
||||
// assertTokenError decodes rec as the exact PHP token error body ({"error":
|
||||
// code}, no error_description) and asserts status/code.
|
||||
func assertTokenError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) map[string]any {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, status, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode error body: %v (body=%s)", err, rec.Body.String())
|
||||
}
|
||||
if got["error"] != code {
|
||||
t.Fatalf("error = %v, want %q", got["error"], code)
|
||||
}
|
||||
if _, has := got["error_description"]; has {
|
||||
t.Fatalf("body = %s carries error_description, PHP token errors never do", rec.Body.String())
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// newTokenExchangeFixture seeds one usable public client and one valid
|
||||
// pending-issued code bound to it, returning everything a caller needs to
|
||||
// build a successful exchange request (and to mutate before breaking it).
|
||||
func newTokenExchangeFixture(t *testing.T) (srv *Server, backend *memoryBackend, verifier string, rawCode string, code *AuthCodeRecord) {
|
||||
t.Helper()
|
||||
backend = newMemoryBackend()
|
||||
srv = newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-tok", "none", nil, nil)
|
||||
var challenge string
|
||||
verifier, challenge = s256Pair(t)
|
||||
rawCode, code = insertTokenTestCode(t, backend, "cli-tok", challenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
func validExchangeForm(rawCode, verifier, clientID string) url.Values {
|
||||
return url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {rawCode},
|
||||
"code_verifier": {verifier},
|
||||
"redirect_uri": {tokenTestRedirect},
|
||||
"client_id": {clientID},
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenRejectsJSONBodyEvenWithValidQueryParams proves D-02/Pitfall 4: a
|
||||
// JSON content type is rejected before ParseForm ever runs, so a valid
|
||||
// grant cannot be smuggled through the query string of a JSON-labeled
|
||||
// request.
|
||||
func TestTokenRejectsJSONBodyEvenWithValidQueryParams(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
|
||||
q := validExchangeForm(rawCode, verifier, "cli-tok")
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token?"+q.Encode(), strings.NewReader(`{"grant_type":"authorization_code"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_request")
|
||||
}
|
||||
|
||||
// TestTokenBodyOverQueryPrecedence proves D-02: when the same key appears in
|
||||
// both the form body and the query string, the body value wins (matching
|
||||
// net/http's own documented ParseForm precedence, verified against the
|
||||
// stdlib source for this plan).
|
||||
func TestTokenBodyOverQueryPrecedence(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
|
||||
form := validExchangeForm(rawCode, verifier, "cli-tok")
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token?grant_type=refresh_token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%s): body grant_type must win over query grant_type", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenBasicCredentialsOverrideFormCredentials proves the confidential
|
||||
// client's Basic header wins over (wrong) form client_id/client_secret
|
||||
// values.
|
||||
func TestTokenBasicCredentialsOverrideFormCredentials(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
hash := sha256Hex("correct-secret")
|
||||
insertTokenTestClient(backend, "cli-basic", "client_secret_basic", &hash, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-basic", challenge, nil)
|
||||
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {rawCode},
|
||||
"code_verifier": {verifier},
|
||||
"redirect_uri": {tokenTestRedirect},
|
||||
"client_id": {"cli-basic"},
|
||||
"client_secret": {"wrong-form-secret"},
|
||||
}
|
||||
req := tokenRequest(form, "")
|
||||
req.SetBasicAuth("cli-basic", "correct-secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%s): Basic header must override form client_secret", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMissingGrantTypeIsInvalidRequest(t *testing.T) {
|
||||
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
||||
req := tokenRequest(url.Values{}, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_request")
|
||||
}
|
||||
|
||||
func TestTokenUnsupportedGrantTypeIsRejected(t *testing.T) {
|
||||
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
||||
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}}, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "unsupported_grant_type")
|
||||
}
|
||||
|
||||
func TestTokenUnknownClientIsInvalidClientWithBasicChallenge(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
form := validExchangeForm(rawCode, verifier, "does-not-exist")
|
||||
req := tokenRequest(form, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
|
||||
assertTokenError(t, rec, http.StatusUnauthorized, "invalid_client")
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` {
|
||||
t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRevokedClientIsInvalidClient(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
client := insertTokenTestClient(backend, "cli-revoked", "none", nil, nil)
|
||||
now := time.Now()
|
||||
client.RevokedAt = &now
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-revoked", challenge, nil)
|
||||
|
||||
req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-revoked"), "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusUnauthorized, "invalid_client")
|
||||
}
|
||||
|
||||
func TestTokenConfidentialClientMissingSecretIsInvalidClient(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
hash := sha256Hex("s3cret")
|
||||
insertTokenTestClient(backend, "cli-conf-missing", "client_secret_post", &hash, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-conf-missing", challenge, nil)
|
||||
|
||||
req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-conf-missing"), "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusUnauthorized, "invalid_client")
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` {
|
||||
t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenConfidentialClientWrongSecretIsInvalidClient is the plan's named
|
||||
// "invalid confidential client" case: exact status/body/no-newline,
|
||||
// Cache-Control absent (only success responses carry it), and the Basic
|
||||
// realm="OAuth" challenge (T-08-SECRET-TIMING: comparison goes through
|
||||
// constantEqual/sha256Hex, never a direct string compare).
|
||||
func TestTokenConfidentialClientWrongSecretIsInvalidClient(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
hash := sha256Hex("correct-secret")
|
||||
insertTokenTestClient(backend, "cli-conf-wrong", "client_secret_post", &hash, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-conf-wrong", challenge, nil)
|
||||
|
||||
form := validExchangeForm(rawCode, verifier, "cli-conf-wrong")
|
||||
form.Set("client_secret", "wrong-secret")
|
||||
req := tokenRequest(form, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if body := rec.Body.String(); body != `{"error":"invalid_client"}` {
|
||||
t.Fatalf("body = %q, want exact %q", body, `{"error":"invalid_client"}`)
|
||||
}
|
||||
if strings.HasSuffix(rec.Body.String(), "\n") {
|
||||
t.Fatal("body has a trailing newline")
|
||||
}
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` {
|
||||
t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "" {
|
||||
t.Fatalf("Cache-Control = %q, want none on an error response", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPublicClientIgnoresSuppliedSecret(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
form := validExchangeForm(rawCode, verifier, "cli-tok")
|
||||
form.Set("client_secret", "irrelevant-for-a-public-client")
|
||||
req := tokenRequest(form, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenWrongVerifierIsInvalidGrant(t *testing.T) {
|
||||
srv, backend, _, rawCode, _ := newTokenExchangeFixture(t)
|
||||
form := validExchangeForm(rawCode, "wrong-verifier-entirely", "cli-tok")
|
||||
req := tokenRequest(form, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
if len(backend.tokens) != 0 {
|
||||
t.Fatal("a wrong-verifier exchange must not mint an access token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenClientMismatchIsInvalidGrant(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-owner", "none", nil, nil)
|
||||
insertTokenTestClient(backend, "cli-other", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-owner", challenge, nil)
|
||||
|
||||
req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-other"), "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestTokenRedirectURIMismatchIsInvalidGrant(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
form := validExchangeForm(rawCode, verifier, "cli-tok")
|
||||
form.Set("redirect_uri", "https://evil.example.test/cb")
|
||||
req := tokenRequest(form, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestTokenResourceMismatchIsInvalidGrant(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-res", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
res := "https://mcp.plytarium.com/mcp"
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-res", challenge, func(rec *AuthCodeRecord) {
|
||||
rec.Resource = &res
|
||||
})
|
||||
|
||||
form := validExchangeForm(rawCode, verifier, "cli-res")
|
||||
form.Set("resource", "https://wrong.example.test/mcp")
|
||||
req := tokenRequest(form, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestTokenResourceOmittedIsAccepted(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-res-omit", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
res := "https://mcp.plytarium.com/mcp"
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-res-omit", challenge, func(rec *AuthCodeRecord) {
|
||||
rec.Resource = &res
|
||||
})
|
||||
|
||||
req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-res-omit"), "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExpiredCodeIsInvalidGrant(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-expired", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-expired", challenge, func(rec *AuthCodeRecord) {
|
||||
rec.ExpiresAt = time.Now().Add(-1 * time.Second)
|
||||
})
|
||||
|
||||
req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-expired"), "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestTokenMissingRequiredFieldsAreInvalidGrant(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
strip func(url.Values)
|
||||
}{
|
||||
{"missing-code", func(v url.Values) { v.Del("code") }},
|
||||
{"missing-redirect-uri", func(v url.Values) { v.Del("redirect_uri") }},
|
||||
{"missing-code-verifier", func(v url.Values) { v.Del("code_verifier") }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
form := validExchangeForm(rawCode, verifier, "cli-tok")
|
||||
tc.strip(form)
|
||||
req := tokenRequest(form, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenCodeSequentialReplayIsInvalidGrantSecondTime is the sequential
|
||||
// half of T-08-CODE-REPLAY: exchanging the same code twice succeeds exactly
|
||||
// once.
|
||||
func TestTokenCodeSequentialReplayIsInvalidGrantSecondTime(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
form := validExchangeForm(rawCode, verifier, "cli-tok")
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
srv.Token(first, tokenRequest(form, ""))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first exchange status = %d, want %d (body=%s)", first.Code, http.StatusOK, first.Body.String())
|
||||
}
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
srv.Token(second, tokenRequest(form, ""))
|
||||
assertTokenError(t, second, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
// TestTokenCodeConcurrentReplayHasExactlyOneWinner is the concurrent half of
|
||||
// T-08-CODE-REPLAY (Pitfall 8): two synchronized goroutines racing to
|
||||
// exchange the same code must produce exactly one 200 and one invalid_grant,
|
||||
// never two successes. memoryBackend serializes the whole WithinTx closure
|
||||
// behind one mutex (08-PATTERNS.md), which is exactly the seam this test
|
||||
// exercises; the real-Postgres row-lock proof lives in fonoteka.go's
|
||||
// classes/auth package.
|
||||
func TestTokenCodeConcurrentReplayHasExactlyOneWinner(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
form := validExchangeForm(rawCode, verifier, "cli-tok")
|
||||
|
||||
results := make([]int, 2)
|
||||
start := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
for i := range 2 {
|
||||
go func(i int) {
|
||||
<-start
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, tokenRequest(form, ""))
|
||||
results[i] = rec.Code
|
||||
done <- struct{}{}
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
<-done
|
||||
<-done
|
||||
|
||||
successCount, grantErrCount := 0, 0
|
||||
for _, code := range results {
|
||||
switch code {
|
||||
case http.StatusOK:
|
||||
successCount++
|
||||
case http.StatusBadRequest:
|
||||
grantErrCount++
|
||||
default:
|
||||
t.Fatalf("unexpected status %d", code)
|
||||
}
|
||||
}
|
||||
if successCount != 1 || grantErrCount != 1 {
|
||||
t.Fatalf("successCount=%d grantErrCount=%d, want 1 and 1 (results=%v)", successCount, grantErrCount, results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenOfflineAccessAppendedToScope(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-offline", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-offline", challenge, func(rec *AuthCodeRecord) {
|
||||
rec.OfflineAccess = true
|
||||
})
|
||||
|
||||
req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-offline"), "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["scope"] != "read write offline_access" {
|
||||
t.Fatalf("scope = %v, want %q", got["scope"], "read write offline_access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenSuccessResponseHasNoEnvelopeAndNoTrailingNewline(t *testing.T) {
|
||||
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
|
||||
req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-tok"), "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
|
||||
if strings.HasSuffix(rec.Body.String(), "\n") {
|
||||
t.Fatal("body has a trailing newline")
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, hasData := got["data"]; hasData {
|
||||
t.Fatal("body has a house \"data\" envelope")
|
||||
}
|
||||
if got["expires_in"] != float64(3600) {
|
||||
t.Fatalf("expires_in = %v, want 3600", got["expires_in"])
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want \"no-store\"", cc)
|
||||
}
|
||||
if p := rec.Header().Get("Pragma"); p != "no-cache" {
|
||||
t.Fatalf("Pragma = %q, want \"no-cache\"", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented proves Token's
|
||||
// own grant-type validity check accepts "refresh_token" exactly like PHP
|
||||
// does (it is not unsupported_grant_type), while full rotation is 08-06's
|
||||
// job in this plan's scope (see rotateRefreshToken).
|
||||
func TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented(t *testing.T) {
|
||||
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
||||
req := tokenRequest(url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {"whatever"},
|
||||
"client_id": {"cli-tok"},
|
||||
}, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestTokenBackendUnavailableIsOpaque500(t *testing.T) {
|
||||
opts := DefaultOptions()
|
||||
opts.Issuer = "https://plytarium.com"
|
||||
srv := NewServer(opts)
|
||||
req := tokenRequest(url.Values{"grant_type": {"authorization_code"}}, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user