bootrapping support
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled

This commit is contained in:
2026-05-24 17:03:01 +02:00
parent 393abf3e0e
commit 7e22fcf3c7
9 changed files with 715 additions and 23 deletions

View File

@@ -64,7 +64,9 @@ lldap:
baseDN: "dc=netkingdom,dc=local"
authelia:
baseURL: "https://authelia.local"
baseURL: "http://authelia.sso.svc.cluster.local:9091"
browserBaseURL: "https://authelia.local"
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
clientId: "keycape"
clientSecret: "secret"
redirectURI: "https://auth.netkingdom.local/authorize/callback"
@@ -81,10 +83,22 @@ clients:
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
```
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
`browserBaseURL` is used only for the human browser redirect to Authelia.
`tokenBaseURL` is used for server-side code exchange. If either is omitted,
KeyCape falls back to `baseURL`.
## Endpoints
| Endpoint | Description |
@@ -93,6 +107,7 @@ Config is validated at startup — the server exits 1 with validation errors if
| `GET /jwks` | RS256 public key in JWK Set format |
| `GET /authorize` | Authorization endpoint (PKCE required) |
| `GET /authorize/callback` | Authelia callback handler |
| `POST /authorize/callback` | privacyIDEA MFA challenge submission |
| `POST /token` | Token exchange (authorization_code only) |
| `GET /userinfo` | Userinfo endpoint (Bearer token required) |
| `GET /healthz` | Health check → `{"status":"ok","version":"0.1.0"}` |

View File

@@ -10,6 +10,8 @@ lldap:
baseDN: "dc=netkingdom,dc=local"
authelia:
baseURL: "http://authelia:9091"
browserBaseURL: "http://localhost:9091"
tokenBaseURL: "http://authelia:9091"
clientId: "keycape"
clientSecret: "changeme"
redirectURI: "http://localhost:8080/authorize/callback"
@@ -22,6 +24,16 @@ clients:
displayName: "Demo Application"
redirectUris:
- "http://localhost:3000/callback"
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"

View File

@@ -43,7 +43,7 @@ func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
// values — and requests the full fixed scope set. PKCE is omitted because
// the confidential client_secret authenticates the token exchange instead.
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
base := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/authorization"
base := strings.TrimRight(a.authorizeBaseURL(), "/") + "/api/oidc/authorization"
q := url.Values{}
q.Set("client_id", a.cfg.ClientID)
@@ -136,7 +136,7 @@ type tokenResponse struct {
// exchangeCode sends a POST to Authelia's token endpoint and returns the
// parsed token response. On any HTTP or status error it returns a non-nil error.
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
tokenURL := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/token"
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
body := url.Values{}
body.Set("grant_type", "authorization_code")
@@ -173,6 +173,20 @@ func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenRe
return &tr, nil
}
func (a *AutheliaAdapter) authorizeBaseURL() string {
if a.cfg.BrowserBaseURL != "" {
return a.cfg.BrowserBaseURL
}
return a.cfg.BaseURL
}
func (a *AutheliaAdapter) tokenBaseURL() string {
if a.cfg.TokenBaseURL != "" {
return a.cfg.TokenBaseURL
}
return a.cfg.BaseURL
}
// parseIDTokenClaims extracts the JWT payload claims without verifying the
// signature. This is intentional — the token is received directly from the
// upstream OIDC provider over a server-to-server TLS connection.

View File

@@ -136,6 +136,33 @@ func TestAuthorizeURL_UsesBaseURL(t *testing.T) {
}
}
func TestAuthorizeURL_UsesBrowserBaseURLWhenConfigured(t *testing.T) {
cfg := testConfig()
cfg.BaseURL = "http://authelia.sso.svc.cluster.local:9091"
cfg.BrowserBaseURL = "https://auth.coulomb.social"
adapter := authelia.New(cfg, &mockHTTPClient{})
req := domain.AuthRequest{
ClientID: "app",
RedirectURI: "https://app.local/cb",
State: "s",
PKCEChallenge: "c",
PKCEChallengeMethod: "S256",
Scopes: []string{"openid"},
}
u, err := adapter.AuthorizeURL(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(u, "https://auth.coulomb.social") {
t.Errorf("expected URL to start with BrowserBaseURL, got: %s", u)
}
if strings.Contains(u, "authelia.sso.svc.cluster.local") {
t.Errorf("browser redirect must not use internal service URL, got: %s", u)
}
}
// ---------------------------------------------------------------------------
// HandleCallback — successful token exchange
// ---------------------------------------------------------------------------
@@ -172,6 +199,32 @@ func TestHandleCallback_Success_PreferredUsername(t *testing.T) {
}
}
func TestHandleCallback_UsesTokenBaseURLWhenConfigured(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-123",
"preferred_username": "alice",
})
var tokenURL string
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
tokenURL = req.URL.String()
return jsonResponse(tokenBody), nil
},
}
cfg := testConfig()
cfg.BaseURL = "https://auth.coulomb.social"
cfg.TokenBaseURL = "http://authelia.sso.svc.cluster.local:9091"
adapter := authelia.New(cfg, client)
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(tokenURL, "http://authelia.sso.svc.cluster.local:9091") {
t.Errorf("expected token exchange to use TokenBaseURL, got: %s", tokenURL)
}
}
func TestHandleCallback_Success_FallsBackToSub(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-456",

View File

@@ -8,17 +8,25 @@ import "net/http"
// Config holds all connection parameters for the Authelia adapter.
type Config struct {
// BaseURL is the Authelia server base URL, e.g. "https://authelia.local".
BaseURL string
BaseURL string `yaml:"baseURL"`
// BrowserBaseURL is the public Authelia URL used for browser redirects.
// If empty, BaseURL is used.
BrowserBaseURL string `yaml:"browserBaseURL,omitempty"`
// TokenBaseURL is the server-side Authelia URL used for token exchange.
// If empty, BaseURL is used.
TokenBaseURL string `yaml:"tokenBaseURL,omitempty"`
// ClientID is the client ID registered in Authelia for KeyCape.
ClientID string
ClientID string `yaml:"clientId"`
// ClientSecret is the client secret for the KeyCape client registration.
ClientSecret string
ClientSecret string `yaml:"clientSecret"`
// RedirectURI is the callback URL registered in Authelia that points back
// to KeyCape's callback handler.
RedirectURI string
RedirectURI string `yaml:"redirectURI"`
}
// HTTPClient is a minimal interface over net/http.Client for test injection.

View File

@@ -81,6 +81,52 @@ clients:
}
}
func TestLoad_AutheliaSplitURLs(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://kc.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
authelia:
baseURL: "http://authelia.sso.svc.cluster.local:9091"
browserBaseURL: "https://auth.example.com"
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
clientId: "keycape"
clientSecret: "secret"
redirectURI: "https://kc.example.com/authorize/callback"
clients:
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if cfg.Authelia.BaseURL != "http://authelia.sso.svc.cluster.local:9091" {
t.Errorf("Authelia.BaseURL: got %q", cfg.Authelia.BaseURL)
}
if cfg.Authelia.BrowserBaseURL != "https://auth.example.com" {
t.Errorf("Authelia.BrowserBaseURL: got %q", cfg.Authelia.BrowserBaseURL)
}
if cfg.Authelia.TokenBaseURL != "http://authelia.sso.svc.cluster.local:9091" {
t.Errorf("Authelia.TokenBaseURL: got %q", cfg.Authelia.TokenBaseURL)
}
if len(cfg.Clients) != 1 || cfg.Clients[0].ClientID != "netkingdom-bootstrap-console" {
t.Fatalf("bootstrap client not loaded: %+v", cfg.Clients)
}
if got := cfg.Clients[0].RedirectURIs; len(got) != 2 || got[0] != "http://127.0.0.1:8876/oidc/callback" {
t.Errorf("bootstrap redirect URIs not loaded: %+v", got)
}
}
func TestLoad_FileNotFound(t *testing.T) {
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
if err == nil {

View File

@@ -1,7 +1,10 @@
package oidc
import (
"context"
"html/template"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -22,6 +25,7 @@ type PendingState struct {
State string
Scopes []string
ExpiresAt time.Time
AuthenticatedUser string
}
// pendingStateStore is a thread-safe map of state → PendingState.
@@ -212,6 +216,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) {
h.init()
ctx := r.Context()
if r.Method == http.MethodPost {
h.serveMFASubmission(w, r)
return
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
state := q.Get("state")
@@ -229,7 +244,6 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
http.Error(w, "authorization request expired", http.StatusBadRequest)
return
}
h.pending.Delete(state)
// Handle upstream callback.
result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{
@@ -248,6 +262,19 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
if result == nil || result.Username == "" {
h.pending.Delete(state)
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: ps.ClientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "auth_failed",
})
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
// Check MFA requirement.
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
@@ -256,34 +283,80 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
return
}
if mfaRequired {
if mfaToken == "" {
ps.AuthenticatedUser = result.Username
h.pending.Store(state, ps)
h.renderMFAChallenge(w, ps, "")
return
}
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: ps.ClientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "mfa_failed",
})
h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
return
}
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, result.Username)
}
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
state := r.Form.Get("state")
mfaToken := r.Form.Get("mfa_token")
ps, ok := h.pending.Load(state)
if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest)
return
}
if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest)
return
}
if ps.AuthenticatedUser == "" {
h.pending.Delete(state)
http.Error(w, "mfa challenge not active", http.StatusBadRequest)
return
}
if strings.TrimSpace(mfaToken) == "" {
h.renderMFAChallenge(w, ps, "Enter the one-time code.")
return
}
if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil {
h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
return
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, ps.AuthenticatedUser)
}
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string) {
// Generate authorization code and store PKCE session.
sess := &PKCESession{
ClientID: ps.ClientID,
RedirectURI: ps.RedirectURI,
PKCEChallenge: ps.PKCEChallenge,
PKCEChallengeMethod: ps.PKCEChallengeMethod,
State: state,
Username: result.Username,
State: ps.State,
Username: username,
Scopes: ps.Scopes,
ExpiresAt: time.Now().Add(10 * time.Minute),
}
authCode := h.Sessions.Create(sess)
h.Emitter.Emit(ctx, telemetry.Event{
h.Emitter.Emit(r.Context(), telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
ClientID: ps.ClientID,
@@ -293,14 +366,94 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
})
// Redirect to client with code and state.
redirectTo := ps.RedirectURI + "?code=" + authCode + "&state=" + state
http.Redirect(w, r, redirectTo, http.StatusFound)
redirectTo, err := url.Parse(ps.RedirectURI)
if err != nil {
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
return
}
q := redirectTo.Query()
q.Set("code", authCode)
q.Set("state", ps.State)
redirectTo.RawQuery = q.Encode()
http.Redirect(w, r, redirectTo.String(), http.StatusFound)
}
func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: clientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "mfa_failed",
})
}
func (h *AuthorizeHandler) renderMFAChallenge(w http.ResponseWriter, ps *PendingState, errorMessage string) {
clientName := ps.ClientID
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" {
clientName = client.DisplayName
}
status := http.StatusOK
if errorMessage != "" {
status = http.StatusBadRequest
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = mfaChallengeTemplate.Execute(w, struct {
State string
Username string
ClientName string
ErrorMessage string
}{
State: ps.State,
Username: ps.AuthenticatedUser,
ClientName: clientName,
ErrorMessage: errorMessage,
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeyCape MFA</title>
<style>
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f6f7f9; color: #17202a; }
main { width: min(420px, calc(100vw - 32px)); background: #fff; border: 1px solid #dfe4ea; border-radius: 8px; padding: 28px; box-shadow: 0 18px 45px rgba(23, 32, 42, .08); }
h1 { margin: 0 0 6px; font-size: 22px; font-weight: 650; letter-spacing: 0; }
p { margin: 0 0 20px; color: #52606d; line-height: 1.45; }
label { display: block; margin: 0 0 8px; font-size: 13px; font-weight: 650; color: #344054; }
input[type="text"] { width: 100%; box-sizing: border-box; height: 44px; border: 1px solid #c9d3df; border-radius: 6px; padding: 0 12px; font: inherit; background: #fff; }
input[type="text"]:focus { outline: 2px solid #2f80ed; outline-offset: 2px; border-color: #2f80ed; }
button { width: 100%; height: 44px; border: 0; border-radius: 6px; margin-top: 16px; background: #17324d; color: #fff; font: inherit; font-weight: 650; cursor: pointer; }
button:focus { outline: 2px solid #2f80ed; outline-offset: 2px; }
.meta { font-size: 13px; color: #667085; }
.error { margin: 0 0 12px; color: #b42318; font-size: 13px; font-weight: 650; }
</style>
</head>
<body>
<main>
<h1>Verify sign-in</h1>
<p class="meta">{{.Username}} for {{.ClientName}}</p>
{{if .ErrorMessage}}<p class="error">{{.ErrorMessage}}</p>{{end}}
<form method="post" action="/authorize/callback" autocomplete="off">
<input type="hidden" name="state" value="{{.State}}">
<label for="mfa_token">One-time code</label>
<input id="mfa_token" name="mfa_token" type="text" inputmode="numeric" autocomplete="one-time-code" required autofocus>
<button type="submit">Verify</button>
</form>
</main>
</body>
</html>`))
func uriRegistered(registered []string, target string) bool {
for _, u := range registered {
if u == target {

View File

@@ -45,14 +45,20 @@ type mockMFAProvider struct {
required bool
requiredErr error
validateErr error
validateErr error
validateCalls int
validatedUser string
validatedToken string
}
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
return m.required, m.requiredErr
}
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, _, _ string) error {
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
m.validateCalls++
m.validatedUser = user
m.validatedToken = token
return m.validateErr
}
@@ -80,10 +86,21 @@ func testClient() map[string]*domain.Client {
return map[string]*domain.Client{
"test-client": {
ClientID: "test-client",
DisplayName: "Test Client",
RedirectURIs: []string{"https://app.example.com/callback"},
AllowedScopes: []string{"openid", "profile", "email"},
ClientType: "public",
},
"netkingdom-bootstrap-console": {
ClientID: "netkingdom-bootstrap-console",
DisplayName: "NetKingdom Bootstrap Console",
RedirectURIs: []string{
"http://127.0.0.1:8876/oidc/callback",
"http://localhost:8876/oidc/callback",
},
AllowedScopes: []string{"openid", "profile", "email", "groups"},
ClientType: "public",
},
}
}
@@ -146,6 +163,28 @@ func TestAuthorizeHandler_ValidRequest_RedirectsToAuthelia(t *testing.T) {
}
}
func TestAuthorizeHandler_BootstrapConsoleRedirectURI_RedirectsToAuthelia(t *testing.T) {
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth?state=bootstrap"}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Set("client_id", "netkingdom-bootstrap-console")
params.Set("redirect_uri", "http://127.0.0.1:8876/oidc/callback")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Errorf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
}
if loc := w.Header().Get("Location"); loc != "https://authelia.example.com/auth?state=bootstrap" {
t.Errorf("expected Authelia redirect, got %q", loc)
}
}
func TestAuthorizeHandler_EmitsAuthStart(t *testing.T) {
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"}
mfa := &mockMFAProvider{}
@@ -449,6 +488,164 @@ func TestAuthorizeCallback_MFAFailed_AuthFailure(t *testing.T) {
}
}
func TestAuthorizeCallback_MFARequired_RendersChallengeWithoutToken(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "alice"},
}
mfa := &mockMFAProvider{required: true}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: sessions,
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
})
req := httptest.NewRequest(http.MethodGet,
"/authorize/callback?code=authelia-code&state=random-state", nil)
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 challenge page, got %d (body: %s)", w.Code, w.Body.String())
}
body := w.Body.String()
for _, want := range []string{"Verify sign-in", "alice", "Test Client", `name="mfa_token"`} {
if !strings.Contains(body, want) {
t.Errorf("challenge page missing %q in body: %s", want, body)
}
}
if mfa.validateCalls != 0 {
t.Errorf("MFA token should not be validated until form submission, got %d calls", mfa.validateCalls)
}
ps, ok := h.PendingStates().Load("random-state")
if !ok {
t.Fatal("expected pending state to remain for MFA form submission")
}
if ps.AuthenticatedUser != "alice" {
t.Errorf("AuthenticatedUser: want alice, got %q", ps.AuthenticatedUser)
}
}
func TestAuthorizeCallback_MFASubmission_ValidToken_RedirectsWithCode(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{required: true}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: sessions,
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback?from=bootstrap",
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
AuthenticatedUser: "alice",
})
form := url.Values{"state": {"random-state"}, "mfa_token": {"123456"}}
req := httptest.NewRequest(http.MethodPost, "/authorize/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusFound {
t.Errorf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
}
if mfa.validatedUser != "alice" || mfa.validatedToken != "123456" {
t.Errorf("validated MFA: want alice/123456, got %q/%q", mfa.validatedUser, mfa.validatedToken)
}
loc := w.Header().Get("Location")
parsed, err := url.Parse(loc)
if err != nil {
t.Fatalf("invalid Location header: %v", err)
}
if parsed.Query().Get("from") != "bootstrap" {
t.Errorf("expected original redirect query to be preserved, got %q", loc)
}
if parsed.Query().Get("code") == "" {
t.Error("expected code param in redirect, got empty")
}
if parsed.Query().Get("state") != "random-state" {
t.Errorf("expected state=random-state, got %q", parsed.Query().Get("state"))
}
if _, ok := h.PendingStates().Load("random-state"); ok {
t.Error("expected pending MFA state to be deleted after successful submission")
}
}
func TestAuthorizeCallback_MFASubmission_InvalidToken_AuthFailure(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{
required: true,
validateErr: domain.ErrMFAFailed,
}
emitter := &captureEmitter{}
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: oidc.NewSessionStore(),
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "abc",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
AuthenticatedUser: "alice",
})
form := url.Values{"state": {"random-state"}, "mfa_token": {"wrong"}}
req := httptest.NewRequest(http.MethodPost, "/authorize/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
if _, ok := h.PendingStates().Load("random-state"); ok {
t.Error("expected pending MFA state to be deleted after invalid submission")
}
found := false
for _, ev := range emitter.events {
if ev.EventType == telemetry.EventAuthFailure && ev.ErrorType == "mfa_failed" {
found = true
break
}
}
if !found {
t.Error("expected mfa_failed auth_failure telemetry event")
}
}
func TestAuthorizeCallback_AuthProviderFailed_AuthFailure(t *testing.T) {
auth := &mockAuthProvider{
callbackErr: domain.ErrAuthFailed,

View File

@@ -0,0 +1,194 @@
---
id: KEY-WP-0003
type: workplan
title: "Bootstrap Console OIDC Login and MFA Verification"
domain: netkingdom
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-05-24"
updated: "2026-05-24"
---
# KEY-WP-0003 - Bootstrap Console OIDC Login and MFA Verification
## Problem
The NetKingdom security bootstrap console now acts as a local OIDC client
callback so the operator can verify the dedicated platform-root login before
approving custody mode. The current live KeyCape deployment rejects that flow
with:
```json
{
"error": "invalid_profile_usage",
"description": "redirect_uri does not match any registered URI",
"feature": "redirect_uri"
}
```
That error is correct profile enforcement: KeyCape only accepts exact
registered redirect URIs. The live `demo-app` registration has not yet been
updated to allow the local bootstrap console callback:
- `http://127.0.0.1:8876/oidc/callback`
- `http://localhost:8876/oidc/callback`
After that is fixed, there is a second usability/security gap. KeyCape checks
privacyIDEA MFA after the Authelia callback, but the browser flow currently
expects an `mfa_token` query parameter instead of presenting a proper OTP
challenge page to the human operator.
## Goal
Make the bootstrap console's "Start demo OIDC login" button a real
end-to-end verification path for the current lightweight IAM stack:
1. KeyCape accepts the bootstrap console callback URI by exact registration.
2. The browser leaves KeyCape for the public Authelia login URL.
3. After password login, KeyCape presents a minimal MFA challenge when
privacyIDEA requires one.
4. KeyCape issues an OIDC authorization code to the bootstrap console callback.
5. The console can exchange the code and let the operator mark
`OIDC login verified` without exposing tokens or secrets.
This keeps KeyCape's security posture intact: no wildcard redirect URIs, no
dynamic client registration, no token display, and no storage of OTP material.
## Design Notes
- Prefer a dedicated public client named `netkingdom-bootstrap-console` for
long-lived clarity. Reusing `demo-app` is acceptable for the immediate
unblock only if the deployment/runbook clearly labels it as a bootstrap test
client.
- The bootstrap callback is local-only and operator-attended. It must be an
exact URI in config, not a wildcard or dynamic registration exception.
- Browser-facing Authelia redirects must use the public Authelia base URL
(`https://auth.coulomb.social`) so the human login page opens correctly.
- KeyCape may still need an internal service URL for back-channel token
exchange. If so, split the current single Authelia URL into browser-facing
authorize URL and internal token URL instead of making the browser use an
in-cluster hostname.
- The MFA prompt should collect only a one-time code, post it back to KeyCape,
validate with privacyIDEA, and then continue the normal OIDC code flow.
- This work unblocks the NetKingdom custody gate in
`NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap`.
## Implementation Notes
**2026-05-24:** Implemented in source:
- added `netkingdom-bootstrap-console` as a public OIDC client in the sample
KeyCape config, while keeping the local callback registered on `demo-app`
for compatibility,
- split Authelia browser redirects from server-side token exchange via
`browserBaseURL` and `tokenBaseURL`,
- added a browser MFA challenge page at `POST /authorize/callback` that
validates the one-time code with privacyIDEA before issuing the downstream
OIDC authorization code,
- updated NetKingdom's `keycape-config` generation template and bootstrap
console to use the dedicated client,
- added regression tests for callback registration, split Authelia URLs, MFA
challenge rendering, valid OTP continuation, and invalid OTP failure.
Live use still requires deployment: build/publish the updated KeyCape image,
refresh the live `keycape-config` Secret through the custodian age-key unlock
ceremony, and restart the KeyCape deployment.
---
## T01 - Register the bootstrap console callback client
```task
id: KEY-WP-0003-T01
status: done
priority: high
```
Add a KeyCape client registration for the bootstrap console. Either create a
dedicated `netkingdom-bootstrap-console` public client or update `demo-app`
temporarily with these exact redirect URIs:
- `http://127.0.0.1:8876/oidc/callback`
- `http://localhost:8876/oidc/callback`
Update the sample config, tests, and deployment/runbook references so the
registered client is reproducible and not just a live-cluster patch.
Gate: an authorize request using the local callback no longer returns
`invalid_profile_usage` for `redirect_uri`.
## T02 - Separate browser-facing and internal Authelia URLs if needed
```task
id: KEY-WP-0003-T02
status: done
priority: high
```
Confirm whether the current `authelia.baseURL` is safe to use for both browser
redirects and server-side token exchange. If not, add explicit configuration
for the browser authorize base URL and internal token/userinfo base URL.
Gate: the first browser redirect leaves `https://kc.coulomb.social` for
`https://auth.coulomb.social/...`; server-side token exchange still works from
inside the deployment.
## T03 - Add a browser MFA challenge step
```task
id: KEY-WP-0003-T03
status: done
priority: high
```
When `CheckMFARequired` returns true after the Authelia callback, render a
minimal KeyCape MFA challenge page instead of requiring `mfa_token` in the
callback query string. The page should:
- show the authenticated username and client display name,
- collect only the OTP code,
- preserve the pending OIDC state server-side,
- validate with privacyIDEA,
- continue to issue the normal authorization code on success,
- fail closed with the existing telemetry on invalid MFA.
Gate: a user enrolled in privacyIDEA can complete password + OTP in the
browser and is returned to the registered downstream callback.
## T04 - Add end-to-end profile tests for the bootstrap login path
```task
id: KEY-WP-0003-T04
status: done
priority: medium
```
Add tests that cover:
- local bootstrap callback registration,
- rejection of unregistered callbacks remains intact,
- Authelia browser redirect uses the expected public URL,
- MFA-required login presents a challenge instead of immediate failure,
- invalid OTP fails closed,
- valid OTP produces an authorization code bound to the original PKCE session.
Gate: `make test` passes and the negative redirect URI tests remain green.
## T05 - Document the live rollout ceremony
```task
id: KEY-WP-0003-T05
status: done
priority: medium
```
Document the deployment path for updating live KeyCape config without
regenerating unrelated secrets. The runbook must fit the NetKingdom custodian
age-key model: decrypt or unlock only during an attended ceremony, apply the
updated client registration/config, restart KeyCape, and remove plaintext
secret material afterward.
Gate: an operator can update the live `keycape-config` Secret and verify the
bootstrap console OIDC login without printing or committing secrets.