oauth2provider

package
v1.9.1 Latest Latest
Warning

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

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 63 Imported by: 0

README

oauth2provider

Turns Authsome into an OAuth2 authorization server and OpenID Connect provider.

Everything else in this repo helps you consume identity. This plugin makes you the source of it. Third-party apps register as clients, your users approve them, and those apps get tokens scoped to what they were granted. If you have ever added a "Sign in with GitHub" button, this is the plugin that lets someone add "Sign in with your product" to theirs.

Supported grants: authorization code with PKCE (RFC 7636), client credentials, and device authorization for TVs and CLIs that cannot host a browser.

Use it when

  • You are building a platform and third parties need to act on behalf of your users
  • You want first-party mobile and CLI apps to authenticate through a standard flow with short-lived tokens
  • Something in your stack speaks OIDC and expects a discovery document

Skip it when

  • You only need machine-to-machine calls with no delegation. apikey is far less work
  • All your clients are first-party and trusted. Sessions already cover that
  • You cannot commit to a stable issuer URL. It is baked into every token you sign

Wiring

authsome.WithPlugin(oauth2provider.New(oauth2provider.Config{
    Issuer:          "https://auth.example.com",
    AccessTokenTTL:  1 * time.Hour,
    AuthCodeTTL:     10 * time.Minute,
}))

Point clients at /v1/oauth/.well-known/openid-configuration and most libraries configure themselves from there.

Config

Field Type Default What it does
Issuer string none Issuer URL stamped into tokens and discovery
AuthCodeTTL time.Duration 10m Lifetime of an authorization code
AccessTokenTTL time.Duration 1h Lifetime of an access token
DeviceCodeTTL time.Duration 10m Lifetime of a device code
DeviceCodeInterval int 5 Minimum client polling interval, in seconds
VerificationURI string {issuer}/v1/oauth/device Where users go to approve a device

Endpoints

Method Path Purpose
GET /v1/oauth/authorize Authorization endpoint
POST /v1/oauth/token Token endpoint
POST /v1/oauth/revoke Token revocation. The caller is authenticated
GET /v1/oauth/userinfo OIDC UserInfo
GET /v1/oauth/.well-known/openid-configuration Discovery document
POST /v1/oauth/device/authorize Start the device flow
POST /v1/oauth/device/complete Complete the device flow
GET /v1/admin/oauth/clients List registered clients
POST /v1/admin/oauth/clients Register a client
DELETE /v1/admin/oauth/clients/:clientId Delete a client

Lifecycle hooks

None. It is a RouteProvider and a MigrationProvider for its client, code and token tables.

consent records what a user agreed to. social and sso are the consuming side, where you are the client rather than the server.

Documentation

Overview

admin_clients.go: the admin surface for editing an OAuth2 client that already exists. Creation, listing and deletion live in plugin.go; this file holds the update path so plugin.go does not grow another handler.

This is deliberately separate from the RFC 7592 management routes in register.go. Those authenticate the *client* with its registration access token and refuse anything that is not DynamicallyRegistered, which is every admin-created client. The two surfaces have disjoint callers and neither can stand in for the other.

clients_service.go: the client and signing key admin logic as plain methods.

The HTTP handlers take a forge.Context, which the dashboard's contract handlers do not have. The logic lives here instead, on a plain context.Context, and the handlers are thin wrappers that resolve the caller's app and map the errors. Every method takes the app it acts for and treats a client of any other app as not found, so the tenancy rule is enforced here rather than left to each caller.

contract.go: Wires the oauth2provider plugin into the forge-dashboard contract surface via plugin.ContractContributor.

Package oauth2provider implements an OAuth2 authorization server plugin for AuthSome. It supports Authorization Code + PKCE (RFC 7636), Client Credentials grants, token revocation, OIDC userinfo, and OpenID Connect discovery.

Index

Constants

View Source
const (
	DeviceCodeStatusPending    = "pending"
	DeviceCodeStatusAuthorized = "authorized"
	DeviceCodeStatusDenied     = "denied"
	DeviceCodeStatusConsumed   = "consumed"
)

Device code status constants.

Variables

View Source
var (
	ErrClientNotFound     = errors.New("oauth2: client not found")
	ErrCodeNotFound       = errors.New("oauth2: authorization code not found")
	ErrDeviceCodeNotFound = errors.New("oauth2: device code not found")
	ErrGrantNotFound      = errors.New("oauth2: grant not found")
)

Store errors.

View Source
var ErrKeyTooNew = errors.New("oauth2: the current signing key is too new to rotate")

ErrKeyTooNew is returned when a manual rotation is asked for while the active key is younger than manualRotationMinAge.

View Source
var ErrOperatorKey = errors.New("oauth2: the signing key for this issuer is managed by configuration")

ErrOperatorKey is returned when a rotation is asked of an issuer whose key comes from Config. Rotating it means changing the configuration.

View Source
var ErrSharedKey = errors.New("oauth2: the deployment issuer's signing key is shared by every app")

ErrSharedKey is returned when the dashboard asks to rotate the deployment issuer's managed key. Every app that has not opted into its own issuer signs with it, so one app's operator must not rotate it; it rotates on IDTokenKeyRotation.

View Source
var ErrSigningKeyConflict = errors.New("oauth2: signing key generation already exists")

ErrSigningKeyConflict is returned by CreateSigningKey when a key with the same (Scope, Generation) already exists.

View Source
var MongoMigrations = migrate.NewGroup("authsome-oauth2", migrate.DependsOn("authsome"))

MongoMigrations is the MongoDB migration group for the OAuth2 plugin. The only thing it creates is the signing key uniqueness index: mongo makes collections on first write, and the unique index is what makes concurrent CreateSigningKey calls for one (scope, generation) pick a single winner.

View Source
var PostgresMigrations = migrate.NewGroup("authsome-oauth2", migrate.DependsOn("authsome"))

PostgresMigrations is the postgres migration group for the OAuth2 provider plugin.

View Source
var SettingAppIssuer = settings.Define("oauth2.app_issuer", false,
	settings.WithScopes(settings.ScopeApp),
	settings.WithDisplayName("Per-app OIDC issuer"),
	settings.WithDescription("Give this app its own issuer at {issuer}/apps/{slug}. Turning this on or off, or renaming the app's slug, changes the issuer for every OAuth2 client of this app, and they will reject tokens until they are reconfigured."),
	settings.WithCategory("OpenID Connect"),
)

SettingAppIssuer lets an app have its own OpenID Connect issuer.

View Source
var SqliteMigrations = migrate.NewGroup("authsome-oauth2", migrate.DependsOn("authsome"))

SqliteMigrations is the SQLite migration group for the OAuth2 provider plugin.

Functions

This section is empty.

Types

type AppSlugRequest added in v1.9.1

type AppSlugRequest struct {
	Slug string `path:"slug"`
}

AppSlugRequest addresses one app's issuer by its slug.

type AuthServerMetadata

type AuthServerMetadata struct {
	Issuer                            string   `json:"issuer"`
	AuthorizationEndpoint             string   `json:"authorization_endpoint"`
	TokenEndpoint                     string   `json:"token_endpoint"`
	UserinfoEndpoint                  string   `json:"userinfo_endpoint"`
	RevocationEndpoint                string   `json:"revocation_endpoint"`
	DeviceAuthorizationEndpoint       string   `json:"device_authorization_endpoint"`
	RegistrationEndpoint              string   `json:"registration_endpoint,omitempty"`
	JWKSURI                           string   `json:"jwks_uri"`
	ResponseTypesSupported            []string `json:"response_types_supported"`
	GrantTypesSupported               []string `json:"grant_types_supported"`
	SubjectTypesSupported             []string `json:"subject_types_supported"`
	IDTokenSigningAlgValuesSupported  []string `json:"id_token_signing_alg_values_supported"`
	ScopesSupported                   []string `json:"scopes_supported"`
	TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
	CodeChallengeMethodsSupported     []string `json:"code_challenge_methods_supported"`

	// ResourceIndicatorsSupported advertises RFC 8707.
	//
	// This name is not registered. RFC 8707 registers the `resource`
	// parameter and the `invalid_target` error and defines no discovery
	// metadata at all, and the RFC 8414 IANA registry has no entry for it.
	// It is the convention that came out of the MCP ecosystem and it is what
	// clients look for, so do not read it as standardised.
	ResourceIndicatorsSupported bool `json:"resource_indicators_supported"`

	// DPoPSigningAlgValuesSupported lists the JWS algorithms accepted in DPoP
	// proofs (RFC 9449 section 5.1). Advertised unconditionally: this document
	// is not app scoped, and a server that can validate ES256 proofs can do so
	// whoever is asking. Per-client mode is discovered at registration.
	DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"`

	ClaimsSupported        []string `json:"claims_supported"`
	ResponseModesSupported []string `json:"response_modes_supported"`
}

