Mode2Test AuthAndToken component complete: login/logout working, token persistence, background refresh active, tested and verified
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user