- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
984 lines
36 KiB
Go
984 lines
36 KiB
Go
package wristband
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const tokenTestRedirect = "https://chatgpt.com/connector/oauth/cb"
|
|
|
|
// insertTokenTestClient inserts an already-usable ClientRecord directly into
|
|
// backend (bypassing CreateWithCap's cap/sweep policy, which this plan's
|
|
// tests do not exercise) and returns it.
|
|
func insertTokenTestClient(backend *memoryBackend, clientID, authMethod string, secretHash *string, ceiling []string) *ClientRecord {
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
backend.nextID++
|
|
rec := &ClientRecord{
|
|
ID: backend.nextID,
|
|
ClientID: clientID,
|
|
ClientSecretHash: secretHash,
|
|
ClientName: "Test Client",
|
|
RedirectURIs: []string{tokenTestRedirect},
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
TokenEndpointAuthMethod: authMethod,
|
|
ScopeCeiling: ceiling,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
backend.clients = append(backend.clients, rec)
|
|
return rec
|
|
}
|
|
|
|
// insertTokenTestCode seeds an already-issued (post-consent) code row
|
|
// directly into backend, matching the shape 08-05's consent flow will
|
|
// produce via AuthCodeStore.MarkIssued: CodeHash set, RequestID nil, UserID
|
|
// set. mutate, when non-nil, is applied to the record before it is stored so
|
|
// individual tests can adjust ExpiresAt/UsedAt/ClientID/etc.
|
|
func insertTokenTestCode(t *testing.T, backend *memoryBackend, clientID string, challenge string, mutate func(*AuthCodeRecord)) (rawCode string, rec *AuthCodeRecord) {
|
|
t.Helper()
|
|
raw, err := randomBase64URL(32)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
backend.nextID++
|
|
userID := uint(1)
|
|
hash := sha256Hex(raw)
|
|
rec = &AuthCodeRecord{
|
|
ID: backend.nextID,
|
|
CodeHash: &hash,
|
|
ClientID: clientID,
|
|
UserID: &userID,
|
|
RedirectURI: tokenTestRedirect,
|
|
Scopes: []string{"read", "write"},
|
|
CodeChallenge: challenge,
|
|
CodeChallengeMethod: "S256",
|
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
|
}
|
|
if mutate != nil {
|
|
mutate(rec)
|
|
}
|
|
backend.codes = append(backend.codes, rec)
|
|
return raw, rec
|
|
}
|
|
|
|
// tokenRequest builds a POST /oauth/mcp/token request from form (encoded as
|
|
// the body) with an optional Authorization header, matching the D-02 body
|
|
// parser every test in this file exercises.
|
|
func tokenRequest(form url.Values, contentType string) *http.Request {
|
|
if contentType == "" {
|
|
contentType = "application/x-www-form-urlencoded"
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", contentType)
|
|
return req
|
|
}
|
|
|
|
// TestPhase8RedCodeExchange is the Phase 8 Wave 4 RED anchor (08-04-PLAN.md
|
|
// Task 1, D-02/D-04/D-05/D-07). It drives one valid S256 authorization-code
|
|
// exchange through the real (in-memory-backed) Server.Token and asserts the
|
|
// exact RFC 6749 success contract. It fails with the
|
|
// PHASE8_RED:code-exchange sentinel while Token is the 501 stub;
|
|
// scripts/check-phase8-red.sh verifies this failure is fail-closed.
|
|
func TestPhase8RedCodeExchange(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-red", "none", nil, nil)
|
|
verifier, challenge := s256Pair(t)
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-red", challenge, nil)
|
|
|
|
req := tokenRequest(url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {rawCode},
|
|
"code_verifier": {verifier},
|
|
"redirect_uri": {tokenTestRedirect},
|
|
"client_id": {"cli-red"},
|
|
}, "")
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PHASE8_RED:code-exchange: 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.Fatalf("PHASE8_RED:code-exchange: decode response: %v", err)
|
|
}
|
|
access, _ := got["access_token"].(string)
|
|
refresh, _ := got["refresh_token"].(string)
|
|
if access == "" || refresh == "" {
|
|
t.Fatalf("PHASE8_RED:code-exchange: access_token/refresh_token empty in %v", got)
|
|
}
|
|
if got["token_type"] != "Bearer" {
|
|
t.Fatalf("PHASE8_RED:code-exchange: token_type = %v, want Bearer", got["token_type"])
|
|
}
|
|
if got["scope"] != "read write" {
|
|
t.Fatalf("PHASE8_RED:code-exchange: scope = %v, want %q", got["scope"], "read write")
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
|
t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store, private\"", 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 != "no-cache, private" {
|
|
t.Fatalf("Cache-Control = %q, want %q", cc, "no-cache, private")
|
|
}
|
|
}
|
|
|
|
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, private" {
|
|
t.Fatalf("Cache-Control = %q, want \"no-store, private\"", 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): a syntactically well-formed but
|
|
// never-issued refresh secret reaches rotateRefreshToken's real lookup and
|
|
// is rejected as invalid_grant, not unsupported_grant_type.
|
|
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")
|
|
}
|
|
|
|
// TestPhase8RedLifecycleFramework is the Phase 8 Wave 6 RED anchor
|
|
// (08-06-PLAN.md Task 1, D-04/D-17). It drives a full refresh-rotation and
|
|
// replay lifecycle against the real (in-memory-backed) Server.Token:
|
|
// exchange a code, rotate the resulting refresh token, replay the spent
|
|
// original, and prove the whole lineage -- both access tokens and both
|
|
// refresh rows -- ends up dead. It fails with the
|
|
// PHASE8_RED:lifecycle-framework sentinel while rotateRefreshToken is
|
|
// 08-04's invalid_grant placeholder (the rotate step below expects 200 but
|
|
// gets 400); scripts/check-phase8-red.sh verifies this failure is
|
|
// fail-closed.
|
|
func TestPhase8RedLifecycleFramework(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-lifecycle", "none", nil, nil)
|
|
verifier, challenge := s256Pair(t)
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-lifecycle", challenge, nil)
|
|
|
|
first := httptest.NewRecorder()
|
|
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-lifecycle"), ""))
|
|
if first.Code != http.StatusOK {
|
|
t.Fatalf("PHASE8_RED:lifecycle-framework: exchange status = %d, want %d (body=%s)", first.Code, http.StatusOK, first.Body.String())
|
|
}
|
|
firstAccess, firstRefresh := decodeTokenPair(t, first)
|
|
|
|
rotate := httptest.NewRecorder()
|
|
srv.Token(rotate, refreshRequest(firstRefresh, "cli-lifecycle"))
|
|
if rotate.Code != http.StatusOK {
|
|
t.Fatalf("PHASE8_RED:lifecycle-framework: rotation status = %d, want %d (body=%s)", rotate.Code, http.StatusOK, rotate.Body.String())
|
|
}
|
|
secondAccess, secondRefresh := decodeTokenPair(t, rotate)
|
|
if secondRefresh == firstRefresh {
|
|
t.Fatalf("PHASE8_RED:lifecycle-framework: rotation must issue a new refresh secret")
|
|
}
|
|
|
|
replay := httptest.NewRecorder()
|
|
srv.Token(replay, refreshRequest(firstRefresh, "cli-lifecycle"))
|
|
if replay.Code != http.StatusBadRequest {
|
|
t.Fatalf("PHASE8_RED:lifecycle-framework: replay status = %d, want %d (body=%s)", replay.Code, http.StatusBadRequest, replay.Body.String())
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
if !refreshRowRevoked(backend, firstRefresh) {
|
|
t.Fatal("PHASE8_RED:lifecycle-framework: original refresh row must be revoked after replay")
|
|
}
|
|
if !refreshRowRevoked(backend, secondRefresh) {
|
|
t.Fatal("PHASE8_RED:lifecycle-framework: rotated successor refresh row must be revoked after replay of its predecessor")
|
|
}
|
|
if !accessTokenRevoked(backend, firstAccess) {
|
|
t.Fatal("PHASE8_RED:lifecycle-framework: original access token must be revoked")
|
|
}
|
|
if !accessTokenRevoked(backend, secondAccess) {
|
|
t.Fatal("PHASE8_RED:lifecycle-framework: rotated access token must be revoked after replay")
|
|
}
|
|
}
|
|
|
|
// decodeTokenPair extracts access_token/refresh_token from a successful
|
|
// Token response body.
|
|
func decodeTokenPair(t *testing.T, rec *httptest.ResponseRecorder) (access, refresh string) {
|
|
t.Helper()
|
|
var got map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode token body: %v (body=%s)", err, rec.Body.String())
|
|
}
|
|
access, _ = got["access_token"].(string)
|
|
refresh, _ = got["refresh_token"].(string)
|
|
if access == "" || refresh == "" {
|
|
t.Fatalf("access_token/refresh_token empty in %v", got)
|
|
}
|
|
return access, refresh
|
|
}
|
|
|
|
// refreshRequest builds a POST /oauth/mcp/token grant_type=refresh_token
|
|
// request.
|
|
func refreshRequest(rawRefresh, clientID string) *http.Request {
|
|
return tokenRequest(url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {rawRefresh},
|
|
"client_id": {clientID},
|
|
}, "")
|
|
}
|
|
|
|
// refreshRowRevoked reports whether the refresh row matching rawRefresh has
|
|
// a non-nil RevokedAt. The caller must already hold backend.mu.
|
|
func refreshRowRevoked(backend *memoryBackend, rawRefresh string) bool {
|
|
hash := sha256Hex(rawRefresh)
|
|
for _, r := range backend.refresh {
|
|
if r.TokenHash == hash {
|
|
return r.RevokedAt != nil
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// accessTokenRevoked reports whether the IssuedToken matching rawAccess is
|
|
// marked revoked in backend.revoked. The caller must already hold
|
|
// backend.mu.
|
|
func accessTokenRevoked(backend *memoryBackend, rawAccess string) bool {
|
|
for _, tok := range backend.tokens {
|
|
if tok.Secret == rawAccess {
|
|
return backend.revoked[tok.ID]
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence proves D-04's
|
|
// normal-path rotation: a fresh (not-yet-rotated) refresh token succeeds
|
|
// exactly once, mints a new access/refresh pair with the same scopes, and
|
|
// leaves the predecessor row retrievable (not deleted) with RotatedToID set
|
|
// but RevokedAt nil -- a rotated-but-unreplayed row is not itself "revoked"
|
|
// (D-17 evidence retention).
|
|
func TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-rotate", "none", nil, nil)
|
|
verifier, challenge := s256Pair(t)
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-rotate", challenge, nil)
|
|
|
|
first := httptest.NewRecorder()
|
|
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-rotate"), ""))
|
|
firstAccess, firstRefresh := decodeTokenPair(t, first)
|
|
|
|
rotate := httptest.NewRecorder()
|
|
srv.Token(rotate, refreshRequest(firstRefresh, "cli-rotate"))
|
|
if rotate.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d (body=%s)", rotate.Code, http.StatusOK, rotate.Body.String())
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(rotate.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["scope"] != "read write" {
|
|
t.Fatalf("scope = %v, want %q", got["scope"], "read write")
|
|
}
|
|
secondAccess, secondRefresh := decodeTokenPair(t, rotate)
|
|
if secondAccess == firstAccess || secondRefresh == firstRefresh {
|
|
t.Fatal("rotation must mint a brand new access/refresh pair")
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
hash := sha256Hex(firstRefresh)
|
|
for _, r := range backend.refresh {
|
|
if r.TokenHash == hash {
|
|
if r.RevokedAt != nil {
|
|
t.Fatal("a rotated-but-unreplayed predecessor must not itself be revoked")
|
|
}
|
|
if r.RotatedToID == nil {
|
|
t.Fatal("predecessor must have RotatedToID set to its successor")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("predecessor refresh row not found (must not be deleted)")
|
|
}
|
|
|
|
// TestRefreshWrongClientIsInvalidGrant proves the client-binding check: a
|
|
// refresh token issued to one client cannot be redeemed by another.
|
|
func TestRefreshWrongClientIsInvalidGrant(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-owner-r", "none", nil, nil)
|
|
insertTokenTestClient(backend, "cli-other-r", "none", nil, nil)
|
|
verifier, challenge := s256Pair(t)
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-owner-r", challenge, nil)
|
|
|
|
first := httptest.NewRecorder()
|
|
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-owner-r"), ""))
|
|
_, firstRefresh := decodeTokenPair(t, first)
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, refreshRequest(firstRefresh, "cli-other-r"))
|
|
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
|
}
|
|
|
|
// TestRefreshExpiredIsInvalidGrant proves an expired refresh row is
|
|
// rejected even though it was never rotated or revoked.
|
|
func TestRefreshExpiredIsInvalidGrant(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-refresh-expired", "none", nil, nil)
|
|
verifier, challenge := s256Pair(t)
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-refresh-expired", challenge, nil)
|
|
|
|
first := httptest.NewRecorder()
|
|
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-refresh-expired"), ""))
|
|
_, firstRefresh := decodeTokenPair(t, first)
|
|
|
|
backend.mu.Lock()
|
|
hash := sha256Hex(firstRefresh)
|
|
for _, r := range backend.refresh {
|
|
if r.TokenHash == hash {
|
|
r.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
|
}
|
|
}
|
|
backend.mu.Unlock()
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, refreshRequest(firstRefresh, "cli-refresh-expired"))
|
|
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
|
}
|
|
|
|
// TestRefreshUnknownTokenIsInvalidGrant proves a syntactically valid but
|
|
// never-issued refresh secret is rejected exactly like PHP's missing-record
|
|
// case, not distinguished from any other invalid_grant.
|
|
func TestRefreshUnknownTokenIsInvalidGrant(t *testing.T) {
|
|
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, refreshRequest("does-not-exist-at-all", "cli-tok"))
|
|
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
|
}
|
|
|
|
// TestRefreshMissingTokenIsInvalidGrant proves an empty refresh_token value
|
|
// is rejected before any store lookup.
|
|
func TestRefreshMissingTokenIsInvalidGrant(t *testing.T) {
|
|
srv, _, _, _, _ := newTokenExchangeFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, tokenRequest(url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"client_id": {"cli-tok"},
|
|
}, ""))
|
|
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
|
}
|
|
|
|
// TestRefreshConcurrentReplayHasExactlyOneWinner is the concurrent half of
|
|
// T-08-REFRESH-REPLAY: two synchronized goroutines racing to rotate the same
|
|
// refresh token must produce exactly one 200 and one invalid_grant, never
|
|
// two successors sharing one predecessor.
|
|
func TestRefreshConcurrentReplayHasExactlyOneWinner(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-refresh-race", "none", nil, nil)
|
|
verifier, challenge := s256Pair(t)
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-refresh-race", challenge, nil)
|
|
|
|
first := httptest.NewRecorder()
|
|
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-refresh-race"), ""))
|
|
_, firstRefresh := decodeTokenPair(t, first)
|
|
|
|
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, refreshRequest(firstRefresh, "cli-refresh-race"))
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence proves D-17: an
|
|
// expired pending/code row and an expired refresh row are gone after the
|
|
// next /token call's sweep, while an unexpired-but-revoked refresh row (real
|
|
// replay evidence) survives untouched.
|
|
func TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-sweep", "none", nil, nil)
|
|
|
|
_, expiredCode := insertTokenTestCode(t, backend, "cli-sweep", "unused-challenge", func(rec *AuthCodeRecord) {
|
|
rec.ExpiresAt = time.Now().Add(-1 * time.Hour)
|
|
})
|
|
expiredCodeID := expiredCode.ID
|
|
|
|
backend.mu.Lock()
|
|
backend.nextID++
|
|
expiredRefreshID := backend.nextID
|
|
backend.refresh = append(backend.refresh, &RefreshTokenRecord{
|
|
ID: expiredRefreshID,
|
|
TokenHash: sha256Hex("expired-refresh-secret"),
|
|
ClientID: "cli-sweep",
|
|
UserID: 1,
|
|
Scopes: []string{"read"},
|
|
ExpiresAt: time.Now().Add(-1 * time.Hour),
|
|
})
|
|
now := time.Now()
|
|
backend.nextID++
|
|
evidenceRefreshID := backend.nextID
|
|
backend.refresh = append(backend.refresh, &RefreshTokenRecord{
|
|
ID: evidenceRefreshID,
|
|
TokenHash: sha256Hex("revoked-but-unexpired-refresh-secret"),
|
|
ClientID: "cli-sweep",
|
|
UserID: 1,
|
|
Scopes: []string{"read"},
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
RevokedAt: &now,
|
|
})
|
|
backend.mu.Unlock()
|
|
|
|
// Any /token call runs the sweep; use a deliberately-broken grant so no
|
|
// mutation beyond the sweep happens.
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, tokenRequest(url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {"does-not-exist"},
|
|
"client_id": {"cli-sweep"},
|
|
}, ""))
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
for _, c := range backend.codes {
|
|
if c.ID == expiredCodeID {
|
|
t.Fatal("expired code row must be swept")
|
|
}
|
|
}
|
|
for _, r := range backend.refresh {
|
|
if r.ID == expiredRefreshID {
|
|
t.Fatal("expired refresh row must be swept")
|
|
}
|
|
}
|
|
found := false
|
|
for _, r := range backend.refresh {
|
|
if r.ID == evidenceRefreshID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("unexpired revoked refresh row must survive the sweep as replay evidence")
|
|
}
|
|
}
|
|
|
|
// TestServerRevokeKillsAccessAndLineage proves Server.Revoke (the seam the
|
|
// app's connected-app controller calls, 08-06-PLAN.md D-08): revoking a live
|
|
// OAuth access token also revokes its linked refresh row.
|
|
func TestServerRevokeKillsAccessAndLineage(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-revoke", "none", nil, nil)
|
|
verifier, challenge := s256Pair(t)
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-revoke", challenge, nil)
|
|
|
|
exchange := httptest.NewRecorder()
|
|
srv.Token(exchange, tokenRequest(validExchangeForm(rawCode, verifier, "cli-revoke"), ""))
|
|
access, refresh := decodeTokenPair(t, exchange)
|
|
|
|
backend.mu.Lock()
|
|
var tokenID uint
|
|
for _, tok := range backend.tokens {
|
|
if tok.Secret == access {
|
|
tokenID = tok.ID
|
|
}
|
|
}
|
|
backend.mu.Unlock()
|
|
if tokenID == 0 {
|
|
t.Fatal("minted access token not found in backend")
|
|
}
|
|
|
|
if err := srv.Revoke(t.Context(), tokenID); err != nil {
|
|
t.Fatalf("Revoke: %v", err)
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
if !backend.revoked[tokenID] {
|
|
t.Fatal("access token must be revoked")
|
|
}
|
|
if !refreshRowRevoked(backend, refresh) {
|
|
t.Fatal("linked refresh row must be revoked")
|
|
}
|
|
backend.mu.Unlock()
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, refreshRequest(refresh, "cli-revoke"))
|
|
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)
|
|
}
|
|
}
|