oauth2

package module
v0.0.0-...-18402f8 Latest Latest
Warning

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

Go to latest
Published: May 25, 2026 License: MIT Imports: 47 Imported by: 0

README

PocketBase OAuth2 Provider Plugin (Multi-Tenant Fork)

A fork of pocketbase-ext-oauth2 that makes all OAuth2 provider state per-core.App instance instead of package-level globals. This enables safe use in multi-tenant deployments where multiple PocketBase apps run in the same process.

Turn any PocketBase instance into a fully compliant OAuth 2.1 Authorization Server with OpenID Connect support. Built on top of ory/fosite.

Why this fork?

The upstream plugin stores OAuth2 state (config, Fosite provider, RSA keys, metadata) in package-level variables. If you create multiple core.App instances in the same process — for example, in a multi-tenant pool — each Register() call overwrites the previous app's state. Tokens, clients, and signing keys leak across tenant boundaries.

This fork replaces all globals with an Instance struct stored per-app. Each PocketBase app gets its own isolated OAuth2 provider, key pair, and client registry.

Changes from upstream
  • Multi-tenant safe: All state is scoped to core.App via app.Store()
  • OAuth 2.1 aligned: PKCE required by default; implicit and hybrid flows removed; discovery metadata updated
  • Duplicate registration guard: Calling Register() twice on the same app returns an error
  • Config isolation: Each registration clones the provided *Config so per-app secrets don't leak
  • Breaking API: GetOAuth2Config(), GetOAuth2Store(), IsRegistered(), and RegisterProtectedResourceMetadata() now require app core.App

Requirements

  • Go 1.25+
  • PocketBase v0.36+

Installation

go get github.com/jesposito/pocketbase-ext-oauth2-mt
package main

import (
	"log"
	"os"
	"time"

	oauth2 "github.com/jesposito/pocketbase-ext-oauth2-mt"
	"github.com/pocketbase/pocketbase"
)

func main() {
	app := pocketbase.New()
	app.RootCmd.ParseFlags(os.Args[1:])

	oauth2.MustRegister(app, &oauth2.Config{
		BaseConfig: &oauth2.BaseConfig{
			AccessTokenLifespan:   time.Hour,
			AuthorizeCodeLifespan: time.Minute * 15,
			RefreshTokenScopes:    []string{}, // allow all scopes for refresh tokens
		},
		PathPrefix:                             "/oauth2",
		UserCollection:                         "users",
		EnableRFC7591DynamicClientRegistration: true,
		EnableRFC9728ProtectedResourceMetadata: true,
	})

	if err := app.Start(); err != nil {
		log.Fatal(err)
	}
}

The plugin will automatically run its database migrations on first boot, creating the required system collections.

Multi-tenant usage

Because all state is per-app, you can safely register multiple apps in the same process:

app1 := pocketbase.NewWithConfig(pocketbase.Config{DefaultDataDir: "./tenant_a"})
app2 := pocketbase.NewWithConfig(pocketbase.Config{DefaultDataDir: "./tenant_b"})

oauth2.MustRegister(app1, &oauth2.Config{...})
oauth2.MustRegister(app2, &oauth2.Config{...})

Each app has its own signing keys, client registry, and session store.

Multiple OPs on the same app

A single core.App can host more than one OAuth2 provider by calling Register() with distinct PathPrefix values. This is useful when a tenant needs separate OPs for different audiences (e.g. admin tooling vs end-customer logins) that authenticate against different PocketBase auth collections:

// Admin OP — authenticates against the `users` collection.
oauth2.MustRegister(app, &oauth2.Config{
    BaseConfig:     baseCfg,
    PathPrefix:     "/oauth2/admin",
    UserCollection: "users",
})

// Members OP — same app, different prefix, different user collection.
oauth2.MustRegister(app, &oauth2.Config{
    BaseConfig:     baseCfg,
    PathPrefix:     "/oauth2/members",
    UserCollection: "members",
})

Each prefix gets its own Instance (config, fosite provider, RSA key reference), but the underlying session collections (_oauth2Clients, _oauth2Access, …) are shared across prefixes on the same app. Lookup helpers have prefix-aware variants:

  • GetOAuth2ConfigAt(app, prefix)
  • GetOAuth2StoreAt(app, prefix)
  • IsRegisteredAt(app, prefix)
  • RegisterProtectedResourceMetadataAt(app, prefix, md)
  • RequireScopeAt(app, prefix, scopes...)
  • DeregisterAt(app, prefix)

The zero-suffix versions (GetOAuth2Config(app), RequireScope(app, …), etc.) remain unchanged and continue to resolve the OP at the default /oauth2 prefix, so existing single-OP integrations keep working.


Endpoints

All OAuth2 endpoints are served under the configured PathPrefix (default /oauth2).

Method Path Description
GET/POST /oauth2/auth Authorization endpoint
GET/POST /oauth2/token Token endpoint
POST /oauth2/revoke Token revocation
POST /oauth2/introspect Token introspection
GET/POST /oauth2/userinfo OpenID Connect UserInfo
POST /oauth2/register Dynamic client registration (RFC 7591, optional)
GET/POST /oauth2/login Built-in login/consent UI
Discovery & Metadata
Method Path Description
GET /.well-known/oauth-authorization-server Authorization Server Metadata (RFC 8414)
GET /.well-known/openid-configuration OpenID Connect Discovery
GET /.well-known/jwks.json JSON Web Key Set
GET /.well-known/oauth-protected-resource/{resource} Protected Resource Metadata (RFC 9728, optional)

How It Works

Access Tokens

Access tokens issued by this plugin are native PocketBase auth tokens. This means any PocketBase endpoint or middleware that accepts a standard auth token will work out of the box with OAuth2-issued access tokens — no additional configuration needed.

Key Management

On first bootstrap the plugin generates an RSA (RS256) signing key pair and a global HMAC secret, both stored in PocketBase's internal _params table. These persist across restarts and are used for signing ID tokens, authorization codes, and refresh tokens respectively. In multi-tenant mode, each tenant gets its own independent key pair.

Envelope encryption at rest

If OAUTH2_MASTER_KEY is set (raw 32-byte, base64, or hex), all OAuth signing key material in _params is sealed with AES-256-GCM, keyed off a per-(param, app) DEK derived via HKDF. Each tenant gets its own stable HKDF context (a UUID persisted at oauth2_envelope_ctx_id in _params) so moving or renaming the PocketBase data directory does NOT invalidate existing envelopes.

Rotating the master key

Set both env vars at the same time during the rotation window:

export OAUTH2_MASTER_KEY=<new-active-key>
export OAUTH2_MASTER_KEY_OLD=<old-key>[,<older-key>...]

On the next load of each _params row the plugin:

  1. Decrypts using whichever master matches the envelope's kid (consulting both the active key and any keys listed in OAUTH2_MASTER_KEY_OLD).
  2. Lazily re-encrypts the row under the active key (compare-and-swap on the row value, so concurrent loaders cannot last-writer-wins each other).
  3. Updates the fingerprint sentinel to the active key.

Once every encrypted row has been touched once after the rotation, OAUTH2_MASTER_KEY_OLD can be removed. Until then, the old key is still required to read any row that has not yet been rewrapped.

Session Storage

OAuth2 session data (authorization codes, access tokens, refresh tokens, PKCE challenges, and OpenID Connect sessions) is stored in dedicated system collections that are automatically created by the plugin's migration. A cron job runs every hour to clean up expired sessions.

The plugin creates the following system collections automatically:

Collection Purpose
_oauth2Clients Registered OAuth2 client applications
_oauth2AuthCode Authorization code sessions
_oauth2Access Access token sessions
_oauth2Refresh Refresh token sessions
_oauth2PKCE PKCE challenge data
_oauth2OpenID OpenID Connect sessions
_oauth2JTI JWT Token Identifiers (for replay protection)
Custom UserInfo Claims

By default the /userinfo endpoint attempts a best-effort extraction of default OpenID claims from the authenticated user's PocketBase auth record. If you have non-standard column names or other requirements, you can customize the claim response by implementing the UserInfoClaimStrategy interface and returning any struct or map — it will be JSON-encoded in the /userinfo response.

type MyCustomClaims struct {
    Sub   string `json:"sub"`
    Email string `json:"email"`
}

type MyClaimStrategy struct{}

func (s *MyClaimStrategy) GetUserInfoClaims(e *core.RequestEvent, scopes []string) (interface{}, error) {
	return &MyCustomClaims{
        Sub:   e.Auth.ID,
        Email: e.Auth.GetString("email"),
	}, nil
}

oauth2.MustRegister(app, &oauth2.Config{
	// ...
	UserInfoClaimStrategy: &MyClaimStrategy{},
})
Protected Resource Metadata (RFC 9728)

You can register additional protected resources so clients can discover your resource server metadata. Because this fork is app-scoped, you must pass the app:

oauth2.RegisterProtectedResourceMetadata(app,
	&rfc9728.ProtectedResourceMetadata{
		Resource:               "https://api.example.com/data",
		AuthorizationServers:   []string{"https://auth.example.com"},
		BearerMethodsSupported: []string{"header"},
		ScopesSupported:        []string{"read", "write"},
	},
)

The metadata will be available at /.well-known/oauth-protected-resource/data.


Scope & Roadmap

What is in scope
  • OAuth 2.1 authorization code flow with PKCE (PKCE required by default)
  • OpenID Connect Core 1.0 (authorization code flow + ID tokens, RS256)
  • Discovery via RFC 8414 (OAuth Authorization Server Metadata) and OpenID Connect Discovery 1.0
  • Dynamic Client Registration (RFC 7591, optional)
  • Protected Resource Metadata (RFC 9728, optional)
  • Token revocation (RFC 7009) and introspection (RFC 7662)
  • RFC 9207 Authorization Response Issuer Identification (iss on success and error redirects)
  • Refresh-token rotation with reuse detection (refresh-token family tracking)
  • Envelope encryption at rest (AES-256-GCM) for OAuth signing key material in _params, keyed off OAUTH2_MASTER_KEY
What is deliberately out of scope (v1)
  • Pushed Authorization Requests (PAR, RFC 9126) — not required by OAuth 2.1 baseline. PAR is a FAPI / high-assurance profile feature. Adding PAR would require a fosite PAR factory, a /oauth2/par endpoint, PARStorage, and pushed_authorization_request_endpoint discovery metadata. Revisit if FAPI conformance becomes a goal.
  • DPoP sender-constrained tokens (RFC 9449) — out of scope for the same reason. DPoP is a FAPI / mobile-app-protected-resource feature. Revisit if FAPI conformance becomes a goal.
  • mTLS-bound tokens (RFC 8705) — same reasoning.

These decisions are recorded here so reviewers don't re-raise them. The plugin tracks OAuth 2.1 baseline + commonly-deployed extensions; FAPI work is a separate epic.


License

This project is licensed under the MIT License. See the LICENSE file for details.

Documentation

Index

Constants

View Source
const (
	RefreshStatusActive  = "active"
	RefreshStatusRotated = "rotated"
	RefreshStatusRevoked = "revoked"
	RefreshStatusReused  = "reused"
)

RefreshStatus values for the RefreshTokenModel.status column.

View Source
const DefaultPathPrefix = "/oauth2"

DefaultPathPrefix is the path prefix used when Config.PathPrefix is empty and when callers pass an empty prefix to the *At lookup helpers. Public constant so callers can reference the default consistently.

View Source
const ScopeContextKey = "oauth_granted_scopes"

ScopeContextKey is the key under which RequireScope stores the granted scopes slice on the RequestEvent for downstream handlers to read via e.Get(ScopeContextKey).

Variables

This section is empty.

Functions

func ConsentCovers

func ConsentCovers(granted, requested []string) bool

ConsentCovers reports whether `granted` includes every scope in `requested`. Empty requested → true (no scopes to consent to).

func CreateInteraction

func CreateInteraction(app core.App, in *Interaction) (string, error)

CreateInteraction persists a new pending authorization for later completion. Returns the generated interaction id.

