diff --git a/pkg/op/token_exchange.go b/pkg/op/token_exchange.go index f8dc0f84..08b3a9d6 100644 --- a/pkg/op/token_exchange.go +++ b/pkg/op/token_exchange.go @@ -311,7 +311,11 @@ func GetTokenIDAndSubjectFromToken( if !ok { break } - claims = accessTokenClaims.Claims + // opaque access tokens carry no JWT claims, so accessTokenClaims can be + // nil even when ok is true. Leave claims empty instead of dereferencing. + if accessTokenClaims != nil { + claims = accessTokenClaims.Claims + } case oidc.RefreshTokenType: refreshTokenRequest, err := exchanger.Storage().TokenRequestByRefreshToken(ctx, token) if err != nil { diff --git a/pkg/op/token_exchange_test.go b/pkg/op/token_exchange_test.go new file mode 100644 index 00000000..be3946a8 --- /dev/null +++ b/pkg/op/token_exchange_test.go @@ -0,0 +1,35 @@ +package op_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/oidc/v3/pkg/op" +) + +func TestGetTokenIDAndSubjectFromToken_OpaqueAccessToken(t *testing.T) { + ctx := context.Background() + + // Opaque access tokens are stored as the encrypted string "tokenID:subject" + // and have no JWT claims attached. This used to panic because the returned + // claims pointer was dereferenced unconditionally. + token, err := testProvider.Crypto().Encrypt("tokenID:subject") + require.NoError(t, err) + + var ( + tokenIDOrToken, subject string + claims map[string]any + ok bool + ) + require.NotPanics(t, func() { + tokenIDOrToken, subject, claims, ok = op.GetTokenIDAndSubjectFromToken(ctx, testProvider, token, oidc.AccessTokenType, false) + }) + + assert.True(t, ok) + assert.Equal(t, "tokenID", tokenIDOrToken) + assert.Equal(t, "subject", subject) + assert.Empty(t, claims) +}