links

package
v12.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 20, 2026 License: AGPL-3.0 Imports: 21 Imported by: 0

Documentation

Overview

Package links mints URLs that prove their bearer was sent them, once, until they expire.

Magic login, email verification, password reset, one-click unsubscribe: four flows, one primitive. Every application writes it, and the four ways of writing it wrong are always the same four — no expiry, a token that works twice, a token sitting in the database in the clear, and a token nobody can withdraw once it is loose.

minter, err := links.NewMinter(store, locker,
	links.WithAction("magic_login", links.ActionPolicy{
		URL: "https://app.example.com/auth/magic/{token}",
		TTL: 15 * time.Minute,
	}),
)

link, err := minter.Mint(ctx, "magic_login", links.Subject(user.ID))
// deliver link.URL; record link.ID

claims, err := minter.Redeem(ctx, token)
// claims.Subject is who to sign in; a second Redeem of that token fails

What is stored

Not the token. The store is keyed by the SHA-256 digest of the token and holds the action, the subject, the timestamps, and whatever metadata the minter attached — nothing that can be turned back into a URL. A dump of the cache is a list of links that were issued, not a set of live credentials, and that is the difference between a leaked backup and a queue of account takeovers.

The digest is also the ID, which is what makes it useful rather than merely safe. Mint returns it, Redeem returns it, Revoke takes it, and none of the three has to write the token down to talk about the same link.

A fast unsalted hash is the right one here and a password KDF is not — see WithHasher, which is where that goes wrong if anyone tries to improve it.

Single use, and what enforces it

Redeem reads the record and writes it back consumed under a lock on that link. Both halves are inside the lock, which is the whole of the guarantee: a check that passes and a write that lands separately is exactly the window in which two requests carrying one token both see it active.

That is why the locker is a required argument with no default. The noop locker acquires unconditionally, and with it every test still passes — single use holds for the sequential case and fails only under the concurrency an attacker supplies deliberately.

Consumption is committed before the claims are returned. If the store cannot be written, Redeem fails and hands back nothing, without a failure-policy knob. idempotency offers FailOpen because a duplicate charge can cost less than an outage; there is no version of that argument where the thing behind the link is an account.

A redeemed link is kept rather than deleted, for WithRetention. That is what lets a second attempt be told ErrLinkAlreadyRedeemed instead of ErrLinkNotFound — two dead ends for the bearer, but only one of them is a sentence a person can act on.

Do not consume on GET

Redeem is not what a GET handler calls, and the reason is not theoretical. Corporate mail security fetches every URL in every message before the recipient sees it. A handler that consumes on GET has its link spent by a scanner, and the user's own click — the first human one — arrives second and is refused.

GET  /reset/{token}   Inspect, then render the form
POST /reset           Redeem, then change the password

Inspect answers the same questions Redeem does without consuming anything, so the page can say "this link expired" before asking for a new password. Its answer is advisory: it takes no lock, so a link it approves can be spent by somebody else a moment later. Nothing may be granted on Inspect alone, and Redeem re-checks all of it.

Magic login has the same problem with no POST to hide behind, so it needs an interstitial — a page that renders from Inspect and carries a button that submits. A scanner does not press the button.

Choosing a lifetime

There is no default TTL, and adding one would be a mistake rather than a convenience. Fifteen minutes is right for a magic login and destroys an unsubscribe link; a year is right for an unsubscribe link and leaves logins live in mailboxes for a year. Every action states its own, next to its URL, because the two are one decision — see ActionPolicy.

An action must be registered before it can be minted, which makes the registry an allowlist as much as a configuration. A typo produces ErrUnknownAction rather than a working-looking link to a page that does not exist.

Delivering it

Email is the usual transport and this package does not know about it: hand link.URL to whatever the email package is sending, or to qrcodes when the link should be scannable rather than clicked. Nothing here composes with either at the type level, because there is nothing to compose — the deliverable is a string.

What matters at delivery is what happens either side of the URL. The token is in it, so it is in the browser's history, in the Referer header of anything the landing page loads from a third party, and in any access log that records query strings. The mitigations are the landing page's, not this package's: send Referrer-Policy: no-referrer, redirect to a clean URL immediately after redeeming, and keep the lifetime short enough that a leaked URL is usually already dead. Putting the token in the path rather than the query does not help with Referer, and helps with access logs only until somebody logs full paths.

