feat: shiroa book, ACL parser, and OIDC-gated Go server

- docs/: shiroa book with three example categories (thermo, security,
  homelab); pages declare access via '#let access-groups = (...)'
- cmd/parser: scans docs/ and generates acl.json (public by default,
  skips book.typ/template.typ/partials)
- internal/acl: loads acl.json, normalizes URLs to page keys so a page's
  renderer data (*.multi.sir.in) inherits its protection; fail closed on
  malformed input
- internal/oidc: authorization-code flow client, verified ID tokens,
  groups claim from Authentik
- internal/middleware: in-memory sessions, login/logout/callback handlers,
  CSRF states, access gate + security headers
- cmd/server + embed.go: single binary embedding dist/ and acl.json;
  /searchindex.json blocked; graceful shutdown
- Justfile (build/run/parse/check/clean/preview-docs), Gitea Actions
  workflow (shiroa v0.4.0-rc2), rootless Dockerfile, README
- legacy docs moved to legacy_docs/; .idea untracked
This commit is contained in:
Klagarge committed 2026-10-10 18:42:40 +02:00
1 parent 3601def1f6
commit fc1b45058c
38 files changed
+1367 -598

No files matched your search

+98 -15
View File
@@ -1,39 +1,122 @@
// Package acl loads the access control list generated by cmd/parser and
// answers authorization questions for the HTTP middleware.
//
// acl.json maps the URL of each protected page ("/thermo/fluids.html") to
// the groups allowed to read it. URLs absent from the list are public.
//
// Keys are normalized to a "page key": the URL path reduced to the page it
// belongs to, e.g. "/thermo/fluids.html" and "/thermo/fluids.html" both
// become "/thermo/fluids". The shiroa renderer fetches the page content
// from companion files such as "/thermo/fluids.light.multi.sir.in", which
// reduce to the same key, so a protected page's content cannot be fetched
// directly while the page itself is protected.
package acl
import (
"encoding/json"
"fmt"
"path"
"strings"
)
// ACL maps page keys ("/thermo/fluids") to the groups allowed to read them.
type ACL map[string][]string
// Load parses acl.json and normalizes its keys. A malformed file is an error:
// the caller must refuse to start rather than serve protected pages as
// public (fail closed).
func Load(data []byte) (ACL, error) {
var acl ACL
if err := json.Unmarshal(data, &acl); err != nil {
return nil, fmt.Errorf("parsing acl json: %w", err)
var raw map[string][]string
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing acl.json: %w", err)
}
acl := make(ACL, len(raw))
for urlPath, groups := range raw {
if len(groups) == 0 {
continue
}
key, err := PageKey(urlPath)
if err != nil {
return nil, fmt.Errorf("acl entry %q: %w", urlPath, err)
}
// Merge entries that reduce to the same page (e.g. a page and
// its directory index): require the union of their groups.
acl[key] = appendUnique(acl[key], groups...)
}
return acl, nil
}
// IsProtected checks if a path has specific access groups defined
func (a ACL) IsProtected(path string) bool {
groups, ok := a[path]
return ok && len(groups) > 0
// PageKey reduces a URL path to the page it belongs to.
//
// "/thermo/fluids.html" -> "/thermo/fluids"
// "/thermo/fluids.light.multi.sir.in" -> "/thermo/fluids"
// "/" -> "/index"
// "/thermo/" -> "/thermo/index"
func PageKey(urlPath string) (string, error) {
if !strings.HasPrefix(urlPath, "/") {
return "", fmt.Errorf("not an absolute path")
}
// A request for a directory serves that directory's index page.
if urlPath != "/" && strings.HasSuffix(urlPath, "/") {
urlPath += "index.html"
}
p := path.Clean(urlPath)
if p == "/" {
return "/index", nil
}
dir, base := path.Split(p)
if i := strings.Index(base, "."); i >= 0 {
base = base[:i]
}
if base == "" {
base = "index"
}
return path.Join(dir, base), nil
}
// Allowed checks if a list of user groups has any overlap with the required groups for a path
func (a ACL) Allowed(path string, userGroups []string) bool {
requiredGroups, ok := a[path]
if !ok || len(requiredGroups) == 0 {
return true // public
// GroupsFor returns the union of the groups required by any of the given
// page keys, and whether at least one of them is protected.
func (a ACL) GroupsFor(keys ...string) (required []string, protected bool) {
seen := make(map[string]struct{})
for _, k := range keys {
for _, g := range a[k] {
if _, dup := seen[g]; !dup {
seen[g] = struct{}{}
required = append(required, g)
}
protected = true
}
}
return required, protected
}
for _, req := range requiredGroups {
for _, user := range userGroups {
if req == user {
// Allows reports whether the user's groups include at least one of the
// required groups.
func Allows(userGroups, required []string) bool {
for _, u := range userGroups {
for _, r := range required {
if u == r {
return true
}
}
}
return false
}
func appendUnique(dst []string, groups ...string) []string {
for _, g := range groups {
if !contains(dst, g) {
dst = append(dst, g)
}
}
return dst
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
+67
View File
@@ -0,0 +1,67 @@
package acl
import "testing"
func TestPageKey(t *testing.T) {
cases := []struct{ in, want string }{
{"/", "/index"},
{"/index.html", "/index"},
{"/thermo/fluids.html", "/thermo/fluids"},
// The renderer fetches content from theme variants; they must map
// to the same page or the ACL would be bypassable.
{"/thermo/fluids.light.multi.sir.in", "/thermo/fluids"},
{"/thermo/fluids.ayu.multi.sir.in", "/thermo/fluids"},
// Directory requests resolve to the directory index page.
{"/thermo/", "/thermo/index"},
{"/thermo", "/thermo"},
// Path traversal is neutralized before matching.
{"/thermo/../security/ssh.html", "/security/ssh"},
}
for _, c := range cases {
if got, err := PageKey(c.in); err != nil {
t.Errorf("PageKey(%q) error: %v", c.in, err)
} else if got != c.want {
t.Errorf("PageKey(%q) = %q, want %q", c.in, got, c.want)
}
}
if _, err := PageKey("relative.html"); err == nil {
t.Error("PageKey must reject relative paths")
}
}
func TestLoadAndCheck(t *testing.T) {
// Keys in acl.json are final page URLs; Load normalizes them.
data := []byte(`{
"/thermo/fluids.html": ["family", "admin"],
"/index.html": ["admin"],
"/public.html": ["family"]
}`)
a, err := Load(data)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got, _ := a.GroupsFor("/thermo/fluids"); len(got) != 2 {
t.Errorf("expected 2 groups for /thermo/fluids, got %v", got)
}
if _, protected := a.GroupsFor("/thermo/fluids"); !protected {
t.Error("/thermo/fluids should be protected")
}
if _, protected := a.GroupsFor("/thermo/heat-pumps"); protected {
t.Error("/thermo/heat-pumps should be public")
}
if !Allows([]string{"family"}, []string{"family", "admin"}) {
t.Error("family should be allowed")
}
if Allows([]string{"guest"}, []string{"family", "admin"}) {
t.Error("guest should be denied")
}
if Allows(nil, []string{"admin"}) {
t.Error("no groups should never be allowed on a protected page")
}
}
func TestLoadMalformed(t *testing.T) {
if _, err := Load([]byte("not json")); err == nil {
t.Fatal("malformed acl.json must be an error (fail closed)")
}
}
+89
View File
@@ -0,0 +1,89 @@
// Package middleware implements the access-control layer of the server:
// OIDC login/logout, cookie sessions, and the gate that decides for every
// request whether its URL may be served to the caller.
package middleware
import (
"net/http"
"net/url"
"sync"
"time"
"github.com/Klagarge/yggdrasil/internal/acl"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
const (
sessionCookie = "ygg_session"
stateTTL = 10 * time.Minute
)
// AccessControl bundles everything the gate needs.
type AccessControl struct {
ACL acl.ACL
Sessions *SessionStore
Auth *oidc.Authenticator // nil: OIDC disabled
CookieSecure bool
stateMu sync.Mutex
states map[string]loginState // pending OIDC flows, keyed by state
}
// NewAccessControl wires the gate. auth may be nil (OIDC disabled): public
// pages keep working and protected pages answer 503.
func NewAccessControl(a acl.ACL, sessions *SessionStore, auth *oidc.Authenticator, cookieSecure bool) *AccessControl {
return &AccessControl{
ACL: a,
Sessions: sessions,
Auth: auth,
CookieSecure: cookieSecure,
states: make(map[string]loginState),
}
}
// Wrap returns a handler that serves next only when the request is allowed.
//
// The request path is reduced to a page key (see acl.PageKey) and looked up
// in the ACL. Public pages pass through; protected pages require a session
// whose groups include at least one of the groups required by the ACL.
func (ac *AccessControl) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, err := acl.PageKey(r.URL.Path)
if err != nil {
http.Error(w, "invalid request path", http.StatusBadRequest)
return
}
required, protected := ac.ACL.GroupsFor(key)
if !protected {
next.ServeHTTP(w, r)
return
}
if ac.Auth == nil {
http.Error(w, "this page is protected, but the server has no OIDC configuration",
http.StatusServiceUnavailable)
return
}
sess, ok := ac.session(r)
if !ok {
http.Redirect(w, r, "/login?target="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
if !acl.Allows(sess.Groups, required) {
http.Error(w, "403 Forbidden: your account does not have access to this page.",
http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// SecurityHeaders sets conservative defaults on every response.
func (ac *AccessControl) SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
+141
View File
@@ -0,0 +1,141 @@
package middleware
import (
"net/http"
"strings"
"time"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
// loginState remembers where a pending OIDC flow must return.
type loginState struct {
target string
expires time.Time
}
// session reads the session cookie and returns the session if valid.
func (ac *AccessControl) session(r *http.Request) (*Session, bool) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return nil, false
}
return ac.Sessions.Get(c.Value)
}
func (ac *AccessControl) setSessionCookie(w http.ResponseWriter, id string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: id,
Path: "/",
HttpOnly: true,
Secure: ac.CookieSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(ac.Sessions.TTL() / time.Second),
})
}
func (ac *AccessControl) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: ac.CookieSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// Login redirects to the OIDC provider, remembering where to return.
func (ac *AccessControl) Login(w http.ResponseWriter, r *http.Request) {
if ac.Auth == nil {
http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable)
return
}
target := sanitizeTarget(r.URL.Query().Get("target"))
state := randomID()
ac.saveState(state, target)
http.Redirect(w, r, ac.Auth.AuthCodeURL(state), http.StatusFound)
}
// Callback finishes the OIDC flow: it verifies the provider's response and
// starts a session.
func (ac *AccessControl) Callback(w http.ResponseWriter, r *http.Request) {
if ac.Auth == nil {
http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable)
return
}
target, ok := ac.takeState(r.URL.Query().Get("state"))
if !ok {
http.Error(w, "unknown or expired login state; restart the login", http.StatusUnauthorized)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
token, err := ac.Auth.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "token exchange failed", http.StatusBadGateway)
return
}
idToken, err := ac.Auth.VerifyIDToken(r.Context(), token)
if err != nil {
http.Error(w, "invalid identity token", http.StatusUnauthorized)
return
}
profile, err := oidc.ProfileFromToken(idToken)
if err != nil {
http.Error(w, "unreadable identity token claims", http.StatusUnauthorized)
return
}
ac.setSessionCookie(w, ac.Sessions.Create(profile))
http.Redirect(w, r, target, http.StatusFound)
}
// Logout ends the session.
func (ac *AccessControl) Logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
ac.Sessions.Delete(c.Value)
}
ac.clearSessionCookie(w)
http.Redirect(w, r, "/", http.StatusFound)
}
func (ac *AccessControl) saveState(state, target string) {
ac.stateMu.Lock()
now := time.Now()
for k, v := range ac.states { // opportunistic purge
if now.After(v.expires) {
delete(ac.states, k)
}
}
ac.states[state] = loginState{target: target, expires: now.Add(stateTTL)}
ac.stateMu.Unlock()
}
func (ac *AccessControl) takeState(state string) (string, bool) {
if state == "" {
return "", false
}
ac.stateMu.Lock()
defer ac.stateMu.Unlock()
s, ok := ac.states[state]
if ok {
delete(ac.states, state) // states are single use
}
if !ok || time.Now().After(s.expires) {
return "", false
}
return s.target, true
}
// sanitizeTarget only accepts local redirect targets (open-redirect guard).
func sanitizeTarget(t string) string {
if t == "" || !strings.HasPrefix(t, "/") || strings.HasPrefix(t, "//") || strings.HasPrefix(t, "/\\") {
return "/"
}
return t
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"testing"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
func TestSanitizeTarget(t *testing.T) {
cases := map[string]string{
"": "/",
"/thermo/fluids.html": "/thermo/fluids.html",
"/a b/c.html?x=1": "/a b/c.html?x=1",
"//evil.example.com": "/",
"/\\evil.example.com": "/",
"https://evil.example.com": "/",
"relative.html": "/",
}
for in, want := range cases {
if got := sanitizeTarget(in); got != want {
t.Errorf("sanitizeTarget(%q) = %q, want %q", in, got, want)
}
}
}
func TestStateRoundTrip(t *testing.T) {
ac := NewAccessControl(nil, NewSessionStore(0), nil, false)
ac.saveState("s1", "/thermo/fluids.html")
target, ok := ac.takeState("s1")
if !ok || target != "/thermo/fluids.html" {
t.Fatalf("state round trip failed: %q %v", target, ok)
}
// States are single use.
if _, ok := ac.takeState("s1"); ok {
t.Error("state must be single use")
}
// Unknown states are rejected (CSRF guard).
if _, ok := ac.takeState("nope"); ok {
t.Error("unknown state must be rejected")
}
}
func TestSessionExpiry(t *testing.T) {
store := NewSessionStore(0) // zero TTL: expires immediately
id := store.Create(oidc.Profile{Subject: "sub", Email: "user@example.com", Groups: []string{"family"}})
if _, ok := store.Get(id); ok {
t.Error("expired session must not validate")
}
}
+101
View File
@@ -0,0 +1,101 @@
package middleware
import (
"context"
"crypto/rand"
"encoding/base64"
"sync"
"time"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
// Session is an authenticated user.
type Session struct {
Subject string
Email string
Name string
Groups []string
Expires time.Time
}
// SessionStore keeps sessions in memory. Sessions do not survive server
// restarts; users simply log in again.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
ttl time.Duration
}
// NewSessionStore creates a store with the given session lifetime.
func NewSessionStore(ttl time.Duration) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
ttl: ttl,
}
}
// TTL reports the session lifetime.
func (s *SessionStore) TTL() time.Duration { return s.ttl }
// Create registers a new session and returns its ID.
func (s *SessionStore) Create(p oidc.Profile) string {
id := randomID()
s.mu.Lock()
s.sessions[id] = &Session{
Subject: p.Subject,
Email: p.Email,
Name: p.Name,
Groups: p.Groups,
Expires: time.Now().Add(s.ttl),
}
s.mu.Unlock()
return id
}
// Get returns the session for id if it exists and has not expired.
func (s *SessionStore) Get(id string) (*Session, bool) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || time.Now().After(sess.Expires) {
return nil, false
}
return sess, true
}
// Delete removes a session.
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
delete(s.sessions, id)
s.mu.Unlock()
}
// StartJanitor purges expired sessions periodically until ctx is done.
// Call it in a goroutine.
func (s *SessionStore) StartJanitor(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
s.mu.Lock()
for id, sess := range s.sessions {
if now.After(sess.Expires) {
delete(s.sessions, id)
}
}
s.mu.Unlock()
}
}
}
func randomID() string {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err) // crypto/rand does not fail on supported platforms
}
return base64.RawURLEncoding.EncodeToString(b[:])
}
+118
View File
@@ -0,0 +1,118 @@
// Package oidc implements the OIDC client (authorization code flow) used to
// authenticate users. It is the only place that talks to the identity
// provider (e.g. Authentik).
package oidc
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// Config is the OIDC client configuration, read from the environment by
// LoadConfigFromEnv.
type Config struct {
ClientID string
ClientSecret string
IssuerURL string
RedirectURL string
Scopes string
}
// LoadConfigFromEnv reads the OIDC_* variables. It returns an error when
// OIDC is not (fully) configured; the caller decides whether that is fatal.
func LoadConfigFromEnv() (*Config, error) {
cfg := &Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
IssuerURL: os.Getenv("OIDC_ISSUER_URL"),
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
Scopes: os.Getenv("OIDC_SCOPES"),
}
if cfg.Scopes == "" {
cfg.Scopes = "openid profile email groups"
}
if cfg.ClientID == "" || cfg.IssuerURL == "" || cfg.RedirectURL == "" {
return nil, errors.New("OIDC_CLIENT_ID, OIDC_ISSUER_URL and OIDC_REDIRECT_URL must be set")
}
return cfg, nil
}
// Authenticator drives the authorization code flow and verifies ID tokens.
type Authenticator struct {
oauth2 oauth2.Config
verifier *oidc.IDTokenVerifier
}
// NewAuthenticator discovers the provider at the issuer URL. It fails fast
// when the issuer is unreachable so that a misconfigured server never
// starts serving protected content.
func NewAuthenticator(ctx context.Context, cfg *Config) (*Authenticator, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("querying OIDC provider %s: %w", cfg.IssuerURL, err)
}
return &Authenticator{
oauth2: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: strings.Fields(cfg.Scopes),
},
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
}, nil
}
// AuthCodeURL returns the URL the user is redirected to for login.
func (a *Authenticator) AuthCodeURL(state string) string {
return a.oauth2.AuthCodeURL(state)
}
// Exchange trades an authorization code for tokens.
func (a *Authenticator) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
return a.oauth2.Exchange(ctx, code)
}
// VerifyIDToken extracts and verifies the ID token issued in the code
// exchange. Signature, issuer, audience and expiry are checked.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token in OAuth2 token response")
}
return a.verifier.Verify(ctx, rawIDToken)
}
// Profile holds the user attributes used for sessions and logging.
type Profile struct {
Subject string
Email string
Name string
Groups []string
}
// ProfileFromToken reads user attributes from a verified ID token. A missing
// groups claim yields nil groups, which denies every protected page
// (fail closed).
func ProfileFromToken(idToken *oidc.IDToken) (Profile, error) {
var claims struct {
Name string `json:"name"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return Profile{}, fmt.Errorf("reading ID token claims: %w", err)
}
return Profile{
Subject: idToken.Subject,
Email: claims.Email,
Name: claims.Name,
Groups: claims.Groups,
}, nil
}