This document is the authoritative guide for operators and front‑end teams integrating against a TAuth deployment. It explains how to run the service, how sessions work, and how to connect a browser application using either the provided helper script or direct HTTP calls.
For a deep dive into internal architecture and implementation details, see ARCHITECTURE.md. For confident‑programming and refactor policies, see POLICY.md and docs/refactor-plan.md.
TAuth sits between Google Identity Services (GIS) and your product UI:
/static/auth-client.js) for zero-token-in-JavaScript sessions.Once TAuth is running for a given registrable domain, any app on that domain (or its subdomains) can rely on the HttpOnly session cookies instead of storing tokens in localStorage or JavaScript memory.
The tauth binary lives under cmd/server in this repository. You can:
go build ./cmd/server), orexamples/docker-compose for a local stack.The binary reads configuration exclusively from a YAML file (default config.yaml). Use tauth --config=/path/to/config.yaml or export TAUTH_CONFIG_FILE to point at a different file; no other environment variables or CLI flags are required.
config.yaml must include the server-level keys below plus at least one tenant:
| Key | Purpose | Example |
|---|---|---|
listen_addr |
HTTP listen address | :8080 |
database_url |
Refresh store DSN | sqlite:///data/tauth.db |
enable_cors |
Enable CORS for cross-origin UIs | true / false |
cors_allowed_origins |
Allowed origins when CORS is enabled (include your UI origins and https://accounts.google.com) |
["https://app.example.com","https://accounts.google.com"] |
enable_tenant_header_override |
Allow X-TAuth-Tenant overrides (dev/local only) |
true / false |
tenants |
Array of tenant entries (see README §5.1 for schema) | [...] |
Key notes:
Secure. Each tenant defines its own cookie_domain; use that field (e.g. .example.com) to share cookies across subdomains. Leave the field blank to emit host-only cookies during localhost development (browsers reject Domain=localhost).sqlite:///data/tauth.db). Host‑based forms such as sqlite://file:/data/tauth.db are rejected. For Postgres, use a standard DSN (postgres://user:pass@host:5432/dbname?sslmode=disable).enable_cors set to false when UI and API share the same origin. Enable it only when your UI is on a different origin (for example, Vite dev server) and set cors_allowed_origins explicitly. Google Identity Services performs its nonce/login exchange from the https://accounts.google.com origin, so always include that origin alongside your UI hosts.http://localhost:8000, http://localhost:4173, …) to the tenant’s allowed_hosts. TAuth inspects the request Origin header to resolve the tenant automatically. You can still enable enable_tenant_header_override and send X-TAuth-Tenant when you want to override the origin mapping manually.jwt_signing_key. TAuth uses that HS256 secret exclusively for the tenant’s cookies, so rotate keys per tenant instead of relying on a global fallback.allow_insecure_http: true on a tenant drops the Secure flag and downgrades cookies to SameSite=Lax so browsers keep them over HTTP even while CORS is enabled. This only works when your dev UI also runs on http://localhost (same host, different port); switching hosts such as 127.0.0.1 will make the browser treat the request as cross-site and block the cookies.This example mirrors the README but focuses on the minimum you need to host TAuth at https://auth.example.com for a product UI at https://app.example.com:
cat > config.yaml <<'YAML'
server:
listen_addr: ":8443"
database_url: "sqlite:///data/tauth.db"
enable_cors: true
cors_allowed_origins:
- "https://app.example.com"
- "https://accounts.google.com"
enable_tenant_header_override: false
tenants:
- id: "prod"
display_name: "Production Tenant"
allowed_hosts:
- "auth.example.com"
- "https://app.example.com"
google_web_client_id: "your_web_client_id.apps.googleusercontent.com"
jwt_signing_key: "replace-with-your-tenant-signing-key"
cookie_domain: ".example.com"
session_ttl: "15m"
refresh_ttl: "1440h"
nonce_ttl: "5m"
allow_insecure_http: false
YAML
tauth --config=config.yaml
Run this behind TLS so the service issues Secure cookies and the browser accepts them.
When migrating an existing tenant that expects the legacy cookie names (app_session, app_refresh), set the session_cookie_name / refresh_cookie_name fields inside the tenant block. These fields are always required—choose unique names per tenant to avoid collisions when multiple tenants share localhost. Legacy stacks (such as Gravity) can keep app_session / app_refresh, but doing so means any other tenant using the same names will overwrite those cookies.
For a full local stack (TAuth + demo UI) without installing Go:
cd examples/docker-composecp .env.tauth.example .env.tauthcp config.yaml.example config.yaml.env.tauth (set TAUTH_CONFIG_FILE=/config/config.yaml and the per-tenant TAUTH_GOOGLE_WEB_CLIENT_ID* / TAUTH_*_JWT_SIGNING_KEY values).config.yaml and replace the placeholder Google OAuth client with one registered for http://localhost:8000 and http://localhost:8080 (or keep the environment variable references from step 4).docker compose up --buildhttp://localhost:8000 for the demo UI. It talks to TAuth at http://localhost:8080.Stop the stack with docker compose down. The tauth_data volume holds the SQLite database, and config.yaml stays next to the compose file for future edits.
Use the preflight command to validate configuration and emit a redacted effective-config report before you launch the service:
tauth preflight --config=config.yaml
The report includes effective server settings, per-tenant cookie names and TTLs, derived SameSite modes, and JWT signing key fingerprints (never raw keys). Redacted reports still emit allowed_host_hashes and jwt_signing_key_fingerprint so external validators can compare secrets without exposing them. To include the raw allowed_hosts list, pass --include-hosts.
The JSON payload is versioned and shaped as:
schema_version, service metadataeffective_config (server + tenant settings)dependencies (preflight checks with readiness status)The preflight builder is generalized under github.com/tyemirov/utils/preflight with a Viper-based adapter (github.com/tyemirov/utils/preflight/viperconfig) for services that load YAML configs and bind env vars through Viper.
TAuth works with two cookies:
app_session – short‑lived JWT access token.
HttpOnly, Secure, SameSite (strict by default).app_refresh – opaque refresh token.
HttpOnly, Secure, Path=/auth./auth/refresh and revoked on /auth/logout.Your product should:
app_session to protect routes (for example via pkg/sessionvalidator in other Go services)./auth/refresh when API calls return 401 to keep sessions alive.auth-client.jsThe simplest way to use TAuth from the browser is through the helper served at /static/auth-client.js. It exports four globals:
initAuthClient(options) – hydrates the current user and sets up refresh behaviour.apiFetch(url, init) – wrapper around fetch that automatically refreshes sessions on 401.getCurrentUser() – returns the current profile object or null.logout() – revokes the refresh token and clears client state.For backend services written in Go, use the pkg/sessionvalidator package described in section 6.8 to validate app_session cookies.
On your product site, include the script from your TAuth origin:
<script
src="https://auth.example.com/static/auth-client.js"
data-tenant-id="tenant-admin"
></script>
If your UI and TAuth share a host (for example both under https://app.example.com), you can serve it directly from that origin instead.
Call initAuthClient once during startup, after the script loads:
<script>
// Optional: override tenant dynamically when the page knows which tenant to use.
setAuthTenantId("tenant-admin");
initAuthClient({
baseUrl: "https://auth.example.com",
tenantId: "demo", // optional override for shared-host dev setups
onAuthenticated(profile) {
renderDashboard(profile);
},
onUnauthenticated() {
showSignInButton();
},
});
</script>
Behaviour:
GET /me to check for an existing session.POST /auth/refresh.onAuthenticated(profile); otherwise it calls onUnauthenticated().profile object matches the /me response (see section 6.3).apiFetchWrap all authenticated HTTP requests through apiFetch:
async function loadProtectedData() {
const response = await apiFetch("/api/data", { method: "GET" });
if (!response.ok) {
throw new Error("request_failed");
}
return response.json();
}
When a call returns 401, apiFetch:
POST /auth/refresh with credentials: "include"."refreshed" events via BroadcastChannel (if available), allowing multiple tabs to stay in sync.If refresh fails, pending requests reject and callers can treat this as “logged out”.
Use logout() to terminate the session:
async function handleLogoutClick() {
await logout();
redirectToLanding();
}
The helper:
POST /auth/logout to revoke the refresh token."logged_out" to other tabs.onUnauthenticated() if provided.Most deployments rely on hostnames to resolve tenants. When multiple tenants intentionally share the same host (for example, several apps pointing at localhost:8080), enable the TAuth server’s header override (--enable_tenant_header_override). Once enabled, the helper tags /me and /auth/* calls with either your explicit tenantId or, when omitted, the current page origin so shared-host setups continue to function even if certain requests omit Origin. You can still pin a specific tenant explicitly by passing tenantId to initAuthClient:
initAuthClient({
baseUrl: "https://auth-dev.example.com",
tenantId: "team-blue",
onAuthenticated: hydrateDashboard,
onUnauthenticated: showGoogleButton,
});
The helper automatically attaches X-TAuth-Tenant: team-blue (or the current page origin when no ID is supplied) to /me, /auth/nonce, /auth/google, /auth/refresh, and logout requests while leaving your own API traffic alone. Switch tenants by reinitialising with a different tenantId (or prefer separate hosts when possible). The override never bypasses host validation — TAuth still checks that the HTTP Host header appears in some tenant definition and rejects requests from unlisted hosts even if the header is present.
TAuth assumes a GIS Web client using the popup flow. A nonce protects each sign‑in exchange.
https://app.example.com) and the TAuth origin (for example https://auth.example.com) to Authorized JavaScript origins.Load the GIS script:
<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="googleSignIn"></div>
The required sequence for custom clients is:
POST /auth/nonce
{ "nonce": "<random>" }.google.accounts.id.initialize({ client_id, nonce, ux_mode: "popup", callback }).response.credential:
POST /auth/google with JSON { "google_id_token": "<response.credential>", "nonce_token": "<same nonce>" } and credentials: "include".google_web_client_id.app_session and app_refresh cookies.You must fetch a fresh nonce for every sign‑in attempt. TAuth invalidates a nonce as soon as it is used.
When using auth-client.js or the mpr‑ui header component, this flow is handled internally; you only need to surface the Google button and configure your client ID.
This section documents the public HTTP surface from a client’s perspective. See ARCHITECTURE.md for a stable contract summary and versioning notes. These endpoints are served exclusively by the TAuth server; consuming applications should call them, not reimplement them.
POST /auth/nonceIssues a one‑time nonce for the next GIS exchange.
credentials: "include" if you want to reuse cookies on same origin.Response: 200 OK with JSON:
{ "nonce": "..." }
POST /auth/googleVerifies a Google ID token and mints cookies.
Request body:
{
"google_id_token": "<id_token_from_gis>",
"nonce_token": "<nonce_from_/auth/nonce>"
}
Response: 200 OK with user profile JSON (see /me below). Sets app_session and app_refresh cookies.
Common failure cases:
401).401).aud) does not match the resolved tenant’s google_web_client_id (401).GET /meReturns the profile associated with the current session.
app_session cookie.Response:
{
"user_id": "google:12345",
"user_email": "user@example.com",
"display": "Example User",
"avatar_url": "https://lh3.googleusercontent.com/a/...",
"roles": ["user"],
"expires": "2024-05-30T12:34:56.000Z"
}
401 when the access cookie is missing, expired, or invalid.POST /auth/refreshRotates the refresh token and mints a new access cookie.
app_refresh cookie.204 No Content on success. Sets new app_session and app_refresh cookies.After a successful refresh, call /me again or rely on auth-client.js to hydrate the profile.
POST /auth/logoutRevokes the refresh token and clears cookies.
204 No Content. Clears app_session and app_refresh.Clients should treat this as “signed out” regardless of prior state.
GET /static/auth-client.jsServes the browser helper described in section 4.
<script src="https://your-tauth-origin/static/auth-client.js"></script>.initAuthClient, apiFetch, getCurrentUser, logout on window.GET /demoOptional demo page shipped with the repository. Intended for local development only.
Downstream Go services that share the TAuth cookie domain can validate app_session cookies directly using the pkg/sessionvalidator package. This is the recommended way to enforce authentication and read identity information without duplicating JWT logic.
If your service can read the same config.yaml as TAuth, call LoadTenantAuthConfig to derive the tenant’s signing key, issuer, and cookie names before constructing a validator.
Add the module to your Go service and construct a validator at startup:
import (
"os"
"github.com/tyemirov/tauth/pkg/sessionvalidator"
)
func newSessionValidator() (*sessionvalidator.Validator, error) {
signingKey := []byte(os.Getenv("TAUTH_NOTES_JWT_SIGNING_KEY"))
return sessionvalidator.New(sessionvalidator.Config{
SigningKey: signingKey,
Issuer: "tauth",
// CookieName: optional; defaults to "app_session".
})
}
The configuration mirrors your TAuth deployment:
SigningKey must match the jwt_signing_key configured for the tenant whose cookies you validate.Issuer must match the issuer configured by the server (typically "tauth"; see ARCHITECTURE.md).CookieName defaults to app_session and should only be overridden if you have customised the cookie name on the TAuth side.The constructor validates configuration up front and returns a typed error if required fields are missing.
For Gin-based services, use the built-in middleware to protect routes and attach claims to the context:
import (
"log"
"github.com/gin-gonic/gin"
"github.com/tyemirov/tauth/pkg/sessionvalidator"
)
func main() {
validator, err := newSessionValidator()
if err != nil {
log.Fatalf("invalid validator configuration: %v", err)
}
router := gin.Default()
router.Use(validator.GinMiddleware(sessionvalidator.DefaultContextKey))
router.GET("/me", func(context *gin.Context) {
claimsValue, exists := context.Get(sessionvalidator.DefaultContextKey)
if !exists {
context.AbortWithStatus(http.StatusUnauthorized)
return
}
claims := claimsValue.(*sessionvalidator.Claims)
context.JSON(http.StatusOK, map[string]interface{}{
"user_id": claims.GetUserID(),
"user_email": claims.GetUserEmail(),
"display": claims.GetUserDisplayName(),
"avatar_url": claims.GetUserAvatarURL(),
"roles": claims.GetUserRoles(),
})
})
_ = router.Run()
}
Key points:
app_session cookie from each request, validates it, and aborts with 401 when invalid.*sessionvalidator.Claims value in the Gin context under the provided key (default auth_claims).GetUserID, GetUserEmail, GetUserDisplayName, GetUserAvatarURL, GetUserRoles, GetExpiresAt) to drive authorization and UI decisions.If you are not using Gin, or you need finer-grained control, use the lower-level helpers:
ValidateRequest(*http.Request) – validates the session cookie on an incoming request and returns *Claims.ValidateToken(string) – validates a raw JWT string, for example when the token is forwarded between services.Example with net/http:
func handleProtectedRoute(response http.ResponseWriter, request *http.Request, validator *sessionvalidator.Validator) {
claims, err := validator.ValidateRequest(request)
if err != nil {
http.Error(response, "unauthorized", http.StatusUnauthorized)
return
}
// Use claims.* accessors here.
}
Using the shared validator keeps your services aligned with TAuth’s JWT format and validation rules, and avoids duplicating cryptographic or time-based logic across codebases.
/auth/nonce, configures GIS with the nonce, and shows the popup./auth/google.apiFetch for protected calls.apiFetch returns 401.apiFetch sends POST /auth/refresh with the refresh cookie."refreshed".logout()."logged_out"; all tabs transition to unauthenticated state.Use this checklist when integrating:
/me but refresh works – Session cookie expired; ensure your client either uses auth-client.js or calls /auth/refresh before retrying./auth/refresh – Refresh cookie missing or revoked; treat as “signed out” and prompt the user to sign in again.cookie_domain matches the registrable domain you expect.enable_cors and cors_allowed_origins in config.yaml).aud claim in the ID token matches the tenant’s google_web_client_id.For more detailed operational guidance, refer to the troubleshooting section in ARCHITECTURE.md.
allowed_hosts so TAuth can resolve the tenant from the Origin header. You can still override the mapping by adding data-tenant-id="tenant-id" to the script tag (see 4.1) or by calling setAuthTenantId("tenant-id") before initAuthClient(...). The helper automatically sends X-TAuth-Tenant whenever you opt into an explicit override, and now falls back to the page origin when no tenant ID is provided.