Documentation
¶
Overview ¶
Package link creates and verifies signed application links that can expire, be used once, or be revoked when state changes.
A signed link carries a "signature" query parameter that proves the server issued it and nobody altered it. Use signed links for email verification, unsubscribe, file downloads, and password reset, where the recipient holds the only proof of authorization. Build one Signer with New at startup and share it: it is immutable and safe for concurrent use. Load a key.Shared with key.LoadShared or key.ParseShared before construction.
signer, err := link.New(
secret,
func(signer *link.Builder) {
signer.Origin("https://app.example.com")
},
)
if err != nil {
return err
}
signed, err := signer.Sign("/unsubscribe?user=42")
if err != nil {
return err
}
if err := signer.Verify(signed); err != nil {
return err
}
Absolute and relative signers ¶
A signer with Builder.Origin set is absolute. It signs the scheme and host as well as the path, so a link for one origin never verifies for another. It accepts a path such as "/files/a.pdf", which it completes with the configured origin, or a full URL whose scheme and host match the origin (default ports are ignored). A different origin returns ErrInvalidURL. The origin must be an HTTP or HTTPS origin without a path, query, fragment, or user info, or New returns ErrInvalidOrigin.
A signer with an empty origin is relative. It signs the path and query only and accepts only values that start with "/". A URL with a scheme or host returns ErrInvalidURL. Use it when the same application is reachable under several hostnames.
Permanent and temporary links ¶
Signer.Sign creates a permanent link, which stays valid until the secret is retired. Signer.Temporary creates a link that expires after a ttl:
signed, err := signer.Temporary("/reset?user=42", 15*time.Minute)
The expiry is stored as a Unix timestamp in the "expires" query parameter and is covered by the signature. A ttl that is not positive returns ErrInvalidExpiry. Signer.SignURL and Signer.TemporaryURL do the same for a parsed url.URL without mutating it. A "signature" or "expires" parameter already present in the input is replaced, never trusted.
What is signed ¶
The signature covers the canonical form of the link: for absolute signers the lowercased scheme and host with default ports dropped, then the escaped path, then the query sorted by name, including "expires". It excludes the "signature" parameter and any Builder.IgnoreQuery names.
The signature does not cover the HTTP method, and the URL must not contain a fragment: a link with a fragment returns ErrInvalidURL, because browsers never send fragments to the server.
Protecting routes ¶
Middleware returns middleware that verifies each request and calls the next handler only for valid links. Register a signed route for a single method so a link issued for GET cannot be replayed as POST; the router answers other methods with 405 before the middleware runs.
r := router.New()
r.With(link.Middleware(signer)).Get("/unsubscribe", unsubscribe)
An absolute signer verifies against its configured origin, not the request Host header, which clients control. Verification uses the request path as the router sees it, so wrapping a handler in http.StripPrefix breaks verification: sign and verify the same path.
Single-use links ¶
A temporary link can be spent once. Give the signer a cache with Builder.Cache and call Signer.Consume in the handler that applies the change; Middleware only checks. A password reset shows its form on GET, which any valid link may do, and burns the link on POST:
signer, err := link.New(secret, func(signer *link.Builder) {
signer.Cache(cache.New(memorycache.New()))
})
if err != nil {
return err
}
r.With(link.Middleware(signer)).Get("/reset", showResetForm)
r.Post("/reset", func(w http.ResponseWriter, r *http.Request) error {
err := signer.ConsumeRequest(r)
if errors.Is(err, link.ErrConsumed) {
return link.ErrLinkUsed.Derive(func(details *problem.Builder) {
details.Error(err)
})
}
if err != nil {
return link.ErrLinkRejected.Derive(func(details *problem.Builder) {
details.Error(err)
})
}
return resetPassword(r)
})
The POST request carries the signed URL in its query, so the handler needs no URL rebuilding. Signer.Consume and Signer.ConsumeURL take the link directly.
Consume verifies the link like Signer.Verify, then records the hash of its signature in the cache with an atomic add under the key prefix "link:v1:consumed:". Concurrent calls, including from several servers sharing a cache driver, have exactly one winner; the others get ErrConsumed. The record lives until the link expires plus one minute for clock skew between servers, after which expiry alone rejects the link. Only temporary links can be consumed: a permanent link returns ErrNotTemporary, since its record would have to last forever. A signer without a cache returns ErrNoCache.
Consume first, then commit the side effect. That gives at-most-once behavior: if the side effect fails after Consume succeeded, the link is spent and the user must request a new one. A cache that evicts entries early lets a spent link work again, so use a driver that keeps them for their TTL. A cache error is uncertain: its Add may have committed before confirmation was lost. Do not proceed with the action after any error or assume the link remains available. Confirm state or issue a new link through your recovery flow.
With a cache, Middleware also rejects temporary links that were already consumed with ErrLinkUsed ("Link Already Used", 403), so a form page does not render for a dead link. It only reads the cache and publishes no Rejected event: like an expired link, a used link is authentic.
Bound links ¶
Bind adds server-side state to the signature, which revokes links without a list of revoked links. Bind a reset link to the user's password hash and it stops working the moment the password changes:
signed, err := signer.Temporary("/reset?user=42", time.Hour, link.Bind(user.PasswordHash))
if err != nil {
return err
}
if err := signer.Verify(signed, link.Bind(user.PasswordHash)); err != nil {
return err
}
// After the password changes:
err = signer.Verify(signed, link.Bind(user.PasswordHash)) // ErrInvalidSignature
Pass the same Bind to every Sign, Temporary, Verify, Consume, and Inspect call for the link. For Middleware, pass MiddlewareBuilder.BindRequest to derive the binding from the request:
link.Middleware(signer, func(policy *link.MiddlewareBuilder) {
policy.BindRequest(func(r *http.Request) ([]byte, error) {
user, err := users.Find(r.Context(), r.URL.Query().Get("user"))
if err != nil {
return nil, err
}
return user.PasswordHash, nil
})
})
The function runs before the signature is checked, so treat everything it reads from the request as untrusted. An error from it rejects the link with ErrLinkRejected. The returned problem retains the cause; the Rejected payload and HTTP error detail do not contain it. Redact causes before logging them: application/provider errors can contain credentials or full URLs.
A binding enters the signed text only as a SHA-256 digest on its own final line, so the link discloses nothing about it, and links signed without Bind keep verifying as before. A wrong or missing binding, or a binding on a link signed without one, returns ErrInvalidSignature.
Inspecting links ¶
Signer.Inspect authenticates a link and describes it without acting on it. Unlike Verify, an expired link is not an error:
details, err := signer.Inspect(ctx, rawURL)
switch {
case err != nil:
// Forged or malformed: no details are returned.
case details.Expired:
// Offer a new link for details.URL.
case details.Previous:
// Signed with a previous secret: re-issue it before rotation ends.
}
The Inspection carries the URL without its "signature" and "expires" parameters, the expiry, whether the link expired, whether a previous secret signed it, and, when the signer has a cache, whether it was consumed. Consumed is false without a cache and for permanent links, so false means "not known to be spent". Inspect never changes state.
Secrets and rotation ¶
Every secret is a validated key.Shared of exactly MinSecretLength bytes. New rejects zero values with ErrInvalidSecret. The package derives its MAC key with HKDF-SHA256; still provision separate material for unrelated purposes.
To rotate, move the old secret to Builder.Previous and pass a new secret to New. New links use the new secret, and links signed with the old one still verify. Drop the old secret after the longest lifetime of the links it signed; for permanent links, dropping it invalidates them.
link.New(
newSecret,
func(signer *link.Builder) {
signer.Previous(oldSecret)
},
)
Ignored query parameters ¶
Mail clients and analytics add parameters such as utm_source to links. List them in Builder.IgnoreQuery so they may be added, changed, or removed without invalidating the link:
link.New(secret, func(signer *link.Builder) {
signer.IgnoreQuery("utm_source", "utm_medium")
})
The setting belongs to the signer, so signing and verifying always agree. Ignored parameters are not protected: never read authorization-relevant data from them. The names "signature" and "expires" are reserved, and an empty or reserved name returns ErrInvalidIgnoreQuery.
Errors ¶
Signer.Verify returns ErrInvalidURL for parsing and signer-mode failures, and ErrInvalidSignature for malformed authentication data, unauthenticated, or tampered links. Authentication errors carry no further detail, so callers cannot learn which signature or binding component failed.
A link that authenticates but is past its expiry returns an *ExpiredError, which matches ErrExpired with errors.Is. Use errors.As to read ExpiredError.ExpiresAt:
var expired *link.ExpiredError
if errors.As(err, &expired) {
// Offer to send a new link; expired.ExpiresAt says when it lapsed.
}
An expiry is reported only after the signature verifies, so an attacker cannot probe expiry times of forged links.
Middleware turns these into safe responses: ErrLinkExpired (403, "Link Expired") for expired links, ErrLinkUsed (403, "Link Already Used") for consumed links, and ErrLinkRejected (403, "Invalid Link") for everything else. The underlying error is attached for logging but is not sent to the client.
Signing and verifying functions return ErrInvalidURL for a URL the signer's mode does not accept. New returns ErrInvalidSecret, ErrInvalidOrigin, or ErrInvalidIgnoreQuery for invalid configuration.
Events ¶
With Builder.Events, Middleware publishes Rejected ("link.rejected") with the method for every invalid link; expired and consumed links publish nothing. A Signer.Consume, Signer.ConsumeURL, or Signer.ConsumeRequest call that spends a one-time link publishes Consumed ("link.consumed") with the method when a request is known; direct URL calls leave it empty. Paths, queries, bindings, and raw causes are omitted because each can contain secrets. Failed and repeated consumption publish nothing. Calls to Signer.Verify and Signer.Inspect publish nothing. Event contexts retain caller/request context values; these payload limits do not sanitize context values or the HTTP problem's normal instance member.
Index ¶
- Constants
- Variables
- func Middleware(signer *Signer, configure ...MiddlewareBuilderFunc) router.Middleware
- type Builder
- type BuilderFunc
- type CallOption
- type Config
- type ConsumedEvent
- type ExpiredError
- type Inspection
- type MiddlewareBuilder
- type MiddlewareBuilderFunc
- type RejectedEvent
- type Signer
- func (signer *Signer) Consume(ctx context.Context, rawURL string, opts ...CallOption) error
- func (signer *Signer) ConsumeRequest(request *http.Request, opts ...CallOption) error
- func (signer *Signer) ConsumeURL(ctx context.Context, value *url.URL, opts ...CallOption) error
- func (signer *Signer) Inspect(ctx context.Context, rawURL string, opts ...CallOption) (Inspection, error)
- func (signer *Signer) InspectURL(ctx context.Context, value *url.URL, opts ...CallOption) (Inspection, error)
- func (signer *Signer) Sign(rawURL string, opts ...CallOption) (string, error)
- func (signer *Signer) SignURL(value *url.URL, opts ...CallOption) (*url.URL, error)
- func (signer *Signer) Temporary(rawURL string, ttl time.Duration, opts ...CallOption) (string, error)
- func (signer *Signer) TemporaryURL(value *url.URL, ttl time.Duration, opts ...CallOption) (*url.URL, error)
- func (signer *Signer) Verify(rawURL string, opts ...CallOption) error
- func (signer *Signer) VerifyURL(value *url.URL, opts ...CallOption) error
Examples ¶
Constants ¶
const ( // MinSecretLength is the required byte length of every signing key. Nonzero // key.Shared values enforce it before signer construction; larger keys are // not accepted either. It is a size requirement, not a source of randomness. // // Example: // logger.InfoContext(ctx, "link key requirements", "bytes", link.MinSecretLength) MinSecretLength = 32 )
Variables ¶
var Consumed = event.NewTopic[ConsumedEvent]("link.consumed")
Consumed is published when Signer.Consume, Signer.ConsumeURL, or Signer.ConsumeRequest spends a one-time link, after the use is recorded. Calls that fail, including ErrConsumed for a link spent earlier, publish nothing, and so does Middleware, which never consumes a link.
Example:
unsubscribe, err := events.Listen(link.Consumed, onConsumed)
if err != nil {
return err
}
defer unsubscribe()
var ErrConsumed = errors.New("link was already used")
ErrConsumed reports an authentic temporary link that was already consumed. Return ErrLinkUsed to the client. Expired or unauthenticated links fail before the consumed-marker lookup.
Example:
if err := signer.Consume(ctx, rawURL); errors.Is(err, link.ErrConsumed) {
return link.ErrLinkUsed
}
var ErrExpired = errors.New("link has expired")
ErrExpired reports an authenticated temporary link whose expiry has passed. Verification returns an *ExpiredError that matches it with errors.Is. An unauthenticated link never reveals whether its apparent expiry passed.
Example:
if err := signer.Verify(rawURL); errors.Is(err, link.ErrExpired) {
return link.ErrLinkExpired
}
var ErrInvalidExpiry = errors.New("link expiry is invalid")
ErrInvalidExpiry reports a temporary link lifetime that is not positive. Temporary links require an explicit positive TTL; zero does not pick a default.
Example:
_, err := signer.Temporary("/reset", 0)
if errors.Is(err, link.ErrInvalidExpiry) {
return err
}
var ErrInvalidIgnoreQuery = errors.New("link ignored query parameter is invalid")
ErrInvalidIgnoreQuery reports an empty or reserved ignored query parameter. The signature and expiry names cannot be removed from authentication policy.
Example:
_, err := link.New(secret, func(signer *link.Builder) {
signer.IgnoreQuery("signature")
})
if errors.Is(err, link.ErrInvalidIgnoreQuery) {
return err
}
var ErrInvalidOrigin = errors.New("link signing origin is invalid")
ErrInvalidOrigin reports an origin unsuitable for absolute link signing. Only HTTP(S) scheme/host origins without application paths are accepted.
Example:
_, err := link.New(secret, func(signer *link.Builder) {
signer.Origin("https://app.example.com/account")
})
if errors.Is(err, link.ErrInvalidOrigin) {
return err
}
var ErrInvalidSecret = errors.New("link signing secret is invalid")
ErrInvalidSecret reports an invalid zero current or previous key.Shared. The key package enforces MinSecretLength before publishing nonzero values.
Example:
_, err := link.New(key.Shared{})
if errors.Is(err, link.ErrInvalidSecret) {
fmt.Println("configure a link key")
}
var ErrInvalidSignature = errors.New("link signature is invalid")
ErrInvalidSignature reports a malformed or unauthenticated signed link. It deliberately does not identify which signature or binding component failed.
Example:
if err := signer.Verify(rawURL); errors.Is(err, link.ErrInvalidSignature) {
return link.ErrLinkRejected
}
var ErrInvalidURL = errors.New("link URL is invalid")
ErrInvalidURL reports a URL unsuitable for the signer's configured mode. It also classifies parse failures without attaching the potentially secret URL.
Example:
_, err := signer.Sign("mailto:person@example.com")
if errors.Is(err, link.ErrInvalidURL) {
return err
}
var ErrLinkExpired = problem.New(func(details *problem.Builder) { details.Type("tag:arkoren.dev,2026-09:core:http/link:link-expired") details.Title("Link Expired") details.Detail("The link has expired. Request a new one.") details.Status(http.StatusForbidden) })
ErrLinkExpired is the safe response returned for authentic but expired links.
Example:
failure := link.ErrLinkExpired.Derive(func(details *problem.Builder) {
details.Detail("The request cannot be completed.")
})
_ = failure.Status()
var ErrLinkRejected = problem.New(func(details *problem.Builder) { details.Type("tag:arkoren.dev,2026-09:core:http/link:link-rejected") details.Title("Invalid Link") details.Detail("The link is invalid, has been altered, or has expired.") details.Status(http.StatusForbidden) })
ErrLinkRejected is the safe response returned for rejected signed requests.
Example:
failure := link.ErrLinkRejected.Derive(func(details *problem.Builder) {
details.Detail("The request cannot be completed.")
})
_ = failure.Status()
var ErrLinkUsed = problem.New(func(details *problem.Builder) { details.Type("tag:arkoren.dev,2026-09:core:http/link:link-used") details.Title("Link Already Used") details.Detail("The link has already been used. Request a new one.") details.Status(http.StatusForbidden) })
ErrLinkUsed is the safe response returned for authentic links that were already consumed. Return it from a handler when Signer.Consume reports ErrConsumed; Middleware returns it for consumed links.
Example:
failure := link.ErrLinkUsed.Derive(func(details *problem.Builder) {
details.Detail("The request cannot be completed.")
})
_ = failure.Status()
var ErrNoCache = errors.New("link signer has no cache")
ErrNoCache reports that Signer.Consume was called on a signer created without Builder.Cache. A malformed raw URL is rejected before this configuration check.
Example:
if err := signer.Consume(ctx, rawURL); errors.Is(err, link.ErrNoCache) {
return err // configure a shared cache before accepting one-time actions
}
var ErrNotTemporary = errors.New("link is not temporary")
ErrNotTemporary reports that Signer.Consume was called on a permanent link. A permanent link never expires, so its consumed record would have to be kept forever. Use Temporary rather than Sign when the action must have a single use.
Example:
if err := signer.Consume(ctx, rawURL); errors.Is(err, link.ErrNotTemporary) {
return err
}
var Rejected = event.NewTopic[RejectedEvent]("link.rejected")
Rejected is published when Middleware rejects a link whose signature or form is invalid. Expired links publish nothing: they are an ordinary outcome of a valid link. Event contexts retain request context values; listeners must select safe attributes rather than dumping the context or a returned error.
Example:
unsubscribe, err := events.Listen(link.Rejected, onRejected)
if err != nil {
return err
}
defer unsubscribe()
Functions ¶
func Middleware ¶
func Middleware(signer *Signer, configure ...MiddlewareBuilderFunc) router.Middleware
Middleware returns middleware that permits only requests signed by signer.
The signature covers the URL, not the HTTP method, so a link is accepted for any method its route accepts. Register a signed route for a single method (for example with router.Router.Get), never with router.Router.Any or router.Router.Methods, so a link signed for GET cannot be replayed as POST: the router answers other methods with 405 before this middleware runs. Verification uses the request's URL path as the router sees it; behind http.StripPrefix the path no longer matches the signed URL.
Pass MiddlewareBuilder.BindRequest to verify links signed with Bind. When the signer has a cache (Builder.Cache), a temporary link that Signer.Consume already spent is answered with ErrLinkUsed, so a form page does not render for a dead link. The check only reads the cache; the middleware never consumes a link. A cache failure is returned as an ordinary error.
With Builder.Events on the signer, a rejected link that has not merely expired or been used publishes Rejected: expired and used links are authentic, so they are ordinary outcomes and publish nothing.
Example:
r.With(link.Middleware(signer)).Get("/reset", showResetForm)
Example ¶
ExampleMiddleware shows applying signature verification before a router handler so a valid signed request can proceed.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"arkoren.dev/core/http/link"
"arkoren.dev/core/http/router"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
routes := router.New()
routes.With(link.Middleware(signer)).Get("/unsubscribe", func(writer http.ResponseWriter, _ *http.Request) error {
_, err := fmt.Fprint(writer, "unsubscribed")
return err
})
signed, err := signer.Sign("/unsubscribe?user=42")
if err != nil {
fmt.Println(err)
return
}
for _, target := range []string{signed, "/unsubscribe?user=43"} {
recorder := httptest.NewRecorder()
routes.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
fmt.Println(recorder.Code)
}
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: 200 403
Types ¶
type Builder ¶
type Builder struct {
// contains filtered or unexported fields
}
Builder configures New before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic. Retaining the draft cannot change published settings. Methods return the same draft.
Example:
signer, err := link.New(secret, func(signer *link.Builder) {
signer.Origin("https://app.example.com")
})
if err != nil {
return err
}
_ = signer
func (*Builder) Cache ¶
Cache records consumed single-use links and lets Middleware reject them. It must be shared by every server that verifies the same links. The default, nil, disables Signer.Consume, which then returns ErrNoCache.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
signer, err := link.New(secret, func(signer *link.Builder) {
signer.Cache(cache.New(memorycache.New()))
})
if err != nil {
return err
}
_ = signer
func (*Builder) Events ¶
Events receives Rejected from Middleware and Consumed from the Consume methods. The default, nil, publishes nothing.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
signer, err := link.New(secret, func(signer *link.Builder) {
signer.Events(event.New())
})
if err != nil {
return err
}
_ = signer
func (*Builder) IgnoreQuery ¶
IgnoreQuery names query parameters excluded from the signature. They may be added, changed, or removed without invalidating a link. The names "signature" and "expires" are reserved and cannot be listed. The default, none, signs every query parameter.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
signer, err := link.New(secret, func(signer *link.Builder) {
signer.IgnoreQuery("utm_source")
})
if err != nil {
return err
}
_ = signer
func (*Builder) Origin ¶
Origin is the public HTTP or HTTPS origin of absolute links. The default, empty, creates a relative signer that signs path-and-query values only.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
signer, err := link.New(secret, func(signer *link.Builder) {
signer.Origin("https://app.example.com")
})
if err != nil {
return err
}
_ = signer
func (*Builder) Previous ¶
Previous sets the shared keys that still verify links during rotation. Each must contain exactly MinSecretLength bytes. The default, none, accepts only links signed with the current secret.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
signer, err := link.New(secret, func(signer *link.Builder) {
signer.Previous(previousSecret)
})
if err != nil {
return err
}
_ = signer
type BuilderFunc ¶
type BuilderFunc = func(*Builder)
BuilderFunc edits one signer draft synchronously. Callbacks run directly once in order, including invalid required keys; nil entries panic. Expanded nil or empty slices omit edits. The completed signer detaches owned settings.
Example:
var policy link.BuilderFunc = func(signer *link.Builder) {
signer.Origin("https://app.example.com")
}
signer, err := link.New(secret, policy)
if err != nil {
return err
}
_ = signer
type CallOption ¶
type CallOption func(*callConfig)
CallOption changes one call to a Signer method, rather than the published signer. Options apply in order; nil entries panic. Use the same Bind policy when signing and verifying a bound link.
Example:
options := []link.CallOption{link.Bind(user.PasswordHash)}
if err := signer.Verify(rawURL, options...); err != nil {
return err
}
func Bind ¶
func Bind(value []byte) CallOption
Bind covers value with the signature, so the link verifies only when the verifier supplies the same value again. Use it for server-side state that should revoke links when it changes, such as a password hash or an updated-at timestamp: every link issued before the change stops verifying without any stored list of revoked links.
Pass the same Bind to the signing call and to every Signer.Verify, Signer.Consume, and Signer.Inspect call for the link. A wrong or missing binding, and a binding on a link signed without one, returns ErrInvalidSignature, which reveals nothing about which part failed. An empty value is a binding like any other, distinct from no binding. Links signed without Bind keep verifying without it.
The value is copied and only its SHA-256 digest enters the signed text, so the link never discloses it. Use MiddlewareBuilder.BindRequest with Middleware.
Example:
signed, err := signer.Temporary("/reset?user=42", time.Hour, link.Bind(user.PasswordHash))
if err != nil {
return err
}
if err := signer.Verify(signed, link.Bind(user.PasswordHash)); err != nil {
return err
}
Example ¶
ExampleBind shows binding a link to application state so verification requires the same state value.
package main
import (
"fmt"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Sign("/reset?user=42", link.Bind([]byte("password-hash-1")))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(signer.Verify(signed, link.Bind([]byte("password-hash-1"))))
// After the password changes, the old link stops verifying.
fmt.Println(signer.Verify(signed, link.Bind([]byte("password-hash-2"))))
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: <nil> link signature is invalid
type Config ¶
type Config struct {
// Origin is the public HTTP or HTTPS origin of absolute links. The
// default, empty, creates a relative signer that signs path-and-query values only.
//
// Example:
// record := link.Config{Origin: "https://app.example.com"}
// logger.InfoContext(ctx, "link settings", "settings", record)
Origin string
// PreviousSecrets still verify links during secret rotation. Each must be
// exactly [MinSecretLength] bytes. The default, none, accepts only
// links signed with the current secret.
//
// Example:
// record := link.Config{PreviousSecrets: []key.Shared{previous}}
// slog.Info("link settings", "settings", record)
PreviousSecrets []key.Shared
// IgnoreQuery names query parameters excluded from the signature. They may
// be added, changed, or removed without invalidating a link. The names
// "signature" and "expires" are reserved and cannot be listed. The default,
// none, signs every query parameter.
// This reporting record does not copy caller-owned slices or validate them;
// use Builder.IgnoreQuery for detached construction policy.
//
// Example:
// record := link.Config{IgnoreQuery: []string{"utm_source"}}
// logger.InfoContext(ctx, "link settings", "settings", record)
IgnoreQuery []string
// Events receives [Rejected] from [Middleware] and [Consumed] from the
// Consume methods. The default, nil, publishes nothing.
//
// Example:
// record := link.Config{Events: events}
// _ = record.Events // the live bus is not printed by Config formatting
Events *event.Bus
// Cache records consumed single-use links and lets [Middleware] reject
// them. It must be shared by every server that verifies the same links.
// The default, nil, disables [Signer.Consume], which then returns
// [ErrNoCache].
//
// Example:
// record := link.Config{Cache: store}
// _ = record.Cache // the live service is omitted from formatted diagnostics
Cache *cache.Cache
}
Config is a printable signer-settings record retained for redacted logging. It is not a constructor input; configure New with Builder callbacks. Formatting omits service pointers and replaces previous secrets with a marker.
Example:
record := link.Config{Origin: "https://app.example.com"}
slog.Info("link settings", "settings", record)
func (Config) GoString ¶
GoString formats config like Config.String for %#v. Origin and query names remain diagnostic configuration, not secret values.
Example:
text := fmt.Sprintf("%#v", link.Config{PreviousSecrets: []key.Shared{previous}})
_ = text // previous keys are represented by a redaction marker
func (Config) LogValue ¶
LogValue implements slog.LogValuer with the redacted form of config. Only rotation keys and services are omitted; supply a non-secret Origin.
Example:
logger.InfoContext(ctx, "link settings", "settings", link.Config{Origin: "https://app.example.com"})
func (Config) String ¶
String formats config with PreviousSecrets replaced by "[REDACTED]" when set, so %v and %+v never print them. It omits service pointers, but does not sanitize Origin or query-name strings.
Example:
record := link.Config{PreviousSecrets: []key.Shared{previous}}
text := record.String()
_ = text // includes PreviousSecrets:[REDACTED], never the key bytes
type ConsumedEvent ¶
type ConsumedEvent struct {
// Method is the request method. It is empty when the link was consumed
// with [Signer.Consume] or [Signer.ConsumeURL], which have no request.
// Custom methods are client-controlled and must not contain secrets.
//
// Example:
// logger.InfoContext(ctx, "link consumed", "method", consumed.Method)
Method string
}
ConsumedEvent reports a recorded single use without a URL, resource path, binding, cache key, or digest. Direct URL calls have no HTTP method to report. The event context retains the caller's context values, which are not redacted.
Example:
unsubscribe, err := events.Listen(link.Consumed, func(ctx context.Context, consumed link.ConsumedEvent) error {
logger.InfoContext(ctx, "link consumed", "method", consumed.Method)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
type ExpiredError ¶
type ExpiredError struct {
// ExpiresAt is the time the link stopped being valid, with second precision.
//
// Example:
// var expired *link.ExpiredError
// if errors.As(err, &expired) {
// logger.InfoContext(ctx, "link expired", "expires_at", expired.ExpiresAt)
// }
ExpiresAt time.Time
}
ExpiredError reports an authenticated temporary link whose expiry has passed and carries the moment it expired. Verification produces this timestamp only after successful authentication. Use errors.As to inspect it, or errors.Is with ErrExpired when it is unnecessary.
Example:
var expired *link.ExpiredError
if err := signer.Verify(rawURL); errors.As(err, &expired) {
logger.InfoContext(ctx, "link expired", "expires_at", expired.ExpiresAt)
}
Example ¶
ExampleExpiredError shows how to distinguish an expired link from other verification failures and extract the expiration timestamp when present.
package main
import (
"errors"
"fmt"
"time"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Temporary("/reset?user=42", time.Hour)
if err != nil {
fmt.Println(err)
return
}
var expired *link.ExpiredError
switch err := signer.Verify(signed); {
case errors.As(err, &expired):
fmt.Printf("link expired at %s, request a new one\n", expired.ExpiresAt.Format(time.RFC3339))
case err != nil:
fmt.Println("invalid link")
default:
fmt.Println("valid link")
}
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output:
func (*ExpiredError) Error ¶
func (expiredError *ExpiredError) Error() string
Error describes authenticated expiry in UTC RFC3339 without a URL, signature, or binding. The timestamp reflects second precision from the signed URL.
Example:
expired := &link.ExpiredError{
ExpiresAt: time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC),
}
text := expired.Error() // "link expired at 2026-10-30T12:00:00Z"
_ = text
func (*ExpiredError) Is ¶
func (expiredError *ExpiredError) Is(target error) bool
Is reports whether target is ErrExpired. Use errors.Is so wrapping added by an application does not hide this identity.
Example:
expired := &link.ExpiredError{
ExpiresAt: time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC),
}
if errors.Is(expired, link.ErrExpired) {
return link.ErrLinkExpired
}
type Inspection ¶
type Inspection struct {
// URL is the link without its "signature" and "expires" parameters. Pass
// it to [Signer.Temporary] or [Signer.Sign] to issue a fresh link.
// It still contains application paths and query values: do not log it as
// automatically safe data merely because the signature was removed.
//
// Example:
// fresh, err := signer.Temporary(details.URL, time.Hour)
// if err != nil {
// return err
// }
// _ = fresh
URL string
// Temporary reports that the link carries an expiry.
//
// Example:
// logger.InfoContext(ctx, "link policy", "temporary", details.Temporary)
Temporary bool
// ExpiresAt is the expiry with second precision. It is the zero time for a
// permanent link.
//
// Example:
// if details.Temporary {
// logger.InfoContext(ctx, "link expiry", "expires_at", details.ExpiresAt)
// }
ExpiresAt time.Time
// Expired reports a temporary link whose expiry has passed.
//
// Example:
// if details.Expired {
// return link.ErrLinkExpired
// }
Expired bool
// Previous reports that a secret from [Builder.Previous] signed the
// link. Re-issue such links before dropping that secret.
//
// Example:
// logger.InfoContext(ctx, "link rotation", "previous", details.Previous)
Previous bool
// Consumed reports a temporary link that [Signer.Consume] spent. It is
// always false when the signer has no cache and for permanent links, so
// false means "not known to be spent", not a guarantee that it is usable.
//
// Example:
// if details.Consumed {
// return link.ErrLinkUsed
// }
Consumed bool
}
Inspection describes an authenticated link, as returned by Signer.Inspect. It is an observation, not an authorization or consumption reservation; another request can consume the link immediately after inspection.
Example:
details, err := signer.Inspect(ctx, rawURL)
if err != nil {
return err
}
if details.Expired {
return link.ErrLinkExpired
}
type MiddlewareBuilder ¶
type MiddlewareBuilder struct {
// contains filtered or unexported fields
}
MiddlewareBuilder configures request binding before middleware publication. Callbacks run once, synchronously in order on one fresh draft; nil entries panic. Retained drafts cannot change the middleware. Binding callbacks are stored, not executed during construction.
Example:
r := router.New()
r.Use(link.Middleware(signer, func(policy *link.MiddlewareBuilder) {
policy.BindRequest(nil)
}))
func (*MiddlewareBuilder) BindRequest ¶
func (policy *MiddlewareBuilder) BindRequest(derive func(*http.Request) ([]byte, error)) *MiddlewareBuilder
BindRequest makes Middleware verify links signed with Bind, taking the binding from derive for each request. Typically derive reads the account the link names and returns its current password hash or update time.
derive runs before the signature is checked, so everything it reads from the request is untrusted: look the record up by a value from the URL, but do not treat that value as proof of anything. An error from derive rejects the link with ErrLinkRejected, as does a missing record. The returned problem retains the cause; the Rejected event and response body do not contain it. Applications must redact the cause before logging it. Every request is verified with a binding, so BindRequest cannot accept links signed without Bind. A nil derive function disables request binding and verifies unbound links. The function and its captures remain shared and run only at request time.
Example:
r := router.New()
r.Use(link.Middleware(signer, func(policy *link.MiddlewareBuilder) {
policy.BindRequest(func(r *http.Request) ([]byte, error) {
user, err := users.Find(r.Context(), r.URL.Query().Get("user"))
if err != nil {
return nil, err
}
return user.PasswordHash, nil
})
}))
type MiddlewareBuilderFunc ¶
type MiddlewareBuilderFunc = func(*MiddlewareBuilder)
MiddlewareBuilderFunc edits request-verification settings once in order on a fresh draft. Nil entries panic; empty expansions omit edits. Request-binding functions are retained for requests and are not called during construction.
Example:
var configure link.MiddlewareBuilderFunc = func(policy *link.MiddlewareBuilder) {
policy.BindRequest(loadBinding)
}
r.With(link.Middleware(signer, configure)).Get("/reset", showResetForm)
type RejectedEvent ¶
type RejectedEvent struct {
// Method is the client-controlled HTTP method, not a route or identity.
// Applications must not place secrets in custom method names.
//
// Example:
// logger.InfoContext(ctx, "link rejected", "method", rejected.Method)
Method string
}
RejectedEvent reports invalid link admission without the URL, path, binding, or cause. Paths and callback errors can contain credentials. The middleware's returned problem retains the cause; listeners do not receive it as payload.
Example:
unsubscribe, err := events.Listen(link.Rejected, func(ctx context.Context, rejected link.RejectedEvent) error {
logger.InfoContext(ctx, "link rejected", "method", rejected.Method)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
type Signer ¶
type Signer struct {
// contains filtered or unexported fields
}
Signer signs and verifies application links with an immutable key set. Construct it once and share it across requests. Cache and event services remain live dependencies; verification alone never reserves a link's single use.
Example:
signer, err := link.New(secret)
if err != nil {
return err
}
signed, err := signer.Sign("/files/report.pdf")
if err != nil {
return err
}
if err := signer.Verify(signed); err != nil {
return err
}
func New ¶
func New(secret key.Shared, configure ...BuilderFunc) (*Signer, error)
New creates a signer from the required secret and the configuration built by applying construction callbacks directly once, synchronously in order on one fresh draft. Nil entries panic; expanded nil/empty slices supply no callbacks. Validation follows all callbacks, and retained drafts cannot change the signer.
secret creates all new signatures. Load a validated key.Shared through the key package. New rejects a zero current or previous secret with ErrInvalidSecret. HKDF-SHA256 derives purpose-bound MAC keys without retaining the original secret. Provision separate material for unrelated purposes.
An empty origin yields a relative signer that signs path-and-query values only; otherwise the origin must be an HTTP or HTTPS origin without a path, query, fragment, or user info (an origin's root slash is also accepted). Owned key material and query-name slices are detached. Cache and event buses remain shared live services; callback captures are not cloned. Configure those dependencies before sharing the signer for concurrent use.
Example:
signer, err := link.New(
secret,
func(signer *link.Builder) {
signer.Origin("https://app.example.com")
},
)
if err != nil {
return err
}
signed, err := signer.Sign("/files/report.pdf")
if err != nil {
return err
}
_ = signed
Example ¶
ExampleNew shows construction with an absolute origin, followed by signing and verifying an application link.
package main
import (
"errors"
"fmt"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(
secret,
func(signer *link.Builder) {
signer.Origin("https://app.example.com")
},
)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Sign("/unsubscribe?user=42")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(signer.Verify(signed))
// An absolute signer rejects other origins.
_, err = signer.Sign("https://evil.example.com/unsubscribe")
fmt.Println(errors.Is(err, link.ErrInvalidURL))
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: <nil> true
Example (IgnoreQuery) ¶
ExampleNew_ignoreQuery shows excluding tracking parameters from the signature while retaining verification of application parameters.
package main
import (
"fmt"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(
secret,
func(signer *link.Builder) {
signer.IgnoreQuery("utm_source")
},
)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Sign("/unsubscribe?user=42")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(signer.Verify(signed + "&utm_source=newsletter"))
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: <nil>
Example (InvalidSecret) ¶
ExampleNew_invalidSecret shows how callers inspect the construction error returned for an undersized signing secret.
package main
import (
"fmt"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
func main() {
_, err := link.New(key.Shared{})
fmt.Println(err)
}
Output: link signing secret is invalid
Example (Relative) ¶
ExampleNew_relative shows that an origin-free signer produces relative links suitable for applications that supply their own host.
package main
import (
"fmt"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Sign("/files/report.pdf")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(signer.Verify(signed))
// A relative signer accepts paths only.
_, err = signer.Sign("https://app.example.com/files/report.pdf")
fmt.Println(err)
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: <nil> link URL is invalid
Example (Rotation) ¶
ExampleNew_rotation shows accepting links signed with a previous secret while new links use the current secret.
package main
import (
"fmt"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
previous, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
signed, err := previous.Sign("/unsubscribe?user=42")
if err != nil {
fmt.Println(err)
return
}
replacement, err := key.NewShared([]byte("fedcba9876543210fedcba9876543210"))
if err != nil {
fmt.Println(err)
return
}
rotated, err := link.New(
replacement,
func(signer *link.Builder) {
signer.Previous(secret)
},
)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(rotated.Verify(signed))
// Once the old secret is dropped, its links stop verifying.
retired, err := link.New(replacement)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(retired.Verify(signed))
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: <nil> link signature is invalid
func (*Signer) Consume ¶
Consume verifies rawURL and spends it, so no later Consume of the same link succeeds. Use it for single-use links such as a password reset: show the form for any valid link, then call Consume in the handler that applies the change.
Consume verifies the signature, the binding (Bind), and the expiry exactly like Signer.Verify, then records the link in the signer's cache with an atomic add, so concurrent calls on one link have exactly one winner, even across servers sharing a cache driver. It returns:
- ErrInvalidURL when rawURL cannot be parsed, before checking the cache;
- ErrNoCache for a parsed URL when the signer has no cache, before verifying;
- ErrInvalidURL, ErrInvalidSignature, or an *ExpiredError on verification;
- ErrNotTemporary for a permanent link;
- ErrConsumed when the link was already spent;
- a wrapped cache error when consumption could not be confirmed. The marker may already have been committed, so the caller must not proceed or assume a retry can succeed. Offer a new link when recovery cannot establish use.
With Builder.Events, a successful call publishes Consumed; failures and repeated calls publish nothing.
Consume before committing the side effect. That gives at-most-once behavior: if the side effect fails afterwards the link stays spent and the user must request a new one. Consuming after the side effect would allow a crash or a concurrent request to apply it twice.
The cache entry lives until the link's expiry plus one minute for clock skew between servers, so the cache holds one small key per consumed link for the link's remaining lifetime. A cache that loses entries early, such as an evicting or restarted in-memory cache, lets a spent link work again.
Example:
err := signer.Consume(ctx, rawURL, link.Bind(user.PasswordHash))
if errors.Is(err, link.ErrConsumed) {
return link.ErrLinkUsed.Derive(func(details *problem.Builder) {
details.Error(err)
})
}
if err != nil {
return err
}
Example ¶
ExampleSigner_Consume shows using a cache-backed signer to accept a temporary link only once and report later reuse.
signer, err := link.New(
secret,
func(signer *link.Builder) {
signer.Cache(cache.New(memorycache.New()))
},
)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Temporary("/reset?user=42", time.Hour)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(signer.Consume(context.Background(), signed))
err = signer.Consume(context.Background(), signed)
fmt.Println(errors.Is(err, link.ErrConsumed))
Output: <nil> true
func (*Signer) ConsumeRequest ¶
func (signer *Signer) ConsumeRequest(request *http.Request, opts ...CallOption) error
ConsumeRequest consumes the link a request was made to, using the same URL reconstruction as Middleware (the request path and query, completed with the signer's origin), and the request's context. Handlers use it so they do not have to rebuild the URL. See Signer.Consume.
Example:
if err := signer.ConsumeRequest(r, link.Bind(user.PasswordHash)); err != nil {
return err
}
func (*Signer) ConsumeURL ¶
ConsumeURL consumes value without mutating it. value is a parsed signed URL, typically returned by TemporaryURL. It checks cache configuration before URL validation; nil value with a configured cache returns ErrInvalidURL. Other errors and uncertain-write behavior follow Signer.Consume.
Example:
if err := signer.ConsumeURL(ctx, signedURL, link.Bind(user.PasswordHash)); err != nil {
return err
}
func (*Signer) Inspect ¶
func (signer *Signer) Inspect(ctx context.Context, rawURL string, opts ...CallOption) (Inspection, error)
Inspect authenticates rawURL and describes it. Use it to decide what to show for a link without acting on it: offer a new link for an expired reset, or re-issue links signed with a previous secret.
Inspect checks the signature and binding (Bind) like Signer.Verify, but an expired link is not an error: it returns the details with Inspection.Expired set. Parsing or signer-mode validation returns ErrInvalidURL; malformed signature data, forgery, or a wrong binding returns ErrInvalidSignature. Every failure returns zero details, so unauthenticated input never reveals expiry or previous-key metadata. With a cache (Builder.Cache), Inspection.Consumed reports whether the link was spent, and a cache failure returns a wrapped error. Inspect never changes the link's state.
Example:
details, err := signer.Inspect(ctx, rawURL)
if err == nil && details.Expired {
// Offer to send a new link for details.URL.
}
Example ¶
ExampleSigner_Inspect shows reading verified link metadata without exposing the signature in the resulting application URL.
signer, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Temporary("/reset?user=42", time.Hour)
if err != nil {
fmt.Println(err)
return
}
details, err := signer.Inspect(context.Background(), signed)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(err, details.URL, details.Temporary, details.Expired)
Output: <nil> /reset?user=42 true false
func (*Signer) InspectURL ¶
func (signer *Signer) InspectURL(ctx context.Context, value *url.URL, opts ...CallOption) (Inspection, error)
InspectURL describes value without mutating it. nil or an unsuitable signer target returns ErrInvalidURL. Signature and cache failures follow Signer.Inspect and always return zero details, not a partially authenticated inspection.
Example:
details, err := signer.InspectURL(ctx, signedURL)
if err != nil {
return err
}
if details.Consumed {
return link.ErrLinkUsed
}
func (*Signer) Sign ¶
func (signer *Signer) Sign(rawURL string, opts ...CallOption) (string, error)
Sign signs rawURL as a permanent link and returns its canonical URL string. A reserved query parameter already present in rawURL is replaced. Pass Bind to cover server-side state in the signature.
Example:
signed, err := signer.Sign("/files/report.pdf")
if err != nil {
return err
}
_ = signed
func (*Signer) SignURL ¶
SignURL signs value as a permanent link without mutating value. See Signer.Sign. nil or a target outside the signer's mode returns ErrInvalidURL. The returned URL is an independent value; it does not grant protection to ignored parameters.
Example:
signed, err := signer.SignURL(&url.URL{
Path: "/files/report.pdf",
})
if err != nil {
return err
}
_ = signed.String()
func (*Signer) Temporary ¶
func (signer *Signer) Temporary(rawURL string, ttl time.Duration, opts ...CallOption) (string, error)
Temporary signs rawURL as a temporary link that expires ttl from now. A ttl that is not positive returns ErrInvalidExpiry. Pass Bind to cover server-side state in the signature.
Example:
signed, err := signer.Temporary("/files/report.pdf", 15*time.Minute)
if err != nil {
return err
}
_ = signed
Example ¶
ExampleSigner_Temporary shows creating and verifying an expiring link, then checking the error returned for a nonpositive lifetime.
package main
import (
"errors"
"fmt"
"time"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Temporary("/reset?user=42", 15*time.Minute)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(signer.Verify(signed))
_, err = signer.Temporary("/reset", 0)
fmt.Println(errors.Is(err, link.ErrInvalidExpiry))
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: <nil> true
func (*Signer) TemporaryURL ¶
func (signer *Signer) TemporaryURL(value *url.URL, ttl time.Duration, opts ...CallOption) (*url.URL, error)
TemporaryURL signs value as a temporary link that expires ttl from now, without mutating value. A ttl that is not positive returns ErrInvalidExpiry. The expiry has second precision and is authenticated with the rest of the URL.
Example:
signed, err := signer.TemporaryURL(&url.URL{
Path: "/reset",
}, 15*time.Minute)
if err != nil {
return err
}
_ = signed.String()
func (*Signer) Verify ¶
func (signer *Signer) Verify(rawURL string, opts ...CallOption) error
Verify verifies rawURL's signature and, when present, its expiry. Pass Bind with the value the link was signed with, or a bound link returns ErrInvalidSignature. Verify does not know whether a link was consumed; see Signer.Consume and Signer.Inspect. Parse/target failures return ErrInvalidURL, authentication failures return ErrInvalidSignature, and authenticated expiry returns *ExpiredError.
Example:
if err := signer.Verify(rawURL, link.Bind(user.PasswordHash)); err != nil {
return err
}
Example ¶
ExampleSigner_Verify shows that verification accepts an intact signed link and rejects a link whose application parameters changed.
package main
import (
"fmt"
"arkoren.dev/core/http/link"
"arkoren.dev/foundation/key"
)
// secret is the fixed, sufficiently long signing key shared by deterministic
// link examples; it is not an application credential. Applications must load
// an unpredictable secret from secure configuration.
var secret = fixtureSecret([]byte("0123456789abcdef0123456789abcdef"))
func main() {
signer, err := link.New(secret)
if err != nil {
fmt.Println(err)
return
}
signed, err := signer.Sign("/download?file=1")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(signer.Verify(signed))
// Changing any signed part invalidates the link.
fmt.Println(signer.Verify("/download?file=2&" + signed[len("/download?file=1&"):]))
}
// fixtureSecret converts deterministic fixture bytes, preserving invalid input
// as an invalid zero value so constructor-boundary rejection is exercised.
func fixtureSecret(raw []byte) key.Shared {
secret, _ := key.NewShared(raw)
return secret
}
Output: <nil> link signature is invalid
func (*Signer) VerifyURL ¶
func (signer *Signer) VerifyURL(value *url.URL, opts ...CallOption) error
VerifyURL verifies value's signature and, when present, its expiry. The expiry is checked only after the signature, so a forged link never reports ErrExpired. value is never mutated; nil or an unsuitable target returns ErrInvalidURL.
Example:
if err := signer.VerifyURL(signedURL, link.Bind(user.PasswordHash)); err != nil {
return err
}