OAuth / OIDC
Tellstone plugs federated identity into its AUTH step. Instead of — or in
addition to — a shared --require-pass password or per-user bcrypt hashes, a
client can present an OpenID Connect (OIDC) id_token (a signed JWT) as
its credential. The token’s claims are mapped to an RBAC role through the
policy file’s oauth.rules, and that role is pinned to the connection exactly
like a password-authenticated session.
OAuth is off by default and adds no overhead when disabled: with no
--oauth-* flag configured the provider is nil, no code path changes, and
AUTH behaves exactly as before.
OAuth requires RBAC: a token can only map to a role
through the policy’s oauth.rules, so enabling it without a policy would
silently deny every connection.
How a presented credential is routed
Section titled “How a presented credential is routed”Both the RESP and binary listeners run the same dispatch on every AUTH:
- A credential that looks like a JWT (two dots, at least 5 bytes) is treated as a bearer token and routed to the token path; anything else goes through the normal password path.
- The token is verified off the event loop on the auth-worker pool: signature, issuer, audience, and expiry.
- The verified claims are mapped to a role via
oauth.rules— first match wins. - The role is pinned to the connection and subsequent commands are gated by the same permissions bitset as every other session.
The JWT shape check is deliberately shallow — a well-formed but forged token still fails verification. It exists purely to keep the password path fast, so a real password never needs a signature check.
If the auth-worker pool is saturated, the connection fails AUTH
synchronously instead of stalling.
Enabling
Section titled “Enabling”./bin/tellstone \ --rbac-config policy.yaml \ --oauth-provider google \ --oauth-client-id 1234-abc.apps.googleusercontent.com| Flag | Env var | Meaning |
|---|---|---|
--oauth-provider | TSD_OAUTH_PROVIDER | Preset name: google, stackit; empty + --oauth-issuer → generic OIDC |
--oauth-issuer | TSD_OAUTH_ISSUER | OIDC discovery base URL of the identity provider |
--oauth-client-id | TSD_OAUTH_CLIENT_ID | OAuth2 client ID used as the expected token audience |
Startup rules:
- No
--oauth-*flag → provider isniland password-onlyAUTHstays untouched. --oauth-provider google/stackit→ the preset constructor, which runs discovery eagerly.--oauth-providerempty +--oauth-issuerset → generic OIDC.- Unknown provider name → startup error.
- Provider set without
--rbac-config→ startup error (see above). --oauth-client-idempty → startup error: an empty audience would skipaudvalidation.
There is no --oauth-client-secret by design. Verification is
signature + issuer + audience based against the provider’s public JWKS, so no
client secret is needed.
Providers
Section titled “Providers”| Name | Selected by | What it does |
|---|---|---|
| (generic) | --oauth-issuer set, --oauth-provider empty | Any OIDC issuer. Runs discovery and caches the JWKS. |
google | --oauth-provider google | Default issuer https://accounts.google.com; maps the hd (hosted-domain) claim into groups so a claim: groups rule works with one line. |
stackit | --oauth-provider stackit | STACKIT IAM preset with default issuer https://accounts.stackit.cloud. |
--oauth-issuer is optional for the presets (they supply a default) and
required for the generic provider.
Token verification
Section titled “Token verification”The generic OIDC provider — the engine behind every preset — enforces:
- Algorithm allowlist — only
RS256andES256. TheHS*family is rejected outright, preventing algorithm-confusion attacks. kidrequired — the signing key is looked up unambiguously; a token without one is rejected rather than tried against every key.- Key rotation without restarts — a missed
kidtriggers exactly one JWKS refresh before giving up. - Temporal checks —
expandnbfare enforced when present. - Issuer and audience —
issis compared against the configured issuer andaudagainst the client ID;audmay be a single string or a list (both are valid OIDC forms). - Fail-fast discovery — discovery and the initial JWKS fetch run at
startup, so a wrong issuer or unreachable identity provider is reported
before the first
AUTH.
Mapping claims to roles
Section titled “Mapping claims to roles”The RBAC policy file carries an oauth section. Each rule names a claim, a
match pattern, and a target role; rules apply in file order and the first
match wins:
roles: - name: admin rules: ["+@all", "~*"] - name: limited rules: ["+get", "~*"]oauth: rules: - claim: email match: "*@tellstone.io" # trailing glob: any local-part at the domain role: admin - claim: groups match: "admins" # exact value match role: admin - claim: sub match: "*" # bare "*" matches any value role: limitedMatch patterns allow an exact value, a leading * glob (suffix
match), or a trailing * glob (prefix match). A middle wildcard is
rejected at policy load, so a typo surfaces before it silently denies access.
Rules are compiled at load time — an unknown target role fails the load.
At AUTH time:
- The token’s claims are verified by the provider.
oauth.rulesare scanned in order; the first claim/value match yields the role.- The connection’s identity (username / audit subject) is the token’s
subclaim, falling back to"default". - No match, or verification failure, means no role — and a session with
no role is deny-all (fail-closed). The client receives the same
ERR invalid passwordas a bad password, so nothing reveals that the IdP path exists.
Authenticating
Section titled “Authenticating”AUTH with a single token argument routes to the token path:
> AUTH eyJhbGciOiJSUzI1NiIs...+OKThe two-argument form is required: AUTH <username> <password> always treats
the second argument as a password, never a token.
Native binary protocol
Section titled “Native binary protocol”client.Auth sends the token in single-password mode (empty username):
c, err := client.Dial("127.0.0.1:9988", 2*time.Second)if err != nil { log.Fatal(err)}defer c.Close()
scratch := make([]byte, 64*1024)if err := c.Auth("<id_token>", scratch); err != nil { log.Fatal(err)}// connection is now authenticated — issue Get/Set/Delete as normalReal id_tokens routinely exceed 1 KB, so the client falls back to a heap
buffer when the credential overflows its 512-byte stack buffer.
Per-connection behavior
Section titled “Per-connection behavior”A successful token AUTH is indistinguishable from a password AUTH
downstream: the resolved role is pinned to the connection for its lifetime,
subsequent commands are gated by the same permissions as every other session,
and acl_deny / auth_* audit events carry the token’s sub as the user. A
policy hot-reload (SIGHUP) never re-evaluates a pinned session.
Failure accounting is identical to the password path: failed attempts count
against the per-connection limit (the listener closes the connection after the
maximum), are recorded in the audit trail, and are surfaced by ACL LOG.
Example
Section titled “Example”A ready-to-run policy and a walkthrough client ship in the main repository at
cmd/example/oauth — start a server with --rbac-config cmd/example/oauth/policy.yaml --oauth-provider google --oauth-client-id <id>, then run the example with a
token to watch it present the id_token and prove the pinned role.