feat: add stable OIDC authentication
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user