clientcreds

package
v1.76.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 16 Imported by: 0

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

View Source
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.

View Source
const (
	SlotCurrent  = "current"
	SlotPrevious = "previous"
	SlotNone     = "none"
)

The slots an authentication can match.

View Source
const CacheTTL = 30 * time.Second

CacheTTL is how long a read of a record is reused.

View Source
const Kind = "oidc-client"

Kind is the credential kind in the Secrets path.

View Source
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.

View Source
const RecordVersion = 1

RecordVersion is the version of the record's JSON.

View Source
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.

View Source
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

View Source
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

func CountAdminRefused(ctx context.Context, reason string)

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

func CountAuth(ctx context.Context, slot string)

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 Generate

func Generate() (string, error)

Generate returns a new secret: 32 random bytes, base64url without padding.

func Path

func Path(clientID string) string

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

type Input interface {
	Get(ctx context.Context, name string) (string, error)
}

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

func (m *Manager) Purge(ctx context.Context, clientID string) error

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.

func (*Manager) Show

func (m *Manager) Show(ctx context.Context, clientID string) (Meta, error)

Show returns the metadata of a client's record. A client with no record is not an error: Exists is false.

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

type Overlapper interface{ Overlap() time.Duration }

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

func DecodeRecord(b []byte) (Record, error)

DecodeRecord reads a stored record. The error never holds a value.

func (Record) Encode

func (r Record) Encode() ([]byte, error)

Encode returns the record's stored form.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver is the Lookup over the credential record and the input secret.

func NewResolver

func NewResolver(store port.Secrets, input Input, log *slog.Logger) *Resolver

NewResolver returns a Resolver. store may be nil (no records: the input only), and so may input.

func (*Resolver) Forget

func (r *Resolver) Forget(clientID string)

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

func (r *Resolver) Reread(ctx context.Context, clientID string) (Secrets, bool)

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

func (r *Resolver) Resolve(ctx context.Context, clientID string) (Secrets, bool)

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

func (r *Resolver) UseGenerated(generated func(clientID string) bool)

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

type Result struct {
	Outcomes map[string]Outcome
}

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.

func (Result) Failed

func (r Result) Failed() int

Failed counts the clients whose secret could not be settled.

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.

type Secrets

type Secrets struct {
	Current string
	// Previous is the secret Current replaced; empty when there is none.
	// It is accepted only while now is before PreviousValidUntil.
	Previous           string
	PreviousValidUntil time.Time
}

Secrets is what a client may authenticate with.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL