The OAuth plugin turns your Prefab server into an OAuth2 authorization server. It supports standard OAuth2 flows for authorizing third-party applications to access user resources.
import (
"github.com/dpup/prefab"
"github.com/dpup/prefab/plugins/auth"
"github.com/dpup/prefab/plugins/oauth"
)
oauthPlugin := oauth.NewBuilder().
WithClient(oauth.Client{
ID: "my-app",
Secret: "secret-key",
Name: "My Application",
RedirectURIs: []string{"https://myapp.com/callback"},
Scopes: []string{"read", "write"},
}).
Build()
server := prefab.New(
prefab.WithPlugin(auth.Plugin()),
prefab.WithPlugin(oauthPlugin),
)The OAuth plugin requires the auth plugin to authenticate users during the authorization flow. Run the full working demo at examples/oauthserver to see every flow end-to-end, including a consent page with CSRF-protected approval.
The snippet above is the bare minimum for local development. See the Integration Checklist below for what to configure before taking this to production.
Before going to production, make sure you've done the following:
- Set
oauth.issuerto your public HTTPS URL (e.g.,https://api.example.com). Without this, metadata falls back to request-derived URLs, which can be poisoned by a spoofedHostheader and may advertisehttp://behind a TLS-terminating proxy. - Register every redirect URI exactly — no wildcards. URIs containing control characters or missing a scheme are rejected at registration (
WithClientwill panic). - Enable
oauth.enforcePkceif you have any public clients. This rejects theplainPKCE method (which provides no protection) and requiresS256. - Use a persistent
TokenStore(see Storage). The default in-memory store loses all tokens on restart and doesn't scale past a single instance. - Decide how consent works. The default treats any authenticated user's request as approval. If you register third-party clients, supply a
WithUserAuthorizationHandlerthat interposes an explicit consent step (see Consent). - Store client secrets securely. Confidential clients must have a non-empty secret —
WithClientpanics ifPublic: falseandSecretis empty. - Require
stateon all authorization requests from your clients as a CSRF defense (OAuth 2.0 §10.12).
Standard OAuth2 flow for web and mobile applications. Users authorize access, receive an authorization code, then exchange it for an access token.
-
Redirect user to
/oauth/authorize:/oauth/authorize?client_id=my-app&response_type=code&redirect_uri=https://myapp.com/callback&scope=read&state=random -
User authenticates and authorizes the application
-
Server redirects to callback with authorization code:
https://myapp.com/callback?code=AUTH_CODE&state=random -
Exchange code for access token:
curl -X POST http://localhost:8000/oauth/token \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE" \ -d "client_id=my-app" \ -d "client_secret=secret-key" \ -d "redirect_uri=https://myapp.com/callback"
Response:
{
"access_token": "ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN"
}Required for public clients (mobile apps, SPAs) when oauth.enforcePkce is enabled. PKCE prevents authorization code interception attacks.
When enforcement is on, only the S256 method is accepted. The plain method sets code_challenge == code_verifier and provides no protection against an attacker who can observe the authorization request — it's explicitly rejected. Requests without code_challenge_method are also rejected (the underlying library would otherwise default them to plain).
-
Generate code verifier and challenge:
const verifier = base64url(randomBytes(32)); const challenge = base64url(sha256(verifier));
-
Authorization request includes challenge:
/oauth/authorize?client_id=my-app&response_type=code&redirect_uri=...&code_challenge=CHALLENGE&code_challenge_method=S256 -
Token request includes verifier:
curl -X POST http://localhost:8000/oauth/token \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE" \ -d "client_id=my-app" \ -d "code_verifier=VERIFIER"
For server-to-server authentication without user involvement.
curl -X POST http://localhost:8000/oauth/token \
-d "grant_type=client_credentials" \
-d "client_id=my-app" \
-d "client_secret=secret-key" \
-d "scope=read"Exchange a refresh token for a new access token. The client must authenticate — the refresh token alone is not sufficient credential. Confidential clients send client_secret; public clients are authenticated by client_id only.
curl -X POST http://localhost:8000/oauth/token \
-d "grant_type=refresh_token" \
-d "refresh_token=REFRESH_TOKEN" \
-d "client_id=my-app" \
-d "client_secret=secret-key"The refreshed token's scope is capped at the original grant's scope — clients cannot escalate scope via refresh. Omitting scope retains the original scope; passing a subset is allowed.
Refresh tokens rotate on use (the old refresh token is invalidated and a new one is issued). A refresh_token in the response replaces any previous one; revoking either the access or refresh token invalidates both.
oauth.NewBuilder().
WithClient(client). // Add OAuth client
WithAccessTokenExpiry(time.Hour). // Default: 1 hour
WithRefreshTokenExpiry(7 * 24 * time.Hour). // Default: 14 days
WithAuthCodeExpiry(10 * time.Minute). // Default: 10 minutes
WithIssuer("https://api.example.com"). // Token issuer URL
WithEnforcePKCE(true). // Require PKCE for public clients
WithClientStore(customStore). // Custom client storage
WithTokenStore(customStore). // Custom token storage
WithUserAuthorizationHandler(consentHandler). // Custom consent/approval logic
Build()| Key | Type | Default | Description |
|---|---|---|---|
oauth.enforcePkce |
bool | false |
Require PKCE for public clients |
oauth.issuer |
string | address config |
Token issuer URL |
WithClient validates each registered client and panics at startup if the configuration is invalid. This surfaces bootstrap mistakes immediately rather than at the first request. The validation rules are:
IDmust be non-empty.- Confidential clients (
Public: false) must have a non-emptySecret. - Public clients (
Public: true) must not have aSecret. - Each
RedirectURIsentry must be an absolute URL with a scheme and must not contain control characters (\r,\n,\t,\0). Newline-containing URIs are rejected specifically to prevent smuggling extra callbacks past the allow-list.
Server-side applications that can securely store a client secret.
oauth.Client{
ID: "server-app",
Secret: "secret-key",
RedirectURIs: []string{"https://app.com/callback"},
Scopes: []string{"read", "write"},
Public: false,
}Browser-based or mobile applications that cannot securely store secrets. Use PKCE for security.
oauth.Client{
ID: "mobile-app",
Secret: "", // No secret for public clients
RedirectURIs: []string{"myapp://callback"},
Scopes: []string{"read"},
Public: true,
}Public clients:
- Cannot authenticate with a client secret
- Should use PKCE when
oauth.enforcePkceis enabled - Tokens are still secure when PKCE is used correctly
The /oauth/authorize endpoint does not render a consent UI. By default, any authenticated user's request is treated as an approval — safe only when all registered clients are first-party (you trust every client equally, e.g., your own apps and internal services).
For multi-tenant or third-party setups, supply a custom UserAuthorizationHandler that enforces an explicit consent step. The handler can redirect the browser to your consent page, verify a signed approval token on return, and then resolve the user's subject:
oauth.NewBuilder().
WithUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) {
identity, err := auth.IdentityFromContext(r.Context())
if err != nil {
return "", err
}
// Check for a valid consent token (double-submit cookie pattern).
submitted := r.FormValue("consent")
cookie, cookieErr := r.Cookie("oauth-consent-csrf")
if submitted != "" && cookieErr == nil && submitted == cookie.Value {
if err := prefab.VerifyCSRFToken(submitted, signingKey); err == nil {
return identity.Subject, nil
}
}
// No valid approval — redirect to the consent page with the
// original authorize params preserved.
http.Redirect(w, r, "/consent?"+r.URL.RawQuery, http.StatusFound)
return "", nil
}).
Build()The consent page mints a CSRF token via prefab.GenerateCSRFToken, sets it as a cookie, and embeds it as a hidden form field. On approval, the form POSTs back to a handler that replays the authorize request with the consent token attached. See examples/oauthserver for a full working implementation.
When a request arrives, the auth plugin walks a chain of identity extractors and uses the first one that produces an identity:
Authorization: Bearer <opaque-token>— resolved by the OAuth plugin. If the token is valid, the request is authenticated as the OAuth subject and the scopes are exposed viaoauth.HasScope,oauth.OAuthScopesFromContext, etc. If the bearer is unknown or expired, the request is rejected with 401 — the server does not fall back to cookie authentication. This prevents a revoked OAuth token from silently being treated as unauthenticated.Authorization: Bearer <jwt>— resolved by the auth plugin's JWT header extractor.Cookie: pf-id=<jwt>— resolved by the auth plugin's cookie extractor.
Net: a request with both a cookie and a Bearer is authenticated by the Bearer. Only requests with no Bearer fall back to the cookie.
func protectedHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Verify the request is authenticated (bearer or cookie).
identity, err := auth.IdentityFromContext(ctx)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// If the request is using OAuth, enforce the required scope.
if oauth.IsOAuthRequest(ctx) {
if !oauth.HasScope(ctx, "read") {
http.Error(w, "Missing 'read' scope", http.StatusForbidden)
return
}
// OAuth metadata is also available:
_ = oauth.OAuthClientIDFromContext(ctx)
_ = oauth.OAuthScopesFromContext(ctx)
}
// Handle request using identity.Subject.
}Scope helper functions:
oauth.HasScope(ctx, "read") // Check single scope
oauth.HasAnyScope(ctx, "read", "write") // Check any of multiple scopes
oauth.HasAllScopes(ctx, "read", "write") // Check all scopes present
oauth.IsOAuthRequest(ctx) // Check if OAuth token was usedScopes are space-separated strings, per RFC 6749 §3.3.
Revoke an access or refresh token:
curl -X POST http://localhost:8000/oauth/revoke \
-u "client_id:client_secret" \
-d "token=ACCESS_TOKEN" \
-d "token_type_hint=access_token"Clients can only revoke their own tokens. The endpoint returns 200 OK even if the token doesn't exist (per RFC 7009).
Check token status and metadata:
curl -X POST http://localhost:8000/oauth/introspect \
-u "client_id:client_secret" \
-d "token=ACCESS_TOKEN"Response for active token:
{
"active": true,
"client_id": "my-app",
"scope": "read write",
"sub": "user123",
"exp": 1234567890,
"iat": 1234564290,
"token_type": "Bearer",
"iss": "https://api.example.com"
}Response for inactive token:
{
"active": false
}Clients can only introspect their own tokens.
The plugin exposes OAuth server metadata at /.well-known/oauth-authorization-server per RFC 8414:
curl http://localhost:8000/.well-known/oauth-authorization-serverResponse includes:
- Endpoint URLs (authorization, token, revocation, introspection)
- Supported grant types and response types
- Supported authentication methods
- Supported PKCE methods
| Endpoint | Method | Description |
|---|---|---|
/oauth/authorize |
GET | Authorization endpoint (user approval) |
/oauth/token |
POST | Token endpoint (exchange codes, refresh tokens) |
/oauth/revoke |
POST | Revoke access or refresh tokens |
/oauth/introspect |
POST | Check token status and metadata |
/.well-known/oauth-authorization-server |
GET | OAuth server metadata |
OAuth errors are returned as JSON following RFC 6749 §5.2:
{
"error": "invalid_client",
"error_description": "Client authentication failed"
}| Error code | When |
|---|---|
invalid_request |
Malformed request, missing required parameter, unsupported grant type |
invalid_client |
Unknown client, wrong secret, public client misconfigured with a secret |
invalid_grant |
Bad/expired authorization code, invalid refresh token, PKCE verifier mismatch |
invalid_scope |
Requested scope not permitted for the client; refresh tried to escalate scope |
access_denied |
User denied consent, or redirect URI not in the client's allow list |
unauthorized_client |
Client not allowed to use this grant type |
For the authorization endpoint, errors are delivered as a redirect to the client's redirect_uri with error= and state= query parameters (when the redirect URI is valid; otherwise the response is a plain 400).
Clients and tokens are stored in memory. This is the default if you don't supply a ClientStore or TokenStore. Suitable for development, tests, and single-instance deployments where token persistence isn't required.
oauthPlugin := oauth.NewBuilder().
WithClient(client).
Build()Caveats — none of these are appropriate for production:
- All tokens are lost on restart. Any user holding an access token at restart time must reauthorize.
- No horizontal scaling: each server instance has its own independent token store, so clients may authenticate on one instance and get rejected on another.
- Expired entries are swept on each
Createto bound memory use, but there is no persistent rate limiting, audit trail, or replication.
Implement ClientStore and TokenStore interfaces to persist clients and tokens:
type ClientStore interface {
GetClient(ctx context.Context, clientID string) (*Client, error)
CreateClient(ctx context.Context, client *Client) error
UpdateClient(ctx context.Context, client *Client) error
DeleteClient(ctx context.Context, clientID string) error
ListClientsByUser(ctx context.Context, userID string) ([]*Client, error)
}
type TokenStore interface {
Create(ctx context.Context, info TokenInfo) error
GetByCode(ctx context.Context, code string) (TokenInfo, error)
GetByAccess(ctx context.Context, access string) (TokenInfo, error)
GetByRefresh(ctx context.Context, refresh string) (TokenInfo, error)
RemoveByCode(ctx context.Context, code string) error
RemoveByAccess(ctx context.Context, access string) error
RemoveByRefresh(ctx context.Context, refresh string) error
}Configure with custom stores:
oauthPlugin := oauth.NewBuilder().
WithClientStore(myClientStore).
WithTokenStore(myTokenStore).
Build()Add clients at runtime:
// Get OAuth plugin from registry
oauthPlugin := registry.Get(oauth.PluginName).(*oauth.OAuthPlugin)
// Add client dynamically
if err := oauthPlugin.AddClient(oauth.Client{
ID: "new-client",
Secret: "new-secret",
RedirectURIs: []string{"https://new.com/callback"},
Scopes: []string{"read"},
CreatedBy: "user123",
}); err != nil {
return err
}Or use the client store directly:
store := oauthPlugin.GetClientStore()
store.CreateClient(ctx, &oauth.Client{...})See examples/oauthserver for a complete working example with:
- Authorization code flow
- Client credentials flow
- Scope-based endpoint protection
- Interactive web interface for testing
Run the example:
go run ./examples/oauthserverThen visit http://localhost:8000 to test the OAuth flows.
- Client secrets: Store securely, never commit to version control. Confidential clients must set a non-empty secret; public clients must not.
- PKCE: Enable
oauth.enforcePkcefor public clients. Only the S256 method is accepted when enforcement is on. - Redirect URIs: Whitelist exact URIs, never use wildcards. Control characters and relative URLs are rejected at registration.
- HTTPS: Use HTTPS in production for all OAuth endpoints. Set
oauth.issuerexplicitly to a stable https URL so metadata doesn't depend on request headers. - Scopes: Grant minimum necessary scopes for each client. Scope allowlists are enforced on all grant types; refresh tokens cannot escalate scope beyond the original grant.
- Consent: The plugin does not render a consent UI. When integrating the
/oauth/authorizeendpoint with third-party clients, interpose your own approval step so an authenticated user's session cannot be used to issue tokens to an attacker-registered client without explicit approval. - Token expiry: Use short-lived access tokens and longer refresh tokens. Revoking either side of a grant invalidates both.