mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-18 16:59:53 +00:00
209 lines
7.2 KiB
Go
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"])
|
|
}
|