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
- Variables
- func NewID() (string, error)
- type Authorization
- type BrowserPKCE
- type CodeDriver
- type CompleteInput
- type Completion
- type DeviceDriver
- type Driver
- type Flow
- type FlowStore
- type MemoryFlowStore
- func (s *MemoryFlowStore) Consume(ctx context.Context, caller core.Caller, id string) (Flow, error)
- func (s *MemoryFlowStore) Create(ctx context.Context, flow Flow) error
- func (s *MemoryFlowStore) Get(ctx context.Context, caller core.Caller, id string) (Flow, error)
- func (s *MemoryFlowStore) ResolveState(ctx context.Context, state string) (core.Caller, string, error)
- func (s *MemoryFlowStore) Update(ctx context.Context, caller core.Caller, flow Flow) (Flow, error)
- type MemoryFlowStoreOptions
- type Method
- type Options
- type Outcome
- type PollResult
- type PollStatus
- type Progress
- type Secrets
- type Service
- func (s *Service) Callback(ctx context.Context, input CompleteInput) (View, error)
- func (s *Service) Complete(ctx context.Context, caller core.Caller, id string, input CompleteInput) (View, error)
- func (s *Service) Get(ctx context.Context, caller core.Caller, id string) (View, error)
- func (s *Service) Poll(ctx context.Context, caller core.Caller, id string) (View, error)
- func (s *Service) Start(ctx context.Context, caller core.Caller, instance string, method Method, ...) (View, error)
- type StartOption
- type StartRequest
- type Status
- type View
Constants ¶
const DefaultTTL = 10 * time.Minute
DefaultTTL is how long a flow lives when its driver names no expiry.
Variables ¶
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") )
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 ¶
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.
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) Create ¶
func (s *MemoryFlowStore) Create(ctx context.Context, flow Flow) error
Create implements FlowStore. A failed Create evicts nothing.
func (*MemoryFlowStore) ResolveState ¶
func (s *MemoryFlowStore) ResolveState(ctx context.Context, state string) (core.Caller, string, error)
ResolveState 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 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 (*Service) Callback ¶
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 ¶
Get returns caller's flow. An expired flow returns ErrFlowExpired with a view whose status is StatusExpired.
func (*Service) Poll ¶
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.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package oauthflowtest is the conformance suite for oauthflow.FlowStore implementations.
|
Package oauthflowtest is the conformance suite for oauthflow.FlowStore implementations. |