Support provider credential renewal and reject unsuccessful OTP validation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
85f5deaf4a
commit
632b1f1376
6 changed files with 292 additions and 5 deletions
20
.forgejo/workflows/acceptance.yaml
Normal file
20
.forgejo/workflows/acceptance.yaml
Normal 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 ./...
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
|||
175
src/internal/adapters/privacyidea/credential_file_test.go
Normal file
175
src/internal/adapters/privacyidea/credential_file_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue