feat(auth): scaffold Ory Kratos identity service — phase 1 (infrastructure)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

First phase of replacing the hand-rolled auth (internal/services/auth_service.go
et al.) with Ory Kratos. This commit is infrastructure only — Kratos will run
but nothing consumes it yet; the Go API still does its own auth until phase 2.

Adds deploy-k3s/manifests/kratos/:
- configmap.yaml  — kratos.yml, identity schema, Google/Apple OIDC claim
                     mappers (no secrets in the ConfigMap)
- migrate-job.yaml — `kratos migrate sql`, run before the Deployment
- kratos.yaml     — Deployment (x2), Service, NetworkPolicies
- ingress.yaml    — auth.myhoneydue.com -> Kratos public API :4433
- README.md       — operator prerequisites + deploy runbook

Wiring:
- 02-setup-secrets.sh creates kratos-secrets, gated on a config.yaml `kratos:`
  block (DSN, cookie/cipher, SMTP URI, OIDC client secret, Apple key).
- 03-deploy.sh applies the Kratos manifests + runs the migrate Job, gated on
  the kratos-secrets Secret existing.

Both gates mean the existing stack deploys completely unaffected until the
operator completes the prerequisites (Neon `kratos` DB, auth.myhoneydue.com
DNS, Apple/Google OAuth apps, Kratos image version). Pre-production, so no
user-data migration — see manifests/kratos/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-18 16:24:38 -05:00
parent c845771946
commit b66151ddd9
7 changed files with 636 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
# Ory Kratos — honeyDue identity service (Phase 1: infrastructure)
This directory deploys [Ory Kratos](https://www.ory.sh/kratos/) into the
`honeydue` namespace as the identity provider — replacing the hand-rolled auth
in `internal/services/auth_service.go` etc.
**Phase 1 is infrastructure only.** Once deployed, Kratos runs but nothing uses
it yet — the honeyDue Go API still does its own auth. Phase 2 (backend swap)
and Phase 3 (KMP/web clients) follow. Migrating onto Kratos can lose all
existing user data — honeyDue is pre-production, so no user import is done.
The deploy is **gated**: `03-deploy.sh` applies Kratos only when the
`kratos-secrets` Secret exists, and `02-setup-secrets.sh` creates that Secret
only when `config.yaml` has a `kratos:` block. Until then the existing stack
deploys completely unaffected.
## Files
| File | What |
|---|---|
| `configmap.yaml` | `kratos.yml`, identity schema, Google/Apple OIDC claim mappers (no secrets) |
| `migrate-job.yaml` | `kratos migrate sql` — schema migration, run before the Deployment |
| `kratos.yaml` | Deployment (×2), Service, NetworkPolicies |
| `ingress.yaml` | `auth.myhoneydue.com` → Kratos public API :4433 |
## Operator prerequisites (must be done before deploying)
1. **Kratos version** — Ory uses CalVer (`v25.x` / `v26.x`). Pick the current
stable, then replace `REPLACE_WITH_CURRENT_STABLE_TAG` in `kratos.yaml` and
`migrate-job.yaml` with `oryd/kratos:vXX.Y@sha256:<digest>`, and set the
matching `version:` in `configmap.yaml`.
2. **Kratos database** — create a separate Neon database named `kratos` (do not
share honeyDue's). Capture its connection string as the DSN.
3. **DNS** — add `auth.myhoneydue.com` in Cloudflare (proxied), pointing at the
cluster ingress like the other honeyDue hosts. Confirm the
`cloudflare-origin-cert` TLS secret covers `auth.myhoneydue.com`.
4. **Google OAuth client** — Google Cloud Console → create an OAuth 2.0 client.
Redirect URI: `https://auth.myhoneydue.com/self-service/methods/oidc/callback/google`.
Put the **client ID** into `configmap.yaml` (`GOOGLE_OAUTH_CLIENT_ID`); the
**client secret** goes in `config.yaml`.
5. **Apple Sign In** — Apple Developer → a Services ID + a Sign in with Apple
key. Return URL: `https://auth.myhoneydue.com/self-service/methods/oidc/callback/apple`.
Put the **Services ID / Team ID / Key ID** into `configmap.yaml`
(`APPLE_SERVICES_ID` / `APPLE_TEAM_ID` / `APPLE_PRIVATE_KEY_ID`); the **.p8
private key** goes in `config.yaml`.
6. **`config.yaml`** — add a `kratos:` block:
```yaml
kratos:
dsn: "postgres://USER:PASS@HOST/kratos?sslmode=require"
secrets_cookie: "<openssl rand -hex 16>" # generate ONCE, keep stable
secrets_cipher: "<openssl rand -hex 16>" # must be exactly 32 chars
smtp_connection_uri: "smtps://USER:PASS@smtp.fastmail.com:465/"
google_client_secret: "<from Google Cloud Console>"
apple_private_key: |
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
```
`secrets_cookie` / `secrets_cipher` must stay stable forever — rotating them
invalidates every session and makes encrypted data unreadable.
## Deploy
```bash
cd honeyDueAPI-go
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
./deploy-k3s/scripts/02-setup-secrets.sh # creates kratos-secrets from config.yaml
./deploy-k3s/scripts/03-deploy.sh # applies kratos manifests, runs migrate, rolls
```
`03-deploy.sh` applies `configmap.yaml` → runs `migrate-job.yaml` → waits →
applies `kratos.yaml` + `ingress.yaml`.
## Verify
- `kubectl -n honeydue get pods -l app.kubernetes.io/name=kratos` — 2/2 Running
- `kubectl -n honeydue logs job/kratos-migrate` — migration succeeded
- `curl https://auth.myhoneydue.com/health/ready` — `{"status":"ok"}`
- `curl https://auth.myhoneydue.com/self-service/registration/api` — returns a flow
## Not yet done (later phases)
- **Phase 2** — honeyDue Go backend: swap `middleware/auth.go` for Kratos
session validation, drop the hand-rolled auth code, rebuild the `users`
table keyed on the Kratos identity ID.
- **Phase 3** — KMP mobile + Next.js web clients point at Kratos flows.
- Admin-panel auth stays on its own JWT (out of scope).
+204
View File
@@ -0,0 +1,204 @@
# Ory Kratos configuration for honeyDue.
#
# Secrets are NOT in this ConfigMap. The DSN, cookie/cipher secrets, SMTP URI
# and OIDC client secrets are injected as environment variables from the
# kratos-secrets Secret (see kratos.yaml). Kratos is configured natively via
# env vars, so this is the idiomatic split — only non-secret config here.
#
# OPERATOR: replace the GOOGLE_OAUTH_CLIENT_ID / APPLE_* client-id placeholders
# below with the real (non-secret) OAuth client identifiers once the Apple and
# Google OAuth apps exist. The matching secrets go in kratos-secrets.
apiVersion: v1
kind: ConfigMap
metadata:
name: kratos-config
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
data:
kratos.yml: |
# version must track the Kratos image tag — confirm against the deployed
# Kratos release (Ory uses CalVer, e.g. v26.x). See kratos/README.md.
version: v1.3.0
serve:
public:
base_url: https://auth.myhoneydue.com/
cors:
enabled: true
allowed_origins:
- https://myhoneydue.com
- https://app.myhoneydue.com
- https://admin.myhoneydue.com
allowed_methods: [GET, POST, PUT, PATCH, DELETE]
allowed_headers: [Authorization, Content-Type, X-Session-Token, Cookie]
exposed_headers: [Content-Type, Set-Cookie]
admin:
base_url: http://kratos.honeydue.svc.cluster.local:4434/
selfservice:
default_browser_return_url: https://app.myhoneydue.com/
allowed_return_urls:
- https://app.myhoneydue.com
- https://myhoneydue.com
- honeydue://callback
methods:
password:
enabled: true
code: # email one-time codes (verify/recover)
enabled: true
oidc:
enabled: true
config:
providers:
# index 0 — Google. client_secret is injected via env var
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_SECRET.
- id: google
provider: google
client_id: GOOGLE_OAUTH_CLIENT_ID
mapper_url: file:///etc/kratos/oidc.google.jsonnet
scope: [openid, email, profile]
# index 1 — Apple. apple_private_key is injected via env var
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_1_APPLE_PRIVATE_KEY.
- id: apple
provider: apple
client_id: APPLE_SERVICES_ID
apple_team_id: APPLE_TEAM_ID
apple_private_key_id: APPLE_PRIVATE_KEY_ID
mapper_url: file:///etc/kratos/oidc.apple.jsonnet
scope: [openid, email, name]
flows:
error:
ui_url: https://app.myhoneydue.com/auth/error
login:
ui_url: https://app.myhoneydue.com/auth/login
lifespan: 10m
registration:
ui_url: https://app.myhoneydue.com/auth/registration
lifespan: 10m
after:
password:
hooks:
- hook: session # auto-login after registration
oidc:
hooks:
- hook: session
verification:
enabled: true
ui_url: https://app.myhoneydue.com/auth/verification
use: code
after:
default_browser_return_url: https://app.myhoneydue.com/
recovery:
enabled: true
ui_url: https://app.myhoneydue.com/auth/recovery
use: code
settings:
ui_url: https://app.myhoneydue.com/auth/settings
privileged_session_max_age: 15m
logout:
after:
default_browser_return_url: https://app.myhoneydue.com/
log:
level: info
format: json
leak_sensitive_values: false
ciphers:
algorithm: xchacha20-poly1305
hashers:
algorithm: bcrypt
bcrypt:
cost: 12
identity:
default_schema_id: honeydue
schemas:
- id: honeydue
url: file:///etc/kratos/identity.schema.json
courier:
smtp:
from_address: noreply@myhoneydue.com
from_name: honeyDue
# connection_uri is injected via env COURIER_SMTP_CONNECTION_URI
session:
lifespan: 720h # 30-day sessions (mobile)
cookie:
domain: myhoneydue.com
same_site: Lax
identity.schema.json: |
{
"$id": "https://honeydue.app/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "honeyDue user",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "Email",
"minLength": 3,
"maxLength": 320,
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"code": { "identifier": true, "via": "email" },
"totp": { "account_name": true }
},
"verification": { "via": "email" },
"recovery": { "via": "email" }
}
},
"name": {
"type": "object",
"title": "Name",
"properties": {
"first": { "type": "string", "title": "First name", "maxLength": 100 },
"last": { "type": "string", "title": "Last name", "maxLength": 100 }
}
}
},
"required": ["email"],
"additionalProperties": false
}
}
}
oidc.google.jsonnet: |
// Maps Google OIDC claims onto the honeyDue identity schema.
local claims = std.extVar('claims');
{
identity: {
traits: {
email: claims.email,
[if 'given_name' in claims || 'family_name' in claims then 'name']: {
first: if 'given_name' in claims then claims.given_name else '',
last: if 'family_name' in claims then claims.family_name else '',
},
},
},
}
oidc.apple.jsonnet: |
// Maps Apple OIDC claims onto the honeyDue identity schema. Apple only
// returns the name on the very first authorization and not in the ID
// token claims, so only email is mapped here.
local claims = std.extVar('claims');
{
identity: {
traits: {
email: claims.email,
},
},
}
+38
View File
@@ -0,0 +1,38 @@
# Public ingress for Ory Kratos — auth.myhoneydue.com → Kratos public API :4433.
#
# Chains the same edge middlewares as the honeyDue API ingress: cloudflare-only
# (reject non-Cloudflare source IPs), security-headers, and the general
# rate-limit. Kratos's self-service flows are multi-request, so the strict
# auth-rate-limit (5/min) is intentionally NOT used here — Kratos applies its
# own per-flow protections.
#
# OPERATOR: confirm the cloudflare-origin-cert TLS secret covers
# auth.myhoneydue.com (apex + wildcard origin cert), and add the
# auth.myhoneydue.com DNS record in Cloudflare (proxied) → cluster ingress.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: honeydue-auth
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-cloudflare-only@kubernetescrd,honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- auth.myhoneydue.com
secretName: cloudflare-origin-cert
rules:
- host: auth.myhoneydue.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kratos
port:
number: 4433
+198
View File
@@ -0,0 +1,198 @@
# Ory Kratos — identity service for honeyDue.
#
# Deployed only once the operator has completed the prerequisites in
# kratos/README.md (Neon `kratos` database, auth.myhoneydue.com DNS, Apple +
# Google OAuth apps, and the kratos-secrets Secret). Until then 03-deploy.sh
# skips the Kratos apply, so the existing stack is unaffected.
#
# IMAGE: oryd/kratos uses CalVer (v25.x / v26.x). The tag below is a
# fail-loud placeholder — set the current stable tag and pin a @sha256:
# digest (like redis/vmagent) before deploying. See kratos/README.md.
# The schema-migration Job is in migrate-job.yaml (run before this).
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kratos
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: kratos
template:
metadata:
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: kratos
image: oryd/kratos:REPLACE_WITH_CURRENT_STABLE_TAG
imagePullPolicy: IfNotPresent
args:
- serve
- --config
- /etc/kratos/kratos.yml
- --watch-courier # send verification/recovery email in-process
ports:
- name: public
containerPort: 4433
- name: admin
containerPort: 4434
env:
# Kratos is configured natively via env vars; secrets come from
# the kratos-secrets Secret rather than the ConfigMap.
- name: DSN
valueFrom: { secretKeyRef: { name: kratos-secrets, key: dsn } }
- name: SECRETS_COOKIE
valueFrom: { secretKeyRef: { name: kratos-secrets, key: secrets_cookie } }
- name: SECRETS_CIPHER
valueFrom: { secretKeyRef: { name: kratos-secrets, key: secrets_cipher } }
- name: COURIER_SMTP_CONNECTION_URI
valueFrom: { secretKeyRef: { name: kratos-secrets, key: smtp_connection_uri } }
# OIDC provider secrets — index must match the providers list
# order in configmap.yaml (0 = google, 1 = apple).
- name: SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_SECRET
valueFrom: { secretKeyRef: { name: kratos-secrets, key: google_client_secret } }
- name: SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_1_APPLE_PRIVATE_KEY
valueFrom: { secretKeyRef: { name: kratos-secrets, key: apple_private_key } }
volumeMounts:
- name: config
mountPath: /etc/kratos
readOnly: true
- name: tmp
mountPath: /tmp
readinessProbe:
httpGet:
path: /health/ready
port: 4434
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/alive
port: 4434
initialDelaySeconds: 10
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: config
configMap:
name: kratos-config
- name: tmp
emptyDir:
sizeLimit: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: kratos
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
selector:
app.kubernetes.io/name: kratos
ports:
- name: public
port: 4433
targetPort: 4433
- name: admin
port: 4434
targetPort: 4434
---
# Ingress to Kratos: Traefik (the auth.myhoneydue.com IngressRoute) and the
# honeyDue api pods (session whoami) may reach the public API :4433. The
# admin API :4434 takes no cluster ingress — it is reachable only in-pod.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-kratos
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: kratos
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
- podSelector:
matchLabels:
app.kubernetes.io/name: api
ports:
- port: 4433
protocol: TCP
---
# Kratos egress: DNS, the Neon Postgres database, SMTP, and HTTPS to the
# OIDC providers (Apple/Google token + JWKS endpoints).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-kratos
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: kratos
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# Neon Postgres (external)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
- 10.43.0.0/16
ports:
- port: 5432
protocol: TCP
# SMTP (Fastmail) + HTTPS to Apple/Google OIDC endpoints (external)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
- 10.43.0.0/16
ports:
- port: 465
protocol: TCP
- port: 443
protocol: TCP
@@ -0,0 +1,51 @@
# Ory Kratos schema migration — runs `kratos migrate sql` against the Kratos
# database before the Kratos Deployment rolls. 03-deploy.sh applies this,
# waits for completion, then applies kratos.yaml.
#
# IMAGE: set the same oryd/kratos tag as kratos.yaml (Ory CalVer v25.x/v26.x);
# pin a @sha256: digest. See kratos/README.md.
apiVersion: batch/v1
kind: Job
metadata:
name: kratos-migrate
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
backoffLimit: 0
template:
metadata:
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: kratos-migrate
image: oryd/kratos:REPLACE_WITH_CURRENT_STABLE_TAG
imagePullPolicy: IfNotPresent
args: ["migrate", "sql", "-e", "--yes"]
env:
- name: DSN
valueFrom:
secretKeyRef:
name: kratos-secrets
key: dsn
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi