diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ad02f84
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+.git
+.delta
+.idea
+.vscode
+docs
+legacy_docs
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..8d87fe9
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,17 @@
+# Server configuration. Copy to .env (gitignored), export or source it.
+
+# Address the server listens on.
+LISTEN_ADDR=:8080
+# Session lifetime.
+SESSION_TTL=12h
+# Set to false only when testing locally over plain http.
+COOKIE_SECURE=false
+
+# OIDC (Authentik). Unset: public pages still work, protected pages answer 503.
+OIDC_ISSUER_URL=https://auth.example.com/application/o/yggdrasil/
+OIDC_CLIENT_ID=yggdrasil
+OIDC_CLIENT_SECRET=change-me
+OIDC_REDIRECT_URL=http://localhost:8080/callback
+# Comma/space separated scopes. The groups claim must be covered by a scope
+# mapping on the provider for group checks to work.
+# OIDC_SCOPES=openid profile email groups
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index e69de29..cd92699 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -0,0 +1,45 @@
+name: build
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ # shiroa bundles its own Typst compiler, so no separate typst install.
+ - name: Install shiroa
+ run: |
+ curl -sSL -o /tmp/shiroa.tar.gz \
+ https://github.com/Myriad-Dreamin/shiroa/releases/download/v0.4.0-rc2/shiroa-x86_64-unknown-linux-musl.tar.gz
+ tar -xzf /tmp/shiroa.tar.gz -C /tmp
+ install -m 755 /tmp/shiroa-x86_64-unknown-linux-musl/shiroa /usr/local/bin/shiroa
+ shiroa --version
+
+ - name: Generate acl.json
+ run: go run ./cmd/parser -in docs -out acl.json
+
+ - name: Build the book
+ run: shiroa build docs --dest-dir ../dist
+
+ - name: Test and build the server
+ run: |
+ go vet ./...
+ go test ./...
+ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o yggdrasil-server ./cmd/server
+
+ - name: Upload server artifact
+ uses: actions/upload-artifact@v3
+ with:
+ name: yggdrasil-server-linux-amd64
+ path: yggdrasil-server
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1b08e70
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# build outputs (dist is regenerated by shiroa; .gitkeep keeps go:embed happy)
+/dist/*
+!/dist/.gitkeep
+/server
+yggdrasil-server
+
+# secrets
+.env
+
+# editors
+.idea/
+.vscode/
+
+# delta workspace
+/.delta/
diff --git a/.idea/Yggdrasil.iml b/.idea/Yggdrasil.iml
deleted file mode 100644
index bc2cd87..0000000
--- a/.idea/Yggdrasil.iml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index a55e7a1..0000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/discord.xml b/.idea/discord.xml
deleted file mode 100644
index 104c42f..0000000
--- a/.idea/discord.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/editor.xml b/.idea/editor.xml
deleted file mode 100644
index 0ef9a5a..0000000
--- a/.idea/editor.xml
+++ /dev/null
@@ -1,329 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 6f94d59..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Docsify.xml b/.idea/runConfigurations/Docsify.xml
deleted file mode 100644
index aa5aa10..0000000
--- a/.idea/runConfigurations/Docsify.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 7ddfc9e..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
deleted file mode 100644
index d4d5e65..0000000
--- a/.idea/workspace.xml
+++ /dev/null
@@ -1,139 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- "lastFilter": {
- "state": "OPEN",
- "assignee": "Klagarge"
- }
-}
-
-
-
- {
- "selectedUrlAndAccountId": {
- "url": "git@github.com:Klagarge/Yggdrasil.git",
- "accountId": "23ae37cb-e333-4d8c-8c08-dde8fa58b070"
- }
-}
-
-
-
- {
- "associatedIndex": 0
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1736105231146
-
-
- 1736105231146
-
-
-
-
-
-
-
-
- 1742139894376
-
-
-
- 1742139894376
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..959e3cd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+# Rootless image of the Yggdrasil server.
+# Build after `just parse build-docs` so that dist/ and acl.json are current:
+# just parse build-docs && docker build -t yggdrasil .
+
+FROM golang:1.27 AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
+
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/server /server
+EXPOSE 8080
+USER nonroot:nonroot
+ENTRYPOINT ["/server"]
diff --git a/Justfile b/Justfile
index e69de29..01c95d7 100644
--- a/Justfile
+++ b/Justfile
@@ -0,0 +1,36 @@
+# Yggdrasil: Typst documentation behind an authenticated Go server.
+# Run `just` alone to build everything.
+
+# Build everything: acl.json, the static site, and the server binary.
+default: build
+build: parse build-docs build-server
+
+# Scan docs/ for access-groups tags and regenerate acl.json.
+parse:
+ go run ./cmd/parser -in docs -out acl.json
+
+# Compile the Typst book into dist/ (requires shiroa on PATH).
+build-docs:
+ shiroa build docs --dest-dir ../dist
+
+# Build the server binary (embeds dist/ and acl.json).
+build-server:
+ CGO_ENABLED=0 go build -trimpath -o server ./cmd/server
+
+# Rebuild everything and run the server locally.
+run: build
+ ./server
+
+# Live-preview the book without authentication (shiroa dev server).
+preview-docs:
+ shiroa serve docs
+
+# Run static checks and tests.
+check:
+ go vet ./...
+ go test ./...
+ @test -z "$(gofmt -l cmd internal embed.go)" || { echo 'gofmt needed:'; gofmt -l cmd internal embed.go; exit 1; }
+
+# Remove build artifacts (keeps the committed acl.json and dist/.gitkeep).
+clean:
+ rm -rf dist server
diff --git a/README.md b/README.md
index df08371..ca54210 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,193 @@
-# Yggdrasil Document Base
+# Yggdrasil
+
+A personal knowledge base written in [Typst](https://typst.app), compiled to
+a static site by [shiroa](https://github.com/Myriad-Dreamin/shiroa), and
+served by a small Go binary that embeds the whole site and gates protected
+pages behind OIDC login (Authentik) with per-page group permissions.
+
+No search, no database, no Node.js: one binary, one configuration.
+
+## How it works
+
+```mermaid
+flowchart TD
+ A["docs/*.typ (Typst sources)"] -->|"just build-docs (shiroa)"| B["dist/ (static site)"]
+ A -->|"just parse (cmd/parser, access-groups tags)"| C["acl.json"]
+ B -->|"go build (//go:embed)"| E["server binary"]
+ C --> E
+ E --> F{"request /page.html"}
+ F -->|"public page"| G["serve page"]
+ F -->|"protected page"| H{"session + groups ok?"}
+ H -->|"no session"| I["redirect to /login, then Authentik"]
+ H -->|"not authorized"| J["403"]
+ H -->|"authorized"| G
+```
+
+- Pages are written in Typst under `docs/` and listed in `docs/book.typ`.
+- A page can declare who may read it with a tag:
+ `#let access-groups = ("family", "admin")`. Pages without a tag are public.
+- At build time, `cmd/parser` scans `docs/`, extracts the tags and generates
+ `acl.json` mapping final page URLs to allowed groups.
+- The Go server embeds both `dist/` and `acl.json`. For every request it
+ reduces the URL to a *page key* and looks it up in the ACL: public pages
+ are served directly; protected pages require a session whose groups include
+ at least one of the allowed groups.
+- Users authenticate with OIDC. The server reads the `groups` claim of the
+ verified ID token to build the session.
+
+## Repository layout
+
+```
+docs/ Typst sources (shiroa book)
+ book.typ book metadata + chapter list (the sidebar)
+ template.typ page layout (from the shiroa minimal template)
+ index.typ home page (public)
+ thermo/, security/, homelab/ example categories
+cmd/parser/ generates acl.json from access-groups tags
+cmd/server/ the web server
+internal/acl/ ACL loading, page-key normalization, group checks
+internal/oidc/ OIDC client (authorization code flow)
+internal/middleware/ sessions, login/logout/callback, access gate
+dist/ compiled site (generated, embedded at build time)
+acl.json generated ACL (embedded at build time)
+legacy_docs/ the old documentation base (kept for manual migration)
+.gitea/workflows/ Gitea Actions CI
+```
+
+## Quick start
+
+Prerequisites: Go 1.22+, [just](https://github.com/casey/just), and
+[shiroa](https://github.com/Myriad-Dreamin/shiroa/releases) on your `PATH`.
+
+```sh
+just run # parse ACL, compile the book, build and start the server
+```
+
+Then open http://localhost:8080. The home page and public pages render;
+protected pages (`/thermo/fluids.html`, `/security/ssh.html`,
+`/homelab/backup.html` in the example book) return **503** until OIDC is
+configured.
+
+For pure writing previews without authentication, use `just preview-docs`.
+
+## Writing documentation
+
+The book structure mirrors the URL structure: `docs/.typ` is compiled
+to `/.html`. To add a page:
+
+1. Create the file, e.g. `docs/security/wireguard.typ`, starting with:
+
+ ```typst
+ #import "../template.typ": project
+ #show: project
+
+ = WireGuard
+
+ Your content...
+ ```
+
+2. Reference it in `docs/book.typ`. A `=` heading creates a category
+ (part) in the sidebar; each `- #chapter(...)` is a page:
+
+ ```typst
+ = Security
+ - #chapter("security/ssh.typ", section: "4")[SSH hardening]
+ - #chapter("security/wireguard.typ", section: "5")[WireGuard]
+ ```
+
+3. Only pages listed in `book.typ` are built. Keep `section:` numbers
+ consistent (sub-chapters like `"4.1"` nest under `"4"`).
+
+To restrict a page, add the tag on the first line of its file:
+
+```typst
+// Only readable by members of these groups.
+#let access-groups = ("family", "admin")
+```
+
+- The groups are compared against the `groups` claim of the OIDC ID token
+ (in Authentik: your user's group memberships).
+- A page without the tag is public.
+- Everything served *for* a protected page is protected too: the shiroa
+ renderer fetches page content from `*.multi.sir.in` files, and those
+ reduce to the same ACL entry as the page itself.
+- The search index (`searchindex.json`) would also contain protected text,
+ so the server never serves it.
+
+## Configuration
+
+Environment variables (see `.env.example`):
+
+| Variable | Default | Meaning |
+|---------------------|-----------------------------|---------|
+| `LISTEN_ADDR` | `:8080` | listen address |
+| `SESSION_TTL` | `12h` | session lifetime |
+| `COOKIE_SECURE` | `true` | `Secure` cookie flag; use `false` only on plain-http local tests |
+| `OIDC_ISSUER_URL` | (unset) | e.g. `https://auth.example.com/application/o/yggdrasil/` |
+| `OIDC_CLIENT_ID` | (unset) | OAuth2 client id |
+| `OIDC_CLIENT_SECRET`| (unset) | OAuth2 client secret |
+| `OIDC_REDIRECT_URL` | (unset) | e.g. `https://docs.example.com/callback` |
+| `OIDC_SCOPES` | `openid profile email groups` | requested scopes |
+
+Without the `OIDC_*` variables the server runs in public-only mode:
+public pages are served, protected pages answer 503. The server refuses to
+start on a malformed `acl.json` or an unreachable OIDC issuer (fail closed).
+
+### Setting up Authentik
+
+1. **Applications -> Providers -> Create**: type *OAuth2/OpenID Connect*.
+2. Authorization flow: *implicit/explicit consent* (default is fine).
+3. Redirect URI: exactly `https://your-docs-host/callback`.
+4. Client type: *Confidential*; note the client id and secret.
+5. **Scope mappings**: make sure the issued ID token contains a `groups`
+ claim. Recent Authentik versions ship a scope mapping that adds
+ `groups`; otherwise create a *Property Mapping (Scope)* with e.g.
+ `return {"groups": [g.name for g in user.ak_groups.all()]}` and add it
+ to the provider.
+6. Note the issuer URL shown on the provider page
+ (`.../application/o//`) and set `OIDC_ISSUER_URL` to it.
+7. Create groups in **Directory -> Groups** (e.g. `family`, `admin`) whose
+ names match the `access-groups` in your pages, and add users to them.
+
+The redirect after login returns the user to the page they originally
+requested. Logout is available at `/logout`.
+
+## Build and CI
+
+Local commands (see `Justfile`): `just build`, `just run`, `just parse`,
+`just check`, `just clean`, `just preview-docs`.
+
+`.gitea/workflows/build.yml` (Gitea Actions, not GitLab CI) runs on push to
+`main` and on pull requests: installs shiroa, regenerates `acl.json`,
+compiles the book, runs `go vet`/`go test`, builds the static binary and
+uploads it as an artifact.
+
+A rootless Docker/Podman image is available:
+
+```sh
+just parse build-docs
+docker build -t yggdrasil .
+docker run --rm -p 8080:8080 --env-file .env yggdrasil
+```
+
+Note that the binary embeds the site at build time: publishing new or
+edited pages always requires rebuilding and redeploying the server.
+
+## Security notes and limitations
+
+- Sessions live in memory: they vanish on restart and do not scale past
+ one instance (users simply log in again).
+- Page *titles* and URLs of protected pages are visible in the sidebar of
+ public pages (that is inherent to a static site). Page *content* is not:
+ neither the HTML nor the renderer data files can be fetched without
+ authorization.
+- Directory listing is enabled (file names only, e.g. `/thermo/`).
+- Logout is a GET request (`/logout`), like most documentation sites.
+- No search is implemented (`searchindex.json` is explicitly blocked).
+- Static assets referenced by pages (theme CSS/JS, fonts, renderer wasm)
+ are public; they contain no page content.
+
+## Legacy documentation
+
+The previous documentation base lives in `legacy_docs/` and is kept only
+for reference; migrate pages into `docs/` manually, then delete the folder.
diff --git a/acl.json b/acl.json
index 9e26dfe..ddb36b3 100644
--- a/acl.json
+++ b/acl.json
@@ -1 +1,12 @@
-{}
\ No newline at end of file
+{
+ "/homelab/backup.html": [
+ "family"
+ ],
+ "/security/ssh.html": [
+ "admin"
+ ],
+ "/thermo/fluids.html": [
+ "family",
+ "admin"
+ ]
+}
diff --git a/cmd/parser/main.go b/cmd/parser/main.go
index 2581d8a..c898af8 100644
--- a/cmd/parser/main.go
+++ b/cmd/parser/main.go
@@ -1,3 +1,13 @@
+// Command parser scans a directory of Typst sources (a shiroa book) for
+// access-group tags and generates the acl.json consumed by the server.
+//
+// A page declares its access groups with a Typst let binding at the top of
+// the file:
+//
+// #let access-groups = ("family", "admin")
+//
+// Pages without a tag are public. The generated acl.json maps the final URL
+// of each page (e.g. "/thermo/fluids.html") to its authorized groups.
package main
import (
@@ -12,69 +22,86 @@ import (
"strings"
)
+// Matches: #let access-groups = ("group1", "group2")
+var accessGroupsRe = regexp.MustCompile(`(?m)^\s*#let\s+access-groups\s*=\s*\(([^)]*)\)`)
+
+// Files that are never pages: book metadata, page layout, and partials
+// (files starting with "_") imported by other pages.
+func isSkipped(base string) bool {
+ return base == "book.typ" || base == "template.typ" || strings.HasPrefix(base, "_")
+}
+
func main() {
- inDir := flag.String("in", "docs", "Input directory containing .typ files")
- outFile := flag.String("out", "acl.json", "Output acl.json file")
+ inDir := flag.String("in", "docs", "input directory containing the shiroa book (.typ sources)")
+ outFile := flag.String("out", "acl.json", "output acl.json file")
flag.Parse()
acl := make(map[string][]string)
-
- // Match: #let access-groups = ("group1", "group2")
- // The regex handles optional spaces and varying number of groups.
- re := regexp.MustCompile(`(?m)^#let\s+access-groups\s*=\s*\(([^)]*)\)`)
- err := filepath.WalkDir(*inDir, func(path string, d fs.DirEntry, err error) error {
+ err := filepath.WalkDir(*inDir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
- if !d.IsDir() && filepath.Ext(path) == ".typ" {
- content, err := os.ReadFile(path)
- if err != nil {
- return fmt.Errorf("reading %s: %w", path, err)
- }
-
- relPath, err := filepath.Rel(*inDir, path)
- if err != nil {
- return err
- }
-
- // Map .typ to .html in the final URL structure
- urlPath := "/" + strings.TrimSuffix(relPath, ".typ") + ".html"
- // Ensure forward slashes for URLs
- urlPath = strings.ReplaceAll(urlPath, "\\", "/")
-
- matches := re.FindSubmatch(content)
- if len(matches) > 1 {
- groupsStr := string(matches[1])
- var groups []string
- // Split by comma and clean up quotes/spaces
- for _, g := range strings.Split(groupsStr, ",") {
- g = strings.TrimSpace(g)
- g = strings.Trim(g, `"'`)
- if g != "" {
- groups = append(groups, g)
- }
- }
- if len(groups) > 0 {
- acl[urlPath] = groups
- }
+ base := d.Name()
+ if d.IsDir() {
+ // Skip build output and hidden directories.
+ if p != *inDir && (base == "dist" || strings.HasPrefix(base, ".")) {
+ return filepath.SkipDir
}
+ return nil
}
+ if filepath.Ext(base) != ".typ" || isSkipped(base) {
+ return nil
+ }
+
+ content, err := os.ReadFile(p)
+ if err != nil {
+ return fmt.Errorf("reading %s: %w", p, err)
+ }
+ groups := extractGroups(content)
+ if len(groups) == 0 {
+ return nil // public page, no rule needed
+ }
+
+ relPath, err := filepath.Rel(*inDir, p)
+ if err != nil {
+ return err
+ }
+ // docs/.typ compiles to dist/.html, served at /.html
+ urlPath := "/" + strings.TrimSuffix(filepath.ToSlash(relPath), ".typ") + ".html"
+
+ acl[urlPath] = groups
+ log.Printf("protected %s -> %s", urlPath, strings.Join(groups, ", "))
return nil
})
-
if err != nil {
- log.Fatalf("Error scanning directory: %v", err)
+ log.Fatalf("scanning %s: %v", *inDir, err)
}
- outData, err := json.MarshalIndent(acl, "", " ")
+ out, err := json.MarshalIndent(acl, "", " ")
if err != nil {
- log.Fatalf("Error generating JSON: %v", err)
+ log.Fatalf("encoding acl: %v", err)
+ }
+ out = append(out, '\n')
+ if err := os.WriteFile(*outFile, out, 0o644); err != nil {
+ log.Fatalf("writing %s: %v", *outFile, err)
}
- if err := os.WriteFile(*outFile, outData, 0644); err != nil {
- log.Fatalf("Error writing %s: %v", *outFile, err)
+ fmt.Printf("generated %s (%d protected pages)\n", *outFile, len(acl))
+}
+
+func extractGroups(content []byte) []string {
+ m := accessGroupsRe.FindSubmatch(content)
+ if m == nil {
+ return nil
+ }
+ var groups []string
+ for _, part := range strings.Split(string(m[1]), ",") {
+ part = strings.TrimSpace(part)
+ part = strings.Trim(part, "\"'")
+ if part != "" {
+ groups = append(groups, part)
+ }
}
-
- fmt.Printf("Successfully generated %s with %d rules\n", *outFile, len(acl))
+ return groups
}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index e69de29..32ceade 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -0,0 +1,120 @@
+// Command server is the Yggdrasil web server: a single static binary that
+// embeds the compiled documentation (dist/) and its access control list
+// (acl.json), serves public pages directly, and gates protected pages
+// behind OIDC authentication (Authentik).
+package main
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/Klagarge/yggdrasil"
+ "github.com/Klagarge/yggdrasil/internal/acl"
+ "github.com/Klagarge/yggdrasil/internal/middleware"
+ "github.com/Klagarge/yggdrasil/internal/oidc"
+)
+
+func main() {
+ listenAddr := envOr("LISTEN_ADDR", ":8080")
+ sessionTTL := envDuration("SESSION_TTL", 12*time.Hour)
+ cookieSecure := envOr("COOKIE_SECURE", "true") == "true"
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ list, err := acl.Load(yggdrasil.ACLData())
+ if err != nil {
+ log.Fatalf("refusing to start: %v", err) // fail closed
+ }
+
+ sessions := middleware.NewSessionStore(sessionTTL)
+ go sessions.StartJanitor(ctx, 10*time.Minute)
+
+ auth := setupOIDC(ctx)
+
+ dist, err := yggdrasil.DistFS()
+ if err != nil {
+ log.Fatalf("embedded dist: %v", err)
+ }
+
+ gate := middleware.NewAccessControl(list, sessions, auth, cookieSecure)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /login", gate.Login)
+ mux.HandleFunc("GET /callback", gate.Callback)
+ mux.HandleFunc("GET /logout", gate.Logout)
+ mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok\n"))
+ })
+ // The search index contains the text of protected pages: never serve it.
+ mux.HandleFunc("/searchindex.json", http.NotFound)
+ mux.Handle("/", gate.Wrap(http.FileServerFS(dist)))
+
+ srv := &http.Server{
+ Addr: listenAddr,
+ Handler: gate.SecurityHeaders(mux),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 2 * time.Minute,
+ }
+
+ errCh := make(chan error, 1)
+ go func() { errCh <- srv.ListenAndServe() }()
+ log.Printf("listening on %s (%d protected pages)", listenAddr, len(list))
+
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("server error: %v", err)
+ }
+ case <-ctx.Done():
+ }
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ log.Printf("shutdown: %v", err)
+ }
+}
+
+// setupOIDC builds the OIDC client from the environment. Without OIDC
+// configuration the server still serves public pages; protected pages
+// answer 503 instead of being exposed.
+func setupOIDC(ctx context.Context) *oidc.Authenticator {
+ cfg, err := oidc.LoadConfigFromEnv()
+ if err != nil {
+ log.Printf("OIDC disabled (%v) - protected pages will answer 503", err)
+ return nil
+ }
+ auth, err := oidc.NewAuthenticator(ctx, cfg)
+ if err != nil {
+ log.Fatalf("OIDC setup: %v", err)
+ }
+ log.Printf("OIDC enabled (issuer: %s)", cfg.IssuerURL)
+ return auth
+}
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func envDuration(key string, def time.Duration) time.Duration {
+ if v := os.Getenv(key); v != "" {
+ if d, err := time.ParseDuration(v); err == nil {
+ return d
+ }
+ log.Printf("invalid %s %q, using %s", key, v, def)
+ }
+ return def
+}
diff --git a/dist/.gitkeep b/dist/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/book.typ b/docs/book.typ
new file mode 100644
index 0000000..2670899
--- /dev/null
+++ b/docs/book.typ
@@ -0,0 +1,22 @@
+#import "@preview/shiroa:0.4.0": *
+#show: book
+
+#book-meta(
+ title: "Yggdrasil",
+ authors: ("Klagarge",),
+ summary: [
+ #prefix-chapter("index.typ")[Home]
+
+ = Thermodynamics
+ - #chapter("thermo/heat-pumps.typ", section: "1")[Heat pumps]
+ - #chapter("thermo/fluids.typ", section: "2")[Fluid mechanics]
+
+ = Security
+ - #chapter("security/opsec.typ", section: "3")[Opsec basics]
+ - #chapter("security/ssh.typ", section: "4")[SSH hardening]
+
+ = Homelab
+ - #chapter("homelab/self-hosting.typ", section: "5")[Self-hosting]
+ - #chapter("homelab/backup.typ", section: "6")[Backups]
+ ],
+)
diff --git a/docs/homelab/backup.typ b/docs/homelab/backup.typ
new file mode 100644
index 0000000..72acbd8
--- /dev/null
+++ b/docs/homelab/backup.typ
@@ -0,0 +1,11 @@
+// Access control: only members of these Authentik groups can read this page.
+// Delete this line to make the page public.
+#let access-groups = ("family",)
+
+#import "../template.typ": project
+#show: project
+
+= Backups
+
+Protected page placeholder about backups: the 3-2-1 rule, restic,
+off-site replication...
diff --git a/docs/homelab/self-hosting.typ b/docs/homelab/self-hosting.typ
new file mode 100644
index 0000000..f496e30
--- /dev/null
+++ b/docs/homelab/self-hosting.typ
@@ -0,0 +1,7 @@
+#import "../template.typ": project
+#show: project
+
+= Self-hosting
+
+Public page placeholder about self-hosting: Docker/Podman, reverse
+proxies, DNS, VLANs...
diff --git a/docs/index.typ b/docs/index.typ
new file mode 100644
index 0000000..1a27fe3
--- /dev/null
+++ b/docs/index.typ
@@ -0,0 +1,17 @@
+#import "template.typ": project
+#show: project
+
+= Yggdrasil
+
+Welcome to the Yggdrasil knowledge base, built with Typst, Shiroa and a Go
+server.
+
+*How it works:*
+
+- Pages are written in Typst under `docs/` and compiled to static HTML.
+- A Go server embeds the HTML and an access-control list (`acl.json`).
+- Pages tagged with `#let access-groups = ("family", "admin")` are only
+ visible to authenticated users in those groups; everything else is
+ public.
+
+Browse the categories in the sidebar to get started.
diff --git a/docs/security/opsec.typ b/docs/security/opsec.typ
new file mode 100644
index 0000000..7693a6a
--- /dev/null
+++ b/docs/security/opsec.typ
@@ -0,0 +1,7 @@
+#import "../template.typ": project
+#show: project
+
+= Opsec basics
+
+Public page placeholder about operational security: threat models,
+password managers, 2FA...
diff --git a/docs/security/ssh.typ b/docs/security/ssh.typ
new file mode 100644
index 0000000..d807c19
--- /dev/null
+++ b/docs/security/ssh.typ
@@ -0,0 +1,11 @@
+// Access control: only members of these Authentik groups can read this page.
+// Delete this line to make the page public.
+#let access-groups = ("admin",)
+
+#import "../template.typ": project
+#show: project
+
+= SSH hardening
+
+Protected page placeholder about SSH: key management, ciphers,
+session hardening, bastion hosts...
diff --git a/docs/template.typ b/docs/template.typ
new file mode 100644
index 0000000..de9f5cd
--- /dev/null
+++ b/docs/template.typ
@@ -0,0 +1,44 @@
+#import "@preview/shiroa:0.4.0": get-page-width, is-html-target, is-pdf-target, is-web-target, templates, x-target
+#import templates: *
+
+// Metadata
+#let page-width = get-page-width()
+#let is-html-target = is-html-target() // target.starts-with("html")
+#let is-pdf-target = is-pdf-target() // target.starts-with("pdf")
+#let is-web-target = is-web-target() // target.starts-with("web")
+
+#let project(body) = {
+ // set web/pdf page properties
+ set page(
+ width: page-width,
+ // for a website, we don't need pagination.
+ height: auto,
+ ) if is-pdf-target or is-web-target
+
+ // remove margins for web target
+ set page(margin: (
+ // reserved beautiful top margin
+ top: 20pt,
+ // Typst is setting the page's bottom to the baseline of the last line of text. So bad :(.
+ bottom: 0.5em,
+ // remove rest margins.
+ rest: 0pt,
+ )) if is-web-target
+
+ let web-theme = if x-target.starts-with("html") and not x-target.starts-with("html-wrapper") {
+ "starlight"
+ } else {
+ "mdbook"
+ }
+ show: if web-theme == "starlight" {
+ import "@preview/shiroa-starlight:0.4.0": starlight
+ starlight.with(include "book.typ")
+ } else if web-theme == "mdbook" {
+ import "@preview/shiroa-mdbook:0.4.0": mdbook
+ mdbook.with(include "book.typ")
+ } else {
+ panic("Unknown web theme: " + web-theme)
+ }
+
+ body
+}
diff --git a/docs/thermo/fluids.typ b/docs/thermo/fluids.typ
new file mode 100644
index 0000000..4578c09
--- /dev/null
+++ b/docs/thermo/fluids.typ
@@ -0,0 +1,11 @@
+// Access control: only members of these Authentik groups can read this page.
+// Delete this line to make the page public.
+#let access-groups = ("family", "admin")
+
+#import "../template.typ": project
+#show: project
+
+= Fluid mechanics
+
+Protected page placeholder about fluid mechanics: statics, dynamics,
+Bernoulli's principle, pressure losses...
diff --git a/docs/thermo/heat-pumps.typ b/docs/thermo/heat-pumps.typ
new file mode 100644
index 0000000..dac8449
--- /dev/null
+++ b/docs/thermo/heat-pumps.typ
@@ -0,0 +1,7 @@
+#import "../template.typ": project
+#show: project
+
+= Heat pumps
+
+Public page placeholder about heat pumps: COP, seasonal performance,
+refrigerant cycles...
diff --git a/embed.go b/embed.go
new file mode 100644
index 0000000..5a0bc69
--- /dev/null
+++ b/embed.go
@@ -0,0 +1,26 @@
+// Package yggdrasil embeds the build artifacts: the static site compiled by
+// shiroa (dist/) and the access control list (acl.json). Embedding them at
+// the repository root lets //go:embed see both directories relative to this
+// file; `go build` requires acl.json and dist/ to exist (see the Justfile).
+package yggdrasil
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed all:dist
+var distFS embed.FS
+
+//go:embed acl.json
+var aclData []byte
+
+// DistFS returns the embedded static site.
+func DistFS() (fs.FS, error) {
+ return fs.Sub(distFS, "dist")
+}
+
+// ACLData returns the embedded acl.json.
+func ACLData() []byte {
+ return aclData
+}
diff --git a/go.mod b/go.mod
index 8d935a2..57b43d9 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,8 @@ module github.com/Klagarge/yggdrasil
go 1.27.1
require (
- github.com/coreos/go-oidc/v3 v3.21.0 // indirect
- golang.org/x/oauth2 v0.37.0 // indirect
+ github.com/coreos/go-oidc/v3 v3.21.0
+ golang.org/x/oauth2 v0.37.0
)
+
+require github.com/go-jose/go-jose/v4 v4.1.4 // indirect
diff --git a/go.sum b/go.sum
index 445d193..ec31947 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,6 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
diff --git a/internal/acl/acl.go b/internal/acl/acl.go
index 9364f09..6c6dcd7 100644
--- a/internal/acl/acl.go
+++ b/internal/acl/acl.go
@@ -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
+}
diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go
new file mode 100644
index 0000000..cd13115
--- /dev/null
+++ b/internal/acl/acl_test.go
@@ -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)")
+ }
+}
diff --git a/internal/middleware/access.go b/internal/middleware/access.go
new file mode 100644
index 0000000..8787ffe
--- /dev/null
+++ b/internal/middleware/access.go
@@ -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)
+ })
+}
diff --git a/internal/middleware/handlers.go b/internal/middleware/handlers.go
new file mode 100644
index 0000000..3ccb77f
--- /dev/null
+++ b/internal/middleware/handlers.go
@@ -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
+}
diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go
new file mode 100644
index 0000000..b7e2e47
--- /dev/null
+++ b/internal/middleware/middleware_test.go
@@ -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")
+ }
+}
diff --git a/internal/middleware/sessions.go b/internal/middleware/sessions.go
new file mode 100644
index 0000000..d3664db
--- /dev/null
+++ b/internal/middleware/sessions.go
@@ -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[:])
+}
diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go
new file mode 100644
index 0000000..aa11b33
--- /dev/null
+++ b/internal/oidc/oidc.go
@@ -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
+}