Add OIDC login and callback against Authentik
CI / ci (push) Successful in 1m27s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high, 2 medium)
CI / ci (pull_request) Successful in 1m22s

internal/auth discovers the provider at MEALQUEST_OIDC_ISSUER with
coreos/go-oidc/v3 and runs a confidential-client authorization code flow
with golang.org/x/oauth2 (client_secret_basic).

GET /auth/login stores state, nonce and a PKCE S256 verifier in a
10-minute HMAC-signed HttpOnly __Host- cookie and redirects with scopes
"openid profile email groups offline_access". An optional "return" path
must be same-origin; anything else fails the login with 400.

GET /auth/callback clears the single-use cookie, checks state, exchanges
the code with the verifier, verifies the ID token (RS256 only, issuer,
audience, expiry) and nonce, then hands the claims to a SessionStarter.
cmd/server wires a placeholder that refuses every login until the
session layer lands.

Config gains required MEALQUEST_OIDC_ISSUER, MEALQUEST_OIDC_CLIENT_ID,
MEALQUEST_OIDC_CLIENT_SECRET_FILE and MEALQUEST_OIDC_ALLOWED_GROUPS.

Pins go-oidc v3.21.0 and oauth2 v0.36.0, the newest releases that
support Go 1.25 (oauth2 v0.37.0 requires Go 1.26).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:36:57 -04:00
co-authored by Claude Opus 5.5
parent 19ea8d52d8
commit a10b24c86c
11 changed files with 1175 additions and 9 deletions
+40
View File
@@ -20,6 +20,11 @@ const (
EnvDataDir = "MEALQUEST_DATA_DIR"
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
EnvOIDCIssuer = "MEALQUEST_OIDC_ISSUER"
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
)
// Config is the validated service configuration.
@@ -29,6 +34,13 @@ type Config struct {
DataDir string
ShutdownTimeout time.Duration
SecretKey Secret
// OIDCIssuer is the Authentik issuer URL used for discovery.
OIDCIssuer *url.URL
OIDCClientID string
OIDCClientSecret Secret
// OIDCAllowedGroups lists the groups whose members may sign in.
OIDCAllowedGroups []string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -51,6 +63,11 @@ func load(lookup func(string) (string, bool)) (Config, error) {
DataDir: r.dir(EnvDataDir),
ShutdownTimeout: r.duration(EnvShutdownTimeout),
SecretKey: r.secretFile(EnvSecretKeyFile),
OIDCIssuer: r.httpURL(EnvOIDCIssuer),
OIDCClientID: r.str(EnvOIDCClientID),
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
@@ -114,6 +131,29 @@ func (r *reader) required(name string) (string, bool) {
return v, true
}
func (r *reader) str(name string) string {
v, _ := r.required(name)
return v
}
// list parses a comma-separated list. Every entry must be non-empty after
// trimming spaces.
func (r *reader) list(name string) []string {
v, ok := r.required(name)
if !ok {
return nil
}
items := strings.Split(v, ",")
for i, item := range items {
items[i] = strings.TrimSpace(item)
if items[i] == "" {
r.fail(name, "empty entry in comma-separated list %q", v)
return nil
}
}
return items
}
func (r *reader) listenAddr(name string) string {
v, ok := r.required(name)
if !ok {
+73 -2
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
@@ -21,6 +22,10 @@ func TestLoad(t *testing.T) {
t.Fatal(err)
}
unreadablePath := filepath.Join(dir, "missing_secret")
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
emptyPath := filepath.Join(dir, "empty_secret")
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
@@ -33,6 +38,11 @@ func TestLoad(t *testing.T) {
EnvDataDir: dir,
EnvShutdownTimeout: "15s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: "mealquest-admin",
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
@@ -70,6 +80,7 @@ func TestLoad(t *testing.T) {
env: map[string]string{},
badVars: []string{
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
},
},
{
@@ -169,6 +180,45 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
badVars: []string{"MEALQUEST_SECRET_KEY"},
},
{
name: "inline OIDC client secret rejected",
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"},
},
{
name: "unreadable OIDC client secret file",
env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}),
badVars: []string{EnvOIDCClientSecretFile},
},
{
name: "relative OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "non-http OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "several allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}),
},
{
name: "empty allowed group entry",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "trailing comma in allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "blank OIDC client ID",
env: with(map[string]string{EnvOIDCClientID: " "}),
badVars: []string{EnvOIDCClientID},
},
{
name: "several invalid values are all named",
env: with(map[string]string{
@@ -211,7 +261,7 @@ func TestLoad(t *testing.T) {
t.Errorf("error message %q does not name %s", err.Error(), v)
}
}
if cfg != (Config{}) {
if !reflect.ValueOf(cfg).IsZero() {
t.Errorf("load() returned non-zero Config alongside error: %+v", cfg)
}
})
@@ -226,12 +276,21 @@ func TestLoadValues(t *testing.T) {
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
t.Fatal(err)
}
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
EnvDataDir: dir,
EnvShutdownTimeout: "1m30s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: " mealquest-admin ,family",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -253,6 +312,18 @@ func TestLoadValues(t *testing.T) {
if cfg.SecretKey.Reveal() != "s3cret-value" {
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
}
if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want {
t.Errorf("OIDCIssuer = %q, want %q", got, want)
}
if cfg.OIDCClientID != "mealquest" {
t.Errorf("OIDCClientID = %q", cfg.OIDCClientID)
}
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
}
func TestSecretRedacted(t *testing.T) {
@@ -260,7 +331,7 @@ func TestSecretRedacted(t *testing.T) {
s := Secret{value: "s3cret-value"}
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
cfg := Config{SecretKey: s}
cfg := Config{SecretKey: s, OIDCClientSecret: s}
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
if strings.Contains(out, "s3cret-value") {
t.Errorf("format %s leaked secret: %s", format, out)