Skip to content
goliatonePublic

About

Generic go authentication module

Resources

Stars

0 stars

Watchers

1 watching

Forks

Latest commit

 

History

731 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-auth

A Go authentication library providing JWT based authentication, password management, and role based access control with support for resource level permissions.

Features

  • JWT token generation and validation with structured claims
  • Password hashing and verification using bcrypt
  • User registration and login with email validation
  • Password reset flow with email verification
  • Role based access control (RBAC) with hierarchical permissions
  • Resource level permissions for fine grained access control
  • HTTP middleware for route protection
  • Built in authentication controllers for web applications
  • Database persistence layer using Bun ORM
  • OAuth2 social login (GitHub, Google) with account linking
  • Customizable identity providers and role providers
  • Store backed permission resolver cache with pluggable backends
  • OIDC/provider sessions, lifecycle coordination, assurance/freshness and audit hooks

Package and documentation map

Use Go 1.26.0 or newer as required by go.mod.

Package/module Ownership Guide
Root auth, repository, middleware Local tokens/passwords, account state, controllers, actor context, permission/freshness contracts and storage Sections below; middleware
social Provider OAuth account links in social_accounts Social login
provider/oidc Browser OIDC, immutable provider identity linking and local-token/provider-session callback modes OIDC
provider/auth0 Auth0 JWT validation and optional identity mirroring Auth0
provider/supabase Supabase identity, session/factor and lifecycle capability adapters Supabase
activitymap Producer event normalization; downstream disclosure/retention remains host-owned Activity mapping
adapters/featuregate Separate module mapping actor context into feature claims/permissions Featuregate adapter
adapters/goadmin Separate module mounting browser SSO and account-link/logout UI routes Admin adapter

The root module does not import go-admin. Install optional adapter modules only when those integrations are needed; check their own go.mod and release tags. examples is a separate runnable-example module.

Provider sessions are server-side records with encrypted token material and opaque browser handoffs. OIDC state/nonce/PKCE, lifecycle fences and operations, refresh/revocation and identity binding have explicit storage/policy contracts; configuring an external token validator alone does not assemble this runtime. The OIDC and Supabase guides describe callback modes and migration requirements.

When an IdentityProvider is configured, SessionFromToken and the HTTP protected-route middleware re-read the signed user ID and reject inactive, missing or mismatched identities and provider lookup errors. This adds one identity lookup per successfully validated token. Use SessionFromTokenContext(ctx, token) to pass request cancellation to that lookup. A nil or typed-nil provider keeps token-validation-only behavior; low-level TokenService.Validate remains a cryptographic check.

JWT validity also does not prove current authorization. Sensitive operations can compose AuthorizationFreshnessGuard with authoritative session/account, assurance and permission-version resolvers, invalidation and explicit operation policy. Hosts own step-up delivery and dependency-outage decisions. See authorization_freshness.go, freshness_policy.go, and freshness_invalidation.go; local passwords and external provider sessions use separate credential lifecycles.

Installation

go get github.com/goliatone/go-auth

Quick Start

Basic Authentication Setup

Supply a tracker implementing both UserTracker and AtomicLoginAttemptTracker; password verification requires atomic attempt reservation. RepositoryManager.Users() exposes additional query arguments and needs a small adapter rather than being passed directly. See the tracker adapter in the runnable example for Bun wiring.

package main

import (
    "context"
    "fmt"

    "github.com/goliatone/go-auth"
)

type AppAuthConfig struct {
    SigningKey            string
    SigningMethod         string
    ContextKey            string
    TokenExpiration       int
    ExtendedTokenDuration int
    TokenLookup           string
    AuthScheme            string
    Issuer                string
    Audience              []string
    RejectedRouteKey      string
    RejectedRouteDefault  string
}

func (c AppAuthConfig) GetSigningKey() string           { return c.SigningKey }
func (c AppAuthConfig) GetSigningMethod() string        { return c.SigningMethod }
func (c AppAuthConfig) GetContextKey() string           { return c.ContextKey }
func (c AppAuthConfig) GetTokenExpiration() int         { return c.TokenExpiration }
func (c AppAuthConfig) GetExtendedTokenDuration() int   { return c.ExtendedTokenDuration }
func (c AppAuthConfig) GetTokenLookup() string          { return c.TokenLookup }
func (c AppAuthConfig) GetAuthScheme() string           { return c.AuthScheme }
func (c AppAuthConfig) GetIssuer() string               { return c.Issuer }
func (c AppAuthConfig) GetAudience() []string           { return c.Audience }
func (c AppAuthConfig) GetRejectedRouteKey() string     { return c.RejectedRouteKey }
func (c AppAuthConfig) GetRejectedRouteDefault() string { return c.RejectedRouteDefault }

func authenticateExample(tracker interface {
    auth.UserTracker
    auth.AtomicLoginAttemptTracker
}) error {
    userProvider := auth.NewUserProvider(tracker)

    // Any struct that satisfies auth.Config works.
    config := AppAuthConfig{
        SigningKey:            "your-secret-key",
        SigningMethod:         "HS256",
        ContextKey:            "auth",
        TokenExpiration:       24, // hours
        ExtendedTokenDuration: 24 * 7,
        TokenLookup:           "header:Authorization",
        AuthScheme:            "Bearer",
        Issuer:                "your-app",
        Audience:              []string{"your-audience"},
        RejectedRouteKey:      "redirect",
        RejectedRouteDefault:  "/",
    }

    authenticator := auth.NewAuthenticator(userProvider, config)

    // Login user
    token, err := authenticator.Login(context.Background(), "user@example.com", "password")
    if err != nil {
        return err
    }

    // Validate token and get session
    session, err := authenticator.SessionFromToken(token)
    if err != nil {
        return err
    }

    fmt.Printf("User ID: %s\n", session.GetUserID())
    return nil
}

Temporary bootstrap passwords

go-auth recognizes temporary password metadata on local users:

  • password_temporary
  • password_change_required
  • password_temporary_issued_at
  • password_temporary_expires_at

UserProvider.VerifyIdentity rejects a matching password when password_temporary=true and the expiry is in the past, missing, or malformed. Before expiry, login succeeds so the host can redirect the user to a password-change flow. Add auth.TemporaryPasswordClaimsDecorator() to copy compact password-change hints into JWT/session metadata. Use TemporaryPasswordResetRepository.ResetPasswordAndClearTemporaryPassword when a permanent password reset must clear temporary-password metadata in the same transaction as the password hash update. Repositories that do not expose this optional primitive still support ordinary password resets for users without temporary-password metadata.

Enhanced Resource Level Permissions

// Implement ResourceRoleProvider for fine-grained permissions
type CustomResourceRoleProvider struct {
    repo auth.RepositoryManager
}

func (p *CustomResourceRoleProvider) FindResourceRoles(ctx context.Context, identity auth.Identity) (map[string]string, error) {
    resourceRoles := make(map[string]string)

    switch identity.Role() {
    case "admin":
        resourceRoles["admin:dashboard"] = "owner"
        resourceRoles["project:default"] = "admin"
    case "user":
        resourceRoles["project:default"] = "member"
    }

    return resourceRoles, nil
}

// Use enhanced authenticator
authenticator = authenticator.WithResourceRoleProvider(&CustomResourceRoleProvider{repo: repoManager})

// Generated tokens will include resource-specific permissions
token, err := authenticator.Login(ctx, "admin@example.com", "password")

HTTP Integration

// Create HTTP authenticator for web applications
httpAuth, err := auth.NewHTTPAuthenticator(authenticator, config)
if err != nil {
    log.Fatal(err)
}

// Register browser authentication routes (login, register, password reset)
// with one stable package-managed CSRF contract.
err = auth.RegisterSecureAuthRoutes(
    router.Group("/auth"),
    auth.AuthRouteSecurityConfig{
        CSRF: csrf.Config{
            SecureKey: auth.DeriveBrowserCSRFSecureKey(config),
        },
    },
    auth.WithControllerLogger(logger),
    func(ac *auth.AuthController) *auth.AuthController {
        ac.Auther = httpAuth
        ac.Repo = repoManager
        return ac
    },
)
if err != nil {
    log.Fatal(err)
}

// Protect routes with middleware
protected := httpAuth.ProtectedBrowserRoute(config, errorHandler)
router.Get("/profile", profileHandler, protected)

RegisterAuthRoutes remains available for compatibility and now shares one process-local stateless CSRF key across its route set. Prefer RegisterSecureAuthRoutes with an explicit stable key for deployed browser applications, especially when requests can reach multiple instances.

Success handler contract (jwt middleware) The JWT middleware now invokes a SuccessHandler with the signature func(ctx router.Context, next router.HandlerFunc) error. The default simply calls next(ctx). If you override it, you are responsible for deciding whether to call next (run the protected handler) or short-circuit (e.g., redirect). Example:

protected := httpAuth.ProtectedRoute(config, errorHandler)

// Custom success hook that adds logging, then runs the handler
wrapped := func(ctx router.Context) error {
    return protected(func(c router.Context) error {
        log.Println("auth ok for", c.Path())
        return profileHandler(c) // or c.Next() if chaining
    })(ctx)
}

router.Get("/profile", profileHandler, wrapped)

Social Login (OAuth2)

import (
    "encoding/base64"

    "github.com/goliatone/go-auth/social"
    "github.com/goliatone/go-auth/social/providers/github"
    "github.com/goliatone/go-auth/social/providers/google"
    repo "github.com/goliatone/go-auth/repository"
)

stateEncKey, _ := base64.StdEncoding.DecodeString(cfg.Social.StateEncryptionKey)
stateHMACKey, _ := base64.StdEncoding.DecodeString(cfg.Social.StateHMACKey)

socialRepo := repo.NewSocialAccountRepository(bunDB)

socialAuth := social.NewSocialAuthenticator(
    socialRepo,
    repoManager.Users(),
    authenticator.TokenService(),
    social.SocialAuthConfig{
        DefaultRedirectURL:   "/",
        StateEncryptionKey:   stateEncKey,
        StateHMACKey:         stateHMACKey,
        AllowSignup:          true,
        AllowLinking:         true,
        RequireEmailVerified: true,
    },
    social.WithProvider(github.New(github.Config{
        ClientID:     cfg.Social.GitHubClientID,
        ClientSecret: cfg.Social.GitHubClientSecret,
        CallbackURL:  cfg.BaseURL + "/auth/social/github/callback",
    })),
    social.WithProvider(google.New(google.Config{
        ClientID:     cfg.Social.GoogleClientID,
        ClientSecret: cfg.Social.GoogleClientSecret,
        CallbackURL:  cfg.BaseURL + "/auth/social/google/callback",
    })),
)

socialController := social.NewHTTPController(socialAuth, social.HTTPConfig{
    PathPrefix:        "/auth/social",
    SessionContextKey: cfg.Auth.GetContextKey(),
    CookieName:        cfg.Auth.GetContextKey(),
    SuccessRedirect:   "/dashboard",
    ErrorRedirect:     "/login?error=auth_failed",
    CookieSecure:      true,
    CookieHTTPOnly:    true,
    CookieSameSite:    "Lax",
})
socialController.RegisterRoutes(router.Group("/auth/social"))

Routes registered by the controller:

  • GET /auth/social/providers
  • GET /auth/social/:provider
  • GET /auth/social/:provider/callback
  • POST /auth/social/:provider/link
  • DELETE /auth/social/:provider
  • GET /auth/social/accounts

See docs/SOCIAL_LOGIN.md for linking policies, migration guidance, and security notes. Example wiring lives in examples/extensions/social_login.go.

Social OAuth account links use social_accounts. Browser SSO provider-subject links use user_identifiers. Keep those tables distinct unless your host implements an explicit migration or lookup bridge.

Generic OIDC browser SSO lives in provider/oidc; go-admin integration lives in the nested adapters/goadmin module. See docs/OIDC.md for setup and operational troubleshooting.

Auth0 Integration (Optional)

Auth0 support is opt-in and uses provider/auth0 for JWT validation and claims mapping. For Auth0 + local tokens, use NewMultiTokenValidator to accept both.

import "github.com/goliatone/go-auth/provider/auth0"

auth0Validator, err := auth0.NewTokenValidator(auth0.Config{
    Domain:   "your-tenant.auth0.com",
    Audience: []string{"https://api.yourapp.com"},
})
if err != nil {
    return err
}

authenticator := auth.NewAuthenticator(userProvider, cfg.Auth)

// Accept Auth0 tokens only.
authenticator = authenticator.WithTokenValidator(auth0Validator)

// Or accept Auth0 + local go-auth tokens.
composite := auth.NewMultiTokenValidator(auth0Validator, authenticator.TokenService())
authenticator = authenticator.WithTokenValidator(composite)

For sync (local mirroring), use provider/auth0/sync with auth.IdentifierStore. See docs/AUTH0.md and examples/auth0/ for full examples.

Auth0 validation errors normalize to ErrTokenExpired / ErrTokenMalformed with provider=auth0 metadata. Use HasUserUUID(session) before calling Session.GetUserUUID because Auth0 sub values are not UUIDs.

Session with Resource Permissions

// Check permissions in handlers
func profileHandler(c router.Context) error {
    session, err := auth.GetRouterSession(c, "auth")
    if err != nil {
        return err
    }

    // Enhanced session with resource-level permissions
    if roleCapableSession, ok := session.(auth.RoleCapableSession); ok {
        canEditSettings := roleCapableSession.CanEdit("admin:settings")
        canDeleteUsers := roleCapableSession.CanDelete("admin:users")
        hasAdminRole := roleCapableSession.HasRole("admin")
        isAtLeastMember := roleCapableSession.IsAtLeast("member")

        // Use permissions for authorization logic
    }

    return c.JSON(map[string]any{
        "user_id": session.GetUserID(),
        "data": session.GetData(),
    })
}

CSRF Protection

go-auth ships with a CSRF middleware that plugs into go-router and the template helper stack.

Construct a middleware instance once and reuse it for every route that participates in the same form flow. The generated stateless signing key belongs to that middleware instance; calling csrf.New() separately for the GET and POST sides intentionally creates separate protection domains.

Stateless (default)

import (
    csrf "github.com/goliatone/go-auth/middleware/csrf"
    "github.com/goliatone/go-router"
)

func main() {
    server := router.NewFiberAdapter()
    app := server.Router()

    // HMAC-signed tokens; a random key is generated once for this middleware.
    app.Use(csrf.New())

    app.Post("/submit", func(ctx router.Context) error {
        // Token checked automatically
        return ctx.SendStatus(router.StatusNoContent)
    })

    if err := server.Serve(":8080"); err != nil {
        panic(err)
    }
}

Stateful (storage backed)

type redisStorage struct{ client *redis.Client }

func (r *redisStorage) Get(key string) (string, error) {
    return r.client.Get(context.Background(), key).Result()
}

// Set/Delete omitted for brevity

app.Use(csrf.New(csrf.Config{
    Storage:    &redisStorage{client: redisClient},
    Expiration: 24 * time.Hour,
}))

Templates

// Inside your handler
viewCtx := auth.MergeTemplateData(ctx, router.ViewContext{
    "title": "Secure form",
})

// MergeTemplateData copies the latest request scoped helpers so csrf_field,
// csrf_meta, etc. always render the token minted by the middleware.
return ctx.Render("form", viewCtx)

csrf_field, csrf_token, and csrf_meta are now lazily evaluated helpers. When you register auth.TemplateHelpers() globally, the template engine resolves the current request’s CSRF token automatically—no need to clone helper maps on every render (though MergeTemplateData remains useful when you want a concrete snapshot).

<form method="post">
  {{ csrf_field }}
  <!-- other fields -->
</form>

<script>
  const token = "{{ csrf_token }}";
  const header = "{{ csrf_header_name }}";
  fetch("/submit", {
    method: "POST",
    headers: { [header]: token },
  });
</script>
<!-- Layout head section -->
{{ csrf_meta }}
<meta name="another-example" content="value" />

For the explicit Fiber v3 view engine, register auth.TemplateHelpersForPongo2V7() instead of auth.TemplateHelpers(). A shared router view configuration can retain both runtime-specific maps:

cfg := router.NewSimpleViewConfig("views").
    WithFunctions(auth.TemplateHelpers()).
    WithFiberV3Functions(auth.TemplateHelpersForPongo2V7())

The v7 helper map does not change the legacy factory. Both read the current request's template_helpers map and return plain strings; normal template escaping still applies. Use |safe only for the trusted markup generated by the CSRF middleware (csrf_field and csrf_meta). Custom template integrations can select a factory per renderer with csrf.CSRFTemplateHelpersWithFactory(factory); a nil factory returns static placeholders without consulting or changing the default.

When auth claims are included in view locals, wrap the configured engine before installing it in the host's view configuration:

engine, err := router.InitializeFiberV3ViewEngine(cfg)
// Handle err, then use this as the host's Views value:
views := auth.NewTemplateViews(engine)

NewTemplateViews is opt-in and also works with the v2 engine. It copies plain render maps and slices, replacing auth claims at any key (including nested helper data) with identity and permission methods, UID, UserRole, and copied resource roles. Custom permission checks retain their original behavior. Request locals, standard-context claims, actor data, and token validation remain unchanged. Raw JWT metadata and extension methods are excluded from views; bind any extra presentation fields explicitly as safe data. Other models and helpers still pass through the renderer's normal safety checks. Configure engine-specific extensions before wrapping the engine.

See middleware/csrf/README.md for more examples and configuration options (custom token lookup, skipping routes, etc.).

AJAX/SPA bootstrap endpoint

For clients that need to refresh tokens via XHR (e.g., SPAs), register the helper route:

import csrf "github.com/goliatone/go-auth/middleware/csrf"

csrf.RegisterRoutes(app.Router())

The handler returns JSON:

{
  "token": "...",
  "field_name": "_token",
  "header_name": "X-CSRF-Token"
}

Responses include Cache-Control: no-store, so clients fetch fresh tokens as needed. Call this endpoint whenever a token expires or before issuing state-changing requests in a long-lived SPA session.

Lifecycle Extensions

User Status & State Machine

go-auth persists lifecycle metadata through two columns on users: status (pending, active, suspended, disabled, archived) and suspended_at (timestamp set whenever a user enters or exits the suspended state). The default UserStateMachine enforces the transition graph (archived is terminal, pending can only move to active or disabled, etc.), keeps timestamps in sync, and publishes ActivitySink events.

Use the shared Users repository helpers (UpdateStatus, Suspend, Reinstate) or work directly with the state machine when you need options such as WithTransitionReason, WithTransitionMetadata, or custom hooks:

auditSink := auth.ActivitySinkFunc(func(ctx context.Context, event auth.ActivityEvent) error {
    log.Printf(
        "user %s transitioned %s -> %s (reason=%v)",
        event.UserID,
        event.FromStatus,
        event.ToStatus,
        event.Metadata["reason"],
    )
    return nil
})

stateMachine := auth.NewUserStateMachine(
    repoManager.Users(),
    auth.WithStateMachineActivitySink(auditSink),
)

actor := auth.ActorRef{ID: "admin-42", Type: "admin"}
updated, err := stateMachine.Transition(
    ctx,
    actor,
    user,
    auth.UserStatusSuspended,
    auth.WithTransitionReason("manual review"),
    auth.WithTransitionMetadata(map[string]any{"ticket": "SEC-204"}),
)
if err != nil {
    panic(err)
}

fmt.Println("new status:", updated.Status, "suspended at:", updated.SuspendedAt)

See examples/extensions/extensions.go for an end-to-end sample that persists activity rows and decorates claims based on tenant context.

Transition hooks bubble failures through a configurable handler. By default go-auth panics with a detailed message (great for development). In production override this behavior with auth.WithStateMachineHookErrorHandler to convert hook failures into domain errors or alerts:

handler := func(ctx context.Context, phase auth.TransitionHookPhase, err error, tc auth.TransitionContext) error {
    log.Printf("hook stage=%s user=%s error=%v", phase, tc.User.ID, err)
    return fmt.Errorf("policy hook failed: %w", err)
}

