Skip to content

Auth

Auth middleware provides a pluggable authentication system with session management, a built-in login UI, and support for multiple authentication strategies (local credentials, OAuth2/OIDC, custom).

go
"github.com/rakunlabs/ada/middleware/auth"
"github.com/rakunlabs/ada/middleware/auth/strategy/local"
authoauth2 "github.com/rakunlabs/ada/middleware/auth/strategy/oauth2"

How It Works

Authentication is a one-shot event. A strategy (OAuth2, local, etc.) verifies the user's identity once and returns a normalized Identity. The middleware then mints its own opaque session token — the upstream IdP token is discarded immediately (optionally revoked). Every subsequent request resolves the session cookie through the internal issuer, never touching the IdP again.

Quick Start

go
import (
    "github.com/rakunlabs/ada/middleware/auth"
    "github.com/rakunlabs/ada/middleware/auth/identity"
    "github.com/rakunlabs/ada/middleware/auth/strategy/local"
)

authMW := auth.New(auth.Config{
    UI: auth.UIConfig{
        Title:    "My App",
        Subtitle: "Sign in to continue",
    },
})

authMW.Strategy(local.New("local",
    func(ctx context.Context, user, pass string) (*identity.Identity, error) {
        // Your credential check (database, LDAP, etc.)
        if user == "admin" && pass == "secret" {
            return &identity.Identity{
                Subject: "admin",
                Email:   "admin@example.com",
                Roles:   []string{"admin"},
            }, nil
        }
        return nil, local.ErrInvalidCredentials
    },
    local.WithLabel("Email & password"),
))

if err := authMW.Init(ctx); err != nil {
    return err
}

// Mount public auth routes
authMW.Mount(mux)

// Protect routes
app := mux.Group("/app")
app.Use(authMW.Require())
app.GET("/dashboard", func(w http.ResponseWriter, r *http.Request) {
    id := identity.FromContext(r.Context())
    fmt.Fprintf(w, "Hello, %s", id.Name)
})

Strategies

Local Strategy

The local strategy authenticates using a user-supplied Verifier function. You own the credential check — it can be a database lookup, LDAP bind, bcrypt comparison, or anything else.

go
import "github.com/rakunlabs/ada/middleware/auth/strategy/local"

authMW.Strategy(local.New("local",
    myVerifierFunc,
    local.WithLabel("Email & password"),
    local.WithPriority(0),
    local.WithFields(
        strategy.Field{Name: "email", Label: "Email", Type: "email", Required: true},
        strategy.Field{Name: "password", Label: "Password", Type: "password", Required: true},
    ),
))

The Verifier signature:

go
type Verifier func(ctx context.Context, username, password string) (*identity.Identity, error)

Return local.ErrInvalidCredentials for bad credentials (produces 401). Any other error produces 500.

Self-Service Sign-up

The local strategy can also create accounts. Pass a Registrar with local.WithRegistrar(...) — its presence enables signup for that strategy and the login UI grows a "Sign up" toggle.

go
authMW.Strategy(local.New("local",
    LocalVerifier,
    local.WithLabel("Username & password"),
    local.WithRegistrar(func(ctx context.Context, req local.RegisterRequest) (*identity.Identity, error) {
        if len(req.Password) < 6 {
            return nil, fmt.Errorf("password must be at least 6 characters: %w", local.ErrInvalidInput)
        }

        user, err := db.CreateUser(ctx, req.Username, req.Password)
        if errors.Is(err, db.ErrDuplicate) {
            return nil, local.ErrUserExists
        }
        if err != nil {
            return nil, err
        }

        return &identity.Identity{
            Subject: user.ID,
            Name:    user.Name,
            Roles:   []string{"user"},
        }, nil
    }),
    local.WithAutoLogin(true),  // optional: issue a session immediately on success
))

The Registrar signature:

go
type Registrar func(ctx context.Context, req local.RegisterRequest) (*identity.Identity, error)

type RegisterRequest struct {
    Username string
    Password string
    Extras   map[string]string  // any non-username/password fields from the form
}

Error contract:

ReturnHTTPUse when
nil error200Account created
local.ErrUserExists409Username already taken
local.ErrInvalidInput (wrap with fmt.Errorf("... : %w", local.ErrInvalidInput))400Validation failed; wrapped message is shown to the user
any other error500Unexpected failure

Customizing the sign-up form — by default the form is username, password, password_confirm (all with matching labels). Override with local.WithRegisterFields(...):

go
local.WithRegisterFields(
    strategy.Field{Name: "username", Label: "Email",        Type: "email",    Required: true},
    strategy.Field{Name: "name",     Label: "Display name", Type: "text"},
    strategy.Field{Name: "password", Label: "Password",     Type: "password", Required: true},
    strategy.Field{Name: "password_confirm", Label: "Confirm password", Type: "password", Required: true},
)
  • Fields matching the strategy's username/password keys are forwarded to the Registrar as req.Username / req.Password.
  • password_confirm is matched client-side against password and stripped before the server sees it.
  • Any other field (e.g. name, email) lands in req.Extras["field_name"].

Auto-login vs return to loginlocal.WithAutoLogin(true) issues a session and redirects after a successful signup. Default (false) flips the UI back to the login form with an "Account created. Please sign in." notice.

First-run bootstrap — for self-hosted apps that want to show the signup form on first visit (until a user exists), set auth.UIConfig.SignupFirst = true. The UI lands on the signup form directly; flip it back to false once bootstrapped (or derive it from whether your user store is empty).

OAuth2 / OIDC Strategy

The OAuth2 strategy supports the authorization code flow (with PKCE) and optional password flow.

go
import authoauth2 "github.com/rakunlabs/ada/middleware/auth/strategy/oauth2"

authMW.Strategy(authoauth2.New("google", authoauth2.Config{
    IssuerURL:    "https://accounts.google.com",  // OIDC Discovery
    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
    Scopes:       []string{"openid", "email", "profile"},
}, authoauth2.Options{
    Label:            "Sign in with Google",
    CallbackBasePath: "/login/callback",
}))

OIDC Discovery

When IssuerURL is set, the strategy fetches /.well-known/openid-configuration to auto-populate:

Discovered FieldConfig Override
authorization_endpointAuthURL
token_endpointTokenURL
userinfo_endpointUserInfoURL
revocation_endpointRevocationURL
end_session_endpointLogoutURL

Explicitly set fields always take precedence over discovered ones.

PKCE (Proof Key for Code Exchange)

PKCE is enabled by default for the authorization code flow (RFC 7636). The strategy generates a code_verifier + code_challenge pair per authorization request and includes them in the flow automatically.

Set DisablePKCE: true only if your IdP does not support it.

Manual Configuration (without Discovery)

go
authoauth2.New("keycloak", authoauth2.Config{
    ClientID:      "my-app",
    ClientSecret:  "secret",
    AuthURL:       "https://kc.example.com/realms/main/protocol/openid-connect/auth",
    TokenURL:      "https://kc.example.com/realms/main/protocol/openid-connect/token",
    UserInfoURL:   "https://kc.example.com/realms/main/protocol/openid-connect/userinfo",
    RevocationURL: "https://kc.example.com/realms/main/protocol/openid-connect/revoke",
    LogoutURL:     "https://kc.example.com/realms/main/protocol/openid-connect/logout",
    Scopes:        []string{"openid", "email", "profile"},
}, authoauth2.Options{
    Label:            "Sign in with Keycloak",
    CallbackBasePath: "/login/callback",
})

API Key Strategy

Header-based authentication for service-to-service or CLI access. No login UI — validates tokens from request headers.

go
import "github.com/rakunlabs/ada/middleware/auth/strategy/apikey"

authMW.Strategy(apikey.New("apikey",
    func(ctx context.Context, key string) (*identity.Identity, error) {
        // Look up the key in your database
        user, err := db.FindByAPIKey(ctx, key)
        if err != nil {
            return nil, apikey.ErrInvalidKey
        }
        return &identity.Identity{
            Subject: user.ID,
            Email:   user.Email,
            Roles:   user.Roles,
        }, nil
    },
))

By default reads Authorization: Bearer <token> and falls back to X-API-Key: <token>. Customize with options:

go
apikey.New("apikey", validator,
    apikey.WithHeaderName("X-Custom-Key"),  // read from a specific header
    apikey.WithBearerPrefix(false),         // don't strip "Bearer " prefix
)

INFO

API Key strategy is hidden from the login UI by default. It's designed for programmatic access alongside browser-based strategies.

LDAP Strategy

Username/password authentication against an LDAP directory (Active Directory, OpenLDAP). Uses the same login form as the local strategy.

go
import "github.com/rakunlabs/ada/middleware/auth/strategy/ldap"

