Repository: zitadel/oidc (go)

Issue:
Decoding a SpaceDelimitedArray using schema is broken
We define the encoders and decoders here:

https://github.com/zitadel/oidc/blob/03f71a67c21ff83a3afc0c884d634f465e522f42/pkg/op/op.go#L183-L186

Which are basically the `gorilla/schema` defaults. When I define a request type and encode it on one side and decode it on another, somehow a entry is lost. The easiest way to reproduce this problem:

```
package main

import (
	"fmt"
	"net/url"

	"github.com/gorilla/schema"
	"github.com/zitadel/oidc/v2/pkg/oidc"
)

type request struct {
	Scopes oidc.SpaceDelimitedArray `schema:"scope"`
}

func main() {
	a := request{
		Scopes: oidc.SpaceDelimitedArray{"foo", "bar"},
	}
	fmt.Println(a)

	values := make(url.Values)
	schema.NewEncoder().Encode(a, values)
	fmt.Println(values)

	var b request
	schema.NewDecoder().Decode(&b, values)
	fmt.Println(b)
}
```

Prints:

```
{[foo bar]}
map[scope:[foo bar]]
{[bar]}
```
The `foo` entry is now gone.

Pull request:
fix: use the same schema encoder everywhere
Properly register SpaceDelimitedArray for all instances
of schema.Encoder inside the oidc framework.

Closes #295

Patch:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 85ea2ca..d2bae79 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -2,10 +2,10 @@ name: "Code scanning - action"
 
 on:
   push:
-    branches: [main, ]
+    branches: [main,next]
   pull_request:
     # The branches below must be a subset of the branches above
-    branches: [main]
+    branches: [main,next]
   schedule:
     - cron: '0 11 * * 0'
 
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d97d41a..9509826 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,6 +3,7 @@ on:
   push:
     branches:
       - main
+      - next
     tags-ignore:
       - '**'
   pull_request:
@@ -31,7 +32,7 @@ jobs:
   release:
     runs-on: ubuntu-20.04
     needs: [test]
-    if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
+    if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' }}
     env:
       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
     steps:
diff --git a/NEXT_RELEASE.md b/NEXT_RELEASE.md
deleted file mode 100644
index 91f7f5d..0000000
--- a/NEXT_RELEASE.md
+++ /dev/null
@@ -1,7 +0,0 @@
-
-# Backwards-incompatible changes to be made in the next major release
-
-- Add `rp/RelyingParty.GetRevokeEndpoint`
-- Rename `op/OpStorage.GetKeyByIDAndUserID` to `op/OpStorage.GetKeyByIDAndClientID` 
-- Add `CanRefreshTokenInfo` (`GetRefreshTokenInfo()`) to `op.Storage`
-
diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go
index b49ce1b..2794783 100644
--- a/example/server/storage/storage.go
+++ b/example/server/storage/storage.go
@@ -327,6 +327,16 @@ func (s *Storage) TerminateSession(ctx context.Context, userID string, clientID
 	return nil
 }
 
+// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id.
+// If given something that is not a refresh token, it must return error.
+func (s *Storage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) {
+	refreshToken, ok := s.refreshTokens[token]
+	if !ok {
+		return "", "", op.ErrInvalidRefreshToken
+	}
+	return refreshToken.UserID, refreshToken.ID, nil
+}
+
 // RevokeToken implements the op.Storage interface
 // it will be called after parsing and validation of the token revocation request
 func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID string, clientID string) *oidc.Error {
@@ -384,7 +394,7 @@ func (s *Storage) KeySet(ctx context.Context) ([]op.Key, error) {
 	// so it will directly use its public key
 	//
 	// when using key rotation you typically would store the public keys alongside the private keys in your database
-	//and give both of them an expiration date, with the public key having a longer lifetime
+	// and give both of them an expiration date, with the public key having a longer lifetime
 	return []op.Key{&publicKey{s.signingKey}}, nil
 }
 
@@ -500,9 +510,9 @@ func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, client
 	return claims, nil
 }
 
-// GetKeyByIDAndUserID implements the op.Storage interface
+// GetKeyByIDAndClientID implements the op.Storage interface
 // it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication)
-func (s *Storage) GetKeyByIDAndUserID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) {
+func (s *Storage) GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) {
 	s.lock.Lock()
 	defer s.lock.Unlock()
 	service, ok := s.services[clientID]
diff --git a/example/server/storage/storage_dynamic.go b/example/server/storage/storage_dynamic.go
index ec6a92e..d424a89 100644
--- a/example/server/storage/storage_dynamic.go
+++ b/example/server/storage/storage_dynamic.go
@@ -126,6 +126,16 @@ func (s *multiStorage) TerminateSession(ctx context.Context, userID string, clie
 	return storage.TerminateSession(ctx, userID, clientID)
 }
 
+// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id.
+// If given something that is not a refresh token, it must return error.
+func (s *multiStorage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) {
+	storage, err := s.storageFromContext(ctx)
+	if err != nil {
+		return "", "", err
+	}
+	return storage.GetRefreshTokenInfo(ctx, clientID, token)
+}
+
 // RevokeToken implements the op.Storage interface
 // it will be called after parsing and validation of the token revocation request
 func (s *multiStorage) RevokeToken(ctx context.Context, token string, userID string, clientID string) *oidc.Error {
@@ -226,14 +236,14 @@ func (s *multiStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, c
 	return storage.GetPrivateClaimsFromScopes(ctx, userID, clientID, scopes)
 }
 
-// GetKeyByIDAndUserID implements the op.Storage interface
+// GetKeyByIDAndClientID implements the op.Storage interface
 // it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication)
-func (s *multiStorage) GetKeyByIDAndUserID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) {
+func (s *multiStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) {
 	storage, err := s.storageFromContext(ctx)
 	if err != nil {
 		return nil, err
 	}
-	return storage.GetKeyByIDAndUserID(ctx, keyID, userID)
+	return storage.GetKeyByIDAndClientID(ctx, keyID, userID)
 }
 
 // ValidateJWTProfileScopes implements the op.Storage interface
diff --git a/pkg/client/client.go b/pkg/client/client.go
index b9ae008..ebe1442 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -8,11 +8,9 @@ import (
 	"io"
 	"net/http"
 	"net/url"
-	"reflect"
 	"strings"
 	"time"
 
-	"github.com/gorilla/schema"
 	"golang.org/x/oauth2"
 	"gopkg.in/square/go-jose.v2"
 
@@ -21,13 +19,7 @@ import (
 	"github.com/zitadel/oidc/v2/pkg/oidc"
 )
 
-var Encoder = func() httphelper.Encoder {
-	e := schema.NewEncoder()
-	e.RegisterEncoder(oidc.SpaceDelimitedArray{}, func(value reflect.Value) string {
-		return value.Interface().(oidc.SpaceDelimitedArray).Encode()
-	})
-	return e
-}()
+var Encoder = httphelper.Encoder(oidc.NewEncoder())
 
 // Discover calls the discovery endpoint of the provided issuer and returns its configuration
 // It accepts an optional argument "wellknownUrl" which can be used to overide the dicovery endpoint url
diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go
index 96fe219..5bd3558 100644
--- a/pkg/client/rp/relying_party.go
+++ b/pkg/client/rp/relying_party.go
@@ -54,7 +54,7 @@ type RelyingParty interface {
 	GetEndSessionEndpoint() string
 
 	// GetRevokeEndpoint returns the endpoint to revoke a specific token
-	// "GetRevokeEndpoint() string" will be added in a future release
+	GetRevokeEndpoint() string
 
 	// UserinfoEndpoint returns the userinfo
 	UserinfoEndpoint() string
diff --git a/pkg/oidc/types.go b/pkg/oidc/types.go
index 1260798..21b6fba 100644
--- a/pkg/oidc/types.go
+++ b/pkg/oidc/types.go
@@ -4,9 +4,11 @@ import (
 	"database/sql/driver"
 	"encoding/json"
 	"fmt"
+	"reflect"
 	"strings"
 	"time"
 
+	"github.com/gorilla/schema"
 	"golang.org/x/text/language"
 	"gopkg.in/square/go-jose.v2"
 )
@@ -125,6 +127,16 @@ func (s SpaceDelimitedArray) Value() (driver.Value, error) {
 	return strings.Join(s, " "), nil
 }
 
+// NewEncoder returns a schema Encoder with
+// a registered encoder for SpaceDelimitedArray.
+func NewEncoder() *schema.Encoder {
+	e := schema.NewEncoder()
+	e.RegisterEncoder(SpaceDelimitedArray{}, func(value reflect.Value) string {
+		return value.Interface().(SpaceDelimitedArray).Encode()
+	})
+	return e
+}
+
 type Time time.Time
 
 func (t *Time) UnmarshalJSON(data []byte) error {
diff --git a/pkg/op/client.go b/pkg/op/client.go
index 1f5e1c9..f1d106b 100644
--- a/pkg/op/client.go
+++ b/pkg/op/client.go
@@ -13,6 +13,7 @@ import (
 
 //go:generate go get github.com/dmarkham/enumer
 //go:generate go run github.com/dmarkham/enumer -linecomment -sql -json -text -yaml -gqlgen -type=ApplicationType,AccessTokenType
+//go:generate go mod tidy
 
 const (
 	ApplicationTypeWeb       ApplicationType = iota // web
diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go
index 44b5ceb..fe7d4da 100644
--- a/pkg/op/mock/configuration.mock.go
+++ b/pkg/op/mock/configuration.mock.go
@@ -400,20 +400,6 @@ func (mr *MockConfigurationMockRecorder) TokenEndpointSigningAlgorithmsSupported
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TokenEndpointSigningAlgorithmsSupported", reflect.TypeOf((*MockConfiguration)(nil).TokenEndpointSigningAlgorithmsSupported))
 }
 
-// UserCodeFormEndpoint mocks base method.
-func (m *MockConfiguration) UserCodeFormEndpoint() op.Endpoint {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "UserCodeFormEndpoint")
-	ret0, _ := ret[0].(op.Endpoint)
-	return ret0
-}
-
-// UserCodeFormEndpoint indicates an expected call of UserCodeFormEndpoint.
-func (mr *MockConfigurationMockRecorder) UserCodeFormEndpoint() *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserCodeFormEndpoint", reflect.TypeOf((*MockConfiguration)(nil).UserCodeFormEndpoint))
-}
-
 // UserinfoEndpoint mocks base method.
 func (m *MockConfiguration) UserinfoEndpoint() op.Endpoint {
 	m.ctrl.T.Helper()
diff --git a/pkg/op/mock/generate.go b/pkg/op/mock/generate.go
index 0066571..ca288d2 100644
--- a/pkg/op/mock/generate.go
+++ b/pkg/op/mock/generate.go
@@ -1,5 +1,6 @@
 package mock
 
+//go:generate go install github.com/golang/mock/mockgen@v1.6.0
 //go:generate mockgen -package mock -destination ./storage.mock.go github.com/zitadel/oidc/v2/pkg/op Storage
 //go:generate mockgen -package mock -destination ./authorizer.mock.go github.com/zitadel/oidc/v2/pkg/op Authorizer
 //go:generate mockgen -package mock -destination ./client.mock.go github.com/zitadel/oidc/v2/pkg/op Client
diff --git a/pkg/op/mock/storage.mock.go b/pkg/op/mock/storage.mock.go
index 58cc2a0..fc0c358 100644
--- a/pkg/op/mock/storage.mock.go
+++ b/pkg/op/mock/storage.mock.go
@@ -159,19 +159,19 @@ func (mr *MockStorageMockRecorder) GetClientByClientID(arg0, arg1 interface{}) *
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClientByClientID", reflect.TypeOf((*MockStorage)(nil).GetClientByClientID), arg0, arg1)
 }
 
-// GetKeyByIDAndUserID mocks base method.
-func (m *MockStorage) GetKeyByIDAndUserID(arg0 context.Context, arg1, arg2 string) (*jose.JSONWebKey, error) {
+// GetKeyByIDAndClientID mocks base method.
+func (m *MockStorage) GetKeyByIDAndClientID(arg0 context.Context, arg1, arg2 string) (*jose.JSONWebKey, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetKeyByIDAndUserID", arg0, arg1, arg2)
+	ret := m.ctrl.Call(m, "GetKeyByIDAndClientID", arg0, arg1, arg2)
 	ret0, _ := ret[0].(*jose.JSONWebKey)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
-// GetKeyByIDAndUserID indicates an expected call of GetKeyByIDAndUserID.
-func (mr *MockStorageMockRecorder) GetKeyByIDAndUserID(arg0, arg1, arg2 interface{}) *gomock.Call {
+// GetKeyByIDAndClientID indicates an expected call of GetKeyByIDAndClientID.
+func (mr *MockStorageMockRecorder) GetKeyByIDAndClientID(arg0, arg1, arg2 interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeyByIDAndUserID", reflect.TypeOf((*MockStorage)(nil).GetKeyByIDAndUserID), arg0, arg1, arg2)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeyByIDAndClientID", reflect.TypeOf((*MockStorage)(nil).GetKeyByIDAndClientID), arg0, arg1, arg2)
 }
 
 // GetPrivateClaimsFromScopes mocks base method.
@@ -189,6 +189,22 @@ func (mr *MockStorageMockRecorder) GetPrivateClaimsFromScopes(arg0, arg1, arg2,
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrivateClaimsFromScopes", reflect.TypeOf((*MockStorage)(nil).GetPrivateClaimsFromScopes), arg0, arg1, arg2, arg3)
 }
 
+// GetRefreshTokenInfo mocks base method.
+func (m *MockStorage) GetRefreshTokenInfo(arg0 context.Context, arg1, arg2 string) (string, string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetRefreshTokenInfo", arg0, arg1, arg2)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(string)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// GetRefreshTokenInfo indicates an expected call of GetRefreshTokenInfo.
+func (mr *MockStorageMockRecorder) GetRefreshTokenInfo(arg0, arg1, arg2 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRefreshTokenInfo", reflect.TypeOf((*MockStorage)(nil).GetRefreshTokenInfo), arg0, arg1, arg2)
+}
+
 // Health mocks base method.
 func (m *MockStorage) Health(arg0 context.Context) error {
 	m.ctrl.T.Helper()
diff --git a/pkg/op/op.go b/pkg/op/op.go
index 2859722..bb45425 100644
--- a/pkg/op/op.go
+++ b/pkg/op/op.go
@@ -189,7 +189,7 @@ func newProvider(ctx context.Context, config *Config, storage Storage, issuer fu
 	o.decoder = schema.NewDecoder()
 	o.decoder.IgnoreUnknownKeys(true)
 
-	o.encoder = schema.NewEncoder()
+	o.encoder = oidc.NewEncoder()
 
 	o.crypto = NewAESCrypto(config.CryptoKey)
 
diff --git a/pkg/op/storage.go b/pkg/op/storage.go
index ebab1c3..8ba1946 100644
--- a/pkg/op/storage.go
+++ b/pkg/op/storage.go
@@ -48,9 +48,15 @@ type AuthStorage interface {
 	// RevokeToken should revoke a token. In the situation that the original request was to
 	// revoke an access token, then tokenOrTokenID will be a tokenID and userID will be set
 	// but if the original request was for a refresh token, then userID will be empty and
-	// tokenOrTokenID will be the refresh token, not its ID.
+	// tokenOrTokenID will be the refresh token, not its ID.  RevokeToken depends upon GetRefreshTokenInfo
+	// to get information from refresh tokens that are not either "<tokenID>:<userID>" strings
+	// nor JWTs.
 	RevokeToken(ctx context.Context, tokenOrTokenID string, userID string, clientID string) *oidc.Error
 
+	// GetRefreshTokenInfo must return ErrInvalidRefreshToken when presented
+	// with a token that is not a refresh token.
+	GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error)
+
 	SigningKey(context.Context) (SigningKey, error)
 	SignatureAlgorithms(context.Context) ([]jose.SignatureAlgorithm, error)
 	KeySet(context.Context) ([]Key, error)
@@ -100,15 +106,6 @@ type TokenExchangeTokensVerifierStorage interface {
 	VerifyExchangeActorToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, actor string, tokenClaims map[string]interface{}, err error)
 }
 
-// CanRefreshTokenInfo is an optional additional interface that Storage can support.
-// Supporting CanRefreshTokenInfo is required to be able to (revoke) a refresh token that
-// is neither an encrypted string of <tokenID>:<userID> nor a JWT.
-type CanRefreshTokenInfo interface {
-	// GetRefreshTokenInfo must return ErrInvalidRefreshToken when presented
-	// with a token that is not a refresh token.
-	GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error)
-}
-
 var ErrInvalidRefreshToken = errors.New("invalid_refresh_token")
 
 type OPStorage interface {
@@ -118,10 +115,7 @@ type OPStorage interface {
 	SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error
 	SetIntrospectionFromToken(ctx context.Context, userinfo oidc.IntrospectionResponse, tokenID, subject, clientID string) error
 	GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (map[string]interface{}, error)
-
-	// GetKeyByIDAndUserID is mis-named.  It does not pass userID.  Instead
-	// it passes the clientID.
-	GetKeyByIDAndUserID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error)
+	GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error)
 	ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error)
 }
 
diff --git a/pkg/op/token_revocation.go b/pkg/op/token_revocation.go
index 7dbd4a7..33978f5 100644
--- a/pkg/op/token_revocation.go
+++ b/pkg/op/token_revocation.go
@@ -39,8 +39,8 @@ func Revoke(w http.ResponseWriter, r *http.Request, revoker Revoker) {
 	}
 	var subject string
 	doDecrypt := true
-	if canRefreshInfo, ok := revoker.Storage().(CanRefreshTokenInfo); ok && tokenTypeHint != "access_token" {
-		userID, tokenID, err := canRefreshInfo.GetRefreshTokenInfo(r.Context(), clientID, token)
+	if tokenTypeHint != "access_token" {
+		userID, tokenID, err := revoker.Storage().GetRefreshTokenInfo(r.Context(), clientID, token)
 		if err != nil {
 			// An invalid refresh token means that we'll try other things (leaving doDecrypt==true)
 			if !errors.Is(err, ErrInvalidRefreshToken) {
diff --git a/pkg/op/verifier_jwt_profile.go b/pkg/op/verifier_jwt_profile.go
index 9befb64..4d83c59 100644
--- a/pkg/op/verifier_jwt_profile.go
+++ b/pkg/op/verifier_jwt_profile.go
@@ -104,7 +104,7 @@ func VerifyJWTAssertion(ctx context.Context, assertion string, v JWTProfileVerif
 }
 
 type jwtProfileKeyStorage interface {
-	GetKeyByIDAndUserID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error)
+	GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error)
 }
 
 func SubjectIsIssuer(request *oidc.JWTTokenRequest) error {
@@ -122,7 +122,7 @@ type jwtProfileKeySet struct {
 // VerifySignature implements oidc.KeySet by getting the public key from Storage implementation
 func (k *jwtProfileKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) {
 	keyID, _ := oidc.GetKeyIDAndAlg(jws)
-	key, err := k.storage.GetKeyByIDAndUserID(ctx, keyID, k.clientID)
+	key, err := k.storage.GetKeyByIDAndClientID(ctx, keyID, k.clientID)
 	if err != nil {
 		return nil, fmt.Errorf("error fetching keys: %w", err)
 	}


Test patch:
diff --git a/pkg/oidc/types_test.go b/pkg/oidc/types_test.go
index 6c62c40..74323da 100644
--- a/pkg/oidc/types_test.go
+++ b/pkg/oidc/types_test.go
@@ -3,10 +3,12 @@ package oidc
 import (
 	"bytes"
 	"encoding/json"
+	"net/url"
 	"strconv"
 	"strings"
 	"testing"
 
+	"github.com/gorilla/schema"
 	"github.com/stretchr/testify/assert"
 	"golang.org/x/text/language"
 )
@@ -335,3 +337,20 @@ func TestSpaceDelimitatedArray_ValuerNil(t *testing.T) {
 		assert.Equal(t, SpaceDelimitedArray(nil), reversed, "scan nil")
 	}
 }
+
+func TestNewEncoder(t *testing.T) {
+	type request struct {
+		Scopes SpaceDelimitedArray `schema:"scope"`
+	}
+	a := request{
+		Scopes: SpaceDelimitedArray{"foo", "bar"},
+	}
+
+	values := make(url.Values)
+	NewEncoder().Encode(a, values)
+	assert.Equal(t, url.Values{"scope": []string{"foo bar"}}, values)
+
+	var b request
+	schema.NewDecoder().Decode(&b, values)
+	assert.Equal(t, a, b)
+}
diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go
index ca68759..b3ac89d 100644
--- a/pkg/op/device_test.go
+++ b/pkg/op/device_test.go
@@ -98,8 +98,6 @@ func TestParseDeviceCodeRequest(t *testing.T) {
 			name:    "empty request",
 			wantErr: true,
 		},
-		/* decoding a SpaceDelimitedArray is broken
-		https://github.com/zitadel/oidc/issues/295
 		{
 			name: "success",
 			req: &oidc.DeviceAuthorizationRequest{
@@ -107,7 +105,6 @@ func TestParseDeviceCodeRequest(t *testing.T) {
 				ClientID: "web",
 			},
 		},
-		*/
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {