feat: implement OAuth Client ID Metadata Document (#1525) (#1526)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Jean-François Roy
2026-08-02 15:05:39 +00:00
committed by GitHub
co-authored by Elias Schneider
parent 7c55bdf115
commit 1934efa84c
67 changed files with 2311 additions and 217 deletions
+9 -9
View File
@@ -176,8 +176,8 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Description: "This is an example description for Nextcloud",
LaunchURL: new("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/logout/callback"},
CallbackURLs: datatype.StringList{"http://nextcloud.localhost/auth/callback"},
LogoutCallbackURLs: datatype.StringList{"http://nextcloud.localhost/auth/logout/callback"},
ImageType: new("png"),
CreatedByID: new(users[0].ID),
},
@@ -187,7 +187,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich.localhost/auth/callback"},
CallbackURLs: datatype.StringList{"http://immich.localhost/auth/callback"},
CreatedByID: new(users[1].ID),
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{
@@ -200,8 +200,8 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Tailscale",
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale.localhost/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale.localhost/auth/logout/callback"},
CallbackURLs: datatype.StringList{"http://tailscale.localhost/auth/callback"},
LogoutCallbackURLs: datatype.StringList{"http://tailscale.localhost/auth/logout/callback"},
IsGroupRestricted: true,
CreatedByID: new(users[0].ID),
AllowedUserGroups: []model.UserGroup{
@@ -214,7 +214,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated.localhost/auth/callback"},
CallbackURLs: datatype.StringList{"http://federated.localhost/auth/callback"},
CreatedByID: new(users[1].ID),
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
@@ -234,7 +234,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "SCIM Client",
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
CallbackURLs: model.UrlList{"http://scimclient.localhost/auth/callback"},
CallbackURLs: datatype.StringList{"http://scimclient.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{
@@ -248,7 +248,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "PAR Test Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://par-client.localhost/auth/callback"},
CallbackURLs: datatype.StringList{"http://par-client.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
},
{
@@ -257,7 +257,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Skip Consent Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://skip-consent.localhost/auth/callback"},
CallbackURLs: datatype.StringList{"http://skip-consent.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
// Trusted client that bypasses the consent screen by default
SkipConsent: true,
+89 -34
View File
@@ -37,10 +37,11 @@ const (
)
type OidcService struct {
db *gorm.DB
jwtService *JwtService
previewBuilder oidcClientPreviewBuilder
scimService *ScimService
db *gorm.DB
jwtService *JwtService
previewBuilder oidcClientPreviewBuilder
metadataRefresher metadataRefresher
scimService *ScimService
httpClient *http.Client
fileStorage storage.FileStorage
@@ -50,21 +51,27 @@ type oidcClientPreviewBuilder interface {
BuildClientPreview(ctx context.Context, client model.OidcClient, userID string, scopes []string, authenticationMethod string) (*oidc.ClientPreview, error)
}
type metadataRefresher interface {
RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error)
}
func NewOidcService(
db *gorm.DB,
jwtService *JwtService,
previewBuilder oidcClientPreviewBuilder,
metadataRefresher metadataRefresher,
scimService *ScimService,
httpClient *http.Client,
fileStorage storage.FileStorage,
) (s *OidcService, err error) {
s = &OidcService{
db: db,
jwtService: jwtService,
previewBuilder: previewBuilder,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
db: db,
jwtService: jwtService,
previewBuilder: previewBuilder,
metadataRefresher: metadataRefresher,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
}
return s, nil
@@ -74,6 +81,22 @@ func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.Oid
return s.getClientInternal(ctx, clientID, s.db, false)
}
// RefreshClientMetadata forces a re-fetch of the OAuth Client ID Metadata Document
// for a CIMD client, bypassing the cache TTL, and returns the refreshed client.
func (s *OidcService) RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error) {
if s.metadataRefresher == nil {
return model.OidcClient{}, &common.ValidationError{Message: "client ID metadata documents are not enabled"}
}
client, err := s.metadataRefresher.RefreshClientMetadata(ctx, clientID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.OidcClient{}, err
}
return model.OidcClient{}, &common.ValidationError{Message: err.Error()}
}
return client, nil
}
func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB, forUpdate bool) (model.OidcClient, error) {
var client model.OidcClient
q := tx.
@@ -161,10 +184,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
tx.Rollback()
}()
var client model.OidcClient
err := tx.WithContext(ctx).
Preload("CreatedBy").
First(&client, "id = ?", clientID).Error
client, err := s.getClientInternal(ctx, clientID, tx, true)
if err != nil {
return model.OidcClient{}, err
}
@@ -179,7 +199,22 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
}
}
err = tx.WithContext(ctx).Save(&client).Error
// Metadata refresh owns all other CIMD columns, so an admin update must never write back a stale metadata snapshot
if client.IsMetadataDocument() {
err = tx.WithContext(ctx).
Model(&client).
Select(
"Description",
"RequiresReauthentication",
"RequiresPushedAuthorizationRequests",
"SkipConsent",
"LaunchURL",
"IsGroupRestricted",
).
Updates(&client).Error
} else {
err = tx.WithContext(ctx).Save(&client).Error
}
if err != nil {
return model.OidcClient{}, err
}
@@ -208,25 +243,32 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
}
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
// Base fields
client.Name = input.Name
// Update fields that remain locally managed for every client type
client.Description = input.Description
client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
// Reset any pkce support prompt if previously flagged
if !input.PkceEnabled {
client.PkceSupported = false
}
client.RequiresReauthentication = input.RequiresReauthentication
client.RequiresPushedAuthorizationRequests = input.RequiresPushedAuthorizationRequests
client.SkipConsent = input.SkipConsent
client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted
// Credentials
// Preserve fields that are sourced from the client metadata document
if client.IsMetadataDocument() {
return
}
// Update registration fields for manually configured clients
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
// Reset any PKCE support prompt if previously flagged
if !input.PkceEnabled {
client.PkceSupported = false
}
// Replace the federated credentials with the submitted configuration
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
for i, fi := range input.Credentials.FederatedIdentities {
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
@@ -255,11 +297,11 @@ func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
// Delete images if present
// Note that storage operations must be done outside of a transaction
if client.ImageType != nil && *client.ImageType != "" {
old := path.Join("oidc-client-images", client.ID+"."+*client.ImageType)
old := oidcClientImagePath(client.ID, "", *client.ImageType)
_ = s.fileStorage.Delete(ctx, old)
}
if client.DarkImageType != nil && *client.DarkImageType != "" {
old := path.Join("oidc-client-images", client.ID+"-dark."+*client.DarkImageType)
old := oidcClientImagePath(client.ID, "-dark", *client.DarkImageType)
_ = s.fileStorage.Delete(ctx, old)
}
@@ -281,6 +323,10 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i
return "", err
}
if client.IsPublic {
return "", &common.ValidationError{Message: "cannot create a secret for a public client"}
}
clientSecret := input.Secret
if clientSecret == "" {
clientSecret, err = utils.GenerateRandomAlphanumericString(32)
@@ -339,7 +385,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
if mimeType == "" {
return nil, 0, "", fmt.Errorf("unsupported image type '%s'", ext)
}
key := path.Join("oidc-client-images", client.ID+suffix+"."+ext)
key := oidcClientImagePath(client.ID, suffix, ext)
reader, size, err := s.fileStorage.Open(ctx, key)
if err != nil {
return nil, 0, "", err
@@ -359,7 +405,7 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
darkSuffix = "-dark"
}
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+fileType)
imagePath := oidcClientImagePath(clientID, darkSuffix, fileType)
reader, err := file.Open()
if err != nil {
return err
@@ -441,7 +487,7 @@ func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID str
}
// All storage operations must be performed outside of a database transaction
imagePath := path.Join("oidc-client-images", client.ID+imagePathSuffix+"."+oldImageType)
imagePath := oidcClientImagePath(client.ID, imagePathSuffix, oldImageType)
err = s.fileStorage.Delete(ctx, imagePath)
if err != nil {
return err
@@ -625,6 +671,7 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
LaunchURL: client.LaunchURL,
HasLogo: client.HasLogo(),
HasDarkLogo: client.HasDarkLogo(),
ClientType: string(client.ClientType),
},
LastUsedAt: lastUsedAt,
}
@@ -761,7 +808,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
return err
}
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+ext)
imagePath := oidcClientImagePath(clientID, darkSuffix, ext)
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
if errors.Is(err, utils.ErrSizeExceeded) {
return errLogoTooLarge
@@ -823,13 +870,21 @@ func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string,
// Storage operations must be executed outside of a transaction
if currentType != nil && *currentType != ext {
old := path.Join("oidc-client-images", client.ID+darkSuffix+"."+*currentType)
old := oidcClientImagePath(client.ID, darkSuffix, *currentType)
_ = s.fileStorage.Delete(ctx, old)
}
return nil
}
func oidcClientImagePath(clientID string, suffix string, extension string) string {
storageID := clientID
if !dto.ValidateClientID(clientID) {
storageID = "cimd-" + utils.CreateSha256Hash(clientID)
}
return path.Join("oidc-client-images", storageID+suffix+"."+extension)
}
func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID string) (model.ScimServiceProvider, error) {
var provider model.ScimServiceProvider
err := s.db.
+120 -12
View File
@@ -15,6 +15,7 @@ import (
"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"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -37,7 +38,7 @@ func TestOidcService_updateClientLogoType(t *testing.T) {
// Create a test client
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
CallbackURLs: datatype.StringList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
@@ -152,6 +153,18 @@ func TestOidcService_updateClientLogoType(t *testing.T) {
})
}
func TestOidcClientImagePath(t *testing.T) {
const metadataClientID = "https://app.example.com/oauth/client"
assert.Equal(t, "oidc-client-images/client-id.png", oidcClientImagePath("client-id", "", "png"))
assert.Equal(
t,
"oidc-client-images/cimd-"+utils.CreateSha256Hash(metadataClientID)+"-dark.webp",
oidcClientImagePath(metadataClientID, "-dark", "webp"),
)
assert.NotContains(t, oidcClientImagePath(metadataClientID, "", "png"), "app.example.com")
}
func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
const publicLogoHost = "https://8.8.8.8"
@@ -165,7 +178,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
// Create a test client
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
CallbackURLs: datatype.StringList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
@@ -457,7 +470,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
func TestOidcService_CreateClient_withDescription(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
description := "A test client description"
@@ -482,7 +495,7 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
input := dto.OidcClientCreateDto{
@@ -504,7 +517,7 @@ func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
func TestOidcService_CreateClientSecret_withCustomSecret(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
client := model.OidcClient{Name: "Test Client"}
@@ -527,13 +540,13 @@ func TestOidcService_CreateClientSecret_withCustomSecret(t *testing.T) {
func TestOidcService_UpdateClient_description(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
// Create a client without a description
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
CallbackURLs: datatype.StringList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
@@ -565,9 +578,104 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
assert.Empty(t, fetched.Description)
}
func TestOidcService_UpdateClient_CIMDPreservesMetadataFields(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
client := model.OidcClient{
Name: "Metadata Client",
CallbackURLs: datatype.StringList{"https://metadata.example.com/callback"},
LogoutCallbackURLs: datatype.StringList{"https://metadata.example.com/logout"},
IsPublic: true,
PkceEnabled: true,
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{{
Issuer: "https://metadata.example.com/client.json",
Subject: "https://metadata.example.com/client.json",
JWKS: "https://metadata.example.com/jwks.json",
}},
},
ClientType: model.OidcClientTypeCIMD,
}
require.NoError(t, db.Create(&client).Error)
launchURL := "https://app.example.com"
input := dto.OidcClientUpdateDto{
Name: "Overridden Client",
Description: "Locally managed description",
CallbackURLs: []string{"https://override.example.com/callback"},
LogoutCallbackURLs: []string{"https://override.example.com/logout"},
IsPublic: false,
PkceEnabled: false,
RequiresReauthentication: true,
RequiresPushedAuthorizationRequests: true,
SkipConsent: true,
LaunchURL: &launchURL,
IsGroupRestricted: true,
Credentials: dto.OidcClientCredentialsDto{
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{{
Issuer: "https://override.example.com",
JWKS: "https://override.example.com/jwks.json",
}},
},
}
_, err = s.UpdateClient(t.Context(), client.ID, input)
require.NoError(t, err)
var fetched model.OidcClient
require.NoError(t, db.First(&fetched, "id = ?", client.ID).Error)
assert.Equal(t, client.Name, fetched.Name)
assert.Equal(t, client.CallbackURLs, fetched.CallbackURLs)
assert.Equal(t, client.LogoutCallbackURLs, fetched.LogoutCallbackURLs)
assert.Equal(t, client.IsPublic, fetched.IsPublic)
assert.Equal(t, client.PkceEnabled, fetched.PkceEnabled)
assert.Equal(t, client.Credentials, fetched.Credentials)
assert.Equal(t, input.Description, fetched.Description)
assert.Equal(t, input.RequiresReauthentication, fetched.RequiresReauthentication)
assert.Equal(t, input.RequiresPushedAuthorizationRequests, fetched.RequiresPushedAuthorizationRequests)
assert.Equal(t, input.SkipConsent, fetched.SkipConsent)
assert.Equal(t, input.LaunchURL, fetched.LaunchURL)
assert.Equal(t, input.IsGroupRestricted, fetched.IsGroupRestricted)
}
func TestOidcService_UpdateClient_CIMDDoesNotOverwriteConcurrentMetadataRefresh(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
client := model.OidcClient{
Name: "Original metadata name",
CallbackURLs: datatype.StringList{"https://metadata.example.com/callback"},
ClientType: model.OidcClientTypeCIMD,
}
require.NoError(t, db.Create(&client).Error)
// Simulate metadata refresh changing a document-owned column after the admin request read its snapshot
require.NoError(t, db.Exec(`
CREATE TRIGGER refresh_metadata_before_admin_update
BEFORE UPDATE OF description ON oidc_clients
BEGIN
UPDATE oidc_clients SET name = 'Refreshed metadata name' WHERE id = OLD.id;
END;
`).Error)
input := dto.OidcClientUpdateDto{Description: "Locally managed description"}
_, err = s.UpdateClient(t.Context(), client.ID, input)
require.NoError(t, err)
var fetched model.OidcClient
require.NoError(t, db.First(&fetched, "id = ?", client.ID).Error)
assert.Equal(t, "Refreshed metadata name", fetched.Name)
assert.Equal(t, input.Description, fetched.Description)
}
func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
allowedGroup := model.UserGroup{Name: "allowed", FriendlyName: "Allowed"}
@@ -581,10 +689,10 @@ func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t
require.NoError(t, db.Create(&userWithoutGroup).Error)
clients := []model.OidcClient{
{Name: "Unrestricted", CallbackURLs: model.UrlList{"https://unrestricted.example.com/callback"}},
{Name: "Restricted without groups", CallbackURLs: model.UrlList{"https://empty.example.com/callback"}, IsGroupRestricted: true},
{Name: "Restricted to user group", CallbackURLs: model.UrlList{"https://allowed.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{allowedGroup}},
{Name: "Restricted to other group", CallbackURLs: model.UrlList{"https://other.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{otherGroup}},
{Name: "Unrestricted", CallbackURLs: datatype.StringList{"https://unrestricted.example.com/callback"}},
{Name: "Restricted without groups", CallbackURLs: datatype.StringList{"https://empty.example.com/callback"}, IsGroupRestricted: true},
{Name: "Restricted to user group", CallbackURLs: datatype.StringList{"https://allowed.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{allowedGroup}},
{Name: "Restricted to other group", CallbackURLs: datatype.StringList{"https://other.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{otherGroup}},
}
for i := range clients {
require.NoError(t, db.Create(&clients[i]).Error)