stateMachine := auth.NewUserStateMachine(
    repoManager.Users(),
    auth.WithStateMachineHookErrorHandler(handler),
)

ActivitySink Wiring

ActivitySink is a small interface used across lifecycle transitions, login/impersonation flows, and password reset handlers:

  • ActivityEvent.EventType distinguishes lifecycle (user.status.changed), login, impersonation, and password reset actions.
  • ActorRef identifies who triggered the change (admin dashboard, API, system job).
  • Metadata is an open map for reasons, ticket numbers, IP addresses, etc.
  • Failures are logged; auth flows continue unless you wrap the sink with your own retry/alerting logic.

Configure sinks wherever lifecycle events occur:

auditSink := auth.ActivitySinkFunc(func(ctx context.Context, event auth.ActivityEvent) error {
    log.Printf("activity event=%s user=%s actor=%s", event.EventType, event.UserID, event.Actor.Type)
    return nil
})

stateMachine := auth.NewUserStateMachine(users,
    auth.WithStateMachineActivitySink(auditSink),
)

auther := auth.NewAuthenticator(provider, cfg).
    WithActivitySink(auditSink)

Activity Mapping Helpers

Use activitymap.Normalize when you need to forward auth.ActivityEvent into another activity record contract without re implementing translation logic:

import (
    "context"
    "time"

    auth "github.com/goliatone/go-auth"
    "github.com/goliatone/go-auth/activitymap"
)

type ExternalRecord struct {
    ActorID    string
    Verb       string
    ObjectType string
    ObjectID   string
    Channel    string
    Metadata   map[string]any
    OccurredAt time.Time
}

sink := auth.ActivitySinkFunc(func(ctx context.Context, event auth.ActivityEvent) error {
    normalized := activitymap.Normalize(
        event,
        activitymap.WithDefaultChannel("auth"),
        activitymap.WithDefaultObjectType("user"),
        activitymap.WithObjectIDResolver(func(e auth.ActivityEvent) string {
            if v, ok := e.Metadata["password_reset_id"].(string); ok {
                return v
            }
            return e.UserID
        }),
    )

    record := ExternalRecord{
        ActorID:    normalized.ActorID,
        Verb:       normalized.Verb,
        ObjectType: normalized.ObjectType,
        ObjectID:   normalized.ObjectID,
        Channel:    normalized.Channel,
        Metadata:   normalized.Metadata,
        OccurredAt: normalized.OccurredAt,
    }
    _ = record
    return nil
})

Defaults:

  • channel: auth
  • object_type: user
  • object_id: event.UserID
  • actor_id: event.Actor.ID -> event.UserID -> "system"

For ordinary events, normalized metadata is shallow-cloned from event.Metadata and enriched with:

  • actor_type (from event.Actor.Type, when available)
  • from_status / to_status for lifecycle transitions

Provider lifecycle/session events use restricted projections and identity fingerprints. Ordinary metadata can still contain submitted identifiers and provider errors; apply host disclosure/retention policy before persistence. See activity mapping for adapter guidance.

The same sink can forward events to a SQL table, queue, or logging pipeline. Refer to examples/extensions/extensions.go for a Postgres-based implementation and batching hints.

ClaimsDecorator Hook

Use Auther.WithClaimsDecorator to enrich JWTs with tenant metadata or derived resource roles before they are signed. Decorators receive the pending JWTClaims and may only mutate extension fields such as Resources, Metadata, or additional custom payload that your product documents. Core JWT claims (sub, uid, iss, aud, iat, exp) are guarded and any attempt to edit them aborts token generation.

decorator := auth.ClaimsDecoratorFunc(func(ctx context.Context, identity auth.Identity, claims *auth.JWTClaims) error {
	if claims.Metadata == nil {
		claims.Metadata = map[string]any{}
	}
	claims.Metadata["tenant_id"] = lookupTenant(identity.ID())
	if claims.Resources == nil {
		claims.Resources = map[string]string{}
	}
	claims.Resources["team:"+identity.ID()] = "editor"
	return nil
})

auther := auth.NewAuthenticator(provider, cfg).
	WithClaimsDecorator(decorator)

If the decorator returns an error the token flow stops, the failure is logged, and no JWT is issued. Coordinate decorations with your ActivitySink so downstream services can reconcile claims with lifecycle transitions. Additional wiring tips and a multi-tenant example live in examples/extensions/extensions.go.

JWT Size Safety Defaults

go-auth minimizes oversized JWT metadata by default to reduce cookie bloat and HTTP 431 Request Header Fields Too Large failures.

  • Default claim minimization removes metadata keys (case-insensitive, - normalized to _):
    • permissions, permission_list, permissions_list
    • scopes, scope_list, scopes_list
  • Top-level structured claims are preserved:
    • role, res, and top-level scopes

Token size guardrails apply to all signed JWTs:

  • Warning threshold: 2048 bytes (default)
  • Hard limit: 4096 bytes (default, returns ErrTokenTooLarge)

You can override thresholds without changing interfaces:

