diff --git a/deploy-k3s/manifests/kratos/README.md b/deploy-k3s/manifests/kratos/README.md new file mode 100644 index 0000000..55d6145 --- /dev/null +++ b/deploy-k3s/manifests/kratos/README.md @@ -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:`, 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: "" # generate ONCE, keep stable + secrets_cipher: "" # must be exactly 32 chars + smtp_connection_uri: "smtps://USER:PASS@smtp.fastmail.com:465/" + google_client_secret: "" + 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). diff --git a/deploy-k3s/manifests/kratos/configmap.yaml b/deploy-k3s/manifests/kratos/configmap.yaml new file mode 100644 index 0000000..53a4fec --- /dev/null +++ b/deploy-k3s/manifests/kratos/configmap.yaml @@ -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, + }, + }, + } diff --git a/deploy-k3s/manifests/kratos/ingress.yaml b/deploy-k3s/manifests/kratos/ingress.yaml new file mode 100644 index 0000000..9637aa5 --- /dev/null +++ b/deploy-k3s/manifests/kratos/ingress.yaml @@ -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 diff --git a/deploy-k3s/manifests/kratos/kratos.yaml b/deploy-k3s/manifests/kratos/kratos.yaml new file mode 100644 index 0000000..52f0235 --- /dev/null +++ b/deploy-k3s/manifests/kratos/kratos.yaml @@ -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 diff --git a/deploy-k3s/manifests/kratos/migrate-job.yaml b/deploy-k3s/manifests/kratos/migrate-job.yaml new file mode 100644 index 0000000..03c61ff --- /dev/null +++ b/deploy-k3s/manifests/kratos/migrate-job.yaml @@ -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 diff --git a/deploy-k3s/scripts/02-setup-secrets.sh b/deploy-k3s/scripts/02-setup-secrets.sh index d5c52b5..0fa997b 100755 --- a/deploy-k3s/scripts/02-setup-secrets.sh +++ b/deploy-k3s/scripts/02-setup-secrets.sh @@ -164,6 +164,35 @@ else warn "Admin panel will NOT have basic auth protection." fi +# --- Create Kratos secrets (Ory Kratos identity service) --- +# Created only when config.yaml has a kratos.dsn. Until then 03-deploy.sh skips +# the Kratos deploy entirely, so the existing stack is unaffected. + +KRATOS_DSN="$(cfg kratos.dsn 2>/dev/null || true)" +if [[ -n "${KRATOS_DSN}" ]]; then + log "Creating kratos-secrets..." + KR_COOKIE="$(cfg kratos.secrets_cookie 2>/dev/null || true)" + KR_CIPHER="$(cfg kratos.secrets_cipher 2>/dev/null || true)" + KR_SMTP="$(cfg kratos.smtp_connection_uri 2>/dev/null || true)" + KR_GOOGLE="$(cfg kratos.google_client_secret 2>/dev/null || true)" + KR_APPLE="$(cfg kratos.apple_private_key 2>/dev/null || true)" + [[ -n "${KR_COOKIE}" && -n "${KR_CIPHER}" ]] \ + || die "kratos.secrets_cookie / secrets_cipher must be set (generate once: openssl rand -hex 16)" + [[ ${#KR_CIPHER} -eq 32 ]] \ + || die "kratos.secrets_cipher must be exactly 32 characters (openssl rand -hex 16)" + kubectl create secret generic kratos-secrets \ + --namespace="${NAMESPACE}" \ + --from-literal="dsn=${KRATOS_DSN}" \ + --from-literal="secrets_cookie=${KR_COOKIE}" \ + --from-literal="secrets_cipher=${KR_CIPHER}" \ + --from-literal="smtp_connection_uri=${KR_SMTP}" \ + --from-literal="google_client_secret=${KR_GOOGLE}" \ + --from-literal="apple_private_key=${KR_APPLE}" \ + --dry-run=client -o yaml | kubectl apply -f - +else + warn "config.yaml has no kratos.dsn — skipping kratos-secrets (Kratos not yet configured)." +fi + # --- Done --- log "" diff --git a/deploy-k3s/scripts/03-deploy.sh b/deploy-k3s/scripts/03-deploy.sh index e49dbfc..e7b8e54 100755 --- a/deploy-k3s/scripts/03-deploy.sh +++ b/deploy-k3s/scripts/03-deploy.sh @@ -264,6 +264,27 @@ if [[ -d "${MANIFESTS}/observability" ]]; then fi fi +# --- Ory Kratos (identity service) --- +# Applied only when kratos-secrets exists — i.e. the operator has completed the +# Kratos prerequisites in deploy-k3s/manifests/kratos/README.md. Otherwise +# skipped, so the existing stack deploys unaffected. +if kubectl -n "${NAMESPACE}" get secret kratos-secrets >/dev/null 2>&1; then + log "Deploying Ory Kratos..." + kubectl apply -f "${MANIFESTS}/kratos/configmap.yaml" + # The migrate Job is immutable — delete any prior run, then apply + wait. + kubectl delete job kratos-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null + kubectl apply -f "${MANIFESTS}/kratos/migrate-job.yaml" + if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=5m job/kratos-migrate; then + warn "Kratos migration Job failed — logs:" + kubectl logs -n "${NAMESPACE}" job/kratos-migrate --tail=100 || true + die "aborting: Kratos schema migration failed" + fi + kubectl apply -f "${MANIFESTS}/kratos/kratos.yaml" + kubectl apply -f "${MANIFESTS}/kratos/ingress.yaml" +else + log "kratos-secrets not present — skipping Kratos deploy (see manifests/kratos/README.md)." +fi + # --- Wait for rollouts --- log "Waiting for rollouts..." @@ -281,6 +302,9 @@ fi if kubectl -n "${NAMESPACE}" get daemonset alloy-logs >/dev/null 2>&1; then kubectl rollout status daemonset/alloy-logs -n "${NAMESPACE}" --timeout=120s fi +if kubectl -n "${NAMESPACE}" get deployment kratos >/dev/null 2>&1; then + kubectl rollout status deployment/kratos -n "${NAMESPACE}" --timeout=180s +fi # --- Done ---