- 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
119 lines
3.6 KiB
Go
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
|
|
}
|