handoff

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

github.com/ubgo/auth/plugin/handoff

Moves a signed-in session to another client in one step: a web page asks for a short-lived, single-use token, hands it to a native app, a desktop helper, a CLI or a page on a second domain, and that client exchanges it for a session of its own.

Install

go get github.com/ubgo/auth

The package is part of the core module, so it adds no dependency.

Wire it

From examples/handoff:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		password.New(),
		handoff.New(),
	),
)

The plugin is not a sign-in method: it needs one beside it, since a token is minted from an existing session.

auth.New fails with:

Error Text Cause
ErrBadTTL handoff: WithTTL needs a positive duration WithTTL(0) or less

Options

Option What it does Default
WithTTL(d) How long a token can be exchanged DefaultTTL = 3 minutes
ServerOnly() The generate route answers auth.unknown_operation and is absent from the OpenAPI document and the generated TypeScript client; tokens are minted only by the app's own Go code through Generate the route is served
WithGenerateLimit(n, window) Rate rule of generate per IP 10 per minute (generateLimit, generateWindow)
WithVerifyLimit(n, window) Rate rule of verify per IP; the per-token rule is fixed 10 per minute
IssueOnNewSession() Every operation that starts a full session (sign-in, sign-up, the promotion after a second factor) also mints a token for it and hands it in the Set-OTT header, better-auth's setOttHeaderOnNewSession; a Go caller reads it with handoff.NewSessionToken(ctx). None for a pending session, an impersonation, or verify itself. Risk: every sign-in answer then carries a second credential, so a proxy that logs or caches responses leaks a session; prefer calling generate when a hand-off is wanted off

Routes and Go API

The routes are under your mount prefix (for example /auth).

