Verify sign-in
+ + {{if .ErrorMessage}}{{.ErrorMessage}}
{{end}} + +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(` + +
+ + +{{.ErrorMessage}}
{{end}} + +