This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/consent-service/internal/services/oauth_service_test.go
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

856 lines
22 KiB
Go

package services
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"time"
)
// TestPKCEVerification tests PKCE code_challenge and code_verifier validation
func TestPKCEVerification_S256_ValidVerifier(t *testing.T) {
// Generate a code_verifier
codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// Calculate expected code_challenge (S256)
hash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
// Verify the challenge matches
verifierHash := sha256.Sum256([]byte(codeVerifier))
calculatedChallenge := base64.RawURLEncoding.EncodeToString(verifierHash[:])
if calculatedChallenge != codeChallenge {
t.Errorf("PKCE verification failed: expected %s, got %s", codeChallenge, calculatedChallenge)
}
}
func TestPKCEVerification_S256_InvalidVerifier(t *testing.T) {
codeVerifier := "correct-verifier-12345678901234567890"
wrongVerifier := "wrong-verifier-00000000000000000000"
// Calculate code_challenge from correct verifier
hash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
// Calculate challenge from wrong verifier
wrongHash := sha256.Sum256([]byte(wrongVerifier))
wrongChallenge := base64.RawURLEncoding.EncodeToString(wrongHash[:])
if wrongChallenge == codeChallenge {
t.Error("PKCE verification should fail for wrong verifier")
}
}
func TestPKCEVerification_Plain_ValidVerifier(t *testing.T) {
codeVerifier := "plain-text-verifier-12345"
codeChallenge := codeVerifier // Plain method: challenge = verifier
if codeVerifier != codeChallenge {
t.Error("Plain PKCE verification failed")
}
}
// TestTokenHashing tests that token hashing is consistent
func TestTokenHashing_Consistency(t *testing.T) {
token := "sample-access-token-12345"
hash1 := sha256.Sum256([]byte(token))
hash2 := sha256.Sum256([]byte(token))
if hash1 != hash2 {
t.Error("Token hashing should be consistent")
}
}
func TestTokenHashing_DifferentTokens(t *testing.T) {
token1 := "token-1-abcdefgh"
token2 := "token-2-ijklmnop"
hash1 := sha256.Sum256([]byte(token1))
hash2 := sha256.Sum256([]byte(token2))
if hash1 == hash2 {
t.Error("Different tokens should produce different hashes")
}
}
// TestScopeValidation tests scope parsing and validation
func TestScopeValidation_ParseScopes(t *testing.T) {
tests := []struct {
name string
requestedScope string
allowedScopes []string
expectedCount int
}{
{
name: "all scopes allowed",
requestedScope: "openid profile email",
allowedScopes: []string{"openid", "profile", "email", "offline_access"},
expectedCount: 3,
},
{
name: "some scopes allowed",
requestedScope: "openid profile admin",
allowedScopes: []string{"openid", "profile", "email"},
expectedCount: 2, // admin not allowed
},
{
name: "no scopes allowed",
requestedScope: "admin superuser",
allowedScopes: []string{"openid", "profile", "email"},
expectedCount: 0,
},
{
name: "empty request defaults",
requestedScope: "",
allowedScopes: []string{"openid", "profile", "email"},
expectedCount: 0, // Empty request returns 0 from this test logic
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.requestedScope == "" {
// Empty scope should use defaults in actual service
return
}
allowedMap := make(map[string]bool)
for _, scope := range tt.allowedScopes {
allowedMap[scope] = true
}
var validScopes []string
requestedScopes := splitScopes(tt.requestedScope)
for _, scope := range requestedScopes {
if allowedMap[scope] {
validScopes = append(validScopes, scope)
}
}
if len(validScopes) != tt.expectedCount {
t.Errorf("Expected %d valid scopes, got %d", tt.expectedCount, len(validScopes))
}
})
}
}
// Helper function for scope splitting
func splitScopes(scopes string) []string {
if scopes == "" {
return nil
}
var result []string
start := 0
for i := 0; i <= len(scopes); i++ {
if i == len(scopes) || scopes[i] == ' ' {
if start < i {
result = append(result, scopes[start:i])
}
start = i + 1
}
}
return result
}
// TestRedirectURIValidation tests redirect URI validation
func TestRedirectURIValidation(t *testing.T) {
tests := []struct {
name string
registeredURIs []string
requestURI string
shouldMatch bool
}{
{
name: "exact match",
registeredURIs: []string{"https://example.com/callback"},
requestURI: "https://example.com/callback",
shouldMatch: true,
},
{
name: "no match different domain",
registeredURIs: []string{"https://example.com/callback"},
requestURI: "https://evil.com/callback",
shouldMatch: false,
},
{
name: "no match different path",
registeredURIs: []string{"https://example.com/callback"},
requestURI: "https://example.com/other",
shouldMatch: false,
},
{
name: "multiple URIs - second matches",
registeredURIs: []string{"https://example.com/callback", "https://example.com/auth"},
requestURI: "https://example.com/auth",
shouldMatch: true,
},
{
name: "localhost for development",
registeredURIs: []string{"http://localhost:3000/callback"},
requestURI: "http://localhost:3000/callback",
shouldMatch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matched := false
for _, uri := range tt.registeredURIs {
if uri == tt.requestURI {
matched = true
break
}
}
if matched != tt.shouldMatch {
t.Errorf("Expected match=%v, got match=%v", tt.shouldMatch, matched)
}
})
}
}
// TestGrantTypeValidation tests grant type validation
func TestGrantTypeValidation(t *testing.T) {
tests := []struct {
name string
allowedGrants []string
requestedGrant string
shouldAllow bool
}{
{
name: "authorization_code allowed",
allowedGrants: []string{"authorization_code", "refresh_token"},
requestedGrant: "authorization_code",
shouldAllow: true,
},
{
name: "refresh_token allowed",
allowedGrants: []string{"authorization_code", "refresh_token"},
requestedGrant: "refresh_token",
shouldAllow: true,
},
{
name: "password not allowed",
allowedGrants: []string{"authorization_code", "refresh_token"},
requestedGrant: "password",
shouldAllow: false,
},
{
name: "client_credentials not allowed",
allowedGrants: []string{"authorization_code", "refresh_token"},
requestedGrant: "client_credentials",
shouldAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed := false
for _, grant := range tt.allowedGrants {
if grant == tt.requestedGrant {
allowed = true
break
}
}
if allowed != tt.shouldAllow {
t.Errorf("Expected allow=%v, got allow=%v", tt.shouldAllow, allowed)
}
})
}
}
// TestAuthorizationCodeExpiry tests that expired codes should be rejected
func TestAuthorizationCodeExpiry_Logic(t *testing.T) {
tests := []struct {
name string
expiryMins int
usedAfter int // minutes after creation
shouldAllow bool
}{
{
name: "code used within expiry",
expiryMins: 10,
usedAfter: 5,
shouldAllow: true,
},
{
name: "code used at expiry boundary",
expiryMins: 10,
usedAfter: 10,
shouldAllow: false, // Expired at exactly 10 mins
},
{
name: "code used after expiry",
expiryMins: 10,
usedAfter: 15,
shouldAllow: false,
},
{
name: "code used immediately",
expiryMins: 10,
usedAfter: 0,
shouldAllow: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.usedAfter < tt.expiryMins
if isValid != tt.shouldAllow {
t.Errorf("Expected allow=%v for code used after %d mins (expiry: %d mins)",
tt.shouldAllow, tt.usedAfter, tt.expiryMins)
}
})
}
}
// TestClientSecretValidation tests confidential client authentication
func TestClientSecretValidation(t *testing.T) {
tests := []struct {
name string
isPublic bool
storedSecret string
providedSecret string
shouldAllow bool
}{
{
name: "public client - no secret needed",
isPublic: true,
storedSecret: "",
providedSecret: "",
shouldAllow: true,
},
{
name: "confidential client - correct secret",
isPublic: false,
storedSecret: "super-secret-123",
providedSecret: "super-secret-123",
shouldAllow: true,
},
{
name: "confidential client - wrong secret",
isPublic: false,
storedSecret: "super-secret-123",
providedSecret: "wrong-secret",
shouldAllow: false,
},
{
name: "confidential client - empty secret",
isPublic: false,
storedSecret: "super-secret-123",
providedSecret: "",
shouldAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var isValid bool
if tt.isPublic {
isValid = true
} else {
isValid = tt.storedSecret == tt.providedSecret
}
if isValid != tt.shouldAllow {
t.Errorf("Expected allow=%v, got allow=%v", tt.shouldAllow, isValid)
}
})
}
}
// ========================================
// Extended OAuth 2.0 Tests
// ========================================
// TestCodeVerifierGeneration tests that code verifiers meet RFC 7636 requirements
func TestCodeVerifierGeneration_RFC7636(t *testing.T) {
tests := []struct {
name string
length int
expectedLength int
description string
}{
{"minimum length (43)", 43, 43, "RFC 7636 minimum"},
{"standard length (64)", 64, 64, "Recommended length"},
{"maximum length (128)", 128, 128, "RFC 7636 maximum"},
{"too short (42) - corrected to minimum", 42, 43, "Should be corrected to minimum"},
{"too long (129) - corrected to maximum", 129, 128, "Should be corrected to maximum"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier := generateCodeVerifier(tt.length)
// Check that length is corrected to valid range
if len(verifier) != tt.expectedLength {
t.Errorf("Expected length %d, got %d", tt.expectedLength, len(verifier))
}
// Check character set (unreserved characters only: A-Z, a-z, 0-9, -, ., _, ~)
for _, c := range verifier {
if !isUnreservedChar(c) {
t.Errorf("Code verifier contains invalid character: %c", c)
}
}
})
}
}
// TestCodeVerifierLength_Validation tests length validation logic
func TestCodeVerifierLength_Validation(t *testing.T) {
tests := []struct {
name string
length int
isValid bool
}{
{"length 42 - too short", 42, false},
{"length 43 - minimum valid", 43, true},
{"length 64 - recommended", 64, true},
{"length 128 - maximum valid", 128, true},
{"length 129 - too long", 129, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.length >= 43 && tt.length <= 128
if isValid != tt.isValid {
t.Errorf("Expected valid=%v for length %d, got valid=%v",
tt.isValid, tt.length, isValid)
}
})
}
}
// generateCodeVerifier generates a code verifier of specified length
func generateCodeVerifier(length int) string {
// Ensure minimum and maximum bounds
if length < 43 {
length = 43
}
if length > 128 {
length = 128
}
const unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
bytes := make([]byte, length)
rand.Read(bytes)
result := make([]byte, length)
for i, b := range bytes {
result[i] = unreserved[int(b)%len(unreserved)]
}
return string(result)
}
// isUnreservedChar checks if a character is an unreserved character per RFC 3986
func isUnreservedChar(c rune) bool {
return (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~'
}
// TestCodeChallengeGeneration tests S256 challenge generation
func TestCodeChallengeGeneration_S256(t *testing.T) {
// Known test vector from RFC 7636 Appendix B
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
expectedChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
hash := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
if challenge != expectedChallenge {
t.Errorf("S256 challenge mismatch: expected %s, got %s", expectedChallenge, challenge)
}
}
// TestRefreshTokenRotation tests that refresh tokens are rotated on use
func TestRefreshTokenRotation_Logic(t *testing.T) {
// Simulate refresh token rotation
oldToken := "old-refresh-token-123"
oldTokenHash := hashToken(oldToken)
// Generate new token
newToken := generateSecureToken(32)
newTokenHash := hashToken(newToken)
// Verify tokens are different
if oldTokenHash == newTokenHash {
t.Error("New refresh token should be different from old token")
}
// Verify old token would be revoked (simulated by marking revoked_at)
oldTokenRevoked := true
if !oldTokenRevoked {
t.Error("Old refresh token should be revoked after rotation")
}
}
func hashToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
func generateSecureToken(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return base64.URLEncoding.EncodeToString(bytes)
}
// TestAccessTokenExpiry tests access token expiration handling
func TestAccessTokenExpiry_Scenarios(t *testing.T) {
tests := []struct {
name string
tokenDuration time.Duration
usedAfter time.Duration
shouldBeValid bool
}{
{
name: "token used immediately",
tokenDuration: time.Hour,
usedAfter: 0,
shouldBeValid: true,
},
{
name: "token used within validity",
tokenDuration: time.Hour,
usedAfter: 30 * time.Minute,
shouldBeValid: true,
},
{
name: "token used at expiry",
tokenDuration: time.Hour,
usedAfter: time.Hour,
shouldBeValid: false,
},
{
name: "token used after expiry",
tokenDuration: time.Hour,
usedAfter: 2 * time.Hour,
shouldBeValid: false,
},
{
name: "short-lived token",
tokenDuration: 5 * time.Minute,
usedAfter: 6 * time.Minute,
shouldBeValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
issuedAt := time.Now()
expiresAt := issuedAt.Add(tt.tokenDuration)
usedAt := issuedAt.Add(tt.usedAfter)
isValid := usedAt.Before(expiresAt)
if isValid != tt.shouldBeValid {
t.Errorf("Expected valid=%v for token used after %v (duration: %v)",
tt.shouldBeValid, tt.usedAfter, tt.tokenDuration)
}
})
}
}
// TestOAuthErrors tests that OAuth error codes are correct
func TestOAuthErrors_RFC6749(t *testing.T) {
tests := []struct {
scenario string
errorCode string
description string
}{
{"invalid client_id", "invalid_client", "Client authentication failed"},
{"invalid grant (code)", "invalid_grant", "Authorization code invalid or expired"},
{"invalid scope", "invalid_scope", "Requested scope is invalid"},
{"invalid request", "invalid_request", "Request is missing required parameter"},
{"unauthorized client", "unauthorized_client", "Client not authorized for this grant type"},
{"access denied", "access_denied", "Resource owner denied the request"},
}
for _, tt := range tests {
t.Run(tt.scenario, func(t *testing.T) {
// Verify error codes match RFC 6749 Section 5.2
validErrors := map[string]bool{
"invalid_request": true,
"invalid_client": true,
"invalid_grant": true,
"unauthorized_client": true,
"unsupported_grant_type": true,
"invalid_scope": true,
"access_denied": true,
"unsupported_response_type": true,
"server_error": true,
"temporarily_unavailable": true,
}
if !validErrors[tt.errorCode] {
t.Errorf("Error code %s is not a valid OAuth 2.0 error code", tt.errorCode)
}
})
}
}
// TestStateParameter tests state parameter handling for CSRF protection
func TestStateParameter_CSRF(t *testing.T) {
tests := []struct {
name string
requestState string
responseState string
shouldMatch bool
}{
{
name: "matching state",
requestState: "abc123xyz",
responseState: "abc123xyz",
shouldMatch: true,
},
{
name: "non-matching state",
requestState: "abc123xyz",
responseState: "different",
shouldMatch: false,
},
{
name: "empty request state",
requestState: "",
responseState: "abc123xyz",
shouldMatch: false,
},
{
name: "empty response state",
requestState: "abc123xyz",
responseState: "",
shouldMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := tt.requestState != "" && tt.requestState == tt.responseState
if matches != tt.shouldMatch {
t.Errorf("Expected match=%v, got match=%v", tt.shouldMatch, matches)
}
})
}
}
// TestResponseType tests response_type validation
func TestResponseType_Validation(t *testing.T) {
tests := []struct {
name string
responseType string
isValid bool
}{
{"code - valid", "code", true},
{"token - implicit flow (disabled)", "token", false},
{"id_token - OIDC", "id_token", false},
{"code token - hybrid", "code token", false},
{"empty", "", false},
{"invalid", "password", false},
}
supportedResponseTypes := map[string]bool{
"code": true, // Only authorization code flow is supported
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := supportedResponseTypes[tt.responseType]
if isValid != tt.isValid {
t.Errorf("Expected valid=%v for response_type=%s, got valid=%v",
tt.isValid, tt.responseType, isValid)
}
})
}
}
// TestCodeChallengeMethod tests code_challenge_method validation
func TestCodeChallengeMethod_Validation(t *testing.T) {
tests := []struct {
name string
method string
isValid bool
}{
{"S256 - recommended", "S256", true},
{"plain - discouraged but valid", "plain", true},
{"empty - defaults to plain", "", true},
{"sha512 - not supported", "sha512", false},
{"invalid", "md5", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.method == "S256" || tt.method == "plain" || tt.method == ""
if isValid != tt.isValid {
t.Errorf("Expected valid=%v for method=%s, got valid=%v",
tt.isValid, tt.method, isValid)
}
})
}
}
// TestTokenRevocation tests token revocation behavior per RFC 7009
func TestTokenRevocation_RFC7009(t *testing.T) {
tests := []struct {
name string
tokenExists bool
tokenRevoked bool
expectSuccess bool
}{
{
name: "revoke existing active token",
tokenExists: true,
tokenRevoked: false,
expectSuccess: true,
},
{
name: "revoke already revoked token",
tokenExists: true,
tokenRevoked: true,
expectSuccess: true, // RFC 7009: Always return 200
},
{
name: "revoke non-existent token",
tokenExists: false,
tokenRevoked: false,
expectSuccess: true, // RFC 7009: Always return 200
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate revocation logic
// Per RFC 7009, revocation endpoint always returns 200 OK
success := true
if success != tt.expectSuccess {
t.Errorf("Expected success=%v, got success=%v", tt.expectSuccess, success)
}
})
}
}
// TestClientIDGeneration tests client_id format
func TestClientIDGeneration_Format(t *testing.T) {
// Generate multiple client IDs
clientIDs := make(map[string]bool)
for i := 0; i < 100; i++ {
bytes := make([]byte, 16)
rand.Read(bytes)
clientID := hex.EncodeToString(bytes)
// Check format (32 hex characters)
if len(clientID) != 32 {
t.Errorf("Client ID should be 32 characters, got %d", len(clientID))
}
// Check uniqueness
if clientIDs[clientID] {
t.Error("Client ID should be unique")
}
clientIDs[clientID] = true
// Check only hex characters
for _, c := range clientID {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("Client ID should only contain hex characters, found %c", c)
}
}
}
}
// TestScopeNormalization tests scope string normalization
func TestScopeNormalization(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "single scope",
input: "openid",
expected: []string{"openid"},
},
{
name: "multiple scopes",
input: "openid profile email",
expected: []string{"openid", "profile", "email"},
},
{
name: "extra spaces",
input: "openid profile email",
expected: []string{"openid", "profile", "email"},
},
{
name: "empty string",
input: "",
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scopes := normalizeScopes(tt.input)
if len(scopes) != len(tt.expected) {
t.Errorf("Expected %d scopes, got %d", len(tt.expected), len(scopes))
return
}
for i, scope := range scopes {
if scope != tt.expected[i] {
t.Errorf("Expected scope[%d]=%s, got %s", i, tt.expected[i], scope)
}
}
})
}
}
func normalizeScopes(scope string) []string {
if scope == "" {
return []string{}
}
parts := strings.Fields(scope) // Handles multiple spaces
return parts
}
// BenchmarkPKCEVerification benchmarks PKCE S256 verification
func BenchmarkPKCEVerification_S256(b *testing.B) {
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
for i := 0; i < b.N; i++ {
hash := sha256.Sum256([]byte(verifier))
base64.RawURLEncoding.EncodeToString(hash[:])
}
}
// BenchmarkTokenHashing benchmarks token hashing for storage
func BenchmarkTokenHashing(b *testing.B) {
token := "sample-access-token-12345678901234567890"
for i := 0; i < b.N; i++ {
hash := sha256.Sum256([]byte(token))
hex.EncodeToString(hash[:])
}
}
// BenchmarkCodeVerifierGeneration benchmarks code verifier generation
func BenchmarkCodeVerifierGeneration(b *testing.B) {
for i := 0; i < b.N; i++ {
generateCodeVerifier(64)
}
}