auther := auth.NewAuthenticator(provider, cfg).
    WithTokenSizeGuardrails(3072, 6144)

Equivalent token-service options:

tokenService := auth.NewTokenService(
    signingKey,
    tokenExpiration,
    issuer,
    audience,
    logger,
    auth.WithTokenSizeGuardrails(2048, 4096),
)

Migration note: applications that previously read permissions/scopes from JWT metadata should move permission expansion to resolver-based checks (CachedPermissionsResolver + request-scoped cache) and keep JWT payloads minimal.

API Reference

TokenService Access

The Authenticator provides access to its underlying TokenService for advanced use cases:

// Access the token service directly
tokenService := authenticator.TokenService()

// Generate tokens manually with custom resource roles
resourceRoles := map[string]string{
    "project:123": "admin",
    "files:uploads": "owner",
}
token, err := tokenService.Generate(identity, resourceRoles)

// Validate tokens manually
claims, err := tokenService.Validate(tokenString)

Scoped Token Minting

Use MintScopedToken to issue short lived tokens with optional scopes and TTL overrides (useful for debug or session scoped access). The helper reuses defaults from the built-in token service when available, and returns the computed expiration alongside the token.

tokenService := authenticator.TokenService()

opts := auth.ScopedTokenOptions{
    TTL:    15 * time.Minute,            // overrides default expiration
    Scopes: []string{"debug.view"},       // stored in the "scopes" claim
}

token, expiresAt, err := auth.MintScopedToken(tokenService, identity, nil, opts)

MintScopedToken keeps top-level JWTClaims.Scopes intact. Only metadata scope arrays are minimized by default on token signing.

Permission Resolver Cache

Use CachedPermissionsResolver to cache expensive permission resolution across requests. The resolver keeps request fanout under control with singleflight and stores permission sets through a pluggable PermissionCacheStore.

baseResolver := func(ctx context.Context) ([]string, error) {
    // load effective permissions (db, role registry, policy engine, etc.)
    return []string{"admin.translations.export"}, nil
}

resolver := auth.NewCachedPermissionsResolver(auth.CachedPermissionsResolverConfig{
    Resolver: baseResolver,
    KeyFunc:  auth.DefaultPermissionsCacheKeyFromContext,
    TTL:      2 * time.Minute,
    // Store omitted -> defaults to in-memory implementation
    // CacheErrorMode omitted -> defaults to fail_open
})

resolvePermissions := resolver.ResolverFunc()

Request-scope dedupe helper:

ctx := auth.WithResolvedPermissionsCache(req.Context())
permissions, err := resolvePermissions(ctx) // repeated calls in this request resolve once

Strict-mode resolver validation:

if err := auth.ValidateResolverConfigured(true, resolvePermissions); err != nil {
    return err
}
// or panic-fast for startup wiring:
auth.MustValidateResolverConfigured(true, resolvePermissions)

PermissionCacheStore contract:

type PermissionCacheStore interface {
    Get(ctx context.Context, key string) (permissions []string, ok bool, err error)
    Set(ctx context.Context, key string, permissions []string, ttl time.Duration) error
    Delete(ctx context.Context, key string) error
}

Optional purge capability:

type PurgeablePermissionCacheStore interface {
    PurgeExpired(ctx context.Context) (purged int, err error)
}

If you use a generic cache package (for example go-cache), create a thin adapter that satisfies PermissionCacheStore:

type PermissionStoreAdapter struct {
    cache cache.Cache[string, []string]
}

func (s *PermissionStoreAdapter) Get(ctx context.Context, key string) ([]string, bool, error) {
    return s.cache.Get(ctx, key)
}

func (s *PermissionStoreAdapter) Set(ctx context.Context, key string, permissions []string, ttl time.Duration) error {
    return s.cache.Set(ctx, key, permissions, ttl)
}

func (s *PermissionStoreAdapter) Delete(ctx context.Context, key string) error {
    return s.cache.Delete(ctx, key)
}

Then wire it into the resolver:

resolver := auth.NewCachedPermissionsResolver(auth.CachedPermissionsResolverConfig{
    Resolver:       baseResolver,
    KeyFunc:        auth.DefaultPermissionsCacheKeyFromContext,
    Store:          &PermissionStoreAdapter{cache: sharedCache},
    TTL:            2 * time.Minute,
    CacheErrorMode: auth.PermissionCacheErrorModeFailOpen, // or FailClosed
})

Manual cache operations:

_ = resolver.Invalidate(ctx, cacheKey)
purged, err := resolver.PurgeExpired(ctx) // returns 0,nil if store is not purgeable
_ = purged
_ = err

Notes:

  • DefaultPermissionsCacheKeyFromContext includes permission affecting discriminators (version, token id, scopes, impersonation, session). If no discriminator is available, cross request caching is bypassed.
  • PermissionCacheErrorModeFailOpen (default) keeps authz available when cache operations fail.
  • PermissionCacheErrorModeFailClosed propagates cache errors immediately.
  • Runtime counters are available through resolver.Stats().
  • SetPermissionsVersionMetadata / PermissionsVersionFromContext keep cache keys stable across permission-set updates.

