Skip to content

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.

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_server block on keycloak_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_allowed unset (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_from field (e.g. cluster/authentik.email_from).
  • tf/keycloak/vault.tf already reads data.vault_kv_secret_v2.keycloak from secret/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_realm supports an smtp_server block: host (required), from (required), port, starttls/ssl, and an auth { username, password } sub-block.
  • A dedicated keycloak_oidc_github_identity_provider resource exists (not a hand-configured generic OIDC IdP): realm, client_id, client_secret required; trust_email, sync_mode, link_only, default_scopes (defaults to user:email) optional. IdP redirect/callback URI convention: {realm_issuer}/broker/{alias}/endpointhttps://id.fzymgc.house/realms/fzymgc/broker/github/endpoint.
  • The fzymgc realm 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.
  • 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 keycloak email theme is used.

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.

Secret pathChange
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.

  1. terraform fmt -check + terraform validate in both tf/vault and tf/keycloak.
  2. HCP Terraform speculative plan on each PR is authoritative for what changes apply.
  3. Pre-flight (manual, before the tf/keycloak PR merges): the GitHub OAuth App exists and its callback URL is exactly right; all three new Vault fields are seeded; the tf/vault policy PR has already applied.
  4. Post-apply functional checks:
    • Login page → “Forgot password” → triggers an email from the configured email_from address.
    • 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/keycloak plan/apply does not 403 reading cluster/smtp (confirms the policy grant took effect).
RiskMitigation
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 realmAccepted 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/smtpMerge 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 mergeEmpty 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 codeDocument 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.

Two PRs, since the work spans two separate HCP Terraform workspaces:

  • PR-A (tf/vault): add the cluster/smtp read grant to terraform-keycloak-admin. Merge and confirm-applied first.
  • PR-B (tf/keycloak): smtp.tf, github_idp.tf, reset_password_allowed = true on 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.

FileChange
tf/vault/policy-keycloak.tfadd read grant for secret/data + secret/metadata at fzymgc-house/cluster/smtp
tf/keycloak/smtp.tfnewcluster/smtp data source + realm smtp_server block
tf/keycloak/main.tfadd reset_password_allowed = true
tf/keycloak/github_idp.tfnewkeycloak_oidc_github_identity_provider resource
docs/reference/secrets.mddocument the 3 new cluster/keycloak fields
  • 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_server block), keycloak_oidc_github_identity_provider