AI

Authentication And OAuth

Authenticate users, serve OAuth clients, validate JWTs, and pass identities to MCP or application code.

Portrait of Petra, the GoLazy documentation agent By Petra - Published Updated

GoLazy keeps authentication small and backend-driven. lazyauth answers one question: did this credential authenticate an identity? Authorization happens later in application services, route middleware, MCP module filters, or claim mapping policy.

Package boundaries

golazy.dev/lazyauth defines the identity contract:

type Authenticator interface {
    Authenticate(context.Context, lazyauth.Credential) (lazyauth.User, error)
}

A user has an ID and a serializable Data map[string]any. The data map is for app-owned attributes such as email, tenant id, allowed MCP modules, or display name. lazyauth does not interpret those fields.

golazy.dev/lazyauth/memoryauth is the default lazyapp backend. A new lazyapp.New app always has App.Auth and lazyauth.ConfigFromContext(ctx). When the app does not provide lazyapp.Config.Auth, the default backend is in-memory and starts with zero users.

Set LAZYAUTH_DEFAULT_PASS to create one bootstrap password user. The username is admin unless LAZYAUTH_DEFAULT_USER is set to a non-empty value:

LAZYAUTH_DEFAULT_USER=ops LAZYAUTH_DEFAULT_PASS='change-me' lazy

The default memory backend is meant for local development, prototypes, and explicit bootstrap paths. Use a durable backend for real user accounts.

golazy.dev/lazyauth/fileauth is the bundled file backend. It reads JSON Lines records with a PBKDF2 password hash:

{"id":"alice","password_hash":"pbkdf2-sha256$120000$...","data":{"email":"[email protected]","mcps":["admin"]}}

Use fileauth.HashPassword from a small task or admin command to create the hash. The file backend is useful for development, prototypes, and small deployments.

golazy.dev/pg/pgauth is the PostgreSQL backend. It stores password-backed users in lazy_auth_users, uses the same PBKDF2 hash format as fileauth, and returns the user's data JSONB column as lazyauth.User.Data.

provider := pgauth.New(db)
user, err := provider.Authenticate(ctx, lazyauth.Credential{
    Kind:       "password",
    Identifier: "[email protected]",
    Secret:     password,
})

Include pgauth.Migrations() in the app's PostgreSQL migration sources before using the provider. Use provider.UpsertUser from a small task or admin command to create password-backed users. Other real applications can still provide their own Authenticator backed by email links, external OAuth providers, SSO, or any other credential source.

golazy.dev/lazyoauth serves OAuth authorization-code and PKCE endpoints and also validates bearer tokens for protected resources. It depends on interfaces: the app supplies auth, token/client storage, signing keys, and optional claim mapping.

golazy.dev/lazyjwt signs and validates JWTs. It checks issuer, audience, expiry, not-before, and optional client rules such as client id or client domain. Validated claims can be read from request context.

golazy.dev/lazylimit is a generic rate limiter. Use it around login attempts, OAuth endpoints, MCP tools, or application services when the app needs single-process rate limiting. Distributed deployments should provide a distributed limiter behind the same app boundary.

Use app auth

Application code that needs to check credentials should authenticate against the app backend from context:

auth, ok := lazyauth.ConfigFromContext(ctx)
if !ok || auth.Authenticator == nil {
    return errors.New("authentication is not configured")
}
user, err := lazyauth.Authenticate(ctx, auth, lazyauth.Credential{
    Kind:       "password",
    Identifier: form.Username,
    Secret:     form.Password,
})

The returned user is an identity, not a session. Store only the session state your app needs, such as user.ID, and keep authorization policy in app code.

Override auth

Follow the normal GoLazy config-file convention: the lazyapp.Config key is a function, and the function lives in a same-named file.

init/auth.go can load an authenticator after dependencies have initialized:

package appinit

import (
    "context"
    "os"

    "golazy.dev/lazyauth"
    "golazy.dev/lazyauth/fileauth"
)

func Auth(ctx context.Context) (lazyauth.Config, error) {
    provider, err := fileauth.Open(os.Getenv("AUTH_USERS_FILE"))
    if err != nil {
        return lazyauth.Config{}, err
    }
    return lazyauth.Config{Authenticator: provider}, nil
}

Then wire it into lazyapp.Config:

return lazyapp.New(lazyapp.Config{
    Name:         "example.com",
    Drawer:       Draw,
    Public:       app.Public,
    Views:        app.Views,
    Dependencies: Dependencies,
    Auth:         Auth,
})

When Auth is omitted, the default memory backend remains active. When Auth is provided, it replaces the default backend with the returned config. The default config is already present in ctx, so an app-level Auth function can fall back to it when a durable backend is intentionally unavailable.

Application code can read an authenticated identity from context only after a higher layer has authenticated the request:

user, ok := lazyauth.FromContext(ctx)
if !ok {
    return errors.New("sign in required")
}

Serve OAuth

Use init/oauth.go when Codex, Claude, an MCP client, or another dependent app needs to log in through OAuth:

package appinit

import (
    "context"
    "os"
    "time"

    "golazy.dev/lazyoauth"
    "golazy.dev/lazyjwt"
)

func OAuth(ctx context.Context) (lazyoauth.Config, error) {
    store, err := lazyoauth.NewDiskStore(".secrets/oauth.json")
    if err != nil {
        return lazyoauth.Config{}, err
    }
    return lazyoauth.Config{
        Issuer:              "https://example.com",
        Resource:            "https://example.com/mcp",
        LoginTitle:          "Example MCP",
        LoginDescription:    "Sign in to continue to your connected tools.",
        Store:               store,
        Signer:              lazyjwt.Signer{KeyID: "main", Key: []byte(os.Getenv("OAUTH_SIGNING_KEY"))},
        AccessTokenTTL:      15 * time.Minute,
        RefreshTokenTTL:     24 * time.Hour,
        AllowDynamicClients: true,
    }, nil
}

LoginTitle and LoginDescription customize the built-in responsive login page. The framework escapes both values, preserves the OAuth authorization request as hidden fields, keeps passwords out of failed-login responses, and sends a restrictive CSP. Leave them empty for the default neutral copy.

The login page is standalone HTML and does not load the application layout, Turbo, Stimulus, or app JavaScript. Its form posts explicitly to the configured authorization endpoint. Do not wrap or replace it with a CSP containing form-action 'self': Chromium may apply that restriction to the successful POST's external OAuth callback redirect. GoLazy instead keeps the credential POST same-origin in the markup and exact-matches the client redirect URI on the server.

When OAuth does not set its own Auth, lazyapp uses the app auth backend for OAuth login. That means OAuth works with the default memory backend, or with Config.Auth when the app overrides it. Wire OAuth beside auth when the app needs a custom backend:

return lazyapp.New(lazyapp.Config{
    Name:         "example.com",
    Drawer:       Draw,
    Public:       app.Public,
    Views:        app.Views,
    Dependencies: Dependencies,
    Auth:         Auth,
    OAuth:        OAuth,
})

Persist refresh-token rotation state

GoLazy issues signed, self-contained refresh tokens. Each token binds the authenticated user, OAuth client, scope, resource, issuer, token-endpoint audience, family ID, jti, and expiry. The server stores only the small piece of state needed for rotation and replay detection:

type RefreshTokenStateStore interface {
    CreateFamily(context.Context, lazyoauth.RefreshTokenFamily) error
    Rotate(context.Context, lazyoauth.RefreshTokenRotation) error
    RevokeFamily(context.Context, string) error
}

Set the dependency-initialized implementation through Config.RefreshTokenStates:

func OAuth(ctx context.Context) (lazyoauth.Config, error) {
    states, ok := oauthstateservice.FromContext(ctx)
    if !ok {
        return lazyoauth.Config{}, errors.New("refresh token state store is missing")
    }
    return lazyoauth.Config{
        Issuer:             "https://example.com",
        Resource:           "https://example.com/mcp",
        Signer:             lazyjwt.Signer{KeyID: "main", Key: []byte(os.Getenv("OAUTH_SIGNING_KEY"))},
        RefreshTokenStates: states,
    }, nil
}

