383 lines
11 KiB
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
|
|
}
|