Implement scoped P06 authentication policy and guarded optional onboarding
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
parent
aa709fb854
commit
e0b3c25f06
12 changed files with 1085 additions and 2 deletions
|
|
@ -66,6 +66,7 @@ func (p *pendingStateStore) Delete(state string) {
|
|||
|
||||
// AuthorizeHandler implements GET /authorize and GET /authorize/callback.
|
||||
type AuthorizeHandler struct {
|
||||
EffectivePolicy func(*domain.Client) (*domain.Client, error)
|
||||
AccountPortalURL string
|
||||
ClientConfig map[string]*domain.Client
|
||||
Auth domain.AuthProvider
|
||||
|
|
@ -372,6 +373,13 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
|
||||
func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) {
|
||||
client := h.ClientConfig[ps.ClientID]
|
||||
if h.EffectivePolicy != nil {
|
||||
var err error
|
||||
client, err = h.EffectivePolicy(client)
|
||||
if err != nil {
|
||||
return domain.AssuranceDecision{}, err
|
||||
}
|
||||
}
|
||||
providerRequired := false
|
||||
if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) {
|
||||
var err error
|
||||
|
|
|
|||
124
src/internal/server/oidc/native_optional_test.go
Normal file
124
src/internal/server/oidc/native_optional_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
69
src/internal/server/oidc/policy_runtime_test.go
Normal file
69
src/internal/server/oidc/policy_runtime_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package oidc_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/server/oidc"
|
||||
"keycape/internal/server/policy"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRuntimePolicyEnrollmentOldSessionAndStepUp(t *testing.T) {
|
||||
clients := map[string]*domain.Client{}
|
||||
for _, id := range []string{"vergabe-demo-company", "user-engine-portal"} {
|
||||
clients[id] = &domain.Client{ClientID: id, GrantTypes: []string{"authorization_code"}}
|
||||
}
|
||||
policies, e := policy.Open(filepath.Join(t.TempDir(), "policy.json"), clients)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer policies.Close()
|
||||
mfa := &mockMFAProvider{required: true}
|
||||
h := &oidc.AuthorizeHandler{ClientConfig: clients, EffectivePolicy: policies.Effective, Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}}, MFA: mfa, Sessions: oidc.NewSessionStore(), Logins: oidc.NewLoginSessionStore(), Emitter: &captureEmitter{}}
|
||||
callback := func(acr []string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
h.PendingStates().Store("runtime", &oidc.PendingState{ClientID: "vergabe-demo-company", RedirectURI: "https://app.test/callback", State: "runtime", ACRValues: acr, ExpiresAt: time.Now().Add(time.Minute)})
|
||||
r := httptest.NewRequest("GET", "/authorize/callback?code=fixture&state=runtime", nil)
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTPCallback(w, r)
|
||||
return w
|
||||
}
|
||||
if w := callback(nil, nil); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("mandatory policy not effective")
|
||||
}
|
||||
preview, e := policies.Operation("operator", policy.Request{Action: "preview", Client: "vergabe-demo-company", Mode: policy.Optional, Reference: "change"})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = policies.Operation("operator", policy.Request{Action: "apply", Confirmation: preview["confirmation"].(string), Acknowledged: true}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
first := callback(nil, nil)
|
||||
if first.Code != 302 {
|
||||
t.Fatal("no-factor login denied", first.Code)
|
||||
}
|
||||
cookie := first.Result().Cookies()[0]
|
||||
mfa.enrolled = true
|
||||
if w := callback(nil, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("old AAL1 session bypassed enrolled factor")
|
||||
}
|
||||
mfa.enrolled = false
|
||||
if w := callback([]string{"aal2"}, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("optional policy bypassed explicit MFA")
|
||||
}
|
||||
mfa.enrolledErr = errors.New("fixture outage")
|
||||
if w := callback(nil, cookie); w.Code != 500 || strings.Contains(w.Header().Get("Location"), "code=") {
|
||||
t.Fatal("lookup outage granted authorization")
|
||||
}
|
||||
mfa.enrolledErr = nil
|
||||
if w := callback(nil, cookie); w.Code != 302 {
|
||||
t.Fatal("lookup recovery failed")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue