Documentation
¶
Overview ¶
Package pinoauth is a small, stdlib-only toolkit for building OAuth 2.0 browser-loopback flows (RFC 8252, "OAuth 2.0 for Native Apps") in CLI and desktop applications.
It provides the building blocks every native-app PKCE flow needs and nothing else:
- GeneratePKCE — RFC 7636 code verifier + S256 challenge.
- GenerateState — random value for the OAuth state parameter (RFC 6749 §10.12).
- StartCallbackServer — loopback HTTP server that catches the redirect, validates the state parameter, renders a styled success or error page, and delivers the result on a channel.
- ParseAuthorizationInput — robust parser for codes the user pastes manually (full URLs, code#state, query strings, or bare codes).
- AwaitAuthCode — races the loopback callback against an optional manual-paste prompt; the first arrival wins.
- Client — configured token-endpoint client with Client.Exchange (RFC 6749 §4.1.3) and Client.Refresh (RFC 6749 §6) methods. Returns a parsed Token whose [Token.Raw] map preserves every provider-specific field. Errors come back as *TokenError (RFC 6749 §5.2).
The Provider interface is a convention for assembling these pieces into provider-specific login flows; pinoauth itself ships no concrete providers.
Non-goals ¶
pinoauth deliberately does NOT provide:
- A TokenSource interface, auto-refreshing http.Client, or RoundTripper that injects bearer tokens.
- Background refresh goroutines or any concurrency primitive around tokens.
- Token storage, on-disk persistence, or keychain integration.
Token lifetime, refresh timing, persistence, and concurrency are the caller's concern — typically a thin layer in the consuming app that already knows how it wants to store credentials. Use Token.Expired or Token.ExpiresWithin to decide when to call Client.Refresh.
pinoauth was extracted from the fir coding-agent harness (https://github.com/kfet/fir), where it powers OAuth login for multiple providers.
Index ¶
- Variables
- func AwaitAuthCode(ctx context.Context, resultCh <-chan *CallbackResult, ...) (code, state string, err error)
- func GenerateState() string
- func JSONBodyEncoder(values url.Values) (string, []byte, error)
- func ParseAuthorizationInput(input string) (code, state string)
- func StartCallbackServer(ctx context.Context, route, addr, expectedState string) (server *http.Server, resultCh <-chan *CallbackResult, actualAddr string, ...)
- type AuthInfo
- type BodyEncoder
- type CallbackResult
- type Client
- type ExchangeRequest
- type LoginCallbacks
- type PKCEChallenge
- type Prompt
- type Provider
- type ProviderInfo
- type RefreshRequest
- type Token
- type TokenError
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrCallbackClosed = errors.New("pinoauth: callback channel closed without a result")
ErrCallbackClosed is returned by AwaitAuthCode when the callback channel from StartCallbackServer is closed before a result arrives — for example because the underlying server failed or its parent context was cancelled.
var ErrRedirectNotAllowed = errors.New("pinoauth: redirect not allowed on token endpoint")
ErrRedirectNotAllowed is returned (wrapped) when the token endpoint responds with a redirect. pinoauth's default HTTP client refuses to follow because a redirect on a token-endpoint POST would re-send the body — which carries client_secret, refresh_token, and code_verifier — to the redirect target. The error surfaces wrapped inside a *url.Error (from net/http) and then inside [TokenError.Err]; errors.Is matches through both layers.
Functions ¶
func AwaitAuthCode ¶
func AwaitAuthCode( ctx context.Context, resultCh <-chan *CallbackResult, manualInput func() (string, error), onDismissManualInput func(), ) (code, state string, err error)
AwaitAuthCode waits for the OAuth authorization code to arrive via either the loopback callback or a manual paste, whichever happens first.
resultCh is the channel returned by StartCallbackServer. manualInput, if non-nil, is invoked in a goroutine to collect a code pasted by the user (typical when the browser cannot reach the loopback address — e.g. SSH sessions). Its returned string is parsed via ParseAuthorizationInput, so any of the formats that helper accepts will work. Pass nil for manualInput to wait only for the callback.
onDismissManualInput, if non-nil, is invoked exactly once after a winner is decided so the caller can hide any visible paste prompt.
AwaitAuthCode honours ctx: if ctx is cancelled before a winner, it returns ctx.Err(). It does not close the callback server or cancel the manualInput goroutine — the caller owns those. In particular, manualInput should be ctx-aware if it might block indefinitely on user input; otherwise the goroutine may outlive the call.
AwaitAuthCode does not validate that state matches the value passed to StartCallbackServer; the loopback server already enforces that when expectedState is non-empty. For codes arriving via manualInput the caller must compare state itself.
func GenerateState ¶
func GenerateState() string
GenerateState returns a 32-byte cryptographically random value, base64url-encoded with no padding, suitable for use as the OAuth 2.0 "state" parameter (RFC 6749 §10.12).
Callers should generate a new state per authorization request and compare it byte-for-byte against the value echoed back by the authorization server (or returned by StartCallbackServer, which already enforces the comparison when given a non-empty expectedState).
GenerateState panics only if the kernel CSPRNG is unavailable, which on every supported platform indicates a host that cannot meaningfully continue.
func JSONBodyEncoder ¶
JSONBodyEncoder is a BodyEncoder that serialises parameters as a flat JSON object with application/json content type. Repeated form keys are collapsed to their first value. Provided for the small set of providers (notably Anthropic) that diverge from RFC 6749's form encoding at the token endpoint.
func ParseAuthorizationInput ¶
ParseAuthorizationInput extracts the authorization code and state from whatever the user pastes back from a browser. It accepts:
- a full callback URL (state is read from the query string);
- the "code#state" form (used by some providers' manual-entry pages);
- a bare query-string fragment containing code=…&state=…;
- a bare authorization code (state will be empty).
Shell-escape backslashes pasted from terminal output are unescaped when the input shows shell-escape "tells" (e.g. \?, \&, \=): then \\ becomes \ and \X becomes X. Backslashes in inputs without those tells are preserved, since RFC 6749 §A.11 allows '\' inside VSCHAR codes. ParseAuthorizationInput does no validation beyond extraction; callers must compare state against their expected value.
func StartCallbackServer ¶
func StartCallbackServer(ctx context.Context, route, addr, expectedState string) (server *http.Server, resultCh <-chan *CallbackResult, actualAddr string, err error)
StartCallbackServer starts a loopback HTTP server to receive an OAuth 2.0 authorization-code redirect (RFC 8252 §7.3).
route is the path to listen on (e.g. "/oauth-callback"). addr is the listener address (e.g. "127.0.0.1:0" to pick a free port). expectedState, if non-empty, is validated server-side: requests with a mismatched state receive HTTP 400 and are not delivered on the result channel.
On success it returns the running http.Server, a receive-only channel carrying at most one CallbackResult, and the resolved listener address (which differs from addr when port 0 was requested).
Lifecycle:
- The result channel is buffered (capacity 1). A successful callback sends exactly one *CallbackResult and the channel is left open; subsequent callbacks are not delivered.
- When ctx is cancelled the server is closed and the channel is closed (a closed channel yields a nil *CallbackResult, distinguishable from a real result).
- If the underlying http.Server.Serve fails for any reason other than http.ErrServerClosed — which should not happen for a healthy loopback listener owned by this package — the server goroutine panics rather than silently closing the channel.
- Callers should still defer srv.Close() (or srv.Shutdown) to release the listener if they exit before ctx is cancelled.
The returned server and channel are safe for concurrent use; the channel has a single producer (the request handler goroutine) and any number of receivers.
Example ¶
ExampleStartCallbackServer demonstrates a minimal loopback flow: spin up the callback server, build the authorization URL, and wait for the browser redirect. Here the "browser" is simulated with an http.Get so the example is fully self-contained and runnable.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"github.com/kfet/pinoauth"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pkce := pinoauth.GeneratePKCE()
state := pinoauth.GenerateState()
srv, resultCh, addr, err := pinoauth.StartCallbackServer(
ctx, "/callback", "127.0.0.1:0", state,
)
if err != nil {
panic(err)
}
defer srv.Close()
// In a real flow you'd open this URL in the user's browser.
authURL := "https://example.com/oauth/authorize?" + url.Values{
"client_id": {"YOUR_CLIENT_ID"},
"redirect_uri": {"http://" + addr + "/callback"},
"response_type": {"code"},
"code_challenge": {pkce.Challenge},
"code_challenge_method": {"S256"},
"state": {state},
}.Encode()
_ = authURL
// Simulate the browser redirect hitting the loopback server.
go func() {
resp, err := http.Get("http://" + addr + "/callback?" + url.Values{
"code": {"sample-auth-code"},
"state": {state},
}.Encode())
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}()
res := <-resultCh
fmt.Printf("code=%s state_matches=%v\n", res.Code, res.State == state)
}
Output: code=sample-auth-code state_matches=true
Types ¶
type AuthInfo ¶
type AuthInfo struct {
// URL is the authorization URL to open in a browser.
URL string
// ShortURL is an optional pre-shortened form of URL produced by
// the provider (e.g. via a public URL shortener that forwards
// click-time query params). Callers typically present it
// prominently and fall back to URL. Empty means no short form.
ShortURL string
// Instructions is human-readable guidance shown alongside URL.
Instructions string
}
AuthInfo describes a URL the user should visit to authorize.
type BodyEncoder ¶
BodyEncoder customises how token request parameters are serialised to the wire. The standard RFC 6749 encoding is application/x-www-form- urlencoded; the default (nil BodyEncoder) uses that. Providers that require a non-standard content type — e.g. JSON — can pass a custom encoder. See JSONBodyEncoder.
A non-nil err aborts the token request; the returned error is wrapped in a *TokenError.
type CallbackResult ¶
type CallbackResult struct {
// Code is the OAuth 2.0 authorization code (RFC 6749 §4.1.2).
Code string
// State is the state value echoed back by the authorization server.
// When [StartCallbackServer] is called with a non-empty expectedState,
// State is guaranteed to equal it.
State string
}
CallbackResult is the authorization data delivered by the loopback server after a successful redirect.
type Client ¶ added in v0.2.1
type Client struct {
// TokenURL is the provider's token endpoint. MUST be https:// in
// production; the library does not enforce this so tests can use
// httptest.NewServer.
TokenURL string
// ClientID is the OAuth client identifier.
ClientID string
// ClientSecret is sent as a form field when non-empty. Native apps
// (RFC 8252) typically have no secret; leave empty in that case.
ClientSecret string
// HTTPClient overrides the default http.Client. When nil, an
// internal client with a 30 s timeout and a CheckRedirect that
// refuses to follow is used (see [ErrRedirectNotAllowed]).
//
// SECURITY: a caller-supplied HTTPClient that does not configure
// CheckRedirect inherits Go's default behavior, which follows up
// to 10 redirects on POST. A redirect from the token endpoint
// would re-POST the request body — carrying client_secret,
// refresh_token, and code_verifier — to the redirect target. If
// you supply your own client (e.g. for a custom Transport),
// either set:
//
// CheckRedirect: func(*http.Request, []*http.Request) error {
// return pinoauth.ErrRedirectNotAllowed
// }
//
// or otherwise ensure the client refuses 30x on token requests.
HTTPClient *http.Client
// BodyEncoder overrides the default form encoding. See
// [JSONBodyEncoder].
BodyEncoder BodyEncoder
// Headers are added to every HTTP request issued by this Client.
// Content-Type is set by the BodyEncoder and must not be specified
// here; a caller-supplied Content-Type is dropped.
Headers http.Header
}
Client is a configured OAuth token-endpoint client. The fields hold the per-provider configuration that does not change between requests (endpoint URL, credentials, transport, encoding); per-request data flows through ExchangeRequest and RefreshRequest.
TokenURL and ClientID are required. The zero value is not useful; construct a Client as a struct literal.
Client is safe for concurrent use as long as callers do not mutate its fields after the first request.
Example ¶
ExampleClient demonstrates the full Anthropic-equivalent flow in pure pinoauth: PKCE → loopback callback → token exchange → refresh. The token endpoint is stubbed with httptest so the example is self-contained.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"time"
"github.com/kfet/pinoauth"
)
func main() {
// --- stub Anthropic-style token endpoint (JSON body, JSON response) ---
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var resp string
switch {
case strings.Contains(string(body), `"grant_type":"authorization_code"`):
resp = `{"access_token":"AT-1","refresh_token":"RT-1","expires_in":3600,"token_type":"Bearer"}`
case strings.Contains(string(body), `"grant_type":"refresh_token"`):
resp = `{"access_token":"AT-2","refresh_token":"RT-2","expires_in":3600,"token_type":"Bearer"}`
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, resp)
}))
defer tokenSrv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 1. PKCE.
pkce := pinoauth.GeneratePKCE()
// 2. Loopback callback server.
state := pinoauth.GenerateState()
srv, resultCh, addr, _ := pinoauth.StartCallbackServer(ctx, "/cb", "127.0.0.1:0", state)
defer srv.Close()
redirectURI := "http://" + addr + "/cb"
// 3. Build auth URL (would be opened in the user's browser).
authURL := "https://claude.ai/oauth/authorize?" + url.Values{
"client_id": {"CID"},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"code_challenge": {pkce.Challenge},
"code_challenge_method": {"S256"},
"state": {state},
}.Encode()
_ = authURL
// 4. Simulate the browser redirect.
go func() {
resp, _ := http.Get(redirectURI + "?code=AC&state=" + state)
if resp != nil {
resp.Body.Close()
}
}()
cb := <-resultCh
// 5. Build a Client and exchange code → tokens. Anthropic's endpoint
// takes JSON.
client := &pinoauth.Client{
TokenURL: tokenSrv.URL,
ClientID: "CID",
BodyEncoder: pinoauth.JSONBodyEncoder,
}
tok, err := client.Exchange(ctx, pinoauth.ExchangeRequest{
Code: cb.Code,
CodeVerifier: pkce.Verifier,
RedirectURI: redirectURI,
})
if err != nil {
panic(err)
}
fmt.Printf("access=%s refresh=%s expires_set=%v\n",
tok.AccessToken, tok.RefreshToken, !tok.ExpiresAt.IsZero())
// 6. Later, when about to expire, refresh — using the same Client.
if tok.ExpiresWithin(time.Hour + time.Minute) {
tok2, err := client.Refresh(ctx, pinoauth.RefreshRequest{
RefreshToken: tok.RefreshToken,
})
if err != nil {
panic(err)
}
fmt.Printf("refreshed=%s\n", tok2.AccessToken)
}
}
Output: access=AT-1 refresh=RT-1 expires_set=true refreshed=AT-2
func (*Client) Exchange ¶ added in v0.2.1
Exchange performs an authorization-code grant (RFC 6749 §4.1.3) with PKCE (RFC 7636) and returns the parsed Token. Any failure (transport, RFC 6749 §5.2 error response, body encoding, response decoding) is returned as a *TokenError — except missing required parameters, which return a plain error.
func (*Client) Refresh ¶ added in v0.2.1
Refresh performs a refresh-token grant (RFC 6749 §6) and returns the parsed Token. Errors are returned the same way as Client.Exchange: any non-validation failure surfaces as a *TokenError.
Refresh is stateless: it does not mutate any input and does not persist tokens. The caller decides when to refresh (typically using Token.ExpiresWithin) and where to store the result.
type ExchangeRequest ¶ added in v0.2.1
type ExchangeRequest struct {
// Code is the authorization code returned by the authorization
// endpoint.
Code string
// CodeVerifier is the PKCE verifier (RFC 7636).
CodeVerifier string
// RedirectURI must match the redirect_uri sent in the authorization
// request.
RedirectURI string
// Extra adds extra fields to the request body. Useful for
// provider-specific knobs (e.g. an audience parameter, or a
// non-standard "state" passthrough required by some token
// endpoints). Reserved keys that pinoauth owns —
// grant_type, client_id, client_secret, code, code_verifier,
// redirect_uri — cause [Client.Exchange] to return an error
// rather than silently overwriting; this prevents callers from
// piping untrusted input into security-critical fields.
Extra url.Values
}
ExchangeRequest is the per-call input to Client.Exchange: the data from the authorization-endpoint round-trip that varies per login attempt. All fields except Extra are required.
type LoginCallbacks ¶
type LoginCallbacks struct {
// OnAuth is called when the user should visit a URL to authorize.
OnAuth func(info AuthInfo)
// OnPrompt asks the user for text input (e.g. a code).
OnPrompt func(prompt Prompt) (string, error)
// OnProgress reports a status message during the flow.
OnProgress func(message string)
// OnManualCodeInput asks the user to paste an auth code manually.
OnManualCodeInput func() (string, error)
// OnDismissManualInput is called when the browser callback succeeds
// and any visible manual-input prompt should be hidden.
OnDismissManualInput func()
}
LoginCallbacks are UI hooks invoked during a Provider login flow. All fields are optional; a nil hook is a no-op (or returns "", nil for input hooks).
Cancellation is conveyed via the ctx argument to [Provider.Login], not through this struct.
type PKCEChallenge ¶
type PKCEChallenge struct {
// Verifier is the high-entropy code verifier (RFC 7636 §4.1),
// base64url-encoded with no padding. Send to the token endpoint
// during the code exchange.
Verifier string
// Challenge is BASE64URL(SHA256(Verifier)) (RFC 7636 §4.2). Send
// to the authorization endpoint as code_challenge with
// code_challenge_method=S256.
Challenge string
}
PKCEChallenge holds an RFC 7636 PKCE code verifier and its derived SHA-256 challenge. The zero value is not useful — obtain values via GeneratePKCE.
func GeneratePKCE ¶
func GeneratePKCE() *PKCEChallenge
GeneratePKCE returns a fresh PKCE pair: a 32-byte cryptographically random verifier (base64url-encoded, no padding) and its S256 challenge.
Callers should generate a new pair per authorization request.
GeneratePKCE panics only if the kernel CSPRNG is unavailable, which on every supported platform indicates a host that cannot meaningfully continue.
type Prompt ¶
type Prompt struct {
// Message is the prompt label shown to the user.
Message string
// Placeholder is suggested input text (UI hint only).
Placeholder string
// AllowEmpty permits the user to submit an empty response.
AllowEmpty bool
}
Prompt describes a text prompt shown to the user during login.
type Provider ¶
type Provider interface {
// ID returns a stable provider identifier (e.g. "anthropic").
ID() string
// Name returns a human-readable provider name.
Name() string
// Login runs the full OAuth login flow and returns a token to
// persist. Implementations must honour ctx for cancellation.
Login(ctx context.Context, callbacks LoginCallbacks) (*Token, error)
// UsesCallbackServer reports whether Login uses a loopback HTTP
// callback server (and thus supports manual-code-input fallback).
UsesCallbackServer() bool
// RefreshToken exchanges an expired token for a fresh one.
// Implementations must honour ctx for cancellation.
RefreshToken(ctx context.Context, tok *Token) (*Token, error)
}
Provider is a convention for assembling pinoauth's primitives into a provider-specific login flow. pinoauth itself ships no concrete providers; Provider exists as a shared shape so callers can plug different login flows behind one type.
The interface is deliberately minimal: ID/Name for display, Login for the interactive flow, RefreshToken for renewal. Anything provider- specific (API-key extraction, model listing, account IDs) belongs in the concrete type's own methods, not here.
type ProviderInfo ¶
type ProviderInfo struct {
// ID is the stable provider identifier.
ID string
// Name is the human-readable provider name.
Name string
// Available reports whether the provider is currently usable
// (e.g. config loaded, dependencies present).
Available bool
}
ProviderInfo describes an OAuth provider for display in the UI.
type RefreshRequest ¶ added in v0.2.1
type RefreshRequest struct {
// RefreshToken is the refresh token issued by a previous token
// response.
RefreshToken string
// Scope optionally narrows the granted scope on refresh
// (RFC 6749 §6 "scope"); leave empty to keep the original scope.
Scope string
// Extra adds extra fields to the request body. Reserved keys
// that pinoauth owns — grant_type, client_id, client_secret,
// refresh_token, scope — cause [Client.Refresh] to return an
// error rather than silently overwriting. See
// [ExchangeRequest.Extra].
Extra url.Values
}
RefreshRequest is the per-call input to Client.Refresh. RefreshToken is required; Scope and Extra are optional.
type Token ¶
type Token struct {
// AccessToken is the bearer access token (RFC 6749 §5.1
// "access_token"). Set when the server returned a standard token
// response; empty for providers that respond with a non-standard
// shape (e.g. an "api_key" field instead) — read [Token.Raw] in
// that case.
AccessToken string
// TokenType is the token type returned by the server, typically
// "Bearer" (RFC 6749 §5.1 "token_type").
TokenType string
// RefreshToken is the refresh token (RFC 6749 §5.1
// "refresh_token"); empty when the server did not issue one.
RefreshToken string
// ExpiresAt is the wall-clock expiry, computed at receive time as
// now + expires_in. Zero when the response omits "expires_in"; in
// that case [Token.Expired] returns false.
ExpiresAt time.Time
// Scope is the granted scope (RFC 6749 §5.1 "scope"); may be empty.
Scope string
// Raw is every top-level field of the JSON token response, decoded
// as a generic map. Use this to read provider-specific fields such
// as id_token, chatgpt_account_id, or api_key without a second
// HTTP round-trip.
Raw map[string]any
}
Token is a parsed OAuth 2.0 token response (RFC 6749 §5.1).
Token is a plain data carrier; concurrent use must be guarded by the caller. Provider-specific fields not modelled by named members (id_token, account_id, api_key, …) are preserved verbatim in [Token.Raw] so callers can extract them without a second round-trip.
type TokenError ¶
type TokenError struct {
// Code is the RFC 6749 §5.2 "error" field (e.g. "invalid_grant").
// Empty when Err is non-nil or the response body could not be
// parsed as a standard OAuth error response.
Code string
// Description is the RFC 6749 §5.2 "error_description" field.
Description string
// URI is the RFC 6749 §5.2 "error_uri" field.
URI string
// HTTPStatus is the response status code, or 0 when no response
// reached us (DNS, connection refused, body-encode failure, ctx
// cancellation before send).
HTTPStatus int
// Body is the raw response body when it could not be parsed as a
// standard OAuth error response, or when a 2xx response body
// failed to decode as JSON. Empty when Code is set.
//
// SECURITY: when a 2xx body fails to decode, Body holds the raw
// bytes the server sent — which on a malformed-but-recognisable
// response may include access_token / refresh_token material.
// Do not log Body verbatim; redact or truncate before emission.
// [TokenError.Error] never prints Body.
Body []byte
// Err is the underlying error wrapped by this TokenError —
// transport (DNS, connection refused, I/O), body encoding (a
// custom [BodyEncoder] returning an error), or response decoding
// (malformed JSON in a 2xx body). Nil when the failure is a
// well-formed RFC 6749 §5.2 error response. Use [errors.Is] /
// [errors.Unwrap] to access.
Err error
}
TokenError is a token-endpoint failure: either an RFC 6749 §5.2 error response from the server (Code/Description/URI populated) or an underlying error (transport, body encoding, response decoding) wrapped via Err. Reachable via errors.As.
func (*TokenError) Error ¶
func (e *TokenError) Error() string
Error implements the error interface.
func (*TokenError) Unwrap ¶
func (e *TokenError) Unwrap() error
Unwrap returns the wrapped underlying error (transport, encode, or decode), if any. See [TokenError.Err].