package httpapi import ( "context" "net/http" "strings" "evobgp/internal/authkey" "github.com/golang-jwt/jwt/v5" ) type ctxKey int const authCtxKey ctxKey = 1 // Auth kinds distinguish API key sessions from portal JWT sessions. const ( AuthKindAPIKey = "apikey" AuthKindJWT = "jwt" ) // Auth holds resolved API identity for a request. type Auth struct { TenantID string Role string // viewer, editor, operator, node, firewall (apikeys only) Token string APIKeyID string // non-empty for DB-managed keys // Portal / dual-auth fields (empty for API keys unless noted). Kind string // "apikey" | "jwt" UserID string // JWT sub Email string // JWT email claim Permissions []string // JWT permissions claim (bgp:*) IsAdmin bool // JWT is_admin claim } func authFromContext(ctx context.Context) (Auth, bool) { a, ok := ctx.Value(authCtxKey).(Auth) return a, ok } type apiKeyRecord struct { token string tenantID string role string keyID string // set for DB-managed keys (last_used_at) } func parseAPIKeysSpec(spec string) []apiKeyRecord { spec = strings.TrimSpace(spec) if spec == "" { return nil } var out []apiKeyRecord for _, part := range strings.Split(spec, ",") { part = strings.TrimSpace(part) if part == "" { continue } fields := strings.Split(part, "|") if len(fields) != 3 { continue } out = append(out, apiKeyRecord{ token: strings.TrimSpace(fields[0]), tenantID: strings.TrimSpace(fields[1]), role: strings.TrimSpace(fields[2]), }) } return out } // looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty). func looksLikeJWT(raw string) bool { if raw == "" { return false } parts := strings.Split(raw, ".") if len(parts) != 3 { return false } for _, p := range parts { if strings.TrimSpace(p) == "" { return false } } return true } func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") const p = "Bearer " if !strings.HasPrefix(h, p) { writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing or invalid bearer token") return } raw := strings.TrimSpace(strings.TrimPrefix(h, p)) if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" { a, status, msg, ok := s.resolveJWT(raw) if !ok { writeProblem(w, status, http.StatusText(status), msg) return } r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) next.ServeHTTP(w, r) return } a, ok := s.resolveAuth(raw) if !ok { writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key") return } if a.APIKeyID != "" { go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID) } r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) next.ServeHTTP(w, r) }) } func authFromKeyRecord(raw string, rec apiKeyRecord) Auth { return Auth{ Kind: AuthKindAPIKey, TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID, } } // resolveAuth maps a bearer token to tenant identity (API key path). // For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed // is available; env/DB mapping is used only when demo tenant is absent. func (s *Server) resolveAuth(raw string) (Auth, bool) { if raw == "dev" { if a, ok := s.devAuth(); ok { return a, true } if rec, ok := s.keyResolver.Lookup(raw); ok { return authFromKeyRecord(raw, rec), true } return Auth{}, false } rec, ok := s.keyResolver.Lookup(raw) if !ok { if s.firewallResolver != nil { if fw, ok := s.firewallResolver.Lookup(raw); ok { return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true } } if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil { return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true } return Auth{}, false } return authFromKeyRecord(raw, rec), true } // resolveJWT parses and validates a portal HS256 token, returning an Auth on success. // Returns (auth, status, detail, ok). status/detail are used when ok=false. func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) { if strings.TrimSpace(s.portalTenantID) == "" { return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false } tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(s.jwtSecret), nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) if err != nil || tok == nil || !tok.Valid { return Auth{}, http.StatusUnauthorized, "invalid jwt", false } claims, ok := tok.Claims.(jwt.MapClaims) if !ok { return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false } if iss := strings.TrimSpace(s.authIssuer); iss != "" { got, _ := claims["iss"].(string) if strings.TrimSpace(got) != iss { return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false } } apps := coerceStringSlice(claims["apps"]) if !containsFold(apps, "bgp") { return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false } sub, _ := claims["sub"].(string) if strings.TrimSpace(sub) == "" { return Auth{}, http.StatusUnauthorized, "jwt missing sub", false } email, _ := claims["email"].(string) perms := coerceStringSlice(claims["permissions"]) isAdmin, _ := claims["is_admin"].(bool) return Auth{ Kind: AuthKindJWT, TenantID: s.portalTenantID, UserID: strings.TrimSpace(sub), Email: strings.TrimSpace(email), Permissions: perms, IsAdmin: isAdmin, Token: raw, }, 0, "", true } func coerceStringSlice(v any) []string { switch t := v.(type) { case []string: return t case []any: out := make([]string, 0, len(t)) for _, x := range t { if s, ok := x.(string); ok { out = append(out, s) } } return out default: return nil } } func containsFold(items []string, needle string) bool { for _, x := range items { if strings.EqualFold(strings.TrimSpace(x), needle) { return true } } return false } func (s *Server) devAuth() (Auth, bool) { tid, _, _, _, _ := s.store.DemoIDs() if tid == "" { return Auth{}, false } return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true } func roleLevel(role string) int { switch strings.ToLower(role) { case "viewer": return 1 case "editor": return 2 case "operator": return 3 default: return 0 } } // requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys. // New code should call requirePerm which supports JWT permissions in addition to API-key roles. func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool { if strings.ToLower(a.Role) == "node" { writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource") return false } if roleLevel(a.Role) < roleLevel(need) { writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role") return false } return true } func (s *Server) requireFirewall(w http.ResponseWriter, a Auth) bool { if strings.ToLower(a.Role) != "firewall" { writeProblem(w, http.StatusForbidden, "Forbidden", "firewall client role required") return false } return true } func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool { if strings.ToLower(a.Role) != "node" { writeProblem(w, http.StatusForbidden, "Forbidden", "node role required") return false } return true }