AuthClient dev-console + AuthServer deviceFlow via k8s/auth-dev.yaml; otel server accepts its own URL as an extra audience (OTEL_OIDC_AUDIENCE). Vite dev-only device-flow sign-in, scripts/dev-auth.mjs helper, AGENTS.md notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
386 lines
9.8 KiB
Go
386 lines
9.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/big"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
// UserKey is the context key for authenticated user claims.
|
|
const UserKey contextKey = "user"
|
|
|
|
// Audience handles either a single string or an array of strings in JWT audience claim.
|
|
type Audience []string
|
|
|
|
func (a *Audience) UnmarshalJSON(b []byte) error {
|
|
if len(b) > 0 && b[0] == '"' {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return err
|
|
}
|
|
*a = Audience{s}
|
|
return nil
|
|
}
|
|
return json.Unmarshal(b, (*[]string)(a))
|
|
}
|
|
|
|
func (a Audience) Contains(s string) bool {
|
|
for _, v := range a {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IDClaims represents verified claims in an OIDC token.
|
|
type IDClaims struct {
|
|
Iss string `json:"iss"`
|
|
Sub string `json:"sub"`
|
|
Aud Audience `json:"aud"`
|
|
Email string `json:"email,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Roles []string `json:"roles,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
|
|
type jwksDoc struct {
|
|
Keys []struct {
|
|
Kid string `json:"kid"`
|
|
Kty string `json:"kty"`
|
|
Alg string `json:"alg"`
|
|
N string `json:"n"`
|
|
E string `json:"e"`
|
|
} `json:"keys"`
|
|
}
|
|
|
|
// OidcAuth provides OIDC token verification and authentication middleware.
|
|
type OidcAuth struct {
|
|
issuer string
|
|
client string
|
|
disabled bool
|
|
ingestKey string
|
|
agentToken string
|
|
// audience is an optional extra accepted token audience: this service's
|
|
// own URL, which the issuer emits via AuthClient.spec.audience (e.g. for
|
|
// device-grant dev tokens minted for another client).
|
|
audience string
|
|
|
|
mu sync.Mutex
|
|
keys map[string]*rsa.PublicKey
|
|
fetched time.Time
|
|
}
|
|
|
|
// NewOidcAuth constructs a new OidcAuth instance.
|
|
func NewOidcAuth(ingestKey string) *OidcAuth {
|
|
issuer := os.Getenv("OTEL_OIDC_ISSUER")
|
|
if issuer == "" {
|
|
issuer = "https://users.k6n.net"
|
|
}
|
|
client := os.Getenv("OTEL_OIDC_CLIENT_ID")
|
|
if client == "" {
|
|
client = "otel-debug"
|
|
}
|
|
disabled := os.Getenv("OTEL_AUTH_DISABLED") == "true"
|
|
|
|
return &OidcAuth{
|
|
issuer: strings.TrimSuffix(issuer, "/"),
|
|
client: client,
|
|
disabled: disabled,
|
|
ingestKey: ingestKey,
|
|
agentToken: os.Getenv("OTEL_AGENT_TOKEN"),
|
|
audience: os.Getenv("OTEL_OIDC_AUDIENCE"),
|
|
}
|
|
}
|
|
|
|
func (a *OidcAuth) Issuer() string {
|
|
return a.issuer
|
|
}
|
|
|
|
func (a *OidcAuth) ClientID() string {
|
|
return a.client
|
|
}
|
|
|
|
func (a *OidcAuth) IsDisabled() bool {
|
|
return a.disabled
|
|
}
|
|
|
|
func (a *OidcAuth) fetchKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
|
|
url := a.issuer + "/oidc/v1/keys"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("jwks endpoint returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var doc jwksDoc
|
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keys := make(map[string]*rsa.PublicKey, len(doc.Keys))
|
|
for _, k := range doc.Keys {
|
|
if k.Kty != "RSA" {
|
|
continue
|
|
}
|
|
n, err := base64.RawURLEncoding.DecodeString(k.N)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
|
|
if err != nil || len(eBytes) > 4 {
|
|
continue
|
|
}
|
|
var eInt uint32
|
|
for _, b := range eBytes {
|
|
eInt = eInt<<8 | uint32(b)
|
|
}
|
|
keys[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(eInt)}
|
|
}
|
|
|
|
if len(keys) == 0 {
|
|
return nil, errors.New("jwks contained no RSA keys")
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func (a *OidcAuth) publicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
if key, ok := a.keys[kid]; ok {
|
|
return key, nil
|
|
}
|
|
|
|
// Throttle refreshes to at most once per minute
|
|
if a.keys == nil || time.Since(a.fetched) > time.Minute {
|
|
keys, err := a.fetchKeys(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.keys = keys
|
|
a.fetched = time.Now()
|
|
}
|
|
|
|
key, ok := a.keys[kid]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown key id %q", kid)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// VerifyToken verifies an RS256 JWT against the cached JWKS public keys.
|
|
func (a *OidcAuth) VerifyToken(ctx context.Context, token string) (*IDClaims, error) {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return nil, errors.New("malformed token")
|
|
}
|
|
|
|
var header struct {
|
|
Kid string `json:"kid"`
|
|
Alg string `json:"alg"`
|
|
}
|
|
h, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode header: %w", err)
|
|
}
|
|
if err := json.Unmarshal(h, &header); err != nil {
|
|
return nil, fmt.Errorf("parse header: %w", err)
|
|
}
|
|
if header.Alg != "RS256" {
|
|
return nil, fmt.Errorf("unexpected alg %q", header.Alg)
|
|
}
|
|
|
|
key, err := a.publicKey(ctx, header.Kid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode signature: %w", err)
|
|
}
|
|
|
|
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
|
if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], sig); err != nil {
|
|
return nil, errors.New("invalid signature")
|
|
}
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode claims: %w", err)
|
|
}
|
|
|
|
var claims IDClaims
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return nil, fmt.Errorf("parse claims: %w", err)
|
|
}
|
|
|
|
if claims.Iss != a.issuer {
|
|
return nil, fmt.Errorf("unexpected issuer %q", claims.Iss)
|
|
}
|
|
if !claims.Aud.Contains(a.client) && (a.audience == "" || !claims.Aud.Contains(a.audience)) {
|
|
return nil, errors.New("token not issued for this client")
|
|
}
|
|
if claims.Exp != 0 && time.Now().Unix() > claims.Exp {
|
|
return nil, errors.New("token expired")
|
|
}
|
|
|
|
return &claims, nil
|
|
}
|
|
|
|
// HandleTokenExchange proxies the OIDC token exchange endpoint so the SPA
|
|
// doesn't face CORS issues.
|
|
func (a *OidcAuth) HandleTokenExchange(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Always ensure client_id is set
|
|
if r.PostForm.Get("client_id") == "" {
|
|
r.PostForm.Set("client_id", a.client)
|
|
}
|
|
|
|
resp, err := http.PostForm(a.issuer+"/oauth/v2/token", r.PostForm)
|
|
if err != nil {
|
|
log.Printf("token endpoint unreachable: %v", err)
|
|
http.Error(w, "token endpoint unreachable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}
|
|
|
|
// HandleConfig returns OIDC client configuration for the SPA frontend.
|
|
func (a *OidcAuth) HandleConfig(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"issuer": a.issuer,
|
|
"clientId": a.client,
|
|
"disabled": a.disabled,
|
|
})
|
|
}
|
|
|
|
// RequireAuth wraps an HTTP handler and ensures valid authentication.
|
|
func (a *OidcAuth) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if a.disabled {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// 1. Allow if upstream proxy (ingress) already verified via auth-url
|
|
if authUser := r.Header.Get("X-Auth-User"); authUser != "" {
|
|
ctx := context.WithValue(r.Context(), UserKey, &IDClaims{Sub: authUser, Email: authUser})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// 2. Allow if valid ingest key is provided (for background automation / agents)
|
|
if a.ingestKey != "" {
|
|
k := r.Header.Get("X-Otel-Ingest-Key")
|
|
if k == "" {
|
|
k = r.URL.Query().Get("key")
|
|
}
|
|
if k != "" && k == a.ingestKey {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Service-to-service credential for debugging agents. It is separate
|
|
// from the ingest key because it grants access to protected debug data.
|
|
if a.agentToken != "" && constantTimeTokenMatch(r, a.agentToken) {
|
|
ctx := context.WithValue(r.Context(), UserKey, &IDClaims{Sub: "service-agent", Name: "debug agent"})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// 3. Check Bearer token (or access_token query param for WebSocket)
|
|
token := bearerToken(r)
|
|
if token != "" {
|
|
claims, err := a.VerifyToken(r.Context(), token)
|
|
if err == nil {
|
|
ctx := context.WithValue(r.Context(), UserKey, claims)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "invalid token: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 4. Unauthorized
|
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="%s"`, a.client))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "authentication required",
|
|
})
|
|
})
|
|
}
|
|
|
|
func constantTimeTokenMatch(r *http.Request, expected string) bool {
|
|
token := r.Header.Get("X-Otel-Agent-Token")
|
|
if token == "" {
|
|
auth := r.Header.Get("Authorization")
|
|
if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") {
|
|
token = strings.TrimSpace(auth[7:])
|
|
}
|
|
}
|
|
return len(token) == len(expected) && subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
|
|
}
|
|
|
|
func bearerToken(r *http.Request) string {
|
|
if t := r.URL.Query().Get("access_token"); t != "" {
|
|
return t
|
|
}
|
|
auth := r.Header.Get("Authorization")
|
|
if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") {
|
|
return strings.TrimSpace(auth[7:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// CurrentUser retrieves the authenticated user claims from the context, if present.
|
|
func CurrentUser(ctx context.Context) *IDClaims {
|
|
claims, _ := ctx.Value(UserKey).(*IDClaims)
|
|
return claims
|
|
}
|