func DeleteInteraction

func DeleteInteraction(app core.App, id string) error

DeleteInteraction removes a completed (or denied) interaction row so it cannot be replayed.

func Deregister

func Deregister(app core.App)

Deregister removes ALL of the plugin's per-app state from the given core.App and clears the registrationGuard entries for every prefix the app was registered at. Use this in long-running processes that create and destroy tenant apps dynamically — otherwise registrationGuard accumulates stale interface-value entries for the lifetime of the process.

After Deregister, Register may be called again on the same app value. HTTP handlers and cron jobs already bound to the app remain bound; this only releases the plugin's bookkeeping state. For full teardown, drop the core.App itself. Use DeregisterAt to release a single prefix.

func DeregisterAt

func DeregisterAt(app core.App, prefix string)

DeregisterAt is the prefix-aware variant of Deregister. It releases just the (app, prefix) registration so other prefixes on the same app remain operational.

func IsRegistered

func IsRegistered(app core.App) bool

func IsRegisteredAt

func IsRegisteredAt(app core.App, prefix string) bool

IsRegisteredAt reports whether an OP is bound at the given prefix.

func MustRegister

func MustRegister(app core.App, config *Config)

func NewClientFromRFC7591Metadata

func NewClientFromRFC7591Metadata(app core.App, md *RFC7591ClientMetadataRequest) (*client.Client, string, error)

func Register

func Register(app core.App, config *Config) error

func RegisterProtectedResourceMetadata

func RegisterProtectedResourceMetadata(app core.App, md *rfc9728.ProtectedResourceMetadata)

RegisterProtectedResourceMetadata registers a protected resource metadata entry for the OAuth2 instance at DefaultPathPrefix on the given app. Use RegisterProtectedResourceMetadataAt for non-default prefixes (e.g. when an app hosts multiple OPs).

func RegisterProtectedResourceMetadataAt

func RegisterProtectedResourceMetadataAt(app core.App, prefix string, md *rfc9728.ProtectedResourceMetadata)

RegisterProtectedResourceMetadataAt is the prefix-aware variant.

func RequireScope

func RequireScope(app core.App, requiredScopes ...string) *hook.Handler[*core.RequestEvent]

RequireScope returns a router middleware that asserts the bearer token in the Authorization header has been granted ALL of the listed scopes. On success it sets the granted-scopes slice on e.Set(ScopeContextKey) for downstream handlers and calls Next. On failure it returns an RFC 6750 "insufficient_scope" 403 with the missing scopes listed in the WWW-Authenticate header.

Header-only by design: the token is read from "Authorization: Bearer <token>" only. Form-body and URL-query token parameters are deliberately rejected because URL-query tokens leak into access logs, browser history, and Referer headers (RFC 6750 §5.3 SHOULD-NOT) and form-body tokens trigger CORS preflight in browser clients. If you need form/query token support, use fosite.AccessTokenFromRequest directly in your own middleware.

This is opt-in: by default OAuth-issued access tokens are valid PocketBase auth tokens with no scope check on PB-native endpoints. Attach RequireScope to your own resource routes when you want OAuth scope to actually gate access. Example:

se.Router.GET("/api/widgets", listWidgetsHandler).
    Bind(oauth2.RequireScope(app, "widgets:read"))

func RequireScopeAt

func RequireScopeAt(app core.App, prefix string, requiredScopes ...string) *hook.Handler[*core.RequestEvent]

RequireScopeAt is the prefix-aware variant of RequireScope. Use it on resource routes that should be gated by an OP registered at a non- default path prefix (e.g. /oauth2/members).

func ResetGlobalStateForTests

func ResetGlobalStateForTests()

ResetGlobalStateForTests is deprecated and no-op. State is now per-app; use ResetStateForTests(app) instead.

func ResetStateForTests

func ResetStateForTests(app core.App)

ResetStateForTests removes the OAuth2 instance from the app's store. Use this in tests that need a clean slate.

func RevokedTokenGuard

func RevokedTokenGuard(app core.App) *hook.Handler[*core.RequestEvent]

RevokedTokenGuard returns a router middleware that rejects bearer tokens which have no corresponding _oauth2Access row — i.e. tokens that were revoked via /oauth2/revoke or expired by the cleanup cron.

PocketBase native auth tokens are stateless JWTs validated against the PB signing secret; a revoked OAuth2 access token remains a syntactically valid PB token until its natural exp. Routes using apis.RequireAuth() alone will accept revoked tokens. Bind RevokedTokenGuard wherever you need OAuth-side revocation to take effect on a PB-native route:

se.Router.GET("/api/private", privateHandler).
    Bind(apis.RequireAuth("users")).
    Bind(oauth2.RevokedTokenGuard(app))

Tokens issued outside the OAuth flow (e.g. PB built-in auth via /api/collections/users/auth-with-password) have no _oauth2Access row and would also be rejected by this middleware — by design. Use it only on routes that should accept ONLY OAuth-issued tokens.

Types

type AccessTokenModel

type AccessTokenModel struct {
	BaseSessionModel
}

func (*AccessTokenModel) GetCollectionName

func (p *AccessTokenModel) GetCollectionName() string

type AuthCodeModel

type AuthCodeModel struct {
	BaseSessionModel
}

func (*AuthCodeModel) GetCollectionName

func (p *AuthCodeModel) GetCollectionName() string

type BaseConfig

type BaseConfig = fosite.Config

type BaseSessionModel

type BaseSessionModel struct {
	core.BaseRecordProxy
}

func (BaseSessionModel) GetClientID

func (m BaseSessionModel) GetClientID() string

func (BaseSessionModel) GetExpiresAt

func (m BaseSessionModel) GetExpiresAt() *time.Time

func (BaseSessionModel) GetFormData

func (m BaseSessionModel) GetFormData() string

func (BaseSessionModel) GetGrantedAudience

func (m BaseSessionModel) GetGrantedAudience() []string

func (BaseSessionModel) GetGrantedScopes

func (m BaseSessionModel) GetGrantedScopes() []string

func (BaseSessionModel) GetID

func (m BaseSessionModel) GetID() string

