diff --git a/README.md b/README.md index 826407a..27980b8 100644 --- a/README.md +++ b/README.md @@ -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"}` | diff --git a/config/dev-config.yaml b/config/dev-config.yaml index 78a3cf7..e6ca0f3 100644 --- a/config/dev-config.yaml +++ b/config/dev-config.yaml @@ -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" diff --git a/src/internal/adapters/authelia/adapter.go b/src/internal/adapters/authelia/adapter.go index 539200c..c860472 100644 --- a/src/internal/adapters/authelia/adapter.go +++ b/src/internal/adapters/authelia/adapter.go @@ -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. diff --git a/src/internal/adapters/authelia/adapter_test.go b/src/internal/adapters/authelia/adapter_test.go index e4f4180..c71756f 100644 --- a/src/internal/adapters/authelia/adapter_test.go +++ b/src/internal/adapters/authelia/adapter_test.go @@ -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", diff --git a/src/internal/adapters/authelia/config.go b/src/internal/adapters/authelia/config.go index 35234d0..1e6c17c 100644 --- a/src/internal/adapters/authelia/config.go +++ b/src/internal/adapters/authelia/config.go @@ -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. diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index 6659d36..4413cd9 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -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 { diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index 87dd58d..8a541d2 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -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(` + + + + + KeyCape MFA + + + +
+

Verify sign-in

+

{{.Username}} for {{.ClientName}}

+ {{if .ErrorMessage}}

{{.ErrorMessage}}

{{end}} +
+ + + + +
+
+ +`)) + func uriRegistered(registered []string, target string) bool { for _, u := range registered { if u == target { diff --git a/src/internal/server/oidc/authorize_test.go b/src/internal/server/oidc/authorize_test.go index ea1055d..f06ff0c 100644 --- a/src/internal/server/oidc/authorize_test.go +++ b/src/internal/server/oidc/authorize_test.go @@ -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, diff --git a/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md b/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md new file mode 100644 index 0000000..28f2038 --- /dev/null +++ b/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md @@ -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.