Keycloak Outbound Email + GitHub Social Login Design
Date: 2026-07-02
Bead: hl-d0md
Purpose: Add SMTP-based outbound email to the Keycloak realm (tf/keycloak) and
add GitHub as a social identity provider, reusing the pattern already proven for
Authentik’s outbound email.
Background — what already exists
Section titled “Background — what already exists”Keycloak’s Terraform module (tf/keycloak) manages the fzymgc realm plus one
keycloak_openid_client/groups set per downstream app (Vault, ArgoCD, Kubernetes).
Today it has:
- No
smtp_serverblock onkeycloak_realm.fzymgc(tf/keycloak/main.tf) — the realm cannot send any email (password reset, email verification, admin “execute actions” emails). - No identity provider resources at all — no GitHub, no other social login.
registration_allowed = false— self-registration is closed; accounts are provisioned by an operator/Terraform.reset_password_allowedunset (Keycloak defaults this off) — no self-service “Forgot password” link on the login page.
This is not a from-scratch problem. Authentik already solved outbound email for
this cluster (docs/engineering/specs/2026-06-26-authentik-outbound-email-design.md):
- A shared Mailgun SMTP transport lives at Vault
secret/fzymgc-house/cluster/smtp(host,port,username,password,use_tls), relayed through a Postfix satellite host (ansible/roles/tp2-bootstrap-node/tasks/mail.yml,[smtp.mailgun.org]:587). - Each app’s own secret carries a per-app
email_fromfield (e.g.cluster/authentik.email_from). tf/keycloak/vault.tfalready readsdata.vault_kv_secret_v2.keycloakfromsecret/fzymgc-house/cluster/keycloak— that path is read-only for Terraform (tf/vault/policy-keycloak.tf); it is operator-seeded, not Terraform-written.
Provider capability (keycloak/keycloak ~> 5.0, tf/keycloak/versions.tf),
confirmed against the Terraform Registry docs:
keycloak_realmsupports ansmtp_serverblock:host(required),from(required),port,starttls/ssl, and anauth { username, password }sub-block.- A dedicated
keycloak_oidc_github_identity_providerresource exists (not a hand-configured generic OIDC IdP):realm,client_id,client_secretrequired;trust_email,sync_mode,link_only,default_scopes(defaults touser:email) optional. IdP redirect/callback URI convention:{realm_issuer}/broker/{alias}/endpoint→https://id.fzymgc.house/realms/fzymgc/broker/github/endpoint.
- The
fzymgcrealm can send email: self-service password reset, and admin “execute actions” emails (e.g. update-password/verify-email links for Terraform-created users). - Users can log in to the realm via GitHub, including auto-creating a new Keycloak account for a GitHub identity that has none yet (explicit user decision — see Non-goals/Risks).
- Zero new mail infrastructure — reuse the existing shared Mailgun transport.
Non-goals
Section titled “Non-goals”- Any other social IdP (Google, Facebook, etc.) — explicitly descoped to just email + GitHub.
- Changing Authentik’s email config, the shared Mailgun account, or the Postfix relay.
- A custom first-broker-login review/approval flow gating GitHub sign-in — default Keycloak behavior is used as-is (see Risks).
- Custom email templates/themes — the built-in
keycloakemail theme is used.
Architecture
Section titled “Architecture”Two independent additions to tf/keycloak, plus one cross-workspace Vault policy
grant (tf/vault, a separate HCP Terraform workspace/PR).
Component 1 — SMTP on the realm (new tf/keycloak/smtp.tf)
Section titled “Component 1 — SMTP on the realm (new tf/keycloak/smtp.tf)”data "vault_kv_secret_v2" "smtp" { mount = "secret" name = "fzymgc-house/cluster/smtp"}keycloak_realm.fzymgc (main.tf) gains:
reset_password_allowed = true
smtp_server { host = data.vault_kv_secret_v2.smtp.data["host"] port = data.vault_kv_secret_v2.smtp.data["port"] from = data.vault_kv_secret_v2.keycloak.data["email_from"]
starttls = tobool(data.vault_kv_secret_v2.smtp.data["use_tls"])
auth { username = data.vault_kv_secret_v2.smtp.data["username"] password = data.vault_kv_secret_v2.smtp.data["password"] }}email_from is a new field on the existing secret/fzymgc-house/cluster/keycloak
secret (already read via data.vault_kv_secret_v2.keycloak in vault.tf — no new
Vault grant needed for this field, since the whole path is already read-granted).
Cross-workspace dependency: tf/keycloak’s HCP workspace does not currently
have read access to cluster/smtp (only tf/authentik’s does, via env injection
into the pod, which is a different access path entirely — not a Terraform data
source read). tf/vault/policy-keycloak.tf needs a new grant:
path "secret/data/fzymgc-house/cluster/smtp" { capabilities = ["read"]}path "secret/metadata/fzymgc-house/cluster/smtp" { capabilities = ["read"]}Because tf/vault and tf/keycloak are separate merge-triggered workspaces, the
Vault policy PR must be merged and applied before the tf/keycloak PR that
reads cluster/smtp — otherwise that plan/apply 403s.
Component 2 — GitHub identity provider (new tf/keycloak/github_idp.tf)
Section titled “Component 2 — GitHub identity provider (new tf/keycloak/github_idp.tf)”resource "keycloak_oidc_github_identity_provider" "github" { realm = keycloak_realm.fzymgc.id alias = "github" # pins the /broker/github/endpoint callback URL explicitly
client_id = data.vault_kv_secret_v2.keycloak.data["github_client_id"] client_secret = data.vault_kv_secret_v2.keycloak.data["github_client_secret"]
trust_email = true sync_mode = "IMPORT" link_only = false}link_only = false is an explicit, confirmed user decision: a GitHub login for an
identity with no matching Keycloak account will auto-create one via Keycloak’s
default first-broker-login flow (see Risks for the blast radius this implies).
client_id/client_secret are two new fields on the existing
secret/fzymgc-house/cluster/keycloak secret — no new Vault policy grant needed
(that path is already read-granted to terraform-keycloak-admin).
Manual, out-of-band step (cannot be automated): GitHub’s classic OAuth Apps
have no creation API — a human must create one at
github.com/settings/developers:
- Homepage URL:
https://id.fzymgc.house/ - Authorization callback URL:
https://id.fzymgc.house/realms/fzymgc/broker/github/endpoint
The resulting client ID/secret are then seeded by hand into the two new Vault fields.
Data flow & secrets summary
Section titled “Data flow & secrets summary”| Secret path | Change |
|---|---|
secret/fzymgc-house/cluster/keycloak (existing, operator-seeded, Terraform-read-only) | + email_from, + github_client_id, + github_client_secret (3 new fields, manually seeded) |
secret/fzymgc-house/cluster/smtp (existing, unchanged) | tf/keycloak workspace gains read access via a new tf/vault policy grant |
No new secret paths, no ExternalSecret/ArgoCD changes. Keycloak’s Terraform provider talks to the Keycloak Admin REST API directly — realm SMTP and IdP config are not env-injected into the pod the way Authentik’s are.
Testing & verification
Section titled “Testing & verification”terraform fmt -check+terraform validatein bothtf/vaultandtf/keycloak.- HCP Terraform speculative plan on each PR is authoritative for what changes apply.
- Pre-flight (manual, before the
tf/keycloakPR merges): the GitHub OAuth App exists and its callback URL is exactly right; all three new Vault fields are seeded; thetf/vaultpolicy PR has already applied. - Post-apply functional checks:
- Login page → “Forgot password” → triggers an email from the configured
email_fromaddress. - Login page shows a “GitHub” button; authenticating with a GitHub identity
that has no matching Keycloak user auto-creates one (
link_only = false). - An existing Keycloak user can additionally link their GitHub identity via Account Console → Linked Accounts.
tf/keycloakplan/apply does not 403 readingcluster/smtp(confirms the policy grant took effect).
- Login page → “Forgot password” → triggers an email from the configured
Risks & mitigations
Section titled “Risks & mitigations”| Risk | Mitigation |
|---|---|
GitHub login can auto-create a new realm account for any GitHub identity that authenticates (link_only = false), which is a materially bigger attack surface than the currently-closed registration_allowed = false realm | Accepted risk per explicit user decision. Revisit later with a custom first-broker-login flow or an allow-list expression policy if unwanted accounts appear. |
tf/keycloak plan applies before the tf/vault policy grant lands → 403 reading cluster/smtp | Merge and confirm-apply the tf/vault PR first; only then merge tf/keycloak. |
Manual Vault fields (email_from, github_client_id, github_client_secret) not seeded before merge | Empty client_id/client_secret will fail plan/apply against the Keycloak Admin API (dedicated resource requires both); empty email_from produces a non-functional smtp_server.from. Verify via the speculative plan diff before applying, not just after. |
| GitHub OAuth App is a manual, out-of-band artifact, not code | Document the callback URL and the 3 required Vault fields (this spec + a short docs update) so the step is repeatable if the OAuth App is ever recreated. |
Rollout / phasing
Section titled “Rollout / phasing”Two PRs, since the work spans two separate HCP Terraform workspaces:
- PR-A (
tf/vault): add thecluster/smtpread grant toterraform-keycloak-admin. Merge and confirm-applied first. - PR-B (
tf/keycloak):smtp.tf,github_idp.tf,reset_password_allowed = trueon the realm. Merge only after PR-A has applied.
Sean’s manual steps, done before or alongside PR-B: create the GitHub OAuth App;
seed the 3 new Vault fields on cluster/keycloak.
Files changed
Section titled “Files changed”| File | Change |
|---|---|
tf/vault/policy-keycloak.tf | add read grant for secret/data + secret/metadata at fzymgc-house/cluster/smtp |
tf/keycloak/smtp.tf | new — cluster/smtp data source + realm smtp_server block |
tf/keycloak/main.tf | add reset_password_allowed = true |
tf/keycloak/github_idp.tf | new — keycloak_oidc_github_identity_provider resource |
docs/reference/secrets.md | document the 3 new cluster/keycloak fields |
References
Section titled “References”tf/authentik/notifications.tf,docs/engineering/specs/2026-06-26-authentik-outbound-email-design.md(email precedent this design mirrors)tf/keycloak/vault.tf,tf/keycloak/main.tf,tf/vault/policy-keycloak.tf(current Keycloak/Vault state)- Terraform Registry:
keycloak_realm(smtp_serverblock),keycloak_oidc_github_identity_provider