oauthflow

package
v1.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 26, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package oauthflow runs server-side OAuth flows for products that sign users in to LLM providers: browser authorization codes with PKCE, device authorization, and codes the owner pastes back.

A FlowStore keeps each flow bound to the core.Caller that started it and consumes it at most once. MemoryFlowStore is the in-memory reference, and can cap each caller's pending flows as the gateway does; run oauthflowtest.Run against every implementation. A Service runs the flows over a store, provider drivers and a core.CredentialStore:

  • Start begins a flow and returns its View.
  • Get reads it.
  • Poll advances a device flow, never faster than its interval.
  • Complete finishes a browser or manual flow with a code the owner pasted, or one from a callback the product routed itself.
  • Callback finishes a browser flow from the provider's redirect, found by its OAuth state because the redirect carries no session.

Verifiers, OAuth states, device codes, redirect URIs and tokens stay on the server. A View never holds them, and the state appears only inside the authorization URL shown to the owner. Completion saves the credential through core.CredentialStore under a key the product chooses.

Gateway routes

The gateway's routes in go/internal/api/oauth.go map onto the Service without changing their requests or responses. The caller is the principal the route acts for: the SSO user, or the principal an administrator's route names.

start     POST .../connections/{provider_id}/oauth/start
          Start(ctx, caller, providerID, method,
              WithRedirectURI(oauthCallbackURL(r, registryID)),
              WithParams(connection name, source, client settings))
          device_code: MethodDevice (Codex, Copilot)
          browser: MethodBrowser (Antigravity), MethodManual (Codex)
          profile consumer_manual: MethodManual (Antigravity)
          flow_id and device_code both carry View.ID
get       POST .../oauth/poll with a browser flow_id
          Get: pending, or authorized once complete
poll      POST .../oauth/poll with device_code
          Poll: ErrSlowDown is slow_down, ErrAccessDenied is denied,
          ErrFlowExpired and ErrFlowNotFound are expired
complete  POST .../oauth/complete {flow_id, authorization_response}
          Complete(ctx, caller, flowID, CompleteInput{Code, State}),
          after manualAuthorizationCode splits the pasted value;
          ErrStateMismatch leaves the flow pending for another paste
callback  GET /oauth/callback/{provider_id}?code&state
          Callback(ctx, CompleteInput{Code, State, Error,
              RedirectURI: oauthCallbackURL(r, provider_id)})

A complete View carries CredentialKey, from which the gateway loads the connection it returns. Refresh and revoke are not flows and stay on the credential store.

Facet Studio routes

Facet Studio's routes in web/backend/api/oauth.go map the same way with core.LocalCaller(). Its statuses are pending, success (StatusComplete), error (StatusFailed) and expired.

start     POST /api/oauth/login {provider, method}
          Start(ctx, core.LocalCaller(), provider, MethodDevice or
              MethodBrowser, WithRedirectURI(buildOAuthRedirectURI(r)))
          api_key and setup_token logins save directly; they are not flows
get       GET /api/oauth/flows/{id}: Get
poll      POST /api/oauth/flows/{id}/poll: Poll; ErrSlowDown stays pending
complete  POST /api/oauth/flows/{id}/complete {code}
          Complete(ctx, core.LocalCaller(), id, CompleteInput{Code, State})
callback  GET /oauth/callback?state&code&error
          Callback(ctx, CompleteInput{Code, State, Error})

Drivers

A product configures one driver per instance and method. BrowserPKCE serves any provider that llm-provider-auth's browseroauth can drive. Codex's device and code flows, the Copilot device flow and Antigravity's browser flow wrap their llm-provider-auth packages in a few lines each: the driver keeps what it needs later, such as a client id or a user code, in Secrets.DriverData.

Index

Constants

View Source
const DefaultTTL = 10 * time.Minute

DefaultTTL is how long a flow lives when its driver names no expiry.

Variables

View Source
var (
	// ErrFlowNotFound reports a flow that does not exist, was already
	// consumed, or belongs to another caller. The three are deliberately
	// indistinguishable, so a flow id never reveals that someone else's flow
	// exists.
	ErrFlowNotFound = errors.New("oauthflow: flow not found")
	// ErrFlowExpired reports a flow past its expiry. Only its owner sees it.
	ErrFlowExpired = errors.New("oauthflow: flow expired")
	// ErrFlowExists reports a Create whose id is already stored.
	ErrFlowExists = errors.New("oauthflow: flow id already exists")
	// ErrConflict reports an Update whose revision is no longer current.
	ErrConflict = errors.New("oauthflow: flow changed concurrently")
)
View Source
var (
	// ErrSlowDown reports a device poll that came before the interval
	// elapsed, or a provider's slow_down. The flow is still pending; poll
	// again after View.Interval seconds.
	ErrSlowDown = errors.New("oauthflow: poll again after the interval")
	// ErrAccessDenied reports that the owner or the provider refused the
	// authorization. The flow has ended.
	ErrAccessDenied = errors.New("oauthflow: authorization was denied")
	// ErrStateMismatch reports a completion whose OAuth state is not the
	// flow's. Nothing is spent: the flow stays pending and its owner may
	// retry with the right redirect URL or code.
	ErrStateMismatch = errors.New("oauthflow: OAuth state does not match the flow")
	// ErrWrongMethod reports an operation the flow's method does not offer,
	// such as polling a browser flow.
	ErrWrongMethod = errors.New("oauthflow: the flow's method does not offer this operation")
	// ErrCodeRequired reports a completion that carries neither a code nor
	// a provider error. The flow is untouched.
	ErrCodeRequired = errors.New("oauthflow: authorization code is required")
)

Functions

func NewID

func NewID() (string, error)

NewID returns an unguessable flow id: 32 bytes from crypto/rand.

Types

type Authorization

type Authorization struct {
	// AuthorizationURL is where a browser or manual flow sends the owner.
	AuthorizationURL string
	// UserCode, VerificationURI and VerificationURIComplete are what a
	// device flow shows the owner.
	UserCode                string
	VerificationURI         string
	VerificationURIComplete string
	// Interval is the provider's minimum polling interval.
	Interval time.Duration
	// ExpiresIn is how long the attempt stays valid; zero uses the
	// Service's default.
	ExpiresIn time.Duration
	// Secrets are kept on the server. The Service fills Params itself.
	Secrets Secrets
}

Authorization is a started attempt: what the owner is shown, and the secrets the server keeps.

type BrowserPKCE

type BrowserPKCE struct {
	// Config names the provider's endpoints, client and scopes.
	Config browseroauth.Config
	// RedirectURI is used when the start request names none, such as the
	// registered loopback redirect of a manual flow.
	RedirectURI string
	// TTL is how long an attempt stays valid; zero uses DefaultTTL.
	TTL time.Duration
	// Record converts the token response. Nil copies its tokens and expiry;
	// a product sets it to add the account or project its provider reports.
	Record func(ctx context.Context, tokens browseroauth.TokenEnvelope) (tokenstore.Record, error)
}

BrowserPKCE is a ready driver for a generic authorization-code flow with PKCE S256, built on llm-provider-auth's browseroauth. It serves MethodBrowser and MethodManual: browseroauth generates the state and verifier, and the Service keeps both on the server.

func (BrowserPKCE) Exchange

func (d BrowserPKCE) Exchange(ctx context.Context, flow Flow, code string) (tokenstore.Record, error)

Exchange implements CodeDriver. browseroauth's errors carry only the endpoint's sanitized error code and description.

func (BrowserPKCE) Start

func (d BrowserPKCE) Start(_ context.Context, request StartRequest) (Authorization, error)

Start implements Driver.

type CodeDriver

type CodeDriver interface {
	Driver
	Exchange(ctx context.Context, flow Flow, code string) (tokenstore.Record, error)
}

CodeDriver exchanges the authorization code of a browser or manual flow, using the flow's verifier, redirect URI and driver data.

type CompleteInput

type CompleteInput struct {
	// Code is the authorization code.
	Code string
	// State is the OAuth state that came back with the code. A callback
	// always carries it and a pasted redirect URL does too; a pasted bare
	// code has none. When set, it must match the flow's.
	State string
	// Error is the provider's error code, such as access_denied, when the
	// owner refused. It ends the flow.
	Error string
	// RedirectURI is where a callback arrived. Callback refuses, without
	// consuming the flow, a callback that arrived anywhere but the flow's
	// redirect URI.
	RedirectURI string
}

CompleteInput is what came back from the provider: a browser callback's query parameters, or what the owner pasted.

type Completion

type Completion struct {
	Caller   core.Caller
	Instance string
	Method   Method
	Params   map[string]string
	Record   tokenstore.Record
}

Completion is what the product's key hook sees when a flow yields a credential. Params are the flow's start inputs, such as a connection name.

type DeviceDriver

type DeviceDriver interface {
	Driver
	// Poll asks the provider once whether the owner approved flow. An error
	// is transient: the flow stays pending and may be polled again.
	Poll(ctx context.Context, flow Flow) (PollResult, error)
}

DeviceDriver polls a device authorization.

type Driver

type Driver interface {
	// Start begins one authorization attempt with the provider.
	Start(ctx context.Context, request StartRequest) (Authorization, error)
}

Driver runs one provider's side of one method. A product registers drivers through Options.Drivers; each wraps a protocol package such as llm-provider-auth's browseroauth, codex or copilot.

A device driver also implements DeviceDriver, and a browser or manual driver implements CodeDriver. Errors a driver returns reach the product, so they must never contain token material.

type Flow

type Flow struct {
	ID         string
	Caller     core.Caller
	Instance   string
	Method     Method
	CreatedAt  time.Time
	ExpiresAt  time.Time
	Revision   string
	ConsumedAt time.Time

	// What the owner is shown. The OAuth state appears only inside
	// AuthorizationURL, which only the owner receives.
	AuthorizationURL        string
	UserCode                string
	VerificationURI         string
	VerificationURIComplete string

	// Progress is the only part Update may change.
	Progress Progress

	Secrets Secrets
}

Flow is one authorization attempt.

The store assigns Revision on every write and sets ConsumedAt when the flow is consumed. Secrets never leave the server; a consumed flow keeps none. String, GoString and LogValue omit them, so a logged flow leaks nothing.

func (Flow) Clone

func (f Flow) Clone() Flow

Clone returns a deep copy, so stores and callers never share maps.

func (Flow) Consumed

func (f Flow) Consumed() bool

Consumed reports whether the flow was consumed.

func (Flow) Expired

func (f Flow) Expired(now time.Time) bool

Expired reports whether the flow is past its expiry at now.

func (Flow) GoString

func (f Flow) GoString() string

GoString keeps %#v from printing secrets.

func (Flow) LogValue

func (f Flow) LogValue() slog.Value

LogValue keeps structured logging from printing secrets.

func (Flow) String

func (f Flow) String() string

String describes the flow without secrets.

type FlowStore

type FlowStore interface {
	// Create stores a new flow and assigns its revision. A flow whose id is
	// already stored fails with ErrFlowExists.
	Create(ctx context.Context, flow Flow) error
	// Get returns caller's flow: ErrFlowNotFound for an unknown flow or
	// another caller's, and ErrFlowExpired once it has expired. A consumed
	// flow is still returned, without secrets, until it expires, so its
	// owner can read the outcome.
	Get(ctx context.Context, caller core.Caller, id string) (Flow, error)
	// Consume atomically ends caller's pending flow and returns it with its
	// secrets. Of any number of concurrent calls exactly one succeeds;
	// every later call gets ErrFlowNotFound. An expired flow gives
	// ErrFlowExpired and can never be consumed. The stored flow keeps no
	// secrets afterwards.
	Consume(ctx context.Context, caller core.Caller, id string) (Flow, error)
	// Update stores flow.Progress, and nothing else, if the stored revision
	// still equals flow.Revision, and returns the flow with its new
	// revision; otherwise it fails with ErrConflict.
	//
	// Device polling needs it: a provider's slow_down lengthens the interval
	// for the rest of the flow, and a poller claims the next poll by moving
	// NextPollAt forward, so concurrent polls, from any process, never reach
	// the provider together. It also records the outcome of a consumed flow.
	Update(ctx context.Context, caller core.Caller, flow Flow) (Flow, error)
	// ResolveState returns the owner and id of the unconsumed flow whose
	// OAuth state is state, or ErrFlowNotFound, or ErrFlowExpired.
	//
	// A provider's redirect carries no session, so a browser callback can
	// name no caller. The state stands in for one: it is unguessable and only
	// ever appeared inside the owner's authorization URL. Implementations
	// should index a hash of the state rather than the state itself.
	ResolveState(ctx context.Context, state string) (owner core.Caller, id string, err error)
}

FlowStore keeps flows where every process serving the product can reach them. Implementations must be safe for concurrent use; the oauthflowtest package verifies the guarantees below.

Every operation that names a caller compares the whole core.Caller (ID, Kind and ProjectID) with the flow's owner and answers ErrFlowNotFound on a mismatch, before it looks at expiry, so another caller learns nothing. Returned flows are copies.

type MemoryFlowStore

type MemoryFlowStore struct {
	// contains filtered or unexported fields
}

MemoryFlowStore is the in-memory reference FlowStore, for tests and single-process products. It purges expired flows lazily, when flows are created or looked up, and needs no background goroutine.

func NewMemoryFlowStore

func NewMemoryFlowStore(options MemoryFlowStoreOptions) *MemoryFlowStore

NewMemoryFlowStore returns an empty store.

func (*MemoryFlowStore) Consume

func (s *MemoryFlowStore) Consume(ctx context.Context, caller core.Caller, id string) (Flow, error)

Consume implements FlowStore.

func (*MemoryFlowStore) Create

func (s *MemoryFlowStore) Create(ctx context.Context, flow Flow) error

Create implements FlowStore. A failed Create evicts nothing.

func (*MemoryFlowStore) Get

func (s *MemoryFlowStore) Get(ctx context.Context, caller core.Caller, id string) (Flow, error)

Get implements FlowStore.

func (*MemoryFlowStore) ResolveState

func (s *MemoryFlowStore) ResolveState(ctx context.Context, state string) (core.Caller, string, error)

ResolveState implements FlowStore.

func (*MemoryFlowStore) Update

func (s *MemoryFlowStore) Update(ctx context.Context, caller core.Caller, flow Flow) (Flow, error)

Update implements FlowStore.

type MemoryFlowStoreOptions

type MemoryFlowStoreOptions struct {
	// Now is the clock; nil means time.Now.
	Now func() time.Time
	// MaxFlowsPerCaller caps a caller's pending flows, those neither
	// consumed nor expired. Creating one more evicts that caller's oldest
	// pending flow, by CreatedAt and then insertion order, as the gateway
	// does; an evicted flow answers ErrFlowNotFound. Zero means no cap.
	MaxFlowsPerCaller int
	// ExpiredRetention is how long an expired flow is kept, without its
	// secrets, so its owner is told it expired rather than that it never
	// existed. Zero means 15 minutes.
	ExpiredRetention time.Duration
}

MemoryFlowStoreOptions configure a MemoryFlowStore. The zero value is a store with the real clock, no cap and the default retention.

type Method

type Method string

Method is how the owner authorizes a flow.

const (
	// MethodBrowser is an authorization-code grant with PKCE whose redirect
	// the product serves: the provider sends the browser back with the code.
	MethodBrowser Method = "browser"
	// MethodDevice is an RFC 8628 device authorization grant, completed by
	// polling while the owner enters a user code elsewhere.
	MethodDevice Method = "device"
	// MethodManual is an authorization-code grant with PKCE whose redirect
	// the product does not serve, so the owner pastes the code or the whole
	// redirect URL back.
	MethodManual Method = "manual"
)

type Options

type Options struct {
	Store       FlowStore
	Credentials core.CredentialStore
	// Drivers returns the driver that runs method for instance, or an
	// error when the instance does not offer it. It is consulted on every
	// operation, so a product's settings may change between them.
	Drivers func(instance string, method Method) (Driver, error)
	// CredentialKey chooses the key a completed flow's credential is saved
	// under: which credential serves whom is product policy.
	CredentialKey func(ctx context.Context, completion Completion) (string, error)
	// Now is the clock; nil means time.Now. It must match the store's.
	Now func() time.Time
}

Options configure a Service. Store, Credentials, Drivers and CredentialKey are required.

type Outcome

type Outcome string

Outcome records how a consumed flow ended.

const (
	// OutcomeComplete means the credential was saved.
	OutcomeComplete Outcome = "complete"
	// OutcomeFailed means the flow ended without a credential.
	OutcomeFailed Outcome = "failed"
	// OutcomeExpired means the provider expired the flow first.
	OutcomeExpired Outcome = "expired"
)

type PollResult

type PollResult struct {
	Status PollStatus
	// Record is the credential when Status is PollApproved.
	Record tokenstore.Record
}

PollResult is the outcome of one device poll.

type PollStatus

type PollStatus string

PollStatus is a device provider's answer to one poll.

const (
	// PollPending means the owner has not approved yet.
	PollPending PollStatus = "pending"
	// PollSlowDown means the provider asked for a longer interval.
	PollSlowDown PollStatus = "slow_down"
	// PollApproved means Record holds the new credential.
	PollApproved PollStatus = "approved"
	// PollDenied means the owner or provider refused; the flow ends.
	PollDenied PollStatus = "denied"
	// PollExpired means the provider expired the device code; the flow ends.
	PollExpired PollStatus = "expired"
)

type Progress

type Progress struct {
	// Interval is the minimum wait between device polls. A provider's
	// slow_down lengthens it.
	Interval time.Duration
	// NextPollAt is the earliest time the provider may be polled again.
	NextPollAt time.Time
	// Outcome is empty until a consumed flow is finished.
	Outcome Outcome
	// CredentialKey is the key the credential was saved under.
	CredentialKey string
}

Progress is what changes after Create: the device polling schedule while a flow is pending, and the outcome once it is consumed.

type Secrets

type Secrets struct {
	Verifier    string
	State       string
	DeviceCode  string
	RedirectURI string
	// DriverData is opaque provider-specific state the driver needs later.
	DriverData map[string]string
	// Params are the product's start inputs, which may include client
	// secrets.
	Params map[string]string
}

Secrets are the server-only values of a flow.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service runs OAuth flows for a product. The product keeps its HTTP routes and maps each onto one Service call; see the package documentation.

func New

func New(options Options) (*Service, error)

New validates options and returns a Service.

func (*Service) Callback

func (s *Service) Callback(ctx context.Context, input CompleteInput) (View, error)

Callback finishes the browser flow a provider redirect names by its OAuth state. It is the one operation without a caller: the redirect carries no session, and the state, which only ever appeared inside its owner's authorization URL, identifies the flow and its owner instead.

func (*Service) Complete

func (s *Service) Complete(ctx context.Context, caller core.Caller, id string, input CompleteInput) (View, error)

Complete finishes caller's browser or manual flow with a pasted code, a pasted redirect URL, or a callback the product routed itself.

Two inputs leave the flow pending, so the owner can paste again: one with neither a code nor an error, and one whose state is not the flow's (ErrStateMismatch). Anything else consumes the flow before the exchange, so the code is used at most once even when the exchange fails: a denial or a failed exchange ends the flow, and the owner starts again.

func (*Service) Get

func (s *Service) Get(ctx context.Context, caller core.Caller, id string) (View, error)

Get returns caller's flow. An expired flow returns ErrFlowExpired with a view whose status is StatusExpired.

func (*Service) Poll

func (s *Service) Poll(ctx context.Context, caller core.Caller, id string) (View, error)

Poll asks the provider once whether the owner approved caller's device flow, and never more often than the flow's interval.

  • Pending returns the pending view. A poll before the interval elapsed, or a provider's slow_down, returns it with ErrSlowDown; slow_down lengthens the interval for the rest of the flow.
  • Approval consumes the flow, saves the credential and returns the complete view.
  • Denial or the provider's expiry consumes the flow and returns ErrAccessDenied or ErrFlowExpired with the ended view.
  • A driver error is transient: the flow stays pending.

A flow that already ended returns its view and no error.

func (*Service) Start

func (s *Service) Start(ctx context.Context, caller core.Caller, instance string, method Method, options ...StartOption) (View, error)

Start begins a flow for caller on instance and stores it. The returned view shows the owner where to authorize; the flow's secrets stay in the store.

type StartOption

type StartOption func(*StartRequest)

StartOption adds a product input to Start.

func WithParams

func WithParams(params map[string]string) StartOption

WithParams passes start inputs to the driver and, at completion, to the key hook. They stay on the server and may hold secrets.

func WithRedirectURI

func WithRedirectURI(redirectURI string) StartOption

WithRedirectURI sets the product's callback for a browser flow.

type StartRequest

type StartRequest struct {
	Caller   core.Caller
	Instance string
	Method   Method
	// RedirectURI is the product's callback for this request, when it has
	// one; both products derive it from the request's origin.
	RedirectURI string
	// Params are the product's start inputs, such as an administrator's
	// OAuth client settings. A driver copies what it needs later into
	// Authorization.Secrets.DriverData.
	Params map[string]string
}

StartRequest is what a driver needs to begin.

type Status

type Status string

Status is where a flow stands, as its owner sees it.

const (
	// StatusPending means the flow awaits the owner, or its completion is
	// in progress.
	StatusPending Status = "pending"
	// StatusComplete means the credential was saved.
	StatusComplete Status = "complete"
	// StatusFailed means the flow ended without a credential.
	StatusFailed Status = "failed"
	// StatusExpired means the flow expired before it completed.
	StatusExpired Status = "expired"
)

type View

type View struct {
	ID       string `json:"id"`
	Instance string `json:"instance"`
	Method   Method `json:"method"`
	Status   Status `json:"status"`

	AuthorizationURL        string `json:"authorization_url,omitempty"`
	UserCode                string `json:"user_code,omitempty"`
	VerificationURI         string `json:"verification_uri,omitempty"`
	VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
	// Interval is the device polling interval in seconds.
	Interval  int       `json:"interval,omitempty"`
	ExpiresAt time.Time `json:"expires_at"`

	// CredentialKey names the saved credential once the flow is complete,
	// so a product can show the connection it created. It is a storage key,
	// not a secret.
	CredentialKey string `json:"credential_key,omitempty"`
}

View is the public projection of a flow, the only one Start, Get, Poll, Complete and Callback return. It never holds the PKCE verifier, the device code, the redirect URI, driver data, start parameters or any token. The OAuth state appears only inside AuthorizationURL, which a pending browser or manual flow shows its owner.

Directories

Path Synopsis
Package oauthflowtest is the conformance suite for oauthflow.FlowStore implementations.
Package oauthflowtest is the conformance suite for oauthflow.FlowStore implementations.

Jump to

Keyboard shortcuts

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