authMW.Strategy(ldap.New("ldap", ldap.Config{
    Address:      "ldap://ad.example.com:389",
    BaseDN:       "dc=example,dc=com",
    BindDN:       "cn=service,dc=example,dc=com",
    BindPassword: os.Getenv("LDAP_BIND_PASSWORD"),
    UserFilter:   "(sAMAccountName=%s)",  // default: "(uid=%s)"
    AttributeMap: ldap.AttributeMap{
        Subject: "sAMAccountName",
        Email:   "mail",
        Name:    "displayName",
        Roles:   "memberOf",
    },
}, myLDAPConnector,  // you provide the LDAP connection implementation
    ldap.WithLabel("Corporate Login"),
))

The strategy uses a Connector interface so it has zero external dependencies — you inject your LDAP library:

go
type Connector interface {
    Connect(ctx context.Context) (Conn, error)
}

type Conn interface {
    Bind(dn, password string) error
    Search(baseDN, filter string, attributes []string) ([]Entry, error)
    Close() error
}

This means you can use go-ldap, go-ldap/v3, or any other LDAP library by wrapping it in this interface.

Passwordless email-based authentication. User enters their email, receives a one-time link, and clicks it to log in.

go
import "github.com/rakunlabs/ada/middleware/auth/strategy/magiclink"

authMW.Strategy(magiclink.New("magic", magiclink.Config{
    Sender: func(ctx context.Context, email, token, verifyURL string) error {
        // Send the magic link via your email service
        return emailService.Send(ctx, email, "Login link", verifyURL)
    },
    Resolver: func(ctx context.Context, email string) (*identity.Identity, error) {
        // Look up or create the user by email
        user, err := db.FindOrCreateByEmail(ctx, email)
        if err != nil {
            return nil, err
        }
        return &identity.Identity{
            Subject: user.ID,
            Email:   email,
            Name:    user.Name,
        }, nil
    },
    TokenTTL: 15 * time.Minute,
}, magiclink.WithLabel("Sign in with email")))

Flow:

The strategy ships with a built-in in-memory TokenStore. For production, provide your own Redis/DB-backed store:

go
type TokenStore interface {
    Store(ctx context.Context, token, email string, ttl time.Duration) error
    Lookup(ctx context.Context, token string) (email string, err error)
    Delete(ctx context.Context, token string) error
}

HTTP Basic Strategy

Standard HTTP Basic authentication (RFC 7617). The browser shows its native credential dialog — no login page needed.

go
import "github.com/rakunlabs/ada/middleware/auth/strategy/basic"

authMW.Strategy(basic.New("basic",
    func(ctx context.Context, user, pass string) (*identity.Identity, error) {
        // Same Verifier signature as the local strategy
        if user == "admin" && pass == "secret" {
            return &identity.Identity{Subject: "admin"}, nil
        }
        return nil, basic.ErrInvalidCredentials
    },
    basic.WithRealm("My Application"),
))

When no Authorization header is present, the strategy responds with 401 + WWW-Authenticate: Basic realm="My Application" which triggers the browser's native login popup.

INFO

HTTP Basic is hidden from the login UI by default. It's useful for API endpoints, dev tools, or as a fallback when the browser-based UI isn't needed.

Header/Proxy Strategy

Trusts identity from headers set by an upstream reverse proxy (Traefik, nginx, Envoy). Zero user interaction — the proxy handles authentication.

go
import "github.com/rakunlabs/ada/middleware/auth/strategy/header"

authMW.Strategy(header.New("proxy",
    header.WithHeaderMap(header.HeaderMap{
        User:   "X-Forwarded-User",   // → Identity.Subject
        Email:  "X-Forwarded-Email",  // → Identity.Email
        Name:   "X-Forwarded-Name",   // → Identity.Name
        Roles:  "X-Forwarded-Roles",  // comma-separated → Identity.Roles
        Groups: "X-Forwarded-Groups", // comma-separated → Identity.Claims["groups"]
    }),
))

All header names have sensible defaults (X-Forwarded-User, X-Forwarded-Email, etc.) so the minimal setup is:

go
authMW.Strategy(header.New("proxy"))

If the User header is missing from the request, the strategy returns 401.

WARNING

Only use this behind a trusted reverse proxy that sets these headers. An attacker can forge them if the proxy is bypassed. Make sure your infrastructure strips these headers from external requests.

Passkey Strategy

