feat(supabase): add Google/Apple OAuth and fueltide.io-branded email flows

Enables the auth providers and transactional email flows the self-hosted
Supabase was missing compared to the cloud instance:

- GoTrue now accepts Google and Apple OAuth (web flow); Apple client-secret
  JWT is signed fresh on every activation from the SOPS-stored .p8 so
  there's no 6-month rotation ritual.
- SMTP points at mail.cloonar.com:587 with SASL auth via a new `supabase`
  LDAP account; a `noreply@fueltide.io` mailAlias lets that account send
  as the fueltide.io address.
- rspamd on mail.cloonar.com gets a per-domain DKIM key for fueltide.io
  (selector `default`) so outbound mail is signed.
- MAILER_AUTOCONFIRM is off so signup confirmation + password reset
  actually go through email.
- SITE_URL + URI_ALLOW_LIST point at app.fueltide.io / stage so links in
  emails and OAuth redirects land in the right app.

FUELTIDE_AUTH_SETUP.md documents the manual steps (LDAP entries, SOPS
additions, DNS records, Google/Apple console setup) that must be completed
before merging.
This commit is contained in:
Dominik Polakovics Polakovics 2026-04-22 22:08:29 +02:00
parent 1f5e5b9a37
commit 67e81d39f3
5 changed files with 366 additions and 12 deletions

View file

@ -10,6 +10,7 @@
./modules/openldap.nix
./modules/dovecot.nix
./modules/postfix.nix
./modules/dkim-fueltide.nix
./utils/modules/borgbackup.nix
./utils/modules/promtail

View file

@ -0,0 +1,28 @@
{ config, pkgs, ... }:
{
sops.secrets.rspamd-dkim-fueltide-io-key = {
owner = "rspamd";
group = "rspamd";
mode = "0400";
};
# rspamd's dkim_signing module in rspamd.nix picks up per-domain keys from
# /var/lib/rspamd/dkim/$domain.$selector.key. This one-shot drops the
# fueltide.io key into place before rspamd starts.
systemd.services.rspamd-dkim-fueltide-setup = {
description = "Install fueltide.io DKIM key into rspamd";
wantedBy = [ "multi-user.target" ];
before = [ "rspamd.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
install -d -o rspamd -g rspamd -m 0750 /var/lib/rspamd/dkim
install -o rspamd -g rspamd -m 0400 \
${config.sops.secrets.rspamd-dkim-fueltide-io-key.path} \
/var/lib/rspamd/dkim/fueltide.io.default.key
'';
};
}

View file

@ -0,0 +1,246 @@
# Supabase auth setup: Google + Apple OAuth, fueltide.io email
This doc lists the **user-side steps** required to make the code changes in
this branch functional. Nothing here is performed by Nix — these are manual
actions on external services, LDAP, SOPS, and DNS.
The Nix changes in this branch cover:
- `hosts/web-arm/modules/supabase/default.nix` — GoTrue env for Google + Apple
OAuth, SMTP pointed at `mail.cloonar.com:587`, `MAILER_AUTOCONFIRM=false`,
`SITE_URL` + `URI_ALLOW_LIST` for fueltide.io, python+cryptography in the
env-generate path (for Apple JWT signing).
- `hosts/web-arm/modules/supabase/env-generate.sh` — new `auth.env` block that
pulls SMTP + OAuth creds from SOPS and signs the Apple client-secret JWT
fresh on every activation.
- `hosts/mail/modules/dkim-fueltide.nix` — installs a per-domain DKIM key for
fueltide.io into rspamd so outbound mail from `noreply@fueltide.io` is
signed.
Complete the seven steps below **before** merging to master. Merging without
them will deploy a broken GoTrue (missing OAuth/SMTP creds → auth emails fail,
OAuth flows 500).
---
## 1. LDAP service account + fueltide alias on `mail.cloonar.com`
Mirrors the `gitea@cloonar.com` / `authelia@cloonar.com` pattern. The alias
on `noreply@fueltide.io` is what `smtpd_sender_login_maps` uses to let the
`supabase` SASL user send as that address without tripping
`reject_authenticated_sender_login_mismatch`.
```bash
# on mail.cloonar.com
SMTP_PASS=$(openssl rand -base64 30 | tr -d '/+=' | head -c 32)
echo "SMTP_PASS (store this in SOPS, step 3): $SMTP_PASS"
CRYPT=$(mkpasswd -m sha-512 "$SMTP_PASS")
cat > /tmp/supabase.ldif <<EOF
dn: uid=supabase,ou=users,dc=cloonar,dc=com
objectClass: mailAccount
objectClass: inetOrgPerson
uid: supabase
cn: Supabase Auth
sn: Auth
mail: supabase@cloonar.com
mailSendOnly: TRUE
userPassword: {CRYPT}$CRYPT
description: SASL account for Supabase GoTrue outbound mail
dn: mail=noreply@fueltide.io,ou=aliases,dc=cloonar,dc=com
objectClass: mailAlias
mail: noreply@fueltide.io
maildrop: supabase@cloonar.com
EOF
ldapadd -x -D "cn=admin,dc=cloonar,dc=com" -W -f /tmp/supabase.ldif
rm /tmp/supabase.ldif
```
## 2. Generate the fueltide.io DKIM key
Selector is `default` to match the glob that rspamd's `dkim_signing` block
(`hosts/mail/modules/rspamd.nix:15-19`) watches for.
```bash
mkdir -p /tmp/dkim-gen && cd /tmp/dkim-gen
nix-shell -p rspamd --run \
"rspamadm dkim_keygen -s default -d fueltide.io -k fueltide.io.default.key"
# private key: fueltide.io.default.key -> goes into SOPS (step 3)
# public key: printed to stdout -> goes into DNS (step 4)
```
Wipe the temp dir once both are copied out.
## 3. SOPS edits (two files)
### `hosts/mail/secrets.yaml`
```bash
nix-shell -p sops --run 'sops hosts/mail/secrets.yaml'
```
Add:
```yaml
rspamd-dkim-fueltide-io-key: |
-----BEGIN PRIVATE KEY-----
<paste contents of /tmp/dkim-gen/fueltide.io.default.key>
-----END PRIVATE KEY-----
```
### `hosts/web-arm/secrets.yaml`
```bash
nix-shell -p sops --run 'sops hosts/web-arm/secrets.yaml'
```
Inside the existing `supabase-env` multiline value, append eight new lines
(these are sourced as shell variables by `env-generate.sh`):
```
SMTP_USER=supabase@cloonar.com
SMTP_PASS=<plaintext from step 1>
GOOGLE_CLIENT_ID=<from step 5>
GOOGLE_SECRET=<from step 5>
APPLE_TEAM_ID=XWJ4DC7TBH
APPLE_KEY_ID=<from step 6>
APPLE_SERVICES_ID=com.cloonar.supabase.fueltide
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n<.p8 body>\n-----END PRIVATE KEY-----
```
Note on `APPLE_PRIVATE_KEY`: it must be **one line** with literal backslash-n
separating the PEM lines (no real newlines inside the value). The python
signer in `env-generate.sh` un-escapes those via `decode("unicode_escape")`
before loading the PEM. To format an existing `AuthKey_XXX.p8` as that single
line:
```bash
awk '{printf "%s\\n", $0}' AuthKey_XXXXXXXXXX.p8
```
## 4. DNS records for `fueltide.io`
Add on whichever DNS provider hosts fueltide.io:
```
TXT @ v=spf1 mx a:mail.cloonar.com ~all
TXT default._domainkey v=DKIM1; k=rsa; p=<public key from step 2>
TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:postmaster@cloonar.com; fo=1
```
PTR for mail.cloonar.com is already set (it's been sending for cloonar.com).
If fueltide.io has no MX record, outbound is fine but bounces from remote MTAs
won't route — acceptable for one-way transactional mail. Add an MX pointing at
`mail.cloonar.com.` if you want bounces to be received.
## 5. Google Cloud OAuth client (≈ 5 min)
1. console.cloud.google.com → **APIs & Services → OAuth consent screen**.
External user type. App name `Fueltide`, user support email, developer
contact. Scopes: `openid`, `email`, `profile`. Submit (or keep in testing
if only internal users).
2. **Credentials → Create Credentials → OAuth client ID → Web application**.
Name `Supabase`. Authorised redirect URI:
`https://supabase.cloonar.com/auth/v1/callback`.
3. Copy Client ID + Client Secret → into SOPS as `GOOGLE_CLIENT_ID` and
`GOOGLE_SECRET`.
## 6. Apple Developer Sign in with Apple (≈ 15 min, paid account required)
1. developer.apple.com → **Certificates, IDs & Profiles → Identifiers → +
→ Services IDs**. Description `Fueltide Supabase Auth`. Identifier
`com.cloonar.supabase.fueltide`. Check **Sign in with Apple → Configure**.
2. Primary App ID: existing `io.fueltide.workout` (Team `XWJ4DC7TBH`, see
`hosts/web-arm/sites/fueltide.io.nix`). Domains and Subdomains:
`supabase.cloonar.com`. Return URLs:
`https://supabase.cloonar.com/auth/v1/callback`. Save.
3. **Keys → +** → name `Fueltide Supabase Auth` → check **Sign in with Apple
→ Configure** → primary App ID `io.fueltide.workout`. Register.
4. **Download the `.p8` file now** — Apple only offers it once.
5. Note the Key ID (10 chars) displayed on the key page.
6. Team ID is `XWJ4DC7TBH` (already known).
7. Into SOPS on web-arm:
- `APPLE_TEAM_ID=XWJ4DC7TBH`
- `APPLE_KEY_ID=<from step 5>`
- `APPLE_SERVICES_ID=com.cloonar.supabase.fueltide`
- `APPLE_PRIVATE_KEY=<single-line .p8 as described in step 3>`
### iOS native flow (optional)
If the fueltide iOS app will use `supabase.auth.signInWithIdToken({ provider:
'apple', token: identityToken })` (native `AuthenticationServices` SDK, no web
browser), the iOS bundle ID must also appear in `GOTRUE_EXTERNAL_APPLE_CLIENT_ID`.
Change the line in `env-generate.sh` that currently reads:
```sh
GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${APPLE_SERVICES_ID:-}
```
to something like:
```sh
GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${APPLE_SERVICES_ID:-},io.fueltide.workout
```
(GoTrue accepts a comma-separated audiences list here and validates incoming
id_tokens against any of them.)
## 7. Merge and deploy
Once steps 16 are done:
```bash
./scripts/test-configuration web-arm
./scripts/test-configuration mail
git checkout master
git merge --no-ff <this-branch>
git push
```
Bento rolls out both hosts. On `web-arm.cloonar.com`:
```bash
sudo systemctl restart supabase-env-generate
sudo cat /run/supabase/auth.env # expect 8 new vars populated
sudo podman exec supabase-auth nc -vz mail.cloonar.com 587
sudo podman restart supabase-auth
```
### Verification checklist
- [ ] `/run/supabase/auth.env` contains `GOTRUE_EXTERNAL_APPLE_SECRET=<long-JWT>`.
- [ ] Second `systemctl restart supabase-env-generate` produces a different
Apple JWT (freshness — signed with new `iat`).
- [ ] `curl -X POST -H 'apikey: <anon>' -H 'Content-Type: application/json' \
https://supabase.cloonar.com/auth/v1/signup \
-d '{"email":"<real inbox>","password":"correct horse battery staple"}'`
delivers a mail with `From: noreply@fueltide.io` within ~30 s.
- [ ] Mail headers show `dkim=pass`, `spf=pass`, `dmarc=pass`
(`Authentication-Results` header).
- [ ] `POST /auth/v1/recover` triggers a reset mail.
- [ ] Browser visit to
`https://supabase.cloonar.com/auth/v1/authorize?provider=google`
completes and lands on `/auth/v1/callback`. Row in `auth.identities`
with `provider='google'`.
- [ ] Same with `?provider=apple` from a page Apple's Return URL accepts.
- [ ] Send a signup to [mail-tester.com](https://www.mail-tester.com/) — target
≥ 9/10 spam score.
## Rotation notes
- **Apple client-secret JWT**: auto-regenerated on every activation
(`supabase-env-generate.service`). No manual rotation.
- **Apple `.p8` key**: no expiry, but revoking it in the Apple console
immediately breaks auth. If ever rotated, update `APPLE_KEY_ID` and
`APPLE_PRIVATE_KEY` in SOPS together.
- **Google client secret**: no expiry; rotate via Google Cloud console if
leaked and update `GOOGLE_SECRET` in SOPS.
- **DKIM key**: no expiry, but best practice is to rotate yearly. Rotation
= regenerate keypair (step 2), replace the SOPS value (step 3), update DNS
(step 4), deploy. Keep both old+new DNS records live for 24h during
cutover.
- **SMTP LDAP password**: no expiry. To rotate, run `mkpasswd` again and
update both the LDAP userPassword attribute and SOPS `SMTP_PASS`.

View file

@ -19,11 +19,14 @@ in
sops.secrets.supabase-env = { };
# --- Persistent data directories ---
# Postgres data lives in a named podman volume (supabase-db-data) so podman
# owns the permissions on the container's postgres UID; logical dumps go to
# /var/backups/supabase where borg picks them up from /var.
systemd.tmpfiles.rules = [
"d /var/lib/supabase/db/data 0700 root root -"
"d /var/lib/supabase/storage 0755 root root -"
"d /var/lib/supabase/functions 0755 root root -"
"d /var/lib/supabase/snippets 0755 root root -"
"d /var/backups/supabase 0700 root root -"
];
@ -67,7 +70,12 @@ in
supabase-env-generate = {
description = "Generate Supabase per-container env files from SOPS secrets";
wantedBy = [ "multi-user.target" ];
path = [ pkgs.jq ];
# python+cryptography is used to sign the Apple OAuth client-secret JWT
# (ES256) inside env-generate.sh.
path = [
pkgs.jq
(pkgs.python3.withPackages (ps: [ ps.cryptography ]))
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
@ -95,9 +103,38 @@ in
after = [ "supabase-functions-seed.service" ];
requires = [ "supabase-functions-seed.service" ];
};
# Logical daily dump of the containerised Postgres cluster. Writes to
# /var/backups/supabase which is covered by the borg path /var;
# /var/lib/containers (the named-volume storage) is excluded from borg,
# so the dump is the only copy borg ships off-host.
supabase-db-backup = {
description = "pg_dumpall of the Supabase Postgres cluster";
after = [ "podman-supabase-db.service" ];
requires = [ "podman-supabase-db.service" ];
serviceConfig = {
Type = "oneshot";
};
script = ''
set -euo pipefail
tmp=/var/backups/supabase/supabase-all.sql.tmp
out=/var/backups/supabase/supabase-all.sql
${pkgs.podman}/bin/podman exec -u postgres supabase-db \
pg_dumpall -U postgres --clean --if-exists > "$tmp"
mv "$tmp" "$out"
'';
};
}
]);
systemd.timers.supabase-db-backup = {
description = "Daily Supabase Postgres dump";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 02:30:00";
Persistent = true;
};
};
# --- Containers ---
virtualisation.oci-containers.containers = {
@ -114,7 +151,7 @@ in
};
environmentFiles = [ "/run/supabase/db.env" ];
volumes = [
"/var/lib/supabase/db/data:/var/lib/postgresql/data"
"supabase-db-data:/var/lib/postgresql/data"
"${./sql/_supabase.sql}:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro"
"${./sql/realtime.sql}:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:ro"
"${./sql/logs.sql}:/docker-entrypoint-initdb.d/migrations/99-logs.sql:ro"
@ -166,8 +203,8 @@ in
GOTRUE_API_PORT = "9999";
API_EXTERNAL_URL = "https://supabase.cloonar.com";
GOTRUE_DB_DRIVER = "postgres";
GOTRUE_SITE_URL = "https://supabase.cloonar.com";
GOTRUE_URI_ALLOW_LIST = "";
GOTRUE_SITE_URL = "https://app.fueltide.io";
GOTRUE_URI_ALLOW_LIST = "https://app.fueltide.io,https://app.fueltide.io/**,https://app.stage.fueltide.io,https://app.stage.fueltide.io/**,io.fueltide.workout://";
GOTRUE_DISABLE_SIGNUP = "false";
GOTRUE_JWT_ADMIN_ROLES = "service_role";
GOTRUE_JWT_AUD = "authenticated";
@ -175,19 +212,21 @@ in
GOTRUE_JWT_EXP = "3600";
GOTRUE_EXTERNAL_EMAIL_ENABLED = "true";
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED = "false";
GOTRUE_MAILER_AUTOCONFIRM = "true";
GOTRUE_SMTP_ADMIN_EMAIL = "admin@cloonar.com";
GOTRUE_SMTP_HOST = "supabase-mail";
GOTRUE_SMTP_PORT = "2500";
GOTRUE_SMTP_USER = "";
GOTRUE_SMTP_PASS = "";
GOTRUE_SMTP_SENDER_NAME = "Supabase";
GOTRUE_MAILER_AUTOCONFIRM = "false";
GOTRUE_SMTP_ADMIN_EMAIL = "noreply@fueltide.io";
GOTRUE_SMTP_HOST = "mail.cloonar.com";
GOTRUE_SMTP_PORT = "587";
GOTRUE_SMTP_SENDER_NAME = "Fueltide";
GOTRUE_MAILER_URLPATHS_INVITE = "/auth/v1/verify";
GOTRUE_MAILER_URLPATHS_CONFIRMATION = "/auth/v1/verify";
GOTRUE_MAILER_URLPATHS_RECOVERY = "/auth/v1/verify";
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE = "/auth/v1/verify";
GOTRUE_EXTERNAL_PHONE_ENABLED = "false";
GOTRUE_SMS_AUTOCONFIRM = "false";
GOTRUE_EXTERNAL_GOOGLE_ENABLED = "true";
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI = "https://supabase.cloonar.com/auth/v1/callback";
GOTRUE_EXTERNAL_APPLE_ENABLED = "true";
GOTRUE_EXTERNAL_APPLE_REDIRECT_URI = "https://supabase.cloonar.com/auth/v1/callback";
};
environmentFiles = [ "/run/supabase/auth.env" ];
extraOptions = supabaseNet ++ [

View file

@ -22,9 +22,49 @@ LOGFLARE_PRIVATE_ACCESS_TOKEN=$LOGFLARE_PRIVATE_ACCESS_TOKEN
POSTGRES_BACKEND_URL=postgresql://supabase_admin:$PG_PASS_ENCODED@db:5432/_supabase
EOF
# Apple client-secret is a short-lived JWT signed with the .p8 key downloaded
# from Apple Developer. Re-sign on every activation (lifetime 180 days, Apple's
# cap) so there is no manual rotation ritual. The SOPS-sourced APPLE_PRIVATE_KEY
# is stored as a single line with literal \n separators; python un-escapes it.
APPLE_SECRET=""
if [ -n "${APPLE_TEAM_ID:-}" ] && [ -n "${APPLE_KEY_ID:-}" ] \
&& [ -n "${APPLE_SERVICES_ID:-}" ] && [ -n "${APPLE_PRIVATE_KEY:-}" ]; then
APPLE_SECRET=$(
APPLE_TEAM_ID="$APPLE_TEAM_ID" \
APPLE_KEY_ID="$APPLE_KEY_ID" \
APPLE_SERVICES_ID="$APPLE_SERVICES_ID" \
APPLE_PRIVATE_KEY="$APPLE_PRIVATE_KEY" \
python3 - <<'PY'
import base64, json, os, time
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
now = int(time.time())
header = {"alg": "ES256", "kid": os.environ["APPLE_KEY_ID"], "typ": "JWT"}
payload = {"iss": os.environ["APPLE_TEAM_ID"], "iat": now, "exp": now + 86400 * 180,
"aud": "https://appleid.apple.com", "sub": os.environ["APPLE_SERVICES_ID"]}
parts = (b64u(json.dumps(header, separators=(",", ":")).encode())
+ "." + b64u(json.dumps(payload, separators=(",", ":")).encode())).encode()
pem = os.environ["APPLE_PRIVATE_KEY"].encode().decode("unicode_escape").encode()
key = serialization.load_pem_private_key(pem, password=None)
der = key.sign(parts, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
print(parts.decode() + "." + b64u(raw))
PY
)
fi
cat > /run/supabase/auth.env <<EOF
GOTRUE_JWT_SECRET=$JWT_SECRET
GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:$PG_PASS_ENCODED@db:5432/postgres
GOTRUE_SMTP_USER=${SMTP_USER:-}
GOTRUE_SMTP_PASS=${SMTP_PASS:-}
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOOGLE_SECRET:-}
GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${APPLE_SERVICES_ID:-}
GOTRUE_EXTERNAL_APPLE_SECRET=$APPLE_SECRET
EOF
cat > /run/supabase/rest.env <<EOF