func (BaseSessionModel) GetRequestID

func (m BaseSessionModel) GetRequestID() string

func (BaseSessionModel) GetRequestedAt

func (m BaseSessionModel) GetRequestedAt() time.Time

func (BaseSessionModel) GetRequestedAudience

func (m BaseSessionModel) GetRequestedAudience() []string

func (BaseSessionModel) GetScopes

func (m BaseSessionModel) GetScopes() []string

func (BaseSessionModel) GetSessionData

func (m BaseSessionModel) GetSessionData() []byte

func (BaseSessionModel) GetSubject

func (m BaseSessionModel) GetSubject() string

func (BaseSessionModel) SetRequester

func (m BaseSessionModel) SetRequester(requester fosite.Requester, tokenType fosite.TokenType) error

func (BaseSessionModel) SetSignature

func (m BaseSessionModel) SetSignature(signature string)

func (BaseSessionModel) ToRequest

func (m BaseSessionModel) ToRequest(ctx context.Context, s *OAuth2Store, session fosite.Session) (*fosite.Request, error)

type ClientModel

type ClientModel struct {
	core.BaseRecordProxy
}

func NewClientModel

func NewClientModel(app core.App) *ClientModel

func (*ClientModel) ToClient

func (m *ClientModel) ToClient() (*client.Client, error)

type Config

type Config struct {
	*BaseConfig

	PathPrefix                             string
	UserCollection                         string
	UserInfoClaimStrategy                  UserInfoClaimStrategy
	EnableRFC7591DynamicClientRegistration bool
	EnableRFC9728ProtectedResourceMetadata bool

	// MasterKeyProvider supplies the at-rest encryption master key for
	// envelope-encrypting OAuth2 key material in _params. If nil, the
	// DefaultMasterKeyProvider (reads OAUTH2_MASTER_KEY env) is used.
	// When the provider returns a nil master, encryption is disabled
	// and values are stored in legacy plaintext form (dev / back-compat).
	MasterKeyProvider MasterKeyProvider

	// DynamicClientRegistrationInitialAccessTokens, when EnableRFC7591…
	// is true, gates the /oauth2/register endpoint behind RFC 7591 §3
	// Initial Access Tokens. Each entry is a bearer token operators must
	// hand out to legitimate registration callers. Requests without an
	// Authorization: Bearer <token> header matching one of these values
	// are rejected with 401.
	//
	// Nil OR empty disables the gate, BUT the plugin then refuses to
	// register the /register route at all unless
	// AllowUnauthenticatedDynamicClientRegistration is also set (a loud
	// opt-in for development environments where rate-limit + network
	// boundary already gate the endpoint).
	DynamicClientRegistrationInitialAccessTokens []string

	// AllowUnauthenticatedDynamicClientRegistration acknowledges that DCR
	// is intentionally exposed with no Initial Access Token requirement.
	// REQUIRED when EnableRFC7591DynamicClientRegistration is true and
	// DynamicClientRegistrationInitialAccessTokens is empty; otherwise
	// the /register route is silently NOT bound.
	//
	// Use only in development or in environments where the registration
	// endpoint is reachable solely from a trusted network segment. Public
	// deployments should always populate InitialAccessTokens instead.
	AllowUnauthenticatedDynamicClientRegistration bool

	// LoginRedirectURL — when non-empty, GET/POST /oauth2/login redirects
	// (302) to this URL with the original query string (including
	// interaction_id) appended. The destination page is the consumer's
	// responsibility to render: authenticate the user against the right
	// collection, then POST back to /oauth2/login/complete with the
	// obtained pb_token + decision + consented_scopes.
	//
	// Empty (default) — the bundled plugin UI at /oauth2/login is served.
	//
	// This is the integration hook that lets a multi-tenant host re-use
	// its own branded /members/login or /admin/login flow instead of
	// presenting end users with two distinct login screens.
	//
	// Relative URLs ("/members/login") and absolute URLs
	// ("https://other.host/login") are both honored. The plugin does NOT
	// validate the destination — the consumer is trusted to point at a
	// page it controls.
	LoginRedirectURL string
}

func GetOAuth2Config

func GetOAuth2Config(app core.App) *Config

GetOAuth2Config returns the Config for the OP registered at DefaultPathPrefix on the given app. Use GetOAuth2ConfigAt when the app hosts multiple OPs at different prefixes.

func GetOAuth2ConfigAt

func GetOAuth2ConfigAt(app core.App, prefix string) *Config

GetOAuth2ConfigAt is the prefix-aware variant of GetOAuth2Config.

type Consent struct {
	ID             string
	UserID         string
	UserCollection string
	ClientID       string
	GrantedScopes  []string
	GrantedAt      time.Time
}

Consent represents a single user's explicit grant of a scope set to a given client. Subsequent prompt=none flows from the same client can proceed silently only when the requested scopes are a subset of an existing Consent. (mci)

func FindConsent

func FindConsent(app core.App, userID, userCollection, clientID string) (*Consent, error)

FindConsent returns the most recent consent row for (user, client) or (nil, nil) if none exists.

func UpsertConsent

func UpsertConsent(app core.App, userID, userCollection, clientID string, newScopes []string) (*Consent, error)

UpsertConsent merges newly-granted scopes into the (user, client) row, creating it if absent. Returns the merged Consent.

type DefaultUserInfoClaimStrategy

type DefaultUserInfoClaimStrategy struct{}

func (*DefaultUserInfoClaimStrategy) GetUserInfoClaims

func (d *DefaultUserInfoClaimStrategy) GetUserInfoClaims(e *core.RequestEvent, scopes []string) (interface{}, error)

GetUserInfoClaims implements UserInfoClaimStrategy.

type Instance

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

Instance holds all OAuth2 provider state for a single (app, prefix) pair. It replaces the upstream package-level globals to enable safe multi-tenant usage where multiple core.App instances run in the same process; with the per-prefix store key, a single app can also host multiple OPs at different path prefixes.

func (*Instance) RegisterProtectedResourceMetadata

func (inst *Instance) RegisterProtectedResourceMetadata(md *rfc9728.ProtectedResourceMetadata)

RegisterProtectedResourceMetadata registers a protected resource metadata entry for this OAuth2 instance.

Safe to call before app bootstrap: the entry is buffered in inst.protected and the discovery metadata's ScopesSupported is merged later in loadParams once inst.metadata exists.

type Interaction

type Interaction struct {
	ID              string
	ClientID        string
	ClientName      string
	UserCollection  string
	RedirectURI     string
	RequestForm     url.Values
	RequestedScopes []string
	Prompt          string
	RequestedAt     time.Time
	ExpiresAt       time.Time
}

Interaction is the server-owned snapshot of a pending authorization request. It replaces the previous browser-controlled base64 JSON `state` parameter. The UI references each interaction by an opaque id, and the server reconstructs the original authorization request from request_form when completing the flow — so a malicious /login URL cannot smuggle in an attacker-chosen redirect_uri (lr7).

func FindInteraction

func FindInteraction(app core.App, id string) (*Interaction, error)

FindInteraction loads + validates an interaction by id. Returns an error if the row is missing or expired; in both cases the UI should treat the interaction as gone and force the user back through /auth.

type JTIModel

type JTIModel struct {
	core.BaseRecordProxy
}

func NewJTIModel

func NewJTIModel(app core.App) *JTIModel

type MasterKeyProvider

type MasterKeyProvider interface {
	// Master returns the 32-byte raw master key. If encryption-at-rest is
	// disabled, Master must return (nil, nil) -- callers treat that as
	// "plaintext mode".
	Master(ctx context.Context) ([]byte, error)
	// Fingerprint returns a short stable hex identifier for the master
	// key (first 8 bytes of its SHA-256). Returns ("", nil) when no
	// master is configured.
	Fingerprint(ctx context.Context) (string, error)
}

MasterKeyProvider abstracts the source of the at-rest encryption master key. The default implementation reads from the OAUTH2_MASTER_KEY env var, but a KMS-backed provider can be supplied via Config.MasterKeyProvider.

var DefaultMasterKeyProvider MasterKeyProvider = envMasterKeyProvider{}

DefaultMasterKeyProvider is the provider used when Config.MasterKeyProvider is nil. It reads OAUTH2_MASTER_KEY at call time (not at init), so tests can mutate the env between calls.

type MasterKeyringProvider

type MasterKeyringProvider interface {
	Keyring(ctx context.Context) (map[string][]byte, error)
}

MasterKeyringProvider is an optional extension implemented by providers that retain historical (decrypt-only) master keys during a rotation. The plugin uses Keyring to:

  • decrypt envelopes whose embedded kid does not match the active master fingerprint (i.e. envelopes written before the rotation);
  • re-encrypt those envelopes against the active master on the next read (lazy rewrap).

Implementations must include the active master in the returned map. The map is keyed by the per-master fingerprint (as returned by fingerprintOf).

type OAuth2Store

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

func GetOAuth2Store

func GetOAuth2Store(app core.App) *OAuth2Store

func GetOAuth2StoreAt

func GetOAuth2StoreAt(app core.App, prefix string) *OAuth2Store

GetOAuth2StoreAt is the prefix-aware variant of GetOAuth2Store.

func NewOAuth2Store

func NewOAuth2Store(app core.App) *OAuth2Store

func (*OAuth2Store) ClientAssertionJWTValid

func (s *OAuth2Store) ClientAssertionJWTValid(ctx context.Context, jti string) error

ClientAssertionJWTValid implements fosite.ClientManager.

func (*OAuth2Store) CreateAccessTokenSession

func (s *OAuth2Store) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error)

CreateAccessTokenSession implements oauth2.AccessTokenStorage.

func (*OAuth2Store) CreateAuthorizeCodeSession

func (s *OAuth2Store) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) (err error)

CreateAuthorizeCodeSession implements oauth2.AuthorizeCodeStorage.

func (*OAuth2Store) CreateOpenIDConnectSession

func (s *OAuth2Store) CreateOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) error

CreateOpenIDConnectSession implements openid.OpenIDConnectRequestStorage.

func (*OAuth2Store) CreatePKCERequestSession

func (s *OAuth2Store) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error

CreatePKCERequestSession implements [pkce.PKCERequestStorage].

func (*OAuth2Store) CreateRefreshTokenSession

func (s *OAuth2Store) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) (err error)

CreateRefreshTokenSession implements oauth2.RefreshTokenStorage.

Family-tracking behavior:

  • On an authorization_code-grant issuance (chain root), a fresh family_id is generated and parent_refresh_id is left empty.
  • On a refresh_token-grant rotation, the predecessor row (already marked status=rotated by RotateRefreshToken) is located by request_id and the new row inherits its family_id with parent_refresh_id set to the predecessor's record id.
  • status defaults to active.

func (*OAuth2Store) DeleteAccessTokenSession

func (s *OAuth2Store) DeleteAccessTokenSession(ctx context.Context, signature string) (err error)

DeleteAccessTokenSession implements oauth2.AccessTokenStorage.

func (*OAuth2Store) DeleteOpenIDConnectSession

func (s *OAuth2Store) DeleteOpenIDConnectSession(ctx context.Context, authorizeCode string) error

DeleteOpenIDConnectSession implements openid.OpenIDConnectRequestStorage.

func (*OAuth2Store) DeletePKCERequestSession

func (s *OAuth2Store) DeletePKCERequestSession(ctx context.Context, signature string) error

DeletePKCERequestSession implements [pkce.PKCERequestStorage].

func (*OAuth2Store) DeleteRefreshTokenSession

func (s *OAuth2Store) DeleteRefreshTokenSession(ctx context.Context, signature string) (err error)

DeleteRefreshTokenSession implements oauth2.RefreshTokenStorage.

func (*OAuth2Store) GetAccessTokenSession

func (s *OAuth2Store) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error)

GetAccessTokenSession implements oauth2.AccessTokenStorage.

func (*OAuth2Store) GetAuthorizeCodeSession

func (s *OAuth2Store) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (request fosite.Requester, err error)

