Files
Yggdrasil/internal/oidc/oidc.go
T
Klagarge fc1b45058c 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
2026-10-10 18:42:40 +02:00

119 lines
3.6 KiB
Go

// 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
}