JWT Middleware Integration

Use the TokenService with JWT middleware for custom authentication flows:

// Create TokenServiceAdapter for jwtware middleware
tokenValidator := auth.NewTokenServiceAdapter(authenticator.TokenService())

// Configure JWT middleware
jwtConfig := jwtware.Config{
    SigningKey: jwtware.SigningKey{
        Key:    []byte(config.GetSigningKey()),
        JWTAlg: config.GetSigningMethod(),
    },
    TokenValidator: tokenValidator,
    ContextKey:     "auth",
}

middleware := jwtware.New(jwtConfig)

Core Concepts

User Roles

The library defines a hierarchical role system:

  • guest - Read-only access
  • member - Read and edit access
  • admin - Read, edit, and create access
  • owner - Full access including delete

Resource-Level Permissions

Beyond global user roles, the library supports resource-specific permissions:

// Example resource roles map
resourceRoles := map[string]string{
    "project:123":     "admin",      // Admin of project 123
    "admin:dashboard": "member",     // Member access to admin dashboard
    "files:uploads":   "owner",      // Owner of file uploads
}

JWT Claims Structure

The library generates structured JWT claims:

{
  "sub": "user-id",
  "uid": "user-id",
  "role": "admin",
  "res": {
    "project:123": "admin",
    "admin:dashboard": "member"
  },
  "scopes": ["debug.view", "debug.repl"],
  "iss": "your-app",
  "aud": ["your-audience"],
  "iat": 1234567890,
  "exp": 1234654290,
  "jti": "token-id"
}

Database Schema

The library requires two database tables. Note: migrations use TEXT IDs in Postgres/SQLite, while Go models cast to uuid.UUID. This is intentional for now; IDs are stored as text and parsed/validated in the app. We may move to native UUID columns in a future migration.

Users Table

CREATE TABLE users (
    id TEXT NOT NULL PRIMARY KEY,
    user_role TEXT NOT NULL DEFAULT 'guest' CHECK (
        user_role IN ('guest', 'member', 'admin', 'owner')
    ),
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    username TEXT NOT NULL,
    profile_picture TEXT,
    email TEXT NOT NULL UNIQUE,
    phone_number TEXT,
    password_hash TEXT,
    metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
    is_email_verified BOOLEAN DEFAULT FALSE,
    login_attempts INTEGER DEFAULT 0,
    login_attempt_at TIMESTAMP,
    loggedin_at TIMESTAMP,
    status TEXT NOT NULL DEFAULT 'active' CHECK (
        status IN ('pending', 'active', 'suspended', 'disabled', 'archived')
    ),
    suspended_at TIMESTAMP,
    external_id TEXT,
    external_id_provider TEXT,
    reseted_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP,
    deleted_at TIMESTAMP
);

Password Reset Table

CREATE TABLE password_reset (
    id TEXT NOT NULL PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id),
    email TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'unknown' CHECK (
        status IN ('unknown', 'requested', 'expired', 'changed')
    ),
    reseted_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP,
    deleted_at TIMESTAMP
);

Social Accounts Table

Social login adds a social_accounts table for linked provider identities. Migrations ship in data/sql/migrations and data/sql/migrations/sqlite. Use auth.GetAuthExtrasMigrationsFS() for segmented registration (or auth.GetMigrationsFS() for the full tree).

CREATE TABLE social_accounts (
    id TEXT NOT NULL PRIMARY KEY,
    user_id TEXT NOT NULL,
    provider TEXT NOT NULL,
    provider_user_id TEXT NOT NULL,
    email TEXT,
    name TEXT,
    username TEXT,
    avatar_url TEXT,
    access_token TEXT,
    refresh_token TEXT,
    token_expires_at TIMESTAMP NULL,
    profile_data JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
    CONSTRAINT uq_social_accounts_provider_id UNIQUE (provider, provider_user_id),
    CONSTRAINT uq_social_accounts_user_provider UNIQUE (user_id, provider)
);

CREATE INDEX idx_social_accounts_user_id ON social_accounts(user_id);
CREATE INDEX idx_social_accounts_provider ON social_accounts(provider, provider_user_id);

Auth0 Identifiers Table (Optional Sync Track)

SSO and Auth0 sync use the provider-neutral user_identifiers mapping table. The canonical lookup key is (provider, identifier), where identifier is the provider subject. Optional external ID columns on users are legacy enrichment, not the primary SSO lookup path. Migrations ship in data/sql/migrations and data/sql/migrations/sqlite:

  • 20240701090000_auth0_identifiers.up.sql
  • 20240701090000_auth0_identifiers.down.sql

See docs/AUTH0.md for schema details and sync wiring.

Migration Registration Profiles

go-auth exposes segmented migration filesystems:

  • auth.GetCoreMigrationsFS() for core auth schema (users, password_reset, user_status).
  • auth.GetAuthExtrasMigrationsFS() for optional extras (social_accounts, user_identifiers, users.external_id*, OIDC state, provider sessions/tokens/fences, and lifecycle operations).
  • auth.GetMigrationsFS() for the full migration tree.