GetAuthorizeCodeSession implements oauth2.AuthorizeCodeStorage.

func (*OAuth2Store) GetClient

func (s *OAuth2Store) GetClient(ctx context.Context, id string) (fosite.Client, error)

GetClient implements fosite.ClientManager.

func (*OAuth2Store) GetOpenIDConnectSession

func (s *OAuth2Store) GetOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) (fosite.Requester, error)

GetOpenIDConnectSession implements openid.OpenIDConnectRequestStorage.

func (*OAuth2Store) GetPKCERequestSession

func (s *OAuth2Store) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetPKCERequestSession implements [pkce.PKCERequestStorage].

func (*OAuth2Store) GetRefreshTokenSession

func (s *OAuth2Store) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error)

GetRefreshTokenSession implements oauth2.RefreshTokenStorage.

Reuse detection: if the row exists but its status is not "active" the token is being replayed. We invalidate every row in the same family (status=reused, reused_at=now) and delete every access token issued against any row in the family, then return fosite.ErrInactiveToken so the upstream refresh-grant handler treats it as reuse per RFC 6819 section 5.2.2.3.

func (*OAuth2Store) InvalidateAuthorizeCodeSession

func (s *OAuth2Store) InvalidateAuthorizeCodeSession(ctx context.Context, code string) (err error)

InvalidateAuthorizeCodeSession implements oauth2.AuthorizeCodeStorage.

func (*OAuth2Store) RegisterClient

func (s *OAuth2Store) RegisterClient(ctx context.Context, client *RFC7591ClientMetadataRequest) (fosite.Client, string, error)

RegisterClient implements RFC7591ClientStorage.

func (*OAuth2Store) RevokeAccessToken

func (s *OAuth2Store) RevokeAccessToken(ctx context.Context, requestID string) error

RevokeAccessToken implements oauth2.AccessTokenStorage.

func (*OAuth2Store) RevokeRefreshToken

func (s *OAuth2Store) RevokeRefreshToken(ctx context.Context, requestID string) error

RevokeRefreshToken implements oauth2.TokenRevocationStorage.

The row is marked status=revoked instead of being deleted so a later replay of the same signature is still caught as reuse against the family breadcrumb. The cleanup cron deletes the row eventually via expires_at.

func (*OAuth2Store) RotateRefreshToken

func (s *OAuth2Store) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) (err error)

RotateRefreshToken implements oauth2.RefreshTokenStorage.

Marks the predecessor refresh row as rotated (keeping the family breadcrumb for reuse detection) and deletes the predecessor access token by request_id. The replacement refresh row is created by the upstream handler in a follow-up CreateRefreshTokenSession call.

func (*OAuth2Store) SetClientAssertionJWT

func (s *OAuth2Store) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error

SetClientAssertionJWT implements fosite.ClientManager.

type OpenIDConnectSessionModel

type OpenIDConnectSessionModel struct {
	BaseSessionModel
}

func (*OpenIDConnectSessionModel) GetCollectionName

func (p *OpenIDConnectSessionModel) GetCollectionName() string

type PKCEModel

type PKCEModel struct {
	BaseSessionModel
}

func (*PKCEModel) GetCollectionName

func (p *PKCEModel) GetCollectionName() string

type PocketBaseStrategy

type PocketBaseStrategy struct {
	App    core.App
	Config interface {
		fosite.AccessTokenIssuerProvider
		fosite.AccessTokenLifespanProvider
		fosite.JWTScopeFieldProvider
	}
	HMACSHAStrategy fositeoauth2.CoreStrategy
}

func NewPocketBaseStrategy

func NewPocketBaseStrategy(app core.App, config fosite.Configurator) *PocketBaseStrategy

func (*PocketBaseStrategy) AccessTokenSignature

func (s *PocketBaseStrategy) AccessTokenSignature(ctx context.Context, token string) string

AccessTokenSignature implements oauth2.CoreStrategy.

func (*PocketBaseStrategy) AuthorizeCodeSignature

func (s *PocketBaseStrategy) AuthorizeCodeSignature(ctx context.Context, token string) string

AuthorizeCodeSignature implements oauth2.CoreStrategy.

func (*PocketBaseStrategy) GenerateAccessToken

func (s *PocketBaseStrategy) GenerateAccessToken(ctx context.Context, requester fosite.Requester) (token string, signature string, err error)

GenerateAccessToken implements oauth2.CoreStrategy.

func (*PocketBaseStrategy) GenerateAuthorizeCode

func (s *PocketBaseStrategy) GenerateAuthorizeCode(ctx context.Context, requester fosite.Requester) (token string, signature string, err error)

GenerateAuthorizeCode implements oauth2.CoreStrategy.

func (*PocketBaseStrategy) GenerateRefreshToken

func (s *PocketBaseStrategy) GenerateRefreshToken(ctx context.Context, requester fosite.Requester) (token string, signature string, err error)

GenerateRefreshToken implements oauth2.CoreStrategy.

func (*PocketBaseStrategy) RefreshTokenSignature

func (s *PocketBaseStrategy) RefreshTokenSignature(ctx context.Context, token string) string

RefreshTokenSignature implements oauth2.CoreStrategy.

func (*PocketBaseStrategy) ValidateAccessToken

func (s *PocketBaseStrategy) ValidateAccessToken(ctx context.Context, requester fosite.Requester, token string) error

ValidateAccessToken implements oauth2.CoreStrategy.

Two checks: (1) the PB native JWT must validate (cryptographic + expiry), and (2) the corresponding _oauth2Access session row must still exist. The second check is what makes /oauth2/revoke actually effective for callers that reach this function (fosite introspection + the local RequireScope middleware). PB-native routes that use apis.RequireAuth() bypass this entirely — wire RevokedTokenGuard() on those routes when you need revocation to take effect there too.

func (*PocketBaseStrategy) ValidateAuthorizeCode

func (s *PocketBaseStrategy) ValidateAuthorizeCode(ctx context.Context, requester fosite.Requester, token string) (err error)

ValidateAuthorizeCode implements oauth2.CoreStrategy.

func (*PocketBaseStrategy) ValidateRefreshToken

