A single-binary OIDC provider and OAuth2 authorization server that gives self-hosted Temporal browser SSO and JWT machine-auth without forking
temporal-serveror running a sidecar proxy.
Self-hosted Temporal can authenticate users and services, but only if you
supply an OIDC issuer. tempogate is that issuer. It federates sign-in to
Google, mints short-lived JWTs Temporal's stock authorizer already understands,
and publishes the JWKS the gRPC frontend verifies against. It runs as one
distroless binary and is aimed at teams operating their own Temporal cluster.
Self-hosted Temporal ships two extension points:
- the Web UI consumes any OIDC issuer via
TEMPORAL_AUTH_*env vars; - the gRPC frontend verifies JWTs against a configurable JWKS endpoint and
reads a
permissions: ["<namespace>:<action>", ...]claim.
Together these cover SSO and machine-auth — but only if something issues the
tokens. The usual alternatives are forking temporal-server or putting a
reverse proxy in front of it; both carry ongoing cost (see
the comparison below). tempogate fills
the gap Temporal's own docs already describe: point the Web UI at
https://tempogate.<your-domain> and the frontend's
global.authorization.jwtKeyProvider.keySourceURIs at
https://tempogate.<your-domain>/.well-known/jwks.json.
┌──────────────┐ OIDC ┌────────────────────┐
│ Temporal Web │ ─────────────▶│ tempogate │ ──wraps──▶ Google OAuth2
│ UI │◀── our JWT ───│ (OIDC + OAuth2 AS) │
└──────┬───────┘ └────────────────────┘
│ Bearer <tempogate JWT> ▲
▼ │
┌──────────────┐ JWKS │
│ temporal- │ ◀───────────────────────┘
│ frontend │ (stock default JWT ClaimMapper)
└──────────────┘
One binary with subcommands serve, login, token, keys, migrate,
version. State is pluggable; the default is SQLite on a PVC via the pure-Go
modernc.org/sqlite driver, with embedded migrations applied by
tempogate migrate.
Wired and exercised end to end:
/healthz,/readyz/.well-known/jwks.jsonand the full/.well-known/openid-configuration- OIDC SSO:
/authorize,/callback/google,/token,/userinfo tempogate login+tempogate tokenfor personal tokens from a laptop (persisted0600, auto-refreshed near expiry)- PKCE mandatory by default, with a narrow secret-gated carve-out for confidential clients such as the Temporal Web UI — see docs/pkce-and-confidential-clients.md
- OIDC Core
nonceround-trip andaudstamping - Multi-arch distroless image and a Helm chart
Authorization is currently flat: every admitted identity receives
cluster-level access (temporal-system:admin), the value Temporal's default
ClaimMapper needs for cluster APIs. Group- or role-derived per-namespace
scoping and an admin API for long-lived integration keys are planned.
DELETE /admin/keys/:id marks a key revoked and adds its jti to a
SQLite-backed denylist. The denylist is consulted by tempogate's own
verifier (refresh-token exchange, /userinfo) through a 30 s read-through
cache, so a revoke takes effect on those flows within 30 s on a single
instance, and is hydrated synchronously in-process for the instance that
served the DELETE.
Temporal's frontend, however, validates JWTs with its default
ClaimMapper against /.well-known/jwks.json only — it has no hook to
consult a per-issuer denylist. The practical consequence:
| Verification path | Time until revoke takes effect |
|---|---|
tempogate (refresh, /userinfo) |
up to 30 s (denylist cache TTL) |
| Temporal frontend gRPC | up to the token's exp lifetime |
This is the acknowledged trade-off of stateless JWTs against Temporal's
stock verifier. Mint integration keys with a deliberately bounded exp if
your threat model needs an upper bound on revoke lag for Temporal gRPC; a
future move to opaque, server-introspected keys would close the gap
end-to-end.
New here? docs/getting-started.md takes you from nothing to a working Web-UI SSO + CLI token in under ten minutes, with no Google account required (a bundled mock IdP stands in).
Tempogate serializes a token's authorization as a flat permissions claim of
"<namespace>:<role>" entries — the exact shape Temporal's default JWT
ClaimMapper parses into claims.Namespaces[ns] = role. Two entries in the
array for different namespaces accumulate independently, so a single token
can grant read on one namespace and admin on another; two entries for the
same namespace last-write-wins, mirroring the ClaimMapper's own behaviour.
The four canonical Temporal roles — read, write, worker, admin —
are honoured verbatim. Cluster-wide access is expressed by granting a role on
the temporal-system namespace (the only construct the default ClaimMapper
recognises for "every namespace"); the literal * is rejected as a real
namespace name by the same authorizer, so the perms.AddWildcard Go-API
sugar is internally rewritten to temporal-system:<role> before serialization.
The model lives in the perms package; both the OIDC
/token flow and the /admin/keys admin API funnel through perms.Grant
so the wire shape stays uniform regardless of which path minted the token.
A token issued for tenant-a cannot perform any action on tenant-b. That
boundary is enforced at the Temporal frontend by the default authorizer — not
inside tempogate — and is the reference behaviour proved end-to-end in
test/e2e/multi_tenant_test.go, which
provisions two namespaces against a real temporal-frontend and asserts both
the cross-namespace denial (gRPC PermissionDenied) and the multi-namespace
accumulation (one token, two namespaces, different roles enforced
independently).
The /admin/keys admin API deliberately mints one (namespace, role) per key
so audit and rotation always reason over a minimal scope; a caller that needs
multi-namespace authorization issues several keys, or — for humans — picks up
the multi-ns shape automatically through the OIDC /token path as the
identity-mapping layer matures.
Run a published image (once a release is cut):
docker run --rm -p 8000:8000 ghcr.io/fenmoai/tempogate:latest
curl http://127.0.0.1:8000/healthzContainer images are published to ghcr.io/fenmoai/tempogate:
| Tag | Meaning |
|---|---|
:vX.Y.Z, :X.Y, :X, :latest |
Stable releases (pushed on git tag vX.Y.Z) |
:vX.Y.Z-rc.N |
Pre-releases (release candidates) |
:sha-<short> |
One-off builds dispatched manually from a specific commit |
Every stable release and release candidate also ships a standalone, lean
tempogate CLI — just login, token, and version, with none of the
server/SQLite/OIDC-issuer stack compiled in — as GitHub Release assets for
linux and darwin on amd64/arm64, alongside a checksums.txt. Stable
releases are published; release candidates are marked pre-release; manual
dispatch builds attach the binaries to the workflow run only.
On macOS, via the Homebrew tap (hosted in this repository):
brew tap fenmoai/tempogate https://github.com/fenmoai/tempogate
brew install tempogateOr download a release asset directly (Linux x86_64 shown — pick your os/arch):
gh release download vX.Y.Z --repo fenmoai/tempogate \
--pattern 'tempogate_*_linux_x86_64.tar.gz' --pattern checksums.txt
sha256sum -c --ignore-missing checksums.txt
tar -xzf tempogate_*_linux_x86_64.tar.gz
./tempogate version --detailedCutting a release? See RELEASING.md.
Build from source:
git clone git@github.com:fenmoai/tempogate.git
cd tempogate
make build
./.bin/tempogate serve # listens on 127.0.0.1:8000Or build the container locally:
docker build -t tempogate:dev .
docker run --rm -p 8000:8000 tempogate:devKubernetes deployment is covered by the chart in
charts/tempogate/. It is published as an
OCI artifact, so no repo clone is needed:
helm install tempogate oci://ghcr.io/fenmoai/charts/tempogate --version 0.1.0The chart is versioned independently of the binary; pick the version from the chart releases.
Once the server is reachable, a user mints a short-lived Temporal JWT without hand-editing any config:
export TEMPOGATE__ISSUER=https://tempogate.example.com
tempogate login # browser sign-in, once
export TEMPORAL_AUTH_TOKEN=$(tempogate token) # thereafter; auto-refreshestempogate login starts a one-shot 127.0.0.1 server, opens your browser to
sign in via Google, prints the token, and persists it to
~/.tempogate/token.json (0600). A fresh ephemeral loopback port is used
each run — no Google Cloud Console edits, just one OIDC__CLIENTS entry on the
server. tempogate token then reuses that file, refreshing the token five
minutes before expiry, so it never re-opens a browser. Both print only the
token to stdout, so they are safe in $(...). See
docs/cli-loopback-login.md for persistence,
auto-refresh, the operator one-liner, and why ephemeral ports work.
For hosts without a browser (remote SSH sessions, cloud dev VMs, CI debug
shells), use tempogate login --device — the OAuth 2.0 device authorization
grant (RFC 8628); the CLI
prints a short code and a URL you open on any other device with a browser.
See docs/cli-device-login.md.
Configuration is layered: defaults, then an optional application.yaml, then
environment variables (env wins). Nested keys flatten with __ as the
separator.
| Env var | Required | Default | Notes |
|---|---|---|---|
OIDC__ISSUER |
For real deploys | http://127.0.0.1:8000 |
Externally reachable base URL; advertised as issuer and used to derive jwks_uri. May include a path (e.g. https://host/idp) — see Sub-path hosting |
OIDC__CLIENTS |
For any login | (empty) | Comma-separated id:redirect_uri_prefix allowlist. Register the CLI as tempogate-cli:http://127.0.0.1: |
OIDC__ALLOWED_DOMAINS |
For any login | (empty) | Comma-separated email-domain gate applied after Google sign-in |
OIDC__GOOGLE__CLIENT_ID |
For any login | (empty) | Upstream Google OAuth client |
OIDC__GOOGLE__CLIENT_SECRET |
For any login | (empty) | Upstream Google OAuth client secret |
OIDC__CLIENT_SECRETS |
No | (empty) | Comma-separated id:secret; promotes a registered client to confidential (PKCE carve-out) |
HTTP__LISTENER |
No | 127.0.0.1:8000 |
host:port for the public listener |
STATE__SQLITE__PATH |
No | /var/lib/tempogate/state.db |
SQLite state-store path (back this with a PVC) |
LOG__LEVEL |
No | info |
debug / info / warn / error |
TEMPOGATE__ISSUER |
No | (empty) | Client-side, read by tempogate login (not the server). Equivalent to --issuer |
OIDC__ISSUER may contain a path, so tempogate can share a hostname with
another app instead of needing its own. Set the issuer to the full external
URL including the path, e.g.:
OIDC__ISSUER=https://tempogate.example.com/idp
tempogate then serves its entire OIDC surface under that prefix —
/idp/.well-known/openid-configuration, /idp/.well-known/jwks.json,
/idp/authorize, /idp/token, /idp/userinfo, /idp/callback/google — and
the discovery document advertises issuer-relative (prefixed) endpoints, so the
iss claim, the advertised endpoints, and the served routes stay in lockstep.
A root issuer (no path) behaves exactly as before.
Route a path prefix on the shared host to tempogate at your reverse proxy (strip nothing — tempogate owns the prefix natively). Two operational notes:
- Health probes stay at the root:
/healthzand/readyzare not moved under the prefix (they are infra probes, not part of the public OIDC surface). Point Kubernetes liveness/readiness at/healthz//readyzregardless of the issuer path; you do not need to route them through the shared-host proxy. - Google authorized redirect URI must match the prefixed callback:
register
https://tempogate.example.com/idp/callback/google(issuer +/callback/google) in the Google OAuth client.
| Approach | Pros | Cons |
|---|---|---|
| tempogate (OIDC + OAuth2 AS, this repo) | Stateless integration with stock Temporal. No fork, no proxy. | A second component to operate. |
| Sidecar reverse-proxy in front of UI/gRPC | Auth logic outside Temporal. | Has to demux gRPC + HTTP; brittle around streaming; obscures Temporal's own auth machinery. |
Forked temporal-server |
Total control. | Permanent rebase tax; loses upstream support; defeats the "self-hosted but supported" posture. |
make tools # install pinned gci + golangci-lint into ./.bin
make check # fmt + vet + gci; fails on a dirty tree
make lint # golangci-lint
make test # check + race + coverage
make ci # what GitHub Actions runs
make test-e2e # container-backed acceptance proofs (needs Docker)make test-e2e stands up temporalio/ui, a JWKS-backed temporal-frontend,
a mock Google IdP, and headless Chrome via testcontainers, and proves two
flows end to end: Web UI SSO login, and the tempogate login CLI loopback
flow. Both assert that the minted JWT authenticates a gRPC ListNamespaces
and that an unauthenticated call is rejected. It is behind a //go:build e2e
tag and a dedicated CI job, so the default make ci stays fast.
Go 1.26+ is required. A dependency (lestrrat-go/jwx/v4) uses
encoding/json/v2, so builds need GOEXPERIMENT=jsonv2; the make targets
export it for you. Set it yourself if you invoke go build/go test
directly. See CONTRIBUTING.md.
- docs/getting-started.md — zero to working Web-UI SSO + CLI token in under ten minutes (bundled mock IdP)
- docs/architecture.md — how the pieces fit and why it isn't a proxy or a fork
- docs/configuration.md — every environment variable, with precedence and sub-path hosting
- docs/cli-loopback-login.md — the
tempogate loginloopback flow, persistence, and auto-refresh - docs/cli-device-login.md — the
tempogate login --deviceflow for hosts without a browser (RFC 8628) - docs/pkce-and-confidential-clients.md — PKCE posture and the confidential-client carve-out
- examples/docker-compose/ — the full stack locally, one command
- examples/kind/ — deploy to a local Kubernetes cluster via the chart
- examples/google-oauth-setup.md — create the upstream Google OAuth client
- charts/tempogate/README.md — Helm deployment
Report vulnerabilities via GitHub Security Advisories — see SECURITY.md. Do not open public issues for security reports.
Apache 2.0 — see LICENSE.