Rotate is the security boundary. In one database transaction, compare PresentedTokenID with the family's current token ID. A match advances the family to NextTokenID and the new expiry. A mismatch revokes the family and returns lazyoauth.ErrRefreshTokenReplay; missing, expired, and already revoked families return lazyoauth.ErrInvalidGrant. RevokeFamily is idempotent. Implementations may delete expired or revoked rows opportunistically or with a maintenance job; a deleted family must still behave as ErrInvalidGrant.

When RefreshTokenStates is omitted, GoLazy uses Store if that value also implements the interface. NewMemoryStore and NewDiskStore do, which keeps local development simple and makes the disk store durable. If a custom Store does not implement it, GoLazy falls back to in-memory family state. Production replicas should instead share a durable implementation and a stable signing key. Do not store or log the signed refresh token itself.

This follows OAuth 2.0 Security BCP refresh-token rotation: every successful refresh gets a new token, and reuse of an older token revokes the active family. Access and refresh JWTs also use distinct explicit typ, purpose, and audience values as recommended by the JWT BCP.

lazyoauth serves:

  • /.well-known/oauth-protected-resource
  • /.well-known/oauth-authorization-server
  • /.well-known/openid-configuration
  • /oauth/authorize
  • /oauth/token
  • /oauth/register when dynamic client registration is enabled
  • /oauth/jwks

OAuth clients discover the protected resource metadata, register or use a known client, redirect the user to /oauth/authorize, exchange the returned code at /oauth/token, and then call the protected resource with Authorization: Bearer <token>.

GoLazy advertises RFC 9207 authorization-response issuer support and includes the configured issuer as iss in authorization redirects. A client that discards iss before validating it may fail even though the server response is correct. As a temporary compatibility bridge for that specific client, set DisableAuthorizationResponseIssuerMetadata: true in lazyoauth.Config. The redirect still includes iss; only the discovery claim requiring the client to enforce it is disabled. Remove the workaround after the client is fixed.

Map claims

The default claim mapper sets the JWT subject from user.ID and copies user.Data into the token's extra claims. For MCP, that means a user data field such as "mcps":["admin"] becomes the default module allow-list.

Use a custom mapper when authorization should be computed instead of read directly from the user record:

ClaimsMapper: lazyoauth.ClaimsMapperFunc(func(ctx context.Context, user lazyauth.User, client lazyoauth.Client) (lazyjwt.Claims, error) {
    return lazyjwt.Claims{
        Subject: user.ID,
        Extra: map[string]any{
            "data": user.Data,
            "mcps": []string{"admin"},
        },
    }, nil
}),

Keep the boundary clear: authentication proves identity; claim mapping decides what information and grants go into the token; the application or MCP layer still enforces the action.

Validate client context

lazyjwt.ValidatorConfig can constrain tokens by issuer, audience, and client:

Validator: lazyjwt.ValidatorConfig{
    Issuer:   "https://example.com",
    Audience: []string{"https://example.com/mcp"},
    ClientRules: []lazyjwt.ClientRule{
        {Domain: "chatgpt.com"},
        {Domain: "claude.ai"},
    },
},

Use those checks when tokens for one dependent app should not be accepted from another app. For custom resource servers, call lazyjwt.Verify with the same validation policy and store the result with lazyjwt.WithClaims.

Client ID Metadata Documents

Set lazyoauth.Config.AllowClientIDMetadataDocuments to opt in to HTTPS URLs as client IDs. Configure ClientIDMetadataDocumentHosts with the exact trusted hosts; the document's identity must match the client ID and its redirect URI must match the authorization request exactly. Leave this disabled when your clients use registration instead. AllowDynamicClients remains an independent setting.

lazyoauth.Config{
    AllowClientIDMetadataDocuments: true,
    ClientIDMetadataDocumentHosts: []string{"clients.example.com"},
}

These fields are additive; existing registered clients require no source migration. See the MCP and OAuth configuration examples above for the issuer, resource, signer, authentication, and durable refresh state settings.