diff --git a/.gitea/workflows/ansible.yml b/.gitea/workflows/ansible.yml index 094a88d..e4d3551 100644 --- a/.gitea/workflows/ansible.yml +++ b/.gitea/workflows/ansible.yml @@ -16,9 +16,6 @@ on: - '.ansible-lint' - '.gitea/workflows/ansible.yml' -env: - ANSIBLE_COLLECTIONS_PATH: /root/.ansible/collections - jobs: lint: runs-on: [self-hosted, pod] @@ -30,6 +27,7 @@ jobs: python3 -m pip install --user --break-system-packages \ --index-url https://pypi.org/simple --quiet uv==0.11.7 echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "ANSIBLE_COLLECTIONS_PATH=$HOME/.ansible/collections" >> "$GITHUB_ENV" - name: Install ansible-lint and collections run: | @@ -50,7 +48,7 @@ jobs: - name: ansible-lint run: | export PATH="$HOME/.local/bin:$PATH" - export ANSIBLE_COLLECTIONS_PATH="$PWD/infrastructure/samba-ad/ansible/collections:/root/.ansible/collections" + export ANSIBLE_COLLECTIONS_PATH="$PWD/infrastructure/samba-ad/ansible/collections:$HOME/.ansible/collections" # 静态检查不应依赖生产 vault 凭据。一次性 checkout 可以去掉加密变量文件; # syntax-check 只验证结构,不需要解析变量的运行时值。 rm -f \ diff --git a/.gitea/workflows/hydra.yml b/.gitea/workflows/hydra.yml new file mode 100644 index 0000000..50c748f --- /dev/null +++ b/.gitea/workflows/hydra.yml @@ -0,0 +1,22 @@ +name: hydra-login +on: + pull_request: + paths: + - 'apps/hydra/login-consent/**' + - '.gitea/workflows/hydra.yml' + workflow_dispatch: +jobs: + verify: + runs-on: [self-hosted, pod] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: apps/hydra/login-consent/go.mod + cache-dependency-path: apps/hydra/login-consent/go.sum + - name: Test authentication boundaries + working-directory: apps/hydra/login-consent + run: | + go test -race ./... + go vet ./... + CGO_ENABLED=0 go build -trimpath . diff --git a/apps/hydra/README.md b/apps/hydra/README.md new file mode 100644 index 0000000..c0be129 --- /dev/null +++ b/apps/hydra/README.md @@ -0,0 +1,102 @@ +# Hydra 与 OIDC Login/Consent PoC + +本目录提供独立 Hydra 签发服务,以及一个薄的 **OIDC 上游适配器**。当前上游配置为 +Authelia;适配器不连接 LDAP,也不管理用户目录。Samba AD、密码和 MFA 继续由现有 +Authelia 链路负责。第一轮只接入人类和 Gitea,不实现 agent 动态授权。 + +```text +Gitea → Hydra → OIDC Login/Consent → Authelia → Samba AD + ← OIDC ← 经验证的上游身份 ← OIDC callback +``` + +目标入口: + +- `https://hydra.ad.ddupan.top`:Hydra 公共 OAuth2/OIDC endpoint。 +- `https://hydra-login.ad.ddupan.top`:上游 OIDC 登录及 consent 适配器。 +- `hydra-admin.hydra.svc.cluster.local:4445`:仅集群内管理接口,无 HTTPRoute。 + +均为 LAN/Tailscale 入口,复用 Envoy `eg/https` wildcard TLS。没有增加公网 tunnel。 +部署及实际验收状态以 wiki 和对应 PR 为准,文件存在不表示登录已验收。 + +## 首次使用与边界 + +在 Gitea 登录页选择 `hydra`,跳转到 Authelia 完成现有人类认证,再返回原有 Gitea +账号。旧 `authelia` 登录源保留。Gitea 的账号关联和资源权限仍由 Gitea 维护。 + +适配器要求验证上游 issuer、audience、签名、过期时间和 nonce,使用 PKCE S256, +并把单次 state 绑定到 Secure/HttpOnly/SameSite=Lax cookie。短期登录事务只存内存, +最多 1024 个、10 分钟过期;单副本重启后正在登录的用户需重试,不存人类密码或 token。 + +Hydra subject 为上游 `(issuer, sub)` 的 SHA-256 加 `human:` 前缀,与可变邮箱/用户名 +分离。第一轮要求上游返回经过验证的 email 及 preferred_username;这些 claims 必须 +明确配置进 ID token。更换 issuer 会改变本 PoC 的 subject,正式迁移前需要身份绑定设计。 + +仅为显式 `ALLOWED_CLIENTS=gitea` 自动 consent,scope 限于 openid/profile/email/groups; +拒绝额外 access-token audience,不发 refresh token。只按实际请求 scope 释放 claims。 +这不是通用的无人确认授权服务。组当前透传,沿用 Gitea 的 gitea-admins 映射;统一组 +模型和 agent 认证均在后续阶段。不存在对 Authelia 专有协议的调用。 + +NetworkPolicy 限制公共端口只接收 Envoy 流量,Hydra admin 只允许适配器访问。 +Hydra 使用正式模式,TLS 由 Envoy 终止;不使用 `--dev`。管理操作使用受控 +`kubectl port-forward`,不要将 admin 接口暴露到 Gateway。 + +## 依赖、秘密与初始化 + +依赖共享 CloudNativePG、OpenBao/ESO、Authelia OIDC、Envoy、Samba DNS、zot 镜像仓库。 +Hydra 使用独立 `hydra` database/role,不与其他应用共享数据库角色。 + +`kv/k8s/hydra` 保存 dsn、system_secret、upstream_client_secret、upstream_client_digest、 +gitea_client_secret;通过 ExternalSecret 投射,值不写入 Git。Bootstrap 创建角色及数据库 +后才启动 Hydra migration。system_secret 必须持久保存,不得在重启时随机重建。 + +Authelia 中新增 confidential client `hydra-login`: + +- redirect URI:`https://hydra-login.ad.ddupan.top/callback`; +- authorization policy:two_factor;grant:authorization_code;PKCE:S256; +- token endpoint auth:client_secret_basic;scope:openid/profile/email/groups; +- claims policy:把 preferred_username、name、email、email_verified、groups 放入 ID token; +- client secret 的 PBKDF2 digest 存入 Authelia,原值仅供适配器使用。 + +Authelia 尚非 Flux 管理。修改 Helm values 时保留所有已有 clients 与 secret 引用, +通过 `--reuse-values` 和最小 overlay 增加客户端,不能以本目录配置覆盖其完整 values。 + +Hydra 中注册 confidential client `gitea`,redirect URI 为 +`https://git.ddupan.top/user/oauth2/hydra/callback`,grant/response 为 authorization_code/code, +scope 为 openid/profile/email/groups,token endpoint auth 为 client_secret_basic。 +Gitea 启动时读取 OIDC discovery,所以应先确认 Hydra 健康和 discovery 可达,再接入 Gitea。 + +## 构建与检查 + +```bash +cd apps/hydra/login-consent +go test -race ./... +go vet ./... +CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o login-consent . +docker build -t hydra-login-consent:VERSION . +``` + +Go module 独立,依赖由 go.sum 锁定;Dockerfile 固定基础镜像 digest。 +使用已授权的短期 SPIFFE zot 凭据发布镜像,部署使用匿名拉取入口与不可变 digest。 +不把 registry 凭据写入源码或 build args。 + +```bash +kubectl kustomize apps/hydra +sudo k3s kubectl -n hydra get deployment,pod,externalsecret,httproute +sudo k3s kubectl -n hydra logs deployment/hydra -c migrate +sudo k3s kubectl -n hydra logs deployment/hydra-login +``` + +日志不输出上游 token、授权 code、challenge 或秘密。登录失败先查两端 Pod 状态、 +DNS/discovery 连通性、client redirect URI 和 scope,再由用户重新发起登录。 +不要在故障排查中关闭签名验证、MFA 或 state/nonce 校验。 + +## 恢复与回退 + +保留共享 PostgreSQL 中 Hydra 数据及 OpenBao 秘密;数据库持有 clients、会话及签名密钥, +单独重建 Deployment 不能替代恢复数据库。先恢复依赖,再启动 Hydra 和适配器。 +当前恢复仍依赖 homelab 共享基础设施,不能声称已完成独立灾备。 + +第一轮不切换 Authelia 的主入口。撤回 Gitea 的新增 Hydra 登录源即可回到旧入口; +先撤消费者,再考虑停用 Hydra。不要删除旧 Authelia 登录源、用户或数据库作为回退手段。 + +跨服务设计见 [独立 IAM 草案](https://git.ddupan.top/panxiao81/homelab-wiki/src/branch/main/architecture/independent-iam-draft.md)。 diff --git a/apps/hydra/deployment.yaml b/apps/hydra/deployment.yaml new file mode 100644 index 0000000..adbf2b3 --- /dev/null +++ b/apps/hydra/deployment.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hydra + namespace: hydra +spec: + replicas: 1 + selector: + matchLabels: + app: hydra + template: + metadata: + labels: + app: hydra + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + initContainers: + - name: migrate + image: docker.io/oryd/hydra:v26.2.0@sha256:ff67c7fb5f95074fa53374d41151713554960504b340cd3f95b09e65deaea2a9 + args: + - migrate + - sql + - -e + - --yes + env: + - name: DSN + valueFrom: + secretKeyRef: + name: hydra + key: dsn + securityContext: &id002 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: &id001 + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 256Mi + containers: + - name: hydra + image: docker.io/oryd/hydra:v26.2.0@sha256:ff67c7fb5f95074fa53374d41151713554960504b340cd3f95b09e65deaea2a9 + args: + - serve + - all + - --config + - /etc/hydra/hydra.yaml + - --sqa-opt-out + env: + - name: DSN + valueFrom: + secretKeyRef: + name: hydra + key: dsn + - name: SECRETS_SYSTEM + valueFrom: + secretKeyRef: + name: hydra + key: system_secret + ports: + - name: public + containerPort: 4444 + - name: admin + containerPort: 4445 + resources: *id001 + securityContext: *id002 + volumeMounts: + - name: config + mountPath: /etc/hydra + readOnly: true + readinessProbe: + httpGet: + path: /health/ready + port: admin + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health/alive + port: admin + initialDelaySeconds: 20 + periodSeconds: 20 + volumes: + - name: config + configMap: + name: hydra-config diff --git a/apps/hydra/external-secret.yaml b/apps/hydra/external-secret.yaml new file mode 100644 index 0000000..332e8e6 --- /dev/null +++ b/apps/hydra/external-secret.yaml @@ -0,0 +1,16 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: hydra + namespace: hydra +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: openbao + target: + name: hydra + creationPolicy: Owner + dataFrom: + - extract: + key: k8s/hydra diff --git a/apps/hydra/httproutes.yaml b/apps/hydra/httproutes.yaml new file mode 100644 index 0000000..111096f --- /dev/null +++ b/apps/hydra/httproutes.yaml @@ -0,0 +1,33 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: hydra-public + namespace: hydra +spec: + parentRefs: + - name: eg + namespace: envoy-gateway-system + sectionName: https + hostnames: + - hydra.ad.ddupan.top + rules: + - backendRefs: + - name: hydra-public + port: 4444 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: hydra-login + namespace: hydra +spec: + parentRefs: + - name: eg + namespace: envoy-gateway-system + sectionName: https + hostnames: + - hydra-login.ad.ddupan.top + rules: + - backendRefs: + - name: hydra-login + port: 8080 diff --git a/apps/hydra/hydra.yaml b/apps/hydra/hydra.yaml new file mode 100644 index 0000000..184bcf3 --- /dev/null +++ b/apps/hydra/hydra.yaml @@ -0,0 +1,25 @@ +serve: + public: + port: 4444 + admin: + port: 4445 + tls: + allow_termination_from: + - 10.42.0.0/16 + cookies: + same_site_mode: Lax +urls: + self: + issuer: https://hydra.ad.ddupan.top + public: https://hydra.ad.ddupan.top + login: https://hydra-login.ad.ddupan.top/login + consent: https://hydra-login.ad.ddupan.top/consent +ttl: + access_token: 15m + id_token: 15m + auth_code: 5m +log: + level: info + leak_sensitive_values: false +oauth2: + expose_internal_errors: false diff --git a/apps/hydra/kustomization.yaml b/apps/hydra/kustomization.yaml new file mode 100644 index 0000000..59c9d92 --- /dev/null +++ b/apps/hydra/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- namespace.yaml +- external-secret.yaml +- deployment.yaml +- login-deployment.yaml +- services.yaml +- httproutes.yaml +- networkpolicy.yaml +configMapGenerator: +- name: hydra-config + namespace: hydra + files: + - hydra.yaml diff --git a/apps/hydra/login-consent/.gitignore b/apps/hydra/login-consent/.gitignore new file mode 100644 index 0000000..00d7d65 --- /dev/null +++ b/apps/hydra/login-consent/.gitignore @@ -0,0 +1 @@ +/login-consent diff --git a/apps/hydra/login-consent/Dockerfile b/apps/hydra/login-consent/Dockerfile new file mode 100644 index 0000000..03afb57 --- /dev/null +++ b/apps/hydra/login-consent/Dockerfile @@ -0,0 +1,4 @@ +FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab +COPY login-consent /login-consent +USER 65532:65532 +ENTRYPOINT ["/login-consent"] diff --git a/apps/hydra/login-consent/go.mod b/apps/hydra/login-consent/go.mod new file mode 100644 index 0000000..bfd77ca --- /dev/null +++ b/apps/hydra/login-consent/go.mod @@ -0,0 +1,13 @@ +module git.ddupan.top/panxiao81/homelab-infra/apps/hydra/login-consent + +go 1.26.0 + +require ( + github.com/coreos/go-oidc/v3 v3.14.1 + golang.org/x/oauth2 v0.37.0 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + golang.org/x/crypto v0.36.0 // indirect +) diff --git a/apps/hydra/login-consent/go.sum b/apps/hydra/login-consent/go.sum new file mode 100644 index 0000000..fa6034d --- /dev/null +++ b/apps/hydra/login-consent/go.sum @@ -0,0 +1,18 @@ +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/hydra/login-consent/main.go b/apps/hydra/login-consent/main.go new file mode 100644 index 0000000..9278dc0 --- /dev/null +++ b/apps/hydra/login-consent/main.go @@ -0,0 +1,285 @@ +// Login/Consent adapter for a single trusted upstream and first-party clients. +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +const cookieName = "__Host-hydra-login" + +type pending struct { + Challenge, Nonce, Verifier string + Expires time.Time +} +type claims struct { + Username string `json:"preferred_username"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Groups []string `json:"groups"` +} +type flowRequest struct { + Client struct { + ID string `json:"client_id"` + } `json:"client"` + Subject string `json:"subject"` + Scopes []string `json:"requested_scope"` + Audience []string `json:"requested_access_token_audience"` + Context claims `json:"context"` +} +type app struct { + admin, public string + client *http.Client + oauth oauth2.Config + verifier *oidc.IDTokenVerifier + allowed map[string]bool + mu sync.Mutex + pending map[string]pending +} + +func required(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing %s", key) + } + return v +} +func random() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} +func (a *app) api(ctx context.Context, method, path string, in, out any) error { + var body io.Reader + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, a.admin+path, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := a.client.Do(req) + if err != nil { + return errors.New("Hydra unavailable") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("Hydra status %d", resp.StatusCode) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) + } + return nil +} +func (a *app) request(r *http.Request, kind, challenge string) (flowRequest, error) { + var f flowRequest + if challenge == "" || len(challenge) > 8192 { + return f, errors.New("missing or invalid challenge") + } + err := a.api(r.Context(), http.MethodGet, "/admin/oauth2/auth/requests/"+kind+"?"+kind+"_challenge="+url.QueryEscape(challenge), nil, &f) + if err != nil { + return f, err + } + if !a.allowed[f.Client.ID] { + return f, errors.New("client not allowed") + } + return f, nil +} +func (a *app) accept(w http.ResponseWriter, r *http.Request, kind, challenge string, body any) { + var result struct { + Redirect string `json:"redirect_to"` + } + if err := a.api(r.Context(), http.MethodPut, "/admin/oauth2/auth/requests/"+kind+"/accept?"+kind+"_challenge="+url.QueryEscape(challenge), body, &result); err != nil { + fail(w, 502) + return + } + // Only Hydra's own authorization endpoint can receive a challenge verifier. + u, err := url.Parse(result.Redirect) + p, _ := url.Parse(a.public) + if err != nil || u.Scheme != p.Scheme || u.Host != p.Host || u.User != nil || u.Path != "/oauth2/auth" { + fail(w, 502) + return + } + http.Redirect(w, r, result.Redirect, http.StatusSeeOther) +} +func fail(w http.ResponseWriter, status int) { http.Error(w, http.StatusText(status), status) } +func (a *app) login(w http.ResponseWriter, r *http.Request) { + challenge := r.URL.Query().Get("login_challenge") + if _, err := a.request(r, "login", challenge); err != nil { + fail(w, 403) + return + } + state := random() + p := pending{challenge, random(), oauth2.GenerateVerifier(), time.Now().Add(10 * time.Minute)} + a.mu.Lock() + for k, v := range a.pending { + if time.Now().After(v.Expires) { + delete(a.pending, k) + } + } + if len(a.pending) >= 1024 { + a.mu.Unlock() + fail(w, 503) + return + } + a.pending[state] = p + a.mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: state, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 600}) + http.Redirect(w, r, a.oauth.AuthCodeURL(state, oidc.Nonce(p.Nonce), oauth2.S256ChallengeOption(p.Verifier)), http.StatusSeeOther) +} +func (a *app) take(r *http.Request) (pending, error) { + state := r.URL.Query().Get("state") + cookie, err := r.Cookie(cookieName) + if err != nil || state == "" || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(state)) != 1 { + return pending{}, errors.New("state mismatch") + } + a.mu.Lock() + defer a.mu.Unlock() + p, ok := a.pending[state] + delete(a.pending, state) + if !ok || time.Now().After(p.Expires) { + return pending{}, errors.New("expired or used state") + } + return p, nil +} +func (a *app) callback(w http.ResponseWriter, r *http.Request) { + p, err := a.take(r) + if err != nil { + fail(w, 403) + return + } + http.SetCookie(w, &http.Cookie{Name: cookieName, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1}) + if r.URL.Query().Get("error") != "" || r.URL.Query().Get("code") == "" { + fail(w, 403) + return + } + ctx := oidc.ClientContext(r.Context(), a.client) + token, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code"), oauth2.VerifierOption(p.Verifier)) + if err != nil { + fail(w, 502) + return + } + raw, ok := token.Extra("id_token").(string) + if !ok { + fail(w, 502) + return + } + id, err := a.verifier.Verify(ctx, raw) + if err != nil || id.Nonce != p.Nonce || id.Subject == "" { + fail(w, 403) + return + } + var c claims + if id.Claims(&c) != nil || c.Username == "" || c.Email == "" || !c.EmailVerified { + fail(w, 403) + return + } + if _, err := a.request(r, "login", p.Challenge); err != nil { + fail(w, 403) + return + } + // Stable identity is tied to the verified upstream issuer+subject, never email. + sum := sha256.Sum256([]byte(id.Issuer + "\x00" + id.Subject)) + a.accept(w, r, "login", p.Challenge, map[string]any{"subject": "human:" + hex.EncodeToString(sum[:]), "remember": false, "context": c}) +} +func consentSession(f flowRequest) (map[string]any, error) { + if !strings.HasPrefix(f.Subject, "human:") || f.Context.Username == "" || f.Context.Email == "" || !f.Context.EmailVerified { + return nil, errors.New("invalid identity context") + } + allowed := map[string]bool{"openid": true, "profile": true, "email": true, "groups": true} + session := map[string]any{"principal_type": "human"} + for _, scope := range f.Scopes { + if !allowed[scope] { + return nil, errors.New("scope not allowed") + } + switch scope { + case "profile": + session["preferred_username"] = f.Context.Username + session["name"] = f.Context.Name + case "email": + session["email"] = f.Context.Email + session["email_verified"] = true + case "groups": + session["groups"] = f.Context.Groups + } + } + if len(f.Audience) > 0 { + return nil, errors.New("access token audience not allowed") + } + return session, nil +} +func (a *app) consent(w http.ResponseWriter, r *http.Request) { + challenge := r.URL.Query().Get("consent_challenge") + f, err := a.request(r, "consent", challenge) + if err != nil { + fail(w, 403) + return + } + session, err := consentSession(f) + if err != nil { + fail(w, 403) + return + } + // Explicit policy for pre-approved first-party clients only; no generic auto-consent. + a.accept(w, r, "consent", challenge, map[string]any{"grant_scope": f.Scopes, "remember": false, "session": map[string]any{"id_token": session}}) +} +func (a *app) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + mux.HandleFunc("GET /login", a.login) + mux.HandleFunc("GET /callback", a.callback) + mux.HandleFunc("GET /consent", a.consent) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + mux.ServeHTTP(w, r) + }) +} +func main() { + client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(r *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} + issuer := required("UPSTREAM_ISSUER") + ctx := oidc.ClientContext(context.Background(), client) + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + log.Fatal("upstream discovery failed") + } + clientID := required("UPSTREAM_CLIENT_ID") + a := &app{admin: required("HYDRA_ADMIN_URL"), public: required("HYDRA_PUBLIC_URL"), client: client, allowed: map[string]bool{}, pending: map[string]pending{}, + oauth: oauth2.Config{ClientID: clientID, ClientSecret: required("UPSTREAM_CLIENT_SECRET"), RedirectURL: required("CALLBACK_URL"), Endpoint: provider.Endpoint(), Scopes: []string{"openid", "profile", "email", "groups"}}, + verifier: provider.Verifier(&oidc.Config{ClientID: clientID})} + for _, id := range strings.Split(required("ALLOWED_CLIENTS"), ",") { + a.allowed[id] = true + } + s := http.Server{Addr: ":8080", Handler: a.handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 45 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16384} + log.Print("login/consent adapter listening on :8080") + log.Fatal(s.ListenAndServe()) +} diff --git a/apps/hydra/login-consent/main_test.go b/apps/hydra/login-consent/main_test.go new file mode 100644 index 0000000..adc8de9 --- /dev/null +++ b/apps/hydra/login-consent/main_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" +) + +func TestStateBoundToCookieSingleUseAndExpiry(t *testing.T) { + a := &app{pending: map[string]pending{"valid": {Challenge: "challenge", Expires: time.Now().Add(time.Minute)}, "expired": {Expires: time.Now().Add(-time.Minute)}}} + request := func(state, cookie string) *http.Request { + r := httptest.NewRequest("GET", "https://login.example/callback?state="+state, nil) + if cookie != "" { + r.AddCookie(&http.Cookie{Name: cookieName, Value: cookie}) + } + return r + } + for _, r := range []*http.Request{request("valid", ""), request("valid", "other"), request("expired", "expired")} { + if _, err := a.take(r); err == nil { + t.Fatal("invalid state accepted") + } + } + if p, err := a.take(request("valid", "valid")); err != nil || p.Challenge != "challenge" { + t.Fatal("valid state rejected") + } + if _, err := a.take(request("valid", "valid")); err == nil { + t.Fatal("replayed state accepted") + } +} +func TestConsentRejectsPrivilegeExpansionAndFiltersClaims(t *testing.T) { + f := flowRequest{Subject: "human:known", Scopes: []string{"openid", "email"}, Context: claims{Username: "alice", Email: "alice@example.org", EmailVerified: true, Groups: []string{"operators"}}} + s, err := consentSession(f) + if err != nil { + t.Fatal(err) + } + if _, ok := s["groups"]; ok { + t.Fatal("groups leaked without scope") + } + if _, ok := s["preferred_username"]; ok { + t.Fatal("profile leaked without scope") + } + for _, scope := range []string{"admin", "offline_access", "unknown"} { + bad := f + bad.Scopes = append([]string{"openid"}, scope) + if _, err := consentSession(bad); err == nil { + t.Fatalf("accepted %s", scope) + } + } + f.Audience = []string{"other-service"} + if _, err := consentSession(f); err == nil { + t.Fatal("unexpected audience accepted") + } + f.Audience = nil + f.Context.EmailVerified = false + if _, err := consentSession(f); err == nil { + t.Fatal("unverified email accepted") + } +} +func TestLoginValidatesClientAndUsesPKCEAndNonce(t *testing.T) { + admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"client": map[string]string{"client_id": r.URL.Query().Get("login_challenge")}}) + })) + defer admin.Close() + a := &app{admin: admin.URL, client: admin.Client(), allowed: map[string]bool{"gitea": true}, pending: map[string]pending{}, oauth: oauth2.Config{ClientID: "hydra-login", RedirectURL: "https://login.example/callback", Endpoint: oauth2.Endpoint{AuthURL: "https://upstream.example/authorize"}}} + w := httptest.NewRecorder() + a.handler().ServeHTTP(w, httptest.NewRequest("GET", "https://login.example/login?login_challenge=rogue", nil)) + if w.Code != 403 { + t.Fatal("unknown client accepted") + } + w = httptest.NewRecorder() + a.handler().ServeHTTP(w, httptest.NewRequest("GET", "https://login.example/login?login_challenge=gitea", nil)) + if w.Code != 303 { + t.Fatalf("status %d", w.Code) + } + u, _ := url.Parse(w.Header().Get("Location")) + q := u.Query() + if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("nonce") == "" || q.Get("state") == "" { + t.Fatal("missing protocol binding") + } + cookies := w.Result().Cookies() + if len(cookies) != 1 || !cookies[0].Secure || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode || cookies[0].Value != q.Get("state") { + t.Fatal("unsafe cookie") + } + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("missing cache protection") + } +} +func TestHydraRedirectCannotLeaveTrustedOrigin(t *testing.T) { + for _, target := range []string{"https://evil.example/oauth2/auth", "https://hydra.example@evil.example/oauth2/auth", "https://hydra.example/other"} { + admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"redirect_to": target}) + })) + a := &app{admin: admin.URL, public: "https://hydra.example", client: admin.Client()} + w := httptest.NewRecorder() + a.accept(w, httptest.NewRequest("GET", "https://login.example/login", nil), "login", "challenge", map[string]string{"subject": "human:test"}) + if w.Code != 502 || strings.Contains(w.Header().Get("Location"), "evil") { + t.Fatal("untrusted redirect accepted") + } + admin.Close() + } +} diff --git a/apps/hydra/login-deployment.yaml b/apps/hydra/login-deployment.yaml new file mode 100644 index 0000000..126dc92 --- /dev/null +++ b/apps/hydra/login-deployment.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hydra-login + namespace: hydra +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: hydra-login + template: + metadata: + labels: + app: hydra-login + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: login-consent + image: zot.ad.ddupan.top/iam/oidc-login-consent@sha256:fede9b9e93c457c4b7a8a6022d9df86ff5d5900d3f6b6c4f3439851a0ae1e944 + env: + - name: HYDRA_ADMIN_URL + value: http://hydra-admin.hydra.svc.cluster.local:4445 + - name: HYDRA_PUBLIC_URL + value: https://hydra.ad.ddupan.top + - name: UPSTREAM_ISSUER + value: https://auth.ddupan.top + - name: UPSTREAM_CLIENT_ID + value: hydra-login + - name: CALLBACK_URL + value: https://hydra-login.ad.ddupan.top/callback + - name: ALLOWED_CLIENTS + value: gitea + - name: UPSTREAM_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: hydra + key: upstream_client_secret + ports: + - name: http + containerPort: 8080 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + readinessProbe: + httpGet: + path: /healthz + port: http + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 diff --git a/apps/hydra/namespace.yaml b/apps/hydra/namespace.yaml new file mode 100644 index 0000000..06d3175 --- /dev/null +++ b/apps/hydra/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: hydra + labels: + pod-security.kubernetes.io/enforce: restricted diff --git a/apps/hydra/networkpolicy.yaml b/apps/hydra/networkpolicy.yaml new file mode 100644 index 0000000..7a76901 --- /dev/null +++ b/apps/hydra/networkpolicy.yaml @@ -0,0 +1,46 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: hydra + namespace: hydra +spec: + podSelector: + matchLabels: + app: hydra + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: envoy-gateway-system + ports: + - port: 4444 + protocol: TCP + - from: + - podSelector: + matchLabels: + app: hydra-login + ports: + - port: 4445 + protocol: TCP +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: hydra-login + namespace: hydra +spec: + podSelector: + matchLabels: + app: hydra-login + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: envoy-gateway-system + ports: + - port: 8080 + protocol: TCP diff --git a/apps/hydra/services.yaml b/apps/hydra/services.yaml new file mode 100644 index 0000000..ac0ef61 --- /dev/null +++ b/apps/hydra/services.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: Service +metadata: + name: hydra-public + namespace: hydra +spec: + selector: + app: hydra + ports: + - port: 4444 + targetPort: 4444 +--- +apiVersion: v1 +kind: Service +metadata: + name: hydra-admin + namespace: hydra +spec: + selector: + app: hydra + ports: + - port: 4445 + targetPort: 4445 +--- +apiVersion: v1 +kind: Service +metadata: + name: hydra-login + namespace: hydra +spec: + selector: + app: hydra-login + ports: + - port: 8080 + targetPort: 8080 diff --git a/clusters/homelab/apps/hydra.yaml b/clusters/homelab/apps/hydra.yaml new file mode 100644 index 0000000..e2a2f29 --- /dev/null +++ b/clusters/homelab/apps/hydra.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: hydra + namespace: flux-system +spec: + dependsOn: + - name: envoy-gateway + - name: external-secrets + interval: 10m + path: ./apps/hydra + prune: false + sourceRef: + kind: GitRepository + name: flux-system + timeout: 5m + wait: true diff --git a/clusters/homelab/kustomization.yaml b/clusters/homelab/kustomization.yaml index caaa4ae..00dcccf 100644 --- a/clusters/homelab/kustomization.yaml +++ b/clusters/homelab/kustomization.yaml @@ -15,3 +15,4 @@ resources: - apps/observability.yaml - apps/zot.yaml - apps/nexus.yaml + - apps/hydra.yaml diff --git a/infrastructure/dns/records.yml b/infrastructure/dns/records.yml index 1b71929..96e6862 100644 --- a/infrastructure/dns/records.yml +++ b/infrastructure/dns/records.yml @@ -20,6 +20,8 @@ homelab_dns: - { zone: ad.ddupan.top, name: nats, type: A, values: [192.168.10.127] } - { zone: ad.ddupan.top, name: nexus, type: A, values: [192.168.10.127] } - { zone: ad.ddupan.top, name: s3, type: A, values: [192.168.10.127] } + - { zone: ad.ddupan.top, name: hydra, type: A, values: [192.168.10.127] } + - { zone: ad.ddupan.top, name: hydra-login, type: A, values: [192.168.10.127] } - { zone: ad.ddupan.top, name: spire-oidc, type: A, values: [192.168.10.127] } - { zone: ad.ddupan.top, name: spire-server, type: A, values: [192.168.10.127] } - { zone: ad.ddupan.top, name: zot, type: A, values: [192.168.10.127] }