AI
Authentication And OAuth
Authenticate users, serve OAuth clients, validate JWTs, and pass identities to MCP or application code.
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",
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
}
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,
})
lazyoauth serves:
/.well-known/oauth-protected-resource/.well-known/oauth-authorization-server/.well-known/openid-configuration/oauth/authorize/oauth/token/oauth/registerwhen 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>.
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.