Withdrawing one

Revoke takes an ID, not a token, because the server never had the token. The ID is what Mint returned and what the audit entry for that mint should have recorded, so a link can be withdrawn months later with nothing secret having been kept in between.

"Invalidate every outstanding reset link for this account" is therefore a query against the audit log — mints of that action for that subject, with no corresponding redemption — followed by a Revoke per result. This package holds no index to answer it directly, and building one would be a second, weaker copy of a log the application already keeps.

Recording it

Mint and redeem are exactly what audit exists for, and this package does not write those entries itself. The entry belongs in the same transaction as the effect — the session that was created, the password that was changed — and this package does not own that transaction. What it provides is the part that is awkward to get right otherwise: an ID that identifies the link in both entries and discloses nothing.

Log the ID. Never the token, never the URL. Nothing in this package writes either to a span, a log line, or a metric attribute, and the type carries no redacting String method to enforce it — one that silently produced a broken URL from an ordinary string concatenation would cost more than it saved.

Deliberately not a JWT

Nothing in the URL is readable, so nothing in it can be trusted by mistake. There is no algorithm to confuse, no key to rotate on a deadline, no claim a holder can assert, and no way to be handed one that is valid but stale. Claims are looked up rather than parsed.

The cost is real and worth naming: redemption requires the store, so links stop working when it does. For account recovery that is the correct trade — a credential that cannot be revoked is worse than one that occasionally cannot be used — and it is not the correct trade everywhere. A stateless token that carries its own claims is a different thing, and this package is not a slow version of it.

The store

The store is a cache.Cache[Record], and the redis provider is the production answer. The memory provider is per-process, so a link minted by one replica does not exist for the next; it also needs cache/memory's WithJanitor to reclaim anything, since an entry written once and never read again is never lazily evicted.

Record expiry is decided by this package against its own clock, not by the cache. The store's TTL is deliberately set past the link's, so a cache that evicts late — or not at all — cannot keep a credential alive past the moment it was supposed to die.

Every record carries a Version. A record written by a different shape reads as absent rather than being decoded with the wrong field meanings, so changing the shape of Record invalidates outstanding links. That is the safe direction, and it is a deploy concern: bump recordVersion when Record changes, and expect the links in flight at that moment to stop working.

Watching it

links_minted         by action.
links_redemptions    by action and outcome: redeemed, not_found,
                     already_redeemed, expired, revoked, invalid_token,
                     store_error.
links_revocations    by action.
links_store_errors   store health. Every one of these is a redemption that
                     did not happen — the alert.
links_stale_records  records ignored for carrying another version; expected
                     to spike once after a shape change and then return to
                     zero.
links_latency_ms     per operation.

already_redeemed is the row to watch and the one most often misread. A steady low rate is people clicking twice. A rate that tracks minting is a mail scanner consuming links on GET, which means a handler is redeeming where it should be inspecting. A spike against one subject is somebody else's mailbox.

No metric is labeled by subject. Nothing bounds that cardinality, and the question it would answer is the audit log's.

Example

Mint a link, deliver its URL, and redeem it once.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	cachememory "github.com/primandproper/platform-go/v12/cache/memory"
	"github.com/primandproper/platform-go/v12/distributedlock"

	dlmemory "github.com/primandproper/platform-go/v12/distributedlock/memory"
	"github.com/primandproper/platform-go/v12/links"
)

// newExampleMinter wires a Minter over in-process pieces. A real deployment
// uses the redis cache provider and a redis or postgres locker: both of these
// are per-process, so a link minted by one replica would not exist for the next.
func newExampleMinter() (*links.Minter, error) {
	store, err := cachememory.NewInMemoryCache[links.Record](0)
	if err != nil {
		return nil, err
	}

	raw, err := dlmemory.NewLocker()
	if err != nil {
		return nil, err
	}

	locker, err := distributedlock.NewScopedLocker(raw)
	if err != nil {
		return nil, err
	}

	return links.NewMinter(store, locker,
		links.WithAction("magic_login", links.ActionPolicy{
			URL: "https://app.example.com/auth/magic/{token}",
			TTL: 15 * time.Minute,
		}),
		links.WithAction("unsubscribe", links.ActionPolicy{
			URL: "https://app.example.com/unsubscribe?t={token}",
			TTL: 365 * 24 * time.Hour,
		}),
	)
}

func main() {
	ctx := context.Background()

	minter, err := newExampleMinter()
	if err != nil {
		panic(err)
	}

	link, err := minter.Mint(ctx, "magic_login", "user_123",
		links.WithMetadata(map[string]string{"next": "/dashboard"}))
	if err != nil {
		panic(err)
	}

	// Deliver link.URL by email. Record link.ID in the audit log — never the
	// URL and never the token, both of which are the credential itself.
	_ = link.URL

	claims, err := minter.Redeem(ctx, link.Token)
	if err != nil {
		panic(err)
	}

	fmt.Println("signing in", claims.Subject, "then sending them to", claims.Metadata["next"])

	// The single-use guarantee, reporting itself.
	if _, err = minter.Redeem(ctx, link.Token); errors.Is(err, links.ErrLinkAlreadyRedeemed) {
		fmt.Println("second redemption refused")
	}

}
Output:
signing in user_123 then sending them to /dashboard
second redemption refused
Example (Revoking)

Revoke withdraws a link that is still sitting in somebody's mailbox, using the ID recorded at mint time rather than the token nobody kept.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	cachememory "github.com/primandproper/platform-go/v12/cache/memory"
	"github.com/primandproper/platform-go/v12/distributedlock"

	dlmemory "github.com/primandproper/platform-go/v12/distributedlock/memory"
	"github.com/primandproper/platform-go/v12/links"
)

// newExampleMinter wires a Minter over in-process pieces. A real deployment
// uses the redis cache provider and a redis or postgres locker: both of these
// are per-process, so a link minted by one replica would not exist for the next.
func newExampleMinter() (*links.Minter, error) {
	store, err := cachememory.NewInMemoryCache[links.Record](0)
	if err != nil {
		return nil, err
	}

	raw, err := dlmemory.NewLocker()
	if err != nil {
		return nil, err
	}

	locker, err := distributedlock.NewScopedLocker(raw)
	if err != nil {
		return nil, err
	}

	return links.NewMinter(store, locker,
		links.WithAction("magic_login", links.ActionPolicy{
			URL: "https://app.example.com/auth/magic/{token}",
			TTL: 15 * time.Minute,
		}),
		links.WithAction("unsubscribe", links.ActionPolicy{
			URL: "https://app.example.com/unsubscribe?t={token}",
			TTL: 365 * 24 * time.Hour,
		}),
	)
}

func main() {
	ctx := context.Background()

	minter, err := newExampleMinter()
	if err != nil {
		panic(err)
	}

	link, err := minter.Mint(ctx, "magic_login", "user_123")
	if err != nil {
		panic(err)
	}

	// Months later, from the audit entry that recorded the mint.
	if err = minter.Revoke(ctx, link.ID); err != nil {
		panic(err)
	}

	_, err = minter.Redeem(ctx, link.Token)
	fmt.Println(errors.Is(err, links.ErrLinkRevoked))

}
Output:
true
Example (TwoStepRedemption)

A password reset spans two requests: the GET that renders the form must not consume the link, or a mail scanner's prefetch spends it before the user ever sees it.

package main

import (
	"context"
	"fmt"
	"time"

	cachememory "github.com/primandproper/platform-go/v12/cache/memory"
	"github.com/primandproper/platform-go/v12/distributedlock"

	dlmemory "github.com/primandproper/platform-go/v12/distributedlock/memory"
	"github.com/primandproper/platform-go/v12/links"
)

// newExampleMinter wires a Minter over in-process pieces. A real deployment
// uses the redis cache provider and a redis or postgres locker: both of these
// are per-process, so a link minted by one replica would not exist for the next.
func newExampleMinter() (*links.Minter, error) {
	store, err := cachememory.NewInMemoryCache[links.Record](0)
	if err != nil {
		return nil, err
	}

	raw, err := dlmemory.NewLocker()
	if err != nil {
		return nil, err
	}

	locker, err := distributedlock.NewScopedLocker(raw)
	if err != nil {
		return nil, err
	}

	return links.NewMinter(store, locker,
		links.WithAction("magic_login", links.ActionPolicy{
			URL: "https://app.example.com/auth/magic/{token}",
			TTL: 15 * time.Minute,
		}),
		links.WithAction("unsubscribe", links.ActionPolicy{
			URL: "https://app.example.com/unsubscribe?t={token}",
			TTL: 365 * 24 * time.Hour,
		}),
	)
}

func main() {
	ctx := context.Background()

	minter, err := newExampleMinter()
	if err != nil {
		panic(err)
	}

	link, err := minter.Mint(ctx, "magic_login", "user_123")
	if err != nil {
		panic(err)
	}

	// GET /reset/{token} — a mail scanner gets here first, and finds a link it
	// leaves intact.
	if _, err = minter.Inspect(ctx, link.Token); err != nil {
		panic(err)
	}

	fmt.Println("scanner fetched the URL")

	// GET /reset/{token} — the user, rendering the same form from the same
	// still-unspent link.
	claims, err := minter.Inspect(ctx, link.Token)
	if err != nil {
		panic(err)
	}

	fmt.Println("rendering the form for", claims.Subject)

	// POST /reset — the button press, which is where the link is spent.
	if _, err = minter.Redeem(ctx, link.Token); err != nil {
		panic(err)
	}

	fmt.Println("password changed")

}
Output:
scanner fetched the URL
rendering the form for user_123
password changed

Index

Examples

Constants

View Source
const (
	// DefaultTokenBytes is how many random bytes a token carries before
	// encoding. Thirty-two is 256 bits, which is not guessable and is short
	// enough to survive a mail client's line wrapping once base64url-encoded.
	DefaultTokenBytes = 32

	// DefaultRetention is how long a resolved link — redeemed, revoked, or
	// expired — stays in the store after it stops working.
	//
	// It buys one thing: the difference between "that link was already used"
	// and "no such link". Both are dead ends for the bearer, but only one of
	// them is a sentence a person can act on, and after retention has elapsed
	// the store cannot tell them apart any more.
	DefaultRetention = 24 * time.Hour

	// DefaultKeyPrefix namespaces the store and lock keys, so a link cannot
	// collide with an unrelated entry in a cache or locker shared with
	// something else.
	DefaultKeyPrefix = "links:"

	// DefaultMaxTokenLength bounds what Redeem will hash. A token this package
	// minted is 43 bytes at the default size; the limit is generous enough to
	// survive a larger WithTokenBytes and small enough that an endpoint reachable
	// without authentication cannot be made to hash a megabyte per request.
	DefaultMaxTokenLength = 512

	// TokenPlaceholder is what an action's URL template must contain, exactly
	// once, to say where the token goes.
	TokenPlaceholder = "{token}"
)

Variables

View Source
var (
	// ErrLinkNotFound indicates no link exists for the token. Either it was
	// never minted, or it resolved long enough ago that retention has dropped
	// the record.
	ErrLinkNotFound = platformerrors.New("action link not found")
	// ErrLinkAlreadyRedeemed indicates the link was already consumed. It is the
	// single-use guarantee reporting itself, and the answer a mail scanner's
	// prefetch produces when the user's own click arrives second — see the
	// package documentation on prefetching.
	ErrLinkAlreadyRedeemed = platformerrors.New("action link already redeemed")
	// ErrLinkExpired indicates the link's lifetime elapsed before it was used.
	ErrLinkExpired = platformerrors.New("action link expired")
	// ErrLinkRevoked indicates the link was withdrawn before it was used.
	ErrLinkRevoked = platformerrors.New("action link revoked")

	// ErrInvalidToken indicates a token that is empty or longer than this
	// package will hash. It is a malformed request rather than a redemption
	// outcome: no link was looked for.
	ErrInvalidToken = platformerrors.New("invalid action link token")
	// ErrInvalidID indicates an empty ID was passed to Revoke.
	ErrInvalidID = platformerrors.New("invalid action link ID")

	// ErrUnknownAction indicates Mint was asked for an action no policy was
	// registered for.
	//
	// Registration is what makes an action mintable, which makes the registry an
	// allowlist as well as a configuration: a typo produces this rather than a
	// working-looking link to a page that does not exist, and a metric labeled
	// by action cannot be given unbounded cardinality by a caller.
	ErrUnknownAction = platformerrors.New("unknown action link action")
	// ErrEmptySubject indicates Mint was called without a subject. A link bound
	// to nobody would redeem into a claim the caller cannot act on, so it is
	// rejected rather than minted.
	ErrEmptySubject = platformerrors.New("empty action link subject")
	// ErrNoActions indicates NewMinter was called with no action registered.
	// A Minter that can mint nothing is a wiring mistake, and one that reports
	// itself at construction is cheaper than one that reports itself at the
	// first password reset.
	ErrNoActions = platformerrors.New("no action link actions registered")

	// ErrInvalidActionURL indicates an action's URL template is unusable: empty,
	// unparseable, missing TokenPlaceholder, carrying more than one of them, or
	// naming a scheme that would put the token on the wire in the clear.
	ErrInvalidActionURL = platformerrors.New("invalid action link URL template")
	// ErrInsecureActionURL indicates an action's URL template is http against a
	// host that is not loopback. The token is a bearer credential and the URL is
	// the only place it exists, so cleartext delivery hands it to every hop.
	// WithInsecureURLs exists for the environments where that is knowingly
	// acceptable.
	ErrInsecureActionURL = platformerrors.Wrap(ErrInvalidActionURL, "action link URL is not https")
	// ErrInvalidTTL indicates a non-positive lifetime.
	//
	// There is no default. A magic-login link and an unsubscribe link differ by
	// four orders of magnitude in how long they should live, and any value this
	// package picked would be wrong for one of them in the dangerous direction.
	ErrInvalidTTL = platformerrors.New("invalid action link TTL")

	// ErrStoreUnavailable indicates the record store could not be read or
	// written. Redemption fails closed on it without exception: a link this
	// package cannot prove is unused, and cannot mark as used, must not be
	// honored — there is no availability argument on the other side of an
	// account.
	ErrStoreUnavailable = platformerrors.New("action link store unavailable")

	// ErrNilStore indicates NewMinter was called without a record store. It
	// wraps errors.ErrNilInputParameter, so a caller may check either.
	ErrNilStore = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil action link store")
	// ErrNilLocker indicates NewMinter was called without a locker. It has no
	// default: an implicit noop would let two concurrent redemptions of one
	// token both succeed, which is the single failure this package exists to
	// prevent. It wraps errors.ErrNilInputParameter, so a caller may check
	// either.
	ErrNilLocker = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil action link locker")
)

Sentinels. errors/http and errors/grpc map these onto status codes, so those packages import this one. That direction is load-bearing: nothing here may import errors/http or errors/grpc, or the cycle closes.

The four redemption failures are separate sentinels rather than one, which is the opposite of what sessions does with its own. The reason is entropy. An unusable session cookie is distinguishable from a forged one only by a check an attacker can run millions of times, so telling the two apart is an oracle; a link token is 256 random bits, and nobody is ever holding one they did not receive. Separating them costs nothing and buys the difference between "this link has already been used" and "this link has expired" — two sentences with two different next steps for the person reading them.

Functions

This section is empty.

Types

type Action

type Action string

Action names what a link does: "magic_login", "verify_email", "password_reset", "unsubscribe".

It is half of what a token is bound to, and the half that stops one flow's link from working in another's. A verification link that redeems as a login is an account takeover, and without the action on the record nothing in the redemption path can tell the two apart.

type ActionPolicy

type ActionPolicy struct {
	// URL is the address links for this action point at, containing
	// TokenPlaceholder exactly once:
	//
	//	https://app.example.com/auth/magic/{token}
	//	https://app.example.com/unsubscribe?t={token}
	//
	// Either position works. Tokens are base64url, whose alphabet is safe
	// unescaped in a path segment and in a query value alike, which is why
	// substitution here is a substitution and not an encoding decision.
	//
	// It must be https unless the host is loopback — see WithInsecureURLs.
	URL string `json:"url,omitempty" yaml:"url,omitempty"`

	// TTL is how long links for this action stay redeemable, and it is
	// required.
	//
	// Fifteen minutes suits a magic login, an hour a password reset, a day an
	// email verification, a year an unsubscribe link. There is no default
	// because no single one of those is wrong in a harmless direction: a
	// package-chosen fifteen minutes silently breaks unsubscribe links, and a
	// package-chosen year silently leaves login links live in mailboxes.
	TTL time.Duration `json:"ttl,omitempty" yaml:"ttl,omitempty"`
}

ActionPolicy is everything a Minter needs to know about one kind of link: where it points, and how long it lives.

The two travel together because they are one decision. "Fifteen minutes" is only defensible next to "this signs somebody in", and a deployment that moves the login URL without revisiting the lifetime has moved half a policy. Declaring both at construction also means neither has to be remembered at a call site, which is where one of them eventually is not.

type Claims

type Claims struct {
	// IssuedAt is when the link was minted.
	IssuedAt time.Time
	// ExpiresAt is when the link would have stopped being redeemable.
	ExpiresAt time.Time
	// Metadata is what the minter attached. It is a copy, so a caller may
	// keep or mutate it.
	Metadata map[string]string
	// Action is what the bearer is entitled to do — and the field that must
	// be checked before doing it, if one handler serves more than one
	// action.
	Action Action
	// Subject is who the link was for.
	Subject Subject
	// ID is the link's non-secret handle, for the audit entry recording
	// what was just granted.
	ID ID
}

Claims is what a successful redemption yields: everything the link was bound to at mint time, and nothing the bearer supplied.

The name is borrowed from JWT and the resemblance stops there. These claims were never in the URL, were never readable by the bearer, and were never signed — they were looked up. There is no algorithm field, no key to confuse, and no way for a holder to assert anything the minter did not.

type ID

type ID string

ID is the non-secret handle for a link: the hex digest of its token.

It exists so that minting and redeeming can be recorded, correlated, and acted on without the token being written down anywhere. Because it is derived rather than stored, mint and redeem agree on it with no extra state, and a preimage of it is exactly as hard to find as the token itself — which is what makes it safe to put in an audit entry.

It is also the handle Revoke takes, which is what lets an application kill an outstanding link months later from its own audit log, holding nothing secret in the meantime.

type Link struct {
	// ExpiresAt is when the link stops being redeemable.
	ExpiresAt time.Time
	// URL is the address to deliver, with the token already in place.
	URL string
	// Token is the bare secret, for a caller that delivers it some other
	// way than as this URL — a deep link, or a QR code carrying only the
	// token.
	Token Token
	// ID is the non-secret handle, for the audit entry recording the mint.
	ID ID
	// Action is what the link does.
	Action Action
	// Subject is who it is for.
	Subject Subject
}

Link is a freshly minted link: the URL to deliver, and the handles for talking about it afterwards.

URL and Token are the same secret in two forms. Deliver one of them and record ID; putting URL or Token into a log, an analytics event, or an error message hands out the credential.

type MintOption

type MintOption func(*mintOptions)

MintOption overrides an action's policy for one link.

The policy is the default for every link of that action; these exist for the link that genuinely differs — an invitation that must expire when the billing period does — rather than as a second place to configure the action.

func WithMetadata

func WithMetadata(metadata map[string]string) MintOption

WithMetadata attaches values to the link, returned verbatim by redemption.

This is the safe place for what a JWT would have put in the URL: the page to land on, the invited role, the campaign a message belongs to. It is stored server-side and never travels, so the bearer can neither read it nor change it, and a handler may act on it without verifying anything.

The map is copied, so a caller may reuse or mutate its own afterwards. It is not a place for secrets: it lands in the store in the clear, which is the one thing the token deliberately does not.

func WithTTL

func WithTTL(ttl time.Duration) MintOption

WithTTL overrides how long this one link stays redeemable. A non-positive value inherits the action's policy.

type Minter

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

Minter mints, inspects, redeems, and revokes action links.

It is a concrete type rather than an interface: there is one implementation, and the seams worth swapping — the store, the locker, the hasher, the randomness — are already interfaces with their own mocks.

func NewMinter

func NewMinter(
	store cache.Cache[Record],
	locker distributedlock.ScopedLocker,
	opts ...Option,
) (*Minter, error)

NewMinter builds a Minter over a record store and a locker.

The locker is required and has no default. Single use is enforced by reading a record and then writing it back consumed, and without mutual exclusion two requests carrying the same token both read "active" and both proceed — which is the entire failure this package exists to prevent, arriving silently and only under concurrency.

At least one action must be registered; see WithAction.

func (*Minter) Actions

func (m *Minter) Actions() []Action

Actions returns the actions this Minter can mint, for a caller that wants to assert its wiring or render a list of the flows a deployment supports.

func (*Minter) Inspect

func (m *Minter) Inspect(ctx context.Context, token Token) (*Claims, error)

Inspect reports what a token would redeem as, without consuming it.

It is what a GET handler calls. A password-reset link lands on a form, and consuming the token to render that form burns it before the user has typed anything — which is also what happens when a mail scanner fetches the URL on its way to the inbox. Render from Inspect, consume from Redeem on the POST.

Its answer is advisory and nothing may be granted on it alone. It takes no lock and makes no write, so between it and the Redeem that follows the link can be redeemed by someone else, revoked, or expire. Redeem re-checks everything Inspect checked; that check is the one that counts.

func (*Minter) Mint

func (m *Minter) Mint(
	ctx context.Context,
	action Action,
	subject Subject,
	opts ...MintOption,
) (*Link, error)

Mint issues a single-use link for an action and a subject.

The returned Link carries the URL to deliver and the ID to record. The token exists in exactly two places afterwards: in that URL, and in whatever the caller delivers it with. It is not in the store — the store holds its digest — and it is not recoverable, so a link that fails to deliver is reminted rather than looked up.

func (*Minter) Redeem

func (m *Minter) Redeem(ctx context.Context, token Token) (*Claims, error)

Redeem consumes a token and returns what it was bound to.

A second call with the same token reports ErrLinkAlreadyRedeemed, and so does a concurrent one: the read and the consuming write happen under a lock on the link, so two simultaneous redemptions of one token cannot both see it active.

It fails closed on the store, without a policy knob. Idempotency offers one because a duplicate charge can be cheaper than an outage; nothing comparable is true here. A link this package cannot mark as consumed must not be honored, because "honored anyway" means an account was handed to whoever asked, twice.

func (*Minter) Revoke

func (m *Minter) Revoke(ctx context.Context, id ID) error

Revoke withdraws a link by its ID, so that a token still sitting in somebody's mailbox stops working.

It takes an ID rather than a token because the server never has the token: it stored a digest, which is the point. The ID is what Mint returned and what the audit entry for that mint recorded, so revoking a link months later needs nothing secret to have been kept in the meantime — and "revoke every outstanding reset link for this account" is a query against that log followed by a call to this per result.

Revoking an already-revoked link succeeds: the outcome asked for is the outcome already in place. Revoking a redeemed one reports ErrLinkAlreadyRedeemed, because there the caller asked to prevent something that has already happened, and an operator revoking after a suspected compromise needs to be told they were too late.

type Option

type Option func(*minterOptions)

Option configures a Minter at construction.

func WithAction

func WithAction(action Action, policy ActionPolicy) Option

WithAction registers an action and the policy for it, and is the only way an action becomes mintable.

Registering the same action twice replaces the earlier policy, so a caller assembling a Minter from configuration and then overriding one action can do so by appending an option rather than by editing the map it built.

func WithActions

func WithActions(actions map[Action]ActionPolicy) Option

WithActions registers several actions at once, on the same terms as WithAction. It exists for the configuration path, which holds a map already.

func WithClock

func WithClock(c clock.Clock) Option

WithClock swaps the clock used to stamp and expire links.

func WithGenerator

func WithGenerator(generator random.Generator) Option

WithGenerator overrides the source of token randomness. The default is random.NewGenerator, which reads crypto/rand.

Tokens are base64url-encoded by the generator, and the URL templates rely on that alphabet being safe unescaped. A generator producing anything else would need its output escaped at every position a template could put it.

func WithHasher

func WithHasher(hasher hashing.Hasher) Option

WithHasher overrides the digest a token is stored and looked up by.

It must be a cryptographic hash. hashing.Hasher also has adler32, crc64, and fnv implementations behind it, and any of them turns the store from something that reveals nothing into something an attacker inverts on a laptop.

It must not be a password KDF either, and for two independent reasons. Lookup is by digest, so the function has to be deterministic and unsalted — argon2 is neither. And the property a KDF buys, survivability of a low-entropy secret, is not a property a 256-bit random token needs: there is no dictionary to run against it. A fast cryptographic hash is both the correct answer and the only workable one.

The default is SHA-256.

func WithInsecureURLs

func WithInsecureURLs() Option

WithInsecureURLs permits http action URLs against hosts that are not loopback.

It is spelled this way so it is legible in a diff. The token is a bearer credential carried entirely in the URL, so cleartext delivery hands it to every proxy, resolver, and access log between the mail client and the app. Loopback http already works without this — see ActionPolicy.URL — so this is for environments that are neither production nor local, and should not outlive them.

func WithKeyPrefix

func WithKeyPrefix(prefix string) Option

WithKeyPrefix overrides the namespace applied to store and lock keys.

An empty prefix is honored rather than ignored, so a caller can deliberately opt out of namespacing; that is why this is the one setting held as a pointer.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger. An absent logger logs nowhere.

func WithMaxTokenLength

func WithMaxTokenLength(maxLength int) Option

WithMaxTokenLength overrides the longest token Redeem will hash.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider. An absent one records nothing.

func WithRetention

func WithRetention(retention time.Duration) Option

WithRetention sets how long a resolved link stays in the store after it stops working, which is how long redemption can still say why it failed.

A resolved record holds the subject and the metadata, so this is also how long that outlives the link. A deployment with a short data-retention posture should shorten it, at the cost of a used link answering "not found".

func WithTokenBytes

func WithTokenBytes(tokenBytes int) Option

WithTokenBytes sets how many random bytes a token carries before encoding.

Values below DefaultTokenBytes are accepted but hard to justify: the token is the entire credential, there is no second factor behind it, and the redemption endpoint is reachable by anyone. Shortening it to fit a layout is trading the security of the flow for the length of a line.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider. An absent one traces nowhere.

type Record

type Record struct {
	// CreatedAt is when the link was minted.
	CreatedAt time.Time
	// ExpiresAt is when the link stops being redeemable.
	//
	// The store's own expiry is set past it deliberately, so this field
	// rather than the cache is what decides. A cache that fails to evict on
	// time must not be able to resurrect a credential.
	ExpiresAt time.Time
	// ResolvedAt is when the link was redeemed or revoked, and is zero while
	// the link is active.
	ResolvedAt time.Time
	// Metadata is what the minter attached, returned verbatim on
	// redemption.
	Metadata map[string]string
	// Action is what this link does.
	Action Action
	// Subject is who it is for.
	Subject Subject
	// Version is the record shape this was written with.
	Version int
	// State is what has happened to the link.
	State State
}

Record is what the store holds for a link, keyed by the digest of its token. It holds no secret: everything in it is already known to whoever minted the link, and none of it can be turned back into a token.

It is exported because the store is a cache.Cache[Record] the caller builds. Its fields are read by this package alone; Claims is what a redemption hands back.

type State

type State uint8

State is what has happened to a link.

const (
	// StateActive marks a link that has not been used and has not been revoked.
	// Whether it is still within its lifetime is a separate question — see
	// Record.ExpiresAt.
	StateActive State = iota + 1
	// StateRedeemed marks a link that has been consumed. The record is kept
	// past redemption for DefaultRetention so a second attempt can be told what
	// happened rather than told nothing.
	StateRedeemed
	// StateRevoked marks a link withdrawn before it was used.
	StateRevoked
)

type Subject

type Subject string

Subject names who or what the link is for — conventionally a user ID. It is returned by redemption and is what the caller acts on.

type Token

type Token string

Token is the secret in the URL. It is the whole credential: whoever holds it can redeem the link.

It is never persisted and never logged. The server stores a digest of it (see ID), so a dump of the store yields nothing that can be redeemed, and nothing in this package writes a Token to a span, a log line, or a metric attribute. A caller that renders one into anything durable undoes both.

Directories

Path Synopsis
Package linkscfg assembles a links.Minter from environment configuration.
Package linkscfg assembles a links.Minter from environment configuration.

Jump to

Keyboard shortcuts

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