feat: implement token introspection (#405)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Andreas Schneider
2025-04-09 09:18:03 +02:00
committed by GitHub
parent 8d6c1e5c08
commit 7e5d16be9b
9 changed files with 416 additions and 14 deletions

View File

@@ -6,14 +6,13 @@ import (
"net/url"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
@@ -31,6 +30,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
group.POST("/oidc/introspect", oc.introspectTokenHandler)
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
@@ -291,6 +291,38 @@ func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
// Implementation is the same as GET
}
// introspectToken godoc
// @Summary Introspect OIDC tokens
// @Description Pass an access_token to verify if it is considered valid.
// @Tags OIDC
// @Produce json
// @Param token formData string true "The token to be introspected."
// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
// @Router /api/oidc/introspect [post]
func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
var input dto.OidcIntrospectDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret have to be passed over the Authorization header. This kind of
// authentication allows us to keep the endpoint protected (since it could be used to
// find valid tokens) while still allowing it to be used by an application that is
// supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway).
clientID, clientSecret, _ := c.Request.BasicAuth()
response, err := oc.oidcService.IntrospectToken(clientID, clientSecret, input.Token)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
// getClientMetaDataHandler godoc
// @Summary Get client metadata
// @Description Get OIDC client metadata for discovery and configuration

View File

@@ -74,6 +74,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{"authorization_code", "refresh_token"},
"scopes_supported": []string{"openid", "profile", "email", "groups"},

View File

@@ -55,6 +55,10 @@ type OidcCreateTokensDto struct {
RefreshToken string `form:"refresh_token"`
}
type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"`
}
type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
}
@@ -73,3 +77,16 @@ type OidcTokenResponseDto struct {
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in"`
}
type OidcIntrospectionResponseDto struct {
Active bool `json:"active"`
TokenType string `json:"token_type,omitempty"`
Scope string `json:"scope,omitempty"`
Expiration int64 `json:"exp,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
Audience []string `json:"aud,omitempty"`
Issuer string `json:"iss,omitempty"`
Identifier string `json:"jti,omitempty"`
}

View File

@@ -15,7 +15,14 @@ func NewCorsMiddleware() *CorsMiddleware {
func (m *CorsMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
// Allow all origins for the token endpoint
switch c.FullPath() {
case "/api/oidc/token", "/api/oidc/introspect":
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
default:
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")

View File

@@ -11,8 +11,11 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
@@ -37,6 +40,12 @@ const (
// This may be omitted on non-admin tokens
IsAdminClaim = "isAdmin"
// AccessTokenJWTType is the media type for access tokens
AccessTokenJWTType = "AT+JWT"
// IDTokenJWTType is the media type for ID tokens
IDTokenJWTType = "ID+JWT"
// Acceptable clock skew for verifying tokens
clockSkew = time.Minute
)
@@ -247,8 +256,13 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
}
}
headers, err := CreateTokenTypeHeader(IDTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set token type: %w", err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey, jws.WithProtectedHeaders(headers)))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
@@ -285,6 +299,11 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
return nil, fmt.Errorf("failed to parse token: %w", err)
}
err = VerifyTokenTypeHeader(tokenString, IDTokenJWTType)
if err != nil {
return nil, fmt.Errorf("failed to verify token type: %w", err)
}
return token, nil
}
@@ -305,8 +324,13 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
headers, err := CreateTokenTypeHeader(AccessTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set token type: %w", err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey, jws.WithProtectedHeaders(headers)))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
@@ -327,6 +351,11 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, erro
return nil, fmt.Errorf("failed to parse token: %w", err)
}
err = VerifyTokenTypeHeader(tokenString, AccessTokenJWTType)
if err != nil {
return nil, fmt.Errorf("failed to verify token type: %w", err)
}
return token, nil
}
@@ -481,6 +510,17 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
return isAdmin, err
}
// CreateTokenTypeHeader creates a new JWS header with the given token type
func CreateTokenTypeHeader(tokenType string) (jws.Headers, error) {
headers := jws.NewHeaders()
err := headers.Set(jws.TypeKey, tokenType)
if err != nil {
return nil, fmt.Errorf("failed to set token type: %w", err)
}
return headers, nil
}
// SetIsAdmin sets the "isAdmin" claim in the token
func SetIsAdmin(token jwt.Token, isAdmin bool) error {
// Only set if true
@@ -495,3 +535,37 @@ func SetIsAdmin(token jwt.Token, isAdmin bool) error {
func SetAudienceString(token jwt.Token, audience string) error {
return token.Set(jwt.AudienceKey, audience)
}
// VerifyTokenTypeHeader verifies that the "typ" header in the token matches the expected type
func VerifyTokenTypeHeader(tokenBytes string, expectedTokenType string) error {
// Parse the raw token string purely as a JWS message structure
// We don't need to verify the signature at this stage, just inspect headers.
msg, err := jws.Parse([]byte(tokenBytes))
if err != nil {
return fmt.Errorf("failed to parse token as JWS message: %w", err)
}
// Get the list of signatures attached to the message. Usually just one for JWT.
signatures := msg.Signatures()
if len(signatures) == 0 {
return errors.New("JWS message contains no signatures")
}
protectedHeaders := signatures[0].ProtectedHeaders()
if protectedHeaders == nil {
return fmt.Errorf("JWS signature has no protected headers")
}
// Retrieve the 'typ' header value from the PROTECTED headers.
var typHeaderValue string
err = protectedHeaders.Get(jws.TypeKey, &typHeaderValue)
if err != nil {
return fmt.Errorf("token is missing required protected header '%s'", jws.TypeKey)
}
if !strings.EqualFold(typHeaderValue, expectedTokenType) {
return fmt.Errorf("'%s' header mismatch: expected '%s', got '%s'", jws.TypeKey, expectedTokenType, typHeaderValue)
}
return nil
}

View File

@@ -6,12 +6,15 @@ import (
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
@@ -651,8 +654,13 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
}
// Create headers with the specified type
hdrs := jws.NewHeaders()
err = hdrs.Set(jws.TypeKey, "ID+JWT")
require.NoError(t, err, "Failed to set header type")
// Sign the token
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey, jws.WithProtectedHeaders(hdrs)))
require.NoError(t, err, "Failed to sign token")
tokenString := string(signed)
@@ -1172,6 +1180,63 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
})
}
func TestVerifyTokenTypeHeader(t *testing.T) {
mockConfig := &AppConfigService{}
tempDir := t.TempDir()
// Helper function to create a token with a specific type header
createTokenWithType := func(tokenType string) (string, error) {
// Create a simple JWT token
token := jwt.New()
err := token.Set("test_claim", "test_value")
if err != nil {
return "", fmt.Errorf("failed to set claim: %w", err)
}
// Create headers with the specified type
hdrs := jws.NewHeaders()
if tokenType != "" {
err = hdrs.Set(jws.TypeKey, tokenType)
if err != nil {
return "", fmt.Errorf("failed to set type header: %w", err)
}
}
// Sign the token with the headers
service := &JwtService{}
err = service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey, jws.WithProtectedHeaders(hdrs)))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
}
t.Run("succeeds when token type matches expected type", func(t *testing.T) {
// Create a token with "JWT" type
tokenString, err := createTokenWithType("JWT")
require.NoError(t, err, "Failed to create test token")
// Verify the token type
err = VerifyTokenTypeHeader(tokenString, "JWT")
assert.NoError(t, err, "Should accept token with matching type")
})
t.Run("fails when token type doesn't match expected type", func(t *testing.T) {
// Create a token with "AT+JWT" type
tokenString, err := createTokenWithType("AT+JWT")
require.NoError(t, err, "Failed to create test token")
// Verify the token with different expected type
err = VerifyTokenTypeHeader(tokenString, "JWT")
require.Error(t, err, "Should reject token with non-matching type")
assert.Contains(t, err.Error(), "header mismatch: expected 'JWT', got 'AT+JWT'")
})
}
func importKey(t *testing.T, privateKeyRaw any, path string) string {
t.Helper()

View File

@@ -14,6 +14,8 @@ import (
"strings"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -356,6 +358,93 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
return accessToken, newRefreshToken, 3600, nil
}
func (s *OidcService) IntrospectToken(clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
if clientID == "" || clientSecret == "" {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
// Get the client to check if we are authorized.
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
// Verify the client secret. This endpoint may not be used by public clients.
if client.IsPublic {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
token, err := s.jwtService.VerifyOauthAccessToken(tokenString)
if err != nil {
if errors.Is(err, jwt.ParseError()) {
// It's apparently not a valid JWT token, so we check if it's a valid refresh_token.
return s.introspectRefreshToken(tokenString)
}
// Every failure we get means the token is invalid. Nothing more to do with the error.
introspectDto.Active = false
return introspectDto, nil
}
introspectDto.Active = true
introspectDto.TokenType = "access_token"
if token.Has("scope") {
var asString string
var asStrings []string
if err := token.Get("scope", &asString); err == nil {
introspectDto.Scope = asString
} else if err := token.Get("scope", &asStrings); err == nil {
introspectDto.Scope = strings.Join(asStrings, " ")
}
}
if expiration, hasExpiration := token.Expiration(); hasExpiration {
introspectDto.Expiration = expiration.Unix()
}
if issuedAt, hasIssuedAt := token.IssuedAt(); hasIssuedAt {
introspectDto.IssuedAt = issuedAt.Unix()
}
if notBefore, hasNotBefore := token.NotBefore(); hasNotBefore {
introspectDto.NotBefore = notBefore.Unix()
}
if subject, hasSubject := token.Subject(); hasSubject {
introspectDto.Subject = subject
}
if audience, hasAudience := token.Audience(); hasAudience {
introspectDto.Audience = audience
}
if issuer, hasIssuer := token.Issuer(); hasIssuer {
introspectDto.Issuer = issuer
}
if identifier, hasIdentifier := token.JwtID(); hasIdentifier {
introspectDto.Identifier = identifier
}
return introspectDto, nil
}
func (s *OidcService) introspectRefreshToken(refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
var storedRefreshToken model.OidcRefreshToken
err = s.db.Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())).
First(&storedRefreshToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
introspectDto.Active = false
return introspectDto, nil
}
return introspectDto, err
}
introspectDto.Active = true
introspectDto.TokenType = "refresh_token"
return introspectDto, nil
}
func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.OidcClient, error) {
return s.getClientInternal(ctx, clientID, s.db)
}

View File

@@ -26,12 +26,14 @@ export const oidcClients = {
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback',
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback'
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
},
immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich',
callbackUrl: 'http://immich/auth/callback'
callbackUrl: 'http://immich/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
},
pingvinShare: {
name: 'Pingvin Share',
@@ -82,3 +84,33 @@ export const refreshTokens = [
expired: true
}
];
export const idTokens = [
{
token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiaWQrand0In0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.LHwNnp9WxFc_NbIVsBC41trA-1LUBxTfKwIqfgGP4WC5j39M2Rmc0G4rw7J96tfwyEobwgPFAP0YJ3BqMaZgHT4Zu0rYSenU-yv_CICWiLL4csyeojlqbqDKDiOD3Gsl4_ZUuo8UuN190RGz6HlxmTwxpmceerSFpx6dBtA6chYZfgnUf289DRWIgTsNrXnkohZRa8zWc8bjbw_hj1u7H6Ev9Yu3U2k4K0cHWZLFjQiPWt3JBaWNAldSEn2q7a3Rkyv17_Gx8Nwl5L4ugWKV8M1YkcHbEkYCJKaJCbZi13R89yH1E0EOfHYXK5Z0KqBq47eTYRGRUtFiP-uTlUDQUQ',
clientId: oidcClients.nextcloud.id,
expired: true
},
{
token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiaWQrand0In0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.yG21sES1HMyQg6GeJtd-6sUJ5a_QBS-hHq3mDTjRoMkL604RxprPvIJ-ypYhzcV5LwlTiD-7jJQ2Z95uUb82aNek55V5Pzq_rcLM5EtHh2bHSegt_1QXcpBzl8mWB1AIZBSRzFDaB1msnkyxGnndJk4VHpUVStvubcldxksH3e9v286x9ak4oTNoaLy4kMi4KAE8WCwrqsYc1iieLOSFTRHjpM9YxWa8X9hGNsikC85NJ0tj1pG9I4QTG62h4ZqJ4-jFWe5dogg_vd9Sk7tA3f9S779XSCG6hpj1V-sxQqLCy9uAmB2URP4N60jamKTn2TCxc1R7xgQ7M9Rc9ty68g',
clientId: oidcClients.nextcloud.id,
expired: false
}
];
export const accessTokens = [
{
token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiYXQrand0In0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6MTc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.iwkQR96BKTJugh87_YOrDb7hXSWsv0RJXrqrqxHn3rwhcKNxwGnYAhTiQ12wyi-77-AFkzUlgs9E9pwgVi3_sE37QCVZ3YZzHjbg5crmT1EJ4f8gN8hw5cDqC3ny0R8rhgNzzirpZNe-i7SXzWCIySyEVh7MGFTPqNA-1ZlGh06FuOFRb22GVaHfrDkpE2RhkeZ-ZLlua9pbTcT1T9CihlCrW8JKTUwT2QspCwtnaJGs34iH77sHry31cTYVyOqd5q218tg_N4ky9iV6k7mK6b7uaPsjYHrtpfK1tp-9_MSp6Fqzw6wu_vrvg5WrIWwiREaz_wJj-SjIuBR5TlntdA',
clientId: oidcClients.nextcloud.id,
expired: true
},
{
token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiYXQrand0In0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6Mjc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.lZMEohQeOi6oKDsKLKDDRYJIJNedUilvCLCi6XLADcHPtKlFJbPqH8IuQxuzryeIYAnTILsjvTkxkHAeRoQZCXQR7oS5BguGx6MtQYjgj--GpLBQ39r_nz-SEfhKtuMzEzPsN1raxOH8jWbnPM7zHxf5NIz7AHDKtCSWRA3JlE9kgAU7S-RRc6xP_BYVPDB97J6k-xuO5zxcdNTb92j8pZWbPPokv6CGG9CTPNzcrNHf-M98M6GE8SVM-8R2MAbpUCqTkTc_O46GHEexZzif2Wg8K5O-htiSQnwumoXXN08zKHCzCAvSdSa9JRMB-cgP7jsM7I6itUBXWxgvWDK3rA',
clientId: oidcClients.nextcloud.id,
expired: false
}
];

View File

@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test';
import { oidcClients, refreshTokens } from './data';
import { accessTokens, idTokens, oidcClients, refreshTokens, users } from './data';
import { cleanupBackend } from './utils/cleanup.util';
import passkeyUtil from './utils/passkey.util';
@@ -116,10 +116,7 @@ test('End session without id token hint shows confirmation page', async ({ page
test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud;
// Note: this token has expired, but it should be accepted by the logout endpoint anyways, per spec
const idToken =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';
const idToken = idTokens.filter((token) => token.expired)[0].token;
let redirectedCorrectly = false;
await page
.goto(
@@ -192,3 +189,91 @@ test('Using refresh token invalidates it for future use', async ({ request }) =>
});
expect(refreshResponse.status()).toBe(400);
});
test.describe('Introspection endpoint', () => {
const client = oidcClients.nextcloud;
const validAccessToken = accessTokens.filter((token) => !token.expired)[0].token;
test('without client_id and client_secret fails', async ({ request }) => {
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: {
token: validAccessToken
}
});
expect(introspectionResponse.status()).toBe(400);
});
test('with client_id and client_secret succeeds', async ({ request }) => {
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + Buffer.from(`${client.id}:${client.secret}`).toString('base64')
},
form: {
token: validAccessToken
}
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe('access_token');
expect(introspectionBody.iss).toBe('http://localhost');
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
});
test('non-expired refresh_token can be verified', async ({ request }) => {
const { token } = refreshTokens.filter((token) => !token.expired)[0];
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + Buffer.from(`${client.id}:${client.secret}`).toString('base64')
},
form: {
token: token
}
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe('refresh_token');
});
test('expired refresh_token can be verified', async ({ request }) => {
const { token } = refreshTokens.filter((token) => token.expired)[0];
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + Buffer.from(`${client.id}:${client.secret}`).toString('base64')
},
form: {
token: token
}
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(false);
});
test("expired access_token can't be verified", async ({ request }) => {
const expiredAccessToken = accessTokens.filter((token) => token.expired)[0].token;
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: {
token: expiredAccessToken
}
});
expect(introspectionResponse.status()).toBe(400);
});
});