Files
pocket-id/backend/internal/oidc/introspection_handler_test.go
2026-07-06 12:25:02 -07:00

209 lines
7.2 KiB
Go

package oidc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/model"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// TestIntrospectionHandlerBindsTokenToCallerClient verifies that an RFC 6750 bearer-auth
// caller (which omits client_id) can only introspect its own client's tokens. A client
// must not be able to introspect another client's token — doing so would leak the token's
// validity and the user PII it carries.
func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-a"}, Name: "Client A"}).Error)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-b"}, Name: "Client B"}).Error)
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
})
require.NoError(t, err)
issueAccessToken := func(t *testing.T, requestID, clientID, subject string) string {
t.Helper()
session := NewEmptySession()
session.Subject = subject
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
request := fosite.NewAccessRequest(session)
request.ID = requestID
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
request.RequestedScope = fosite.Arguments{"openid"}
request.GrantedScope = fosite.Arguments{"openid"}
request.RequestedAudience = fosite.Arguments{clientID}
request.GrantedAudience = fosite.Arguments{clientID}
response, err := provider.NewAccessResponse(t.Context(), request)
require.NoError(t, err)
return response.GetAccessToken()
}
clientAToken := issueAccessToken(t, "req-a", "client-a", "user-a")
clientBToken := issueAccessToken(t, "req-b", "client-b", "user-b")
clientBOtherToken := issueAccessToken(t, "req-b-2", "client-b", "user-b")
handler := newIntrospectionHandler(provider, nil, "https://issuer.example.com")
introspect := func(t *testing.T, bearer, token string) map[string]any {
t.Helper()
body := url.Values{"token": {token}}.Encode()
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+bearer)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
handler.introspectToken(c)
var out map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
return out
}
// A client using its own token as bearer must not learn about another client's token.
cross := introspect(t, clientBToken, clientAToken)
require.Equal(t, false, cross["active"])
// A client may introspect another of its own tokens.
same := introspect(t, clientBToken, clientBOtherToken)
require.Equal(t, true, same["active"])
}
func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T) {
gin.SetMode(gin.TestMode)
const (
baseURL = "https://issuer.example.com"
issuer = "https://idp.example.com"
clientID = "federated-client"
subject = "external-workload"
audience = "api://pocket-id"
jwksURL = "https://idp.example.com/jwks.json"
)
signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
require.NoError(t, err)
signingAlg, ok := signingKey.Algorithm()
require.True(t, ok)
publicKey, err := signingKey.PublicKey()
require.NoError(t, err)
jwks := jwk.NewSet()
require.NoError(t, jwks.AddKey(publicKey))
db := testutils.NewDatabaseForTest(t)
replayProtection := false
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Federated Client",
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{{
Issuer: issuer,
Subject: subject,
Audience: audience,
JWKS: jwksURL,
ReplayProtection: replayProtection,
}},
},
}).Error)
store := NewStore(db, nil)
authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), baseURL)
require.NoError(t, err)
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
BaseURL: baseURL,
TokenBaseURL: baseURL,
Secret: []byte("test-secret"),
})
require.NoError(t, err)
session := NewEmptySession()
session.Subject = "user-a"
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
accessRequest := fosite.NewAccessRequest(session)
accessRequest.ID = "replay-test-request"
accessRequest.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
accessRequest.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
accessRequest.RequestedScope = fosite.Arguments{"openid"}
accessRequest.GrantedScope = fosite.Arguments{"openid"}
accessRequest.RequestedAudience = fosite.Arguments{clientID}
accessRequest.GrantedAudience = fosite.Arguments{clientID}
accessResponse, err := provider.NewAccessResponse(t.Context(), accessRequest)
require.NoError(t, err)
assertionToken, err := jwt.NewBuilder().
Issuer(issuer).
Subject(subject).
Audience([]string{audience}).
IssuedAt(time.Now()).
Expiration(time.Now().Add(5 * time.Minute)).
JwtID("cached-provider-token").
Build()
require.NoError(t, err)
signedAssertion, err := jwt.Sign(assertionToken, jwt.WithKey(signingAlg, signingKey))
require.NoError(t, err)
handler := newIntrospectionHandler(provider, authenticator, baseURL)
introspect := func(t *testing.T) (int, map[string]any) {
t.Helper()
body := url.Values{
"client_id": {clientID},
"token": {accessResponse.GetAccessToken()},
}.Encode()
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+string(signedAssertion))
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
handler.introspectToken(c)
var out map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
return rec.Code, out
}
status, body := introspect(t)
require.Equal(t, http.StatusOK, status)
require.Equal(t, true, body["active"])
status, body = introspect(t)
require.Equal(t, http.StatusOK, status)
require.Equal(t, true, body["active"])
}