The OTP page now points at the account security page without carrying challenge state, and the render test checks that link. Workplan headings sit above their task blocks so the hub titles match KEY-WP-0038 and KEY-WP-0039. KEY-WP-0039-T02 stays waiting on the Forgejo identity gate. Assistant: grok Assistant-Session: 01a13b3b-6856-7900-9d04-bccba2337a30
1013 lines
32 KiB
Go
1013 lines
32 KiB
Go
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 {
|
|
request domain.AuthRequest
|
|
authorizeURL string
|
|
authorizeErr error
|
|
|
|
callbackResult *domain.AuthResult
|
|
callbackErr error
|
|
}
|
|
|
|
func (m *mockAuthProvider) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
|
|
m.request = req
|
|
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
|
|
enrolled bool
|
|
enrolledErr 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) HasEnrolledFactor(_ context.Context, _ string) (bool, error) {
|
|
return m.enrolled, m.enrolledErr
|
|
}
|
|
|
|
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
|
|
m.validateCalls++
|
|
m.validatedUser = user
|
|
m.validatedToken = token
|
|
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",
|
|
DisplayName: "Test Client",
|
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
|
AllowedScopes: []string{"openid", "profile", "email", "groups"},
|
|
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",
|
|
},
|
|
}
|
|
}
|
|
|
|
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_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{}
|
|
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_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"`,
|
|
`href="https://users.coulomb.social/security"`,
|
|
`rel="noreferrer"`,
|
|
"Code rejected or lost your authenticator?",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("challenge page missing %q in body: %s", want, body)
|
|
}
|
|
}
|
|
if formEnd := strings.Index(body, "</form>"); formEnd < 0 || !strings.Contains(body[formEnd:], `href="https://users.coulomb.social/security"`) {
|
|
t.Error("lost-authenticator help link must follow the OTP form and carry no request state")
|
|
}
|
|
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")
|
|
}
|
|
|
|
// KEY-WP-0005-T01: the resulting session must record that MFA was
|
|
// actually verified in this flow, feeding token.go's assurance claim
|
|
// (aal2, not aal1).
|
|
code := parsed.Query().Get("code")
|
|
sess, ok := sessions.Get(code)
|
|
if !ok {
|
|
t.Fatal("expected a PKCE session for the issued code")
|
|
}
|
|
if !sess.MFAVerified {
|
|
t.Error("MFAVerified: want true after a successful MFA submission")
|
|
}
|
|
}
|
|
|
|
// INFD-IN-0005: a browser that already carried an older kc_login cookie for
|
|
// the same user must not have that stale IssuedAt stamped onto a session
|
|
// that just passed a fresh MFA challenge. Downstream freshness checks (e.g.
|
|
// flex-auth's 900-second review window) read AuthTime as proof of when MFA
|
|
// actually happened; silently backdating it made real, just-completed MFA
|
|
// look stale.
|
|
func TestAuthorizeCallback_MFASubmission_FreshEvenWithStalePriorLoginCookie(t *testing.T) {
|
|
mfa := &mockMFAProvider{required: true}
|
|
emitter := &captureEmitter{}
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
logins := oidc.NewLoginSessionStore()
|
|
stale := logins.Create("alice", domain.AssuranceAAL2)
|
|
stale.IssuedAt = time.Now().Add(-4 * time.Hour)
|
|
|
|
h := &oidc.AuthorizeHandler{
|
|
ClientConfig: testClient(),
|
|
Auth: &mockAuthProvider{},
|
|
MFA: mfa,
|
|
Sessions: sessions,
|
|
Logins: logins,
|
|
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),
|
|
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")
|
|
req.AddCookie(&http.Cookie{Name: "kc_login", Value: stale.ID})
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTPCallback(w, req)
|
|
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
|
|
}
|
|
loc, _ := url.Parse(w.Header().Get("Location"))
|
|
sess, ok := sessions.Get(loc.Query().Get("code"))
|
|
if !ok {
|
|
t.Fatal("expected a PKCE session for the issued code")
|
|
}
|
|
if time.Since(sess.AuthTime) > time.Minute {
|
|
t.Errorf("AuthTime = %v, want close to now: a fresh MFA submission must not inherit the stale kc_login cookie's IssuedAt", sess.AuthTime)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCallback_MFANotRequired_SessionRecordsMFAVerifiedFalse(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,
|
|
}
|
|
|
|
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.StatusFound {
|
|
t.Fatalf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
|
|
}
|
|
loc, err := url.Parse(w.Header().Get("Location"))
|
|
if err != nil {
|
|
t.Fatalf("invalid Location header: %v", err)
|
|
}
|
|
code := loc.Query().Get("code")
|
|
sess, ok := sessions.Get(code)
|
|
if !ok {
|
|
t.Fatal("expected a PKCE session for the issued code")
|
|
}
|
|
if sess.MFAVerified {
|
|
t.Error("MFAVerified: want false when MFA was never required for this flow")
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCallback_ClientPolicyCanDisableEnrolledMFA(t *testing.T) {
|
|
disabled := false
|
|
mfa := &mockMFAProvider{required: true}
|
|
h := &oidc.AuthorizeHandler{
|
|
ClientConfig: map[string]*domain.Client{"test-client": {
|
|
ClientID: "test-client", DisplayName: "Test", MFARequired: &disabled,
|
|
}},
|
|
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
|
MFA: mfa, Sessions: oidc.NewSessionStore(), Emitter: &captureEmitter{},
|
|
}
|
|
h.PendingStates().Store("state-client-aal1", &oidc.PendingState{
|
|
ClientID: "test-client", RedirectURI: "https://app.example/callback",
|
|
State: "state-client-aal1", ExpiresAt: time.Now().Add(time.Minute),
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=state-client-aal1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTPCallback(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCallback_ACRStepUpOverridesClientAAL1(t *testing.T) {
|
|
disabled := false
|
|
mfa := &mockMFAProvider{required: false}
|
|
h := &oidc.AuthorizeHandler{
|
|
ClientConfig: map[string]*domain.Client{"test-client": {
|
|
ClientID: "test-client", DisplayName: "Test", MFARequired: &disabled,
|
|
}},
|
|
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
|
MFA: mfa, Sessions: oidc.NewSessionStore(), Emitter: &captureEmitter{},
|
|
}
|
|
h.PendingStates().Store("state-step-up", &oidc.PendingState{
|
|
ClientID: "test-client", RedirectURI: "https://app.example/callback",
|
|
State: "state-step-up", ACRValues: []string{"aal2"},
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=state-step-up", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTPCallback(rec, req)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "KeyCape MFA") {
|
|
t.Fatalf("expected MFA challenge, status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFreshLoginRequirementsReachProvider(t *testing.T) {
|
|
for _, age := range []string{"0", "60"} {
|
|
auth := &mockAuthProvider{authorizeURL: "https://auth.example/login"}
|
|
handler := newAuthorizeHandler(auth, &mockMFAProvider{}, &captureEmitter{})
|
|
params := validAuthorizeParams()
|
|
params.Set("prompt", "login")
|
|
params.Set("max_age", age)
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, authorizeRequest(params))
|
|
if response.Code != http.StatusFound {
|
|
t.Fatalf("authorize failed: %d", response.Code)
|
|
}
|
|
expected, _ := time.ParseDuration(age + "s")
|
|
if !auth.request.PromptLogin || auth.request.MaxAge == nil || *auth.request.MaxAge != expected {
|
|
t.Fatalf("fresh login requirements lost before provider: %+v", auth.request)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Freshness is KeyCape's to enforce: the provider is asked only for a bounded
|
|
// max_age, so the callback must refuse an upstream authentication that
|
|
// predates a prompt=login request or exceeds max_age, and must fail closed
|
|
// when freshness was requested but no auth_time came back.
|
|
func TestCallbackEnforcesUpstreamFreshness(t *testing.T) {
|
|
now := time.Now()
|
|
zero, minute := time.Duration(0), time.Minute
|
|
cases := []struct {
|
|
name string
|
|
prompt bool
|
|
maxAge *time.Duration
|
|
created time.Time
|
|
authTime time.Time
|
|
ok bool
|
|
}{
|
|
{"prompt login after request", true, nil, now.Add(-5 * time.Second), now.Add(-2 * time.Second), true},
|
|
{"prompt login same second as request", true, nil, now.Truncate(time.Second).Add(700 * time.Millisecond), now.Truncate(time.Second), true},
|
|
{"prompt login reused session", true, nil, now.Add(-5 * time.Second), now.Add(-time.Hour), false},
|
|
{"prompt login without auth_time", true, nil, now.Add(-5 * time.Second), time.Time{}, false},
|
|
{"max_age zero is prompt login", false, &zero, now.Add(-5 * time.Second), now.Add(-time.Hour), false},
|
|
{"max_age satisfied", false, &minute, now.Add(-5 * time.Second), now.Add(-30 * time.Second), true},
|
|
{"max_age exceeded", false, &minute, now.Add(-5 * time.Second), now.Add(-5 * time.Minute), false},
|
|
{"no freshness requested", false, nil, now, time.Time{}, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
auth := &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice", AuthTime: tc.authTime}}
|
|
h := &oidc.AuthorizeHandler{
|
|
ClientConfig: testClient(),
|
|
Auth: auth,
|
|
MFA: &mockMFAProvider{},
|
|
Sessions: oidc.NewSessionStore(),
|
|
Emitter: &captureEmitter{},
|
|
}
|
|
h.PendingStates().Store("fresh-state", &oidc.PendingState{
|
|
ClientID: "test-client",
|
|
RedirectURI: "https://app.example.com/callback",
|
|
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
PKCEChallengeMethod: "S256",
|
|
State: "fresh-state",
|
|
Scopes: []string{"openid"},
|
|
PromptLogin: tc.prompt,
|
|
MaxAge: tc.maxAge,
|
|
CreatedAt: tc.created,
|
|
ExpiresAt: now.Add(5 * time.Minute),
|
|
})
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTPCallback(w, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=c&state=fresh-state", nil))
|
|
issued := w.Code == http.StatusFound && strings.Contains(w.Header().Get("Location"), "code=")
|
|
if issued != tc.ok {
|
|
t.Fatalf("code issued = %v, want %v (status %d, location %q)", issued, tc.ok, w.Code, w.Header().Get("Location"))
|
|
}
|
|
})
|
|
}
|
|
}
|