This commit is contained in:
2025-08-23 23:15:30 -04:00
parent e5bccc85c2
commit 02323a8b5c
7 changed files with 305 additions and 77 deletions

View File

@ -49,39 +49,46 @@ func (hv *HeaderValidator) ValidateAuthenticationHeaders(r *http.Request) (*Vali
return nil, errors.NewAuthenticationError("User authentication required")
}
if timestamp == "" || signature == "" {
hv.logger.Warn("Missing authentication signature headers",
// In development mode, skip signature validation for trusted headers
if hv.config.IsDevelopment() {
hv.logger.Debug("Development mode: skipping signature validation",
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Authentication signature required")
}
} else {
// Production mode: require full signature validation
if timestamp == "" || signature == "" {
hv.logger.Warn("Missing authentication signature headers",
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Authentication signature required")
}
// Validate timestamp (prevent replay attacks)
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
hv.logger.Warn("Invalid timestamp format",
zap.String("timestamp", timestamp),
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Invalid timestamp format")
}
// Validate timestamp (prevent replay attacks)
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
hv.logger.Warn("Invalid timestamp format",
zap.String("timestamp", timestamp),
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Invalid timestamp format")
}
timestampTime := time.Unix(timestampInt, 0)
now := time.Now()
// Allow 5 minutes clock skew
maxAge := 5 * time.Minute
if now.Sub(timestampTime) > maxAge || timestampTime.After(now.Add(1*time.Minute)) {
hv.logger.Warn("Timestamp outside acceptable window",
zap.Time("timestamp", timestampTime),
zap.Time("now", now),
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Request timestamp outside acceptable window")
}
timestampTime := time.Unix(timestampInt, 0)
now := time.Now()
// Allow 5 minutes clock skew
maxAge := 5 * time.Minute
if now.Sub(timestampTime) > maxAge || timestampTime.After(now.Add(1*time.Minute)) {
hv.logger.Warn("Timestamp outside acceptable window",
zap.Time("timestamp", timestampTime),
zap.Time("now", now),
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Request timestamp outside acceptable window")
}
// Validate HMAC signature
if !hv.validateSignature(userEmail, timestamp, signature) {
hv.logger.Warn("Invalid authentication signature",
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Invalid authentication signature")
// Validate HMAC signature
if !hv.validateSignature(userEmail, timestamp, signature) {
hv.logger.Warn("Invalid authentication signature",
zap.String("user_email", userEmail))
return nil, errors.NewAuthenticationError("Invalid authentication signature")
}
}
// Validate email format
@ -94,11 +101,24 @@ func (hv *HeaderValidator) ValidateAuthenticationHeaders(r *http.Request) (*Vali
hv.logger.Debug("Authentication headers validated successfully",
zap.String("user_email", userEmail))
// Set defaults for development mode
var timestampTime time.Time
var signatureValue string
if hv.config.IsDevelopment() {
timestampTime = time.Now()
signatureValue = "dev-mode-bypass"
} else {
timestampInt, _ := strconv.ParseInt(timestamp, 10, 64)
timestampTime = time.Unix(timestampInt, 0)
signatureValue = signature
}
return &ValidatedUserContext{
UserID: userEmail,
Email: userEmail,
Timestamp: timestampTime,
Signature: signature,
Signature: signatureValue,
}, nil
}

View File

@ -124,11 +124,20 @@ type VerifyResponse struct {
Error string `json:"error,omitempty"`
}
// TokenDeliveryMode specifies how tokens should be delivered in redirect flows
type TokenDeliveryMode string
const (
TokenDeliveryCookie TokenDeliveryMode = "cookie" // Token in secure cookie (default)
TokenDeliveryQuery TokenDeliveryMode = "query" // Token in query parameter (for integrations)
)
// LoginRequest represents a user login request
type LoginRequest struct {
AppID string `json:"app_id" validate:"required"`
Permissions []string `json:"permissions,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
AppID string `json:"app_id" validate:"required"`
Permissions []string `json:"permissions,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
TokenDelivery TokenDeliveryMode `json:"token_delivery,omitempty"` // How to deliver token in redirect flows
}
// LoginResponse represents a user login response

View File

@ -81,25 +81,53 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// For redirect flows, use secure cookie-based token delivery
// Set secure cookie with the token
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(
"auth_token", // name
token, // value
604800, // maxAge (7 days)
"/", // path
"", // domain (empty for current domain)
true, // secure (HTTPS only)
true, // httpOnly (no JavaScript access)
)
// For redirect flows, choose token delivery method
// Default to cookie delivery for security
tokenDelivery := req.TokenDelivery
if tokenDelivery == "" {
tokenDelivery = domain.TokenDeliveryCookie
}
h.logger.Debug("Token delivery mode", zap.String("mode", string(tokenDelivery)))
// Generate a secure state parameter for CSRF protection
state := h.generateSecureState(userContext.UserID, req.AppID)
// Redirect without token in URL
var redirectURL string
switch tokenDelivery {
case domain.TokenDeliveryQuery:
// Deliver token via query parameter (for integrations like VS Code)
redirectURL = req.RedirectURI + "?token=" + token + "&state=" + state
case domain.TokenDeliveryCookie:
// Deliver token via secure cookie (default, more secure)
c.SetSameSite(http.SameSiteStrictMode)
// In development mode, make cookie accessible to JavaScript for testing
// In production, keep HTTP-only for security
httpOnly := !h.config.IsDevelopment()
secure := !h.config.IsDevelopment() // Only require HTTPS in production
c.SetCookie(
"auth_token", // name
token, // value
604800, // maxAge (7 days)
"/", // path
"", // domain (empty for current domain)
secure, // secure (HTTPS only in production)
httpOnly, // httpOnly (no JavaScript access in production)
)
// Redirect without token in URL for security
redirectURL = req.RedirectURI + "?state=" + state
default:
// Invalid delivery mode, default to cookie
redirectURL = req.RedirectURI + "?state=" + state
}
response := domain.LoginResponse{
RedirectURL: req.RedirectURI + "?state=" + state,
RedirectURL: redirectURL,
}
c.JSON(http.StatusOK, response)

View File

@ -47,7 +47,13 @@ interface CallbackTestResult {
success: boolean;
token?: string;
verified?: boolean;
permitted?: boolean;
user_id?: string;
permissions?: string[];
permission_results?: Record<string, boolean>;
expires_at?: string;
max_valid_at?: string;
token_type?: string;
error?: string;
timestamp: string;
}
@ -79,6 +85,8 @@ const TokenTester: React.FC = () => {
const [callbackModalVisible, setCallbackModalVisible] = useState(false);
const [form] = Form.useForm();
const [callbackForm] = Form.useForm();
const [useCallback, setUseCallback] = useState(false);
const [extractedToken, setExtractedToken] = useState('');
useEffect(() => {
loadApplications();
@ -123,7 +131,8 @@ const TokenTester: React.FC = () => {
const response = await apiService.login(
values.app_id,
values.permissions || [],
values.use_callback ? callbackUrl : undefined
values.use_callback ? callbackUrl : undefined,
values.token_delivery || 'query'
);
console.log('Login response:', response);
@ -146,16 +155,25 @@ const TokenTester: React.FC = () => {
// 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 || [],
});
// Extract token from redirect URL if using query parameter delivery
let tokenFromUrl = '';
if (values.token_delivery === 'query') {
try {
const url = new URL(response.redirect_url);
tokenFromUrl = url.searchParams.get('token') || '';
} catch (e) {
console.warn('Failed to parse redirect URL for token extraction:', e);
}
}
setExtractedToken(tokenFromUrl);
callbackForm.setFieldsValue({
app_id: values.app_id,
token: tokenFromUrl, // Pre-fill with extracted token if available
permissions: values.permissions || [],
});
}
} catch (error: any) {
@ -195,7 +213,13 @@ const TokenTester: React.FC = () => {
success: verifyResponse.valid,
token: values.token,
verified: verifyResponse.valid,
permitted: verifyResponse.permitted,
user_id: verifyResponse.user_id,
permissions: verifyResponse.permissions,
permission_results: verifyResponse.permission_results,
expires_at: verifyResponse.expires_at,
max_valid_at: verifyResponse.max_valid_at,
token_type: verifyResponse.token_type,
error: verifyResponse.error,
timestamp: new Date().toISOString(),
};
@ -205,8 +229,16 @@ const TokenTester: React.FC = () => {
if (verifyResponse.valid) {
message.success('Callback test completed successfully!');
// Auto-close modal after successful verification to show results
setTimeout(() => {
setCallbackModalVisible(false);
}, 1500);
} else {
message.warning('Callback test completed - token verification failed');
// Auto-close modal after failed verification to show results
setTimeout(() => {
setCallbackModalVisible(false);
}, 1500);
}
} catch (error: any) {
@ -221,6 +253,11 @@ const TokenTester: React.FC = () => {
setCallbackResult(result);
setCurrentStep(4);
message.error('Callback test failed');
// Auto-close modal to show error results
setTimeout(() => {
setCallbackModalVisible(false);
}, 1500);
} finally {
setCallbackLoading(false);
}
@ -231,6 +268,8 @@ const TokenTester: React.FC = () => {
setLoginResult(null);
setCallbackResult(null);
setCallbackModalVisible(false);
setUseCallback(false);
setExtractedToken('');
form.resetFields();
callbackForm.resetFields();
// Clear stored test data
@ -257,6 +296,20 @@ const TokenTester: React.FC = () => {
<Text type="secondary">
Test the /login flow and callback handling for user tokens
</Text>
<div style={{ marginTop: '8px' }}>
<Alert
message="Two Testing Modes Available"
description={
<div>
<Text strong>Direct Mode:</Text> Login returns token directly in response body (no callback)<br/>
<Text strong>Callback Mode:</Text> Login returns redirect URL, token in query parameter (default) or secure cookie
</div>
}
type="info"
showIcon
style={{ fontSize: '12px' }}
/>
</div>
</div>
<Button
icon={<ReloadOutlined />}
@ -316,11 +369,44 @@ const TokenTester: React.FC = () => {
valuePropName="checked"
label=" "
>
<Checkbox>Use callback URL (test full flow)</Checkbox>
<Checkbox onChange={(e) => setUseCallback(e.target.checked)}>
Use callback URL (test full flow)
</Checkbox>
</Form.Item>
</Col>
</Row>
<Form.Item
name="token_delivery"
label="Token Delivery Method (for callback flows)"
tooltip="Choose how tokens are delivered when using callback URLs"
initialValue="query"
>
<Select placeholder="Select delivery method" disabled={!useCallback} defaultValue="query">
<Option value="query">
<div>
<Text strong>Query Parameter</Text> (Recommended for testing)
<br />
<Text type="secondary" style={{ fontSize: '12px' }}>
Token included in callback URL query string
</Text>
</div>
</Option>
<Option value="cookie">
<div>
<Text strong>Cookie</Text> (More secure for production)
<br />
<Text type="secondary" style={{ fontSize: '12px' }}>
Token stored in HTTP-only cookie
</Text>
</div>
</Option>
</Select>
</Form.Item>
<Row gutter={16}>
</Row>
<Form.Item
name="permissions"
label="Permissions to Request"
@ -480,20 +566,83 @@ const TokenTester: React.FC = () => {
showIcon
/>
{callbackResult.success && callbackResult.permissions && (
{callbackResult.success && (
<div>
<Text strong>Verified Permissions:</Text>
<div style={{ marginTop: '8px' }}>
{callbackResult.permissions.map(permission => (
<Tag key={permission} color="green" style={{ margin: '2px' }}>
{permission}
</Tag>
))}
</div>
<Row gutter={16}>
<Col span={12}>
<Card size="small" title="Token Information">
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text strong>Token Type:</Text>
<div>
<Tag color="blue">{(callbackResult.token_type || 'user').toUpperCase()}</Tag>
</div>
</div>
{callbackResult.user_id && (
<div>
<Text strong>User ID:</Text>
<div>{callbackResult.user_id}</div>
</div>
)}
{callbackResult.expires_at && (
<div>
<Text strong>Expires At:</Text>
<div>{new Date(callbackResult.expires_at).toLocaleString()}</div>
</div>
)}
{callbackResult.max_valid_at && (
<div>
<Text strong>Max Valid Until:</Text>
<div>{new Date(callbackResult.max_valid_at).toLocaleString()}</div>
</div>
)}
</Space>
</Card>
</Col>
<Col span={12}>
<Card size="small" title="Permissions">
<Space direction="vertical" style={{ width: '100%' }}>
{callbackResult.permissions && callbackResult.permissions.length > 0 ? (
<div>
<Text strong>Available Permissions:</Text>
<div style={{ marginTop: '8px' }}>
{callbackResult.permissions.map(permission => (
<Tag key={permission} color="green" style={{ margin: '2px' }}>
{permission}
</Tag>
))}
</div>
</div>
) : (
<Text type="secondary">No permissions available</Text>
)}
{callbackResult.permission_results && Object.keys(callbackResult.permission_results).length > 0 && (
<div style={{ marginTop: '16px' }}>
<Text strong>Permission Check Results:</Text>
<div style={{ marginTop: '8px' }}>
{Object.entries(callbackResult.permission_results).map(([permission, granted]) => (
<div key={permission} style={{ marginBottom: '4px' }}>
<Tag color={granted ? 'green' : 'red'}>
{permission}: {granted ? 'GRANTED' : 'DENIED'}
</Tag>
</div>
))}
</div>
</div>
)}
</Space>
</Card>
</Col>
</Row>
</div>
)}
<div>
<div style={{ marginTop: '16px' }}>
<Text strong>Timestamp:</Text>
<div>{new Date(callbackResult.timestamp).toLocaleString()}</div>
</div>
@ -514,7 +663,10 @@ const TokenTester: React.FC = () => {
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Alert
message="Callback URL Received"
description="The login flow returned a redirect URL with a token. Test the callback handling by verifying the token."
description={extractedToken
? "Token successfully extracted from callback URL. Verify the token to complete the flow test."
: "Redirect URL received. If using cookie delivery, the token is stored in a secure cookie."
}
type="info"
showIcon
/>

View File

@ -62,8 +62,15 @@ const TokenTesterCallback: React.FC = () => {
// Parse URL parameters
const urlParams = new URLSearchParams(location.search);
let token = urlParams.get('token') || undefined;
// If no token in URL, try to extract from auth_token cookie
if (!token) {
token = getCookie('auth_token') || undefined;
}
const data: CallbackData = {
token: urlParams.get('token') || undefined,
token: token,
state: urlParams.get('state') || undefined,
error: urlParams.get('error') || undefined,
error_description: urlParams.get('error_description') || undefined,
@ -84,6 +91,17 @@ const TokenTesterCallback: React.FC = () => {
}
};
// Utility function to get cookie value by name
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
const cookieValue = parts.pop()?.split(';').shift();
return cookieValue || null;
}
return null;
};
const verifyToken = async (token: string) => {
try {
// We need to extract app_id from the state or make a best guess

View File

@ -186,11 +186,12 @@ class ApiService {
}
// Authentication
async login(appId: string, permissions: string[], redirectUri?: string): Promise<any> {
async login(appId: string, permissions: string[], redirectUri?: string, tokenDelivery?: string): Promise<any> {
const response = await this.api.post('/api/login', {
app_id: appId,
permissions,
redirect_uri: redirectUri,
token_delivery: tokenDelivery,
});
return response.data;
}

View File

@ -14,7 +14,7 @@ server {
# Apply rate limiting
limit_req zone=api burst=20 nodelay;
# Add test user header for HeaderAuthenticationProvider
# Development mode: only user email header required
proxy_set_header X-User-Email "test@example.com";
# Standard proxy headers
@ -38,7 +38,7 @@ server {
# Apply stricter rate limiting for auth endpoints
limit_req zone=login burst=5 nodelay;
# Add test user header for HeaderAuthenticationProvider
# Development mode: only user email header required
proxy_set_header X-User-Email "test@example.com";
# Standard proxy headers
@ -47,7 +47,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Proxy to API service (cannot have URI part in regex location)
# Proxy to API service
proxy_pass http://api-service:8080;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
@ -139,7 +139,7 @@ server {
location /api/ {
limit_req zone=api burst=50 nodelay;
# Admin test user
# Development mode: admin test user
proxy_set_header X-User-Email "admin@example.com";
proxy_set_header Host $host;
@ -159,7 +159,7 @@ server {
location /api/ {
limit_req zone=api burst=10 nodelay;
# Limited test user
# Development mode: limited test user
proxy_set_header X-User-Email "limited@example.com";
proxy_set_header Host $host;