Compare commits

...

3 Commits

Author SHA1 Message Date
Max Kotliar
12e0b8a184 prompt 2026-06-16 22:56:04 +03:00
Max Kotliar
914f72e062 test code 2026-06-16 22:44:47 +03:00
Max Kotliar
35b7bd90fd sso poc 2026-06-16 22:37:52 +03:00
9 changed files with 716 additions and 32 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
View 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"
}

View File

@@ -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
View 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).

View 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

View 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
}
]
}
]
}

View 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