AI

Authentication And OAuth

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

By Guillermo Alvarez - 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/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. Real applications can provide their own Authenticator backed by a database, 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.

Wire 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,
})

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 Config.Auth is set and OAuth does not set its own Auth, lazyapp uses the configured auth backend for OAuth login. Wire OAuth beside auth:

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/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>.

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.