WebAuthn / FIDO2 passwordless login using device biometrics (Touch ID, Windows Hello) or a roaming security key. The implementation is stdlib-only — CBOR/COSE/attestation parsing is hand-rolled, no third-party dependency.

go
import "github.com/rakunlabs/ada/middleware/auth/strategy/passkey"

engine, err := passkey.New(&passkey.Config{
    RPID:             "example.com",                        // bare host, no scheme
    RPDisplayName:    "Example",                            // shown in the platform UI
    RPOrigins:        []string{"https://example.com"},      // exact origins to accept
    UserVerification: passkey.UVPreferred,                  // ask for biometric/PIN when possible
    ChallengeTTL:     5 * time.Minute,
})
if err != nil { return err }

strategy, err := passkey.NewStrategy("passkey", engine,
    func(ctx context.Context, credentialID []byte) (*passkey.Credential, *identity.Identity, error) {
        // Look up the credential in your store. Return passkey.ErrCredentialNotFound
        // for any unresolved input (unknown id, disabled user, etc.) so the strategy
        // emits a uniform 401 — never leak which credential ids you've seen.
        row, err := db.PasskeyByCredentialID(ctx, credentialID)
        if err != nil {
            return nil, nil, passkey.ErrCredentialNotFound
        }
        return &passkey.Credential{
                ID:         row.CredentialID,
                UserHandle: row.UserHandle,
                PublicKey:  row.PublicKey,        // raw COSE_Key bytes
                AAGUID:     row.AAGUID,
                SignCount:  row.SignCount,
                Transports: row.Transports,
            },
            &identity.Identity{Subject: row.Username, Provider: "passkey"},
            nil
    },
    passkey.WithLabel("Sign in with a passkey"),
    passkey.WithSignCountUpdater(func(ctx context.Context, credentialID []byte, newCount uint32) error {
        return db.UpdatePasskeySignCount(ctx, credentialID, newCount)
    }),
)
if err != nil { return err }

authMW.Strategy(strategy)

Registration endpoints

ada does not mount registration endpoints — the RP owns persistence and policy, and a registration ceremony is almost always gated on an already-authenticated session. You mount them yourself:

go
mux.POST("/passkey/register/begin", mux.Wrap(func(c *ada.Context) error {
    user := identity.FromContext(c.Request.Context()) // current signed-in user
    opts, session, err := engine.BeginRegistration(passkey.User{
        Handle:      []byte(user.Subject),            // ≤64 bytes, stable per user
        Name:        user.Subject,
        DisplayName: user.Name,
    }, db.ExcludeCredentialsFor(user.Subject))
    if err != nil { return err }
    sid := db.SaveRegSession(user.ID, session)       // short-TTL store, your storage
    return c.SetStatus(200).SendJSON(map[string]any{"session_id": sid, "options": opts})
}))

mux.POST("/passkey/register/finish", mux.Wrap(func(c *ada.Context) error {
    var req struct {
        SessionID string          `json:"session_id"`
        Response  json.RawMessage `json:"response"`
    }
    if err := c.Bind(&req); err != nil { return err }
    session, ok := db.TakeRegSession(req.SessionID)
    if !ok { return c.SetStatus(401).SendJSON(map[string]string{"error": "invalid_session"}) }
    cred, _, err := engine.FinishRegistration(session, req.Response)
    if err != nil { return err }
    return db.SaveCredential(user.Subject, cred)      // persist for future logins
}))

The login endpoint is mounted automatically at POST /login/pass/{name} — the SPA dispatches between begin/finish by including or omitting the assertion field in the JSON body.

Username-first vs discoverable flow

By default the ceremony is discoverable — the SPA posts an empty body and the authenticator picks any resident credential. Add WithUserCredentialsLookup for the username-first flow:

go
strategy, _ := passkey.NewStrategy("passkey", engine, lookup,
    passkey.WithUserCredentialsLookup(func(ctx context.Context, hint passkey.UserHint) ([][]byte, error) {
        // hint.Username is the typed identifier; hint.Handle is the cached user handle.
        // Returning a non-empty slice scopes allowCredentials; returning (nil, nil) for
        // unresolvable hints lets the ceremony fall back to discoverable without leaking
        // which usernames exist.
        if hint.Username == "" { return nil, nil }
        return db.CredentialIDsByUsername(ctx, hint.Username)
    }),
)

The SPA then posts { "username": "alice" } to the begin step instead of an empty body.

Conditional UI (autofill)

Browsers that support Conditional Mediation (Safari 16+, Chrome 108+) surface enrolled passkeys inline with the username field's autocomplete dropdown — no button click required. The built-in ada login UI activates this automatically when a passkey strategy is advertised; custom SPAs opt in with autocomplete="username webauthn" on the username input plus mediation: "conditional" on navigator.credentials.get.

Algorithms

passkey.DefaultAlgorithms advertises ES256 → EdDSA → RS256 in that order. The verification path additionally accepts ES384/ES512 and PS256/PS384/PS512. EdDSA (Ed25519, COSE alg -8) is supported as of v0.5.

Attestation formats: "none" (the default — what platform authenticators emit) and "packed" with self or x5c-basic. Other formats (tpm, android-key, android-safetynet, apple, fido-u2f) are deliberately unsupported.

Clustered deployments

The default ChallengeStore is in-memory and won't survive a load-balancer that routes begin/finish to different instances. Inject your own backend with WithChallengeStore — any type that satisfies the three-method ChallengeStore interface works (Redis is a typical choice).

Sign-count replay defense

WithSignCountUpdater persists the new sign counter after every successful login. Hardware keys advance the counter on each use; if a stored value is non-zero and the next assertion presents a smaller value, the strategy rejects the login (cloned-authenticator defense). Platform authenticators always report 0 — that's not a regression and is accepted.

Multiple Strategies

Register multiple strategies — the login UI renders all of them automatically:

go
authMW.Strategy(local.New("local", myVerifier, local.WithLabel("Email & password")))
authMW.Strategy(authoauth2.New("google", googleCfg, authoauth2.Options{Label: "Google"}))
authMW.Strategy(authoauth2.New("github", githubCfg, authoauth2.Options{Label: "GitHub"}))
authMW.Strategy(magiclink.New("magic", magicCfg, magiclink.WithLabel("Email link")))
authMW.Strategy(apikey.New("apikey", keyValidator))    // hidden from UI
authMW.Strategy(basic.New("basic", verifier))           // hidden, browser dialog
authMW.Strategy(header.New("proxy"))                    // hidden, proxy-injected

The UI groups them: form-based strategies (local, LDAP, magic link, password-flow OAuth2) show as tab-switchable forms; redirect-based strategies (OAuth2 code flow) show as buttons below an "or" divider. API key, basic, and header strategies are hidden from the UI.

Configuration

Auth Config

FieldTypeDefaultDescription
Basestring"/"URL prefix for all auth routes
UI.Titlestring"Sign in"Title shown on login page
UI.Subtitlestring""Text below the title
UI.Iconstring""Logo URL, data URI, inline SVG, or filename in embedded FS
UI.Versionstring""Version text shown in header
UI.ExternalFolderboolfalseUse your own login page instead of embedded UI
UI.SignupFirstboolfalseLand on the signup form instead of login (first-run bootstrap)
UI.Thememap[string]stringnilCSS variable overrides applied to :root at load (e.g. {"btn-bg": "#3b82f6"})
UI.CustomCSSURLstring""URL of a stylesheet appended to the login page for full restyling
CookieNamestring"auth_session"Session cookie name
Cookie.Pathstring"/"Cookie path
Cookie.MaxAgeint0Cookie max-age in seconds
Cookie.Domainstring""Cookie domain
Cookie.SecureboolfalseCookie secure flag
Cookie.HttpOnlyboolfalseCookie HttpOnly flag
Cookie.SameSitehttp.SameSiteLaxCookie SameSite policy
IssuerConfig.AccessTTLtime.Duration15mAccess token lifetime
IssuerConfig.RefreshTTLtime.Duration7dRefresh token lifetime
IssuerConfig.RotateRefreshbooltrueRotate refresh token on each refresh

OAuth2 Config

FieldTypeDefaultDescription
IssuerURLstring""OIDC issuer URL for auto-discovery
ClientIDstringrequiredOAuth2 client ID
ClientSecretstringrequiredOAuth2 client secret
Scopes[]string[]Requested scopes
AuthURLstringautoAuthorization endpoint
TokenURLstringautoToken endpoint
UserInfoURLstringautoUserInfo endpoint
RevocationURLstringautoToken revocation endpoint
LogoutURLstringautoEnd-session endpoint
DisablePKCEboolfalseDisable PKCE (not recommended)
PasswordFlowboolfalseEnable resource owner password flow
AuthHeaderStyleint0 (Basic)0=Basic, 1=Bearer, 2=Params

