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" ) type UserAuthorizedOidcClient struct { Scope datatype.StringList LastUsedAt datatype.DateTime `sortable:"true"` UserID string `gorm:"primary_key;"` User User ClientID string `gorm:"primary_key;"` Client OidcClient } // OidcClientType identifies how an OIDC client was registered. type OidcClientType string const ( OidcClientTypeStandard OidcClientType = "standard" OidcClientTypeCIMD OidcClientType = "cimd" // DefaultAccessTokenDurationMinutes is the access-token lifetime used for new clients DefaultAccessTokenDurationMinutes int64 = 60 // DefaultRefreshTokenDurationMinutes is the refresh-token lifetime used for new clients DefaultRefreshTokenDurationMinutes int64 = 30 * 24 * 60 // MinTokenDurationMinutes is the shortest configurable token lifetime 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 { Base Name string `sortable:"true"` Description string CallbackURLs datatype.StringList LogoutCallbackURLs datatype.StringList ImageType *string DarkImageType *string IsPublic bool PkceEnabled bool `sortable:"true" filterable:"true"` RequiresReauthentication bool `sortable:"true" filterable:"true"` RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"` SkipConsent bool `sortable:"true" filterable:"true"` Credentials OidcClientCredentials LaunchURL *string IsGroupRestricted bool `sortable:"true" filterable:"true"` PkceSupported bool `sortable:"true" filterable:"true"` ClientType OidcClientType `gorm:"default:standard" sortable:"true" filterable:"true"` MetadataExpiresAt *datatype.DateTime MetadataGrantTypes datatype.StringList AccessTokenDurationMinutes int64 `gorm:"default:60"` RefreshTokenDurationMinutes int64 `gorm:"default:43200"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` CreatedByID *string CreatedBy *User UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"` } // IsValidTokenDurationMinutes reports whether a duration is within the configurable range func IsValidTokenDurationMinutes(minutes int64) bool { return minutes >= MinTokenDurationMinutes && minutes <= MaxTokenDurationMinutes } func (c OidcClient) HasLogo() bool { return c.ImageType != nil && *c.ImageType != "" } func (c OidcClient) HasDarkLogo() bool { return c.DarkImageType != nil && *c.DarkImageType != "" } // IsMetadataDocument reports whether the client was synthesized from an OAuth // Client ID Metadata Document. Its ID is then the https URL of the document. func (c OidcClient) IsMetadataDocument() bool { return c.ClientType == OidcClientTypeCIMD } 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 ":" // 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 { Issuer string `json:"issuer"` Subject string `json:"subject,omitempty"` Audience string `json:"audience,omitempty"` JWKS string `json:"jwks,omitempty"` // URL of the JWKS ReplayProtection bool `json:"replayProtection,omitempty"` } func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) { if issuer == "" { return OidcClientFederatedIdentity{}, false } for _, fi := range occ.FederatedIdentities { if fi.Issuer == issuer { return fi, true } } return OidcClientFederatedIdentity{}, false } func (occ *OidcClientCredentials) Scan(value any) error { return utils.UnmarshalJSONFromDatabase(occ, value) } func (occ OidcClientCredentials) Value() (driver.Value, error) { return json.Marshal(occ) }