diff --git a/cmd/server/main.go b/cmd/server/main.go index 8e97f53..cff6057 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -62,7 +62,7 @@ func main() { // Initialize services appService := services.NewApplicationService(appRepo, logger) - tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, cfg.GetString("INTERNAL_HMAC_KEY"), logger) + tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, cfg.GetString("INTERNAL_HMAC_KEY"), cfg, logger) authService := services.NewAuthenticationService(cfg, logger) // Initialize handlers diff --git a/internal/services/token_service.go b/internal/services/token_service.go index 197f933..2378431 100644 --- a/internal/services/token_service.go +++ b/internal/services/token_service.go @@ -8,6 +8,8 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + "github.com/kms/api-key-service/internal/auth" + "github.com/kms/api-key-service/internal/config" "github.com/kms/api-key-service/internal/crypto" "github.com/kms/api-key-service/internal/domain" "github.com/kms/api-key-service/internal/repository" @@ -20,6 +22,7 @@ type tokenService struct { permRepo repository.PermissionRepository grantRepo repository.GrantedPermissionRepository tokenGen *crypto.TokenGenerator + jwtManager *auth.JWTManager logger *zap.Logger } @@ -30,15 +33,17 @@ func NewTokenService( permRepo repository.PermissionRepository, grantRepo repository.GrantedPermissionRepository, hmacKey string, + config config.ConfigProvider, logger *zap.Logger, ) TokenService { return &tokenService{ - tokenRepo: tokenRepo, - appRepo: appRepo, - permRepo: permRepo, - grantRepo: grantRepo, - tokenGen: crypto.NewTokenGenerator(hmacKey), - logger: logger, + tokenRepo: tokenRepo, + appRepo: appRepo, + permRepo: permRepo, + grantRepo: grantRepo, + tokenGen: crypto.NewTokenGenerator(hmacKey), + jwtManager: auth.NewJWTManager(config, logger), + logger: logger, } } @@ -197,11 +202,57 @@ func (s *tokenService) Delete(ctx context.Context, tokenID uuid.UUID, userID str func (s *tokenService) GenerateUserToken(ctx context.Context, appID, userID string, permissions []string) (string, error) { s.logger.Info("Generating user token", zap.String("app_id", appID), zap.String("user_id", userID)) - // TODO: Validate application - // TODO: Validate permissions - // TODO: Generate JWT token + // Validate application exists + app, err := s.appRepo.GetByID(ctx, appID) + if err != nil { + s.logger.Error("Failed to get application", zap.Error(err), zap.String("app_id", appID)) + return "", fmt.Errorf("application not found: %w", err) + } - return "user-token-placeholder-" + userID, nil + // Validate permissions exist (if any provided) + var validPermissions []string + if len(permissions) > 0 { + validPermissions, err = s.permRepo.ValidatePermissionScopes(ctx, permissions) + if err != nil { + s.logger.Error("Failed to validate permissions", zap.Error(err)) + return "", fmt.Errorf("failed to validate permissions: %w", err) + } + + if len(validPermissions) != len(permissions) { + s.logger.Warn("Some permissions are invalid", + zap.Strings("requested", permissions), + zap.Strings("valid", validPermissions)) + return "", fmt.Errorf("some requested permissions are invalid") + } + } + + // Create user token with proper timing + now := time.Now() + userToken := &domain.UserToken{ + AppID: appID, + UserID: userID, + Permissions: validPermissions, + IssuedAt: now, + ExpiresAt: now.Add(app.TokenRenewalDuration.Duration), + MaxValidAt: now.Add(app.MaxTokenDuration.Duration), + TokenType: domain.TokenTypeUser, + } + + // Generate JWT token using JWT manager + tokenString, err := s.jwtManager.GenerateToken(userToken) + if err != nil { + s.logger.Error("Failed to generate JWT token", zap.Error(err)) + return "", fmt.Errorf("failed to generate token: %w", err) + } + + s.logger.Info("User token generated successfully", + zap.String("app_id", appID), + zap.String("user_id", userID), + zap.Strings("permissions", validPermissions), + zap.Time("expires_at", userToken.ExpiresAt), + zap.Time("max_valid_at", userToken.MaxValidAt)) + + return tokenString, nil } // VerifyToken verifies a token and returns verification response @@ -338,12 +389,101 @@ func (s *tokenService) verifyStaticToken(ctx context.Context, req *domain.Verify func (s *tokenService) verifyUserToken(ctx context.Context, req *domain.VerifyRequest, app *domain.Application) (*domain.VerifyResponse, error) { s.logger.Debug("Verifying user token", zap.String("app_id", req.AppID)) - // TODO: Implement JWT token verification - // For now, return an error since user tokens are not fully implemented + // Check if token is revoked first + isRevoked, err := s.jwtManager.IsTokenRevoked(req.Token) + if err != nil { + s.logger.Error("Failed to check token revocation status", zap.Error(err)) + return &domain.VerifyResponse{ + Valid: false, + Permitted: false, + Error: "Token verification failed", + }, nil + } + + if isRevoked { + s.logger.Warn("Token is revoked", zap.String("app_id", req.AppID)) + return &domain.VerifyResponse{ + Valid: false, + Permitted: false, + Error: "Token has been revoked", + }, nil + } + + // Validate JWT token + claims, err := s.jwtManager.ValidateToken(req.Token) + if err != nil { + s.logger.Warn("JWT token validation failed", zap.Error(err), zap.String("app_id", req.AppID)) + return &domain.VerifyResponse{ + Valid: false, + Permitted: false, + Error: "Invalid token", + }, nil + } + + // Verify the token is for the correct application + if claims.AppID != req.AppID { + s.logger.Warn("Token app_id mismatch", + zap.String("expected", req.AppID), + zap.String("actual", claims.AppID)) + return &domain.VerifyResponse{ + Valid: false, + Permitted: false, + Error: "Token not valid for this application", + }, nil + } + + // Check specific permissions if requested + var permissionResults map[string]bool + var permitted bool = true // Default to true if no specific permissions requested + + if len(req.Permissions) > 0 { + permissionResults = make(map[string]bool) + + // Check each requested permission against token permissions + for _, requestedPerm := range req.Permissions { + hasPermission := false + for _, tokenPerm := range claims.Permissions { + if tokenPerm == requestedPerm { + hasPermission = true + break + } + } + permissionResults[requestedPerm] = hasPermission + + // If any permission is missing, set permitted to false + if !hasPermission { + permitted = false + } + } + } + + // Convert timestamps + var expiresAt, maxValidAt *time.Time + if claims.ExpiresAt != nil { + expTime := claims.ExpiresAt.Time + expiresAt = &expTime + } + if claims.MaxValidAt > 0 { + maxTime := time.Unix(claims.MaxValidAt, 0) + maxValidAt = &maxTime + } + + s.logger.Info("User token verified successfully", + zap.String("user_id", claims.UserID), + zap.String("app_id", req.AppID), + zap.Strings("permissions", claims.Permissions), + zap.Bool("permitted", permitted)) + return &domain.VerifyResponse{ - Valid: false, - Permitted: false, - Error: "User token verification not yet implemented", + Valid: true, + Permitted: permitted, + UserID: claims.UserID, + Permissions: claims.Permissions, + PermissionResults: permissionResults, + ExpiresAt: expiresAt, + MaxValidAt: maxValidAt, + TokenType: domain.TokenTypeUser, + Claims: claims.Claims, }, nil } diff --git a/kms-frontend/src/App.tsx b/kms-frontend/src/App.tsx index 3e693f1..8f84ab5 100644 --- a/kms-frontend/src/App.tsx +++ b/kms-frontend/src/App.tsx @@ -8,6 +8,7 @@ import { UserOutlined, AuditOutlined, LoginOutlined, + ExperimentOutlined, } from '@ant-design/icons'; import { useState } from 'react'; import './App.css'; @@ -19,6 +20,8 @@ import Tokens from './components/Tokens'; import Users from './components/Users'; import Audit from './components/Audit'; import Login from './components/Login'; +import TokenTester from './components/TokenTester'; +import TokenTesterCallback from './components/TokenTesterCallback'; import { AuthProvider, useAuth } from './contexts/AuthContext'; const { Header, Sider, Content } = Layout; @@ -50,6 +53,11 @@ const AppContent: React.FC = () => { icon: , label: 'Tokens', }, + { + key: '/token-tester', + icon: , + label: 'Token Tester', + }, { key: '/users', icon: , @@ -103,6 +111,8 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/kms-frontend/src/components/TokenTester.tsx b/kms-frontend/src/components/TokenTester.tsx new file mode 100644 index 0000000..d59ca28 --- /dev/null +++ b/kms-frontend/src/components/TokenTester.tsx @@ -0,0 +1,568 @@ +import React, { useState, useEffect } from 'react'; +import { + Card, + Button, + Form, + Input, + Select, + Space, + Typography, + Alert, + Divider, + Row, + Col, + Tag, + Checkbox, + message, + Modal, + Steps, +} from 'antd'; +import { + PlayCircleOutlined, + CheckCircleOutlined, + ExclamationCircleOutlined, + CopyOutlined, + ReloadOutlined, + LinkOutlined, +} from '@ant-design/icons'; +import { apiService, Application } from '../services/apiService'; + +const { Title, Text, Paragraph } = Typography; +const { Option } = Select; +const { TextArea } = Input; +const { Step } = Steps; + +interface LoginTestResult { + success: boolean; + token?: string; + redirectUrl?: string; + userId?: string; + appId?: string; + expiresIn?: number; + error?: string; + timestamp: string; +} + +interface CallbackTestResult { + success: boolean; + token?: string; + verified?: boolean; + permissions?: string[]; + error?: string; + timestamp: string; +} + +const availablePermissions = [ + 'app.read', + 'app.write', + 'app.delete', + 'token.read', + 'token.create', + 'token.revoke', + 'repo.read', + 'repo.write', + 'repo.admin', + 'permission.read', + 'permission.write', + 'permission.grant', + 'permission.revoke', +]; + +const TokenTester: React.FC = () => { + const [applications, setApplications] = useState([]); + const [loading, setLoading] = useState(false); + const [testLoading, setTestLoading] = useState(false); + const [callbackLoading, setCallbackLoading] = useState(false); + const [loginResult, setLoginResult] = useState(null); + const [callbackResult, setCallbackResult] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [callbackModalVisible, setCallbackModalVisible] = useState(false); + const [form] = Form.useForm(); + const [callbackForm] = Form.useForm(); + + useEffect(() => { + loadApplications(); + }, []); + + const loadApplications = async () => { + try { + setLoading(true); + const response = await apiService.getApplications(); + setApplications(response.data); + } catch (error) { + console.error('Failed to load applications:', error); + message.error('Failed to load applications'); + } finally { + setLoading(false); + } + }; + + const handleLoginTest = async (values: any) => { + try { + setTestLoading(true); + setCurrentStep(1); + + const selectedApp = applications.find(app => app.app_id === values.app_id); + const callbackUrl = `${window.location.origin}/token-tester/callback`; + + // Store test data in localStorage for the callback page + const testData = { + app_id: values.app_id, + permissions: values.permissions || [], + use_callback: values.use_callback, + timestamp: new Date().toISOString(), + }; + localStorage.setItem('token_tester_data', JSON.stringify(testData)); + + console.log('Testing login flow with:', { + app_id: values.app_id, + permissions: values.permissions || [], + redirect_uri: values.use_callback ? callbackUrl : undefined, + }); + + const response = await apiService.login( + values.app_id, + values.permissions || [], + values.use_callback ? callbackUrl : undefined + ); + + console.log('Login response:', response); + + const result: LoginTestResult = { + success: true, + token: response.token, + redirectUrl: response.redirect_url, + userId: response.user_id, + appId: values.app_id, + expiresIn: response.expires_in, + timestamp: new Date().toISOString(), + }; + + setLoginResult(result); + setCurrentStep(2); + + message.success('Login test completed successfully!'); + + // If we have a redirect URL, show the callback modal + if (response.redirect_url && values.use_callback) { + setCallbackModalVisible(true); + // Pre-fill the callback form with the token from the redirect URL + const urlParams = new URLSearchParams(response.redirect_url.split('?')[1]); + const token = urlParams.get('token'); + if (token) { + callbackForm.setFieldsValue({ + app_id: values.app_id, + token: token, + permissions: values.permissions || [], + }); + } + } + + } catch (error: any) { + console.error('Login test failed:', error); + + const result: LoginTestResult = { + success: false, + error: error.response?.data?.message || error.message || 'Login test failed', + timestamp: new Date().toISOString(), + }; + + setLoginResult(result); + setCurrentStep(2); + message.error('Login test failed'); + } finally { + setTestLoading(false); + } + }; + + const handleCallbackTest = async (values: any) => { + try { + setCallbackLoading(true); + setCurrentStep(3); + + console.log('Testing callback with token verification:', values); + + // Verify the token received in the callback + const verifyResponse = await apiService.verifyToken({ + app_id: values.app_id, + type: 'user', + token: values.token, + permissions: values.permissions || [], + }); + + console.log('Token verification response:', verifyResponse); + + const result: CallbackTestResult = { + success: verifyResponse.valid, + token: values.token, + verified: verifyResponse.valid, + permissions: verifyResponse.permissions, + error: verifyResponse.error, + timestamp: new Date().toISOString(), + }; + + setCallbackResult(result); + setCurrentStep(4); + + if (verifyResponse.valid) { + message.success('Callback test completed successfully!'); + } else { + message.warning('Callback test completed - token verification failed'); + } + + } catch (error: any) { + console.error('Callback test failed:', error); + + const result: CallbackTestResult = { + success: false, + error: error.response?.data?.message || error.message || 'Callback test failed', + timestamp: new Date().toISOString(), + }; + + setCallbackResult(result); + setCurrentStep(4); + message.error('Callback test failed'); + } finally { + setCallbackLoading(false); + } + }; + + const resetTest = () => { + setCurrentStep(0); + setLoginResult(null); + setCallbackResult(null); + setCallbackModalVisible(false); + form.resetFields(); + callbackForm.resetFields(); + // Clear stored test data + localStorage.removeItem('token_tester_data'); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + message.success('Copied to clipboard'); + }; + + const openCallbackUrl = () => { + if (loginResult?.redirectUrl) { + window.open(loginResult.redirectUrl, '_blank'); + } + }; + + return ( + + + + + Token Tester + + Test the /login flow and callback handling for user tokens + + + } + onClick={resetTest} + disabled={testLoading || callbackLoading} + > + Reset Test + + + + {/* Test Progress */} + + + + + + + + + + + {/* Test Configuration */} + + + + + + + {applications.map(app => ( + + + {app.app_id} + + + {app.app_link} + + + + ))} + + + + + + Use callback URL (test full flow) + + + + + + + + {availablePermissions.map(permission => ( + + {permission} + + ))} + + + + + + } + loading={testLoading} + size="large" + > + Start Login Test + + + + + + {/* Login Test Results */} + {loginResult && ( + + {loginResult.success ? ( + + ) : ( + + )} + Login Test Results + + } + > + + + + {loginResult.success && ( + + + {loginResult.token && ( + + + + + } + onClick={() => copyToClipboard(loginResult.token!)} + > + Copy Token + + + + + )} + + {loginResult.redirectUrl && ( + + + + + {loginResult.redirectUrl} + + + } + onClick={() => copyToClipboard(loginResult.redirectUrl!)} + > + Copy URL + + } + onClick={openCallbackUrl} + > + Open URL + + + + + + )} + + + + + + + User ID: + {loginResult.userId || 'N/A'} + + + App ID: + {loginResult.appId} + + + Expires In: + {loginResult.expiresIn ? `${loginResult.expiresIn}s` : 'N/A'} + + + Timestamp: + {new Date(loginResult.timestamp).toLocaleString()} + + + + )} + + + )} + + {/* Callback Test Results */} + {callbackResult && ( + + {callbackResult.success ? ( + + ) : ( + + )} + Callback Test Results + + } + > + + + + {callbackResult.success && callbackResult.permissions && ( + + Verified Permissions: + + {callbackResult.permissions.map(permission => ( + + {permission} + + ))} + + + )} + + + Timestamp: + {new Date(callbackResult.timestamp).toLocaleString()} + + + + )} + + + {/* Callback Test Modal */} + setCallbackModalVisible(false)} + onOk={() => callbackForm.submit()} + confirmLoading={callbackLoading} + width={700} + > + + + + + + + + + + + + + + + + {availablePermissions.map(permission => ( + + {permission} + + ))} + + + + + + + + ); +}; + +export default TokenTester; diff --git a/kms-frontend/src/components/TokenTesterCallback.tsx b/kms-frontend/src/components/TokenTesterCallback.tsx new file mode 100644 index 0000000..2ebfd40 --- /dev/null +++ b/kms-frontend/src/components/TokenTesterCallback.tsx @@ -0,0 +1,379 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { + Card, + Button, + Space, + Typography, + Alert, + Row, + Col, + Tag, + Spin, + Result, + Input, +} from 'antd'; +import { + CheckCircleOutlined, + ExclamationCircleOutlined, + CopyOutlined, + ArrowLeftOutlined, +} from '@ant-design/icons'; +import { apiService } from '../services/apiService'; + +const { Title, Text } = Typography; +const { TextArea } = Input; + +interface CallbackData { + token?: string; + state?: string; + error?: string; + error_description?: string; +} + +interface VerificationResult { + valid: boolean; + permitted: boolean; + user_id?: string; + permissions: string[]; + permission_results?: Record; + expires_at?: string; + max_valid_at?: string; + token_type: string; + claims?: Record; + error?: string; +} + +const TokenTesterCallback: React.FC = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [callbackData, setCallbackData] = useState({}); + const [verificationResult, setVerificationResult] = useState(null); + const [verificationError, setVerificationError] = useState(null); + + useEffect(() => { + parseCallbackData(); + }, [location]); + + const parseCallbackData = async () => { + try { + setLoading(true); + + // Parse URL parameters + const urlParams = new URLSearchParams(location.search); + const data: CallbackData = { + token: urlParams.get('token') || undefined, + state: urlParams.get('state') || undefined, + error: urlParams.get('error') || undefined, + error_description: urlParams.get('error_description') || undefined, + }; + + setCallbackData(data); + + // If we have a token, try to verify it + if (data.token && !data.error) { + await verifyToken(data.token); + } + + } catch (error) { + console.error('Error parsing callback data:', error); + setVerificationError('Failed to parse callback data'); + } finally { + setLoading(false); + } + }; + + const verifyToken = async (token: string) => { + try { + // We need to extract app_id from the state or make a best guess + // For now, we'll try to verify without specifying app_id + // In a real implementation, the app_id should be included in the state parameter + + // Try to get app_id from localStorage if it was stored during the test + const testData = localStorage.getItem('token_tester_data'); + let appId = ''; + + if (testData) { + try { + const parsed = JSON.parse(testData); + appId = parsed.app_id || ''; + } catch (e) { + console.warn('Could not parse stored test data'); + } + } + + if (!appId) { + // If we don't have app_id, we can't verify the token properly + setVerificationError('Cannot verify token: Application ID not found in callback state'); + return; + } + + const verifyRequest = { + app_id: appId, + type: 'user', + token: token, + permissions: [], // We'll verify without specific permissions + }; + + const result = await apiService.verifyToken(verifyRequest); + setVerificationResult(result); + + } catch (error: any) { + console.error('Token verification failed:', error); + setVerificationError( + error.response?.data?.message || + error.message || + 'Token verification failed' + ); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const goBackToTester = () => { + navigate('/token-tester'); + }; + + if (loading) { + return ( + + + Processing callback... + + ); + } + + return ( + + + + + Token Tester - Callback + + Callback page for testing the login flow + + + } + onClick={goBackToTester} + > + Back to Tester + + + + {/* Callback Status */} + + {callbackData.error ? ( + + ) : callbackData.token ? ( + + ) : ( + + )} + + + {/* Token Information */} + {callbackData.token && ( + + + + Token: + + } + onClick={() => copyToClipboard(callbackData.token!)} + style={{ marginTop: '8px' }} + > + Copy Token + + + + {callbackData.state && ( + + State: + + {callbackData.state} + + + )} + + + )} + + {/* Token Verification Results */} + {verificationResult && ( + + {verificationResult.valid ? ( + + ) : ( + + )} + Token Verification Results + + } + > + + + + {verificationResult.valid && ( + + + + + + + Token Type: + + {verificationResult.token_type.toUpperCase()} + + + + {verificationResult.user_id && ( + + User ID: + {verificationResult.user_id} + + )} + + {verificationResult.expires_at && ( + + Expires At: + {new Date(verificationResult.expires_at).toLocaleString()} + + )} + + {verificationResult.max_valid_at && ( + + Max Valid Until: + {new Date(verificationResult.max_valid_at).toLocaleString()} + + )} + + + + + + + + {verificationResult.permissions && verificationResult.permissions.length > 0 ? ( + + Available Permissions: + + {verificationResult.permissions.map(permission => ( + + {permission} + + ))} + + + ) : ( + No permissions available + )} + + {verificationResult.permission_results && Object.keys(verificationResult.permission_results).length > 0 && ( + + Permission Check Results: + + {Object.entries(verificationResult.permission_results).map(([permission, granted]) => ( + + + {permission}: {granted ? 'GRANTED' : 'DENIED'} + + + ))} + + + )} + + + + + + {verificationResult.claims && Object.keys(verificationResult.claims).length > 0 && ( + + + {Object.entries(verificationResult.claims).map(([key, value]) => ( + + {key}: + {value} + + ))} + + + )} + + )} + + + )} + + {/* Verification Error */} + {verificationError && ( + + + + )} + + {/* No Token or Error */} + {!callbackData.token && !callbackData.error && ( + + Go Back to Token Tester + + } + /> + )} + + + ); +}; + +export default TokenTesterCallback; diff --git a/server b/server index e91a7ec..2702590 100755 Binary files a/server and b/server differ