AuthServerMetadata is the RFC 8414 authorization server metadata document. It carries every field the OIDC discovery document does, plus the RFC 7591 registration endpoint. handleDiscovery hand-maps buildAuthServerMetadata's output onto the OIDC DiscoveryResponse struct field by field; the two are not one shared type, so TestMetadata_OIDCAndAuthServerAgree compares the full set of JSON keys both documents actually advertise, to catch a field added to one and not the other.

type AuthorizationCode

type AuthorizationCode struct {
	ID          id.AuthCodeID `json:"id"`
	Code        string        `json:"-"`
	ClientID    string        `json:"client_id"`
	UserID      id.UserID     `json:"user_id"`
	AppID       id.AppID      `json:"app_id"`
	RedirectURI string        `json:"redirect_uri"`
	Scopes      []string      `json:"scopes"`
	// Resources is the audience the user authorized this code for. The token
	// endpoint may narrow it further but never widen it.
	Resources           []string  `json:"resources"`
	CodeChallenge       string    `json:"-"` // PKCE
	CodeChallengeMethod string    `json:"-"` // "S256" or "plain"
	ExpiresAt           time.Time `json:"expires_at"`
	Consumed            bool      `json:"consumed"`
	CreatedAt           time.Time `json:"created_at"`
	// Nonce is the client's OpenID Connect nonce from the authorization
	// request, empty when it sent none. It is echoed into the id_token.
	Nonce string `json:"-"`
	// AuthTime is when the user authenticated: the creation time of the
	// session that approved the request. Zero on codes minted before this
	// field existed.
	AuthTime time.Time `json:"-"`
}

AuthorizationCode represents a short-lived authorization code.

type AuthorizeRequest

type AuthorizeRequest struct {
	ResponseType string `query:"response_type"`
	ClientID     string `query:"client_id"`
	// Optional per RFC 6749 §4.1.1 — when omitted the client must have exactly
	// one registered URI, which resolveRedirectURI selects. Marking it
	// required here rejected such requests at the binder, before the handler
	// could apply that rule.
	RedirectURI         string `query:"redirect_uri,omitempty"`
	Scope               string `query:"scope,omitempty"`
	State               string `query:"state,omitempty"`
	CodeChallenge       string `query:"code_challenge,omitempty"`
	CodeChallengeMethod string `query:"code_challenge_method,omitempty"`
	// RFC 8707, repeatable. A field rather than a raw-request read, because
	// only a declared field reaches the OpenAPI document, and only a described
	// parameter reaches the generated clients.
	Resource []string `query:"resource,omitempty"`
	// Prompt=consent forces the consent page even when a grant covers the
	// request (OpenID Connect Core 3.1.2.1).
	//
	// It stays last. The generated clients take query parameters positionally
	// in field order, so a field added above Resource moves it, and a caller
	// passing resources by position would hand them to this one instead.
	Prompt string `query:"prompt,omitempty"`
	// Nonce is the OpenID Connect replay guard (Core 3.1.2.1), echoed into the
	// id_token. At most maxNonceBytes. It stays last for the same reason
	// Prompt did: generated clients pass query parameters positionally.
	Nonce string `query:"nonce,omitempty"`
}

AuthorizeRequest is the OAuth2 authorization request.

type Config

type Config struct {
	// Issuer is the OAuth2 issuer URL (e.g. "https://auth.example.com").
	Issuer string

	// AuthCodeTTL is the lifetime of authorization codes (default: 10 minutes).
	AuthCodeTTL time.Duration

	// AccessTokenTTL is the lifetime of access tokens (default: 1 hour).
	AccessTokenTTL time.Duration

	// DeviceCodeTTL is the lifetime of device authorization codes (default: 10 minutes).
	DeviceCodeTTL time.Duration

	// DeviceCodeInterval is the minimum polling interval in seconds (default: 5).
	DeviceCodeInterval int

	// VerificationURI is the customizable user verification URL for the device flow.
	// If empty, defaults to "{issuer}/v1/oauth/device".
	// Set this to a custom URL (e.g. "https://myapp.com/device") when using
	// an external UI like authsome-ui to host the verification page.
	VerificationURI string

	// DynamicRegistration enables RFC 7591 registration. Off by default:
	// an upgrade must never open a public registration endpoint on its own.
	DynamicRegistration bool

	// RegistrationAppID is the app dynamic clients belong to when the
	// request carries no resolvable publishable key. Leave empty on a
	// multi-tenant deployment so an unkeyed request is refused rather
	// than pooled into somebody's app.
	RegistrationAppID string

	// DynamicRegistrationScopes is the allowlist a dynamic client's
	// requested scopes are intersected against. Defaults to openid,
	// profile, email and offline_access.
	DynamicRegistrationScopes []string

	// RegistrationRateLimit caps POST /register per client IP.
	// Defaults to 10 per hour.
	RegistrationRateLimit RateLimit

	// ProtectedResources declares additional RFC 9728 resource identifiers,
	// keyed by the path suffix they are served under. AuthSome always
	// describes itself at the unsuffixed path regardless of this map.
	ProtectedResources map[string]ProtectedResource
	// ConsentGate, if set, is consulted before every authorization code is
	// issued. See SetConsentGate for post-construction wiring.
	ConsentGate ConsentGate

	// IDTokenSigningKey is a PEM private key (RSA of at least 2048 bits, or
	// EC P-256) that signs id_tokens for the deployment issuer. When it or
	// IDTokenSigningKeyFile is set, no key is generated for that issuer.
	IDTokenSigningKey     []byte
	IDTokenSigningKeyFile string
	// IDTokenSigningKeyID is the kid. Defaults to the key's RFC 7638
	// thumbprint.
	IDTokenSigningKeyID string
	// IDTokenPreviousKeys are PEM keys, public or private, that are published
	// in JWKS but never sign. Keep a rotated-out key here until every token it
	// signed has expired.
	//
	// A previous key's kid is always its RFC 7638 thumbprint. If the old key
	// signed under IDTokenSigningKeyID, its tokens carry that kid, so either
	// keep it as the signing key until they expire or accept that they stop
	// verifying when the kid changes.
	IDTokenPreviousKeys [][]byte

	// IDTokenTTL is the lifetime of id_tokens (default: 1 hour).
	IDTokenTTL time.Duration
	// IDTokenKeyRotation is how old a managed signing key gets before the
	// next one replaces it (default: 90 days).
	IDTokenKeyRotation time.Duration
	// IDTokenKeyRetireGrace is how long a replaced managed key stays in
	// JWKS so tokens it signed still verify (default: 7 days).
	IDTokenKeyRetireGrace time.Duration
}

Config configures the OAuth2 provider plugin.

type ConsentDecision

type ConsentDecision struct {
	CID      string `json:"cid"`
	CSRF     string `json:"csrf"`
	Decision string `json:"decision"`
}

ConsentDecision is the JSON form of the decision body.

type ConsentGate

type ConsentGate interface {
	// Evaluate returns a non-nil error to refuse the authorization. The error
	// is surfaced to the caller, so it should be a forge HTTP error.
	//
	// orgID is the caller's active organization, taken from the session that
	// is granting consent. It is the zero value (id.OrgID{}) whenever that
	// session is not org-scoped — that is not an assertion that no policy
	// applies. An implementation must not treat a zero orgID as permission to
	// skip its policy check; do the same evaluation you would for any org.
	//
	// appID is the app the OAuth2 client (clientID) belongs to, resolved by
	// the caller from its own client/device-code record rather than trusted
	// from the request. A gate that resolves clientID to some other record
	// globally (not scoped to an app) must bind its decision to appID, or a
	// client_id collision across two apps lets one app's registration govern
	// the other's client.
	Evaluate(ctx context.Context, clientID string, userID id.UserID, orgID id.OrgID, appID id.AppID, scopes []string) error
}

ConsentGate is an optional hook another plugin implements to veto an authorization before a code is issued. agentauth registers itself as the gate so an org's policy on delegated agents is enforced at the moment a user consents, which is the only point where "may this agent touch our data" is a well-formed question.

A nil gate allows everything, so the provider's behavior is unchanged when no plugin registers one.

type ConsentResult

type ConsentResult struct {
	RedirectURL string `json:"redirect_url"`
}

ConsentResult tells a JSON caller where to send the browser next.

type ConsentView

type ConsentView struct {
	CID        string   `json:"cid"`
	CSRF       string   `json:"csrf"`
	ClientID   string   `json:"client_id"`
	ClientName string   `json:"client_name"`
	Scopes     []string `json:"scopes"`
	// Action is the path the decision posts to.
	Action string `json:"action"`
}

ConsentView is what the page and the JSON form of it show.

type CreateClientRequest

type CreateClientRequest struct {
	AppID        string   `json:"app_id"`
	Name         string   `json:"name"`
	RedirectURIs []string `json:"redirect_uris"`
	Scopes       []string `json:"scopes,omitempty"`
	Resources    []string `json:"resources,omitempty"`
	GrantTypes   []string `json:"grant_types,omitempty"`
	Public       bool     `json:"public,omitempty"`
	DPoPMode     string   `json:"dpop_mode,omitempty"`
	// FirstParty marks a client the operator owns; authorization skips the
	// consent page for it.
	FirstParty bool `json:"first_party,omitempty"`
}

CreateClientRequest is the admin request to create an OAuth2 client.

type CreateClientResponse

type CreateClientResponse struct {
	ID           string   `json:"id"`
	ClientID     string   `json:"client_id"`
	ClientSecret string   `json:"client_secret,omitempty"` // Only returned once at creation
	Name         string   `json:"name"`
	RedirectURIs []string `json:"redirect_uris"`
	Scopes       []string `json:"scopes"`
	Resources    []string `json:"resources"`
	GrantTypes   []string `json:"grant_types"`
	Public       bool     `json:"public"`
}

CreateClientResponse is returned when an OAuth2 client is created.

type DeleteClientRequest

type DeleteClientRequest struct {
	ClientID string `path:"clientId"`
}

DeleteClientRequest deletes a client by internal ID.

The tag is `path`, not `param`: forge binds path parameters from the former (internal/router/openapi_request_schema.go). Under `param` the field never bound, so request validation rejected every call with "ClientID: field is required" and the handler never ran.

type DeleteClientResponse

type DeleteClientResponse struct {
	Status string `json:"status"`
}

DeleteClientResponse is the response after deleting a client.

type DeviceAuthRequest

type DeviceAuthRequest struct {
	ClientID string `json:"client_id" form:"client_id"`
	Scope    string `json:"scope,omitempty" form:"scope,omitempty"`
	// RFC 8707, repeatable. RFC 8628 section 3.1 makes this endpoint
	// form-encoded, the same as the token endpoint, and bindFormParam fills a
	// []string from every occurrence. The document describes the field from
	// these tags, so this endpoint needs no separate declaration the way
	// /authorize does.
	Resource []string `json:"resource,omitempty" form:"resource,omitempty"`
}

DeviceAuthRequest is the device authorization request (RFC 8628 Section 3.1).

type DeviceAuthResponse

type DeviceAuthResponse struct {
	DeviceCode              string `json:"device_code"`
	UserCode                string `json:"user_code"`
	VerificationURI         string `json:"verification_uri"`
	VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
	ExpiresIn               int    `json:"expires_in"`
	Interval                int    `json:"interval"`
}

DeviceAuthResponse is the device authorization response (RFC 8628 Section 3.2).

type DeviceCode

type DeviceCode struct {
	ID         id.DeviceCodeID `json:"id"`
	DeviceCode string          `json:"-"`         // opaque polling token (256-bit hex)
	UserCode   string          `json:"user_code"` // short human-readable code, e.g. "BCDF-GHJK"
	ClientID   string          `json:"client_id"` // OAuth2 client_id
	AppID      id.AppID        `json:"app_id"`
	Scopes     []string        `json:"scopes"`
	// Resources is the audience this device authorization was granted for.
	Resources       []string  `json:"resources"`
	VerificationURI string    `json:"verification_uri"` // where the user goes
	ExpiresAt       time.Time `json:"expires_at"`
	Interval        int       `json:"interval"`                 // polling interval in seconds
	Status          string    `json:"status"`                   // "pending", "authorized", "denied", "consumed"
	UserID          id.UserID `json:"user_id,omitempty"`        // set when user authorizes
	LastPolledAt    time.Time `json:"last_polled_at,omitempty"` // last time the CLI polled for this code
	CreatedAt       time.Time `json:"created_at"`
	// AuthTime is when the approving user authenticated, set at approval from
	// that user's session. Zero until approved.
	AuthTime time.Time `json:"-"`
}

DeviceCode represents an OAuth2 device authorization code (RFC 8628).

type DeviceCompleteRequest

type DeviceCompleteRequest struct {
	UserCode string `json:"user_code" form:"user_code"`
	Action   string `json:"action" form:"action"` // "approve" or "deny"
}

DeviceCompleteRequest allows an authenticated user to approve or deny a device code.

type DeviceCompleteResponse

type DeviceCompleteResponse struct {
	Status string `json:"status"` // "authorized" or "denied"
}

DeviceCompleteResponse is the response after completing device authorization.

type DiscoveryRequest

type DiscoveryRequest struct{}

DiscoveryRequest is empty.

type DiscoveryResponse

type DiscoveryResponse struct {
	Issuer                            string   `json:"issuer"`
	AuthorizationEndpoint             string   `json:"authorization_endpoint"`
	TokenEndpoint                     string   `json:"token_endpoint"`
	UserinfoEndpoint                  string   `json:"userinfo_endpoint"`
	RevocationEndpoint                string   `json:"revocation_endpoint"`
	DeviceAuthorizationEndpoint       string   `json:"device_authorization_endpoint"`
	RegistrationEndpoint              string   `json:"registration_endpoint,omitempty"`
	JWKSURI                           string   `json:"jwks_uri"`
	ResponseTypesSupported            []string `json:"response_types_supported"`
	GrantTypesSupported               []string `json:"grant_types_supported"`
	SubjectTypesSupported             []string `json:"subject_types_supported"`
	IDTokenSigningAlgValuesSupported  []string `json:"id_token_signing_alg_values_supported"`
	ScopesSupported                   []string `json:"scopes_supported"`
	TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
	CodeChallengeMethodsSupported     []string `json:"code_challenge_methods_supported"`
	// ResourceIndicatorsSupported advertises RFC 8707.
	//
	// This name is not registered. RFC 8707 registers the `resource`
	// parameter and the `invalid_target` error and defines no discovery
	// metadata at all, and the RFC 8414 IANA registry has no entry for it.
	// It is the convention that came out of the MCP ecosystem and it is what
	// clients look for, so do not read it as standardised.
	ResourceIndicatorsSupported bool `json:"resource_indicators_supported"`

	// DPoPSigningAlgValuesSupported lists the JWS algorithms accepted in DPoP
	// proofs (RFC 9449 section 5.1).
	DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"`

	ClaimsSupported        []string `json:"claims_supported"`
	ResponseModesSupported []string `json:"response_modes_supported"`
}

DiscoveryResponse is the OpenID Connect discovery document.

type Grant

type Grant struct {
	ID        id.OAuth2GrantID `json:"id"`
	AppID     id.AppID         `json:"app_id"`
	UserID    id.UserID        `json:"user_id"`
	ClientID  string           `json:"client_id"`
	Scopes    []string         `json:"scopes"`
	CreatedAt time.Time        `json:"created_at"`
	UpdatedAt time.Time        `json:"updated_at"`
}

Grant records that a user approved a client for a set of scopes in an app. Authorization consults it to decide whether to show the consent page, and the user can list and revoke grants at /v1/me/oauth/grants.

func (*Grant) Covers

func (g *Grant) Covers(scopes []string) bool

Covers reports whether the grant already approves every scope in scopes.

type GrantPathRequest

type GrantPathRequest struct {
	ClientID string `path:"clientId"`
}

GrantPathRequest names the client whose grant is revoked.

type GrantView

type GrantView struct {
	ClientID   string    `json:"client_id"`
	ClientName string    `json:"client_name"`
	Scopes     []string  `json:"scopes"`
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

GrantView is one entry of the signed-in user's grant list.

type IssuerInfo added in v1.9.1

type IssuerInfo struct {
	Issuer       string
	DiscoveryURL string
	JWKSURI      string
	// AppIssuer is true when the app has its own issuer rather than the
	// deployment's.
	AppIssuer bool
	Algs      []string
}

IssuerInfo is what an operator needs to point a relying party at the issuer an app's id_tokens name.

type JWKSResponse added in v1.9.1

type JWKSResponse struct {
	Keys []jwkutil.JWK `json:"keys"`
}

JWKSResponse is the JSON Web Key Set the provider publishes.

type KeyInfo added in v1.9.1

type KeyInfo struct {
	KID string
	Alg string
	// Source is "operator" or "managed".
	Source string
	// Status is "active", "previous" or "retiring".
	Status    string
	CreatedAt time.Time
	RetiresAt *time.Time
}

KeyInfo describes one signing key for the admin dashboard. It never carries key material.

type ListClientsRequest

type ListClientsRequest struct {
	AppID string `query:"app_id"`
}

ListClientsRequest is the request to list OAuth2 clients.

type ListClientsResponse

type ListClientsResponse struct {
	Clients []*OAuth2Client `json:"clients"`
}

ListClientsResponse is the response listing OAuth2 clients.

type ListGrantsRequest

type ListGrantsRequest struct{}

ListGrantsRequest carries nothing; the user comes from the session.

type ListGrantsResponse

type ListGrantsResponse struct {
	Grants []GrantView `json:"grants"`
}

ListGrantsResponse is the body of GET /v1/me/oauth/grants.

type MemoryStore

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

MemoryStore is an in-memory implementation of the OAuth2 Store for testing.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore creates a new in-memory OAuth2 store.

func (*MemoryStore) ConsumeAuthCode

func (s *MemoryStore) ConsumeAuthCode(_ context.Context, code string) (bool, error)

func (*MemoryStore) CreateAuthCode

func (s *MemoryStore) CreateAuthCode(_ context.Context, code *AuthorizationCode) error

func (*MemoryStore) CreateClient

func (s *MemoryStore) CreateClient(_ context.Context, c *OAuth2Client) error

func (*MemoryStore) CreateDeviceCode

func (s *MemoryStore) CreateDeviceCode(_ context.Context, dc *DeviceCode) error

func (*MemoryStore) CreateSigningKey added in v1.9.1

func (s *MemoryStore) CreateSigningKey(_ context.Context, k *SigningKey) error

func (*MemoryStore) DeleteClient

func (s *MemoryStore) DeleteClient(_ context.Context, clientID id.OAuth2ClientID) error

func (*MemoryStore) DeleteExpiredAuthCodes

func (s *MemoryStore) DeleteExpiredAuthCodes(_ context.Context, before time.Time, batch int) (int64, error)

func (*MemoryStore) DeleteExpiredDeviceCodes

func (s *MemoryStore) DeleteExpiredDeviceCodes(_ context.Context) error

func (*MemoryStore) DeleteExpiredDeviceCodesBefore

func (s *MemoryStore) DeleteExpiredDeviceCodesBefore(_ context.Context, before time.Time, batch int) (int64, error)

func (*MemoryStore) DeleteGrant

func (s *MemoryStore) DeleteGrant(_ context.Context, appID id.AppID, userID id.UserID, clientID string) error

func (*MemoryStore) GetAuthCode

func (s *MemoryStore) GetAuthCode(_ context.Context, code string) (*AuthorizationCode, error)

func (*MemoryStore) GetClient

func (s *MemoryStore) GetClient(_ context.Context, clientID string) (*OAuth2Client, error)

func (*MemoryStore) GetClientByID

func (s *MemoryStore) GetClientByID(_ context.Context, clientID id.OAuth2ClientID) (*OAuth2Client, error)

func (*MemoryStore) GetDeviceCodeByDeviceCode

func (s *MemoryStore) GetDeviceCodeByDeviceCode(_ context.Context, deviceCode string) (*DeviceCode, error)

func (*MemoryStore) GetDeviceCodeByUserCode

func (s *MemoryStore) GetDeviceCodeByUserCode(_ context.Context, userCode string) (*DeviceCode, error)

func (*MemoryStore) GetGrant

func (s *MemoryStore) GetGrant(_ context.Context, appID id.AppID, userID id.UserID, clientID string) (*Grant, error)

func (*MemoryStore) ListClients

func (s *MemoryStore) ListClients(_ context.Context, appID id.AppID) ([]*OAuth2Client, error)

func (*MemoryStore) ListGrantsByUser

func (s *MemoryStore) ListGrantsByUser(_ context.Context, appID id.AppID, userID id.UserID) ([]*Grant, error)

func (*MemoryStore) ListSigningKeys added in v1.9.1

func (s *MemoryStore) ListSigningKeys(_ context.Context, scope string) ([]*SigningKey, error)

func (*MemoryStore) RetireSigningKey added in v1.9.1

func (s *MemoryStore) RetireSigningKey(_ context.Context, keyID id.OAuth2SigningKeyID, at time.Time) error

func (*MemoryStore) UpdateClient

func (s *MemoryStore) UpdateClient(_ context.Context, c *OAuth2Client) error

func (*MemoryStore) UpdateDeviceCode

func (s *MemoryStore) UpdateDeviceCode(_ context.Context, dc *DeviceCode) error

func (*MemoryStore) UpsertGrant

func (s *MemoryStore) UpsertGrant(_ context.Context, g *Grant) error

type MongoStore

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

MongoStore implements oauth2provider.Store using the Grove MongoDB driver.

func NewMongoStore

func NewMongoStore(db *grove.DB) *MongoStore

NewMongoStore creates a new MongoDB-backed OAuth2 store.

func (*MongoStore) ConsumeAuthCode

func (s *MongoStore) ConsumeAuthCode(ctx context.Context, code string) (bool, error)

func (*MongoStore) CreateAuthCode

func (s *MongoStore) CreateAuthCode(ctx context.Context, code *AuthorizationCode) error

func (*MongoStore) CreateClient

func (s *MongoStore) CreateClient(ctx context.Context, c *OAuth2Client) error

func (*MongoStore) CreateDeviceCode

func (s *MongoStore) CreateDeviceCode(ctx context.Context, dc *DeviceCode) error

func (*MongoStore) CreateSigningKey added in v1.9.1

func (s *MongoStore) CreateSigningKey(ctx context.Context, k *SigningKey) error

func (*MongoStore) DeleteClient

func (s *MongoStore) DeleteClient(ctx context.Context, clientID id.OAuth2ClientID) error

func (*MongoStore) DeleteExpiredAuthCodes

func (s *MongoStore) DeleteExpiredAuthCodes(ctx context.Context, before time.Time, batch int) (int64, error)

func (*MongoStore) DeleteExpiredDeviceCodes

func (s *MongoStore) DeleteExpiredDeviceCodes(ctx context.Context) error

func (*MongoStore) DeleteExpiredDeviceCodesBefore

func (s *MongoStore) DeleteExpiredDeviceCodesBefore(ctx context.Context, before time.Time, batch int) (int64, error)

func (*MongoStore) DeleteGrant

func (s *MongoStore) DeleteGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) error

func (*MongoStore) GetAuthCode

func (s *MongoStore) GetAuthCode(ctx context.Context, code string) (*AuthorizationCode, error)

func (*MongoStore) GetClient

func (s *MongoStore) GetClient(ctx context.Context, clientID string) (*OAuth2Client, error)

func (*MongoStore) GetClientByID

func (s *MongoStore) GetClientByID(ctx context.Context, clientID id.OAuth2ClientID) (*OAuth2Client, error)

func (*MongoStore) GetDeviceCodeByDeviceCode

func (s *MongoStore) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error)

func (*MongoStore) GetDeviceCodeByUserCode

func (s *MongoStore) GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error)

func (*MongoStore) GetGrant

func (s *MongoStore) GetGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) (*Grant, error)

func (*MongoStore) ListClients

func (s *MongoStore) ListClients(ctx context.Context, appID id.AppID) ([]*OAuth2Client, error)

func (*MongoStore) ListGrantsByUser

func (s *MongoStore) ListGrantsByUser(ctx context.Context, appID id.AppID, userID id.UserID) ([]*Grant, error)

func (*MongoStore) ListSigningKeys added in v1.9.1

func (s *MongoStore) ListSigningKeys(ctx context.Context, scope string) ([]*SigningKey, error)

func (*MongoStore) RetireSigningKey added in v1.9.1

func (s *MongoStore) RetireSigningKey(ctx context.Context, keyID id.OAuth2SigningKeyID, at time.Time) error

RetireSigningKey sets retires_at, overwriting an earlier value. An unknown id matches nothing and is not an error.

func (*MongoStore) UpdateClient

func (s *MongoStore) UpdateClient(ctx context.Context, c *OAuth2Client) error

func (*MongoStore) UpdateDeviceCode

func (s *MongoStore) UpdateDeviceCode(ctx context.Context, dc *DeviceCode) error

func (*MongoStore) UpsertGrant

func (s *MongoStore) UpsertGrant(ctx context.Context, g *Grant) error

type OAuth2Client