func (s *PocketBaseStrategy) ValidateRefreshToken(ctx context.Context, requester fosite.Requester, token string) (err error)

ValidateRefreshToken implements oauth2.CoreStrategy.

type RFC7591ClientMetadata

type RFC7591ClientMetadata struct {
	RFC7591ClientMetadataRequest
	ClientID              string `json:"client_id"`
	ClientSecret          string `json:"client_secret"`
	ClientSecretExpiresAt int64  `json:"client_secret_expires_at"`
}

type RFC7591ClientMetadataRequest

type RFC7591ClientMetadataRequest struct {
	Scope                   string              `json:"scope"`
	RedirectURIs            []string            `json:"redirect_uris"`
	TokenEndpointAuthMethod string              `json:"token_endpoint_auth_method"`
	GrantTypes              []string            `json:"grant_types"`
	ResponseTypes           []string            `json:"response_types"`
	Contacts                []string            `json:"contacts,omitempty"`
	ClientName              string              `json:"client_name"`
	ClientURI               string              `json:"client_uri,omitempty"`
	LogoURI                 string              `json:"logo_uri,omitempty"`
	TermsOfServiceURI       string              `json:"tos_uri,omitempty"`
	PolicyURI               string              `json:"policy_uri,omitempty"`
	JwksURI                 string              `json:"jwks_uri,omitempty"`
	Jwks                    *jose.JSONWebKeySet `json:"jwks,omitempty"`
	SoftwareID              string              `json:"software_id,omitempty"`
	SoftwareVersion         string              `json:"software_version,omitempty"`

	// The following fields are not part of the RFC7591 but are required for OpenID Connect client registration.
	// @ref https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.6.2
	RequestURIs []string `json:"request_uris,omitempty"`
}

type RFC7591ClientStorage

type RFC7591ClientStorage interface {
	// RegisterClient registers a new client with the given metadata
	// and returns the created client or an error if the registration failed.
	RegisterClient(ctx context.Context, client *RFC7591ClientMetadataRequest) (fosite.Client, string, error)
}

type RefreshTokenModel

type RefreshTokenModel struct {
	BaseSessionModel
}

func (*RefreshTokenModel) GetCollectionName

func (p *RefreshTokenModel) GetCollectionName() string

func (*RefreshTokenModel) GetFamilyID

func (m *RefreshTokenModel) GetFamilyID() string

func (*RefreshTokenModel) GetParentRefreshID

func (m *RefreshTokenModel) GetParentRefreshID() string

func (*RefreshTokenModel) GetReusedAt

func (m *RefreshTokenModel) GetReusedAt() int64

func (*RefreshTokenModel) GetRotatedAt

func (m *RefreshTokenModel) GetRotatedAt() int64

func (*RefreshTokenModel) GetStatus

func (m *RefreshTokenModel) GetStatus() string

func (*RefreshTokenModel) SetFamilyID

func (m *RefreshTokenModel) SetFamilyID(v string)

func (*RefreshTokenModel) SetParentRefreshID

func (m *RefreshTokenModel) SetParentRefreshID(v string)

func (*RefreshTokenModel) SetReusedAt

func (m *RefreshTokenModel) SetReusedAt(ts int64)

func (*RefreshTokenModel) SetRotatedAt

func (m *RefreshTokenModel) SetRotatedAt(ts int64)

func (*RefreshTokenModel) SetStatus

func (m *RefreshTokenModel) SetStatus(v string)

type Session

type Session struct {
	fositeopenid.DefaultSession

	CollectionId string `json:"collection,omitempty"`
}

func NewSession

func NewSession(app core.App, recordId string, collectionId string) *Session

NewSession constructs a Session with issuer + subject metadata populated.

Claims.ExpiresAt is intentionally left as the zero time.Time. fosite's openid/strategy_jwt.go computes the actual ID-token expiry from Config.GetIDTokenLifespan (or per-client overrides) when ExpiresAt is zero; setting an arbitrary 6-hour default here unconditionally over-rode that configuration. Callers that genuinely need a per-session override can still mutate Claims.ExpiresAt before passing the session to fosite.

func (*Session) Clone

func (s *Session) Clone() fosite.Session

Clone returns a deep copy of the session. fosite calls Clone before mutating session state in introspection/refresh flows; sharing a single session pointer would let one request observe another's amr/acr/sub changes. We hand-clone instead of pulling in github.com/mohae/deepcopy (unmaintained since 2017) since the struct is small and fully known.

func (*Session) GetJWTClaims

func (s *Session) GetJWTClaims() jwt.JWTClaimsContainer

type SessionModel

type SessionModel interface {
	core.RecordProxy

	GetCollectionName() string
}

type UserInfoAddressClaim

type UserInfoAddressClaim struct {
	// Formatted
	// Full mailing address, formatted for display or use on a mailing label. This field MAY
	// contain multiple lines, separated by newlines. Newlines can be represented either as a
	// carriage return/line feed pair ("\r\n") or as a single line feed character ("\n").
	Formatted string `json:"formatted,omitempty"`

	// Street Address
	// Full street address component, which MAY include house number, street name, Post Office Box,
	// and multi-line extended street address information. This field MAY contain multiple lines,
	// separated by newlines. Newlines can be represented either as a carriage return/line feed
	// pair ("\r\n") or as a single line feed character ("\n").
	StreetAddress string `json:"street_address,omitempty"`

	// Locality
	// City or locality component.
	Locality string `json:"locality,omitempty"`

	// Region
	// State, province, prefecture, or region component.
	Region string `json:"region,omitempty"`

	// Postal Code
	// Zip code or postal code component.
	PostalCode string `json:"postal_code,omitempty"`

	// Country
	// Country name component.
	Country string `json:"country,omitempty"`
}

func (UserInfoAddressClaim) IsEmpty

func (a UserInfoAddressClaim) IsEmpty() bool

type UserInfoClaimStrategy

type UserInfoClaimStrategy interface {
	GetUserInfoClaims(e *core.RequestEvent, scopes []string) (interface{}, error)
}

UserInfoClaimStrategy defines the interface for retrieving user info claims based on the request event and scopes. This allows for custom implementations to determine how user info claims are populated and returned in the /userinfo endpoint.

type UserInfoClaims

type UserInfoClaims struct {
	// Subject
	// Identifier for the End-User at the Issuer.
	Sub string `json:"sub"`

	// Name
	// End-User's full name in displayable form including all name parts,
	// possibly including titles and suffixes, ordered according to the
	// End-User's locale and preferences.
	Name string `json:"name,omitempty"`

	// Given Name
	// Given name(s) or first name(s) of the End-User. Note that in some
	// cultures, people can have multiple given names; all can be present,
	// with the names being separated by space characters.
	GivenName string `json:"given_name,omitempty"`

	// Family Name
	// Surname(s) or last name(s) of the End-User. Note that in some
	// cultures, people can have multiple family names or no family name;
	// all can be present, with the names being separated by space
	// characters.
	FamilyName string `json:"family_name,omitempty"`

	// Middle Name
	// Middle name(s) of the End-User. Note that in some cultures, people
	// can have multiple middle names; all can be present, with the names
	// being separated by space characters.
	MiddleName string `json:"middle_name,omitempty"`

	// Nickname
	// Casual name of the End-User that may or may not be the same as the
	// given_name. For instance, a nickname value of Mike might be returned
	// alongside a given_name value of Michael.
	Nickname string `json:"nickname,omitempty"`

	// Preferred Username
	// Shorthand name by which the End-User wishes to be referred to at the
	// RP, such as janedoe or j.doe. This value MAY be any valid JSON string
	// including special characters such as @, /, or whitespace. The RP MUST
	// NOT rely upon this value being unique, as discussed in Section 5.7.
	PreferredUsername string `json:"preferred_username,omitempty"`

	// Profile
	// URL of the End-User's profile page. The contents of this Web page SHOULD
	// be about the End-User.
	Profile string `json:"profile,omitempty"`

	// Picture
	// URL of the End-User's profile picture. This URL MUST refer to an image
	// file (for example, a PNG, JPEG, or GIF image file), rather than to a
	// Web page containing an image. Note that this URL SHOULD specifically
	// reference a profile photo of the End-User suitable for displaying when
	// describing the End-User, rather than an arbitrary photo taken by the End-User.
	Picture string `json:"picture,omitempty"`

	// Website
	// URL of the End-User's Web page or blog. This Web page SHOULD contain
	// information published by the End-User or an organization that the End-User
	// is affiliated with.
	Website string `json:"website,omitempty"`

	// Email
	// End-User's preferred e-mail address. Its value MUST conform to the RFC 5322
	// addr-spec syntax. The RP MUST NOT rely upon this value being unique, as
	// discussed in Section 5.7.
	Email string `json:"email,omitempty"`

	// Email Verified
	// True if the End-User's e-mail address has been verified; otherwise false.
	// When this Claim Value is true, this means that the OP took affirmative steps
	// to ensure that this e-mail address was controlled by the End-User at the time
	// the verification was performed. The means by which an e-mail address is
	// verified is context specific, and dependent upon the trust framework or
	// contractual agreements within which the parties are operating.
	EmailVerified bool `json:"email_verified,omitempty"`

	// Gender
	// End-User's gender. Values defined by this specification are female and male.
	// Other values MAY be used when neither of the defined values are applicable.
	Gender string `json:"gender,omitempty"`

	// Birthdate
	// End-User's birthday, represented as an ISO 8601-1 [ISO8601‑1] YYYY-MM-DD format.
	// The year MAY be 0000, indicating that it is omitted. To represent only the year,
	// YYYY format is allowed. Note that depending on the underlying platform's date
	// elated function, providing just year can result in varying month and day, so the
	// implementers need to take this factor into account to correctly process the dates.
	Birthdate string `json:"birthdate,omitempty"`

	// ZoneInfo
	// String from IANA Time Zone Database [IANA.time‑zones] representing the End-User's
	// time zone. For example, Europe/Paris or America/Los_Angeles.
	ZoneInfo string `json:"zoneinfo,omitempty"`

	// Locale
	// End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically
	// an ISO 639 Alpha-2 [ISO639] language code in lowercase and an ISO 3166-1 Alpha-2
	// [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or
	// fr-CA. As a compatibility note, some implementations have used an underscore as
	// the separator rather than a dash, for example, en_US; Relying Parties MAY choose
	// to accept this locale syntax as well.
	Locale string `json:"locale,omitempty"`

	// Phone Number
	// End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of
	// this Claim, for example, +1 (425) 555-1212 or +56 (2) 687 2400. If the phone number
	// contains an extension, it is RECOMMENDED that the extension be represented using the
	// RFC 3966 [RFC3966] extension syntax, for example, +1 (604) 555-1234;ext=5678.
	PhoneNumber string `json:"phone_number,omitempty"`

	// Phone Number Verified
	// True if the End-User's phone number has been verified; otherwise false. When this Claim
	// Value is true, this means that the OP took affirmative steps to ensure that this phone
	// number was controlled by the End-User at the time the verification was performed. The
	// means by which a phone number is verified is context specific, and dependent upon the
	// trust framework or contractual agreements within which the parties are operating. When
	// true, the phone_number Claim MUST be in E.164 format and any extensions MUST be
	// represented in RFC 3966 format.
	PhoneNumberVerified bool `json:"phone_number_verified,omitempty"`

	// Address
	// End-User's preferred postal address. The value of the address member is a JSON [RFC8259]
	// structure containing some or all of the members defined in Section 5.1.1.
	Address *UserInfoAddressClaim `json:"address,omitempty"`

	// Updated At
	// Time the End-User's information was last updated. Its value is a JSON number representing
	// the number of seconds from 1970-01-01T00:00:00Z as measured in UTC until the date/time.
	UpdatedAt int64 `json:"updated_at,omitempty"`
}

Directories

Path Synopsis
examples
base command
multitenant command
Multi-tenant OAuth2 OP demo.
Multi-tenant OAuth2 OP demo.

Jump to

Keyboard shortcuts

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