package oidc_test import ( "context" "crypto/hmac" "crypto/sha1" "encoding/base32" "encoding/binary" "encoding/json" "fmt" "keycape/internal/adapters/privacyidea" "keycape/internal/domain" "keycape/internal/server/oidc" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" ) // Uses only the companion disposable provider fixture, never a production URL. func TestNativeOptionalEnrollmentAndOldSession(t *testing.T) { base := os.Getenv("P06_NATIVE_PROVIDER_URL") if base == "" { t.Skip("requires disposable installed-provider fixture") } parsed, e := url.Parse(base) if e != nil || parsed.Hostname() != "127.0.0.1" || parsed.Scheme != "http" { t.Fatal("loopback fixture required") } call := func(method, path, token string, data url.Values) map[string]interface{} { r, e := http.NewRequest(method, base+path, strings.NewReader(data.Encode())) if e != nil { t.Fatal("fixture request") } r.Header.Set("Content-Type", "application/x-www-form-urlencoded") if token != "" { r.Header.Set("Authorization", token) } client := &http.Client{Timeout: 10 * time.Second} response, e := client.Do(r) if e != nil { t.Fatal("fixture unavailable") } defer response.Body.Close() var body map[string]interface{} if json.NewDecoder(response.Body).Decode(&body) != nil || response.StatusCode != 200 { t.Fatal("fixture request failed", response.StatusCode) } return body } value := func(b map[string]interface{}) map[string]interface{} { return b["result"].(map[string]interface{})["value"].(map[string]interface{}) } reader := value(call("POST", "/auth", "", url.Values{"username": {"fixture-reader"}, "password": {"fixture-service-password"}}))["token"].(string) user := value(call("POST", "/auth", "", url.Values{"username": {"native-alice"}, "password": {"fixture-password"}, "realm": {"fixture"}}))["token"].(string) adapter := privacyidea.New(privacyidea.Config{BaseURL: base, Realm: "fixture", AdminToken: reader, ReadProbeSerial: "P06SCOPEPROBE", RequireForAll: true}, nil) h := &oidc.AuthorizeHandler{ClientConfig: map[string]*domain.Client{"fixture": {ClientID: "fixture", MFAOptional: true}}, Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "native-alice"}}, MFA: adapter, Sessions: oidc.NewSessionStore(), Logins: oidc.NewLoginSessionStore(), Emitter: &captureEmitter{}} callback := func(acr []string, cookie *http.Cookie) *httptest.ResponseRecorder { h.PendingStates().Store("native", &oidc.PendingState{ClientID: "fixture", RedirectURI: "https://fixture.test/callback", State: "native", ACRValues: acr, ExpiresAt: time.Now().Add(time.Minute)}) r := httptest.NewRequest("GET", "/authorize/callback?code=fixture&state=native", nil) if cookie != nil { r.AddCookie(cookie) } w := httptest.NewRecorder() h.ServeHTTPCallback(w, r) return w } first := callback(nil, nil) if first.Code != 302 { t.Fatal("native no-factor decision", first.Code) } cookie := first.Result().Cookies()[0] detail := call("POST", "/token/init", user, url.Values{"type": {"totp"}, "genkey": {"1"}})["detail"].(map[string]interface{}) if detail["rollout_state"] != "verify" { t.Fatal("possession confirmation not required") } if w := callback(nil, cookie); w.Code != 302 { t.Fatal("pending setup activated MFA") } uri, e := url.Parse(detail["googleurl"].(map[string]interface{})["value"].(string)) if e != nil { t.Fatal("invalid fixture enrollment URI") } key, e := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.TrimRight(uri.Query().Get("secret"), "=")) if e != nil { t.Fatal("fixture seed format") } otp := func() string { counter := make([]byte, 8) binary.BigEndian.PutUint64(counter, uint64(time.Now().Unix()/30)) mac := hmac.New(sha1.New, key) mac.Write(counter) digest := mac.Sum(nil) offset := digest[len(digest)-1] & 15 return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(digest[offset:offset+4])&0x7fffffff)%1000000) } call("POST", "/token/init", user, url.Values{"serial": {detail["serial"].(string)}, "type": {"totp"}, "verify": {otp()}}) if enrolled, e := adapter.HasEnrolledFactor(context.Background(), "native-alice"); e != nil || !enrolled { t.Fatal("native activation not observed") } if w := callback(nil, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") { t.Fatal("old AAL1 session bypassed native enrolled factor") } if w := callback([]string{"aal2"}, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") { t.Fatal("native explicit step-up bypassed") } // Confirmation consumed the current TOTP; wait for the next independent code. time.Sleep(time.Duration(31-time.Now().Unix()%30) * time.Second) request := httptest.NewRequest("POST", "/authorize/callback", strings.NewReader(url.Values{"state": {"native"}, "mfa_token": {otp()}}.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response := httptest.NewRecorder() h.ServeHTTPCallback(response, request) if response.Code != 302 { t.Fatal("native OTP sign-in failed", response.Code) } location, _ := url.Parse(response.Header().Get("Location")) session, ok := h.Sessions.Get(location.Query().Get("code")) if !ok || !session.MFAVerified { t.Fatal("native OTP did not establish MFA") } }