mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-09 14:53:00 +03:00
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:
committed by
GitHub
parent
8d6c1e5c08
commit
7e5d16be9b
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user