Documentation
¶
Overview ¶
Package clientcreds keeps the secrets of confidential OIDC clients the issuer generates itself (a policy client written `secret: {generate: true}`).
A generated secret is a credential, so it lives in the Secrets port under `credentials/oidc-client/<id>/secret` as one JSON Record. It is made once, with a create-only write (port.Secrets.PutIfVersion with an empty version), so that however many replicas or invocations start together exactly one value exists and nobody ever overwrites it.
Reconcile makes sure every generated client has a record. Resolver is what the token endpoint asks for a client's secret: the record when there is one, otherwise the input `clients/<id>/secret` the installation delivers, for every client, so that a first deploy never refuses a valid client before the first reconcile ran.
No value is ever logged, put in an error or used as a metric attribute.
Index ¶
- Constants
- Variables
- func CountAdminRefused(ctx context.Context, reason string)
- func CountAuth(ctx context.Context, slot string)
- func Generate() (string, error)
- func Path(clientID string) string
- func ReconcileOrphans(ctx context.Context, clients []string, store port.Secrets, now time.Time, ...) []string
- type Hooks
- type Input
- type Locker
- type Lookup
- type Manager
- type Meta
- type Outcome
- type Overlapper
- type Record
- type Resolver
- type Result
- type Rotation
- type Secrets
Constants ¶
const ( DefaultOverlap = 24 * time.Hour MaxOverlap = 7 * 24 * time.Hour )
The overlap of a rotation: how long the secret it replaces is still accepted, so a relying party is not cut off between the new value being made and being deployed. Zero is a hard cut.
const ( SlotCurrent = "current" SlotPrevious = "previous" SlotNone = "none" )
The slots an authentication can match.
const CacheTTL = 30 * time.Second
CacheTTL is how long a read of a record is reused.
const Kind = "oidc-client"
Kind is the credential kind in the Secrets path.
const KindLease = "client-secret"
KindLease is the lease kind of one client's record: whoever writes an existing record (a rotation, an orphaned mark) holds the lease on its client, since a secrets adapter's conditional write may be a read and then a write. [rails.Leases] is a Locker.
const RecordVersion = 1
RecordVersion is the version of the record's JSON.
const RereadFloor = 5 * time.Second
RereadFloor is how recent a read must be for Resolver.Reread to reuse it: a flood of wrong guesses costs the store one read per client per floor, not one per guess.
const StaleFor = 5 * time.Minute
StaleFor is how long past its last successful read a record is still served when the store cannot be read: a replica whose store fails does not accept a rotated-away secret for longer than this.
Variables ¶
var ( // ErrNotGenerated: the client does not have `secret: {generate: true}`. ErrNotGenerated = errors.New("clientcreds: the client does not have `secret: {generate: true}`") // ErrNoRecord: there is no stored secret for the client (yet). ErrNoRecord = errors.New("clientcreds: the client has no stored secret") // ErrBusy: another writer holds the client's lease, or changed the record // between our read and our write. Nothing was written; try again. ErrBusy = errors.New("clientcreds: the client's secret is being changed by another writer") // ErrOverlap: the overlap is negative or longer than [MaxOverlap]. ErrOverlap = errors.New("clientcreds: the overlap is between 0 and 7 days") // ErrStillDeclared: the client is still a generated client of the policy, // so its record is not an orphan to purge. ErrStillDeclared = errors.New("clientcreds: the client is still a generated client of the policy; remove it from the policy first") )
What a person's request to Manager can be refused for. None holds a value.
Functions ¶
func CountAdminRefused ¶
CountAdminRefused counts one refused request to the admin endpoint, by reason. No client id and no identity: an unauthenticated caller must not mint a series.
func CountAuth ¶
CountAuth counts one confidential client authentication by the slot that matched (SlotCurrent, SlotPrevious or SlotNone). No client id: a refused guess must not mint a metric series per id an attacker invents.
func Path ¶
Path is the Secrets path of a client's record. A client id that is not one path segment is spelled as `u-` and its bytes in hex, the way the credential layout does, so any declared id has a path and two ids never share one.
func ReconcileOrphans ¶
func ReconcileOrphans( ctx context.Context, clients []string, store port.Secrets, now time.Time, log *slog.Logger, hooks Hooks, ) []string
ReconcileOrphans looks for stored records whose client is not in clients (the generated clients of the policy in force) and reports each one once: it writes the time into the record (`orphaned`), calls hooks.Orphaned and counts it. A record already marked is not reported again, and a record whose client returns is cleared by Reconcile, which keeps the secret as it is. It returns the ids it reported now.
An orphan is never deleted here: a person decides (`sluisctl clients purge`). A failure is logged and left for the next pass.
Types ¶
type Hooks ¶
type Hooks struct {
// Lock serialises a write to an existing record (the orphaned mark) with
// a rotation of the same client. A secrets adapter whose conditional
// write is a read and then a write (ssm) needs it. Nil runs unserialised,
// which is right only for one process.
Lock Locker
// Orphaned is called once for each record newly found with no generated
// client in the policy. See [ReconcileOrphans].
Orphaned func(ctx context.Context, clientID string)
// Outcome is called once per client per pass. err is set for
// OutcomeUnsupported and OutcomeFailed.
Outcome func(ctx context.Context, clientID string, outcome Outcome, err error)
}
Hooks are told what Reconcile did, for the audit trail part of the installation. Every field may be nil. A hook receives the client id and the outcome and never a value.
type Input ¶
Input delivers the installation's input secrets by name: secrets.Source is one.
type Locker ¶
type Locker interface {
Do(ctx context.Context, kind, target string, fn func(ctx context.Context)) (ran bool, err error)
}
Locker runs fn under an exactly-one-holder lease on a target of a kind, and says whether it ran: false with no error when another runner holds the lease.
type Lookup ¶
type Lookup interface {
// Resolve returns the client's secrets, false when it has none.
Resolve(ctx context.Context, clientID string) (Secrets, bool)
}
Lookup is what the token endpoint asks for a client's secrets.
type Manager ¶
type Manager struct {
Store port.Secrets
// Lock serialises writers of one client's record (see [KindLease]). Nil
// runs unserialised, which is right only for one process.
Lock Locker
// Resolver is told to forget a client after its record changes, so this
// replica serves the new value at once; another replica's cache lapses
// within [CacheTTL]. May be nil.
Resolver *Resolver
// Generated says whether the policy in force declares the client with
// `secret: {generate: true}`.
Generated func(clientID string) bool
// Now replaces the clock, for a test.
Now func() time.Time
Log *slog.Logger
}
Manager is what a person asks of the stored secrets: rotate one, look at its metadata, purge an orphan. It never returns or logs a value.
func (*Manager) Purge ¶
Purge deletes the record of a client that is no longer a generated client of the policy. The copy an export made is the export's: the export is declared with the client, so it is gone with it and nothing here knows where it went.
func (*Manager) Rotate ¶
func (m *Manager) Rotate(ctx context.Context, clientID string, overlap time.Duration) (Rotation, error)
Rotate replaces the client's current secret with a new one. The old one is accepted for overlap more (zero: not at all). It is refused for a client that is not generated, and for one with no record yet.
type Meta ¶
type Meta struct {
// Exists is false when the client has no record.
Exists bool
// Generated is whether the policy in force declares the client generated.
Generated bool
Created, Rotated time.Time
HasPrevious bool
PreviousValidUntil time.Time
// PreviousActive is whether the previous secret is still accepted now.
PreviousActive bool
Orphaned time.Time
}
Meta is what may be said about a client's stored secret: when, never what.
type Outcome ¶
type Outcome string
Outcome is what Reconcile did for one client.
const ( // OutcomeCreated: a new secret was generated and stored. OutcomeCreated Outcome = "created" // OutcomeAdopted: the input `clients/<id>/secret` was copied into the // record, so a client that already uses it keeps working. OutcomeAdopted Outcome = "adopted" // OutcomeRestored: a record that had been reported orphaned is back in use // (its client is in the policy again); the record is kept as it is and // only the orphaned mark was cleared. OutcomeRestored Outcome = "restored" // OutcomeExisting: a record was already there; nothing was written. OutcomeExisting Outcome = "existing" // OutcomeConflict: another writer created the record between the read and // our create; ours was discarded and theirs is the record. OutcomeConflict Outcome = "conflict" // OutcomeUnsupported: the secrets adapter cannot create only-if-absent, // so no secret was made (fail closed). OutcomeUnsupported Outcome = "unsupported" // OutcomeFailed: any other error; the next pass tries again. OutcomeFailed Outcome = "failed" )
The outcomes. Part of the audit and metric vocabulary: keep the spellings.
type Overlapper ¶ added in v1.74.0
Overlapper is a Secrets store that fixes the overlap of a rotation itself: the layout-v4 store keeps no bookkeeping, so the previous secret is whatever the document's previous revision is, for the configured grace.
type Record ¶
type Record struct {
V int `json:"v"`
Current string `json:"current"`
// Previous is the secret Current replaced, accepted until
// PreviousValidUntil so a rotation does not cut a relying party off.
Previous string `json:"previous,omitempty"`
PreviousValidUntil time.Time `json:"previous_valid_until,omitzero"`
Created time.Time `json:"created"`
Rotated time.Time `json:"rotated,omitzero"`
// Orphaned is when reconcile first found the record with no generated
// client of that id in the policy. It is how the finding is reported once;
// a client that comes back clears it and keeps the record as it is.
Orphaned time.Time `json:"orphaned,omitzero"`
}
Record is what is stored for one client.
func DecodeRecord ¶
DecodeRecord reads a stored record. The error never holds a value.
type Resolver ¶
type Resolver struct {
// contains filtered or unexported fields
}
Resolver is the Lookup over the credential record and the input secret.
func NewResolver ¶
NewResolver returns a Resolver. store may be nil (no records: the input only), and so may input.
func (*Resolver) Forget ¶
Forget drops what is cached for a client, so the next Resolver.Resolve reads the record again. A rotation calls it after it writes.
func (*Resolver) Reread ¶ added in v1.74.0
Reread is what the token check asks before it refuses a secret: the client's secrets read again from the store, because this replica may have cached them before a rotation. A read younger than RereadFloor is reused.
func (*Resolver) Resolve ¶
Resolve implements Lookup.
A generated client is served by its record. The input `clients/<id>/secret` serves it only while the record is definitively absent (the store says not found), so a first deploy never refuses a valid client before the secret exists. A record that is corrupt, or a store that cannot be read with nothing recent to serve, authenticates nobody: failing closed. A client with a named input secret is served by the input alone.
func (*Resolver) UseGenerated ¶
UseGenerated tells the Resolver which clients are generated, from the policy in force when asked. Only a generated client's record is consulted; any other client has its input secret and nothing else, so a client returned to `secret: <name>` is served by the input at once.
type Result ¶
Result is what one pass did, by client.
func Reconcile ¶
func Reconcile( ctx context.Context, clients []string, store port.Secrets, input Input, now time.Time, log *slog.Logger, hooks Hooks, ) Result
Reconcile makes sure every client in clients (the ids of those declared `secret: {generate: true}`) has a record in store.
A record that exists is left alone, always. A missing one is made from the input `clients/<id>/secret` when the installation delivers one (adopt: the secret a client already uses stays valid), otherwise from a newly generated value, and written create-only. A lost race re-reads and continues; the write is never retried and never overwrites.
A client's failure does not stop the others and is not returned: it is logged, counted and given to hooks, and the next pass tries again. Nothing here logs a value.
type Rotation ¶
type Rotation struct {
Rotated time.Time
Overlap time.Duration
PreviousValidUntil time.Time
// DiscardedPrevious is set when a rotation was made while the one before
// still had an open overlap: that older secret stopped being accepted.
DiscardedPrevious bool
}
Rotation is what Manager.Rotate did.