diff --git a/.env.example b/.env.example index 0eedfa5..add35ad 100644 --- a/.env.example +++ b/.env.example @@ -23,9 +23,28 @@ CONFIG_JWT_SECRET=this is a very secret secret # - hybrid: both sources are accepted and separated by gateway_users.source. IDENTITY_MODE=hybrid +# Auth Center stable OIDC verification. Keep legacy HS256 during the staged rollout. +OIDC_ENABLED=false +OIDC_ISSUER=https://auth.51easyai.com/issuer/shared +OIDC_AUDIENCE=https://api.51easyai.com/gateway +OIDC_TENANT_ID= +OIDC_ROLE_PREFIX=gateway. +OIDC_REQUIRED_SCOPES=gateway.access +OIDC_JWKS_CACHE_TTL_SECONDS=300 +OIDC_ACCEPT_LEGACY_HS256=true +OIDC_CLIENT_ID= +OIDC_REDIRECT_URI=http://localhost:5178/auth/callback +AI_GATEWAY_WEB_BASE_PATH=/ +AI_GATEWAY_GO_BUILD_IMAGE=golang:1.26.3-alpine +AI_GATEWAY_API_RUNTIME_IMAGE=alpine:3.22 +AI_GATEWAY_NODE_BUILD_IMAGE=node:22-alpine +AI_GATEWAY_WEB_RUNTIME_IMAGE=nginx:1.27-alpine + # Used when the gateway delegates OpenAPI sk-* validation, user/group sync, file upload, and settlement callbacks. SERVER_MAIN_BASE_URL=http://localhost:3000 SERVER_MAIN_INTERNAL_TOKEN=change-me +SERVER_MAIN_INTERNAL_KEY=gateway +SERVER_MAIN_INTERNAL_SECRET=change-me # Gateway writes progress events locally, then calls this server-main endpoint. # server-main receives the callback and pushes it through the existing WebSocket gateway. diff --git a/Dockerfile b/Dockerfile index 16b8c5a..1b59cc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ -# syntax=docker/dockerfile:1.7 - ARG GO_VERSION=1.26.3 ARG NODE_VERSION=22 +ARG GO_BUILD_IMAGE=golang:${GO_VERSION}-alpine +ARG API_RUNTIME_IMAGE=alpine:3.22 +ARG NODE_BUILD_IMAGE=node:${NODE_VERSION}-alpine +ARG WEB_RUNTIME_IMAGE=nginx:1.27-alpine -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS api-builder +FROM --platform=$BUILDPLATFORM ${GO_BUILD_IMAGE} AS api-builder ARG TARGETOS=linux ARG TARGETARCH=amd64 ARG GOPROXY=https://goproxy.cn,direct @@ -32,7 +34,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/easyai-ai-gateway ./cmd/gateway && \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/easyai-ai-gateway-migrate ./cmd/migrate -FROM alpine:3.22 AS api +FROM ${API_RUNTIME_IMAGE} AS api RUN apk add --no-cache ca-certificates tzdata wget && \ adduser -D -H -u 10001 appuser @@ -54,7 +56,7 @@ ENV APP_ENV=production \ CMD ["/app/easyai-ai-gateway"] -FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS web-builder +FROM --platform=$BUILDPLATFORM ${NODE_BUILD_IMAGE} AS web-builder WORKDIR /src RUN npm install -g pnpm@10.18.1 @@ -69,10 +71,20 @@ COPY packages packages COPY apps/web apps/web ARG VITE_GATEWAY_API_BASE_URL=/gateway-api +ARG VITE_OIDC_ENABLED=false +ARG VITE_OIDC_ISSUER= +ARG VITE_OIDC_CLIENT_ID= +ARG VITE_OIDC_REDIRECT_URI= +ARG VITE_BASE_PATH=/ ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL +ENV VITE_OIDC_ENABLED=$VITE_OIDC_ENABLED \ + VITE_OIDC_ISSUER=$VITE_OIDC_ISSUER \ + VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \ + VITE_OIDC_REDIRECT_URI=$VITE_OIDC_REDIRECT_URI \ + VITE_BASE_PATH=$VITE_BASE_PATH RUN pnpm --filter @easyai-ai-gateway/web build -FROM nginx:1.27-alpine AS web +FROM ${WEB_RUNTIME_IMAGE} AS web COPY docker/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=web-builder /src/apps/web/dist /usr/share/nginx/html diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index 09877f2..c41e5f2 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -3,10 +3,16 @@ package auth import ( "bytes" "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "log/slog" "net/http" + "strconv" "strings" "time" @@ -50,11 +56,15 @@ const userContextKey contextKey = "easyai-auth-user" var ErrUnauthorized = errors.New("unauthorized") type Authenticator struct { - JWTSecret string - ServerMainBaseURL string - ServerMainInternalToken string - HTTPClient *http.Client - LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error) + JWTSecret string + ServerMainBaseURL string + ServerMainInternalToken string + ServerMainInternalKey string + ServerMainInternalSecret string + HTTPClient *http.Client + LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error) + OIDCVerifier *OIDCVerifier + LegacyJWTEnabled bool } func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator { @@ -65,6 +75,7 @@ func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Auth HTTPClient: &http.Client{ Timeout: 10 * time.Second, }, + LegacyJWTEnabled: true, } } @@ -77,6 +88,9 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, err := a.Authenticate(r) if err != nil { + if strings.HasPrefix(err.Error(), ErrUnauthorized.Error()+":") { + slog.WarnContext(r.Context(), "OIDC authentication rejected", "reason", err.Error()) + } if permission == PermissionPublic { next.ServeHTTP(w, r) return @@ -109,6 +123,16 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { if strings.HasPrefix(token, "sk-") { return a.verifyAPIKey(r.Context(), token) } + algorithm := jwtAlgorithm(token) + if algorithm == "RS256" || algorithm == "ES256" { + if a.OIDCVerifier == nil { + return nil, ErrUnauthorized + } + return a.OIDCVerifier.Verify(r.Context(), token) + } + if !a.LegacyJWTEnabled { + return nil, ErrUnauthorized + } return a.verifyJWT(token) } @@ -196,16 +220,34 @@ func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, return nil, ErrUnauthorized } } - if a.ServerMainBaseURL == "" || a.ServerMainInternalToken == "" { + secret := a.ServerMainInternalSecret + if secret == "" { + secret = a.ServerMainInternalToken + } + if a.ServerMainBaseURL == "" || a.ServerMainInternalKey == "" || secret == "" { return nil, ErrUnauthorized } body, _ := json.Marshal(map[string]string{"apiKey": apiKey}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.ServerMainBaseURL+"/internal/platform/auth/verify-api-key", bytes.NewReader(body)) + const path = "/internal/auth/verify-apikey" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.ServerMainBaseURL+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+a.ServerMainInternalToken) + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + nonceBytes := make([]byte, 16) + if _, err := rand.Read(nonceBytes); err != nil { + return nil, err + } + nonce := hex.EncodeToString(nonceBytes) + bodyHash := sha256.Sum256(body) + signingText := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:]), a.ServerMainInternalKey}, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(signingText)) + req.Header.Set("X-Internal-Key", a.ServerMainInternalKey) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) resp, err := a.HTTPClient.Do(req) if err != nil { @@ -215,10 +257,16 @@ func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, if resp.StatusCode != http.StatusOK { return nil, ErrUnauthorized } - var user User - if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + var payload struct { + Success bool `json:"success"` + Data struct { + User User `json:"user"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } + user := payload.Data.User if user.ID == "" { return nil, ErrUnauthorized } @@ -236,6 +284,15 @@ func extractBearer(value string) string { return "" } +func jwtAlgorithm(token string) string { + parsed, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) + if err != nil || parsed == nil { + return "" + } + algorithm, _ := parsed.Header["alg"].(string) + return algorithm +} + func hasPermission(roles []string, required Permission) bool { if required == PermissionPublic { return true diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go new file mode 100644 index 0000000..2ccee16 --- /dev/null +++ b/apps/api/internal/auth/oidc.go @@ -0,0 +1,330 @@ +package auth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const maxOIDCResponseBytes = 1 << 20 + +type OIDCConfig struct { + Issuer string + Audience string + TenantID string + RolePrefix string + RequiredScopes []string + JWKSCacheTTL time.Duration + HTTPClient *http.Client +} + +type OIDCVerifier struct { + config OIDCConfig + client *http.Client + mu sync.Mutex + keys map[string]any + expiresAt time.Time +} + +type discoveryDocument struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` +} + +type jsonWebKeySet struct { + Keys []jsonWebKey `json:"keys"` +} + +type jsonWebKey struct { + KID string `json:"kid"` + KTY string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { + config.Issuer = strings.TrimRight(strings.TrimSpace(config.Issuer), "/") + config.Audience = strings.TrimSpace(config.Audience) + config.TenantID = strings.TrimSpace(config.TenantID) + config.RolePrefix = strings.TrimSpace(config.RolePrefix) + if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" { + return nil, errors.New("issuer, audience, tenant and role prefix are required") + } + if config.JWKSCacheTTL <= 0 { + config.JWKSCacheTTL = 5 * time.Minute + } + client := config.HTTPClient + if client == nil { + client = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }} + } + return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil +} + +func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { + parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"})) + unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{}) + if err != nil || unverified == nil { + return nil, oidcUnauthorized("token envelope is invalid", err) + } + kid, _ := unverified.Header["kid"].(string) + if kid == "" { + return nil, oidcUnauthorized("kid is missing", nil) + } + key, err := v.key(ctx, kid) + if err != nil { + return nil, oidcUnauthorized("signing key lookup failed", err) + } + token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { + if token.Header["kid"] != kid { + return nil, ErrUnauthorized + } + return key, nil + }, jwt.WithValidMethods([]string{"RS256", "ES256"}), jwt.WithIssuer(v.config.Issuer), + jwt.WithAudience(v.config.Audience), jwt.WithExpirationRequired(), jwt.WithLeeway(30*time.Second)) + if err != nil || !token.Valid { + return nil, oidcUnauthorized("signature or registered claims are invalid", err) + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || stringClaim(claims, "sub") == "" || stringClaim(claims, "tid") != v.config.TenantID { + return nil, oidcUnauthorized("stable identity claims are invalid", nil) + } + if _, ok := numericDateClaim(claims["nbf"]); !ok { + return nil, oidcUnauthorized("nbf is missing", nil) + } + scopes := scopeClaims(claims) + if !containsAll(scopes, v.config.RequiredScopes) { + return nil, oidcUnauthorized("required scope is missing", nil) + } + roles := gatewayRoles(stringSliceClaim(claims, "roles"), v.config.RolePrefix) + if len(roles) == 0 { + roles = gatewayRoles(stringSliceClaim(claims, "role"), v.config.RolePrefix) + } + if len(roles) == 0 { + return nil, oidcUnauthorized("mapped role is missing", nil) + } + username := stringClaim(claims, "preferred_username") + if username == "" { + username = stringClaim(claims, "username") + } + return &User{ + ID: stringClaim(claims, "sub"), Username: username, Roles: roles, + TenantID: v.config.TenantID, Source: "oidc", + }, nil +} + +func oidcUnauthorized(reason string, cause error) error { + if cause != nil { + return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause) + } + return fmt.Errorf("%w: %s", ErrUnauthorized, reason) +} + +func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) { + if err := v.refresh(ctx, false); err != nil { + return nil, err + } + v.mu.Lock() + key, ok := v.keys[kid] + v.mu.Unlock() + if ok { + return key, nil + } + if err := v.refresh(ctx, true); err != nil { + return nil, err + } + v.mu.Lock() + defer v.mu.Unlock() + key, ok = v.keys[kid] + if !ok { + return nil, errors.New("signing key not found") + } + return key, nil +} + +func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error { + v.mu.Lock() + defer v.mu.Unlock() + if !force && len(v.keys) > 0 && time.Now().Before(v.expiresAt) { + return nil + } + discoveryURL := v.config.Issuer + "/.well-known/openid-configuration" + var discovery discoveryDocument + if err := v.fetchJSON(ctx, discoveryURL, &discovery); err != nil { + return err + } + if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil { + return errors.New("OIDC discovery metadata is invalid") + } + var set jsonWebKeySet + if err := v.fetchJSON(ctx, discovery.JWKSURI, &set); err != nil { + return err + } + keys := make(map[string]any, len(set.Keys)) + for _, item := range set.Keys { + if item.KID == "" || item.Use != "" && item.Use != "sig" { + continue + } + key, err := item.publicKey() + if err == nil { + keys[item.KID] = key + } + } + if len(keys) == 0 { + return errors.New("OIDC JWKS contains no supported signing keys") + } + v.keys = keys + v.expiresAt = time.Now().Add(v.config.JWKSCacheTTL) + return nil +} + +func (v *OIDCVerifier) fetchJSON(ctx context.Context, endpoint string, output any) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + request.Header.Set("Accept", "application/json") + response, err := v.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("OIDC endpoint returned HTTP %d", response.StatusCode) + } + payload, err := io.ReadAll(io.LimitReader(response.Body, maxOIDCResponseBytes+1)) + if err != nil || len(payload) > maxOIDCResponseBytes { + return errors.New("OIDC response is invalid") + } + decoder := json.NewDecoder(strings.NewReader(string(payload))) + return decoder.Decode(output) +} + +func (j jsonWebKey) publicKey() (any, error) { + switch { + case j.KTY == "RSA" && (j.Alg == "" || j.Alg == "RS256"): + n, err := decodeBigInt(j.N) + if err != nil { + return nil, err + } + e, err := decodeBigInt(j.E) + if err != nil || !e.IsInt64() || e.Int64() <= 0 { + return nil, errors.New("invalid RSA exponent") + } + return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil + case j.KTY == "EC" && j.Crv == "P-256" && (j.Alg == "" || j.Alg == "ES256"): + x, err := decodeBigInt(j.X) + if err != nil { + return nil, err + } + y, err := decodeBigInt(j.Y) + if err != nil || !elliptic.P256().IsOnCurve(x, y) { + return nil, errors.New("invalid EC point") + } + return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, nil + default: + return nil, errors.New("unsupported JWK") + } +} + +func decodeBigInt(value string) (*big.Int, error) { + payload, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(payload) == 0 { + return nil, errors.New("invalid JWK integer") + } + return new(big.Int).SetBytes(payload), nil +} + +func validatePublicURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("invalid URL") + } + if parsed.Scheme == "https" { + return nil + } + if parsed.Scheme == "http" && (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "localhost") { + return nil + } + return errors.New("URL must use HTTPS") +} + +func numericDateClaim(value any) (time.Time, bool) { + switch typed := value.(type) { + case float64: + return time.Unix(int64(typed), 0), true + case json.Number: + seconds, err := typed.Int64() + return time.Unix(seconds, 0), err == nil + default: + return time.Time{}, false + } +} + +func scopeClaims(claims jwt.MapClaims) []string { + result := make([]string, 0) + seen := map[string]bool{} + for _, scope := range strings.Fields(stringClaim(claims, "scope")) { + if !seen[scope] { + result = append(result, scope) + seen[scope] = true + } + } + for _, scope := range stringSliceClaim(claims, "scp") { + if !seen[scope] { + result = append(result, scope) + seen[scope] = true + } + } + return result +} + +func containsAll(actual, required []string) bool { + set := make(map[string]struct{}, len(actual)) + for _, item := range actual { + set[item] = struct{}{} + } + for _, item := range required { + if _, ok := set[item]; !ok { + return false + } + } + return true +} + +func gatewayRoles(external []string, prefix string) []string { + allowed := map[string]bool{"user": true, "creator": true, "operator": true, "manager": true, "admin": true} + roles := make([]string, 0, len(external)) + seen := map[string]bool{} + for _, role := range external { + if !strings.HasPrefix(role, prefix) { + continue + } + local := strings.TrimPrefix(role, prefix) + if allowed[local] && !seen[local] { + roles = append(roles, local) + seen[local] = true + } + } + return roles +} diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go new file mode 100644 index 0000000..d218921 --- /dev/null +++ b/apps/api/internal/auth/oidc_test.go @@ -0,0 +1,142 @@ +package auth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"}) + case "/jwks": + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ + rsaJWK("rsa-key", &rsaKey.PublicKey), ecJWK("ec-key", &ecKey.PublicKey), + }}) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + issuer = server.URL + verifier, err := NewOIDCVerifier(OIDCConfig{ + Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", + RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + kid string + method jwt.SigningMethod + key any + }{{"rs256", "rsa-key", jwt.SigningMethodRS256, rsaKey}, {"es256", "ec-key", jwt.SigningMethodES256, ecKey}} { + t.Run(test.name, func(t *testing.T) { + token := signedOIDCToken(t, issuer, test.kid, test.method, test.key, nil) + user, err := verifier.Verify(context.Background(), token) + if err != nil { + t.Fatal(err) + } + if user.ID != "platform-subject" || user.TenantID != "tenant-1" || user.Source != "oidc" || + len(user.Roles) != 1 || user.Roles[0] != "admin" { + t.Fatalf("unexpected OIDC user: %#v", user) + } + }) + } +} + +func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/.well-known/openid-configuration" { + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}}) + })) + defer server.Close() + issuer = server.URL + verifier, _ := NewOIDCVerifier(OIDCConfig{ + Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", + RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), + }) + tests := []struct { + name string + mutate func(jwt.MapClaims) + }{ + {"missing nbf", func(claims jwt.MapClaims) { delete(claims, "nbf") }}, + {"wrong audience", func(claims jwt.MapClaims) { claims["aud"] = "other-api" }}, + {"wrong tenant", func(claims jwt.MapClaims) { claims["tid"] = "tenant-2" }}, + {"missing scope", func(claims jwt.MapClaims) { claims["scope"] = "openid" }}, + {"unmapped role", func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, test.mutate) + if _, err := verifier.Verify(context.Background(), raw); err == nil { + t.Fatal("expected token to be rejected") + } + }) + } +} + +func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string { + t.Helper() + now := time.Now() + claims := jwt.MapClaims{ + "iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1", + "preferred_username": "acceptance", "roles": []string{"gateway.admin"}, + "scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), + "exp": now.Add(time.Hour).Unix(), + } + if mutate != nil { + mutate(claims) + } + token := jwt.NewWithClaims(method, claims) + token.Header["kid"] = kid + raw, err := token.SignedString(key) + if err != nil { + t.Fatal(err) + } + return raw +} + +func rsaJWK(kid string, key *rsa.PublicKey) map[string]any { + return map[string]any{ + "kid": kid, "kty": "RSA", "use": "sig", "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + } +} + +func ecJWK(kid string, key *ecdsa.PublicKey) map[string]any { + return map[string]any{ + "kid": kid, "kty": "EC", "use": "sig", "alg": "ES256", "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(key.X.FillBytes(make([]byte, 32))), + "y": base64.RawURLEncoding.EncodeToString(key.Y.FillBytes(make([]byte, 32))), + } +} diff --git a/apps/api/internal/auth/servermain_test.go b/apps/api/internal/auth/servermain_test.go new file mode 100644 index 0000000..d67afb0 --- /dev/null +++ b/apps/api/internal/auth/servermain_test.go @@ -0,0 +1,50 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestServerMainAPIKeyUsesSignedCurrentContract(t *testing.T) { + const key = "gateway-test" + const secret = "server-main-internal-secret" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/internal/auth/verify-apikey" || request.Header.Get("X-Internal-Key") != key { + t.Fatalf("unexpected request %s", request.URL.Path) + } + body, _ := io.ReadAll(request.Body) + hash := sha256.Sum256(body) + text := strings.Join([]string{ + request.Method, request.URL.RequestURI(), request.Header.Get("X-Timestamp"), request.Header.Get("X-Nonce"), + hex.EncodeToString(hash[:]), key, + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(text)) + if !hmac.Equal([]byte(request.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) { + t.Fatal("invalid internal request signature") + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"user": map[string]any{ + "sub": "server-user", "username": "server", "role": []string{"user"}, "tenantId": "server-tenant", + }}}) + })) + t.Cleanup(server.Close) + authenticator := New("jwt", server.URL, "") + authenticator.ServerMainInternalKey = key + authenticator.ServerMainInternalSecret = secret + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + request.Header.Set("Authorization", "Bearer sk-server-main") + user, err := authenticator.Authenticate(request) + if err != nil { + t.Fatal(err) + } + if user.ID != "server-user" || user.Source != "server-main" || user.TenantID != "server-tenant" { + t.Fatalf("user = %#v", user) + } +} diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 11e4248..23e14b2 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -21,6 +21,16 @@ type Config struct { JWTSecret string ServerMainBaseURL string ServerMainInternalToken string + ServerMainInternalKey string + ServerMainInternalSecret string + OIDCEnabled bool + OIDCIssuer string + OIDCAudience string + OIDCTenantID string + OIDCRolePrefix string + OIDCRequiredScopes []string + OIDCJWKSCacheTTLSeconds int + OIDCAcceptLegacyHS256 bool PublicBaseURL string WebBaseURL string LocalGeneratedStorageDir string @@ -49,6 +59,16 @@ func Load() Config { "/", ), ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), + ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), + ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), + OIDCEnabled: env("OIDC_ENABLED", "false") == "true", + OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"), + OIDCAudience: env("OIDC_AUDIENCE", ""), + OIDCTenantID: env("OIDC_TENANT_ID", ""), + OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), + OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), + OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), + OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), @@ -121,6 +141,17 @@ func normalizePostgresURL(raw string) string { return parsed.String() } +func splitCSV(value string) []string { + items := strings.Split(value, ",") + result := make([]string, 0, len(items)) + for _, item := range items { + if trimmed := strings.TrimSpace(item); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + func withDatabase(raw string, databaseName string) string { parsed, err := url.Parse(raw) if err != nil || databaseName == "" { diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 922c750..ac8dfd5 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" "sync" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" @@ -36,6 +37,20 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor runner: runner.New(cfg, db, logger), logger: logger, } + server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256 + server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey + server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret + if cfg.OIDCEnabled { + verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ + Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, + RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes, + JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, + }) + if err != nil { + panic("invalid OIDC configuration: " + err.Error()) + } + server.auth.OIDCVerifier = verifier + } server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey server.runner.StartAsyncQueueWorker(ctx) server.startLocalTempAssetCleanup(ctx) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0dc778e..d9fc3e2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -111,6 +111,7 @@ import { useCatalogOperations } from './hooks/useCatalogOperations'; import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations'; import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations'; import { persistAccessToken, readStoredAccessToken } from './lib/auth-storage'; +import { completeOIDCLogin, oidcLoginEnabled, startOIDCLogin, startOIDCLogout } from './lib/oidc'; import { runTask } from './lib/run-task'; import { AdminPage } from './pages/AdminPage'; import { ApiDocsPage } from './pages/ApiDocsPage'; @@ -270,6 +271,18 @@ export function App() { currentTaskQueryKeyRef.current = taskListRequestKey; currentTransactionQueryKeyRef.current = transactionListRequestKey; + useEffect(() => { + void completeOIDCLogin() + .then((result) => { + if (!result) return; + persistAccessToken(result.accessToken); + setToken(result.accessToken); + }) + .catch((err) => { + setState('error'); + setError(err instanceof Error ? err.message : '统一认证登录失败'); + }); + }, []); useEffect(() => { void ensureData(['health']); }, []); @@ -1089,11 +1102,21 @@ export function App() { return true; } - function signOut() { + async function signOut() { resetAuthenticatedSession(); + if (await startOIDCLogout()) return; navigatePath('/'); } + function loginWithOIDC() { + setState('loading'); + setError(''); + void startOIDCLogin().catch((err) => { + setState('error'); + setError(err instanceof Error ? err.message : '统一认证登录失败'); + }); + } + function showLogin() { setAuthMode('login'); navigatePath(pathForWorkspaceSection('overview')); @@ -1222,6 +1245,8 @@ export function App() { onSubmitExternalToken={submitExternalToken} onSubmitLogin={submitLogin} onSubmitRegister={submitRegister} + oidcEnabled={oidcLoginEnabled()} + onOIDCLogin={loginWithOIDC} /> ) )} @@ -1280,6 +1305,8 @@ export function App() { onSubmitExternalToken={submitExternalToken} onSubmitLogin={submitLogin} onSubmitRegister={submitRegister} + oidcEnabled={oidcLoginEnabled()} + onOIDCLogin={loginWithOIDC} /> ) )} diff --git a/apps/web/src/components/AuthPanel.tsx b/apps/web/src/components/AuthPanel.tsx index 4a3b6fd..1440d99 100644 --- a/apps/web/src/components/AuthPanel.tsx +++ b/apps/web/src/components/AuthPanel.tsx @@ -22,6 +22,8 @@ export function AuthPanel(props: { onSubmitExternalToken: (event: FormEvent) => void; onSubmitLogin: (event: FormEvent) => void; onSubmitRegister: (event: FormEvent) => void; + oidcEnabled: boolean; + onOIDCLogin: () => void; }) { return (
@@ -33,6 +35,12 @@ export function AuthPanel(props: { + {props.oidcEnabled && ( + + )} {props.authMode === 'login' && } {props.authMode === 'register' && } diff --git a/apps/web/src/lib/oidc.ts b/apps/web/src/lib/oidc.ts new file mode 100644 index 0000000..a7aa00b --- /dev/null +++ b/apps/web/src/lib/oidc.ts @@ -0,0 +1,124 @@ +const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true'; +const issuer = (import.meta.env.VITE_OIDC_ISSUER ?? '').replace(/\/$/, ''); +const clientId = import.meta.env.VITE_OIDC_CLIENT_ID ?? ''; +const configuredRedirect = import.meta.env.VITE_OIDC_REDIRECT_URI ?? ''; +const transactionKey = 'easyai.gateway.oidc.transaction'; +const idTokenKey = 'easyai.gateway.oidc.id-token'; + +interface Discovery { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + end_session_endpoint?: string; +} + +interface Transaction { + state: string; + nonce: string; + verifier: string; + returnTo: string; + createdAt: number; +} + +export function oidcLoginEnabled() { + return enabled && Boolean(issuer && clientId); +} + +export async function startOIDCLogin() { + assertConfigured(); + const discovery = await getDiscovery(); + const verifier = randomValue(48); + const transaction: Transaction = { + state: randomValue(24), + nonce: randomValue(24), + verifier, + returnTo: window.location.pathname, + createdAt: Date.now(), + }; + window.sessionStorage.setItem(transactionKey, JSON.stringify(transaction)); + const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); + const params = new URLSearchParams({ + response_type: 'code', client_id: clientId, redirect_uri: redirectURI(), + scope: 'openid profile', state: transaction.state, nonce: transaction.nonce, + code_challenge: challenge, code_challenge_method: 'S256', + }); + window.location.assign(`${discovery.authorization_endpoint}?${params}`); +} + +export async function completeOIDCLogin(): Promise<{ accessToken: string; returnTo: string } | null> { + if (!oidcLoginEnabled()) return null; + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + if (!code && !state) return null; + if (!code || !state || params.get('error')) throw new Error('统一认证回调不完整'); + const transaction = readTransaction(); + window.sessionStorage.removeItem(transactionKey); + if (!transaction || transaction.state !== state || Date.now() - transaction.createdAt > 10 * 60_000) { + throw new Error('统一认证登录事务已失效'); + } + const discovery = await getDiscovery(); + const response = await fetch(discovery.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', client_id: clientId, redirect_uri: redirectURI(), + code, code_verifier: transaction.verifier, + }), + }); + if (!response.ok) throw new Error('统一认证 Token 交换失败'); + const payload = await response.json() as { access_token?: string; id_token?: string }; + if (!payload.access_token) throw new Error('统一认证未返回 Access Token'); + if (payload.id_token) window.sessionStorage.setItem(idTokenKey, payload.id_token); + window.history.replaceState({}, '', transaction.returnTo || '/'); + return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' }; +} + +export async function startOIDCLogout() { + if (!oidcLoginEnabled()) return false; + const idToken = window.sessionStorage.getItem(idTokenKey); + window.sessionStorage.removeItem(idTokenKey); + if (!idToken) return false; + const discovery = await getDiscovery(); + if (!discovery.end_session_endpoint) return false; + const params = new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: window.location.origin + '/' }); + window.location.assign(`${discovery.end_session_endpoint}?${params}`); + return true; +} + +function assertConfigured() { + if (!oidcLoginEnabled()) throw new Error('统一认证未配置'); +} + +async function getDiscovery(): Promise { + const response = await fetch(`${issuer}/.well-known/openid-configuration`, { headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error('统一认证 Discovery 不可用'); + const value = await response.json() as Discovery; + if (value.issuer !== issuer || !value.authorization_endpoint || !value.token_endpoint) { + throw new Error('统一认证 Discovery 不符合预期'); + } + return value; +} + +function redirectURI() { + return configuredRedirect || `${window.location.origin}/auth/callback`; +} + +function readTransaction(): Transaction | null { + try { + return JSON.parse(window.sessionStorage.getItem(transactionKey) ?? 'null') as Transaction | null; + } catch { + return null; + } +} + +function randomValue(bytes: number) { + const value = new Uint8Array(bytes); + crypto.getRandomValues(value); + return base64url(value.buffer); +} + +function base64url(value: ArrayBuffer) { + return btoa(String.fromCharCode(...new Uint8Array(value))) + .replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d800fd3..f5ed735 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,10 +1,12 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; -export default defineConfig({ - plugins: [tailwindcss(), react()], - server: { - port: 5178, - }, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), 'VITE_'); + return { + base: env.VITE_BASE_PATH || '/', + plugins: [tailwindcss(), react()], + server: { port: 5178 }, + }; }); diff --git a/docker-compose.yml b/docker-compose.yml index db4ad27..aac8ce2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,18 @@ x-api-environment: &api-environment AI_GATEWAY_DATABASE_URL: ${AI_GATEWAY_COMPOSE_DATABASE_URL:-postgresql://easyai:easyai2025@postgres:5432/easyai_ai_gateway?sslmode=disable} CONFIG_JWT_SECRET: ${CONFIG_JWT_SECRET:-this is a very secret secret} IDENTITY_MODE: ${AI_GATEWAY_COMPOSE_IDENTITY_MODE:-hybrid} + OIDC_ENABLED: ${OIDC_ENABLED:-false} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_AUDIENCE: ${OIDC_AUDIENCE:-} + OIDC_TENANT_ID: ${OIDC_TENANT_ID:-} + OIDC_ROLE_PREFIX: ${OIDC_ROLE_PREFIX:-gateway.} + OIDC_REQUIRED_SCOPES: ${OIDC_REQUIRED_SCOPES:-gateway.access} + OIDC_JWKS_CACHE_TTL_SECONDS: ${OIDC_JWKS_CACHE_TTL_SECONDS:-300} + OIDC_ACCEPT_LEGACY_HS256: ${OIDC_ACCEPT_LEGACY_HS256:-true} SERVER_MAIN_BASE_URL: ${AI_GATEWAY_COMPOSE_SERVER_MAIN_BASE_URL:-http://host.docker.internal:3000} SERVER_MAIN_INTERNAL_TOKEN: ${SERVER_MAIN_INTERNAL_TOKEN:-change-me} + SERVER_MAIN_INTERNAL_KEY: ${SERVER_MAIN_INTERNAL_KEY:-gateway} + SERVER_MAIN_INTERNAL_SECRET: ${SERVER_MAIN_INTERNAL_SECRET:-${SERVER_MAIN_INTERNAL_TOKEN:-change-me}} TASK_PROGRESS_CALLBACK_ENABLED: ${AI_GATEWAY_COMPOSE_TASK_PROGRESS_CALLBACK_ENABLED:-false} TASK_PROGRESS_CALLBACK_URL: ${AI_GATEWAY_COMPOSE_TASK_PROGRESS_CALLBACK_URL:-http://host.docker.internal:3000/internal/platform/task-progress-callbacks} TASK_PROGRESS_CALLBACK_TIMEOUT_MS: ${TASK_PROGRESS_CALLBACK_TIMEOUT_MS:-5000} @@ -46,6 +56,8 @@ services: target: api args: GOPROXY: ${AI_GATEWAY_GO_PROXY:-https://goproxy.cn,direct} + GO_BUILD_IMAGE: ${AI_GATEWAY_GO_BUILD_IMAGE:-golang:1.26.3-alpine} + API_RUNTIME_IMAGE: ${AI_GATEWAY_API_RUNTIME_IMAGE:-alpine:3.22} command: ["/app/easyai-ai-gateway-migrate"] environment: *api-environment extra_hosts: @@ -64,6 +76,8 @@ services: target: api args: GOPROXY: ${AI_GATEWAY_GO_PROXY:-https://goproxy.cn,direct} + GO_BUILD_IMAGE: ${AI_GATEWAY_GO_BUILD_IMAGE:-golang:1.26.3-alpine} + API_RUNTIME_IMAGE: ${AI_GATEWAY_API_RUNTIME_IMAGE:-alpine:3.22} environment: *api-environment ports: - "${AI_GATEWAY_API_PORT:-8088}:8088" @@ -91,6 +105,13 @@ services: target: web args: VITE_GATEWAY_API_BASE_URL: ${AI_GATEWAY_WEB_API_BASE_URL:-/gateway-api} + NODE_BUILD_IMAGE: ${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine} + WEB_RUNTIME_IMAGE: ${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine} + VITE_OIDC_ENABLED: ${OIDC_ENABLED:-false} + VITE_OIDC_ISSUER: ${OIDC_ISSUER:-} + VITE_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + VITE_OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} + VITE_BASE_PATH: ${AI_GATEWAY_WEB_BASE_PATH:-/} ports: - "${AI_GATEWAY_WEB_PORT:-5178}:80" volumes: