Support provider credential renewal and reject unsuccessful OTP validation
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m10s
Build and Publish Container Image / build-and-push (push) Successful in 44s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 14:23:24 +02:00
parent 85f5deaf4a
commit 632b1f1376
6 changed files with 292 additions and 5 deletions

View file

@ -7,6 +7,8 @@ import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"keycape/internal/domain"
@ -64,7 +66,11 @@ func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string)
if err != nil {
return false, fmt.Errorf("privacyidea: build token list request: %w", err)
}
req.Header.Set("Authorization", a.cfg.AdminToken)
credential, err := a.adminCredential()
if err != nil {
return false, err
}
req.Header.Set("Authorization", credential)
resp, err := a.client.Do(req)
if err != nil {
@ -121,7 +127,11 @@ func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token
return fmt.Errorf("privacyidea: build validate request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", a.cfg.AdminToken)
credential, err := a.adminCredential()
if err != nil {
return err
}
req.Header.Set("Authorization", credential)
resp, err := a.client.Do(req)
if err != nil {
@ -143,12 +153,49 @@ func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token
return fmt.Errorf("privacyidea: decode validate response: %w", err)
}
if !parsed.Result.Value {
if !parsed.Result.Status || !parsed.Result.Value {
return domain.ErrMFAFailed
}
return nil
}
// adminCredential rereads the mounted file, including atomic Secret volume updates.
// Never include paths, bytes or underlying OS errors in a returned error.
func (a *PrivacyIDEAAdapter) adminCredential() (string, error) {
const maxCredentialBytes = 16384
if a.cfg.AdminToken != "" && a.cfg.AdminTokenFile != "" {
return "", fmt.Errorf("privacyidea: configure only one administrative credential source")
}
credential := a.cfg.AdminToken
if a.cfg.AdminTokenFile != "" {
if !filepath.IsAbs(a.cfg.AdminTokenFile) {
return "", fmt.Errorf("privacyidea: administrative credential path must be absolute")
}
info, err := os.Stat(a.cfg.AdminTokenFile)
if err != nil || !info.Mode().IsRegular() {
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
}
file, err := os.Open(a.cfg.AdminTokenFile)
if err != nil {
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
}
defer file.Close()
info, err = file.Stat()
if err != nil || !info.Mode().IsRegular() {
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
}
data, err := io.ReadAll(io.LimitReader(file, maxCredentialBytes+1))
if err != nil || len(data) > maxCredentialBytes {
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
}
credential = strings.TrimSpace(string(data))
}
if credential == "" || len(credential) > maxCredentialBytes || strings.IndexFunc(credential, func(r rune) bool { return r < 33 || r > 126 }) >= 0 {
return "", fmt.Errorf("privacyidea: administrative credential invalid or missing")
}
return credential, nil
}
// ---------------------------------------------------------------------------
// JSON response types (internal to this package)
// ---------------------------------------------------------------------------

View file

@ -3,7 +3,10 @@
// every check and validation call is forwarded verbatim to privacyIDEA.
package privacyidea
import "net/http"
import (
"net/http"
"time"
)
// Config holds all connection parameters for the privacyIDEA adapter.
type Config struct {
@ -14,6 +17,10 @@ type Config struct {
// privacyIDEA admin API.
AdminToken string `yaml:"adminToken"`
// AdminTokenFile is a protected mounted credential reread for each request.
// Configure exactly one source. Failed reads never fall back to a cached token.
AdminTokenFile string `yaml:"adminTokenFile,omitempty"`
// Realm is the privacyIDEA realm to scope token and validate requests.
// Defaults to "netkingdom" when empty.
Realm string `yaml:"realm"`
@ -38,4 +45,7 @@ type HTTPClient interface {
}
// defaultHTTPClient is the production HTTP client used when none is injected.
var defaultHTTPClient HTTPClient = &http.Client{}
var defaultHTTPClient HTTPClient = &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse },
}

View file

@ -0,0 +1,175 @@
package privacyidea_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"keycape/internal/adapters/privacyidea"
)
func TestCredentialFileRenewalAndFailureRecovery(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "credential")
cfg := testConfig()
cfg.AdminToken = ""
cfg.AdminTokenFile = path
var seen []string
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) {
seen = append(seen, req.Header.Get("Authorization"))
if req.URL.Path == "/validate/check" {
return jsonResponse(`{"result":{"status":true,"value":true}}`), nil
}
return jsonResponse(`{"result":{"status":true,"value":{"tokens":[],"count":0}}}`), nil
}})
write := func(value string) {
t.Helper()
next := filepath.Join(dir, "replacement")
if err := os.WriteFile(next, []byte(value), 0600); err != nil {
t.Fatal(err)
}
if err := os.Rename(next, path); err != nil {
t.Fatal(err)
}
}
write("synthetic-first\n")
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err != nil {
t.Fatal(err)
}
write("synthetic-renewed")
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err == nil {
t.Fatal("missing file reused stale credential")
}
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err == nil {
t.Fatal("validation reused stale credential")
}
write("synthetic-restored")
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err != nil {
t.Fatal(err)
}
if strings.Join(seen, ",") != "synthetic-first,synthetic-renewed,synthetic-restored" {
t.Fatal("renewal not used on next request")
}
}
func TestCredentialSourceErrorsNeverReachProviderOrExposeValues(t *testing.T) {
dir := t.TempDir()
for _, tc := range []struct {
name, inline, content string
missing, directory bool
}{
{name: "ambiguous", inline: "private-inline", content: "private-file"},
{name: "empty"}, {name: "newline", content: "private\nvalue"},
{name: "oversized", content: strings.Repeat("x", 16385)},
{name: "missing", missing: true}, {name: "directory", directory: true},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(dir, "private-path-"+tc.name)
if tc.directory {
if err := os.Mkdir(path, 0700); err != nil {
t.Fatal(err)
}
} else if !tc.missing {
if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil {
t.Fatal(err)
}
}
cfg := testConfig()
cfg.AdminToken = tc.inline
cfg.AdminTokenFile = path
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) {
t.Fatal("invalid credential reached provider")
return nil, nil
}})
_, err := adapter.HasEnrolledFactor(context.Background(), "alice")
if err == nil {
t.Fatal("invalid credential accepted")
}
if strings.Contains(err.Error(), "private") || strings.Contains(err.Error(), dir) {
t.Fatal("credential details exposed")
}
})
}
}
func TestProviderFailureFlagCannotGrantAAL2(t *testing.T) {
for _, body := range []string{`{"result":{"status":false,"value":true}}`, `{"result":{"value":true}}`, `{}`, `{"result":{"status":true,"value":false}}`} {
adapter := privacyidea.New(testConfig(), &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { return jsonResponse(body), nil }})
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err == nil {
t.Fatal("unsuccessful provider response accepted")
}
}
}
func TestProductionClientDoesNotFollowProviderRedirects(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path == "/redirect-target" {
t.Error("credential-bearing redirect followed")
}
http.Redirect(w, r, "/redirect-target", http.StatusFound)
}))
defer server.Close()
cfg := testConfig()
cfg.BaseURL = server.URL
adapter := privacyidea.New(cfg, nil)
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err == nil {
t.Fatal("redirect accepted")
}
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err == nil {
t.Fatal("redirect accepted")
}
if calls != 2 {
t.Fatal("unexpected redirect requests")
}
}
func TestRejectedCredentialCanRecoverAfterMountedSymlinkRenewal(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mounted-token")
old := filepath.Join(dir, "old")
replacement := filepath.Join(dir, "new")
for p, value := range map[string]string{old: "synthetic-expired", replacement: "synthetic-fresh"} {
if err := os.WriteFile(p, []byte(value), 0600); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(old, path); err != nil {
t.Fatal(err)
}
cfg := testConfig()
cfg.AdminToken = ""
cfg.AdminTokenFile = path
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) {
resp := jsonResponse(`{"result":{"status":true,"value":{"tokens":[],"count":0}}}`)
if req.Header.Get("Authorization") != "synthetic-fresh" {
resp.StatusCode = http.StatusUnauthorized
}
return resp, nil
}})
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err == nil {
t.Fatal("expired credential accepted")
}
next := filepath.Join(dir, "next-link")
if err := os.Symlink(replacement, next); err != nil {
t.Fatal(err)
}
if err := os.Rename(next, path); err != nil {
t.Fatal(err)
}
enrolled, err := adapter.HasEnrolledFactor(context.Background(), "alice")
if err != nil || enrolled {
t.Fatal("renewed credential did not recover lookup")
}
}