easyai-ai-gateway/apps/api/internal/auth/oidc.go

383 lines
11 KiB
Go

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
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
HTTPClient *http.Client
}
type OIDCVerifier struct {
config OIDCConfig
client *http.Client
mu sync.Mutex
keys map[string]any
expiresAt time.Time
introspectionEndpoint string
}
type discoveryDocument struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
}
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.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
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)
}
if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
if err != nil || !active {
return nil, oidcUnauthorized("token is inactive", err)
}
}
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")
}
if v.config.IntrospectionEnabled && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
return errors.New("OIDC introspection 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)
v.introspectionEndpoint = discovery.IntrospectionEndpoint
return nil
}
func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) {
v.mu.Lock()
endpoint := v.introspectionEndpoint
v.mu.Unlock()
if endpoint == "" {
return false, errors.New("OIDC introspection endpoint is unavailable")
}
form := url.Values{"token": {raw}, "token_type_hint": {"access_token"}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return false, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret)
response, err := v.client.Do(request)
if err != nil {
return false, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOIDCResponseBytes))
return false, fmt.Errorf("OIDC introspection returned HTTP %d", response.StatusCode)
}
var result struct {
Active bool `json:"active"`
}
decoder := json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes))
if err := decoder.Decode(&result); err != nil {
return false, errors.New("OIDC introspection response is invalid")
}
return result.Active, 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
}