pinoauth

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: May 16, 2026 License: MIT Imports: 17 Imported by: 0

README

pinoauth

CI Go Reference Go Report Card

A small, stdlib-only Go toolkit for browser-loopback OAuth 2.0 flows in CLI and desktop applications — i.e. RFC 8252 "OAuth 2.0 for Native Apps" with PKCE.

Extracted from the fir coding-agent harness, where it handles login for multiple providers.

What it is

The pieces every native-app PKCE flow needs, and nothing else:

  • PKCEGeneratePKCE() returns a 32-byte random verifier and its base64url-encoded SHA-256 challenge.
  • StateGenerateState() returns a 32-byte random base64url-encoded value suitable for the OAuth state parameter (RFC 6749 §10.12).
  • Loopback callback serverStartCallbackServer() binds a port on 127.0.0.1, listens for the redirect, validates the state parameter, renders a styled HTML success/error page, and delivers {Code, State} on a channel.
  • Pasted-code parserParseAuthorizationInput() robustly extracts code and state from whatever the user pastes back from the browser: a full callback URL, code#state (the OpenAI-style "manual entry" form), a code=…&state=… query fragment, or a bare code.
  • Callback / paste raceAwaitAuthCode() waits for either the loopback callback or a manual paste, whichever arrives first. Composes the three primitives above into the SSH-friendly fallback every native-app OAuth flow needs in practice.
  • Token endpointClient is a configured token-endpoint client with Exchange (authorization-code grant) and Refresh (refresh-token grant) methods. Both return a parsed Token (with ExpiresAt computed at receive time) and surface RFC 6749 §5.2 errors as *TokenError. Stateless: no auto-refresh, no goroutines, no storage. The provider- specific fields a non-trivial flow needs (id_token, account IDs, even non-standard top-level shapes) are preserved verbatim in Token.Raw.

Plus a Provider interface that's a convention for assembling these into a provider-specific login flow. pinoauth ships no concrete providers — those live in your code.

What it isn't

  • Not a full OAuth client/server framework.
  • Not a token store. Persistence is your problem.
  • Not for browser-based / SPA / confidential-client flows. Loopback only.
  • No TokenSource, no auto-refreshing http.Client, no background goroutines. Token is a plain value; the caller decides when to refresh (Token.ExpiresWithin(5*time.Minute)) and where to store the result.

Install

go get github.com/kfet/pinoauth

Quick start

package main

import (
    "context"
    "fmt"
    "net/url"

    "github.com/kfet/pinoauth"
)

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

    // 1. PKCE.
    pkce := pinoauth.GeneratePKCE()

    // 2. Spin up the loopback callback server.
    state := pinoauth.GenerateState()
    srv, resultCh, addr, err := pinoauth.StartCallbackServer(
        ctx, "/callback", "127.0.0.1:0", state,
    )
    if err != nil { panic(err) }
    defer srv.Shutdown(ctx)

    // 3. Build the authorization URL with redirect_uri pointing at addr.
    redirect := "http://" + addr + "/callback"
    authURL := "https://example.com/oauth/authorize?" + url.Values{
        "client_id":             {"YOUR_CLIENT_ID"},
        "redirect_uri":          {redirect},
        "response_type":         {"code"},
        "code_challenge":        {pkce.Challenge},
        "code_challenge_method": {"S256"},
        "state":                 {state},
    }.Encode()

    fmt.Println("Open in your browser:", authURL)

    // 4. Wait for the callback.
    res := <-resultCh
    fmt.Printf("Got code=%s state=%s\n", res.Code, res.State)

    // 5. Exchange res.Code + pkce.Verifier for tokens via Client.
    //    Reuse the same Client later for Refresh.
    //
    //    client := &pinoauth.Client{TokenURL: "...", ClientID: "..."}
    //    tok, err := client.Exchange(ctx, pinoauth.ExchangeRequest{
    //        Code:         res.Code,
    //        CodeVerifier: pkce.Verifier,
    //        RedirectURI:  redirect,
    //    })
}

For the manual-paste fallback (when the browser can't reach 127.0.0.1, e.g. SSH sessions), call pinoauth.ParseAuthorizationInput(pasted) on the text the user provides.

Stability

v0.x — the API is in flux but I try to avoid pointless churn. The extracted-from-fir surface has been stable for many months.

License

MIT

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:

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

Examples

Constants

This section is empty.

Variables

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

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

func JSONBodyEncoder(values url.Values) (string, []byte, error)

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

func ParseAuthorizationInput(input string) (code, state string)

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

type BodyEncoder func(values url.Values) (contentType string, body []byte, err error)

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

func (c *Client) Exchange(ctx context.Context, req ExchangeRequest) (*Token, error)

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

func (c *Client) Refresh(ctx context.Context, req RefreshRequest) (*Token, error)

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.

func (*Token) Expired

func (t *Token) Expired() bool

Expired reports whether the token's ExpiresAt is in the past. It returns false when ExpiresAt is the zero value (server did not provide an expiry).

func (*Token) ExpiresWithin

func (t *Token) ExpiresWithin(d time.Duration) bool

ExpiresWithin reports whether the token's ExpiresAt is within d of now (i.e. about to expire or already expired). Returns false when ExpiresAt is the zero value.

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].

Jump to

Keyboard shortcuts

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