feat: 部署 Hydra 与通用 OIDC 登录适配器
This commit is contained in:
@@ -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 .
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/login-consent
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab
|
||||
COPY login-consent /login-consent
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/login-consent"]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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: "[email protected]", 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://[email protected]/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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: hydra
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -15,3 +15,4 @@ resources:
|
||||
- apps/observability.yaml
|
||||
- apps/zot.yaml
|
||||
- apps/nexus.yaml
|
||||
- apps/hydra.yaml
|
||||
|
||||
@@ -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] }
|
||||
|
||||
Reference in New Issue
Block a user