generated from coulomb/repo-seed
bootrapping support
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
This commit is contained in:
17
README.md
17
README.md
@@ -64,7 +64,9 @@ lldap:
|
|||||||
baseDN: "dc=netkingdom,dc=local"
|
baseDN: "dc=netkingdom,dc=local"
|
||||||
|
|
||||||
authelia:
|
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"
|
clientId: "keycape"
|
||||||
clientSecret: "secret"
|
clientSecret: "secret"
|
||||||
redirectURI: "https://auth.netkingdom.local/authorize/callback"
|
redirectURI: "https://auth.netkingdom.local/authorize/callback"
|
||||||
@@ -81,10 +83,22 @@ clients:
|
|||||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
grantTypes: ["authorization_code"]
|
grantTypes: ["authorization_code"]
|
||||||
clientType: "public"
|
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.
|
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
|
## Endpoints
|
||||||
|
|
||||||
| Endpoint | Description |
|
| 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 /jwks` | RS256 public key in JWK Set format |
|
||||||
| `GET /authorize` | Authorization endpoint (PKCE required) |
|
| `GET /authorize` | Authorization endpoint (PKCE required) |
|
||||||
| `GET /authorize/callback` | Authelia callback handler |
|
| `GET /authorize/callback` | Authelia callback handler |
|
||||||
|
| `POST /authorize/callback` | privacyIDEA MFA challenge submission |
|
||||||
| `POST /token` | Token exchange (authorization_code only) |
|
| `POST /token` | Token exchange (authorization_code only) |
|
||||||
| `GET /userinfo` | Userinfo endpoint (Bearer token required) |
|
| `GET /userinfo` | Userinfo endpoint (Bearer token required) |
|
||||||
| `GET /healthz` | Health check → `{"status":"ok","version":"0.1.0"}` |
|
| `GET /healthz` | Health check → `{"status":"ok","version":"0.1.0"}` |
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ lldap:
|
|||||||
baseDN: "dc=netkingdom,dc=local"
|
baseDN: "dc=netkingdom,dc=local"
|
||||||
authelia:
|
authelia:
|
||||||
baseURL: "http://authelia:9091"
|
baseURL: "http://authelia:9091"
|
||||||
|
browserBaseURL: "http://localhost:9091"
|
||||||
|
tokenBaseURL: "http://authelia:9091"
|
||||||
clientId: "keycape"
|
clientId: "keycape"
|
||||||
clientSecret: "changeme"
|
clientSecret: "changeme"
|
||||||
redirectURI: "http://localhost:8080/authorize/callback"
|
redirectURI: "http://localhost:8080/authorize/callback"
|
||||||
@@ -22,6 +24,16 @@ clients:
|
|||||||
displayName: "Demo Application"
|
displayName: "Demo Application"
|
||||||
redirectUris:
|
redirectUris:
|
||||||
- "http://localhost:3000/callback"
|
- "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"]
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
grantTypes: ["authorization_code"]
|
grantTypes: ["authorization_code"]
|
||||||
clientType: "public"
|
clientType: "public"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
|
|||||||
// values — and requests the full fixed scope set. PKCE is omitted because
|
// values — and requests the full fixed scope set. PKCE is omitted because
|
||||||
// the confidential client_secret authenticates the token exchange instead.
|
// the confidential client_secret authenticates the token exchange instead.
|
||||||
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
|
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 := url.Values{}
|
||||||
q.Set("client_id", a.cfg.ClientID)
|
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
|
// 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.
|
// 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) {
|
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 := url.Values{}
|
||||||
body.Set("grant_type", "authorization_code")
|
body.Set("grant_type", "authorization_code")
|
||||||
@@ -173,6 +173,20 @@ func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenRe
|
|||||||
return &tr, nil
|
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
|
// parseIDTokenClaims extracts the JWT payload claims without verifying the
|
||||||
// signature. This is intentional — the token is received directly from the
|
// signature. This is intentional — the token is received directly from the
|
||||||
// upstream OIDC provider over a server-to-server TLS connection.
|
// upstream OIDC provider over a server-to-server TLS connection.
|
||||||
|
|||||||
@@ -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
|
// 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) {
|
func TestHandleCallback_Success_FallsBackToSub(t *testing.T) {
|
||||||
tokenBody := buildTokenResponse(map[string]interface{}{
|
tokenBody := buildTokenResponse(map[string]interface{}{
|
||||||
"sub": "user-uuid-456",
|
"sub": "user-uuid-456",
|
||||||
|
|||||||
@@ -8,17 +8,25 @@ import "net/http"
|
|||||||
// Config holds all connection parameters for the Authelia adapter.
|
// Config holds all connection parameters for the Authelia adapter.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// BaseURL is the Authelia server base URL, e.g. "https://authelia.local".
|
// 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 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 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
|
// RedirectURI is the callback URL registered in Authelia that points back
|
||||||
// to KeyCape's callback handler.
|
// to KeyCape's callback handler.
|
||||||
RedirectURI string
|
RedirectURI string `yaml:"redirectURI"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPClient is a minimal interface over net/http.Client for test injection.
|
// HTTPClient is a minimal interface over net/http.Client for test injection.
|
||||||
|
|||||||
@@ -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) {
|
func TestLoad_FileNotFound(t *testing.T) {
|
||||||
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package oidc
|
package oidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,6 +25,7 @@ type PendingState struct {
|
|||||||
State string
|
State string
|
||||||
Scopes []string
|
Scopes []string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
|
AuthenticatedUser string
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingStateStore is a thread-safe map of state → PendingState.
|
// 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) {
|
func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
h.init()
|
h.init()
|
||||||
ctx := r.Context()
|
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()
|
q := r.URL.Query()
|
||||||
|
|
||||||
state := q.Get("state")
|
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)
|
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.pending.Delete(state)
|
|
||||||
|
|
||||||
// Handle upstream callback.
|
// Handle upstream callback.
|
||||||
result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{
|
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)
|
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||||
return
|
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.
|
// Check MFA requirement.
|
||||||
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
||||||
@@ -256,34 +283,80 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mfaRequired {
|
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 {
|
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
h.pending.Delete(state)
|
||||||
Timestamp: time.Now(),
|
h.emitMFAFailure(ctx, ps.ClientID)
|
||||||
EventType: telemetry.EventAuthFailure,
|
|
||||||
ClientID: ps.ClientID,
|
|
||||||
Endpoint: "/authorize/callback",
|
|
||||||
Result: "failure",
|
|
||||||
ErrorType: "mfa_failed",
|
|
||||||
})
|
|
||||||
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||||
return
|
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.
|
// Generate authorization code and store PKCE session.
|
||||||
sess := &PKCESession{
|
sess := &PKCESession{
|
||||||
ClientID: ps.ClientID,
|
ClientID: ps.ClientID,
|
||||||
RedirectURI: ps.RedirectURI,
|
RedirectURI: ps.RedirectURI,
|
||||||
PKCEChallenge: ps.PKCEChallenge,
|
PKCEChallenge: ps.PKCEChallenge,
|
||||||
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
||||||
State: state,
|
State: ps.State,
|
||||||
Username: result.Username,
|
Username: username,
|
||||||
Scopes: ps.Scopes,
|
Scopes: ps.Scopes,
|
||||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||||
}
|
}
|
||||||
authCode := h.Sessions.Create(sess)
|
authCode := h.Sessions.Create(sess)
|
||||||
|
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
h.Emitter.Emit(r.Context(), telemetry.Event{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
EventType: telemetry.EventAuthSuccess,
|
EventType: telemetry.EventAuthSuccess,
|
||||||
ClientID: ps.ClientID,
|
ClientID: ps.ClientID,
|
||||||
@@ -293,14 +366,94 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Redirect to client with code and state.
|
// Redirect to client with code and state.
|
||||||
redirectTo := ps.RedirectURI + "?code=" + authCode + "&state=" + state
|
redirectTo, err := url.Parse(ps.RedirectURI)
|
||||||
http.Redirect(w, r, redirectTo, http.StatusFound)
|
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
|
// 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 {
|
func uriRegistered(registered []string, target string) bool {
|
||||||
for _, u := range registered {
|
for _, u := range registered {
|
||||||
if u == target {
|
if u == target {
|
||||||
|
|||||||
@@ -45,14 +45,20 @@ type mockMFAProvider struct {
|
|||||||
required bool
|
required bool
|
||||||
requiredErr error
|
requiredErr error
|
||||||
|
|
||||||
validateErr error
|
validateErr error
|
||||||
|
validateCalls int
|
||||||
|
validatedUser string
|
||||||
|
validatedToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
|
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
|
||||||
return m.required, m.requiredErr
|
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
|
return m.validateErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +86,21 @@ func testClient() map[string]*domain.Client {
|
|||||||
return map[string]*domain.Client{
|
return map[string]*domain.Client{
|
||||||
"test-client": {
|
"test-client": {
|
||||||
ClientID: "test-client",
|
ClientID: "test-client",
|
||||||
|
DisplayName: "Test Client",
|
||||||
RedirectURIs: []string{"https://app.example.com/callback"},
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
||||||
AllowedScopes: []string{"openid", "profile", "email"},
|
AllowedScopes: []string{"openid", "profile", "email"},
|
||||||
ClientType: "public",
|
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) {
|
func TestAuthorizeHandler_EmitsAuthStart(t *testing.T) {
|
||||||
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"}
|
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"}
|
||||||
mfa := &mockMFAProvider{}
|
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) {
|
func TestAuthorizeCallback_AuthProviderFailed_AuthFailure(t *testing.T) {
|
||||||
auth := &mockAuthProvider{
|
auth := &mockAuthProvider{
|
||||||
callbackErr: domain.ErrAuthFailed,
|
callbackErr: domain.ErrAuthFailed,
|
||||||
|
|||||||
194
workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md
Normal file
194
workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md
Normal 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.
|
||||||
Reference in New Issue
Block a user