Documentation
¶
Overview ¶
Package handoff moves a signed-in session to another client in one step: a web page hands a native app a short-lived, single-use token, and the app exchanges it for a session of its own. The same works for a second domain, a desktop helper, or a CLI the user authorized in the browser.
The token is a core verification token, so it is stored hashed, expires, and is spent by exactly one exchange even under concurrent attempts. The exchange creates a NEW session rather than copying the source one: the source's raw token is never stored, and two clients sharing one session would let either end the other's.
A client that will receive the token on a channel another program could also receive it on (a custom URL scheme) binds it first: it sends the S256 challenge of a verifier with generate and the verifier with verify, as a native OAuth client does with PKCE (RFC 7636, RFC 8252 §8.1), so a token caught by the wrong app cannot be spent.
Index ¶
- Constants
- Variables
- func ChallengeOf(verifier string) string
- func NewSessionToken(ctx context.Context) (auth.Token, bool)
- func NewVerifier() (verifier, challenge string, err error)
- type GenerateRequest
- type GenerateResult
- type Option
- type Plugin
- func (p *Plugin) Generate(ctx context.Context) (GenerateResult, error)
- func (p *Plugin) GenerateWith(ctx context.Context, in GenerateRequest) (GenerateResult, error)
- func (p *Plugin) Init(a *auth.Auth) error
- func (p *Plugin) Name() string
- func (p *Plugin) OperationHooks() []auth.OperationHook
- func (p *Plugin) Operations() []auth.Operation
- func (p *Plugin) RateLimits() []auth.RateRule
- func (p *Plugin) Validate(*auth.Config) error
- func (p *Plugin) Verify(ctx context.Context, t auth.Token) (VerifyResult, error)
- func (p *Plugin) VerifyWith(ctx context.Context, in VerifyRequest) (VerifyResult, error)
- type VerifyRequest
- type VerifyResult
Constants ¶
const ( EndpointGenerate auth.EndpointID = "handoff.generate" EndpointVerify auth.EndpointID = "handoff.verify" )
Operation ids, exported so a hook or a rate rule can name them. They are wire names, stable across releases.
const ( // ChallengeMethod is the one method; it is named so a client can state // it, as an OAuth client states code_challenge_method. //ds:def id=handoff-challengemethod-4k7xp2nm owner=@khanakia stability=stable ChallengeMethod = "S256" // VerifierBytes is how much randomness NewVerifier draws: 32 bytes, the // size of the hash that binds it, encoded as 43 characters. //ds:def id=handoff-verifierbytes-8q2mt5rw owner=@khanakia stability=stable VerifierBytes = 32 // MinVerifierLen and MaxVerifierLen bound a verifier as RFC 7636 §4.1 // does: 43 to 128 characters of the unreserved set. A shorter one has // too little entropy to be a secret; a longer one is a mistake. //ds:def id=handoff-minverifierlen-6n9wq3kt owner=@khanakia stability=stable MinVerifierLen = 43 //ds:def id=handoff-maxverifierlen-2m5zr8vp owner=@khanakia stability=stable MaxVerifierLen = 128 )
The optional verifier binding (RFC 7636 PKCE, method S256 only). A client that will receive the token on a channel another program could also receive it on (a custom URL scheme, which any app on the machine can register) makes a random verifier, sends its SHA-256 challenge with generate, and sends the verifier with verify; a token minted with a challenge is spent only by the holder of the verifier. "plain" is not offered: it would put the verifier itself on the URL.
const ( // DefaultTTL is three minutes: a hand-off happens within seconds of // being asked for, and a longer life only serves whoever reads the // token off a URL or a log later. //ds:def id=defaultttl-k8w2pn5q owner=@khanakia stability=stable DefaultTTL = 3 * time.Minute )
Defaults and limits.
const PluginName = "handoff"
PluginName is the plugin's permanent identity.
const PurposeHandoff auth.Purpose = "handoff.token"
PurposeHandoff is the verification purpose of a hand-off token. Its Identifier is the user id; its Meta is the id of the session that minted it, so an exchange after that session ended is refused.
const SetOTTHeader = "Set-OTT"
SetOTTHeader is the response header IssueOnNewSession hands the token in, better-auth's set-ott, so a client written for better-auth reads it unchanged. The transport lists it in Access-Control-Expose-Headers.
Variables ¶
var ( ErrChallengeInvalid = auth.NewError("handoff.challenge_invalid", http.StatusBadRequest, "The challenge is not a base64url SHA-256.") ErrVerifierInvalid = auth.NewError("handoff.verifier_invalid", http.StatusBadRequest, "The verifier must be 43 to 128 unreserved characters.") )
Request errors of the verifier binding. Both describe the shape of what the client sent and reveal nothing about any token: a challenge that is not a base64url SHA-256, a verifier outside RFC 7636's bounds. A verifier that is well formed but wrong, missing for a bound token or present for an unbound one answers auth.ErrTokenInvalid, like a wrong token, so the two cases cannot be told apart from outside.
var ErrBadTTL = errors.New("handoff: WithTTL needs a positive duration")
ErrBadTTL is a configuration error from auth.New: WithTTL was given zero or less, which would make every token expired at birth.
Functions ¶
func ChallengeOf ¶
ChallengeOf is the S256 challenge of a verifier: the base64url SHA-256 of its ASCII bytes, without padding (RFC 7636 §4.2).
func NewSessionToken ¶
NewSessionToken returns the token IssueOnNewSession handed out during the request in ctx, for a Go caller, which has no response headers to read; false when there is none (the option is off, or no full session started).
func NewVerifier ¶
NewVerifier makes a verifier for the client that will receive a token and the challenge to send with generate. The verifier stays in the client's memory; only the challenge travels.
Types ¶
type GenerateRequest ¶
type GenerateRequest struct {
Challenge string `json:"challenge,omitempty"`
}
GenerateRequest is the input of generate. Challenge is optional: when the receiving client made a verifier (NewVerifier), its challenge binds the token so that only that client can spend it; see ChallengeMethod.
type GenerateResult ¶
GenerateResult is the output of generate. The token is the one value a result struct carries in the clear, because handing it to the client is the operation's whole purpose; it is a hand-off token, not the session's.
type Option ¶
type Option func(*Plugin)
Option configures the plugin; options are applied in order by New.
func IssueOnNewSession ¶
func IssueOnNewSession() Option
IssueOnNewSession also mints a hand-off token whenever an operation starts a new signed-in session (a sign-in, a sign-up, a promotion after the second factor) and hands it in the SetOTTHeader response header, better-auth's setOttHeaderOnNewSession. A Go caller reads it with NewSessionToken.
The token is exactly one generate would mint for that session: bound to it, DefaultTTL or WithTTL, single use, refused once the session ends. None is minted for a pending session (the second factor is still owed, and the exchange would skip it), for an impersonation (generate refuses those too), or by verify itself (its client is the one the hand-off was for). The cost of the option is that every sign-in answer carries a second credential, so a response logged or cached by a proxy leaks a session; prefer generate, called when a hand-off is actually wanted, unless the client needs one on every sign-in.
func ServerOnly ¶
func ServerOnly() Option
ServerOnly makes the generate route answer auth.ErrUnknownOperation, so tokens are minted only by the app's own Go code through Generate, for an app that hands sessions off on its own terms (after its own checks, say). The operation is then SelfServed: no transport mounts the route and no generated client or API reference lists it, while Generate keeps running the pipeline. A call that reaches the operation some other way (a custom transport through Auth.Operation) is still told apart by a marker only Generate sets, not by transport, because the app's handler calls Generate inside an HTTP request too.
func WithGenerateLimit ¶
WithGenerateLimit replaces the default of 10 tokens per IP per minute.
func WithVerifyLimit ¶
WithVerifyLimit replaces the default of 10 exchanges per IP per minute. The per-token rule (verifyTokenLimit per minute) is not configurable: it is what bounds guessing a verifier, and a token has no legitimate use for more.
type Plugin ¶
type Plugin struct {
// contains filtered or unexported fields
}
Plugin is the hand-off token plugin. Build it with New: the zero value has no TTL and no operations.
func New ¶
New builds the plugin. With no options a token lives DefaultTTL and both routes are served.
func (*Plugin) Generate ¶
func (p *Plugin) Generate(ctx context.Context) (GenerateResult, error)
Generate mints a token for the signed-in session in ctx, through the same operation the HTTP route runs.
func (*Plugin) GenerateWith ¶
func (p *Plugin) GenerateWith(ctx context.Context, in GenerateRequest) (GenerateResult, error)
GenerateWith is Generate with a challenge from the receiving client, for a page that passes the app's challenge through.
func (*Plugin) OperationHooks ¶
func (p *Plugin) OperationHooks() []auth.OperationHook
OperationHooks adds, under IssueOnNewSession, the after-hook on every operation that mints a token for the session it started; nothing otherwise.
func (*Plugin) Operations ¶
Operations lists generate and verify.
func (*Plugin) RateLimits ¶
RateLimits bounds token minting per IP, and exchanges per IP and per token. Guessing a token is not a strategy (256 bits, spent on first use). A bound token is different in one way: a refused exchange leaves it unspent, so whoever intercepted it could retry verifiers. That is harmless because a verifier is at least MinVerifierLen (43) unreserved characters, about 256 bits like the token, so the guess space is the same; the two verify rules exist so that the retries cost the attacker time as well, a handful a minute per token and per address.
func (*Plugin) Validate ¶
Validate refuses a lifetime nobody could use and a rate limit that could not count.
func (*Plugin) Verify ¶
Verify exchanges a token for a new session of its user and records it on the call's effects, so the transport sets the cookie or bearer token.
func (*Plugin) VerifyWith ¶
func (p *Plugin) VerifyWith(ctx context.Context, in VerifyRequest) (VerifyResult, error)
VerifyWith is Verify with the verifier of a token minted with a challenge.
type VerifyRequest ¶
type VerifyRequest struct {
Token auth.WireToken `json:"token"`
Verifier string `json:"verifier,omitempty"`
}
VerifyRequest carries the token the other client received and, when the token was minted with a challenge, the verifier that challenge was made from.
func (VerifyRequest) RateIdentifier ¶
func (r VerifyRequest) RateIdentifier() string
RateIdentifier counts exchanges per token, keyed by the token's hash so the rate store never holds the credential itself.
type VerifyResult ¶
VerifyResult is the output of verify: the user now signed in on this client. Token is the new session's token for Go callers and bearer transports, never encoded into JSON; a cookie client gets the cookie.