Routes

With default Base: "/":

MethodPathDescription
GET/login/Login UI (embedded Svelte or external folder)
GET/login/infoJSON: title, subtitle, icon, version, strategies
GET/login/meCurrent user's Identity as JSON
GET/POST/login/pass/{strategy}Initiate or submit login
POST/login/register/{strategy}Create an account (strategy must implement Registerer)
GET/login/callback/{strategy}OAuth2 redirect callback
POST/login/refreshForce-refresh access token
POST/logoutRevoke session and clear cookie
GET/login/statusStatus iframe (for popup flow)

Identity

After Require(), the identity is available in the request context:

go
id := identity.FromContext(r.Context())

id.Subject       // "alice"
id.Name          // "Alice Example"
id.Email         // "alice@example.com"
id.EmailVerified // true
id.Roles         // ["admin", "user"]
id.Scopes        // ["read", "write"]
id.Provider      // "local" or "google"
id.Claims        // map[string]any — raw OIDC claims
id.IssuedAt      // time.Time

// Typed claim access
tenantID := identity.Claim[string](id, "tenant_id")

// Role/scope checks
id.HasRole("admin")  // true
id.HasScope("write") // true

Session & Issuer

The session is backed by an internal issuer that mints opaque access/refresh tokens:

  • Browser cookie carries only an opaque session ID (never a JWT)
  • Access tokens live for 15 minutes (configurable)
  • Refresh tokens live for 7 days (configurable), rotated on each use
  • Session state is stored in a pluggable backend (in-memory by default)

Custom Issuer Backend

go
import "github.com/rakunlabs/ada/middleware/auth/sessionstore/file"

store := file.New(file.Config{Path: "/var/sessions"}, sessionstore.Options{...})
authMW.WithSessionStore(store)

For Redis:

go
import redisstore "github.com/rakunlabs/ada/middleware/auth/sessionstore/redis"

store, err := redisstore.New(ctx, redisstore.Config{
    Address:  "localhost:6379",
    Password: "secret",
}, sessionstore.Options{...})
authMW.WithSessionStore(store)

Login UI

The middleware ships an embedded Svelte login page that reads /login/info to dynamically render:

  • Form inputs per strategy (username/password, email/token, etc.)
  • OAuth2 redirect buttons
  • Strategy tabs when multiple form strategies exist
  • Optional "Sign up" toggle (per-strategy — appears only when the strategy has a Registrar)
  • Light/dark theme toggle (persisted to localStorage, respects prefers-color-scheme)
  • Runtime theme overrides (UIConfig.Theme) and custom stylesheet (UIConfig.CustomCSSURL)
  • Configurable title, subtitle, icon, and version

Styling the Built-in UI

Every color, radius, and surface in the login card reads a --auth-* CSS custom property. You can tune them without touching the Svelte source. Two knobs:

Theme tokens (lightweight brand tweaks)

Pass a map of CSS variable overrides. Keys may be bare (primary, btn-bg) or fully qualified (--auth-btn-bg); the UI normalizes them to --auth-* custom properties on :root.

go
authMW := auth.New(auth.Config{
    UI: auth.UIConfig{
        Theme: map[string]string{
            "btn-bg":              "#3b82f6",
            "btn-bg-hover":        "#2563eb",
            "input-focus-border":  "#3b82f6",
            "input-focus-ring":    "rgba(59, 130, 246, 0.18)",
        },
    },
})

Radius scale — three tokens cover every rounded corner, so you can make everything sharp, pill-shaped, or in between:

go
Theme: map[string]string{
    "radius-lg": "0",    // the card
    "radius":    "0",    // inputs, primary button, OAuth buttons
    "radius-sm": "0",    // tabs, banners, theme toggle
}

Full token list (also documented at the top of _ui/src/style/global.css):

CategoryTokens
Surfaces--auth-bg, --auth-card-bg, --auth-card-border, --auth-card-shadow
Radii--auth-radius-sm, --auth-radius, --auth-radius-lg
Text--auth-text-primary, --auth-text-secondary, --auth-text-muted
Inputs--auth-input-bg, --auth-input-border, --auth-input-focus-border, --auth-input-focus-ring
Primary button--auth-btn-bg, --auth-btn-bg-hover, --auth-btn-text
OAuth buttons--auth-oauth-bg, --auth-oauth-border, --auth-oauth-hover, --auth-oauth-text
Misc--auth-divider, --auth-toggle-bg, --auth-toggle-text, --auth-toggle-hover
Error banner--auth-error-bg, --auth-error-border, --auth-error-text
Notice banner--auth-notice-bg, --auth-notice-border, --auth-notice-text

