mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-12 12:49:07 +02:00
feat: support multiple client secrets per OIDC client (#1679)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user