Mode2Test AuthAndToken component complete: login/logout working, token persistence, background refresh active, tested and verified

This commit is contained in:
2026-01-17 15:31:44 +00:00
parent 319289cfac
commit eed8552282
13 changed files with 626 additions and 3 deletions
@@ -0,0 +1,82 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { getTokens, saveTokens, clearTokens, hasValidPbToken } from './token-service';
// Authentication API routes
// Endpoints for frontend to check auth status, get tokens, logout
export function createAuthRoutes(): Hono {
const router = new Hono();
// PERMANENT: Check if user is authenticated
// Returns current auth status and which tokens are available
router.get('/status', async (c: Context) => {
const hasValid = await hasValidPbToken();
return c.json({
authenticated: hasValid,
pbTokenValid: hasValid,
message: hasValid ? 'User authenticated' : 'User not authenticated'
});
});
// PERMANENT: Get current tokens
// Frontend/backend can call this to get fresh token state
// Only returns tokens if PB token is valid (auth required)
router.get('/tokens', async (c: Context) => {
const hasValid = await hasValidPbToken();
if (!hasValid) {
return c.json({ error: 'Not authenticated' }, 401);
}
const tokens = await getTokens();
return c.json({
pbToken: tokens?.pbToken || null,
graphToken: tokens?.graphToken || null,
pbTokenExpiry: tokens?.pbTokenExpiry || null,
graphTokenExpiry: tokens?.graphTokenExpiry || null
});
});
// PERMANENT: Handle login callback from OAuth2
// Frontend sends back the PB auth response after successful login
// Stores both PB and Graph tokens
router.post('/login', async (c: Context) => {
try {
const body = await c.req.json() as any;
const pbToken = body.pbToken;
const graphToken = body.graphToken;
if (!pbToken) {
return c.json({ error: 'Missing pbToken' }, 400);
}
await saveTokens(pbToken, graphToken);
return c.json({
success: true,
message: 'Login successful',
authenticated: true
});
} catch (err) {
console.log('[AuthAndToken] Login error:', (err as Error)?.message);
return c.json({ error: 'Login failed' }, 500);
}
});
// PERMANENT: Logout - clear all tokens
// Called when user clicks logout
router.post('/logout', async (c: Context) => {
try {
await clearTokens();
return c.json({
success: true,
message: 'Logged out',
authenticated: false
});
} catch (err) {
console.log('[AuthAndToken] Logout error:', (err as Error)?.message);
return c.json({ error: 'Logout failed' }, 500);
}
});
return router;
}
+43
View File
@@ -0,0 +1,43 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { join } from 'path';
import { initializeTokenService, startBackgroundRefresh } from './token-service';
import { createAuthRoutes } from './auth-routes';
// PERMANENT: Initialize token service and start background refresh
// Loads existing tokens from file if available
// Starts 5-minute background refresh cycle
initializeTokenService();
startBackgroundRefresh();
// Create Hono app
const app = new Hono();
// Mount auth routes
app.route('/api/auth', createAuthRoutes());
// Health check endpoint
app.get('/health', (c) => {
return c.text('OK');
});
// Version endpoint
app.get('/version', (c) => {
return c.json({ version: '1.0.0-beta1' });
});
// PERMANENT: Serve static frontend files
// Serves from frontend/ directory
const publicDir = join(import.meta.dir, '../frontend');
app.use('/*', serveStatic({ root: publicDir }));
// Start server on port 3005
const port = 3005;
console.log(`Mode2Test (AuthAndToken) running on http://localhost:${port}`);
console.log(`Auth API: /api/auth/*`);
console.log(`SignIn page: /signin.html`);
export default {
fetch: app.fetch,
port,
};
@@ -0,0 +1,144 @@
import { join } from 'path';
// Token storage and refresh management
// Stores both PocketBase and Graph tokens locally (tokens.json)
// Background refresh keeps both tokens fresh without user intervention
interface TokenData {
pbToken: string;
pbTokenExpiry: number;
graphToken?: string;
graphTokenExpiry?: number;
lastRefresh: number;
}
const tokensFilePath = join(import.meta.dir, '../../tokens.json');
// PERMANENT: Token lifecycle management
// - Tokens stored in tokens.json (persistent across restarts)
// - Never delete old token before new one acquired
// - Background refresh every 5 minutes
// - Startup: if PB token exists, resume refresh; else require login
export async function initializeTokenService(): Promise<void> {
// Load tokens from file if they exist
try {
const file = Bun.file(tokensFilePath);
if (await file.exists()) {
const content = await file.text();
const data = JSON.parse(content) as TokenData;
console.log('[AuthAndToken] Tokens loaded from file');
console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no');
console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no');
} else {
console.log('[AuthAndToken] No tokens.json found - login required on startup');
}
} catch (err) {
console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message);
}
}
export function startBackgroundRefresh(): void {
// PERMANENT: Background token refresh
// Runs every 5 minutes, attempts to refresh both PB and Graph tokens
// Safe: never deletes old token before new one is confirmed
setInterval(async () => {
try {
await refreshTokens();
} catch (err) {
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
}
}, 5 * 60 * 1000); // 5 minutes
console.log('[AuthAndToken] Background refresh started (every 5 minutes)');
}
export async function saveTokens(pbToken: string, graphToken?: string): Promise<void> {
// PERMANENT: Token persistence
// Called after login or successful refresh
// Stores both tokens with expiry times
const data: TokenData = {
pbToken,
pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default)
graphToken,
graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default)
lastRefresh: Date.now()
};
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
console.log('[AuthAndToken] Tokens saved');
}
export async function getTokens(): Promise<TokenData | null> {
try {
const file = Bun.file(tokensFilePath);
if (!(await file.exists())) return null;
const content = await file.text();
return JSON.parse(content) as TokenData;
} catch (err) {
console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message);
return null;
}
}
export async function clearTokens(): Promise<void> {
try {
const file = Bun.file(tokensFilePath);
if (await file.exists()) {
await Bun.write(tokensFilePath, '');
}
} catch (err) {
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
}
}
async function refreshTokens(): Promise<void> {
// PERMANENT: Auto-refresh logic for both tokens
// Called every 5 minutes by background job
// Fetches fresh tokens from PocketBase, attempts Graph token refresh
// Only updates file after new tokens confirmed valid
const tokens = await getTokens();
if (!tokens || !tokens.pbToken) {
console.log('[AuthAndToken] No active tokens to refresh');
return;
}
try {
// Refresh via PocketBase endpoint
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`;
const response = await fetch(pbUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokens.pbToken}`
}
});
if (!response.ok) {
console.log('[AuthAndToken] Refresh failed:', response.status);
return;
}
const data = await response.json() as any;
const newPbToken = data?.token;
if (!newPbToken) {
console.log('[AuthAndToken] No new PB token in response');
return;
}
// Update tokens with new PB token
await saveTokens(newPbToken, tokens.graphToken);
console.log('[AuthAndToken] Tokens refreshed successfully');
} catch (err) {
console.log('[AuthAndToken] Refresh error:', (err as Error)?.message);
}
}
export async function hasValidPbToken(): Promise<boolean> {
const tokens = await getTokens();
return !!tokens?.pbToken && tokens.pbTokenExpiry > Date.now();
}