Multi-Guard Auth

Sometimes a single route must accept both session-authenticated browser users and API clients sending a Bearer token. Nimbus does not ship a combined guard out of the box, but building one is straightforward using the auth.Guard interface and the standard middleware primitives.

The Guard interface

Any guard implements three methods. User(ctx) returns nil, nil when the guard does not find a user โ€” not an error. Multi-guard logic exploits this: try each guard in order and stop at the first non-nil user.

type Guard interface {
    User(ctx context.Context) (User, error)
    Login(ctx context.Context, user User) error
    Logout(ctx context.Context) error
}

AnyGuard โ€” try multiple guards in order

Create a middleware that walks a list of guards and authenticates with the first that returns a user. Drop this into app/middleware/any_guard.go:

package middleware

import (
    "github.com/CodeSyncr/nimbus/auth"
    "github.com/CodeSyncr/nimbus/http"
    "github.com/CodeSyncr/nimbus/router"
)

// AnyGuard tries each guard in order. The first one that returns a non-nil
// user wins. If none succeed, it returns 401 JSON (or redirects if redirectTo
// is non-empty โ€” useful for web + API hybrid routes).
func AnyGuard(redirectTo string, guards ...auth.Guard) router.Middleware {
    return func(next router.HandlerFunc) router.HandlerFunc {
        return func(c *http.Context) error {
            for _, g := range guards {
                user, err := g.User(c.Request.Context())
                if err != nil {
                    return err // hard error โ€” stop immediately
                }
                if user != nil {
                    req := c.Request.WithContext(auth.WithUser(c.Request.Context(), user))
                    c.Request = req
                    return next(c)
                }
            }
            // No guard authenticated the request
            if redirectTo != "" {
                c.Redirect(http.StatusFound, redirectTo)
                return nil
            }
            return c.JSON(http.StatusUnauthorized, map[string]string{
                "error": "unauthorized",
            })
        }
    }
}

Wiring it in routes

Create both guards, then pass them to AnyGuard. The order determines priority โ€” web session is tried first, then the API Bearer token:

import (
    "github.com/CodeSyncr/nimbus/auth"
    "nimbus-starter/app/middleware"
    "nimbus-starter/app/models"
)

func RegisterRoutes(app *nimbus.App) {
    db := app.Container.MustMake("db").(*nimbus.DB)

    // Session guard (web โ€” cookie / session store)
    sessionGuard := auth.NewSessionGuardWithLoader(
        auth.UserLoaderFunc(func(ctx context.Context, id string) (auth.User, error) {
            var u models.User
            if err := db.First(&u, id).Error; err != nil {
                return nil, nil
            }
            return &u, nil
        }),
    )

    // Token guard (API โ€” Bearer token from personal_access_tokens table)
    tokenGuard := auth.NewTokenGuard(db)

    // โ”€โ”€ Dashboard: accepts both browser sessions AND API tokens โ”€โ”€
    app.Router.Get("/dashboard", dashboardHandler).
        Use(middleware.AnyGuard("", sessionGuard, tokenGuard))

    // โ”€โ”€ Web-only routes (always redirect to /login) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    web := app.Router.Group("/")
    web.Use(auth.RequireAuth(sessionGuard, "/login"))
    web.Get("/settings", settingsHandler)

    // โ”€โ”€ API-only routes (return 401 JSON) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    api := app.Router.Group("/api")
    api.Use(auth.RequireToken(tokenGuard))
    api.Get("/me", meHandler)
}

Reading the user inside a handler

After any auth middleware runs, retrieve the user the same way regardless of which guard authenticated it:

func dashboardHandler(c *http.Context) error {
    user := auth.UserFromContext(c.Request.Context())
    // user is guaranteed non-nil here โ€” middleware already returned 401 if not set
    appUser := user.(*models.User)
    return c.JSON(http.StatusOK, map[string]string{
        "message": "Welcome " + appUser.Name,
    })
}

How token vs session detection works

Client typeRequestGuard that wins
BrowserCookie session_idSessionGuard
Mobile / SPAAuthorization: Bearer <token>TokenGuard
NeitherNo credentials401 Unauthorized

Checking which guard was used

If you need to know which guard authenticated the request (e.g. to branch on web vs API behaviour), store a guard name on context:

type guardNameKey struct{}

// GuardNameFromContext returns "web", "api", or "" if not set.
func GuardNameFromContext(ctx context.Context) string {
    name, _ := ctx.Value(guardNameKey{}).(string)
    return name
}

func AnyGuardNamed(redirectTo string, guards map[string]auth.Guard) router.Middleware {
    return func(next router.HandlerFunc) router.HandlerFunc {
        return func(c *http.Context) error {
            for name, g := range guards {
                user, err := g.User(c.Request.Context())
                if err != nil {
                    return err
                }
                if user != nil {
                    ctx := auth.WithUser(c.Request.Context(), user)
                    ctx = context.WithValue(ctx, guardNameKey{}, name)
                    c.Request = c.Request.WithContext(ctx)
                    return next(c)
                }
            }
            if redirectTo != "" {
                c.Redirect(http.StatusFound, redirectTo)
                return nil
            }
            return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
        }
    }
}

// In handler:
func dashboardHandler(c *http.Context) error {
    guard := middleware.GuardNameFromContext(c.Request.Context()) // "web" or "api"
    _ = guard
    user := auth.UserFromContext(c.Request.Context())
    return c.JSON(200, map[string]string{"guard": guard, "user": user.GetID()})
}

Stateless (JWT) guard

If you use auth.NewStatelessGuard (JWT) instead of personal access tokens, swap it into AnyGuard as a drop-in replacement โ€” the interface is identical:

jwtGuard := auth.NewStatelessGuard(auth.StatelessConfig{
    Secret:    os.Getenv("JWT_SECRET"),
    Loader:    userLoader,
})

app.Router.Get("/dashboard", dashboardHandler).
    Use(middleware.AnyGuard("", sessionGuard, jwtGuard))

See also