type OAuth2Client struct {
	ID           id.OAuth2ClientID `json:"id"`
	AppID        id.AppID          `json:"app_id"`
	Name         string            `json:"name"`
	ClientID     string            `json:"client_id"`
	ClientSecret string            `json:"-"` // Hashed; never exposed
	RedirectURIs []string          `json:"redirect_uris"`
	Scopes       []string          `json:"scopes"`
	// Resources is the allowlist of resource identifiers this client may
	// request a token for (RFC 8707). Empty means the client may target
	// nothing, so a request naming any resource is rejected with
	// invalid_target. Deny by default: an empty list is the state every
	// existing client is in, and a client that never sends `resource` is
	// unaffected either way.
	Resources  []string `json:"resources"`
	GrantTypes []string `json:"grant_types"` // "authorization_code", "client_credentials"

	// PrincipalID links this client to the non-human principal it acts as.
	// Required for the RFC 8693 token exchange grant and unused by the other
	// grants: Engine.ExchangeToken takes a principal.Ref actor and matches it
	// against a delegation grant, so a client with no principal has no
	// identity to act with and nothing a grant could have been written
	// against. Zero for every client that only uses the other grants.
	PrincipalID id.ServiceAccountID `json:"principal_id,omitempty"`
	Public      bool                `json:"public"` // Public clients (SPAs, mobile) don't have a secret
	// FirstParty marks a client the operator owns. Authorization skips the
	// consent page for it, since the user already trusts the application
	// they are signing in to. Only the admin API can set it.
	FirstParty bool `json:"first_party"`

	// TokenEndpointAuthMethod is RFC 7591 token_endpoint_auth_method:
	// "none", "client_secret_basic" or "client_secret_post". Every
	// enforcement site (plugin.go's PKCE, client-secret and
	// client_credentials checks) reads Public, not this field; nothing
	// reads TokenEndpointAuthMethod to decide behaviour. The two are kept
	// in sync at every write site instead: the admin and dashboard paths
	// derive the method from Public, and the dynamic registration path
	// derives Public from the method ("none" means public). Two flags that
	// can disagree is a bug waiting to happen.
	TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`

	// RegistrationTokenHash is the bcrypt hash of the RFC 7592 registration
	// access token. Empty for admin-created clients, which is what makes
	// them unreachable over the 7592 routes.
	RegistrationTokenHash string `json:"-"`

	// DynamicallyRegistered records that this client came in over RFC 7591
	// rather than the admin surface. It gates the 7592 routes and lets the
	// dashboard tell the two populations apart.
	DynamicallyRegistered bool `json:"dynamically_registered"`

	// ClientSecretExpiresAt is RFC 7591 client_secret_expires_at. Nil means
	// the secret never expires.
	//
	// A pointer, not a bare time.Time: encoding/json never treats a struct as
	// empty, so omitempty would not fire and every client serialised through
	// ListClientsResponse would carry "0001-01-01T00:00:00Z".
	ClientSecretExpiresAt *time.Time `json:"client_secret_expires_at,omitempty"`

	// Metadata holds the RFC 7591 fields that carry no behaviour:
	// client_uri, logo_uri, contacts, tos_uri, policy_uri, software_id,
	// software_version. They only need to round-trip on a 7592 read, so
	// they do not each earn a column across four backends.
	Metadata map[string]any `json:"metadata,omitempty"`

	// DPoPMode is this client's RFC 9449 mode: "off", "optional", "required",
	// or empty to inherit the app's setting.
	//
	// Resolution is monotonic. The effective mode is the stricter of the app's
	// and this value, so a client can raise the bar but never lower one the
	// app has set.
	DPoPMode string `json:"dpop_mode,omitempty"`

	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

OAuth2Client represents a registered OAuth2 client application.

type OAuth2Error

type OAuth2Error struct {
	Error            string `json:"error"`
	ErrorDescription string `json:"error_description,omitempty"`
}

OAuth2Error is an RFC 6749 / RFC 8628 error response.

type OAuth2HTTPError

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

OAuth2HTTPError wraps OAuth2 error codes as a framework-compatible HTTP error. This allows RFC 8628 device flow errors to flow through the Forge framework's error handler while maintaining the OAuth2 JSON error response format.

func (*OAuth2HTTPError) Error

func (e *OAuth2HTTPError) Error() string

func (*OAuth2HTTPError) ResponseBody

func (e *OAuth2HTTPError) ResponseBody() any

func (*OAuth2HTTPError) StatusCode

func (e *OAuth2HTTPError) StatusCode() int

type Plugin

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

Plugin is the OAuth2 provider plugin.

func New

func New(cfg ...Config) *Plugin

New creates a new OAuth2 provider plugin.

func (*Plugin) CreateClientFor added in v1.9.1

func (p *Plugin) CreateClientFor(ctx context.Context, appID id.AppID, req CreateClientRequest) (*CreateClientResponse, error)

CreateClientFor registers a client under appID. req.AppID is ignored: the caller has already decided which app this is for.

func (*Plugin) DeclareSettings added in v1.9.1

func (p *Plugin) DeclareSettings(m *settings.Manager) error

DeclareSettings implements plugin.SettingsProvider.

func (*Plugin) DeleteClientFor added in v1.9.1

func (p *Plugin) DeleteClientFor(ctx context.Context, appID id.AppID, clientID string) error

DeleteClientFor deletes one of appID's clients. The row is loaded first: the tenancy check needs its AppID and the store's delete takes only an id.

func (*Plugin) EvaluateConsent

func (p *Plugin) EvaluateConsent(ctx context.Context, clientID string, userID id.UserID, orgID id.OrgID, appID id.AppID, scopes []string) error

EvaluateConsent runs the registered gate, if any.

func (*Plugin) GetClientFor added in v1.9.1

func (p *Plugin) GetClientFor(ctx context.Context, appID id.AppID, clientID string) (*OAuth2Client, error)

GetClientFor loads one of appID's clients by its internal id.

func (*Plugin) IssuerInfo added in v1.9.1

func (p *Plugin) IssuerInfo(ctx context.Context, appID id.AppID) (IssuerInfo, error)

IssuerInfo describes the issuer appID's id_tokens are minted under.

func (*Plugin) ListClientsFor added in v1.9.1

func (p *Plugin) ListClientsFor(ctx context.Context, appID id.AppID) ([]*OAuth2Client, error)

ListClientsFor lists appID's clients. The result is never nil.

func (*Plugin) MigrationGroups

func (p *Plugin) MigrationGroups(driverName string) []*migrate.Group

MigrationGroups returns the OAuth2 migration groups for the given driver.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns the plugin name.

func (*Plugin) OnInit

func (p *Plugin) OnInit(ctx context.Context, engine plugin.Engine) error

OnInit captures dependencies from the engine.

func (*Plugin) RegisterContract

func (p *Plugin) RegisterContract(
	d *dispatcher.Dispatcher,
	reg contract.Registry,
	wreg contract.WardenRegistry,
	engine plugin.Engine,
) error

func (*Plugin) RegisterRootRoutes

func (p *Plugin) RegisterRootRoutes(router forge.Router) error

RegisterRootRoutes registers discovery documents at the origin root. These cannot live on the grouped router: a client that only knows the host fetches https://host/.well-known/... with no prefix.

func (*Plugin) RegisterRoutes

func (p *Plugin) RegisterRoutes(router forge.Router) error

RegisterRoutes registers OAuth2 provider HTTP endpoints.

func (*Plugin) RotateClientSecretFor added in v1.9.1

func (p *Plugin) RotateClientSecretFor(ctx context.Context, appID id.AppID, clientID string) (secret string, err error)

RotateClientSecretFor replaces a confidential client's secret and returns the new one in the clear. It is the only time the secret is readable.

func (*Plugin) RotateSigningKey added in v1.9.1

func (p *Plugin) RotateSigningKey(ctx context.Context, appID id.AppID) (KeyInfo, error)

RotateSigningKey generates the next managed signing key for appID's own issuer. It only rotates an app's own key: an app on the deployment issuer gets ErrOperatorKey when a configured key serves it and ErrSharedKey otherwise, since that key signs for every app and rotates on its own schedule. An app key younger than an hour gets ErrKeyTooNew.

func (*Plugin) SetConsentGate

func (p *Plugin) SetConsentGate(g ConsentGate)

SetConsentGate registers a gate consulted before every authorization code is issued. Use it when the gate is only available after the provider has been constructed; otherwise set Config.ConsentGate.

func (*Plugin) SetDynamicRegistrationForTest

func (p *Plugin) SetDynamicRegistrationForTest(enabled bool)

SetDynamicRegistrationForTest toggles RFC 7591 registration after construction. Tests use it to prove the 7592 routes keep working once registration itself is closed.

func (*Plugin) SetOAuth2Store

func (p *Plugin) SetOAuth2Store(s Store)

SetOAuth2Store allows direct OAuth2 store injection for testing.

func (*Plugin) SetStore

func (p *Plugin) SetStore(s store.Store)

SetStore allows direct store injection for testing.

func (*Plugin) SigningKeys added in v1.9.1

func (p *Plugin) SigningKeys(ctx context.Context, appID id.AppID) (issuer string, keys []KeyInfo, err error)

SigningKeys lists the id_token signing keys for appID's issuer, active key first, along with that issuer.

func (*Plugin) SweepRetention

func (p *Plugin) SweepRetention(ctx context.Context, cutoff func(kind string) time.Time, batch int) (int64, error)

SweepRetention implements plugin.RetentionSweeper: authorization codes and device codes that expired before their cutoff are removed in batches.

func (*Plugin) UpdateClientFor added in v1.9.1

func (p *Plugin) UpdateClientFor(ctx context.Context, appID id.AppID, clientID string, req UpdateClientRequest) (*OAuth2Client, error)

UpdateClientFor applies the fields req carries to one of appID's clients. req.ClientID is not read: clientID is the only identifier that counts, so a body cannot redirect the write.

type PostgresStore

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

PostgresStore implements oauth2provider.Store using the Grove ORM with PostgreSQL.

func NewPostgresStore

func NewPostgresStore(db *grove.DB) *PostgresStore

NewPostgresStore creates a new PostgreSQL-backed OAuth2 store.

func (*PostgresStore) ConsumeAuthCode

func (s *PostgresStore) ConsumeAuthCode(ctx context.Context, code string) (bool, error)

func (*PostgresStore) CreateAuthCode

func (s *PostgresStore) CreateAuthCode(ctx context.Context, code *AuthorizationCode) error

func (*PostgresStore) CreateClient

func (s *PostgresStore) CreateClient(ctx context.Context, c *OAuth2Client) error

func (*PostgresStore) CreateDeviceCode

func (s *PostgresStore) CreateDeviceCode(ctx context.Context, dc *DeviceCode) error

func (*PostgresStore) CreateSigningKey added in v1.9.1

func (s *PostgresStore) CreateSigningKey(ctx context.Context, k *SigningKey) error

func (*PostgresStore) DeleteClient

func (s *PostgresStore) DeleteClient(ctx context.Context, clientID id.OAuth2ClientID) error

func (*PostgresStore) DeleteExpiredAuthCodes

func (s *PostgresStore) DeleteExpiredAuthCodes(ctx context.Context, before time.Time, batch int) (int64, error)

func (*PostgresStore) DeleteExpiredDeviceCodes

func (s *PostgresStore) DeleteExpiredDeviceCodes(ctx context.Context) error

func (*PostgresStore) DeleteExpiredDeviceCodesBefore

func (s *PostgresStore) DeleteExpiredDeviceCodesBefore(ctx context.Context, before time.Time, batch int) (int64, error)

func (*PostgresStore) DeleteGrant

func (s *PostgresStore) DeleteGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) error

func (*PostgresStore) GetAuthCode

func (s *PostgresStore) GetAuthCode(ctx context.Context, code string) (*AuthorizationCode, error)

func (*PostgresStore) GetClient

func (s *PostgresStore) GetClient(ctx context.Context, clientID string) (*OAuth2Client, error)

func (*PostgresStore) GetClientByID

func (s *PostgresStore) GetClientByID(ctx context.Context, clientID id.OAuth2ClientID) (*OAuth2Client, error)

func (*PostgresStore) GetDeviceCodeByDeviceCode

func (s *PostgresStore) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error)

func (*PostgresStore) GetDeviceCodeByUserCode

func (s *PostgresStore) GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error)

func (*PostgresStore) GetGrant

func (s *PostgresStore) GetGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) (*Grant, error)

func (*PostgresStore) ListClients

func (s *PostgresStore) ListClients(ctx context.Context, appID id.AppID) ([]*OAuth2Client, error)

func (*PostgresStore) ListGrantsByUser

func (s *PostgresStore) ListGrantsByUser(ctx context.Context, appID id.AppID, userID id.UserID) ([]*Grant, error)

func (*PostgresStore) ListSigningKeys added in v1.9.1

func (s *PostgresStore) ListSigningKeys(ctx context.Context, scope string) ([]*SigningKey, error)

func (*PostgresStore) RetireSigningKey added in v1.9.1

func (s *PostgresStore) RetireSigningKey(ctx context.Context, keyID id.OAuth2SigningKeyID, at time.Time) error

RetireSigningKey sets RetiresAt, overwriting an earlier value. An unknown id updates no rows and is not an error.

func (*PostgresStore) UpdateClient

func (s *PostgresStore) UpdateClient(ctx context.Context, c *OAuth2Client) error

func (*PostgresStore) UpdateDeviceCode

func (s *PostgresStore) UpdateDeviceCode(ctx context.Context, dc *DeviceCode) error

func (*PostgresStore) UpsertGrant

func (s *PostgresStore) UpsertGrant(ctx context.Context, g *Grant) error

type ProtectedResource

type ProtectedResource struct {
	Resource        string
	ScopesSupported []string
}

ProtectedResource describes one RFC 9728 protected resource.

type ProtectedResourceMetadata

type ProtectedResourceMetadata struct {
	Resource               string   `json:"resource"`
	AuthorizationServers   []string `json:"authorization_servers"`
	ScopesSupported        []string `json:"scopes_supported,omitempty"`
	BearerMethodsSupported []string `json:"bearer_methods_supported"`
}

ProtectedResourceMetadata is the RFC 9728 protected resource metadata document. An MCP client fetches this first and follows authorization_servers to the RFC 8414 document.

type ProtectedResourceRequest

type ProtectedResourceRequest struct {
	Path string `path:"resourcePath"`
}

ProtectedResourceRequest addresses one configured resource by the path suffix it is served under (RFC 9728 section 3.1).

type RateLimit

type RateLimit struct {
	Limit  int
	Window time.Duration
}

RateLimit bounds how often an endpoint may be called.

type RegisterClientRequest

type RegisterClientRequest struct {
	RedirectURIs            []string `json:"redirect_uris"`
	ClientName              string   `json:"client_name,omitempty"`
	GrantTypes              []string `json:"grant_types,omitempty"`
	ResponseTypes           []string `json:"response_types,omitempty"`
	Scope                   string   `json:"scope,omitempty"`
	TokenEndpointAuthMethod string   `json:"token_endpoint_auth_method,omitempty"`
	ClientURI               string   `json:"client_uri,omitempty"`
	LogoURI                 string   `json:"logo_uri,omitempty"`
	TOSURI                  string   `json:"tos_uri,omitempty"`
	PolicyURI               string   `json:"policy_uri,omitempty"`
	SoftwareID              string   `json:"software_id,omitempty"`
	SoftwareVersion         string   `json:"software_version,omitempty"`
	Contacts                []string `json:"contacts,omitempty"`
}

RegisterClientRequest is an RFC 7591 client registration request.

type RegisterClientResponse

type RegisterClientResponse struct {
	ClientID                string   `json:"client_id"`
	ClientSecret            string   `json:"client_secret,omitempty"`
	ClientIDIssuedAt        int64    `json:"client_id_issued_at"`
	ClientSecretExpiresAt   int64    `json:"client_secret_expires_at"`
	RegistrationAccessToken string   `json:"registration_access_token,omitempty"`
	RegistrationClientURI   string   `json:"registration_client_uri,omitempty"`
	RedirectURIs            []string `json:"redirect_uris"`
	GrantTypes              []string `json:"grant_types"`
	ResponseTypes           []string `json:"response_types"`
	Scope                   string   `json:"scope"`
	TokenEndpointAuthMethod string   `json:"token_endpoint_auth_method"`
	ClientName              string   `json:"client_name,omitempty"`
	ClientURI               string   `json:"client_uri,omitempty"`
	LogoURI                 string   `json:"logo_uri,omitempty"`
	TOSURI                  string   `json:"tos_uri,omitempty"`
	PolicyURI               string   `json:"policy_uri,omitempty"`
	SoftwareID              string   `json:"software_id,omitempty"`
	SoftwareVersion         string   `json:"software_version,omitempty"`
	Contacts                []string `json:"contacts,omitempty"`
}

RegisterClientResponse is an RFC 7591 client information response. It is also what the RFC 7592 read and update endpoints return.

type RegistrationRequest

type RegistrationRequest struct {
	ClientID string `path:"clientId" json:"-"`
}

RegistrationRequest addresses one registration by its client_id.

ClientID is tagged json:"-" on purpose, not left untagged. forge's BindRequest binds path params first and then JSON-decodes the body over the same struct, and encoding/json matches an untagged field case-insensitively — so a body key "clientId" would otherwise be able to overwrite the path value after the fact. This struct has no other field for a body to collide with today, which is the only reason leaving it untagged would happen to be safe right now; json:"-" keeps it safe if a body field is ever added here later. See UpdateRegistrationRequest below for the case where that actually bit us.

type RevokeRequest

type RevokeRequest struct {
	Token         string `json:"token" form:"token"`
	TokenTypeHint string `json:"token_type_hint,omitempty" form:"token_type_hint"`
	ClientID      string `json:"client_id,omitempty" form:"client_id"`
	ClientSecret  string `json:"client_secret,omitempty" form:"client_secret"`
}

RevokeRequest is the OAuth2 revocation request.

type RotateClientSecretRequest

type RotateClientSecretRequest struct {
	ClientID string `path:"clientId"`
}

RotateClientSecretRequest identifies the client whose secret to replace. There is no body: the new secret is server-generated, the way it is at creation. Letting a caller supply one would invite weak or reused values.

type RotateClientSecretResponse

type RotateClientSecretResponse struct {
	ID           string `json:"id"`
	ClientID     string `json:"client_id"`
	ClientSecret string `json:"client_secret"`
}

RotateClientSecretResponse carries the new secret in the clear. This is the only time it is ever readable, matching CreateClientResponse.

type SigningKey added in v1.9.1

type SigningKey struct {
	ID id.OAuth2SigningKeyID
	// Scope is "" for the deployment issuer, else the app id.
	Scope string
	Alg   string // "RS256"
	// PrivateKey is sealed with engine.TokenEncryptor().
	PrivateKey string
	// PublicJWK is the JSON of one JWK, exactly as served.
	PublicJWK  []byte
	Generation int64
	CreatedAt  time.Time
	// RetiresAt is nil while the key is active or not yet retired.
	RetiresAt *time.Time
}

SigningKey is one id_token signing key. Keys are never deleted: a retired key keeps its public half in the JWKS until RetiresAt passes, so tokens signed just before a rotation still verify.

type SqliteStore

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

SqliteStore implements oauth2provider.Store using the Grove ORM with SQLite.

func NewSqliteStore

func NewSqliteStore(db *grove.DB) *SqliteStore

NewSqliteStore creates a new SQLite-backed OAuth2 store.

func (*SqliteStore) ConsumeAuthCode

func (s *SqliteStore) ConsumeAuthCode(ctx context.Context, code string) (bool, error)

func (*SqliteStore) CreateAuthCode

func (s *SqliteStore) CreateAuthCode(ctx context.Context, code *AuthorizationCode) error

func (*SqliteStore) CreateClient

func (s *SqliteStore) CreateClient(ctx context.Context, c *OAuth2Client) error

func (*SqliteStore) CreateDeviceCode

func (s *SqliteStore) CreateDeviceCode(ctx context.Context, dc *DeviceCode) error

func (*SqliteStore) CreateSigningKey added in v1.9.1

func (s *SqliteStore) CreateSigningKey(ctx context.Context, k *SigningKey) error

func (*SqliteStore) DeleteClient

func (s *SqliteStore) DeleteClient(ctx context.Context, clientID id.OAuth2ClientID) error

func (*SqliteStore) DeleteExpiredAuthCodes

func (s *SqliteStore) DeleteExpiredAuthCodes(ctx context.Context, before time.Time, batch int) (int64, error)

func (*SqliteStore) DeleteExpiredDeviceCodes

func (s *SqliteStore) DeleteExpiredDeviceCodes(ctx context.Context) error

func (*SqliteStore) DeleteExpiredDeviceCodesBefore

func (s *SqliteStore) DeleteExpiredDeviceCodesBefore(ctx context.Context, before time.Time, batch int) (int64, error)

func (*SqliteStore) DeleteGrant

func (s *SqliteStore) DeleteGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) error

func (*SqliteStore) GetAuthCode

func (s *SqliteStore) GetAuthCode(ctx context.Context, code string) (*AuthorizationCode, error)

func (*SqliteStore) GetClient

func (s *SqliteStore) GetClient(ctx context.Context, clientID string) (*OAuth2Client, error)

func (*SqliteStore) GetClientByID

func (s *SqliteStore) GetClientByID(ctx context.Context, clientID id.OAuth2ClientID) (*OAuth2Client, error)

func (*SqliteStore) GetDeviceCodeByDeviceCode

func (s *SqliteStore) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error)

func (*SqliteStore) GetDeviceCodeByUserCode

func (s *SqliteStore) GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error)

func (*SqliteStore) GetGrant

func (s *SqliteStore) GetGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) (*Grant, error)

func (*SqliteStore) ListClients

func (s *SqliteStore) ListClients(ctx context.Context, appID id.AppID) ([]*OAuth2Client, error)

func (*SqliteStore) ListGrantsByUser

func (s *SqliteStore) ListGrantsByUser(ctx context.Context, appID id.AppID, userID id.UserID) ([]*Grant, error)

func (*SqliteStore) ListSigningKeys added in v1.9.1

func (s *SqliteStore) ListSigningKeys(ctx context.Context, scope string) ([]*SigningKey, error)

func (*SqliteStore) RetireSigningKey added in v1.9.1

func (s *SqliteStore) RetireSigningKey(ctx context.Context, keyID id.OAuth2SigningKeyID, at time.Time) error

RetireSigningKey sets RetiresAt, overwriting an earlier value. An unknown id updates no rows and is not an error.

func (*SqliteStore) UpdateClient

func (s *SqliteStore) UpdateClient(ctx context.Context, c *OAuth2Client) error

func (*SqliteStore) UpdateDeviceCode

func (s *SqliteStore) UpdateDeviceCode(ctx context.Context, dc *DeviceCode) error

func (*SqliteStore) UpsertGrant

func (s *SqliteStore) UpsertGrant(ctx context.Context, g *Grant) error

type StandardClaims added in v1.9.1

type StandardClaims struct {
	Name                string `json:"name,omitempty"`
	GivenName           string `json:"given_name,omitempty"`
	FamilyName          string `json:"family_name,omitempty"`
	Picture             string `json:"picture,omitempty"`
	PreferredUsername   string `json:"preferred_username,omitempty"`
	UpdatedAt           int64  `json:"updated_at,omitempty"`
	Email               string `json:"email,omitempty"`
	EmailVerified       *bool  `json:"email_verified,omitempty"`
	PhoneNumber         string `json:"phone_number,omitempty"`
	PhoneNumberVerified *bool  `json:"phone_number_verified,omitempty"`
}

StandardClaims are the OpenID Connect Core 5.1 claims authsome can fill from a user. Userinfo and id_tokens share this one shape, so a claim cannot be present in one and missing from the other. The verified flags are pointers so a false value is still written when its scope is granted. They point at copies, never into the user.

type Store

type Store interface {
	// Clients
	CreateClient(ctx context.Context, c *OAuth2Client) error
	GetClient(ctx context.Context, clientID string) (*OAuth2Client, error)
	GetClientByID(ctx context.Context, id id.OAuth2ClientID) (*OAuth2Client, error)
	// UpdateClient persists changes to an existing client, matched on ID.
	// Returns ErrClientNotFound when no row matches.
	UpdateClient(ctx context.Context, c *OAuth2Client) error
	ListClients(ctx context.Context, appID id.AppID) ([]*OAuth2Client, error)
	DeleteClient(ctx context.Context, id id.OAuth2ClientID) error

	// Authorization codes
	CreateAuthCode(ctx context.Context, code *AuthorizationCode) error
	GetAuthCode(ctx context.Context, code string) (*AuthorizationCode, error)
	// ConsumeAuthCode marks a code used and reports whether this call was the
	// one that did so. A false return means the code was already consumed —
	// callers must treat that as a replay, not a success. Implementations must
	// make the test-and-set atomic.
	ConsumeAuthCode(ctx context.Context, code string) (bool, error)

	// Device codes (RFC 8628)
	CreateDeviceCode(ctx context.Context, dc *DeviceCode) error
	GetDeviceCodeByDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error)
	GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error)
	UpdateDeviceCode(ctx context.Context, dc *DeviceCode) error
	DeleteExpiredDeviceCodes(ctx context.Context) error

	// Retention. Each call removes at most batch codes that expired before
	// the given instant and reports how many went; a batch of zero or less
	// removes every match. See store.Retention for why deletes are batched.
	DeleteExpiredAuthCodes(ctx context.Context, before time.Time, batch int) (int64, error)
	DeleteExpiredDeviceCodesBefore(ctx context.Context, before time.Time, batch int) (int64, error)

	// Grants are keyed by (app, user, client). UpsertGrant creates the grant
	// or replaces its scopes; GetGrant returns ErrGrantNotFound when the user
	// never approved the client; DeleteGrant revokes it.
	UpsertGrant(ctx context.Context, g *Grant) error
	GetGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) (*Grant, error)
	ListGrantsByUser(ctx context.Context, appID id.AppID, userID id.UserID) ([]*Grant, error)
	DeleteGrant(ctx context.Context, appID id.AppID, userID id.UserID, clientID string) error

	// Signing keys for id_tokens. Generation is unique within a scope:
	// CreateSigningKey returns ErrSigningKeyConflict when (Scope, Generation)
	// already exists, which is how concurrent rotations pick one winner.
	// ListSigningKeys returns a scope's keys, newest generation first.
	// RetireSigningKey sets RetiresAt on the key; an unknown id is a no-op.
	CreateSigningKey(ctx context.Context, k *SigningKey) error
	ListSigningKeys(ctx context.Context, scope string) ([]*SigningKey, error)
	RetireSigningKey(ctx context.Context, keyID id.OAuth2SigningKeyID, at time.Time) error
}

Store persists OAuth2 clients, authorization codes, and device codes.

type TokenRequest

type TokenRequest struct {
	GrantType    string `json:"grant_type" form:"grant_type"`
	Code         string `json:"code,omitempty" form:"code"`
	RedirectURI  string `json:"redirect_uri,omitempty" form:"redirect_uri"`
	ClientID     string `json:"client_id,omitempty" form:"client_id"`
	ClientSecret string `json:"client_secret,omitempty" form:"client_secret"`
	CodeVerifier string `json:"code_verifier,omitempty" form:"code_verifier"`
	DeviceCode   string `json:"device_code,omitempty" form:"device_code"`
	RefreshToken string `json:"refresh_token,omitempty" form:"refresh_token"`
	// RFC 8707, repeatable. RFC 6749 section 4.1.3 sends token-endpoint
	// parameters as a form body, and a JSON body is accepted too, so the field
	// carries both tags.
	Resource []string `json:"resource,omitempty" form:"resource,omitempty"`

	// RFC 8693 token exchange.
	SubjectToken       string `json:"subject_token,omitempty" form:"subject_token"`
	SubjectTokenType   string `json:"subject_token_type,omitempty" form:"subject_token_type"`
	ActorToken         string `json:"actor_token,omitempty" form:"actor_token"`
	ActorTokenType     string `json:"actor_token_type,omitempty" form:"actor_token_type"`
	RequestedTokenType string `json:"requested_token_type,omitempty" form:"requested_token_type"`
	Scope              string `json:"scope,omitempty" form:"scope"`
	Audience           string `json:"audience,omitempty" form:"audience"`
}

TokenRequest is the OAuth2 token request.

type TokenResponse

type TokenResponse struct {
	AccessToken string `json:"access_token"`
	// TokenType is "Bearer" for an unbound token and "DPoP" for a bound one
	// (RFC 9449 section 5). A spec-compliant client reads this to decide
	// whether to attach proofs, so getting it wrong makes binding invisible
	// to exactly the clients that implement it correctly.
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token,omitempty"`
	Scope        string `json:"scope,omitempty"`
	// IssuedTokenType is required by RFC 8693 section 2.2.1 and omitted by
	// every other grant, which is why it carries omitempty.
	IssuedTokenType string `json:"issued_token_type,omitempty"`
	// IDToken is the OpenID Connect id_token, present only when the grant
	// is for a user and the granted scopes include openid.
	IDToken string `json:"id_token,omitempty"`
}

TokenResponse is the OAuth2 token endpoint response.

type UpdateClientRequest

type UpdateClientRequest struct {
	ClientID string `path:"clientId" json:"-"`

	Name         *string   `json:"name,omitempty"`
	RedirectURIs *[]string `json:"redirect_uris,omitempty"`
	Scopes       *[]string `json:"scopes,omitempty"`
	GrantTypes   *[]string `json:"grant_types,omitempty"`
	Resources    *[]string `json:"resources,omitempty"`

	// These three are bound only so they can be refused. Leaving them out of
	// the struct would make encoding/json discard them silently, and a
	// caller who sends {"public": true} would get a 200 describing a change
	// that never happened. app_id is immutable because re-parenting a client
	// moves it across a tenancy boundary; public is immutable because
	// flipping it mints or destroys a secret, which belongs on the rotation
	// route; client_id is the value this endpoint exists to preserve.
	BodyClientID *string `json:"client_id,omitempty"`
	BodyAppID    *string `json:"app_id,omitempty"`
	BodyPublic   *bool   `json:"public,omitempty"`

	// Public and TokenEndpointAuthMethod encode one fact between them, and
	// models.go is explicit that nothing reads the latter for behaviour:
	// every write site derives one from the other instead. Accepting this
	// field on its own would let the two disagree, which is the bug that
	// comment exists to prevent.
	BodyTokenEndpointAuthMethod *string `json:"token_endpoint_auth_method,omitempty"`

	// FirstParty marks a client the operator owns; authorization skips the
	// consent page for it.
	FirstParty *bool `json:"first_party,omitempty"`
}

UpdateClientRequest is the admin request to edit an existing OAuth2 client.

Every mutable field is a pointer so the handler can distinguish "field absent, leave it alone" from "field present but empty, clear it". A plain []string cannot carry that difference: an omitted key and an explicit [] both unmarshal to nil.

The path-bound client id carries json:"-" so a "client_id" key in the body can never land on it, mirroring UpdateRegistrationRequest. The handler also reads ctx.Param directly rather than trusting this field, so there are two independent protections against the identifier being editable.

type UpdateClientResponse

type UpdateClientResponse struct {
	ID           string   `json:"id"`
	ClientID     string   `json:"client_id"`
	Name         string   `json:"name"`
	RedirectURIs []string `json:"redirect_uris"`
	Scopes       []string `json:"scopes"`
	GrantTypes   []string `json:"grant_types"`
	Resources    []string `json:"resources"`
	Public       bool     `json:"public"`
}

UpdateClientResponse echoes the client as it stands after the edit. It carries no client_secret: this route never issues or rotates one.

type UpdateRegistrationRequest

type UpdateRegistrationRequest struct {
	ClientID string `path:"clientId" json:"-"`

	BodyClientID     string   `json:"client_id,omitempty"`
	BodyClientSecret string   `json:"client_secret,omitempty"`
	RedirectURIs     []string `json:"redirect_uris"`
	ClientName       string   `json:"client_name,omitempty"`
	GrantTypes       []string `json:"grant_types,omitempty"`
	Scope            string   `json:"scope,omitempty"`

	TokenEndpointAuthMethod string   `json:"token_endpoint_auth_method,omitempty"`
	ClientURI               string   `json:"client_uri,omitempty"`
	LogoURI                 string   `json:"logo_uri,omitempty"`
	TOSURI                  string   `json:"tos_uri,omitempty"`
	PolicyURI               string   `json:"policy_uri,omitempty"`
	SoftwareID              string   `json:"software_id,omitempty"`
	SoftwareVersion         string   `json:"software_version,omitempty"`
	Contacts                []string `json:"contacts,omitempty"`
}

