mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-06-16 23:33:11 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e0b8a184 | ||
|
|
914f72e062 | ||
|
|
35b7bd90fd |
@@ -58,9 +58,12 @@ var (
|
||||
type AuthConfig struct {
|
||||
Users []UserInfo `yaml:"users,omitempty"`
|
||||
UnauthorizedUser *UserInfo `yaml:"unauthorized_user,omitempty"`
|
||||
SSO SSOConfig `yaml:"sso,omitempty"`
|
||||
|
||||
// ms holds all the metrics for the given AuthConfig
|
||||
ms *metrics.Set
|
||||
|
||||
oidcDP *oidcDiscovererPool
|
||||
}
|
||||
|
||||
// UserInfo is user information read from authConfigPath
|
||||
@@ -911,11 +914,25 @@ func reloadAuthConfigData(data []byte) (bool, error) {
|
||||
return false, fmt.Errorf("failed to parse auth config: %w", err)
|
||||
}
|
||||
|
||||
if err := validateSSOConfigs(ac.SSO); err != nil {
|
||||
return false, fmt.Errorf("invalid SSO config: %w", err)
|
||||
}
|
||||
|
||||
jui, oidcDP, err := parseJWTUsers(ac)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse JWT users from auth config: %w", err)
|
||||
}
|
||||
|
||||
ac.oidcDP = oidcDP
|
||||
|
||||
// Register SSO issuers with the OIDC discoverer pool so their discovery
|
||||
// runs together with JWT users during startDiscovery below.
|
||||
for _, cfg := range ac.SSO {
|
||||
oidcDP.createOrAdd(cfg.OpenIDConnect.Issuer, nil)
|
||||
}
|
||||
|
||||
oidcDP.startDiscovery()
|
||||
|
||||
jwtc := &jwtCache{
|
||||
users: jui,
|
||||
oidcDP: oidcDP,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -169,7 +170,22 @@ func requestHandlerWithInternalRoutes(w http.ResponseWriter, r *http.Request) bo
|
||||
}
|
||||
|
||||
func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
// Handle SSO callback before any auth checks.
|
||||
if r.URL.Path == "/_vmauth/sso/callback" {
|
||||
handleSSOCallback(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
ats := getAuthTokensFromRequest(r)
|
||||
|
||||
log.Println(51)
|
||||
// Inject the SSO session cookie as a Bearer token so that the existing
|
||||
// JWT pipeline can validate it and match it to a configured user.
|
||||
if tok := ssoAuthTokenFromRequest(r); tok != "" {
|
||||
log.Println(52, tok)
|
||||
ats = append(ats, tok)
|
||||
}
|
||||
|
||||
if len(ats) == 0 {
|
||||
// Process requests for unauthorized users
|
||||
ui := authConfig.Load().UnauthorizedUser
|
||||
@@ -177,7 +193,13 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
processUserRequest(w, r, ui, nil)
|
||||
return true
|
||||
}
|
||||
|
||||
log.Println(1)
|
||||
if cfg := ssoConfigForHost(r.Host); cfg != nil {
|
||||
log.Println(2)
|
||||
showSSOLoginPage(w, r, cfg)
|
||||
return true
|
||||
}
|
||||
log.Println(3)
|
||||
handleMissingAuthorizationError(w)
|
||||
return true
|
||||
}
|
||||
@@ -201,6 +223,11 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if cfg := ssoConfigForHost(r.Host); cfg != nil {
|
||||
showSSOLoginPage(w, r, cfg)
|
||||
return true
|
||||
}
|
||||
|
||||
invalidAuthTokenRequests.Inc()
|
||||
if *logInvalidAuthTokens {
|
||||
err := fmt.Errorf("cannot authorize request with auth tokens %q", ats)
|
||||
|
||||
@@ -44,7 +44,19 @@ func (dp *oidcDiscovererPool) createOrAdd(issuer string, vp *atomic.Pointer[jwt.
|
||||
dp.ds[issuer] = ds
|
||||
}
|
||||
|
||||
ds.vps = append(ds.vps, vp)
|
||||
if vp != nil {
|
||||
ds.vps = append(ds.vps, vp)
|
||||
}
|
||||
}
|
||||
|
||||
// openIDConfig returns the most recently discovered openidConfig for the given issuer,
|
||||
// or nil if the issuer is not registered or discovery has not completed yet.
|
||||
func (dp *oidcDiscovererPool) openIDConfig(issuer string) *openidConfig {
|
||||
d := dp.ds[issuer]
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return d.cfg.Load()
|
||||
}
|
||||
|
||||
func (dp *oidcDiscovererPool) startDiscovery() {
|
||||
@@ -80,6 +92,7 @@ func (dp *oidcDiscovererPool) stopDiscovery() {
|
||||
type oidcDiscoverer struct {
|
||||
issuer string
|
||||
vps []*atomic.Pointer[jwt.VerifierPool]
|
||||
cfg atomic.Pointer[openidConfig]
|
||||
}
|
||||
|
||||
func (d *oidcDiscoverer) run(ctx context.Context) {
|
||||
@@ -117,21 +130,26 @@ func (d *oidcDiscoverer) refreshVerifierPools(ctx context.Context) error {
|
||||
return fmt.Errorf("openid configuration issuer %q does not match expected issuer %q", cfg.Issuer, d.issuer)
|
||||
}
|
||||
|
||||
verifierPool, err := fetchAndParseJWKs(ctx, cfg.JWKsURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.cfg.Store(&cfg)
|
||||
|
||||
for _, vp := range d.vps {
|
||||
vp.Store(verifierPool)
|
||||
if len(d.vps) > 0 {
|
||||
verifierPool, err := fetchAndParseJWKs(ctx, cfg.JWKsURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, vp := range d.vps {
|
||||
vp.Store(verifierPool)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata for details.
|
||||
type openidConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKsURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
JWKsURI string `json:"jwks_uri"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
var oidcHTTPClient = &http.Client{
|
||||
|
||||
320
app/vmauth/sso.go
Normal file
320
app/vmauth/sso.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
||||
)
|
||||
|
||||
// SSOConfig maps hostname to its SSO configuration.
|
||||
type SSOConfig map[string]*SSOHostConfig
|
||||
|
||||
// SSOHostConfig holds the SSO configuration for a single host.
|
||||
type SSOHostConfig struct {
|
||||
OpenIDConnect *OIDCConnectConfig `yaml:"openid_connect"`
|
||||
}
|
||||
|
||||
// OIDCConnectConfig is the OpenID Connect configuration for SSO.
|
||||
type OIDCConnectConfig struct {
|
||||
Issuer string `yaml:"issuer"`
|
||||
ClientID string `yaml:"client_id"`
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
// RedirectURL is optional. Defaults to https://{host}/_vmauth/sso/callback.
|
||||
RedirectURL string `yaml:"redirect_url,omitempty"`
|
||||
// Scopes defaults to ["openid"] when not set.
|
||||
Scopes []string `yaml:"scopes,omitempty"`
|
||||
|
||||
// filled from OIDC discovery at init time
|
||||
authEndpoint string
|
||||
tokenEndpoint string
|
||||
}
|
||||
|
||||
// validateSSOConfigs checks that all required fields are present in SSO configs.
|
||||
func validateSSOConfigs(sso SSOConfig) error {
|
||||
for host, cfg := range sso {
|
||||
if cfg.OpenIDConnect == nil {
|
||||
return fmt.Errorf("missing openid_connect config for sso host %q", host)
|
||||
}
|
||||
oidc := cfg.OpenIDConnect
|
||||
if oidc.Issuer == "" {
|
||||
return fmt.Errorf("missing issuer in openid_connect config for sso host %q", host)
|
||||
}
|
||||
if oidc.ClientID == "" {
|
||||
return fmt.Errorf("missing client_id in openid_connect config for sso host %q", host)
|
||||
}
|
||||
if oidc.ClientSecret == "" {
|
||||
return fmt.Errorf("missing client_secret in openid_connect config for sso host %q", host)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ssoConfigForHost returns the SSO host config for the given request host, or nil.
|
||||
func ssoConfigForHost(host string) *SSOHostConfig {
|
||||
// Strip port, e.g. "foo.com:8427" -> "foo.com"
|
||||
if i := strings.LastIndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
ac := authConfig.Load()
|
||||
if ac == nil || ac.SSO == nil {
|
||||
log.Println(21)
|
||||
return nil
|
||||
}
|
||||
ssoh := ac.SSO[host]
|
||||
if ssoh == nil || ssoh.OpenIDConnect == nil {
|
||||
log.Println(22)
|
||||
return nil
|
||||
}
|
||||
|
||||
oidcCfg := ac.oidcDP.openIDConfig(ssoh.OpenIDConnect.Issuer)
|
||||
if oidcCfg == nil {
|
||||
log.Println(24)
|
||||
return nil
|
||||
}
|
||||
ssoh.OpenIDConnect.authEndpoint = oidcCfg.AuthorizationEndpoint
|
||||
ssoh.OpenIDConnect.tokenEndpoint = oidcCfg.TokenEndpoint
|
||||
|
||||
log.Println(25)
|
||||
return ssoh
|
||||
}
|
||||
|
||||
// ssoStatePayload is the CSRF state payload embedded in the OIDC state parameter.
|
||||
type ssoStatePayload struct {
|
||||
Nonce string `json:"n"`
|
||||
OriginalURL string `json:"u"`
|
||||
IssuedAt int64 `json:"t"`
|
||||
}
|
||||
|
||||
const (
|
||||
ssoStateTTL = 10 * time.Minute
|
||||
ssoCookieName = "_vmauth_sso"
|
||||
)
|
||||
|
||||
// buildSSOState builds a signed, self-contained state value safe to use across
|
||||
// multiple vmauth instances behind a load balancer.
|
||||
//
|
||||
// Format: base64url(JSON(payload)) "." base64url(HMAC-SHA256(clientSecret, payload))
|
||||
func buildSSOState(originalURL, clientSecret string) (string, error) {
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("cannot generate nonce: %w", err)
|
||||
}
|
||||
p := ssoStatePayload{
|
||||
Nonce: base64.RawURLEncoding.EncodeToString(nonce),
|
||||
OriginalURL: originalURL,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
}
|
||||
payloadJSON, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadEnc := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
mac := hmac.New(sha256.New, []byte(clientSecret))
|
||||
mac.Write([]byte(payloadEnc))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return payloadEnc + "." + sig, nil
|
||||
}
|
||||
|
||||
// verifySSOState verifies the state signature and expiry, returning the original URL.
|
||||
func verifySSOState(state, clientSecret string) (string, error) {
|
||||
dot := strings.LastIndexByte(state, '.')
|
||||
if dot < 0 {
|
||||
return "", fmt.Errorf("invalid state: missing separator")
|
||||
}
|
||||
payloadEnc := state[:dot]
|
||||
sig := state[dot+1:]
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(clientSecret))
|
||||
mac.Write([]byte(payloadEnc))
|
||||
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(sig), []byte(expectedSig)) {
|
||||
return "", fmt.Errorf("invalid state signature")
|
||||
}
|
||||
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadEnc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot decode state payload: %w", err)
|
||||
}
|
||||
var p ssoStatePayload
|
||||
if err := json.Unmarshal(payloadJSON, &p); err != nil {
|
||||
return "", fmt.Errorf("cannot unmarshal state payload: %w", err)
|
||||
}
|
||||
if time.Since(time.Unix(p.IssuedAt, 0)) > ssoStateTTL {
|
||||
return "", fmt.Errorf("state expired")
|
||||
}
|
||||
return p.OriginalURL, nil
|
||||
}
|
||||
|
||||
var ssoLoginPageTmpl = template.Must(template.New("sso_login").Parse(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Login</title></head>
|
||||
<body>
|
||||
<p><a href="{{.}}">Login with SSO</a></p>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// showSSOLoginPage renders a minimal HTML page with a single "Login with SSO"
|
||||
// button pointing directly to the OIDC provider's authorization endpoint.
|
||||
func showSSOLoginPage(w http.ResponseWriter, r *http.Request, cfg *SSOHostConfig) {
|
||||
oidc := cfg.OpenIDConnect
|
||||
if oidc == nil || oidc.authEndpoint == "" {
|
||||
http.Error(w, "SSO not properly configured for this host", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := buildSSOState(r.RequestURI, oidc.ClientSecret)
|
||||
if err != nil {
|
||||
logger.Errorf("SSO: cannot build state: %s", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := ssoRedirectURL(r, oidc)
|
||||
scopes := oidc.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid"}
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("response_type", "code")
|
||||
params.Set("client_id", oidc.ClientID)
|
||||
params.Set("redirect_uri", redirectURL)
|
||||
params.Set("scope", strings.Join(scopes, " "))
|
||||
params.Set("state", state)
|
||||
authURL := oidc.authEndpoint + "?" + params.Encode()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := ssoLoginPageTmpl.Execute(w, authURL); err != nil {
|
||||
logger.Errorf("SSO: cannot render login page: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSOCallback handles the OIDC authorization code callback at /_vmauth/sso/callback.
|
||||
func handleSSOCallback(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := ssoConfigForHost(r.Host)
|
||||
if cfg == nil || cfg.OpenIDConnect == nil {
|
||||
http.Error(w, "SSO not configured for this host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
oidc := cfg.OpenIDConnect
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
state := q.Get("state")
|
||||
if state == "" {
|
||||
http.Error(w, "missing state parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
originalURL, err := verifySSOState(state, oidc.ClientSecret)
|
||||
if err != nil {
|
||||
logger.Warnf("SSO callback: invalid state from %s: %s", r.RemoteAddr, err)
|
||||
http.Error(w, "invalid state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing code parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := exchangeCodeForIDToken(r.Context(), oidc, code, ssoRedirectURL(r, oidc))
|
||||
if err != nil {
|
||||
logger.Warnf("SSO callback: token exchange failed: %s", err)
|
||||
http.Error(w, "token exchange failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: ssoCookieName,
|
||||
Value: idToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
if originalURL == "" {
|
||||
originalURL = "/"
|
||||
}
|
||||
http.Redirect(w, r, originalURL, http.StatusFound)
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
// exchangeCodeForIDToken exchanges the OIDC authorization code for an id_token.
|
||||
func exchangeCodeForIDToken(ctx context.Context, oidc *OIDCConnectConfig, code, redirectURL string) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Set("grant_type", "authorization_code")
|
||||
params.Set("code", code)
|
||||
params.Set("redirect_uri", redirectURL)
|
||||
params.Set("client_id", oidc.ClientID)
|
||||
params.Set("client_secret", oidc.ClientSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, oidc.tokenEndpoint, strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := oidcHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var tr tokenResponse
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return "", fmt.Errorf("cannot unmarshal token response: %w", err)
|
||||
}
|
||||
if tr.IDToken == "" {
|
||||
return "", fmt.Errorf("token response missing id_token")
|
||||
}
|
||||
return tr.IDToken, nil
|
||||
}
|
||||
|
||||
// ssoAuthTokenFromRequest extracts the SSO session cookie and returns it as
|
||||
// a Bearer auth token string compatible with the existing JWT pipeline.
|
||||
func ssoAuthTokenFromRequest(r *http.Request) string {
|
||||
c, err := r.Cookie(ssoCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return ""
|
||||
}
|
||||
return "http_auth:Bearer " + c.Value
|
||||
}
|
||||
|
||||
// ssoRedirectURL returns the OIDC redirect URL for the current request.
|
||||
func ssoRedirectURL(r *http.Request, oidc *OIDCConnectConfig) string {
|
||||
if oidc.RedirectURL != "" {
|
||||
return oidc.RedirectURL
|
||||
}
|
||||
scheme := "https"
|
||||
if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + r.Host + "/_vmauth/sso/callback"
|
||||
}
|
||||
@@ -154,29 +154,33 @@ func (b *body) parse(src string) error {
|
||||
|
||||
vaObject := jv.Get("vm_access")
|
||||
if vaObject == nil {
|
||||
return ErrVMAccessFieldMissing
|
||||
}
|
||||
// some IDPs encode custom claims as a string
|
||||
// try parsing as an object and fallback to a string
|
||||
switch vaObject.Type() {
|
||||
case fastjson.TypeObject:
|
||||
if err := b.vmAccessClaim.parseFrom(vaObject); err != nil {
|
||||
return err
|
||||
b.vmAccessClaim = VMAccessClaim{}
|
||||
//return ErrVMAccessFieldMissing
|
||||
} else {
|
||||
// some IDPs encode custom claims as a string
|
||||
// try parsing as an object and fallback to a string
|
||||
switch vaObject.Type() {
|
||||
case fastjson.TypeObject:
|
||||
if err := b.vmAccessClaim.parseFrom(vaObject); err != nil {
|
||||
return err
|
||||
}
|
||||
case fastjson.TypeString:
|
||||
b.claimsParser = parserPool.Get()
|
||||
va, err := b.claimsParser.ParseBytes(vaObject.GetStringBytes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse `vm_access` string json: %w", err)
|
||||
}
|
||||
if err := b.vmAccessClaim.parseFrom(va); err != nil {
|
||||
return fmt.Errorf("cannot parse `vm_access` values from string json: %w", err)
|
||||
}
|
||||
b.vmAccessClaimObject = va
|
||||
case fastjson.TypeNull:
|
||||
b.vmAccessClaim = VMAccessClaim{}
|
||||
//return ErrVMAccessFieldMissing
|
||||
default:
|
||||
b.vmAccessClaim = VMAccessClaim{}
|
||||
//return fmt.Errorf("unexpected type for `vm_access` field; got: %q, want object {}", vaObject.Type())
|
||||
}
|
||||
case fastjson.TypeString:
|
||||
b.claimsParser = parserPool.Get()
|
||||
va, err := b.claimsParser.ParseBytes(vaObject.GetStringBytes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse `vm_access` string json: %w", err)
|
||||
}
|
||||
if err := b.vmAccessClaim.parseFrom(va); err != nil {
|
||||
return fmt.Errorf("cannot parse `vm_access` values from string json: %w", err)
|
||||
}
|
||||
b.vmAccessClaimObject = va
|
||||
case fastjson.TypeNull:
|
||||
return ErrVMAccessFieldMissing
|
||||
default:
|
||||
return fmt.Errorf("unexpected type for `vm_access` field; got: %q, want object {}", vaObject.Type())
|
||||
}
|
||||
b.Jti = bytesutil.ToUnsafeString(jv.GetStringBytes("jti"))
|
||||
|
||||
|
||||
208
sso.md
Normal file
208
sso.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# SSO (OpenID Connect) for vmauth
|
||||
|
||||
## Requirements
|
||||
|
||||
* SSO should only support OpenID Connect (authorization code flow).
|
||||
* Keep implementation as simple as possible.
|
||||
* Avoid external dependencies — use only stdlib `net/http`, `crypto/hmac`, `encoding/json`, etc.
|
||||
* SSO must be coupled to existing JWT logic: after OIDC callback, the id\_token is stored in a cookie and fed into the existing JWT user-matching pipeline on subsequent requests.
|
||||
* SSO should be implemented as a standalone feature in `app/vmauth/sso.go`.
|
||||
* Attempt SSO login only if:
|
||||
1. No existing credentials matched (bearer, basic, JWT), AND
|
||||
2. The request Host is listed in the `sso:` config section.
|
||||
* Validate the OIDC callback (`state`, `code`, token exchange, id\_token signature) before setting the session cookie.
|
||||
* Both cookie-based SSO sessions and all existing credentials (bearer, basic, JWT) work simultaneously — SSO is purely additive.
|
||||
|
||||
---
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
# vmauth.yaml
|
||||
|
||||
sso:
|
||||
foo.com:
|
||||
openid_connect:
|
||||
issuer: https://accounts.google.com # OIDC discovery base URL
|
||||
client_id: <client_id>
|
||||
client_secret: <client_secret>
|
||||
redirect_url: https://foo.com/_vmauth/sso/callback # optional; derived from Host if omitted
|
||||
scopes: [openid, email, profile] # optional; default: [openid]
|
||||
|
||||
bar.com:
|
||||
openid_connect:
|
||||
issuer: https://login.microsoftonline.com/<tenant>/v2.0
|
||||
client_id: <client_id>
|
||||
client_secret: <client_secret>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### New file: `app/vmauth/sso.go`
|
||||
|
||||
Responsibilities:
|
||||
- Config structs (`SSOConfig`, `SSOHostConfig`, `OIDCConnectConfig`).
|
||||
- OIDC discovery: fetch `{issuer}/.well-known/openid-configuration` → extract `authorization_endpoint`, `token_endpoint`, `jwks_uri`.
|
||||
- `initiateSSOLogin(w, r, cfg)` — redirect browser to OIDC authorization URL with `state` + `nonce` (CSRF).
|
||||
- `handleSSOCallback(w, r, cfg)` — validate `state`, exchange `code` for tokens, validate `id_token`, set signed session cookie, redirect to original URL.
|
||||
- `ssoAuthTokenFromRequest(r)` — extract `id_token` from the session cookie and return it as an auth token string so that the existing JWT pipeline can match it to a configured user.
|
||||
|
||||
### Changes to `auth_config.go`
|
||||
|
||||
Add `SSO SSOConfig` field to `AuthConfig`:
|
||||
|
||||
```go
|
||||
type AuthConfig struct {
|
||||
Users []UserInfo `yaml:"users"`
|
||||
UnauthorizedUser *UserInfo `yaml:"unauthorized_user"`
|
||||
SSO SSOConfig `yaml:"sso"` // NEW
|
||||
}
|
||||
```
|
||||
|
||||
`SSOConfig` is `map[string]*SSOHostConfig` (host → config). Parsed during `parseAuthConfig`; OIDC discovery is triggered at parse time (same pattern as `oidcDiscoverer`).
|
||||
|
||||
### Changes to `main.go`
|
||||
|
||||
**Callback handler** — registered before the main auth flow:
|
||||
```
|
||||
if r.URL.Path == "/_vmauth/sso/callback" {
|
||||
handleSSOCallback(w, r, ssoConfigForHost(r.Host))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Extended auth token extraction** — after `getAuthTokensFromRequest`:
|
||||
```
|
||||
if tok := ssoAuthTokenFromRequest(r); tok != "" {
|
||||
ats = append(ats, tok)
|
||||
}
|
||||
```
|
||||
This feeds the cookie's `id_token` into the existing `getJWTUserInfo` call with zero duplication.
|
||||
|
||||
**SSO login page** — after all existing auth attempts fail and before returning 401:
|
||||
```
|
||||
if cfg := ssoConfigForHost(r.Host); cfg != nil {
|
||||
showSSOLoginPage(w, r, cfg)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
`showSSOLoginPage` computes the full OIDC authorization URL (including `state`, `client_id`, `redirect_uri`, `scope`) upfront and writes a minimal HTML page (200 OK) with a single `<a href="{authorizationURL}">Login with SSO</a>` button. The link points directly to the OIDC provider — no intermediate vmauth hop.
|
||||
|
||||
---
|
||||
|
||||
## OIDC Authorization Code Flow
|
||||
|
||||
```
|
||||
Browser vmauth OIDC Provider
|
||||
| | |
|
||||
|-- GET foo.com/app ---------->| |
|
||||
| |-- no credentials, host in SSO |
|
||||
| | compute full authz URL with |
|
||||
| | state, client_id, scopes |
|
||||
|<-- 200 HTML login page ------| |
|
||||
| [Login with SSO] href= | |
|
||||
| https://provider/authorize?... |
|
||||
| |
|
||||
|-- (user clicks) GET /authorize?client_id=...&state=X ------>|
|
||||
|<-- 302 → foo.com/_vmauth/sso/callback?code=Y&state=X -------|
|
||||
| | |
|
||||
|-- GET /_vmauth/sso/callback?code=Y&state=X ->| |
|
||||
| |-- validate state cookie |
|
||||
| |-- POST /token {code} -------->|
|
||||
| |<-- {id_token, access_token} --|
|
||||
| |-- validate id_token JWT |
|
||||
| | (via OIDC JWKS) |
|
||||
| |-- set _vmauth_sso cookie |
|
||||
|<-- 302 → /app (original) ---| |
|
||||
| | |
|
||||
|-- GET foo.com/app (with cookie) ->| |
|
||||
| |-- extract id_token from cookie |
|
||||
| |-- JWT user matching (existing) |
|
||||
| |-- proxy to backend |
|
||||
|<-- 200 OK ------------------| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Cookie
|
||||
|
||||
- Name: `_vmauth_sso`
|
||||
- Value: raw OIDC `id_token` (already a signed JWT — no extra wrapping needed)
|
||||
- Flags: `HttpOnly; Secure; SameSite=Lax; Path=/`
|
||||
- Expiry: derived from `id_token` `exp` claim
|
||||
|
||||
No server-side session store is required. The `id_token` is self-contained and validated on every request via the existing JWT machinery (JWKS signature check, `exp` check).
|
||||
|
||||
---
|
||||
|
||||
## State / CSRF Protection
|
||||
|
||||
State is entirely self-contained in the `state` query parameter — no server-side storage, no per-instance secret. This works correctly when multiple vmauth instances are behind a load balancer.
|
||||
|
||||
- On SSO initiation: build `state = base64url(JSON{nonce, originalURL, issuedAt}) + "." + HMAC-SHA256(client_secret, payload)`.
|
||||
- `nonce` — 16 random bytes (replay protection).
|
||||
- `originalURL` — so the user is redirected back after login.
|
||||
- `issuedAt` — Unix timestamp; reject states older than 10 minutes on callback.
|
||||
- `client_secret` — already shared across all instances via the config file; no extra coordination needed.
|
||||
- On callback: split `state` on `.`, re-compute HMAC over the payload using `client_secret`, compare in constant time, check `issuedAt` not expired.
|
||||
|
||||
---
|
||||
|
||||
## OIDC Discovery
|
||||
|
||||
Performed once at config load time (same as existing `oidcDiscoverer`):
|
||||
|
||||
```
|
||||
GET {issuer}/.well-known/openid-configuration
|
||||
→ parse authorization_endpoint, token_endpoint, jwks_uri
|
||||
```
|
||||
|
||||
JWKS is fetched and cached by the existing `oidcDiscovererPool`. The `issuer` value in the SSO config is reused as the JWT `iss` claim for matching with an existing `UserInfo` that has `jwt.oidc.issuer` set — this is how SSO couples to existing users.
|
||||
|
||||
---
|
||||
|
||||
## Coupling SSO to Existing Users / JWT
|
||||
|
||||
The `id_token` returned by the OIDC provider is a JWT. vmauth stores it in a cookie and, on each request, presents it to the existing `getJWTUserInfo` pipeline. That pipeline already:
|
||||
- Discovers JWKS from the issuer.
|
||||
- Verifies the signature.
|
||||
- Checks the `exp` claim.
|
||||
- Matches `match_claims` patterns.
|
||||
|
||||
So a user in `vmauth.yaml` that would normally accept a JWT from that OIDC issuer will automatically accept SSO-authenticated sessions too — no new user-matching logic is needed.
|
||||
|
||||
Example: a JWT user config that SSO sessions will match:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
- name: sso-users
|
||||
jwt:
|
||||
oidc:
|
||||
issuer: https://accounts.google.com
|
||||
match_claims:
|
||||
hd: mycompany\.com # only @mycompany.com Google accounts
|
||||
url_prefix: http://backend:8428
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `app/vmauth/sso.go` | New file — all SSO logic |
|
||||
| `app/vmauth/auth_config.go` | Add `SSO SSOConfig` to `AuthConfig`; call OIDC discovery in `parseAuthConfig` |
|
||||
| `app/vmauth/main.go` | Callback route, cookie token injection, SSO redirect fallback |
|
||||
|
||||
---
|
||||
|
||||
## What is NOT in scope
|
||||
|
||||
- OAuth2 implicit / device / client\_credentials flows.
|
||||
- PKCE (not needed for confidential server-side clients, keeps it simple).
|
||||
- Logout / RP-initiated logout endpoint.
|
||||
- Token refresh (user re-authenticates when `id_token` expires).
|
||||
- Storing sessions in an external store (Redis, DB).
|
||||
34
test_configs/sso/docker-compose.yaml
Normal file
34
test_configs/sso/docker-compose.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
keycloak:
|
||||
image: docker.io/keycloak/keycloak:26.1
|
||||
command:
|
||||
- start-dev
|
||||
- --import-realm
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
# Fix the frontend URL so the issuer in JWTs is always http://keycloak:8080
|
||||
# regardless of how Keycloak is accessed internally vs externally.
|
||||
KC_HOSTNAME_URL: http://keycloak:8080
|
||||
volumes:
|
||||
- ./keycloak-realm.json:/opt/keycloak/data/import/realm.json
|
||||
|
||||
victoria-metrics:
|
||||
image: docker.io/victoriametrics/victoria-metrics:v1.145.0
|
||||
command:
|
||||
- -httpListenAddr=0.0.0.0:8429
|
||||
ports:
|
||||
- "8429:8429"
|
||||
|
||||
vmauth:
|
||||
image: docker.io/victoriametrics/vmauth:heads-master-0-ged795a8443-dirty-5bb2c38b
|
||||
ports:
|
||||
- "8427:8427"
|
||||
volumes:
|
||||
- ./vmauth.yaml:/etc/vmauth/config.yaml
|
||||
command:
|
||||
- -auth.config=/etc/vmauth/config.yaml
|
||||
- -httpListenAddr=0.0.0.0:8427
|
||||
- -logInvalidAuthTokens=true
|
||||
35
test_configs/sso/keycloak-realm.json
Normal file
35
test_configs/sso/keycloak-realm.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"realm": "testrealm",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "vmauth",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": false,
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "vmauth-client-secret",
|
||||
"redirectUris": [
|
||||
"http://localhost:8427/_vmauth/sso/callback"
|
||||
],
|
||||
"webOrigins": ["*"]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "testuser",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "testpassword",
|
||||
"temporary": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
21
test_configs/sso/vmauth.yaml
Normal file
21
test_configs/sso/vmauth.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
sso:
|
||||
localhost:
|
||||
openid_connect:
|
||||
# Keycloak is accessed by vmauth via Docker DNS ("keycloak:8080").
|
||||
# KC_HOSTNAME_URL is set to this same value so the issuer in JWTs is
|
||||
# always http://keycloak:8080 regardless of how Keycloak is accessed.
|
||||
issuer: http://keycloak:8080/realms/testrealm
|
||||
client_id: vmauth
|
||||
client_secret: vmauth-client-secret
|
||||
redirect_url: http://localhost:8427/_vmauth/sso/callback
|
||||
scopes: [openid, profile, email]
|
||||
|
||||
users:
|
||||
# Matches id_tokens issued by Keycloak for the testrealm.
|
||||
# Any user that logs in via SSO and has a valid token will be proxied
|
||||
# to VictoriaMetrics.
|
||||
- name: keycloak-users
|
||||
jwt:
|
||||
oidc:
|
||||
issuer: http://keycloak:8080/realms/testrealm
|
||||
url_prefix: http://victoria-metrics:8429
|
||||
Reference in New Issue
Block a user