OAuth 2.0 / OAUTHBEARER + XOAUTH2 Integration
yarilo-auth validates OAuth 2.0 bearer tokens for the SASL OAUTHBEARER (RFC 7628) and XOAUTH2 (Google/Microsoft legacy extension) mechanisms and exposes them as a passdb so OAuth logins flow through the same chain machinery as SQL logins (cache, penalty, policy, audit log — all work unchanged).
Both mechanisms share the same token validator. The only difference is the wire format of the client's initial response.
When to enable
A token-based login is useful when:
- the deployment integrates with Google Workspace, Microsoft 365, Keycloak, Authelia or any OIDC-compliant IdP
- mailbox passwords should live entirely outside the mail server (no SQL passdb at all, or SQL-only as fallback)
- per-mailbox app passwords are replaced by IdP-issued tokens
Skip when the threat model is satisfied by a simple SQL passdb and the operator has no OIDC infrastructure.
Configuration
auth. in yarilo. is a list (the Helm names are below). Each entry becomes one passdb in the auth chain, placed ahead of SQL so an OAUTHBEARER login resolves through the validator before SQL ever sees the bearer token as a plaintext password.
Four validation modes:
| Mode | Required fields | Notes |
|---|---|---|
local | oauth2_ and/or oauth2_ | JWT signature verify against operator-provided keys and/or a cached JWKS. No HTTP call per login. RECOMMENDED when the IdP issues signed JWTs (Google, Microsoft, most OIDC providers). |
introspection | oauth2_, usually oauth2_ + oauth2_ | RFC 7662 introspection. Supports opaque (non-JWT) tokens. Slower (one HTTP call per login). |
tokeninfo | oauth2_ | Google-style endpoint (?access_). Pre-OIDC providers. |
discovery | oauth2_ | Auto-resolves jwks_ + introspection_ from <issuer>/. PREFERRED for new deployments — least operator config. |
Common fields (apply across modes)
| Field | Default | Notes |
|---|---|---|
oauth2_ | [] | Allow-list of iss claim values. Empty disables the check. In discovery mode the document's issuer is auto-added for local mode. |
oauth2_ | "" | Required aud claim value. Empty disables. JWT spec allows aud to be a string array — match against any entry passes. |
oauth2_ | [] | Scopes every token MUST carry (intersection check). |
oauth2_ | email | Claim name resolving to the mail user. |
oauth2_ | %{user} | Template applied to the SASL authzid before comparison. Supports %u, %{user}, %Lu, %n, %Ln, %d, %Ld. |
oauth2_ | "" | Optional claim name that must be present. Disables check when empty. |
oauth2_ | "" | When non-empty, the claim's value must equal this string. |
oauth2_ | [] | Claim names whose values are projected onto the auth response as userdb_ fields. |
oauth2_ | 60 | Clock-skew tolerance after the token's exp. |
oauth2_ | 5000 | Round-trip cap for introspection / tokeninfo / discovery / JWKS refresh. |
oauth2_ | false | discovery mode only: when the document advertises both, validate through the introspection endpoint instead of the JWKS. |
oauth2_ | 300 | A token whose kid the cached JWKS lacks refreshes the JWKS at most this often; 0 never refreshes for one (the hourly refresh still runs). Chart: jwks_. |
oauth2_ | "" | PEM file of the CAs this entry's HTTPS calls (JWKS, introspection, tokeninfo, discovery, grant) trust, instead of the system roots. Empty uses the system roots. A missing or certificate-less file stops yarilo-auth at startup. |
oauth2_ | "" | Token endpoint for the password grant. Must be https; needs oauth2_. |
oauth2_ | false | See forced introspection. |
oauth2_ | [] | local mode only: see local keys. |
Password grant
With oauth2_ set, a plaintext login (PLAIN, LOGIN) that reaches the entry has its username and password exchanged at the token endpoint (grant_, plus client_, client_ and scope when set). A token from OAUTHBEARER or XOAUTH2 is never sent there.
- The token endpoint's reply stands when it already carries
oauth2_(andusername_ attribute oauth2_, when set); otherwise the access token it returned is validated by the entry's mode, exactly like a presented token.active_ attribute invalid_is a wrong password: the chain goes on to the next passdb. Any other failure (endpoint down,grant invalid_, a reply withoutclient access_) is a temporary failure.token - The URL must be
https— the request carries the user's password — andoauth2_is required.client_ id
Local keys
In local mode, oauth2_ lists verification keys the operator provides, so the IdP's keys can be pinned or a JWKS endpoint is not needed at all. Each key is picked by the token's azp claim, its alg header and its kid header; a token without azp or kid matches the key whose oauth2_ / oauth2_ is empty (or default).
| Field | Notes |
|---|---|
oauth2_ | HS256/HS384/HS512 (shared secret), RS256/RS384/RS512, PS256/PS384/PS512, ES256/ES384/ES512, EdDSA |
oauth2_ | Client the key belongs to; empty = default |
oauth2_ | Key id; empty = default |
oauth2_ | For HS*: the shared secret, base64 (standard or URL-safe). Otherwise a PEM PUBLIC KEY. |
HS*means a shared secret: whoever holds the file can mint tokens. An HS token is checked only against these files, never against the JWKS.- A token the local keys do not name falls back to
oauth2_, when one is configured.jwks_ url - A key file that does not parse, or a key that cannot sign its
alg(an ES384 entry holding a P-256 key), stopsyarilo-authat startup.
Forced introspection
oauth2_ sends a token to oauth2_ even when the tokeninfo reply or the password grant reply already names the user. It has no effect on a token validated locally, so the config is refused where nothing would reach it: in tokeninfo mode without oauth2_, and in other modes without oauth2_.
In tokeninfo mode, oauth2_ also decides any token whose tokeninfo reply does not name the user.
Introspection sub-modes
When oauth2_ (or discovery with introspection selected), oauth2_ picks the transport:
| Value | Transport |
|---|---|
post (default) | RFC 7662: POST application/ with token=<token> in the body |
auth | GET with Authorization: Bearer <token> header; the token is not in the URL. 2.4.3-dev.20 and earlier sent a POST with an empty body |
get | GET <url>?token=<token> |
Helm
The chart builds auth. from components., where every key is the auth. name without the oauth2_ prefix — with two more changes: oauth2_ is scopes and oauth2_ is extra_. A local_ entry takes alg, azp, kid and either file (a path in the pod) or secret_ (name, key): the chart mounts that Secret key into yarilo-auth and renders its path.
components:
auth:
oauth2:
- mode: discovery
issuer_url: https://accounts.google.com
audience: "1234567890-abcdef.apps.googleusercontent.com"
scopes: [openid, email]
username_validation_format: "%Lu"
extra_fields: [sub, hd]From 2.4.2 a key under its oauth2_ name fails the render and names the chart's; in 2.4.1 it is dropped without a warning.
To keep the introspection client secret out of the config ConfigMap, name a Secret in client_ instead of setting client_:
components:
auth:
oauth2:
- mode: introspection
introspection_url: https://keycloak.example/realms/yarilo/protocol/openid-connect/token/introspect
client_id: yarilo-auth
client_secret_ref:
name: yarilo-oauth2
key: client_secret # the defaultThe chart then renders oauth2_, where <n> is the entry's index in the list, and sets that variable in the yarilo-auth pod from the Secret. Setting both client_ and client_ fails the render.
Wire shapes
OAUTHBEARER (RFC 7628, recommended)
Client sends a GS2-wrapped bearer token in the initial response:
n,[email protected],\x01host=mail.example.com\x01port=993\x01auth=Bearer <token>\x01\x01Fields are separated by \x01 (ASCII SOH). The host= and port= fields are optional; field order is flexible.
The a= authzid is optional too (RFC 7628 §3.1): n,,\x01auth=Bearer <token>\x01\x01 logs in as the user the token names. When a= is sent it must match the token's user after oauth2_.
XOAUTH2 (Google/Microsoft legacy)
Client sends a simpler format — no GS2 header, no host/port:
[email protected]\x01auth=Bearer <token>\x01\x01Fields are \x01-separated. The Bearer prefix on auth= is case-insensitive. XOAUTH2 does not support channel binding.
When to use XOAUTH2: Legacy Outlook configurations, older Android mail apps, and some older Thunderbird OAuth setups that advertise XOAUTH2 instead of OAUTHBEARER. New clients should prefer OAUTHBEARER. Both mechanisms hit the same token validator — there is no difference in security posture once a TLS session is established.
Both mechanisms validate the token per the configured provider and either succeed or return a JSON failure descriptor ({"status":"invalid_).
Mechanism advertisement
yarilo-auth runs both mechanisms and announces them in its handshake only when an OAuth provider is configured (auth. non-empty), since that is what puts a token validator in the chain. The login proxies offer exactly what the service announced:
- IMAP —
AUTH=OAUTHBEARERandAUTH=XOAUTH2inCAPABILITY - POP3 —
CAPAlists them on theSASLline - Submission (SMTP) — the EHLO
AUTHline lists them - ManageSieve — the
SASLcapability lists them;AUTHENTICATE "OAUTHBEARER" "<base64>", challenges as quoted base64. 2.4.3-dev.21 and earlier offered onlyPLAIN LOGINhere - JMAP — a
401listsBearer realm="jmap"next toBasic; a request'sAuthorization: Bearer <token>is run inyarilo-authas OAUTHBEARER for thejmapservice, with no authzid, so the token names the user
A bearer token is a password to whoever reads it, so with auth_ both are held back with PLAIN and LOGIN on a connection that is not TLS, not proxied and not local, and refused there. JMAP applies the same rule to Bearer as to Basic: refused over plain HTTP.
Failure
A refused login follows RFC 7628 §3.2.2: the server answers with a challenge carrying a JSON status, the client acknowledges it with a single 0x01 byte, and only then is the login refused (NO, -ERR, 535).
JMAP answers a refused token with 401 and WWW-Authenticate: Bearer realm="jmap", error="invalid_ (RFC 6750 §3), the same for every refusal (a bad or expired token, nologin, the policy server), so the answer never tells a valid token apart. While yarilo-auth cannot answer, the request gets 503 instead, for Basic as well: the client retries rather than asks for new credentials.
| Mechanism | JSON |
|---|---|
OAUTHBEARER | {"status":"invalid_ — invalid_ for a token that did not validate, invalid_ for a malformed message |
XOAUTH2 | {"status":"401","schemes":"bearer","scope":"…","openid-configuration":"…"} — 401 for a bad token, 400 for a malformed message |
scope is the configured oauth2_ list, or mail when none is set; openid-configuration is <oauth2_ and appears only with an issuer configured.
Worked examples
Google Workspace (discovery mode)
auth:
oauth2:
- oauth2_mode: discovery
oauth2_issuer_url: https://accounts.google.com
oauth2_audience: "1234567890-abcdef.apps.googleusercontent.com"
oauth2_scope: [openid, email]
oauth2_username_attribute: email
oauth2_username_validation_format: "%Lu"
oauth2_fields: [sub, hd]oauth2_auto-resolved from the discovery document.issuers oauth2_is the OAuth client ID from Google Cloud Console.audience oauth2_accepts a mixed-case SASL authzid against a lowercasedusername_ validation_ format: %Lu emailclaim.oauth2_projects the Google "hosted domain" claim asfields: [hd] userdb_so downstream rules (ACL, quota presets) can branch on it.hd
Microsoft 365 (local JWT mode)
auth:
oauth2:
- oauth2_mode: local
oauth2_jwks_url: https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys
oauth2_issuers:
- https://login.microsoftonline.com/<tenant>/v2.0
oauth2_audience: "<azure-app-id>"
oauth2_scope: [openid, email, profile]
oauth2_username_attribute: preferred_username
oauth2_fields: [oid, tid, roles]oauth2_— Microsoft's email is in this claim, notusername_ attribute: preferred_ username email.oauth2_projects Azure AD app roles for downstream policy.fields: [roles]
Keycloak / Authelia (introspection mode)
auth:
oauth2:
- oauth2_mode: introspection
oauth2_introspection_url: https://keycloak.example/realms/yarilo/protocol/openid-connect/token/introspect
oauth2_introspection_mode: post
oauth2_client_id: yarilo-auth
oauth2_client_secret: "${KEYCLOAK_CLIENT_SECRET}"
oauth2_issuers: [https://keycloak.example/realms/yarilo]
oauth2_audience: yarilo-auth
oauth2_username_attribute: email
oauth2_active_attribute: active
oauth2_active_value: "true"
oauth2_fields: [sub, realm_access]- Keycloak's introspection endpoint requires client credentials HTTP Basic auth.
oauth2_expandsclient_ secret ${ENV}, like a passdbdsn, so it can come from a Secret (through the chart:client_). 2.4.1 and earlier take it literally.secret_ ref oauth2_+active_ attribute: active oauth2_enforces the RFC 7662active_ value: "true" activefield explicitly even when client + server versions disagree on the default.
Chained provider order
The OAuth chain runs before SQL. The order within auth. is honoured — list the strictest / cheapest provider first:
auth:
oauth2:
- oauth2_mode: local # try cached JWKS first
oauth2_jwks_url: https://idp.example/jwks.json
- oauth2_mode: introspection # fall through to introspection for opaque tokens
oauth2_introspection_url: https://idp.example/introspect
oauth2_client_id: yarilo
oauth2_client_secret: …
passdb:
- driver: sqlite # SQL fallback for app passwords
dsn: /var/lib/yarilo/users.dbA login that supplies an opaque token falls through local (no matching signature → ResultNext) and lands in introspection. A token from OAUTHBEARER or XOAUTH2 only ever meets the OAuth entries: when none accepts it, the login is refused, and SQL never sees it as a password. A login with a username + plaintext password is tried as a token by both OAuth entries, fails, and reaches SQL. With oauth2_ on an entry, that password is first exchanged at the IdP; invalid_ passes it on to SQL, while an unreachable IdP stops the chain with a temporary failure.
Error mapping
Validator-level errors translate to chain results:
| Validator error | Chain result | Effect |
|---|---|---|
ErrUpstream (network / 5xx, grant failure other than invalid_) | ResultTempFail | Caller maps to temp_; the internal failure delay applies, the penalty counter is not raised |
ErrTokenExpired / ErrTokenInvalid / ErrTokenInactive / grant invalid_ | ResultNext | The next OAuth entry tries; a PLAIN/LOGIN password goes on to SQL |
ErrUsernameMismatch / ErrIssuerMismatch / ErrAudienceMismatch / ErrScopeMissing / ErrInactiveAccount | ResultNext | Same |
A deployment that mixes OAuth and SQL accounts handles both with one chain: tokens are decided by the OAuth entries, passwords by SQL (after an OAuth entry has tried them as a token, or through its password grant). 2.4.3-dev.21 and earlier handed a refused bearer token on to SQL as a password.
A temporary failure reaches the client as one: IMAP NO [UNAVAILABLE], POP3 -ERR [SYS/, submission 454 4., ManageSieve NO (TRYLATER); a refused token or password is [AUTHENTICATIONFAILED], [AUTH], 535, (AUTHENTICATIONFAILED). 2.4.3-dev.21 and earlier answered both the same way.
Privacy notes
localmode never sends the bearer token to a third party. Only the IdP's public JWKS is fetched (no token leaves the auth pod).introspection/tokeninfo/discoverysend the token to the configured endpoint. Use HTTPS endpoints exclusively.- The auth cache (
auth.set) caches validated tokens as HMAC, not plaintext. Setcache. auth_ cache_ size auth.shorter than the token's typicalcache. auth_ cache_ ttl expso revocation propagates within one cache window.
Tuning notes
- For
localmode, the JWKS auto-refreshes every hour and on an unknownkid, at most once peroauth2_. A token whosejwks_ unknown_ kid_ interval_ seconds issis not inoauth2_is refused before the JWKS is asked, so it cannot use that refresh up and delay a key rotation; setissuers oauth2_for that protection.issuers - For
introspection/tokeninfo/discovery, pair withauth.to avoid one HTTP call per IMAP IDLE reconnect.cache oauth2_covers normal clock skew. Set higher (300+) when the IdP and the auth pod are in different data centres with poor NTP discipline.token_ expire_ grace_ seconds: 60
Client smoke-test recipes
Acquire a bearer token from the IdP (Google: oauth2l fetch; Azure: az account get-access-token; Keycloak: kcadm. or direct token endpoint POST). Then:
swaks (Submission)
TOKEN="$(oauth2l fetch --scope='https://mail.google.com/' …)"
swaks --server mail.yarilo.example:587 \
--tls --tls-verify \
--auth OAUTHBEARER --auth-user [email protected] \
--auth-password "$TOKEN" \
--from [email protected] --to [email protected]openssl s_client + raw SASL (IMAP / POP3)
TOKEN="…"
PAYLOAD=$(printf 'n,[email protected],\1auth=Bearer %s\1\1' "$TOKEN" | base64 -w0)
# IMAP
openssl s_client -quiet -connect mail.yarilo.example:993 <<EOF
a1 AUTHENTICATE OAUTHBEARER $PAYLOAD
a2 SELECT INBOX
a3 LOGOUT
EOF
# POP3
openssl s_client -quiet -connect mail.yarilo.example:995 <<EOF
AUTH OAUTHBEARER $PAYLOAD
STAT
QUIT
EOFThunderbird
Settings → Account → Authentication method → OAuth2. On the first connection Thunderbird opens the IdP's consent page; subsequent reconnects refresh the access token automatically.