package oidc_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "keycape/internal/domain" profileerrors "keycape/internal/errors" "keycape/internal/server/oidc" "keycape/internal/server/telemetry" ) // --------------------------------------------------------------------------- // Mock implementations // --------------------------------------------------------------------------- // mockAuthProvider implements domain.AuthProvider. type mockAuthProvider struct { authorizeURL string authorizeErr error callbackResult *domain.AuthResult callbackErr error } func (m *mockAuthProvider) AuthorizeURL(_ context.Context, _ domain.AuthRequest) (string, error) { if m.authorizeErr != nil { return "", m.authorizeErr } return m.authorizeURL, nil } func (m *mockAuthProvider) HandleCallback(_ context.Context, _ domain.CallbackParams) (*domain.AuthResult, error) { return m.callbackResult, m.callbackErr } // mockMFAProvider implements domain.MFAProvider. type mockMFAProvider struct { required bool requiredErr error validateErr error } func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) { return m.required, m.requiredErr } func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, _, _ string) error { return m.validateErr } // captureEmitter captures the last emitted event. type captureEmitter struct { events []telemetry.Event } func (c *captureEmitter) Emit(_ context.Context, ev telemetry.Event) { c.events = append(c.events, ev) } func (c *captureEmitter) last() telemetry.Event { if len(c.events) == 0 { return telemetry.Event{} } return c.events[len(c.events)-1] } // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- func testClient() map[string]*domain.Client { return map[string]*domain.Client{ "test-client": { ClientID: "test-client", RedirectURIs: []string{"https://app.example.com/callback"}, AllowedScopes: []string{"openid", "profile", "email"}, ClientType: "public", }, } } func newAuthorizeHandler(auth domain.AuthProvider, mfa domain.MFAProvider, emitter telemetry.Emitter) *oidc.AuthorizeHandler { return &oidc.AuthorizeHandler{ ClientConfig: testClient(), Auth: auth, MFA: mfa, Sessions: oidc.NewSessionStore(), Emitter: emitter, } } func validAuthorizeParams() url.Values { return url.Values{ "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "response_type": []string{"code"}, "scope": []string{"openid profile"}, "state": []string{"random-state"}, "code_challenge": []string{"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"}, "code_challenge_method": []string{"S256"}, } } func authorizeRequest(params url.Values) *http.Request { return httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil) } func decodeProfileError(t *testing.T, body string) profileerrors.ErrorType { t.Helper() var pe profileerrors.ProfileError if err := json.Unmarshal([]byte(body), &pe); err != nil { t.Fatalf("could not decode ProfileError: %v (body: %q)", err, body) } return pe.Error } // --------------------------------------------------------------------------- // T06 Authorization Endpoint Tests // --------------------------------------------------------------------------- func TestAuthorizeHandler_ValidRequest_RedirectsToAuthelia(t *testing.T) { auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth?state=xyz"} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) req := authorizeRequest(validAuthorizeParams()) 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()) } loc := w.Header().Get("Location") if loc != "https://authelia.example.com/auth?state=xyz" { t.Errorf("expected redirect to Authelia, got %q", loc) } } func TestAuthorizeHandler_EmitsAuthStart(t *testing.T) { auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) req := authorizeRequest(validAuthorizeParams()) w := httptest.NewRecorder() h.ServeHTTP(w, req) found := false for _, ev := range emitter.events { if ev.EventType == telemetry.EventAuthStart { found = true break } } if !found { t.Error("expected auth_start telemetry event to be emitted") } } func TestAuthorizeHandler_MissingCodeChallenge_InvalidProfileUsage(t *testing.T) { auth := &mockAuthProvider{} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) params := validAuthorizeParams() params.Del("code_challenge") params.Del("code_challenge_method") req := authorizeRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrInvalidProfileUsage { t.Errorf("expected invalid_profile_usage, got %q", errType) } } func TestAuthorizeHandler_WildcardRedirectURI_RejectedForSafety(t *testing.T) { auth := &mockAuthProvider{} mfa := &mockMFAProvider{} emitter := &captureEmitter{} clients := map[string]*domain.Client{ "wildcard-client": { ClientID: "wildcard-client", RedirectURIs: []string{"https://app.example.com/*"}, ClientType: "public", }, } h := &oidc.AuthorizeHandler{ ClientConfig: clients, Auth: auth, MFA: mfa, Sessions: oidc.NewSessionStore(), Emitter: emitter, } params := validAuthorizeParams() params.Set("client_id", "wildcard-client") params.Set("redirect_uri", "https://app.example.com/anything") req := authorizeRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrRejectedForSafety { t.Errorf("expected rejected_for_profile_safety, got %q", errType) } } func TestAuthorizeHandler_UnknownClient_InvalidProfileUsage(t *testing.T) { auth := &mockAuthProvider{} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) params := validAuthorizeParams() params.Set("client_id", "no-such-client") req := authorizeRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrInvalidProfileUsage { t.Errorf("expected invalid_profile_usage, got %q", errType) } } func TestAuthorizeHandler_WrongResponseType_FeatureNotSupported(t *testing.T) { auth := &mockAuthProvider{} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) params := validAuthorizeParams() params.Set("response_type", "token") req := authorizeRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrFeatureNotSupported { t.Errorf("expected feature_not_supported_by_profile, got %q", errType) } } func TestAuthorizeHandler_MissingOpenIDScope_InvalidProfileUsage(t *testing.T) { auth := &mockAuthProvider{} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) params := validAuthorizeParams() params.Set("scope", "profile email") req := authorizeRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrInvalidProfileUsage { t.Errorf("expected invalid_profile_usage, got %q", errType) } } func TestAuthorizeHandler_PlainCodeChallengeMethod_RejectedForSafety(t *testing.T) { auth := &mockAuthProvider{} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) params := validAuthorizeParams() params.Set("code_challenge_method", "plain") req := authorizeRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrRejectedForSafety { t.Errorf("expected rejected_for_profile_safety, got %q", errType) } } func TestAuthorizeHandler_UnknownRedirectURI_InvalidProfileUsage(t *testing.T) { auth := &mockAuthProvider{} mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) params := validAuthorizeParams() params.Set("redirect_uri", "https://evil.example.com/callback") req := authorizeRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrInvalidProfileUsage { t.Errorf("expected invalid_profile_usage, got %q", errType) } } // --------------------------------------------------------------------------- // Callback tests // --------------------------------------------------------------------------- func TestAuthorizeCallback_Success_RedirectsWithCode(t *testing.T) { auth := &mockAuthProvider{ callbackResult: &domain.AuthResult{Username: "alice"}, } mfa := &mockMFAProvider{required: false} emitter := &captureEmitter{} sessions := oidc.NewSessionStore() h := &oidc.AuthorizeHandler{ ClientConfig: testClient(), Auth: auth, MFA: mfa, Sessions: sessions, Emitter: emitter, } req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=authelia-code&state=random-state", nil) // Simulate that there is an ongoing PKCE flow stored in query param forwarding // The callback needs the original client context. We store it via a pre-seeded // pending session keyed by state. // For the callback handler, we expect it to look up the pending state by the // "state" parameter that was originally embedded. We seed the pending state. 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", "profile"}, ExpiresAt: time.Now().Add(5 * time.Minute), }) 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()) } loc := w.Header().Get("Location") parsed, err := url.Parse(loc) if err != nil { t.Fatalf("invalid Location header: %v", err) } 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")) } } func TestAuthorizeCallback_MFAFailed_AuthFailure(t *testing.T) { auth := &mockAuthProvider{ callbackResult: &domain.AuthResult{Username: "alice"}, } mfa := &mockMFAProvider{ required: true, validateErr: domain.ErrMFAFailed, } 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&mfa_token=wrong", nil) w := httptest.NewRecorder() h.ServeHTTPCallback(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", w.Code) } found := false for _, ev := range emitter.events { if ev.EventType == telemetry.EventAuthFailure { found = true break } } if !found { t.Error("expected auth_failure telemetry event") } } func TestAuthorizeCallback_AuthProviderFailed_AuthFailure(t *testing.T) { auth := &mockAuthProvider{ callbackErr: domain.ErrAuthFailed, } mfa := &mockMFAProvider{} 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", State: "random-state", ExpiresAt: time.Now().Add(5 * time.Minute), }) req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=bad&state=random-state", nil) w := httptest.NewRecorder() h.ServeHTTPCallback(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", w.Code) } found := false for _, ev := range emitter.events { if ev.EventType == telemetry.EventAuthFailure { found = true break } } if !found { t.Error("expected auth_failure telemetry event") } } func TestAuthorizeCallback_EmitsAuthSuccess(t *testing.T) { auth := &mockAuthProvider{ callbackResult: &domain.AuthResult{Username: "bob"}, } mfa := &mockMFAProvider{required: false} emitter := &captureEmitter{} sessions := oidc.NewSessionStore() h := &oidc.AuthorizeHandler{ ClientConfig: testClient(), Auth: auth, MFA: mfa, Sessions: sessions, Emitter: emitter, } h.PendingStates().Store("s1", &oidc.PendingState{ ClientID: "test-client", RedirectURI: "https://app.example.com/callback", PKCEChallenge: "abc", PKCEChallengeMethod: "S256", State: "s1", Scopes: []string{"openid"}, ExpiresAt: time.Now().Add(5 * time.Minute), }) req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=c&state=s1", nil) w := httptest.NewRecorder() h.ServeHTTPCallback(w, req) found := false for _, ev := range emitter.events { if ev.EventType == telemetry.EventAuthSuccess { found = true break } } if !found { t.Errorf("expected auth_success telemetry event, got events: %v", emitter.events) } } // --------------------------------------------------------------------------- // ServeHTTP dispatch // --------------------------------------------------------------------------- func TestAuthorizeHandler_ServeHTTP_DispatchesToCallback(t *testing.T) { auth := &mockAuthProvider{ callbackResult: &domain.AuthResult{Username: "alice"}, } mfa := &mockMFAProvider{} emitter := &captureEmitter{} h := newAuthorizeHandler(auth, mfa, emitter) // A request to /authorize/callback should not be treated as the initial // authorize request and must not require PKCE params. req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=y", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) // Without a seeded pending state for "y", the callback returns an error. // The important thing is that it is NOT a redirect to Authelia. if w.Code == http.StatusFound { loc := w.Header().Get("Location") if strings.Contains(loc, "authelia") { t.Error("callback path must not redirect to Authelia") } } }