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

@ -0,0 +1,20 @@
name: Authentication acceptance
on:
push:
branches: [main]
paths: ["src/**", ".forgejo/workflows/acceptance.yaml"]
workflow_dispatch:
jobs:
acceptance:
runs-on: ubuntu-latest
container:
image: golang:1.23-alpine
steps:
- name: Test exact source revision
run: |
set -eu
wget -qO /tmp/keycape-source.tar.gz "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
mkdir -p /tmp/keycape-acceptance
tar xzf /tmp/keycape-source.tar.gz -C /tmp/keycape-acceptance --strip-components=1
cd /tmp/keycape-acceptance/src
/usr/local/go/bin/go test ./...

View file

@ -57,3 +57,26 @@ After credential delivery:
Rollback: restore the exact previous client registration and image pin via CAS.
This restores mandatory MFA for the demo client; it is not password-only access.
## Credential renewal consumer contract
KeyCape accepts either `privacyidea.adminToken` (existing startup configuration)
or `privacyidea.adminTokenFile` (an absolute protected mounted-file path). Configure
one source only. With a file, KeyCape reads the current value for each factor lookup
and OTP validation. Deliver renewal atomically; do not truncate a live file in
place. A projected Secret volume may update asynchronously; a `subPath` mount
will not provide live renewal. The deployment owner must verify propagation.
The file contains only the raw provider JWT with an optional trailing newline;
input is bounded to 16 KiB. Missing, empty, malformed, oversized or unreadable
files deny the operation without a cached or inline fallback. File contents and
paths are excluded from credential errors. Provider rejection also denies access;
file presence alone does not prove credential validity, scope or freshness.
Default provider requests have a ten-second timeout and do not follow redirects.
The credential must remain provider-issued, realm-scoped and renewed through
owner custody. This consumer feature neither mints credentials nor establishes
an OpenBao path. Deploying an approved file requires a separate protected mount,
read permission for KeyCape's runtime identity, renewal/expiry ownership, and
positive factor/no-factor plus failed-credential/recovery evidence. Existing live
configuration and policy have not been changed by implementing this feature.

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")
}
}

View file

@ -58,3 +58,15 @@ Verify provider self-service login, possession-confirmed activation, cancellatio
and removal/recovery. Resolve shared portal assurance scope before surfacing the
verified OTP setup link. Actual user login acceptance remains open under
KEY-WP-0034 and VERGABE-WP-0019; this work does not finish either workplan.
## Support bounded provider credential renewal without issuer restart
```task
id: KEY-WP-0035-T04
status: progress
priority: high
```
Supports platform journey P05 and USER-WP-0030-T03. Add an exclusive mounted adminTokenFile credential source, fresh reads for lookup and validation, no stale fallback, bounded input, sanitized failures and atomic replacement acceptance. Preserve inline configuration compatibility. Require provider success and true validation value before AAL2; bound default request time and reject credential-bearing redirects. Test renewal, rejection, recovery and policy regressions. This implements the consumer delivery contract; owner credential issuance/custody and live P04/P05/P06 acceptance remain T02/T03.
All Go regression/conformance suites pass, including five new renewal/validation tests with invalid-source subcases. Added exact-commit authentication acceptance CI. Provider-mounted credential delivery and effective optional policy remain gated separately.