Register core first, then extras when both are used. Segmented assets are now directory-packaged under data/sql/migration_tracks/{core,auth_extras} to avoid filename-pattern embeds.

Configuration

The library uses a configuration interface that you can implement:

type Config interface {
    GetSigningKey() string
    GetTokenExpiration() int
    GetExtendedTokenDuration() int
    GetIssuer() string
    GetAudience() []string
    GetContextKey() string
    GetTokenLookup() string
    GetAuthScheme() string
    GetRejectedRouteKey() string
    GetRejectedRouteDefault() string
}

Authentication Flow

User Registration

  1. User submits registration form
  2. Password is hashed using bcrypt
  3. User record is created in database
  4. Optional email verification can be triggered

Login Process

  1. User provides credentials
  2. Identity provider verifies password
  3. Resource role provider fetches permissions (if configured)
  4. JWT token is generated with structured claims
  5. Token is returned to client

Password Reset

  1. User requests password reset via email
  2. Reset token is generated and stored
  3. Email with reset link is sent
  4. User follows link to reset password
  5. New password is hashed and updated

Middleware

HTTP Middleware

The library provides HTTP middleware for route protection:

// Protect routes requiring authentication
httpAuth, err := auth.NewHTTPAuthenticator(authenticator, config)
if err != nil {
    log.Fatal(err)
}
protectedRoute := httpAuth.ProtectedRoute(config, errorHandler)
router.Use("/api/", protectedRoute)

// Custom authorization logic
func requireAdminRole(c router.Context) error {
    session, err := auth.GetRouterSession(c, config.GetContextKey())
    if err != nil {
        return c.Status(401).SendString("Unauthorized")
    }
    if !session.IsAtLeast(auth.RoleAdmin) {
        return c.Status(403).SendString("Forbidden")
    }
    return c.Next()
}

WebSocket Authentication

auth.NewWSTokenValidator adapts a TokenValidator to go-router's WSTokenValidator. Auther.NewWSAuthMiddleware returns the high-level router.WebSocketMiddleware shape (WSHandler to WSHandler). Register the wrapped handler through router.NewWSHandler; there is no router.WS method or *router.WSContext type.

func registerChat[T any](routes router.Router[T], authenticator *auth.Auther) {
    protected := authenticator.NewWSAuthMiddleware()
    routes.Get("/chat", router.NewWSHandler(protected(
        func(ctx context.Context, client router.WSClient) error {
            claims, ok := auth.WSAuthClaimsFromContext(ctx)
            if !ok || !claims.CanRead("chat:messages") {
                return client.Close(1008, "Unauthorized")
            }
            return client.SendJSON(map[string]string{"user_id": claims.UserID()})
        },
    )))
}

For custom extraction/failure handling, pass router.WSAuthConfig:

wsConfig := router.WSAuthConfig{
    TokenExtractor: func(ctx context.Context, client router.WSClient) (string, error) {
        token := client.Conn().Header("X-Auth-Token")
        if token == "" { return "", errors.New("missing auth token") }
        return token, nil
    },
    OnAuthFailure: func(ctx context.Context, client router.WSClient, err error) error {
        return client.Close(1008, "Authentication failed")
    },
}
protected := authenticator.NewWSAuthMiddleware(wsConfig)

Default extraction checks token query parameters, then the Authorization header; cookie extraction requires EnableTokenCookie. Hosts choose the transport and avoid exposing token-bearing URLs in logs. Authentication here runs in the high-level connection handler. Use package-managed HTTP/pre-upgrade admission when a rejection must carry an HTTP status before the upgrade.

For a custom validator, return claims through auth.NewWSTokenValidator(customTokenValidator) rather than constructing WSAuthClaimsAdapter directly: its wrapped claims are private. Use the low-level router.WebSocketContext API only when the application owns the read/write loop. See websocket_adapter.go and go-router's WebSocket guides.

Command Handlers

The library includes command handlers for common operations. resetDelivery below is a host-provided auth.PasswordResetDelivery that sends the opaque credential through a trusted channel; reset credentials are not returned in command responses. Configure feature gates through WithFeatureGate when used.

// Register user
registerUser := auth.NewRegisterUserHandler(repositoryManager)
err := registerUser.Execute(ctx, auth.RegisterUserMessage{
    FirstName: "John",
    LastName:  "Doe",
    Email:     "john@example.com",
    Password:  "securepassword",
})

// Initialize password reset
initReset := auth.NewInitializePasswordResetHandler(repositoryManager).WithDelivery(resetDelivery)
err := initReset.Execute(ctx, auth.InitializePasswordResetMessage{
    Email: "user@example.com",
    Stage: "show-reset",
})

Development

See docs/GUIDE_DEVELOPMENT_CICD.md for the multi-module formatting, test, lint, security, CI, and release-validation workflow.

Example application

Run the complete local browser example from its module:

cd examples
./taskfile dev:run

The task regenerates Go configuration, installs the pinned frontend build dependencies when needed, rebuilds public/css/main.css from every template under views/, builds the Go binary, and starts it on http://127.0.0.1:8572.

About

Generic go authentication module

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

⌂ 换网址