Google Sign-In, email/password login, and JWT sessions for single-origin apps
TAuth lets product teams accept Google Sign-In or tenant-managed email/password credentials, mint their own cookies, and keep browsers free of token storage. Ship a secure authentication stack by pairing this Go service with the tiny tauth.js module.
TAuth servers are the only place /auth/* and /me endpoints are implemented; consuming apps call those endpoints rather than hosting their own copies.
TAuth is authentication-only: it validates Google ID tokens and issues first-party session cookies/JWTs. It does not implement OAuth2 authorization flows for Google APIs (YouTube/Drive/etc) and does not manage Google API access/refresh tokens.
- Own the session lifecycle – verify an identity once, then rely on short-lived access cookies and rotating refresh tokens.
- Zero tokens in JavaScript – the client handles hydration, silent refresh, and logout notifications without touching
localStorage. - Minutes to value – a single binary with predictable defaults, powered by Gin and Google’s official identity SDK.
- Designed for growth – plug in Postgres or SQLite to persist refresh tokens, and extend the web hook points to fit your product.
Every deployment — even “single tenant” ones — loads configuration from a YAML file. Define your tenants (origins, Google clients, cookie domain, and TTLs) once and pass that file to every TAuth process:
cat > tenants.yaml <<'YAML'
tenants:
- id: "prod"
display_name: "Production tenant"
tenant_origins:
- "https://gravity.mprlab.com"
- "https://pinguin.mprlab.com"
google_web_client_id: "your_web_client_id.apps.googleusercontent.com"
google_native_client_id: "your_desktop_native_client_id.apps.googleusercontent.com"
google_native_clients:
- platform: "ios"
client_id: "your_ios_client_id.apps.googleusercontent.com"
redirect_uris:
- "com.example.app://oauth2redirect/google"
- "https://app.example.com/oauth/google/callback"
- platform: "android"
client_id: "your_android_client_id.apps.googleusercontent.com"
redirect_uris:
- "com.example.app:/oauth2redirect/google"
password_auth:
enabled: true
users:
- email: "operator@example.com"
display_name: "Operator"
password_hash: "$2a$10$7EqJtq98hPqEX7fNZaFWoOhiG6MQT2Vjex6Dh2M1ngqRh5JalXH1V6"
jwt_signing_key: "replace-with-your-tenant-signing-key"
cookie_domain: ".mprlab.com"
session_cookie_name: "app_session_prod"
refresh_cookie_name: "app_refresh_prod"
session_ttl: "15m"
refresh_ttl: "1440h"
nonce_ttl: "5m"
allow_insecure_http: false
YAMLTenant files accept shell-style environment placeholders (${TENANT_COOKIE_DOMAIN} or $TENANT_COOKIE_DOMAIN) in any string field. TAuth expands those variables before validation so you can keep secrets or per-host values in .env files; missing variables collapse to empty strings, so keep sensible defaults in the YAML when a field is required.
Each entry defines:
id– stable identifier used inside JWTs and storage (lowercase letters/numbers/underscores/hyphens).display_name– friendly label surfaced in logs and the demo UI.tenant_origins– browser origins that should resolve to this tenant. Entries must be full origins (https://app.example.com,http://localhost:8000); the resolver uses the requestOriginheader to select a tenant, and can optionally accept anX-TAuth-Tenantoverride when you enable it for shared-origin or non-browser clients.allowed_users– optional list of email addresses allowed to log in for the tenant; when present, only these users may sign in. An empty list blocks all sign-ins for the tenant.google_web_client_id– OAuth Web client configured in Google Cloud Console for this tenant’s origins.google_native_client_id– optional legacy OAuth Desktop/installed-app client used by native apps that sign in through the system browser and exchange ID tokens withPOST /auth/google/native.google_native_clients– optional platform-specific native clients. Useplatform: "ios"/"android"for Expo mobile apps, set the matching Google OAuth client ID, and list every custom-scheme or app-link redirect URI the app may use. Every native client ID must be unique across tenants.password_auth– optional email/password provider. Setenabled: trueand seed users with normalized emails, display names, optional avatar URLs, and bcryptpassword_hashvalues. The first supported slice is login-only; signup, verification, reset-password, and account linking are intentionally outside this flow.jwt_signing_key– HS256 secret unique to this tenant. Every tenant must declare its own signing key so sessions remain isolated.cookie_domain– registrable domain for cookies (e.g..mprlab.comto share cookies across subdomains). Leave it blank to emit host-only cookies when developing onlocalhost.session_ttl/refresh_ttl/nonce_ttl– durations using Go’stime.ParseDurationsyntax.allow_insecure_http–trueonly for local development; production tenants must stayfalse. When enabled, cookies drop theSecureflag and default toSameSite=Laxso browsers keep them over HTTP (even if CORS is on). That setup only works when your dev UI also runs onhttp://localhost, so avoid mixing hosts like127.0.0.1.
cat > config.yaml <<'YAML'
server:
listen_addr: ":8443"
database_url: "sqlite:///data/tauth.db"
enable_cors: true
cors_allowed_origins:
- "https://gravity.mprlab.com"
- "https://accounts.google.com"
cors_allowed_origin_exceptions:
- "https://accounts.google.com"
enable_tenant_header_override: false
tenants:
- id: "gravity"
display_name: "Gravity"
tenant_origins: ["https://gravity.mprlab.com"]
google_web_client_id: "gravity-client.apps.googleusercontent.com"
google_native_client_id: "gravity-native.apps.googleusercontent.com"
jwt_signing_key: "replace-with-gravity-signing-key"
cookie_domain: ".mprlab.com"
session_cookie_name: "app_session_gravity"
refresh_cookie_name: "app_refresh_gravity"
session_ttl: "30m"
refresh_ttl: "720h"
nonce_ttl: "10m"
allow_insecure_http: false
YAML
tauth --config=config.yaml
# or set TAUTH_CONFIG_FILE=/etc/tauth/config.yaml and run `tauth`Before deploying, run tauth preflight --config=config.yaml to validate the config and emit a redacted effective-config report (signing keys and tenant origins are reported as fingerprints only so validators can compare without seeing secrets).
SQLite DSN tip: use three slashes for absolute paths (e.g.
sqlite:///data/tauth.db). Host-based forms such assqlite://file:/data/tauth.dbare invalid and rejected at startup.
When multiple product origins need access, list them under the cors_allowed_origins array inside config.yaml. If you include non-tenant origins (for example https://accounts.google.com), mirror them in cors_allowed_origin_exceptions so config validation permits them.
Host the binary behind TLS (or terminate TLS at your load balancer) so responses set Secure cookies. Working from the tenants file above, cookies issued by https://tauth.mprlab.com will also be sent with requests made by https://gravity.mprlab.com because both live under .mprlab.com.
We ship a compose example under examples/tauth-demo that builds TAuth from the local Dockerfile and pairs it with a simple static web server (ghcr.io/tyemirov/ghttp:latest) serving the demo assets on port 8000. The TAuth service itself serves only API endpoints plus /tauth.js.
-
cd examples/tauth-demo -
Update the environment file with your Google OAuth client ID and signing key:
$EDITOR .env.tauth -
Review
config.yamlto ensure the tenant origins and ports match your local setup. -
Build and start the stack:
docker compose up --build -
Visit
http://localhost:8000to load the demo UI (it communicates with TAuth athttp://localhost:8082via CORS).
The sample config now defines two tenants so you can exercise origin-based routing without touching /etc/hosts. Thanks to RFC 6761, any *.localhost name automatically resolves to 127.0.0.1, so both tenants work out of the box:
notes– resolve viahttp://localhost:8082(or the Gravity UI athttp://localhost:8000). This matches the default Gravity config and is the tenant you’ve already used.mpr-sites– thempr-frontendcontainer servesexamples/tauth-demo/index.htmlonhttp://localhost:8001. Its browser origin (http://localhost:4173) lives undertenant_origins, so TAuth can derive the tenant from the requestOriginheader without extra UI wiring.
This setup lets you verify header overrides, cookie isolation, and resolver behavior locally before promoting changes to production.
When multiple tenants run on the same machine, list each distinct frontend origin (for example http://localhost:8000 for Gravity and http://localhost:4173 for the MPR demo) under tenant_origins. TAuth resolves tenants by the request Origin header, so you only need explicit tenant overrides when two tenants intentionally share the exact same origin.
Stop the stack with docker compose down. The compose file persists refresh tokens inside a named tauth_data volume mounted at /data, so you can inspect or reset the SQLite database between runs. Update .env.tauth (or the referenced config.yaml) to change ports, database DSNs, origins, cookie domains, or Google credentials before re-running. Re-run docker compose up --build whenever you change Go code so the local image picks up your edits.
<script src="https://tauth.mprlab.com/tauth.js"></script>
<script>
initAuthClient({
baseUrl: "https://tauth.mprlab.com",
tenantId: "demo", // optional override when multiple tenants share an origin
onAuthenticated(profile) {
renderDashboard(profile);
},
onUnauthenticated() {
showGoogleButton();
}
});
</script>
<div id="googleSignIn"></div>The GitHub Pages workflow in .github/workflows/frontend-deploy.yml publishes the docs/ site and copies web/tauth.js into the site root, so the helper is available at https://<pages-domain>/tauth.js when Pages is enabled.
tauth.js requires an explicit baseUrl in initAuthClient; it never infers the API host from the script origin. On first load the helper defaults to bootstrapMode: "restore-if-hinted": anonymous visitors are reported through onUnauthenticated() without probing protected endpoints, while browsers that previously authenticated carry a non-secret local restore hint that allows /me and /auth/refresh recovery. Use bootstrapMode: "eager" only when you intentionally want the legacy probe-first behavior, or bootstrapMode: "passive" when a public surface should never restore on load.
tauth.js already fetches nonces, initializes Google Identity Services, and exchanges credentials for you. Render the button, provide onAuthenticated / onUnauthenticated callbacks, and the helper keeps cookies fresh across your origin. When building a custom UI, follow the handshake described in ARCHITECTURE.md#google-sign-in-exchange: fetch a nonce, pass it to Google when initializing the popup, then POST { google_id_token, nonce_token } to /auth/google. The minted app_session cookie authenticates /api/me and any downstream routes on the configured domain (e.g. .mprlab.com).
For tenants with password_auth.enabled: true, call exchangePasswordCredential({ email, password }) from the same helper or POST directly to /auth/password/login with credentials: "include". The response, HttpOnly cookies, refresh behavior, and /me profile shape are identical to Google login.
-
Create or reuse a Google OAuth Web client. Add every product origin (e.g.
https://gravity.mprlab.com) to the Authorized JavaScript origins list. Redirect URIs are not required for this popup flow. -
Load the GIS SDK before you render a button.
<script src="https://accounts.google.com/gsi/client" async defer></script> <div id="googleSignIn"></div>
-
Fetch and attach a nonce before prompting Google. Use
POST /auth/nonce, callgoogle.accounts.id.initialize({ nonce, client_id, ux_mode: "popup" }), and render the button programmatically (seeprepareGoogleSignInabove orexamples/tauth-demo/index.html). -
Exchange the credential without redirecting. When GIS invokes your callback, post
{ google_id_token, nonce_token }tohttps://tauth.mprlab.com/auth/google(or your hosted base URL) withcredentials: "include"so TAuth can mint cookies.
- Open the browser console and confirm a nonce request (
POST /auth/nonce) fires before the GIS popup. - Click the button; the popup should open and return a credential to
handleCredential. - Check the network tab for
POST https://tauth.mprlab.com/auth/googleand ensure it succeeds (200). - Inspect cookies;
app_sessionandapp_refreshshould now be scoped to the configured domain (e.g..mprlab.com). - Call
/api/meand verify it returns the signed-in profile.
Tip: The Docker demo ships with a placeholder Google OAuth Web client inside
examples/tauth-demo/.env.tauth. Replace it with your own value before sharing the stack beyond local testing.
TAuth also supports installed apps that cannot use the browser popup flow. Native clients such as PromptDew desktop or PromptDew Mobile should:
- Fetch tenant-specific metadata from
GET /auth/google/native/config. Mobile clients should pass?platform=iosor?platform=android; non-browser requests must includeX-TAuth-Tenant. - Open Google in the system browser with
response_type=code,scope=openid email profile, PKCES256, and the OIDC nonce. Desktop apps can use a loopback redirect likehttp://127.0.0.1:<port>/oauth/google/callback; Expo mobile apps should use one configured custom-scheme or HTTPS app-link redirect URI. - Exchange the authorization code directly with Google and extract the returned
id_token. - Send that
id_tokenplus the original OIDC nonce toPOST /auth/google/native. Mobile clients should also sendplatformand theredirect_urithey used so TAuth can select the correct accepted audience and reject unconfigured redirects. - Reuse the minted
app_session/app_refreshcookies just like a browser client.
This keeps TAuth authentication-only: Google authorization codes and Google refresh tokens never transit through TAuth.
TAuth does not return bearer or refresh tokens in the response body for mobile clients. Expo apps should preserve the Set-Cookie headers in the native cookie jar and send cookies on calls to TAuth and downstream API hosts. For cross-host use, configure a shared cookie_domain such as .mprlab.com and have downstream services validate app_session with pkg/sessionvalidator.
Successful exchanges populate /me with a rich profile:
{
"user_id": "google:12345",
"user_email": "user@example.com",
"display": "Example User",
"avatar_url": "https://lh3.googleusercontent.com/a/AEdFTp7...",
"roles": ["user"],
"expires": "2024-05-30T12:34:56.000Z"
}Use the new avatar_url field to render signed-in UI chrome in your frontend.
TAuth now reads all configuration from a single YAML file (config.yaml by default). The snippet above shows the server-level keys; the example below highlights the tenants section. A “single-tenant deployment” is simply a file with one entry; adding more entries lets you serve multiple products from the same binary without touching CLI flags.
tenants:
- id: "demo"
display_name: "Demo tenant"
tenant_origins:
- "https://demo.localhost"
- "https://demo.example.com"
google_web_client_id: "demo-client.apps.googleusercontent.com"
google_native_client_id: "demo-native.apps.googleusercontent.com"
google_native_clients:
- platform: "ios"
client_id: "demo-ios.apps.googleusercontent.com"
redirect_uris: ["com.demo.app://oauth2redirect/google"]
- platform: "android"
client_id: "demo-android.apps.googleusercontent.com"
redirect_uris: ["com.demo.app:/oauth2redirect/google"]
password_auth:
enabled: true
users:
- email: "user@example.com"
display_name: "Example User"
avatar_url: "https://example.com/avatar.png"
password_hash: "$2a$10$7EqJtq98hPqEX7fNZaFWoOhiG6MQT2Vjex6Dh2M1ngqRh5JalXH1V6"
jwt_signing_key: "demo-signing-key"
cookie_domain: "demo.example.com"
session_cookie_name: "app_session_demo"
refresh_cookie_name: "app_refresh_demo"
session_ttl: "30m"
refresh_ttl: "720h"
nonce_ttl: "10m"
allow_insecure_http: trueRules enforced by the loader:
- IDs must use lowercase letters, digits, underscores, or hyphens (
demo,customer_b). display_nameis required so operators can distinguish tenants in logs.tenant_originsentries are validated and normalized as origins (scheme + host + optional port). Add every browser origin that should resolve to this tenant (for examplehttps://app.example.com,http://localhost:8000). If multiple tenants share the same origin, enable the header override and sendX-TAuth-Tenant.allowed_usersis optional; when provided, only those email addresses can log in for the tenant (an empty list denies all logins).- Behavior:
allowed_usersabsent → allow all; present empty → deny all; present with entries → allow only listed emails. - Unlisted users are rejected during
/auth/google,/auth/google/native, and/auth/password/loginwith403anderror: "user_not_allowed"whenallowed_usersis set. google_web_client_idand each TTL must be present and non-empty.google_native_client_idandgoogle_native_clientsare optional and enableGET /auth/google/native/configplusPOST /auth/google/nativefor installed apps; every configured native client ID must be unique across tenants. Durations use Go’stime.ParseDurationsyntax (e.g.15m,720h); zero or negative values are invalid.cookie_domainmay be blank to issue host-only cookies (recommended locally); when provided it must be a valid registrable domain (e.g..example.com).password_auth.enabledgatesPOST /auth/password/login. Configured password users are seeded at startup into the active store; persistent deployments keep credentials in the same database as refresh tokens and profiles. Startup seeding reconciles the credential table, so users removed frompassword_auth.userscan no longer authenticate after restart.session_cookie_name/refresh_cookie_namemust be specified for every tenant. Choose unique values per tenant to avoid overwriting each other’s cookies when they share a cookie domain (for exampleapp_session_notes,app_refresh_mpr). Legacy stacks (such as Gravity) can keepapp_session/app_refreshas long as they understand the collision risk.nonce_ttldefaults to5mif omitted;allow_insecure_httpdefaults tofalseand should only betruefor localhost development. With that flag enabled, cookies downgrade toSameSite=Laxand omit theSecurebit so browsers accept them over HTTP.- Values support shell-style environment expansion (
${TENANT_COOKIE_DOMAIN}or$TENANT_COOKIE_DOMAIN) before parsing. Missing variables resolve to empty strings, so leave meaningful defaults in the file to avoid loader validation errors. Literal bcrypt hashes beginning with$2a$,$2b$, or$2y$are preserved so password hashes are not mistaken for env placeholders.
The internal/tenants package validates the entire file before returning domain objects, so downstream routing relies on trusted tenant definitions. Request routing works as follows:
- The resolver matches tenants by the request’s
Originheader. Requests without anOriginheader (or with an unknown origin) are rejected unless you enable the header override. - For local development, non-browser clients, or shared origins, enable the optional header override (
enable_tenant_header_override: true). When enabled, TAuth accepts either a tenant ID (X-TAuth-Tenant: demo) or a frontend origin (X-TAuth-Tenant: http://localhost:8000) as the override hint. Leave it disabled in production when every tenant owns unique origins. internal/tenants.TenantMiddlewareattaches the resolved tenant togin.Context; downstream handlers calltenants.TenantFromContextto retrieve the resolved configuration and proceed with tenant-scoped logic.- Launch the server with
tauth --config=/path/to/config.yaml(or exportTAUTH_CONFIG_FILE); no other CLI flags or environment variables are required. - Front-ends that share a single origin can opt into an explicit tenant selection by adding
data-tenant-id="tenant-a"to the<script src="/proxy/https/github.com/tyemirov/.../tauth.js">tag or by callingsetAuthTenantId("tenant-a")beforeinitAuthClient(...)when you need to override the origin mapping (for example, preview builds served from the same origin).tauth.jsonly adds theX-TAuth-Tenantheader to its own/me,/auth/*, and logout calls when a tenant id is explicitly configured, leaving your product’s API traffic untouched. Restore hints are scoped bybaseUrland tenant id so shared-origin tenants do not reuse each other’s bootstrap state. - Refresh tokens, nonce pools, and the built-in demo user store are keyed by tenant ID. Session JWTs now embed a
tenant_idclaim, and the middleware rejects cookies presented under the wrong tenant so credentials cannot hop between tenants.
Custom clients must follow the nonce exchange documented in ARCHITECTURE.md#google-sign-in-exchange. The README’s quick-start sticks to the happy-path view; dive into the architecture doc for the exact sequencing (nonce issuance, GIS initialization, credential exchange, and /auth/google expectations). The default helpers already implement the full set of guardrails.
The tauth doctor command validates TAuth configurations and reports issues. Use it to verify your configuration before deployment or to audit multiple project configurations:
# Validate a single configuration
tauth doctor config.yaml
# Validate multiple configurations with cross-config checks
tauth doctor config.yaml other-config.yaml --cross-validate
# Output as JSON for CI/CD pipelines
tauth doctor config.yaml --json
# Check database connectivity
tauth doctor config.yaml --check-databaseThe doctor command performs comprehensive validation including:
- Configuration file syntax and structure
- Tenant configuration requirements (TTLs, signing keys, origins)
- CORS origin alignment with tenant origins
- Cookie scope isolation across tenants
- Cross-config validation (conflicting origins, shared signing keys)
- Works out of the box for any single registrable domain—host TAuth once and share cookies across subdomains.
- Toggle CORS (and
SameSite=Noneautomatically) when your UI is served from a different origin during development. - Set
database_urlto a Postgres or SQLite DSN to store refresh tokens durably. - Structured zap logging makes it easy to monitor sign-in, refresh, and logout flows wherever you deploy.
- Read the authoritative usage guide in
docs/usage.mdfor end-to-end setup and integration details. - Dive into ARCHITECTURE.md for endpoints, request flows, and deployment guidance.
- Read POLICY.md for the confident-programming rules enforced across the codebase.
- Inspect
web/tauth.jsto extend UI hooks or wire additional analytics. - Validate sessions from other Go services with
pkg/sessionvalidator.
MIT (or your preferred license). Add a LICENSE file accordingly.