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:
38 files changed
+1367
-598
No files matched your search
+98
-15
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user