oauth

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: AGPL-3.0 Imports: 23 Imported by: 0

Documentation

Overview

Package oauth is Exhale's own OAuth 2.1 authorization server, the part of the MCP authorization spec that lets a cloud assistant (Claude.ai, ChatGPT, Grok) connect to a person's Exhale by its URL (ADR 0018 milestone 3, ADR 0024).

It is deliberately small: the authorization code grant with PKCE (S256 only), rotating refresh tokens, resource indicators (RFC 8707), two ways for an application to introduce itself (a Client ID Metadata Document, or dynamic registration, RFC 7591), revocation (RFC 7009) and opaque tokens stored as hashes. There are no JWTs and no signing keys. The HTTP endpoints live in internal/httpapi; this package holds the rules.

Index

Constants

View Source
const (
	// KindDynamic clients registered themselves (RFC 7591). Anyone can, so
	// nothing they say about themselves is verified.
	KindDynamic = "dynamic"
	// KindMetadata clients are identified by the https URL of a document
	// that Exhale fetched: who controls that address is known.
	KindMetadata = "metadata"
)

How an application introduced itself.

View Source
const (
	// AccessPrefix and RefreshPrefix mark the tokens (secret-scanning
	// friendly, like exhale_pat_).
	AccessPrefix  = "exhale_oat_"
	RefreshPrefix = "exhale_ort_"

	// AccessTTL is short because an access token cannot be recalled from
	// where it leaked to; the refresh token renews it.
	AccessTTL = time.Hour
	// RefreshTTL: a connection nobody uses for this long ends by itself.
	// Every refresh issues a new refresh token with a new lifetime.
	RefreshTTL = 90 * 24 * time.Hour
	// CodeTTL bounds the time between approving and the application
	// fetching its tokens.
	CodeTTL = 5 * time.Minute
	// RequestTTL is how long the consent screen stays valid.
	RequestTTL = 10 * time.Minute
)
View Source
const (
	ScopeRead  = "read"
	ScopeWrite = "write"
)

Scopes an application can ask for. They are the scopes of personal tokens; "trusted" is never available through OAuth.

Variables

View Source
var ErrInvalidToken = errors.New("invalid access token")

ErrInvalidToken is returned for an access token that is unknown, expired, revoked or meant for another resource.

View Source
var ErrRequestGone = errors.New("this connection request has expired or was already answered")

ErrRequestGone means the consent screen was answered already or waited too long.

Scopes is what the protected resource metadata advertises.

Functions

func Code

func Code(err error) string

Code returns the OAuth error code of err ("server_error" if it has none).

func Description

func Description(err error) string

Description returns the sentence of an OAuth error, or a generic one.

func LoopbackRedirect

func LoopbackRedirect(raw string) bool

LoopbackRedirect reports whether a redirect URI points at this computer: any program running there could be the one that receives the code.

func NormalizeResource

func NormalizeResource(raw string) (string, bool)

NormalizeResource returns the canonical form of a resource identifier (RFC 8707): lowercase scheme and host, no default port, no trailing slash. It fails for anything that is not an absolute http(s) URL without fragment.

func ScopeString

func ScopeString(s app.Scope) string

ScopeString is the OAuth scope value of a granted scope: write includes read.

Types

type App

type App struct {
	ID           string `json:"id"`
	Name         string `json:"name"`
	Verified     bool   `` /* 143-byte string literal not displayed */
	ClientHost   string `json:"clientHost,omitempty" doc:"host of the application's address, when verified"`
	RedirectHost string `json:"redirectHost" doc:"where the application received its access"`
	Scope        string `json:"scope" enum:"read,write"`
	CreatedAt    string `json:"createdAt"`
	LastUsedAt   string `json:"lastUsedAt,omitempty"`
}

App is an application a member connected.

type AuthorizeParams

type AuthorizeParams struct {
	ResponseType        string
	ClientID            string
	RedirectURI         string
	Scope               string
	State               string
	CodeChallenge       string
	CodeChallengeMethod string
	Resource            []string
}

AuthorizeParams are the query parameters of an authorization request.

type Client

type Client struct {
	ID           string
	Kind         string
	Name         string
	URI          string
	RedirectURIs []string
	// contains filtered or unexported fields
}

Client is an application that may ask for access.

func (Client) Host

func (c Client) Host() string

Host is the host of a verified client's address ("" for the others).

func (Client) Public

func (c Client) Public() bool

Public reports whether the client has no secret (PKCE alone protects its codes).

func (Client) Verified

func (c Client) Verified() bool

Verified reports whether the client's identity is an address Exhale fetched, rather than something the client said about itself.

type Error

type Error struct {
	Code        string
	Description string
}

Error is an OAuth error: Code goes to the application ("invalid_grant"), Description is a sentence for whoever debugs it.

func (*Error) Error

func (e *Error) Error() string

type Registered

type Registered struct {
	Client
	Secret     string
	AuthMethod string
	CreatedAt  time.Time
}

Registered is what a registration returns: the client, and its secret once for a confidential client.

type Registration

type Registration struct {
	RedirectURIs            []string `json:"redirect_uris"`
	ClientName              string   `json:"client_name"`
	ClientURI               string   `json:"client_uri"`
	GrantTypes              []string `json:"grant_types"`
	ResponseTypes           []string `json:"response_types"`
	TokenEndpointAuthMethod string   `json:"token_endpoint_auth_method"`
}

Registration is the body of a dynamic client registration (RFC 7591). Fields we have no use for are accepted and ignored.

type Request

type Request struct {
	ID          string
	Client      Client
	RedirectURI string
	State       string
	Challenge   string
	Resource    string
	Issuer      string
	// MaxScope is the most the application asked for.
	MaxScope app.Scope
	// contains filtered or unexported fields
}

Request is an authorization request waiting for the member's answer on the consent screen. It lives in memory: a restart only asks the person to connect again.

func (*Request) ErrorRedirect

func (r *Request) ErrorRedirect(err error) string

ErrorRedirect returns the redirect for an error from Authorize.

func (*Request) Loopback

func (r *Request) Loopback() bool

Loopback reports whether the answer goes to a program on the person's own computer, which any program running there could be.

func (*Request) RedirectHost

func (r *Request) RedirectHost() string

RedirectHost is where the browser goes after the answer: the consent screen must show it (MCP security considerations).

type Service

type Service struct {

	// HTTP fetches client metadata documents. The default refuses private
	// and local addresses and does not follow redirects.
	HTTP *http.Client
	// Now is the clock (tests move it).
	Now func() time.Time
	// contains filtered or unexported fields
}

Service implements the authorization server on top of the store.

func New

func New(st *store.Store) *Service

New returns the service. Client metadata documents are fetched through netguard: an application's URL must never reach the server's own network.

func (*Service) Access

func (s *Service) Access(ctx context.Context, plain, resource string) (app.Actor, error)

Access returns the actor of an access token presented at resource: the member who approved it, under the application's name, with the scope they chose.

func (*Service) Apps

func (s *Service) Apps(ctx context.Context, memberID string) ([]App, error)

Apps lists the applications a member has connected and that can still reach Exhale.

func (*Service) Authorize

func (s *Service) Authorize(ctx context.Context, p AuthorizeParams, issuer, resource string) (*Request, error)

Authorize checks an authorization request. With a nil request the error must be shown to the person: the client or its redirect URI is not trusted, so nothing may be sent there. With a request and an error, the error goes back to the application at its redirect URI. Without error the request waits for the consent screen.

issuer is this server's issuer identifier and resource the canonical URI of its MCP endpoint, the only resource it issues tokens for.

func (*Service) Cleanup

func (s *Service) Cleanup(ctx context.Context) error

Cleanup deletes what has expired: tokens, codes, grants without tokens and clients nobody uses. It is cheap and safe to call often.

func (*Service) Client

func (s *Service) Client(ctx context.Context, id string) (Client, error)

Client resolves a client_id as an authorization request presents it: a registered id, or the URL of a metadata document, fetched (or read from its cached copy) and checked.

func (*Service) Decide

func (s *Service) Decide(ctx context.Context, id string, member app.Actor, approve bool, scope app.Scope) (string, error)

Decide records the member's answer and returns where to send the browser: the application's redirect URI with a code, or with access_denied. scope is what the member chose; it cannot exceed what the application asked for nor what the member may do.

func (*Service) Disconnect

func (s *Service) Disconnect(ctx context.Context, memberID, grantID string) error

Disconnect ends one of a member's connections, with every token it had.

func (*Service) KnownClient

func (s *Service) KnownClient(ctx context.Context, id string) (Client, error)

KnownClient returns a client without fetching anything: the token endpoint only serves clients that went through an authorization request.

func (*Service) Pending

func (s *Service) Pending(id string) (*Request, bool)

Pending returns a request waiting for consent.

func (*Service) Register

func (s *Service) Register(ctx context.Context, in Registration) (Registered, error)

Register stores a self-registered client. A client is public (no secret, PKCE protects the code) unless it asks for client_secret_basic or client_secret_post; PKCE is required either way. When the table is full the oldest registration nobody approved makes room.

func (*Service) Revoke

func (s *Service) Revoke(ctx context.Context, clientID, secret, token string) error

Revoke answers a revocation request (RFC 7009). Revoking an access token ends that token; revoking a refresh token ends the whole connection. An unknown token is not an error: the result is the same.

func (*Service) Token

func (s *Service) Token(ctx context.Context, in TokenRequest) (Tokens, error)

Token answers a token request: the authorization code grant or the refresh token grant.

type TokenRequest

type TokenRequest struct {
	GrantType    string
	ClientID     string
	ClientSecret string
	Code         string
	RedirectURI  string
	CodeVerifier string
	RefreshToken string
	Scope        string
	Resource     []string
}

TokenRequest is the body of a token request, with the client's credentials from either the body or the Authorization header.

type Tokens

type Tokens struct {
	AccessToken  string `json:"access_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token"`
	Scope        string `json:"scope"`
}

Tokens is a successful token response.

Jump to

Keyboard shortcuts

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