Dark-mode overrides apply automatically from [data-theme="dark"]. If you need different values per mode, use the custom stylesheet escape hatch below.

Custom stylesheet (full control)

Point CustomCSSURL at a stylesheet your app serves; the UI appends it to <head> on load so your rules win the cascade.

go
UI: auth.UIConfig{
    CustomCSSURL: "/static/auth.css",
}

Example auth.css — override dark-mode only:

css
[data-theme="dark"] {
  --auth-bg: #0b1220;
  --auth-card-bg: #111a2c;
  --auth-btn-bg: #22d3ee;
}

You can also rewrite anything else — the custom CSS loads last and has full access to the card's class names (.card, .btn-primary, .btn-oauth, .field, .strategy-tab, .notice-banner, .error-banner, etc.).

INFO

The UI serves at /login/ and its CSS loads before /login/info returns, so there's a brief flash of default styling before your Theme overrides apply. For fully flicker-free branding, use CustomCSSURL pointing at a file that hard-codes your values.

Custom Login Page

Set ExternalFolder: true and mount your own handler:

go
authMW := auth.New(auth.Config{
    UI: auth.UIConfig{ExternalFolder: true},
})

// After authMW.Mount(mux):
mux.GET("/login/", myCustomLoginHandler)

Your page should fetch ./info (relative to the login path) to get strategy descriptors and render matching forms/buttons.

Architecture

Complete Example

go
package main

import (
    "context"
    "fmt"
    "net/http"
    "os"

    "github.com/rakunlabs/ada"
    "github.com/rakunlabs/ada/middleware/auth"
    "github.com/rakunlabs/ada/middleware/auth/identity"
    "github.com/rakunlabs/ada/middleware/auth/strategy/local"
    authoauth2 "github.com/rakunlabs/ada/middleware/auth/strategy/oauth2"
)

func main() {
    ctx := context.Background()

    authMW := auth.New(auth.Config{
        UI: auth.UIConfig{
            Title:    "My Application",
            Subtitle: "Sign in to your account",
            Version:  "v1.0.0",
        },
    })

    // Local strategy
    authMW.Strategy(local.New("local", verifyUser,
        local.WithLabel("Email & password"),
    ))

    // OAuth2 with OIDC Discovery + PKCE (auto-enabled)
    if clientID := os.Getenv("GOOGLE_CLIENT_ID"); clientID != "" {
        authMW.Strategy(authoauth2.New("google", authoauth2.Config{
            IssuerURL:    "https://accounts.google.com",
            ClientID:     clientID,
            ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
            Scopes:       []string{"openid", "email", "profile"},
        }, authoauth2.Options{
            Label:            "Sign in with Google",
            CallbackBasePath: "/login/callback",
        }))
    }

    if err := authMW.Init(ctx); err != nil {
        panic(err)
    }

    server, _ := ada.NewWithFunc(ctx, func(ctx context.Context, mux *ada.Mux) error {
        authMW.Mount(mux)

        mux.GET("/", func(w http.ResponseWriter, r *http.Request) {
            fmt.Fprintln(w, "Public homepage")
        })

        app := mux.Group("/app")
        app.Use(authMW.Require())
        app.GET("/dashboard", func(w http.ResponseWriter, r *http.Request) {
            id := identity.FromContext(r.Context())
            fmt.Fprintf(w, "Hello %s (%s)", id.Name, id.Provider)
        })

        return nil
    })

    server.Start(":8080")
}

func verifyUser(ctx context.Context, user, pass string) (*identity.Identity, error) {
    // Replace with your database lookup
    if user == "admin" && pass == "secret" {
        return &identity.Identity{
            Subject: "admin",
            Name:    "Admin User",
            Email:   "admin@example.com",
            Roles:   []string{"admin"},
        }, nil
    }
    return nil, local.ErrInvalidCredentials
}

WARNING

Always use Cookie.Secure: true and Cookie.HttpOnly: true in production. The defaults are permissive for local development.

DANGER

Never store secrets (ClientSecret, SessionKey) in source code. Use environment variables or a secret manager.