feat: support multiple client secrets per OIDC client (#1679)

This commit is contained in:
Alessandro (Ale) Segala
2026-08-11 00:54:52 +00:00
committed by GitHub
parent 03498e2f51
commit 155a1fcba0
29 changed files with 1353 additions and 165 deletions
+78 -21
View File
@@ -34,7 +34,9 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.deleteClientHandler))
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), httpserver.Handle(oc.updateAllowedUserGroupsHandler))
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), httpserver.Handle(oc.createClientSecretHandler))
group.GET("/oidc/clients/:id/secrets", authMiddleware.Add(), httpserver.Handle(oc.listClientSecretsHandler))
group.POST("/oidc/clients/:id/secrets", authMiddleware.Add(), httpserver.Handle(oc.createClientSecretHandler))
group.DELETE("/oidc/clients/:id/secrets/:secretId", authMiddleware.Add(), httpserver.Handle(oc.deleteClientSecretHandler))
group.GET("/oidc/clients/:id/logo", httpserver.Handle(oc.getClientLogoHandler))
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), httpserver.Handle(oc.deleteClientLogoHandler))
@@ -161,7 +163,8 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) error {
// @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) error {
var input dto.OidcClientCreateDto
if err := httpserver.BindJSON(c, &input); err != nil {
err := httpserver.BindJSON(c, &input)
if err != nil {
return err
}
@@ -171,7 +174,8 @@ func (oc *OidcController) createClientHandler(c *gin.Context) error {
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
err = dto.MapStruct(client, &clientDto)
if err != nil {
return err
}
@@ -208,7 +212,8 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) error {
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) error {
var input dto.OidcClientUpdateDto
if err := httpserver.BindJSON(c, &input); err != nil {
err := httpserver.BindJSON(c, &input)
if err != nil {
return err
}
@@ -218,7 +223,8 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) error {
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
err = dto.MapStruct(client, &clientDto)
if err != nil {
return err
}
@@ -241,7 +247,8 @@ func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) error {
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
err = dto.MapStruct(client, &clientDto)
if err != nil {
return err
}
@@ -250,28 +257,78 @@ func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) error {
return nil
}
// createClientSecretHandler godoc
// @Summary Create client secret
// @Description Set or generate a new secret for an OIDC client
// listClientSecretsHandler godoc
// @Summary List client secrets
// @Description List the secrets of an OIDC client, without disclosing their values
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param payload body dto.OidcClientSecretDto false "Client secret"
// @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) error {
var input dto.OidcClientSecretDto
if err := httpserver.BindOptionalJSON(c, &input); err != nil {
return err
}
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"), input)
// @Success 200 {array} dto.OidcClientSecretDto "Client secrets"
// @Router /api/oidc/clients/{id}/secrets [get]
func (oc *OidcController) listClientSecretsHandler(c *gin.Context) error {
secrets, err := oc.oidcService.ListClientSecrets(c.Request.Context(), c.Param("id"))
if err != nil {
return err
}
c.JSON(http.StatusOK, gin.H{"secret": secret})
var secretsDto []dto.OidcClientSecretDto
err = dto.MapStructList(secrets, &secretsDto)
if err != nil {
return err
}
c.JSON(http.StatusOK, secretsDto)
return nil
}
// createClientSecretHandler godoc
// @Summary Create client secret
// @Description Add a new secret to an OIDC client, leaving the existing ones usable. The value is only returned by this endpoint and cannot be retrieved later.
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param payload body dto.OidcClientSecretCreateDto false "Client secret"
// @Success 201 {object} dto.OidcClientSecretCreatedDto "Created client secret"
// @Router /api/oidc/clients/{id}/secrets [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) error {
var input dto.OidcClientSecretCreateDto
err := httpserver.BindOptionalJSON(c, &input)
if err != nil {
return err
}
created, secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"), input)
if err != nil {
return err
}
var secretDto dto.OidcClientSecretCreatedDto
err = dto.MapStruct(created, &secretDto)
if err != nil {
return err
}
secretDto.Secret = secret
c.JSON(http.StatusCreated, secretDto)
return nil
}
// deleteClientSecretHandler godoc
// @Summary Delete client secret
// @Description Delete a single secret of an OIDC client, making it immediately unusable
// @Tags OIDC
// @Param id path string true "Client ID"
// @Param secretId path string true "Client secret ID"
// @Success 204 "No content"
// @Router /api/oidc/clients/{id}/secrets/{secretId} [delete]
func (oc *OidcController) deleteClientSecretHandler(c *gin.Context) error {
err := oc.oidcService.DeleteClientSecret(c.Request.Context(), c.Param("id"), c.Param("secretId"))
if err != nil {
return err
}
c.Status(http.StatusNoContent)
return nil
}
+22
View File
@@ -64,12 +64,34 @@ type OidcClientCreateDto struct {
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
}
// OidcClientSecretDto describes a client secret without disclosing its value, which is only ever returned right after the secret is created
type OidcClientSecretDto struct {
ID string `json:"id"`
// Prefix holds the first few characters of the secret in clear text, and is empty for secrets migrated from the single-secret column
Prefix string `json:"prefix"`
CreatedAt datatype.DateTime `json:"createdAt"`
ExpiresAt *datatype.DateTime `json:"expiresAt"`
IsActive bool `json:"isActive"`
}
// OidcClientSecretCreateDto is the request body for creating a new client secret
type OidcClientSecretCreateDto struct {
// Secret allows callers to supply their own value instead of having Pocket ID generate one
Secret string `json:"secret" binding:"omitempty,min=16,printascii"`
// ExpiresAt makes the secret unusable after the given time (if nil, secrets don't expire)
ExpiresAt *datatype.DateTime `json:"expiresAt"`
}
// OidcClientSecretCreatedDto is returned when a secret is created, and is the only response that contains the secret's value
type OidcClientSecretCreatedDto struct {
OidcClientSecretDto
Secret string `json:"secret"`
}
type OidcClientCredentialsDto struct {
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
// Secrets is read-only: secrets are managed through the dedicated client secret endpoints and any value sent by a client is ignored
Secrets []OidcClientSecretDto `json:"secrets"`
}
type OidcClientFederatedIdentityDto struct {
+52
View File
@@ -3,10 +3,14 @@ package dto
import (
"encoding/json"
"testing"
"time"
"github.com/gin-gonic/gin/binding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
func TestOidcClientUpdateDto_tokenLifetimes(t *testing.T) {
@@ -70,3 +74,51 @@ func TestOidcClientUpdateDto_tokenLifetimes(t *testing.T) {
})
}
}
func TestOidcClientDto_secrets(t *testing.T) {
expired := datatype.DateTime(time.Now().Add(-time.Hour))
valid := datatype.DateTime(time.Now().Add(time.Hour))
client := model.OidcClient{
Base: model.Base{ID: "client-id"},
Name: "Test Client",
Credentials: model.OidcClientCredentials{
Secrets: []model.OidcClientSecret{
{ID: "active", Algorithm: model.OidcClientSecretHashSHA256, Hash: "hash-1", Prefix: "abcd"},
{ID: "expiring", Algorithm: model.OidcClientSecretHashSHA256, Hash: "hash-2", Prefix: "efgh", ExpiresAt: &valid},
{ID: "expired", Algorithm: model.OidcClientSecretHashSHA256, Hash: "hash-3", Prefix: "ijkl", ExpiresAt: &expired},
},
},
}
var clientDto OidcClientDto
require.NoError(t, MapStruct(client, &clientDto))
require.Len(t, clientDto.Credentials.Secrets, 3)
assert.Equal(t, "active", clientDto.Credentials.Secrets[0].ID)
assert.Equal(t, "abcd", clientDto.Credentials.Secrets[0].Prefix)
assert.True(t, clientDto.Credentials.Secrets[0].IsActive)
assert.True(t, clientDto.Credentials.Secrets[1].IsActive)
assert.False(t, clientDto.Credentials.Secrets[2].IsActive)
// Serializing the client must never disclose the hashes of its secrets
serialized, err := json.Marshal(clientDto)
require.NoError(t, err)
assert.NotContains(t, string(serialized), "hash-1")
assert.Contains(t, string(serialized), `"isActive":true`)
}
func TestOidcClientDto_secretsAlwaysSerialized(t *testing.T) {
client := model.OidcClient{
Base: model.Base{ID: "client-id"},
Name: "Test Client",
}
var clientDto OidcClientDto
require.NoError(t, MapStruct(client, &clientDto))
assert.Empty(t, clientDto.Credentials.Secrets)
// A client without secrets must serialize an empty list rather than omitting the field, so consumers never have to handle a missing value
serialized, err := json.Marshal(clientDto)
require.NoError(t, err)
assert.Contains(t, string(serialized), `"secrets":[]`)
}
+67 -1
View File
@@ -3,6 +3,8 @@ package model
import (
"database/sql/driver"
"encoding/json"
"slices"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -34,6 +36,11 @@ const (
MinTokenDurationMinutes int64 = 1
// MaxTokenDurationMinutes is the longest configurable token lifetime
MaxTokenDurationMinutes int64 = 365 * 24 * 60
// MaxOidcClientSecrets is the number of secrets a single client can have at the same time, including expired ones
MaxOidcClientSecrets = 20
// OidcClientSecretPrefixLength is how many leading characters of a client secret are kept in clear text so admins can tell secrets apart
OidcClientSecretPrefixLength = 4
)
type OidcClient struct {
@@ -41,7 +48,6 @@ type OidcClient struct {
Name string `sortable:"true"`
Description string
Secret string
CallbackURLs datatype.StringList
LogoutCallbackURLs datatype.StringList
ImageType *string
@@ -88,6 +94,66 @@ func (c OidcClient) IsMetadataDocument() bool {
type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
Secrets []OidcClientSecret `json:"secrets,omitempty"`
}
// OidcClientSecretHashAlgorithm identifies how the hash of a client secret was computed
type OidcClientSecretHashAlgorithm string
const (
// OidcClientSecretHashSHA256 is used by every client secret generated by Pocket ID
// A plain hash is enough because client secrets are generated with enough entropy that they cannot be brute-forced
OidcClientSecretHashSHA256 OidcClientSecretHashAlgorithm = "sha256"
// OidcClientSecretHashBcrypt is only found on secrets migrated from the single-secret column that Pocket ID used before it supported multiple secrets
// Those hashes cannot be converted to SHA-256, so they are kept as-is and verified with bcrypt until the admin rotates the secret
OidcClientSecretHashBcrypt OidcClientSecretHashAlgorithm = "bcrypt"
)
// OidcClientSecret is a single client secret of an OIDC client, stored hashed in the credentials JSON document
type OidcClientSecret struct {
ID string `json:"id"`
Algorithm OidcClientSecretHashAlgorithm `json:"alg"`
Hash string `json:"hash"`
// Prefix is empty for secrets migrated from the single-secret column, whose value was never stored
Prefix string `json:"prefix,omitempty"`
CreatedAt datatype.DateTime `json:"createdAt"`
ExpiresAt *datatype.DateTime `json:"expiresAt,omitempty"`
}
// IsActive reports whether the secret can still be used to authenticate the client
func (s OidcClientSecret) IsActive() bool {
return !s.IsExpiredAt(time.Now())
}
// IsExpiredAt reports whether the secret is expired at the given time
// Secrets without an expiration date are always valid
func (s OidcClientSecret) IsExpiredAt(now time.Time) bool {
return s.ExpiresAt != nil && !now.Before(s.ExpiresAt.ToTime())
}
// EncodedHash returns the secret's hash with its algorithm prepended, in the format "<alg>:<hash>"
// Fosite compares hashes without any other context, so the algorithm has to travel alongside the hash
func (s OidcClientSecret) EncodedHash() []byte {
return []byte(string(s.Algorithm) + ":" + s.Hash)
}
// ActiveSecrets returns the secrets that have not expired yet, ordered from the most recently created to the oldest
func (occ OidcClientCredentials) ActiveSecrets() []OidcClientSecret {
now := time.Now()
active := make([]OidcClientSecret, 0, len(occ.Secrets))
for _, secret := range occ.Secrets {
if !secret.IsExpiredAt(now) {
active = append(active, secret)
}
}
// Sort the copy so the primary hash is always the most recently created active secret
slices.SortStableFunc(active, func(a, b OidcClientSecret) int {
return b.CreatedAt.ToTime().Compare(a.CreatedAt.ToTime())
})
return active
}
type OidcClientFederatedIdentity struct {
+21 -1
View File
@@ -19,8 +19,28 @@ func (c Client) GetID() string {
return c.ID
}
// GetHashedSecret returns the hash of the client's most recently created secret that is still active
// Fosite checks this one first and then falls back to GetRotatedHashes for the others
func (c Client) GetHashedSecret() []byte {
return []byte(c.Secret)
active := c.Credentials.ActiveSecrets()
if len(active) == 0 {
return nil
}
return active[0].EncodedHash()
}
// GetRotatedHashes returns the hashes of every active secret except the one returned by GetHashedSecret
func (c Client) GetRotatedHashes() [][]byte {
active := c.Credentials.ActiveSecrets()
if len(active) <= 1 {
return nil
}
hashes := make([][]byte, len(active)-1)
for i, secret := range active[1:] {
hashes[i] = secret.EncodedHash()
}
return hashes
}
func (c Client) GetRedirectURIs() []string {
@@ -0,0 +1,49 @@
package oidc
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// errUnsupportedHashAlgorithm is returned when a stored hash carries an algorithm this build cannot verify
var errUnsupportedHashAlgorithm = errors.New("unsupported client secret hash algorithm")
// clientSecretHasher verifies client secrets against the hashes stored in the client's credentials document
// Fosite hands it the encoded hashes produced by model.OidcClientSecret.EncodedHash, which carry the algorithm they were computed with
type clientSecretHasher struct{}
// Compare checks a presented client secret against a stored, algorithm-prefixed hash
func (clientSecretHasher) Compare(_ context.Context, hash []byte, data []byte) error {
algorithm, encoded, found := strings.Cut(string(hash), ":")
if !found {
return errUnsupportedHashAlgorithm
}
switch model.OidcClientSecretHashAlgorithm(algorithm) {
case model.OidcClientSecretHashSHA256:
sum := sha256.Sum256(data)
if subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(encoded)) != 1 {
return errors.New("client secret does not match")
}
return nil
case model.OidcClientSecretHashBcrypt:
// Legacy hashes migrated from the single-secret column, which cannot be re-hashed because the secret's value is not recoverable
return bcrypt.CompareHashAndPassword([]byte(encoded), data)
default:
return errUnsupportedHashAlgorithm
}
}
// Hash is required by the fosite.Hasher interface but is never called, because Pocket ID hashes secrets when it stores them
func (clientSecretHasher) Hash(_ context.Context, data []byte) ([]byte, error) {
sum := sha256.Sum256(data)
return []byte(string(model.OidcClientSecretHashSHA256) + ":" + hex.EncodeToString(sum[:])), nil
}
@@ -0,0 +1,99 @@
package oidc
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestClientSecretHasherCompare(t *testing.T) {
const value = "client-secret-value"
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.MinCost)
require.NoError(t, err)
for _, test := range []struct {
name string
hash string
data string
wantErr bool
}{
{
name: "matching SHA-256 hash",
hash: "sha256:" + utils.CreateSha256Hash(value),
data: value,
},
{
name: "SHA-256 hash of another secret",
hash: "sha256:" + utils.CreateSha256Hash(value),
data: "some-other-value",
wantErr: true,
},
{
name: "bcrypt hash migrated from the single-secret column",
hash: "bcrypt:" + string(bcryptHash),
data: value,
},
{
name: "bcrypt hash of another secret",
hash: "bcrypt:" + string(bcryptHash),
data: "some-other-value",
wantErr: true,
},
{
name: "hash without an algorithm",
hash: utils.CreateSha256Hash(value),
data: value,
wantErr: true,
},
{
name: "unknown algorithm",
hash: "md5:" + utils.CreateSha256Hash(value),
data: value,
wantErr: true,
},
{
name: "empty hash, as returned for a client without secrets",
hash: "",
data: value,
wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
err := clientSecretHasher{}.Compare(t.Context(), []byte(test.hash), []byte(test.data))
if test.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestClientSecretHasherHash(t *testing.T) {
const value = "client-secret-value"
hash, err := clientSecretHasher{}.Hash(t.Context(), []byte(value))
require.NoError(t, err)
assert.Equal(t, "sha256:"+utils.CreateSha256Hash(value), string(hash))
// A hash produced by the hasher is accepted by its own comparison
err = clientSecretHasher{}.Compare(t.Context(), hash, []byte(value))
require.NoError(t, err)
}
func TestClientSecretHasherAcceptsModelEncoding(t *testing.T) {
const value = "client-secret-value"
secret := model.OidcClientSecret{
Algorithm: model.OidcClientSecretHashSHA256,
Hash: utils.CreateSha256Hash(value),
}
err := clientSecretHasher{}.Compare(t.Context(), secret.EncodedHash(), []byte(value))
require.NoError(t, err)
}
+60
View File
@@ -1,13 +1,17 @@
package oidc
import (
"strconv"
"testing"
"time"
"github.com/ory/fosite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/utils"
)
// Interface assertions
@@ -15,8 +19,26 @@ var (
_ fosite.Client = (*Client)(nil)
_ fosite.ResponseModeClient = (*Client)(nil)
_ fosite.ClientWithCustomTokenLifespans = (*Client)(nil)
_ fosite.ClientWithSecretRotation = (*Client)(nil)
)
// testClientCredentials returns credentials with a single never-expiring secret with the given value
func testClientCredentials(values ...string) model.OidcClientCredentials {
credentials := model.OidcClientCredentials{
Secrets: make([]model.OidcClientSecret, len(values)),
}
for i, value := range values {
credentials.Secrets[i] = model.OidcClientSecret{
ID: "secret-" + strconv.Itoa(i),
Algorithm: model.OidcClientSecretHashSHA256,
Hash: utils.CreateSha256Hash(value),
Prefix: value[:model.OidcClientSecretPrefixLength],
CreatedAt: datatype.DateTime(time.Now()),
}
}
return credentials
}
func TestClientGetEffectiveLifespan(t *testing.T) {
client := Client{OidcClient: model.OidcClient{
AccessTokenDurationMinutes: 2 * 60,
@@ -51,3 +73,41 @@ func TestClientGetEffectiveLifespan(t *testing.T) {
require.Equal(t, fallback, client.GetEffectiveLifespan(fosite.GrantTypeAuthorizationCode, fosite.AccessToken, fallback))
require.Equal(t, fallback, client.GetEffectiveLifespan(fosite.GrantTypeAuthorizationCode, fosite.RefreshToken, fallback))
}
func TestClientSecretHashes(t *testing.T) {
past := datatype.DateTime(time.Now().Add(-time.Hour))
future := datatype.DateTime(time.Now().Add(time.Hour))
older := datatype.DateTime(time.Now().Add(-48 * time.Hour))
newer := datatype.DateTime(time.Now().Add(-time.Minute))
t.Run("client without secrets", func(t *testing.T) {
client := Client{}
assert.Nil(t, client.GetHashedSecret())
assert.Empty(t, client.GetRotatedHashes())
})
t.Run("expired secrets are never returned", func(t *testing.T) {
client := Client{OidcClient: model.OidcClient{Credentials: model.OidcClientCredentials{
Secrets: []model.OidcClientSecret{
{ID: "expired", Algorithm: model.OidcClientSecretHashSHA256, Hash: "expired-hash", ExpiresAt: &past},
},
}}}
assert.Nil(t, client.GetHashedSecret())
assert.Empty(t, client.GetRotatedHashes())
})
t.Run("the most recent active secret comes first", func(t *testing.T) {
client := Client{OidcClient: model.OidcClient{Credentials: model.OidcClientCredentials{
Secrets: []model.OidcClientSecret{
{ID: "older", Algorithm: model.OidcClientSecretHashSHA256, Hash: "older-hash", CreatedAt: older},
{ID: "expired", Algorithm: model.OidcClientSecretHashSHA256, Hash: "expired-hash", CreatedAt: newer, ExpiresAt: &past},
{ID: "newer", Algorithm: model.OidcClientSecretHashSHA256, Hash: "newer-hash", CreatedAt: newer, ExpiresAt: &future},
},
}}}
assert.Equal(t, "sha256:newer-hash", string(client.GetHashedSecret()))
rotated := client.GetRotatedHashes()
require.Len(t, rotated, 1)
assert.Equal(t, "sha256:older-hash", string(rotated[0]))
})
}
+1
View File
@@ -57,6 +57,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
GlobalSecret: secret,
JWTScopeClaimKey: jwt.JWTScopeFieldBoth,
ClientResolver: clientResolver,
ClientSecretsHasher: clientSecretHasher{},
}
keyGetter := func(context.Context) (interface{}, error) {
+135 -22
View File
@@ -20,10 +20,10 @@ import (
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
fositejwt "github.com/ory/fosite/token/jwt"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -49,12 +49,10 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
require.NoError(t, err)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Client Credentials Client",
Secret: string(hashed),
Credentials: testClientCredentials(clientPlain),
IsPublic: false,
AccessTokenDurationMinutes: 2 * 60,
}).Error)
@@ -111,13 +109,11 @@ func TestTokenHandlerClientCredentialsDropsIdentityScopes(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
require.NoError(t, err)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Client Credentials Client",
Secret: string(hashed),
IsPublic: false,
Base: model.Base{ID: clientID},
Name: "Client Credentials Client",
Credentials: testClientCredentials(clientPlain),
IsPublic: false,
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
@@ -166,13 +162,11 @@ func TestTokenHandlerClientCredentialsUsesClientSubjectGrants(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
require.NoError(t, err)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Client Credentials Client",
Secret: string(hashed),
IsPublic: false,
Base: model.Base{ID: clientID},
Name: "Client Credentials Client",
Credentials: testClientCredentials(clientPlain),
IsPublic: false,
}).Error)
apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
@@ -239,13 +233,11 @@ func TestTokenHandlerClientCredentialsDefaultsResourceScopes(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
require.NoError(t, err)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Client Credentials Client",
Secret: string(hashed),
IsPublic: false,
Base: model.Base{ID: clientID},
Name: "Client Credentials Client",
Credentials: testClientCredentials(clientPlain),
IsPublic: false,
}).Error)
apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
@@ -731,3 +723,124 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
require.Equal(t, "invalid_scope", body["error"])
})
}
// TestTokenHandlerClientSecretRotation drives the token endpoint with each of a client's secrets,
// to check that every active secret authenticates the client while expired ones are rejected.
func TestTokenHandlerClientSecretRotation(t *testing.T) {
gin.SetMode(gin.TestMode)
const (
baseURL = "https://issuer.example.com"
secret = "test-secret"
clientID = "rotation-client"
currentPlain = "current-secret-value"
olderPlain = "older-secret-value"
expiredPlain = "expired-secret-value"
)
db := testutils.NewDatabaseForTest(t)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
credentials := testClientCredentials(currentPlain, olderPlain, expiredPlain)
expiredAt := datatype.DateTime(time.Now().Add(-time.Hour))
credentials.Secrets[2].ExpiresAt = &expiredAt
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Secret Rotation Client",
Credentials: credentials,
IsPublic: false,
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
BaseURL: baseURL,
TokenBaseURL: baseURL,
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
requestToken := func(t *testing.T, clientSecret string) map[string]any {
t.Helper()
form := url.Values{"grant_type": {"client_credentials"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(clientID, clientSecret)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
handler.token(c)
var body map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
return body
}
for _, test := range []struct {
name string
clientSecret string
wantToken bool
}{
{name: "most recently created secret", clientSecret: currentPlain, wantToken: true},
{name: "secret being rotated out", clientSecret: olderPlain, wantToken: true},
{name: "expired secret", clientSecret: expiredPlain},
{name: "secret that was never configured", clientSecret: "not-a-secret"},
} {
t.Run(test.name, func(t *testing.T) {
body := requestToken(t, test.clientSecret)
if test.wantToken {
require.NotEmpty(t, body["access_token"], "expected a token, got error: %v", body["error"])
} else {
require.Empty(t, body["access_token"])
require.Equal(t, "invalid_client", body["error"])
}
})
}
}
// TestTokenHandlerClientWithoutSecrets checks that a confidential client that has no secrets at all cannot authenticate.
func TestTokenHandlerClientWithoutSecrets(t *testing.T) {
gin.SetMode(gin.TestMode)
const (
baseURL = "https://issuer.example.com"
secret = "test-secret"
clientID = "no-secrets-client"
)
db := testutils.NewDatabaseForTest(t)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Client Without Secrets",
IsPublic: false,
}).Error)
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
BaseURL: baseURL,
TokenBaseURL: baseURL,
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
form := url.Values{"grant_type": {"client_credentials"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(clientID, "")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
handler.token(c)
var body map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Empty(t, body["access_token"])
require.Equal(t, "invalid_client", body["error"])
}
+27 -7
View File
@@ -103,6 +103,26 @@ func (s *TestService) initExternalIdP() error {
return nil
}
// seededClientCredentials returns credentials holding a single client secret with the given value, which E2E tests use to authenticate as the client
func seededClientCredentials(secretID string, value string) model.OidcClientCredentials {
return model.OidcClientCredentials{
Secrets: seededClientSecrets(secretID, value),
}
}
// seededClientSecrets returns a single never-expiring client secret with the given value
func seededClientSecrets(secretID string, value string) []model.OidcClientSecret {
return []model.OidcClientSecret{
{
ID: secretID,
Algorithm: model.OidcClientSecretHashSHA256,
Hash: utils.CreateSha256Hash(value),
Prefix: value[:model.OidcClientSecretPrefixLength],
CreatedAt: datatype.DateTime(time.Now()),
},
}
}
//nolint:gocognit
func (s *TestService) SeedDatabase(baseURL string) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
@@ -181,7 +201,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Nextcloud",
Description: "This is an example description for Nextcloud",
LaunchURL: new("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000001", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
CallbackURLs: datatype.StringList{"http://nextcloud.localhost/auth/callback"},
LogoutCallbackURLs: datatype.StringList{"http://nextcloud.localhost/auth/logout/callback"},
ImageType: new("png"),
@@ -193,7 +213,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Immich",
LaunchURL: new("https://immich.local"),
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000002", "PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x"),
CallbackURLs: datatype.StringList{"http://immich.localhost/auth/callback"},
CreatedByID: new(users[1].ID),
IsGroupRestricted: true,
@@ -206,7 +226,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
},
Name: "Tailscale",
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000003", "n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo"),
CallbackURLs: datatype.StringList{"http://tailscale.localhost/auth/callback"},
LogoutCallbackURLs: datatype.StringList{"http://tailscale.localhost/auth/logout/callback"},
IsGroupRestricted: true,
@@ -220,11 +240,11 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
},
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: datatype.StringList{"http://federated.localhost/auth/callback"},
CreatedByID: new(users[1].ID),
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
Secrets: seededClientSecrets("2f1b8f1a-1d3e-4f0c-9c1a-000000000004", "PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x"),
FederatedIdentities: []model.OidcClientFederatedIdentity{
{
Issuer: "https://external-idp.local",
@@ -240,7 +260,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
},
Name: "SCIM Client",
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000005", "nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn"),
CallbackURLs: datatype.StringList{"http://scimclient.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
IsGroupRestricted: true,
@@ -254,7 +274,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
},
Name: "PAR Test Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000006", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
CallbackURLs: datatype.StringList{"http://par-client.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
},
@@ -263,7 +283,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "e1f2a3b4-c5d6-7890-abcd-ef0000000002",
},
Name: "Skip Consent Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
Credentials: seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000007", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
CallbackURLs: datatype.StringList{"http://skip-consent.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
// Trusted client that bypasses the consent screen by default
@@ -0,0 +1,88 @@
package service
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// versionBeforeMultipleClientSecrets is the migration version right before client secrets moved into the credentials document
const versionBeforeMultipleClientSecrets = 20260802120000
// TestMigrateClientSecretsToCredentials checks that the secret of every existing client is preserved as an entry of the credentials document
func TestMigrateClientSecretsToCredentials(t *testing.T) {
const legacySecret = "legacy-client-secret"
legacyHash, err := bcrypt.GenerateFromPassword([]byte(legacySecret), bcrypt.MinCost)
require.NoError(t, err)
createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMultipleClientSecrets, func(t *testing.T, db *gorm.DB) {
// A client with a secret and no other credentials
err := db.Exec(
`INSERT INTO oidc_clients (id, created_at, name, secret, callback_urls, is_public, pkce_enabled) VALUES (?, ?, ?, ?, ?, ?, ?)`,
"client-with-secret", createdAt.Unix(), "With Secret", string(legacyHash), `["https://example.com/callback"]`, false, false,
).Error
require.NoError(t, err)
// A client whose credentials document already holds a federated identity, which the migration must preserve
err = db.Exec(
`INSERT INTO oidc_clients (id, created_at, name, secret, callback_urls, is_public, pkce_enabled, credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
"client-with-federated-identity", createdAt.Unix(), "With Federated Identity", string(legacyHash), `["https://example.com/callback"]`, false, false,
`{"federatedIdentities":[{"issuer":"https://issuer.example.com"}]}`,
).Error
require.NoError(t, err)
// A public client, which never had a secret
err = db.Exec(
`INSERT INTO oidc_clients (id, created_at, name, secret, callback_urls, is_public, pkce_enabled) VALUES (?, ?, ?, ?, ?, ?, ?)`,
"public-client", createdAt.Unix(), "Public", "", `["https://example.com/callback"]`, true, true,
).Error
require.NoError(t, err)
})
// The legacy column is gone once the migration has run
ok := db.Migrator().HasColumn(&model.OidcClient{}, "secret")
assert.False(t, ok)
var clients []model.OidcClient
err = db.Find(&clients).Error
require.NoError(t, err)
byID := make(map[string]model.OidcClient, len(clients))
for _, client := range clients {
byID[client.ID] = client
}
require.Len(t, byID, 3)
// The migrated secret keeps its bcrypt hash, has no expiration, and carries no prefix because the value was never stored
migrated := byID["client-with-secret"].Credentials.Secrets
require.Len(t, migrated, 1)
assert.NotEmpty(t, migrated[0].ID)
assert.Equal(t, model.OidcClientSecretHashBcrypt, migrated[0].Algorithm)
assert.Equal(t, string(legacyHash), migrated[0].Hash)
assert.Empty(t, migrated[0].Prefix)
assert.Nil(t, migrated[0].ExpiresAt)
assert.True(t, migrated[0].IsActive())
assert.Equal(t, createdAt.UTC(), migrated[0].CreatedAt.UTC())
// Existing federated identities survive the migration alongside the new secret
withFederated := byID["client-with-federated-identity"].Credentials
require.Len(t, withFederated.Secrets, 1)
assert.Equal(t, string(legacyHash), withFederated.Secrets[0].Hash)
require.Len(t, withFederated.FederatedIdentities, 1)
assert.Equal(t, "https://issuer.example.com", withFederated.FederatedIdentities[0].Issuer)
// Clients that never had a secret do not get an empty one
assert.Empty(t, byID["public-client"].Credentials.Secrets)
// Every migrated secret gets its own identifier
assert.NotEqual(t, migrated[0].ID, withFederated.Secrets[0].ID)
}
+89 -13
View File
@@ -10,10 +10,11 @@ import (
"net/http"
"net/url"
"path"
"slices"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -320,7 +321,23 @@ func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
return nil
}
func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, input dto.OidcClientSecretDto) (string, error) {
// ListClientSecrets returns all secrets configured for a client, including the expired ones
func (s *OidcService) ListClientSecrets(ctx context.Context, clientID string) ([]model.OidcClientSecret, error) {
client, err := s.getClientInternal(ctx, clientID, s.db, false)
if err != nil {
return nil, err
}
return client.Credentials.Secrets, nil
}
// CreateClientSecret adds a new secret to a client and returns both the stored record and the secret's value, which is not recoverable afterwards
func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, input dto.OidcClientSecretCreateDto) (model.OidcClientSecret, string, error) {
// An expiration date in the past would create a secret that can never be used
if input.ExpiresAt != nil && !input.ExpiresAt.ToTime().After(time.Now()) {
return model.OidcClientSecret{}, "", apperror.ValidationMessage("The expiration date of a client secret must be in the future")
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -328,41 +345,100 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i
client, err := s.getClientInternal(ctx, clientID, tx, true)
if err != nil {
return "", err
return model.OidcClientSecret{}, "", fmt.Errorf("error retrieving OIDC client: %w", err)
}
if client.IsPublic {
return "", apperror.ValidationMessage("Cannot create a secret for a public client")
return model.OidcClientSecret{}, "", apperror.ValidationMessage("Cannot create a secret for a public client")
}
if len(client.Credentials.Secrets) >= model.MaxOidcClientSecrets {
return model.OidcClientSecret{}, "", apperror.ValidationMessage(fmt.Sprintf("A client cannot have more than %d secrets", model.MaxOidcClientSecrets))
}
// Callers may supply their own value, otherwise one with enough entropy is generated here
clientSecret := input.Secret
if clientSecret == "" {
clientSecret, err = utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
return model.OidcClientSecret{}, "", fmt.Errorf("failed to generate client secret: %w", err)
}
}
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
if err != nil {
return "", err
// Only the hash and a short prefix are persisted, so this is the last time the value is available
secret := model.OidcClientSecret{
ID: uuid.New().String(),
Algorithm: model.OidcClientSecretHashSHA256,
Hash: utils.CreateSha256Hash(clientSecret),
Prefix: clientSecretPrefix(clientSecret),
CreatedAt: datatype.DateTime(time.Now()),
ExpiresAt: input.ExpiresAt,
}
client.Credentials.Secrets = append(client.Credentials.Secrets, secret)
client.Secret = string(hashedSecret)
err = tx.
WithContext(ctx).
Save(&client).
Model(&client).
Select("Credentials").
Updates(&client).
Error
if err != nil {
return "", err
return model.OidcClientSecret{}, "", fmt.Errorf("failed to update OIDC client: %w", err)
}
err = tx.Commit().Error
if err != nil {
return "", err
return model.OidcClientSecret{}, "", fmt.Errorf("failed to commit transaction: %w", err)
}
return clientSecret, nil
return secret, clientSecret, nil
}
// DeleteClientSecret removes a single secret from a client, making it immediately unusable
func (s *OidcService) DeleteClientSecret(ctx context.Context, clientID string, secretID string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
client, err := s.getClientInternal(ctx, clientID, tx, true)
if err != nil {
return fmt.Errorf("error retrieving OIDC client: %w", err)
}
countBefore := len(client.Credentials.Secrets)
client.Credentials.Secrets = slices.DeleteFunc(client.Credentials.Secrets, func(secret model.OidcClientSecret) bool {
return secret.ID == secretID
})
if len(client.Credentials.Secrets) == countBefore {
return apperror.NotFound("Client secret")
}
err = tx.
WithContext(ctx).
Model(&client).
Select("Credentials").
Updates(&client).
Error
if err != nil {
return fmt.Errorf("failed to update OIDC client: %w", err)
}
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// clientSecretPrefix returns the leading characters of a secret that are stored in clear text to help admins tell secrets apart
func clientSecretPrefix(clientSecret string) string {
if len(clientSecret) <= model.OidcClientSecretPrefixLength {
return ""
}
return clientSecret[:model.OidcClientSecretPrefixLength]
}
func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (io.ReadCloser, int64, string, error) {
+118 -5
View File
@@ -6,8 +6,7 @@ import (
"strconv"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -668,16 +667,130 @@ func TestOidcService_CreateClientSecret_withCustomSecret(t *testing.T) {
require.NoError(t, err)
customSecret := "custom-client-secret-with-a-minimum-length"
input := dto.OidcClientSecretDto{Secret: customSecret}
input := dto.OidcClientSecretCreateDto{Secret: customSecret}
secret, err := s.CreateClientSecret(t.Context(), client.ID, input)
created, secret, err := s.CreateClientSecret(t.Context(), client.ID, input)
require.NoError(t, err)
assert.Equal(t, customSecret, secret)
assert.Equal(t, "cust", created.Prefix)
assert.Nil(t, created.ExpiresAt)
assert.True(t, created.IsActive())
var fetched model.OidcClient
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fetched.Secret), []byte(customSecret)))
require.Len(t, fetched.Credentials.Secrets, 1)
assert.Equal(t, created.ID, fetched.Credentials.Secrets[0].ID)
assert.Equal(t, model.OidcClientSecretHashSHA256, fetched.Credentials.Secrets[0].Algorithm)
assert.Equal(t, utils.CreateSha256Hash(customSecret), fetched.Credentials.Secrets[0].Hash)
}
func TestOidcService_CreateClientSecret_multipleSecrets(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: "Test Client"}
err = db.Create(&client).Error
require.NoError(t, err)
// Adding a second secret leaves the first one in place
first, _, err := s.CreateClientSecret(t.Context(), client.ID, dto.OidcClientSecretCreateDto{})
require.NoError(t, err)
expiresAt := datatype.DateTime(time.Now().Add(24 * time.Hour))
second, _, err := s.CreateClientSecret(t.Context(), client.ID, dto.OidcClientSecretCreateDto{ExpiresAt: &expiresAt})
require.NoError(t, err)
secrets, err := s.ListClientSecrets(t.Context(), client.ID)
require.NoError(t, err)
require.Len(t, secrets, 2)
assert.Equal(t, first.ID, secrets[0].ID)
assert.Equal(t, second.ID, secrets[1].ID)
assert.Equal(t, expiresAt.ToTime().Unix(), secrets[1].ExpiresAt.ToTime().Unix())
// Deleting one secret keeps the other usable
err = s.DeleteClientSecret(t.Context(), client.ID, first.ID)
require.NoError(t, err)
secrets, err = s.ListClientSecrets(t.Context(), client.ID)
require.NoError(t, err)
require.Len(t, secrets, 1)
assert.Equal(t, second.ID, secrets[0].ID)
// Deleting an unknown secret is reported as not found
err = s.DeleteClientSecret(t.Context(), client.ID, first.ID)
require.Error(t, err)
}
func TestOidcService_CreateClientSecret_expirationInThePast(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: "Test Client"}
err = db.Create(&client).Error
require.NoError(t, err)
expiresAt := datatype.DateTime(time.Now().Add(-time.Minute))
_, _, err = s.CreateClientSecret(t.Context(), client.ID, dto.OidcClientSecretCreateDto{ExpiresAt: &expiresAt})
require.Error(t, err)
}
func TestOidcService_CreateClientSecret_limit(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: "Test Client"}
err = db.Create(&client).Error
require.NoError(t, err)
for range model.MaxOidcClientSecrets {
_, _, err = s.CreateClientSecret(t.Context(), client.ID, dto.OidcClientSecretCreateDto{})
require.NoError(t, err)
}
_, _, err = s.CreateClientSecret(t.Context(), client.ID, dto.OidcClientSecretCreateDto{})
require.Error(t, err)
}
func TestOidcService_CreateClientSecret_preservesFederatedIdentities(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: "Test Client",
CallbackURLs: datatype.StringList{"https://example.com/callback"},
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{{Issuer: "https://issuer.example.com"}},
},
}
err = db.Create(&client).Error
require.NoError(t, err)
_, _, err = s.CreateClientSecret(t.Context(), client.ID, dto.OidcClientSecretCreateDto{})
require.NoError(t, err)
// Updating the client must not drop the secrets managed by the dedicated endpoints
_, err = s.UpdateClient(t.Context(), client.ID, dto.OidcClientUpdateDto{
Name: "Test Client",
CallbackURLs: []string{"https://example.com/callback"},
Credentials: dto.OidcClientCredentialsDto{
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{{Issuer: "https://other.example.com"}},
},
})
require.NoError(t, err)
var fetched model.OidcClient
require.NoError(t, db.First(&fetched, "id = ?", client.ID).Error)
require.Len(t, fetched.Credentials.Secrets, 1)
require.Len(t, fetched.Credentials.FederatedIdentities, 1)
assert.Equal(t, "https://other.example.com", fetched.Credentials.FederatedIdentities[0].Issuer)
}
func TestOidcService_UpdateClient_description(t *testing.T) {
@@ -0,0 +1,16 @@
ALTER TABLE oidc_clients ADD COLUMN secret TEXT;
-- Only secrets that were migrated up from this same column can be restored, since the old column stores a bcrypt hash
-- Secrets created while multiple secrets were supported are hashed with SHA-256 and are dropped here
UPDATE oidc_clients
SET secret = (
SELECT secret_entry ->> 'hash'
FROM jsonb_array_elements(credentials -> 'secrets') AS secret_entry
WHERE secret_entry ->> 'alg' = 'bcrypt'
LIMIT 1
)
WHERE jsonb_typeof(credentials -> 'secrets') = 'array';
UPDATE oidc_clients
SET credentials = credentials - 'secrets'
WHERE jsonb_typeof(credentials -> 'secrets') = 'array';
@@ -0,0 +1,14 @@
-- Move the single client secret into the credentials document, as the only entry of the new "secrets" array
-- Migrated secrets keep their bcrypt hash because the secret's value is not recoverable, and they never expire so that existing integrations keep working
UPDATE oidc_clients
SET credentials = COALESCE(credentials, '{}'::jsonb) || jsonb_build_object(
'secrets', jsonb_build_array(jsonb_build_object(
'id', gen_random_uuid()::text,
'alg', 'bcrypt',
'hash', secret,
'createdAt', to_char(COALESCE(created_at, now()) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
))
)
WHERE secret IS NOT NULL AND secret <> '';
ALTER TABLE oidc_clients DROP COLUMN secret;
@@ -0,0 +1,28 @@
PRAGMA foreign_keys= OFF;
BEGIN;
ALTER TABLE oidc_clients ADD COLUMN secret TEXT;
-- Only secrets that were migrated up from this same column can be restored, since the old column stores a bcrypt hash
-- Secrets created while multiple secrets were supported are hashed with SHA-256 and are dropped here
UPDATE oidc_clients
SET secret = (
SELECT json_extract(value, '$.hash')
FROM json_each(json_extract(credentials, '$.secrets'))
WHERE json_extract(value, '$.alg') = 'bcrypt'
LIMIT 1
)
WHERE credentials IS NOT NULL
AND credentials != ''
AND json_valid(credentials)
AND json_type(credentials, '$.secrets') = 'array';
UPDATE oidc_clients
SET credentials = json_remove(credentials, '$.secrets')
WHERE credentials IS NOT NULL
AND credentials != ''
AND json_valid(credentials)
AND json_type(credentials, '$.secrets') = 'array';
COMMIT;
PRAGMA foreign_keys= ON;
@@ -0,0 +1,31 @@
PRAGMA foreign_keys= OFF;
BEGIN;
-- Move the single client secret into the credentials document, as the only entry of the new "secrets" array
-- Migrated secrets keep their bcrypt hash because the secret's value is not recoverable, and they never expire so that existing integrations keep working
UPDATE oidc_clients
SET credentials = json_set(
COALESCE(NULLIF(credentials, ''), '{}'),
'$.secrets',
json_array(json_object(
-- Generate a UUID
'id', lower(
hex(randomblob(4)) || '-' ||
hex(randomblob(2)) || '-4' ||
substr(hex(randomblob(2)), 2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2) || '-' ||
hex(randomblob(6))
),
'alg', 'bcrypt',
'hash', secret,
'createdAt', strftime('%Y-%m-%dT%H:%M:%SZ', COALESCE(created_at, strftime('%s', 'now')), 'unixepoch')
))
)
WHERE secret IS NOT NULL
AND secret != ''
AND (credentials IS NULL OR credentials = '' OR json_valid(credentials));
ALTER TABLE oidc_clients DROP COLUMN secret;
COMMIT;
PRAGMA foreign_keys= ON;