OpenID Connect login (AUTH_MODE=oidc)

AUTH_MODE=local keeps the HTTP Basic authentication (unchanged default);
AUTH_MODE=oidc logs in through an OpenID Connect provider with the
authorization code flow and PKCE, standard library only: discovery,
ID token signature (RS/PS/ES) and claims checks, signed session cookie
whose key is kept in DATA_DIR. The UI gets a log out button and reloads
into the login when the session ends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-10-03 10:39:54 +02:00
1 parent 84f8b9f9ad
commit f30c353b46
9 files changed
+1013 -37

No files matched your search

+13 -1
View File
@@ -4,9 +4,21 @@ HTTP_PORT=8080
TZ=Europe/Paris
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
RETENTION=30d
# Web UI authentication (empty = disabled)
# Web UI authentication: local (HTTP Basic below, or none) or oidc (OpenID Connect provider)
AUTH_MODE=local
# local mode: user and password (empty = no authentication)
AUTH_USER=
AUTH_PASS=
# oidc mode: issuer URL exactly as the provider announces it
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended)
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
# Requested scopes (openid is always added) and session lifetime (e.g. 8h, 24h)
OIDC_SCOPES=openid profile email
OIDC_SESSION_TTL=12h
# Reverse DNS: show host names instead of IP addresses (on/off)
RDNS=on
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
+47 -3
View File
@@ -243,7 +243,44 @@ couleur, est mémorisé par navigateur.
taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`) ; mettez
`ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface
peut purger : définissez `AUTH_USER` / `AUTH_PASS` si l'interface est accessible à d'autres.
peut purger : activez l'[authentification](#authentification) si l'interface est accessible
à d'autres.
## Authentification
`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) :
- **`local`** (par défaut) : authentification HTTP Basic avec `AUTH_USER` / `AUTH_PASS` ; laissez-les
vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà).
- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
flux « authorization code » avec PKCE.
Pour utiliser OIDC :
1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez
l'URL de retour `https://logs.example.org/auth/callback` (votre adresse).
2. Dans `.env` :
```bash
AUTH_MODE=oidc
OIDC_ISSUER=https://sso.example.org/realms/maison # exactement l'« issuer » du fournisseur
OIDC_CLIENT_ID=logstream
OIDC_CLIENT_SECRET=...
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
```
3. `docker compose up -d`. Les logs affichent `oidc authentication enabled`, ou la raison pour
laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…).
Ouvrir l'interface renvoie vers la page de connexion du fournisseur, puis revient sur logstream.
La session dure `OIDC_SESSION_TTL` (12 h par défaut) et survit aux redémarrages (sa clé de
signature est dans `/data/session.key`) ; à son expiration, la page repasse par la connexion. Le
bouton de déconnexion (en haut à droite) termine la session logstream, puis ouvre la page de
déconnexion du fournisseur s'il en a une.
Tout utilisateur accepté par le fournisseur pour ce client peut se connecter : restreignez l'accès
dans le fournisseur (Keycloak : rôles du client ou realm dédié ; Authentik : liaisons de
l'application). Les connexions sont écrites dans les logs de logstream (`oidc: alice logged in`).
Avec une URL de retour en `https`, les cookies ne sont envoyés qu'en HTTPS : logstream doit être
joint à travers un reverse proxy TLS.
## Noms d'hôtes (DNS inverse)
@@ -265,7 +302,13 @@ résolutions.
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
| `HTTP_PORT` | `8080` | port de l'interface web |
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface |
| `AUTH_MODE` | `local` | `local` (HTTP Basic) ou `oidc`, voir [Authentification](#authentification) |
| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface (mode `local`) |
| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | vide | client déclaré dans le fournisseur |
| `OIDC_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | scopes demandés |
| `OIDC_SESSION_TTL` | `12h` | durée de la session |
| `RDNS` | `on` | résoudre les hôtes donnés par leur IP en noms DNS |
| `DNS_SERVER` | vide | serveur DNS pour les résolutions inverses (`ip` ou `ip:port`) |
| `ALLOW_PURGE` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres |
@@ -322,7 +365,8 @@ Pour mettre à jour l'une d'elles :
| Fichier | Contenu |
|---|---|
| `main.go` | configuration, démarrage, authentification |
| `main.go` | configuration, démarrage |
| `auth.go` | authentification : HTTP Basic ou OpenID Connect (découverte, PKCE, contrôle de l'ID token, cookie de session) |
| `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 |
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
+45 -4
View File
@@ -219,8 +219,42 @@ remembered per browser.
- **Data**: "Delete all logs" permanently erases every stored log (you must type
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
(already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature.
Anyone who can open the UI can purge: set `AUTH_USER` / `AUTH_PASS` if the UI is reachable
by others.
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI
is reachable by others.
## Authentication
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open):
- **`local`** (default): HTTP Basic authentication with `AUTH_USER` / `AUTH_PASS`; leave them
empty to have no authentication (for instance behind a reverse proxy that already checks).
- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
authorization code flow with PKCE.
To use OIDC:
1. In the provider, create a **confidential** client (with a secret) for logstream and register
the redirect URL `https://logs.example.org/auth/callback` (your address).
2. In `.env`:
```bash
AUTH_MODE=oidc
OIDC_ISSUER=https://sso.example.org/realms/home # exactly the "issuer" of the provider
OIDC_CLIENT_ID=logstream
OIDC_CLIENT_SECRET=...
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
```
3. `docker compose up -d`. The logs show `oidc authentication enabled`, or the reason the
provider could not be read (wrong issuer, unreachable…).
Opening the UI sends you to the provider's login page, then back to logstream. The session
lasts `OIDC_SESSION_TTL` (12 h by default) and survives restarts (its signing key is in
`/data/session.key`); when it ends, the page goes through the login again. The log out button
(top right) ends the logstream session, then opens the provider's log out page if it has one.
Every user the provider accepts for this client can log in: restrict access in the provider
(Keycloak: client roles or a dedicated realm; Authentik: application bindings). Logins are written
in the logstream logs (`oidc: alice logged in`). With an `https` redirect URL, the cookies are
only sent over HTTPS: logstream must be reached through a TLS reverse proxy.
## Host names (reverse DNS)
@@ -240,7 +274,13 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `SYSLOG_PORT` | `514` | syslog port published on the host |
| `HTTP_PORT` | `8080` | web UI port |
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI |
| `AUTH_MODE` | `local` | `local` (HTTP Basic) or `oidc`, see [Authentication](#authentication) |
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI (`local` mode) |
| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider |
| `OIDC_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | requested scopes |
| `OIDC_SESSION_TTL` | `12h` | session lifetime |
| `RDNS` | `on` | resolve IP hosts to DNS names |
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
@@ -294,7 +334,8 @@ To update one of them:
| File | Contents |
|---|---|
| `main.go` | configuration, startup, authentication |
| `main.go` | configuration, startup |
| `auth.go` | authentication: HTTP Basic or OpenID Connect (discovery, PKCE, ID token checks, session cookie) |
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
| `query.go` | turns UI filters into LogsQL; live-view filter |
+631
View File
@@ -0,0 +1,631 @@
package main
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
_ "crypto/sha512" // SHA-384/512 for RS384, ES384, RS512…
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Web UI authentication. AUTH_MODE=local (default) keeps the optional HTTP Basic
// authentication (AUTH_USER / AUTH_PASS); AUTH_MODE=oidc delegates the login to an
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
// flow and PKCE. Only the standard library is used.
const (
sessionCookie = "logstream_session"
loginCookie = "logstream_login_" // + state: one cookie per login in progress
loginTTL = 10 * time.Minute
clockSkew = time.Minute
)
type authConfig struct {
mode string
user, pass string // local mode
issuer string
clientID string
clientSecret string
redirectURL string
scopes string
sessionTTL time.Duration
dataDir string
}
// newAuth returns the middleware that protects the UI and the API (except /healthz).
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
switch strings.ToLower(c.mode) {
case "", "local":
return basicAuth(c.user, c.pass, next), nil
case "oidc":
o, err := newOIDC(c)
if err != nil {
return nil, err
}
o.next = next
log.Printf("oidc authentication enabled (issuer %s)", c.issuer)
return o, nil
}
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode)
}
// basicAuth protects the UI when AUTH_USER is set (except /healthz).
func basicAuth(user, pass string, next http.Handler) http.Handler {
if user == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
u, p, ok := r.BasicAuth()
if !ok ||
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="logstream"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
type oidcMeta struct {
Issuer string `json:"issuer"`
AuthEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
EndSession string `json:"end_session_endpoint"`
TokenAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
}
type OIDC struct {
cfg authConfig
callback string // path of OIDC_REDIRECT_URL
secure bool // cookies only sent over HTTPS
key []byte // signs the session and login cookies
client *http.Client
next http.Handler
mu sync.Mutex
meta *oidcMeta
keys map[string]crypto.PublicKey
keysAt time.Time
}
func newOIDC(c authConfig) (*OIDC, error) {
var missing []string
for _, v := range [][2]string{
{"OIDC_ISSUER", c.issuer}, {"OIDC_CLIENT_ID", c.clientID},
{"OIDC_CLIENT_SECRET", c.clientSecret}, {"OIDC_REDIRECT_URL", c.redirectURL},
} {
if v[1] == "" {
missing = append(missing, v[0])
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("AUTH_MODE=oidc: missing %s", strings.Join(missing, ", "))
}
ru, err := url.Parse(c.redirectURL)
if err != nil || ru.Host == "" || ru.Path == "" || ru.Path == "/" {
return nil, fmt.Errorf("OIDC_REDIRECT_URL=%q: expected a full URL such as https://logs.example.org/auth/callback", c.redirectURL)
}
if c.scopes == "" {
c.scopes = "openid profile email"
}
if !strings.Contains(" "+c.scopes+" ", " openid ") {
c.scopes = "openid " + c.scopes
}
if c.sessionTTL <= 0 {
c.sessionTTL = 12 * time.Hour
}
return &OIDC{
cfg: c,
callback: ru.Path,
secure: ru.Scheme == "https",
key: sessionKey(c.dataDir),
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
// checkProvider reads the provider configuration at startup so a mistake shows in the logs.
func (o *OIDC) checkProvider() {
if _, err := o.discover(); err != nil {
log.Printf("oidc: %v", err)
}
}
// sessionKey is kept in DATA_DIR so sessions survive a restart.
func sessionKey(dir string) []byte {
path := filepath.Join(dir, "session.key")
if k, err := os.ReadFile(path); err == nil && len(k) >= 32 {
return k
}
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
log.Fatalf("session key: %v", err)
}
if err := os.WriteFile(path, k, 0o600); err != nil {
log.Printf("oidc: cannot save %s (%v): sessions end when logstream restarts", path, err)
}
return k
}
type session struct {
User string `json:"u"`
Exp int64 `json:"e"`
}
type loginState struct {
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
Exp int64 `json:"e"`
}
func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
o.next.ServeHTTP(w, r)
return
case o.callback:
o.handleCallback(w, r)
return
case "/auth/logout":
o.handleLogout(w, r)
return
}
var s session
if c, err := r.Cookie(sessionCookie); err == nil && o.verifyCookie(c.Value, &s) && time.Now().Unix() < s.Exp {
if r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User})
return
}
o.next.ServeHTTP(w, r)
return
}
// Not logged in: pages go to the provider, API calls get a 401 that the UI turns
// into a reload (and so into a new login).
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me" {
o.startLogin(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
}
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
meta, err := o.discover()
if err != nil {
log.Printf("oidc: %v", err)
http.Error(w, "identity provider unreachable, try again later", http.StatusBadGateway)
return
}
state, nonce, verifier := randomString(), randomString(), randomString()+randomString()
ret := r.URL.RequestURI()
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") {
ret = "/"
}
http.SetCookie(w, &http.Cookie{
Name: loginCookie + state,
Value: o.signCookie(loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
Path: "/",
MaxAge: int(loginTTL.Seconds()),
HttpOnly: true,
Secure: o.secure,
SameSite: http.SameSiteLaxMode, // sent back on the redirect from the provider
})
challenge := sha256.Sum256([]byte(verifier))
q := url.Values{
"response_type": {"code"},
"client_id": {o.cfg.clientID},
"redirect_uri": {o.cfg.redirectURL},
"scope": {o.cfg.scopes},
"state": {state},
"nonce": {nonce},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, addQuery(meta.AuthEndpoint, q), http.StatusFound)
}
func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if e := q.Get("error"); e != "" {
log.Printf("oidc: login refused by the provider: %s %s", e, q.Get("error_description"))
http.Error(w, "login refused by the identity provider: "+e, http.StatusForbidden)
return
}
state := q.Get("state")
var ls loginState
c, err := r.Cookie(loginCookie + state)
if state == "" || err != nil || !o.verifyCookie(c.Value, &ls) || time.Now().Unix() > ls.Exp {
http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
user, err := o.exchange(r, q.Get("code"), ls)
if err != nil {
log.Printf("oidc: login failed: %v", err)
http.Error(w, "login failed, see the logstream logs", http.StatusForbidden)
return
}
log.Printf("oidc: %s logged in", user)
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: o.signCookie(session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
Path: "/",
MaxAge: int(o.cfg.sessionTTL.Seconds()),
HttpOnly: true,
Secure: o.secure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, ls.Return, http.StatusFound)
}
// The session ends here; the provider's own session ends on its logout page if it has one.
func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
if meta, err := o.discover(); err == nil && meta.EndSession != "" {
http.Redirect(w, r, addQuery(meta.EndSession, url.Values{"client_id": {o.cfg.clientID}}), http.StatusFound)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
// exchange trades the code for tokens and returns the user name from the verified ID token.
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) {
if code == "" {
return "", errors.New("no code in the callback")
}
meta, err := o.discover()
if err != nil {
return "", err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {o.cfg.redirectURL},
"code_verifier": {ls.Verifier},
}
// client_secret_basic is the default; some providers only accept client_secret_post.
post := len(meta.TokenAuthMethods) > 0 && !contains(meta.TokenAuthMethods, "client_secret_basic") && contains(meta.TokenAuthMethods, "client_secret_post")
if post {
form.Set("client_id", o.cfg.clientID)
form.Set("client_secret", o.cfg.clientSecret)
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if !post {
req.SetBasicAuth(url.QueryEscape(o.cfg.clientID), url.QueryEscape(o.cfg.clientSecret))
}
res, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("token endpoint: %w", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
}
var tok struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" {
return "", errors.New("token endpoint: no id_token in the response")
}
claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce)
if err != nil {
return "", err
}
for _, k := range []string{"preferred_username", "email", "name", "sub"} {
if v, _ := claims[k].(string); v != "" {
return v, nil
}
}
return "", errors.New("id_token: no sub")
}
// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token.
func (o *OIDC) verifyIDToken(raw, nonce string) (map[string]any, error) {
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return nil, errors.New("id_token: not a JWT")
}
var hdr struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
if err := decodeSegment(parts[0], &hdr); err != nil {
return nil, fmt.Errorf("id_token header: %w", err)
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, errors.New("id_token: bad signature encoding")
}
key, err := o.keyFor(hdr.Kid)
if err != nil {
return nil, err
}
if err := verifySignature(hdr.Alg, key, []byte(parts[0]+"."+parts[1]), sig); err != nil {
return nil, fmt.Errorf("id_token: %w", err)
}
var claims map[string]any
if err := decodeSegment(parts[1], &claims); err != nil {
return nil, fmt.Errorf("id_token claims: %w", err)
}
if iss, _ := claims["iss"].(string); iss != o.cfg.issuer {
return nil, fmt.Errorf("id_token: issuer %q, expected %q", iss, o.cfg.issuer)
}
var aud []string
switch v := claims["aud"].(type) {
case string:
aud = []string{v}
case []any:
for _, a := range v {
if s, ok := a.(string); ok {
aud = append(aud, s)
}
}
}
if !contains(aud, o.cfg.clientID) {
return nil, fmt.Errorf("id_token: audience %v does not include %q", aud, o.cfg.clientID)
}
if azp, ok := claims["azp"].(string); ok && len(aud) > 1 && azp != o.cfg.clientID {
return nil, fmt.Errorf("id_token: azp %q", azp)
}
now := time.Now()
exp, _ := claims["exp"].(float64)
if exp == 0 || now.After(time.Unix(int64(exp), 0).Add(clockSkew)) {
return nil, errors.New("id_token: expired (check the clocks)")
}
if iat, ok := claims["iat"].(float64); ok && time.Unix(int64(iat), 0).After(now.Add(clockSkew)) {
return nil, errors.New("id_token: issued in the future (check the clocks)")
}
if n, _ := claims["nonce"].(string); subtle.ConstantTimeCompare([]byte(n), []byte(nonce)) != 1 {
return nil, errors.New("id_token: wrong nonce")
}
return claims, nil
}
func verifySignature(alg string, key crypto.PublicKey, signed, sig []byte) error {
if len(alg) != 5 {
return fmt.Errorf("unsupported algorithm %q", alg)
}
var h crypto.Hash
switch alg[2:] {
case "256":
h = crypto.SHA256
case "384":
h = crypto.SHA384
case "512":
h = crypto.SHA512
}
if h == 0 {
return fmt.Errorf("unsupported algorithm %q", alg)
}
hh := h.New()
hh.Write(signed)
digest := hh.Sum(nil)
switch k := key.(type) {
case *rsa.PublicKey:
switch alg[:2] {
case "RS":
return rsa.VerifyPKCS1v15(k, h, digest, sig)
case "PS":
return rsa.VerifyPSS(k, h, digest, sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash})
}
case *ecdsa.PublicKey:
size := (k.Curve.Params().BitSize + 7) / 8
if alg[:2] != "ES" || len(sig) != 2*size {
break
}
r, s := new(big.Int).SetBytes(sig[:size]), new(big.Int).SetBytes(sig[size:])
if ecdsa.Verify(k, digest, r, s) {
return nil
}
return errors.New("bad signature")
}
return fmt.Errorf("algorithm %q does not match the key", alg)
}
// discover reads the provider configuration once (and again after a failure).
func (o *OIDC) discover() (*oidcMeta, error) {
o.mu.Lock()
defer o.mu.Unlock()
if o.meta != nil {
return o.meta, nil
}
u := strings.TrimSuffix(o.cfg.issuer, "/") + "/.well-known/openid-configuration"
var m oidcMeta
if err := o.getJSON(u, &m); err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
if m.Issuer != o.cfg.issuer {
return nil, fmt.Errorf("discovery: the provider says its issuer is %q, set OIDC_ISSUER to that exact value", m.Issuer)
}
if m.AuthEndpoint == "" || m.TokenEndpoint == "" || m.JWKSURI == "" {
return nil, errors.New("discovery: incomplete provider configuration")
}
o.meta = &m
return o.meta, nil
}
// keyFor returns the signing key kid; the key set is reloaded when the provider rotates its keys.
func (o *OIDC) keyFor(kid string) (crypto.PublicKey, error) {
meta, err := o.discover()
if err != nil {
return nil, err
}
o.mu.Lock()
defer o.mu.Unlock()
pick := func() crypto.PublicKey {
if k, ok := o.keys[kid]; ok {
return k
}
if kid == "" && len(o.keys) == 1 {
for _, k := range o.keys {
return k
}
}
return nil
}
if k := pick(); k != nil {
return k, nil
}
if time.Since(o.keysAt) < 10*time.Second {
return nil, fmt.Errorf("id_token: unknown key %q", kid)
}
var set struct {
Keys []struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
} `json:"keys"`
}
if err := o.getJSON(meta.JWKSURI, &set); err != nil {
return nil, fmt.Errorf("jwks: %w", err)
}
keys := map[string]crypto.PublicKey{}
for _, k := range set.Keys {
if k.Use != "" && k.Use != "sig" {
continue
}
switch k.Kty {
case "RSA":
n, e := decodeBig(k.N), decodeBig(k.E)
if n != nil && e != nil && e.IsInt64() {
keys[k.Kid] = &rsa.PublicKey{N: n, E: int(e.Int64())}
}
case "EC":
var c elliptic.Curve
switch k.Crv {
case "P-256":
c = elliptic.P256()
case "P-384":
c = elliptic.P384()
case "P-521":
c = elliptic.P521()
}
x, y := decodeBig(k.X), decodeBig(k.Y)
if c != nil && x != nil && y != nil && c.IsOnCurve(x, y) {
keys[k.Kid] = &ecdsa.PublicKey{Curve: c, X: x, Y: y}
}
}
}
o.keys, o.keysAt = keys, time.Now()
if k := pick(); k != nil {
return k, nil
}
return nil, fmt.Errorf("id_token: unknown key %q", kid)
}
func (o *OIDC) getJSON(u string, v any) error {
res, err := o.client.Get(u)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", u, res.Status)
}
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v)
}
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
func (o *OIDC) signCookie(v any) string {
b, _ := json.Marshal(v)
p := base64.RawURLEncoding.EncodeToString(b)
m := hmac.New(sha256.New, o.key)
m.Write([]byte(p))
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
func (o *OIDC) verifyCookie(s string, v any) bool {
p, sig, ok := strings.Cut(s, ".")
if !ok {
return false
}
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil {
return false
}
m := hmac.New(sha256.New, o.key)
m.Write([]byte(p))
if !hmac.Equal(got, m.Sum(nil)) {
return false
}
return decodeSegment(p, v) == nil
}
func decodeSegment(s string, v any) error {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
func decodeBig(s string) *big.Int {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil || len(b) == 0 {
return nil
}
return new(big.Int).SetBytes(b)
}
func randomString() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func addQuery(endpoint string, q url.Values) string {
sep := "?"
if strings.Contains(endpoint, "?") {
sep = "&"
}
return endpoint + sep + q.Encode()
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
+230
View File
@@ -0,0 +1,230 @@
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// hosts routes requests to in-memory handlers (no listening socket needed).
type hosts map[string]http.Handler
func (h hosts) RoundTrip(r *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
h[r.URL.Host].ServeHTTP(rec, r)
res := rec.Result()
res.Request = r
return res, nil
}
// fakeIdP is a minimal OpenID provider: it logs in "alice" without asking.
type fakeIdP struct {
mux *http.ServeMux
rsaKey *rsa.PrivateKey
ecKey *ecdsa.PrivateKey
useEC bool
codes map[string]url.Values // code -> authorize request
claims func(map[string]any) // last-minute changes to the ID token
tokenErr bool
}
func newFakeIdP(t *testing.T) *fakeIdP {
rk, _ := rsa.GenerateKey(rand.Reader, 2048)
ek, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
p := &fakeIdP{rsaKey: rk, ecKey: ek, codes: map[string]url.Values{}}
mux := http.NewServeMux()
p.mux = mux
iss := "http://idp.test/realm"
mux.HandleFunc("/realm/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": iss, "authorization_endpoint": iss + "/auth", "token_endpoint": iss + "/token",
"jwks_uri": iss + "/jwks", "end_session_endpoint": iss + "/logout",
})
})
mux.HandleFunc("/realm/jwks", func(w http.ResponseWriter, r *http.Request) {
b := func(i *big.Int) string { return base64.RawURLEncoding.EncodeToString(i.Bytes()) }
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{
map[string]string{"kty": "RSA", "kid": "r1", "use": "sig", "n": b(rk.N), "e": "AQAB"},
map[string]string{"kty": "EC", "kid": "e1", "crv": "P-256", "x": b(ek.X), "y": b(ek.Y)},
}})
})
mux.HandleFunc("/realm/auth", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
code := randomString()
p.codes[code] = q
http.Redirect(w, r, q.Get("redirect_uri")+"?code="+code+"&state="+q.Get("state"), http.StatusFound)
})
mux.HandleFunc("/realm/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
id, secret, _ := r.BasicAuth()
authz, ok := p.codes[r.Form.Get("code")]
sum := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
if p.tokenErr || !ok || id != "logstream" || secret != "s3cret" ||
base64.RawURLEncoding.EncodeToString(sum[:]) != authz.Get("code_challenge") ||
r.Form.Get("redirect_uri") != authz.Get("redirect_uri") {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
return
}
delete(p.codes, r.Form.Get("code"))
c := map[string]any{
"iss": iss, "aud": "logstream", "sub": "123", "preferred_username": "alice",
"exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), "nonce": authz.Get("nonce"),
}
if p.claims != nil {
p.claims(c)
}
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "x", "id_token": p.sign(c)})
})
return p
}
func (p *fakeIdP) sign(claims map[string]any) string {
alg, kid := "RS256", "r1"
if p.useEC {
alg, kid = "ES256", "e1"
}
h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid, "typ": "JWT"})
c, _ := json.Marshal(claims)
in := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(c)
d := sha256.Sum256([]byte(in))
var sig []byte
if p.useEC {
r, s, _ := ecdsa.Sign(rand.Reader, p.ecKey, d[:])
sig = make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
} else {
sig, _ = rsa.SignPKCS1v15(rand.Reader, p.rsaKey, crypto.SHA256, d[:])
}
return in + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// newOIDCApp puts logstream's auth in front of a handler that echoes "app" and returns
// a browser (client with cookies) that reaches both the app and the provider.
func newOIDCApp(t *testing.T, idp *fakeIdP) (string, *http.Client) {
h, err := newAuth(authConfig{
mode: "oidc", issuer: "http://idp.test/realm", clientID: "logstream", clientSecret: "s3cret",
redirectURL: "http://app.test/auth/callback", dataDir: t.TempDir(),
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) }))
if err != nil {
t.Fatal(err)
}
net := hosts{"app.test": h, "idp.test": idp.mux}
h.(*OIDC).client.Transport = net
jar, _ := cookiejar.New(nil)
return "http://app.test", &http.Client{Jar: jar, Transport: net}
}
func get(t *testing.T, c *http.Client, u string) (int, string) {
t.Helper()
res, err := c.Get(u)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := res.Body.Read(buf)
b.Write(buf[:n])
if err != nil {
break
}
}
return res.StatusCode, b.String()
}
func TestOIDCLoginFlow(t *testing.T) {
for _, ec := range []bool{false, true} {
idp := newFakeIdP(t)
idp.useEC = ec
app, c := newOIDCApp(t, idp)
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatalf("api without session: %d", code)
}
if code, body := get(t, c, app+"/healthz"); code != 200 || body != "app /healthz" {
t.Fatalf("healthz: %d %q", code, body)
}
// A page goes through the provider and comes back to the page asked for.
if code, body := get(t, c, app+"/index.html?x=1"); code != 200 || body != "app /index.html" {
t.Fatalf("login (ec=%v): %d %q", ec, code, body)
}
if code, body := get(t, c, app+"/api/logs"); code != 200 || body != "app /api/logs" {
t.Fatalf("api with session: %d %q", code, body)
}
if code, body := get(t, c, app+"/auth/me"); code != 200 || !strings.Contains(body, `"user":"alice"`) {
t.Fatalf("me: %d %q", code, body)
}
// Logout drops the session (the fake provider has no logout page: 404).
get(t, c, app+"/auth/logout")
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatalf("api after logout: %d", code)
}
}
}
func TestOIDCRejectsBadTokens(t *testing.T) {
cases := map[string]func(map[string]any){
"wrong nonce": func(c map[string]any) { c["nonce"] = "x" },
"wrong audience": func(c map[string]any) { c["aud"] = "other" },
"wrong issuer": func(c map[string]any) { c["iss"] = "https://evil" },
"expired": func(c map[string]any) { c["exp"] = time.Now().Add(-time.Hour).Unix() },
}
for name, change := range cases {
idp := newFakeIdP(t)
idp.claims = change
app, c := newOIDCApp(t, idp)
if code, _ := get(t, c, app+"/"); code != http.StatusForbidden {
t.Errorf("%s: login gave %d, expected 403", name, code)
}
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Errorf("%s: session created", name)
}
}
}
func TestOIDCCallbackNeedsLoginCookie(t *testing.T) {
idp := newFakeIdP(t)
app, c := newOIDCApp(t, idp)
if code, _ := get(t, c, app+"/auth/callback?code=abc&state=forged"); code != http.StatusBadRequest {
t.Fatalf("forged callback: %d", code)
}
}
func TestOIDCForgedSessionCookie(t *testing.T) {
idp := newFakeIdP(t)
app, c := newOIDCApp(t, idp)
u, _ := url.Parse(app)
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"u":"mallory","e":9999999999}`))
c.Jar.SetCookies(u, []*http.Cookie{{Name: sessionCookie, Value: payload + ".AAAA"}})
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatalf("forged session accepted: %d", code)
}
}
func TestAuthModeConfig(t *testing.T) {
next := http.NotFoundHandler()
if _, err := newAuth(authConfig{mode: "oidc"}, next); err == nil || !strings.Contains(err.Error(), "OIDC_CLIENT_ID") {
t.Errorf("missing variables not reported: %v", err)
}
if _, err := newAuth(authConfig{mode: "ldap"}, next); err == nil {
t.Error("unknown mode accepted")
}
if h, err := newAuth(authConfig{mode: "local"}, next); err != nil || h == nil {
t.Errorf("local mode: %v", err)
}
}
+7
View File
@@ -14,8 +14,15 @@ services:
VLOGS_URL: http://victorialogs:9428
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
TZ: ${TZ:-Europe/Paris}
AUTH_MODE: ${AUTH_MODE:-local} # local (Basic Auth ci-dessous) ou oidc
AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
AUTH_PASS: ${AUTH_PASS:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
OIDC_SESSION_TTL: ${OIDC_SESSION_TTL:-12h}
RDNS: ${RDNS:-on} # resol dns
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
ALLOW_PURGE: ${ALLOW_PURGE:-true}
+22 -28
View File
@@ -4,7 +4,6 @@ package main
import (
"context"
"crypto/subtle"
"embed"
"errors"
"io/fs"
@@ -29,8 +28,7 @@ type config struct {
httpAddr string
vlogsURL string
dataDir string
authUser string
authPass string
auth authConfig
rdns bool
dnsServer string
allowPurge bool
@@ -82,8 +80,17 @@ func main() {
httpAddr: getenv("HTTP_ADDR", ":8080"),
vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"),
dataDir: getenv("DATA_DIR", "/data"),
authUser: os.Getenv("AUTH_USER"),
authPass: os.Getenv("AUTH_PASS"),
auth: authConfig{
mode: getenv("AUTH_MODE", "local"),
user: os.Getenv("AUTH_USER"),
pass: os.Getenv("AUTH_PASS"),
issuer: os.Getenv("OIDC_ISSUER"),
clientID: os.Getenv("OIDC_CLIENT_ID"),
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
scopes: os.Getenv("OIDC_SCOPES"),
sessionTTL: getenvDuration("OIDC_SESSION_TTL", 12*time.Hour),
},
rdns: getenvBool("RDNS", true),
dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true),
@@ -98,6 +105,8 @@ func main() {
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
}
cfg.auth.dataDir = cfg.dataDir
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -149,9 +158,16 @@ func main() {
api.Routes(mux)
mux.Handle("GET /", http.FileServer(http.FS(static)))
handler, err := newAuth(cfg.auth, mux)
if err != nil {
log.Fatalf("auth: %v", err)
}
if o, ok := handler.(*OIDC); ok {
go o.checkProvider()
}
srv := &http.Server{
Addr: cfg.httpAddr,
Handler: basicAuth(cfg.authUser, cfg.authPass, mux),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
// Requests inherit the global context so SSE streams end on shutdown.
BaseContext: func(net.Listener) context.Context { return ctx },
@@ -170,25 +186,3 @@ func main() {
<-storeDone
log.Println("shutdown complete")
}
// basicAuth protects the UI when AUTH_USER is set (except /healthz).
func basicAuth(user, pass string, next http.Handler) http.Handler {
if user == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
u, p, ok := r.BasicAuth()
if !ok ||
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="logstream"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+14 -1
View File
@@ -14,6 +14,7 @@ const I18N = {
liveUnavailable: 'Live view is not available in LogsQL mode',
settings: 'Settings',
theme: 'Light / dark theme',
logout: 'Log out',
close: 'Close',
rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application',
histoAria: 'Log volume over time',
@@ -157,6 +158,7 @@ const I18N = {
liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL',
settings: 'Paramètres',
theme: 'Thème clair / sombre',
logout: 'Se déconnecter',
close: 'Fermer',
rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application',
histoAria: 'Volume de logs dans le temps',
@@ -409,6 +411,8 @@ async function api(url, opts = {}) {
// Known error codes are translated; otherwise the server message is shown.
function apiError(res, text, data) {
// OIDC session expired: reloading the page goes through the login again.
if (res.status === 401 && data && data.code === 'auth') location.reload();
let msg = (data && data.error) || text || res.statusText;
if (data && data.code && I18N[lang]['err_' + data.code]) {
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
@@ -452,7 +456,7 @@ const list = $('#list');
function applyLang() {
document.documentElement.lang = lang;
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle) + (el.dataset.user ? ` (${el.dataset.user})` : '');
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh);
for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang));
@@ -2099,6 +2103,15 @@ $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
$('#severity').value = store.get('severity', '');
// With OIDC login, show who is logged in and the log out button.
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
if (!me || !me.user) return;
const btn = $('#logoutBtn');
btn.hidden = false;
btn.dataset.user = me.user;
btn.title = `${t('logout')} (${me.user})`;
}).catch(() => {});
(async () => {
await loadTags();
loadFacets();
+4
View File
@@ -41,6 +41,10 @@
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<a id="logoutBtn" class="icon-btn" href="/auth/logout" hidden data-i18n-title="logout" data-i18n-aria="logout">
<!-- Log out icon (Lucide "log-out", ISC license) -->
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
</a>
</div>
<div class="progress" aria-hidden="true"></div>
</header>