feat: implement T06, T07 — authorization endpoint, token endpoint

- T06: /authorize with full PKCE validation, Authelia delegation, MFA check
- T07: /token with RS256 JWT issuance (stdlib only), PKCE verification, scope-filtered claims

50 OIDC tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-03-13 01:56:57 +01:00
parent d05c73dc19
commit 4097a7de8b
5 changed files with 1679 additions and 0 deletions

View file

@ -0,0 +1,565 @@
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")
}
}
}