Route Go method Requirement Rate limit
POST /handoff/generate (challenge, optional) Generate(ctx), GenerateWith(ctx, GenerateRequest{Challenge}) return GenerateResult{Token, ExpiresAt} SignedIn 10 per minute per IP (generateLimit, generateWindow)
POST /handoff/verify (token, verifier when the token was bound) Verify(ctx, token), VerifyWith(ctx, VerifyRequest{Token, Verifier}) return VerifyResult{User, Token} Public 10 per minute per IP (WithVerifyLimit) and 5 per minute per token, keyed by the token's hash: a refused exchange leaves a bound token unspent, so this is what bounds guessing its verifier
  • generate mints a token for the request's own session. The token is in the response body in the clear: handing it to the client is the point, and it is a hand-off token, not the session's. An admin acting as the user is refused (auth.impersonation_refused): an impersonation must not outlive its short life on another client.
  • verify spends the token and starts a NEW session for its user, recorded on the call's effects, so a cookie client gets the cookie and a bearer client (Auth-Token-Mode: bearer) gets Set-Auth-Token. The source session must still be live; a token minted before a sign-out is refused. The new session skips the second factor, which the source session already passed. In stateless session mode the source cannot be checked, and the token's short life is the bound.
  • Binding the token to the receiving client (RFC 7636 PKCE, method S256 only). When the token will travel on a channel another program could also receive it on, such as a custom URL scheme any app on the machine can register, the client makes a verifier and sends its challenge with generate; verify then needs the verifier, which never left the client. handoff.NewVerifier() returns a verifier of 32 random bytes as 43 base64url characters and its challenge; handoff.ChallengeOf(verifier) is the base64url SHA-256 for a client written in another language. A challenge that is not a base64url SHA-256 is handoff.challenge_invalid; a verifier outside 43 to 128 unreserved characters is handoff.verifier_invalid; both are shape errors that touch no token. A wrong verifier, a missing one for a bound token or one sent for an unbound token answer auth.token_invalid, like a wrong token, and the token stays unspent (the operation's transaction rolls back), so the program that caught the token can neither use nor burn it, and the holder of the verifier still exchanges it within the token's life. A token minted without a challenge works as before.

Tables and columns

The plugin owns no table and adds no column. A token is a core verification token (auth_verification) with purpose PurposeHandoff (handoff.token); its identifier is the user id and its meta the id of the session that minted it, followed, for a bound token, by a newline and the challenge.

Errors

Error Code HTTP When
auth.ErrTokenInvalid auth.token_invalid 400 the token is bad, spent or expired; its source session ended; its user was deleted; or the verifier is wrong, missing for a bound token, or sent for an unbound one
ErrChallengeInvalid handoff.challenge_invalid 400 generate with a challenge that is not a base64url SHA-256
ErrVerifierInvalid handoff.verifier_invalid 400 verify with a verifier outside 43 to 128 unreserved characters
auth.ErrImpersonationRefused auth.impersonation_refused 403 generate from an impersonation session
auth.ErrUnknownOperation auth.unknown_operation 404 generate over a transport under ServerOnly()
auth.ErrRateLimited auth.rate_limited 429 the generate and verify limits above

Security notes

Property Pinned by
A token is exchanged once for a new session of the same user; a reused, expired or made-up token, and one whose source session ended, are refused alike; nobody signed out can mint one integration/TestHandoff_ExchangeStartsANewSessionOnce
The new session is full, not pending; a token of a deleted user answers like a spent one integration/TestHandoff_NewSessionIsFullAndUserMayBeGone
An impersonation cannot be handed off integration/TestHandoff_ImpersonationRefused
ServerOnly refuses the transport and keeps the Go call integration/TestHandoff_ServerOnly
The exchange works in stateless session mode, and a deleted user's token answers like a spent one integration/TestHandoff_Stateless
A token minted with a challenge is exchanged only with its verifier; a wrong or missing verifier, or one for an unbound token, answers like a bad token and leaves the token unspent; a malformed challenge or verifier is refused before any token is touched integration/TestHandoff_ChallengeBindsTheToken
ChallengeOf is RFC 7636's S256 (appendix B's example); NewVerifier draws 32 bytes plugin/handoff/TestChallengeOf
Exchanges are bounded per token (five a minute, on the token's hash) and per IP (ten a minute), so a verifier cannot be guessed at volume while the token lives; the right one still works after the window integration/TestHandoff_VerifyIsRateLimited, plugin/handoff/TestGenerateLimitOption
Every database failure fails the operation, bound or not integration/TestIdentFaults_Handoff

Limits:

  • Whoever holds the token for its three minutes can sign in as the user, unless it was bound to a verifier. Pass it over a channel the user controls (a deep link, a QR code, a localhost redirect), never a log line or a referrer, and bind it when the channel may have another listener.
  • The two sessions are independent: signing out of one leaves the other; "sign out everywhere" ends both.

Example

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

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

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

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

View Source
const PluginName = "handoff"

PluginName is the plugin's permanent identity.

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

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

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

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

func ChallengeOf(verifier string) string

ChallengeOf is the S256 challenge of a verifier: the base64url SHA-256 of its ASCII bytes, without padding (RFC 7636 §4.2).

func NewSessionToken

func NewSessionToken(ctx context.Context) (auth.Token, bool)

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

func NewVerifier() (verifier, challenge string, err error)

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

type GenerateResult struct {
	Token     string    `json:"token"`
	ExpiresAt time.Time `json:"expiresAt"`
}

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

func WithGenerateLimit(n int, window time.Duration) Option

WithGenerateLimit replaces the default of 10 tokens per IP per minute.

func WithTTL

func WithTTL(d time.Duration) Option

WithTTL overrides DefaultTTL, how long a token can be exchanged.

func WithVerifyLimit

func WithVerifyLimit(n int, window time.Duration) Option

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

func New(opts ...Option) *Plugin

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) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core handle Generate uses from Go.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName, the key the core registers this plugin under.

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

func (p *Plugin) Operations() []auth.Operation

Operations lists generate and verify.

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

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

func (p *Plugin) Validate(*auth.Config) error

Validate refuses a lifetime nobody could use and a rate limit that could not count.

func (*Plugin) Verify

func (p *Plugin) Verify(ctx context.Context, t auth.Token) (VerifyResult, error)

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

type VerifyResult struct {
	User  auth.UserView `json:"user"`
	Token auth.Token    `json:"-"`
}

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.

Jump to

Keyboard shortcuts

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