From bd57c7ae46602ab46a7ed8262e5adc842e56a90c Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 27 Aug 2026 16:02:41 +0200 Subject: [PATCH] fix(auth): repair the inert auth configuration schema The schema string was the inside of a "properties" block: jwts, oidc, ldap and required sat at the root with no enclosing "type"/"properties". jsonschema compiled it as a document with only unknown keywords, so every auth section validated and no option was ever checked. Wrapped it correctly and dropped the root-level required: ["jwts"]. Init logs and continues when the jwts section is absent ("Missing JWT configuration: No JWT token support!"), so enforcing it would abort OIDC- or LDAP-only deployments that work today. The per-subsection required lists are unchanged. Validation errors are now printed with err.Error() instead of %#v, which rendered a jsonschema.ValidationError unreadably. Co-Authored-By: Claude Opus 5 (1M context) --- internal/auth/schema.go | 14 +++-- internal/auth/schema_test.go | 104 +++++++++++++++++++++++++++++++++++ internal/config/validate.go | 2 +- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 internal/auth/schema_test.go diff --git a/internal/auth/schema.go b/internal/auth/schema.go index 0056e4cb..62148c12 100644 --- a/internal/auth/schema.go +++ b/internal/auth/schema.go @@ -5,8 +5,13 @@ package auth +// configSchema describes the "auth" section of config.json. Every subsection is +// optional: Init() logs and continues when "jwts", "oidc" or "ldap" is absent, +// so only the fields within a configured subsection are required. var configSchema = ` - { +{ + "type": "object", + "properties": { "jwts": { "description": "For JWT token authentication.", "type": "object", @@ -55,6 +60,7 @@ var configSchema = ` "required": ["max-age"] }, "oidc": { + "description": "For OpenID Connect authentication.", "type": "object", "properties": { "provider": { @@ -148,6 +154,6 @@ var configSchema = ` } }, "required": ["url", "user-base", "search-dn", "user-bind", "user-filter"] - }, - "required": ["jwts"] - }` + } + } +}` diff --git a/internal/auth/schema_test.go b/internal/auth/schema_test.go new file mode 100644 index 00000000..6774bbbb --- /dev/null +++ b/internal/auth/schema_test.go @@ -0,0 +1,104 @@ +// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. This file is part of cc-backend. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "encoding/json" + "os" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +// compileConfigSchema guards against the schema silently degrading into a +// document with no constraints (which is what happens when the "type"/ +// "properties" wrapper is missing: every instance validates). +func compileConfigSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + sch, err := jsonschema.CompileString("schema.json", configSchema) + if err != nil { + t.Fatalf("compiling auth config schema: %v", err) + } + return sch +} + +func validateAuthConfig(t *testing.T, sch *jsonschema.Schema, raw string) error { + t.Helper() + var v any + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("invalid test json: %v", err) + } + return sch.Validate(v) +} + +func TestConfigSchemaAccepts(t *testing.T) { + sch := compileConfigSchema(t) + + tests := map[string]string{ + "jwts only": `{"jwts":{"max-age":"2000h"}}`, + "oidc only": `{"oidc":{"provider":"http://localhost:8080/realms/cc"}}`, + "empty": `{}`, + "ldap": `{"ldap":{"url":"ldap://127.0.0.1:389","user-base":"ou=users,dc=example,dc=com","search-dn":"cn=admin,dc=example,dc=com","user-bind":"uid={username},ou=users,dc=example,dc=com","user-filter":"(objectclass=posixAccount)","role-filters":{"admin":"(memberOf=cn=cc-admin,ou=groups,dc=example,dc=com)"}}}`, + "role-mapping": `{"oidc":{"provider":"http://p","role-mapping":{"cc-admin":"admin"}}}`, + } + + for name, raw := range tests { + if err := validateAuthConfig(t, sch, raw); err != nil { + t.Errorf("%s: unexpected validation error: %v", name, err) + } + } +} + +func TestConfigSchemaRejects(t *testing.T) { + sch := compileConfigSchema(t) + + tests := map[string]string{ + "jwts without max-age": `{"jwts":{"public-key":"abc"}}`, + "jwts max-age not a string": `{"jwts":{"max-age":2000}}`, + "oidc without provider": `{"oidc":{"client-id":"cc-backend"}}`, + "ldap without user-bind": `{"ldap":{"url":"ldap://127.0.0.1:389","user-base":"ou=users","search-dn":"cn=admin","user-filter":"(objectclass=posixAccount)"}}`, + "role-filters not strings": `{"ldap":{"url":"u","user-base":"b","search-dn":"d","user-bind":"ub","user-filter":"f","role-filters":{"admin":true}}}`, + "jwts not an object": `{"jwts":"2000h"}`, + } + + for name, raw := range tests { + if err := validateAuthConfig(t, sch, raw); err == nil { + t.Errorf("%s: expected validation error, got none", name) + } + } +} + +// TestConfigSchemaExampleConfigs validates the auth sections of the shipped +// example configurations. +func TestConfigSchemaExampleConfigs(t *testing.T) { + sch := compileConfigSchema(t) + + for _, path := range []string{ + "../../configs/config.json", + "../../configs/config-demo.json", + "../../configs/config-large.json", + } { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var sections map[string]json.RawMessage + if err := json.Unmarshal(raw, §ions); err != nil { + t.Fatal(err) + } + authCfg, ok := sections["auth"] + if !ok { + continue + } + var v any + if err := json.Unmarshal(authCfg, &v); err != nil { + t.Fatal(err) + } + if err := sch.Validate(v); err != nil { + t.Errorf("%s: auth section does not validate: %v", path, err) + } + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index af8591ca..7321f718 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -24,6 +24,6 @@ func Validate(schema string, instance json.RawMessage) { } if err = sch.Validate(v); err != nil { - cclog.Fatalf("%#v", err) + cclog.Fatalf("config validation failed: %s", err.Error()) } }