UpdateRegistrationRequest is an RFC 7592 client update. The body repeats the full registration; anything omitted is cleared, per RFC 7592 section 2.2, which specifies a replacement and not a merge.

ClientID is tagged json:"-": without it, a body key "clientId" (matched case-insensitively by encoding/json against the Go field name, not against the path parameter name or the RFC wire name "client_id", which binds to the separately tagged BodyClientID below) would land here and overwrite the path-bound value, since BindRequest binds the path first and then decodes the body on top of it. That would let a caller PUT to an arbitrary junk path with {"clientId": "<victim-id>"} in the body and have the section 2.2 client_id check below compare the body against itself instead of against the URL, while the route's rate limiter (keyed on the path segment) charged the junk value instead of the real one.

Two independent protections close this, either sufficient on its own, confirmed by reverting each in isolation and rerunning TestManage_UpdateIgnoresClientIDSmuggledInBody. With the json:"-" tag removed but the handler below still reading ctx.Param("clientId") instead of this field, the test still passes: the handler never looks at this field at all, regardless of what body content lands in it, so restoring the tag was not what made that direction safe, the handler simply never looked here. With the tag restored but the handler changed to read this field instead of ctx.Param, the test also still passes, because the tag alone already keeps this field pinned to the path value. Only removing both at once reproduces the original bug and makes the test fail. So this tag and the handler's ctx.Param read are not a primary fix plus a backup; either one alone already prevents the body from winning.

type UserInfo

type UserInfo struct {
	Sub string `json:"sub"`
	StandardClaims
}

UserInfo is the OIDC userinfo response.

type UserInfoRequest

type UserInfoRequest struct{}

UserInfoRequest is empty (user is determined from bearer token).

Directories

Path Synopsis
Package contract wires the oauth2provider plugin into the forge-dashboard contract registry: the nine intents that manage OAuth2 clients, the issuer an app's id_tokens name, and that issuer's signing keys.
Package contract wires the oauth2provider plugin into the forge-dashboard contract registry: the nine intents that manage OAuth2 clients, the issuer an app's id_tokens name, and that issuer's signing keys.
Package oauth2test provides a backend-agnostic conformance suite for the oauth2provider.Store interface.
Package oauth2test provides a backend-agnostic conformance suite for the oauth2provider.Store interface.

Jump to

Keyboard shortcuts

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