All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m50s
Verified first: grant_types_supported advertises client_credentials in discovery.go, but token.go only ever accepted authorization_code -- no service-token issuance path exists at all. Building one from scratch is materially bigger than extending the existing flow; explicitly not attempted here, left open in the workplan rather than declared done. What shipped for the human Authorization Code + PKCE flow: - domain.User.Tenant (new, omitempty) + token.go's effectiveTenant(): falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006) when unset -- never an empty tenant claim, never a silent reassignment. - principal_type: "human", unconditional. - groups/roles promoted from scope-gated to unconditional core claims, always [] not null when empty. One pre-existing test asserted the old scope-gated groups behavior -- updated to match the new intentional behavior, not left failing or reverted. - assurance built from PKCESession.MFAVerified (new field, threaded through completeAuthorization's two call sites in authorize.go) -- whether MFA was actually verified in this session, not static enrollment state. aal2 only when required-and-passed this time, aal1 otherwise. go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go cases assert MFAVerified on both paths. tests/profile/profile_test.go's TestCompleteTokenFlow (the repo's own full HTTP integration test) extended with real value assertions for all five claims, not just presence checks. Python conformance tool not run against a live instance (needs the full Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round trip covers the equivalent claim checks instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
822 lines
24 KiB
Go
822 lines
24 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 {
|
|
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
|
|
validateCalls int
|
|
validatedUser string
|
|
validatedToken string
|
|
}
|
|
|
|
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
|
|
return m.required, m.requiredErr
|
|
}
|
|
|
|
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"},
|
|
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"`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("challenge page missing %q in body: %s", want, body)
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
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_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")
|
|
}
|
|
}
|
|
}
|