Add Mode2Svelte - advanced SvelteKit implementation

- Created Mode2Svelte as a copy of Mode1Svelte
- Updated branding (title, description) to distinguish from Mode1Svelte
- Added Mode2Svelte support to orchestrator
- Configured orchestrator to spawn Mode2Svelte with Node.js adapter
- Set Mode2Svelte as active mode in activeMode.txt
- Built production bundle for Mode2Svelte
- Both Mode1Svelte and Mode2Svelte now managed by orchestrator on port 3005
- Orchestrator controls which mode runs via activeMode.txt configuration
This commit is contained in:
2026-01-23 03:00:47 +00:00
parent 7b997dc354
commit 7d8fd9f251
28 changed files with 4322 additions and 4 deletions
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+313
View File
@@ -0,0 +1,313 @@
# Mode2Svelte - Advanced SvelteKit Auth Testing Suite
An enhanced SvelteKit-based testing environment for advanced authentication workflows with Microsoft Azure MSAL and PocketBase. This is an extended implementation of Mode1Svelte with additional testing capabilities.
## Tech Stack
- **Framework**: SvelteKit 2.x with TypeScript
- **Backend**: Node.js Adapter (production-ready)
- **Styling**: Tailwind CSS 4
- **Authentication**:
- Azure MSAL Node (@azure/msal-node) - Microsoft Graph token management
- PocketBase - User OAuth2 and token validation
- **Build Tool**: Vite (integrated with SvelteKit)
- **Runtime**: Node.js 18+
## Features
### ✅ Frontend Authentication (PocketBase)
- OAuth2 login initialization with Microsoft
- Auth state management and retrieval
- Token refresh and validation
- Session persistence
- Automatic logout on invalid tokens
### ✅ Backend Token Management (Microsoft Graph)
- App-only token acquisition via MSAL
- Automatic token caching with 60-second expiration buffer
- Token refresh on demand
- JWT payload inspection
### ✅ Token Validation & Comparison
- PocketBase user token validation
- User record retrieval from tokens
- Side-by-side token comparison (Graph vs PocketBase)
- Token payload analysis
### ✅ Interactive Testing Dashboard
- Real-time authentication status
- Visual token testing interface
- API endpoint testing
- Response inspection
## Project Structure
```
Mode1Svelte/
├── src/
│ ├── lib/
│ │ └── auth/
│ │ ├── types.ts # TypeScript interfaces
│ │ ├── backend.ts # Backend auth classes (MSAL, PocketBase)
│ │ └── frontend.ts # Frontend PocketBase auth class
│ ├── routes/
│ │ ├── +layout.svelte # App layout
│ │ ├── +page.svelte # Main dashboard
│ │ └── api/
│ │ └── auth/
│ │ ├── config/ # GET /api/auth/config
│ │ ├── graph/ # GET /api/auth/graph/token
│ │ ├── validate-pb-token/ # POST /api/auth/validate-pb-token
│ │ ├── refresh-graph/ # POST /api/auth/refresh-graph
│ │ └── compare-tokens/ # POST /api/auth/compare-tokens
│ └── app.css # Tailwind directives
├── static/ # Static assets
├── package.json # Dependencies
├── svelte.config.js # SvelteKit config (Node adapter)
├── tailwind.config.js # Tailwind CSS config
├── vite.config.ts # Vite config
└── tsconfig.json # TypeScript config
```
## Installation
```bash
cd Mode1Svelte
npm install
```
## Environment Setup
Ensure your `.env` file or environment variables are set:
```bash
export CLIENT_ID="<Azure Client ID>"
export TENANT_ID="<Azure Tenant ID>"
export CLIENT_SECRET="<Azure Client Secret>"
export PB_URL="<PocketBase URL>"
export PB_DB="<PocketBase Database URL>"
export PORT=5173
```
For development, create `/home/admin/secrets/.env`:
```
CLIENT_ID=your_client_id
TENANT_ID=your_tenant_id
CLIENT_SECRET=your_client_secret
PB_URL=https://pocketbase.example.com
PB_DB=https://pocketbase.example.com
```
## Running the Application
### Development Mode
```bash
npm run dev
```
Starts the dev server at `http://localhost:5173` with hot reloading.
### Production Build
```bash
npm run build
npm start
```
Builds the application and runs the Node.js server.
### Type Checking
```bash
npm run check
```
Run TypeScript type checking across the project.
## API Endpoints
### GET `/api/auth/config`
Returns frontend-safe configuration for PocketBase OAuth setup.
**Response:**
```json
{
"pbUrl": "https://pocketbase.example.com",
"provider": "microsoft",
"collection": "Users"
}
```
### GET `/api/auth/graph/token`
Fetches a Microsoft Graph token for backend API access.
**Response:**
```json
{
"success": true,
"token": "eyJ0eXAiOiJKV1Q...",
"fullToken": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"expiresOn": "2026-01-23T02:15:00.000Z",
"isValid": true,
"tokenDetails": {
"aud": "https://graph.microsoft.com",
"iss": "https://sts.windows.net/tenant-id",
"scp": "..."
}
}
```
### POST `/api/auth/validate-pb-token`
Validates a PocketBase user token and retrieves user information.
**Request:**
```json
{
"pbToken": "user_token_string"
}
```
**Response:**
```json
{
"valid": true,
"user": {
"id": "user_id",
"email": "user@example.com",
"name": "User Name"
}
}
```
### POST `/api/auth/refresh-graph`
Clears the cached Graph token and acquires a new one.
**Response:**
```json
{
"success": true,
"refreshed": true,
"expiresOn": "2026-01-23T02:15:00.000Z"
}
```
### POST `/api/auth/compare-tokens`
Compares Graph token (backend) and PocketBase token (user) to demonstrate their differences.
**Request:**
```json
{
"pbToken": "user_token_string"
}
```
**Response:**
```json
{
"success": true,
"comparison": {
"graph_token": {
"issuer": "https://sts.windows.net/tenant",
"audience": "https://graph.microsoft.com",
"app": "App Name",
"scopes": "...",
"issued_at": "2026-01-23T01:15:00.000Z",
"expires_at": "2026-01-23T02:15:00.000Z",
"type": "Microsoft Graph Token (Backend/App-only)",
"source": "@azure/msal-node",
"purpose": "Backend API calls to Microsoft Graph"
},
"pocketbase_token": {
"type": "PocketBase User Token (Frontend/User)",
"source": "PocketBase OAuth2 flow",
"authenticated_user": "user@example.com"
},
"are_different": true
}
}
```
## Authentication Flow
### Frontend (Browser)
1. User clicks "Login with Microsoft"
2. PocketBase initiates OAuth2 flow with Microsoft provider
3. Microsoft redirects back with authorization code
4. PocketBase exchanges code for user token
5. Token stored in PocketBase auth store
6. User dashboard updates with authenticated state
### Backend (Server)
1. Application starts with MSAL configuration
2. On first API request, acquires Graph token using client credentials
3. Token cached for 55 minutes (60-minute lifetime - 5-minute buffer)
4. Subsequent requests use cached token
5. Token automatically refreshed when expired
## Development Notes
- **CORS**: Configured for `localhost:5173` and production URLs
- **Token Caching**: Graph tokens cached in memory; refresh on demand with `/api/auth/refresh-graph`
- **Error Handling**: Comprehensive error messages with specific failure reasons
- **Type Safety**: Full TypeScript support throughout frontend and backend
- **SvelteKit Routing**: File-based routing with `+page.svelte` and `+server.ts` files
## Comparison with Mode1 (Hono/Bun)
| Feature | Mode1Svelte | Mode1 |
|---------|-------------|--------|
| Framework | SvelteKit 2 | SvelteKit 4 (old) |
| Backend | Node.js Adapter | Hono + Bun |
| Frontend | Svelte Components | HTML + Vanilla JS |
| Styling | Tailwind CSS | CSS (custom) |
| Build Tool | Vite | Vite |
| API Routes | `/routes/api/` | `/routes/api/` |
| Auth Module | `/lib/auth/` | `/routes/api/` |
| Type Safety | Full TypeScript | TypeScript (partial) |
| Production Ready | ✅ Yes | ⚠️ Experimental |
## Troubleshooting
### Token Acquisition Fails
- Verify `CLIENT_ID`, `TENANT_ID`, and `CLIENT_SECRET` are correct
- Ensure the Azure application is configured for client credentials flow
- Check that the application has Graph API permissions
### PocketBase Token Validation Fails
- Verify `PB_URL` and `PB_DB` are correct
- Ensure the user is properly logged in via OAuth
- Check that PocketBase is running and accessible
### Login Button Not Appearing
- Ensure JavaScript is enabled
- Check browser console for errors
- Verify that `loginBtn` HTML element ID matches configuration
## Links
- [SvelteKit Documentation](https://svelte.dev/docs/kit)
- [Azure MSAL Node](https://github.com/AzureAD/microsoft-authentication-library-for-js)
- [PocketBase Documentation](https://pocketbase.io/docs/)
- [Tailwind CSS](https://tailwindcss.com/)
## License
Private - Job Info Testing Suite
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+2823
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "mode2svelte",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --port 3005",
"build": "vite build",
"preview": "vite preview",
"start": "PORT=3005 node build/index.js",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/postcss": "^4.1.18",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"dependencies": {
"@azure/msal-node": "^5.0.2",
"@types/node": "^25.0.10",
"dotenv": "^17.2.3",
"pocketbase": "^0.26.6"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
+22
View File
@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
sans-serif;
background: #0f172a;
color: #e2e8f0;
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#1e293b" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+204
View File
@@ -0,0 +1,204 @@
import { config } from 'dotenv';
import { ConfidentialClientApplication } from '@azure/msal-node';
import PocketBase from 'pocketbase';
import type { GraphTokenCache, BackendAuthConfig } from './types';
// Load environment variables from shared secrets directory
config({ path: '/home/admin/secrets/.env' });
/**
* Configuration Manager
* Exposes frontend-safe configuration loaded from environment
*/
export class AuthConfigManager {
/**
* Get frontend configuration (safe to expose to browser)
*/
static getFrontendConfig() {
return {
pbUrl: process.env.PB_URL!,
provider: 'microsoft',
collection: 'Users',
};
}
/**
* Get all backend secrets (never expose to frontend)
*/
static getBackendConfig() {
return {
clientId: process.env.CLIENT_ID!,
tenantId: process.env.TENANT_ID!,
clientSecret: process.env.CLIENT_SECRET!,
pbDb: process.env.PB_DB!,
};
}
}
/**
* Microsoft Graph Token Management (Backend)
* Handles token acquisition and caching for backend Graph API calls
*/
export class GraphTokenManager {
private cca: ConfidentialClientApplication;
private cache: GraphTokenCache | null = null;
private config: Required<BackendAuthConfig>;
constructor(config: BackendAuthConfig) {
this.config = {
clientId: config.clientId || process.env.CLIENT_ID || '',
tenantId: config.tenantId || process.env.TENANT_ID || '',
clientSecret: config.clientSecret || process.env.CLIENT_SECRET || '',
};
this.cca = new ConfidentialClientApplication({
auth: {
clientId: this.config.clientId,
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
clientSecret: this.config.clientSecret,
},
});
}
/**
* Get Graph token (from cache or acquire new)
*/
async getToken(): Promise<string> {
const now = Date.now();
// Check cache validity (with 60s buffer for expiration)
if (this.cache && this.cache.expiresOn - 60000 > now) {
return this.cache.token;
}
try {
const result = await this.cca.acquireTokenByClientCredential({
scopes: ['https://graph.microsoft.com/.default'],
});
if (!result?.accessToken) {
throw new Error('Failed to acquire Graph token');
}
const expiresOn = result.expiresOn
? new Date(result.expiresOn).getTime()
: now + 55 * 60 * 1000; // Default 55 minutes
this.cache = {
token: result.accessToken,
expiresOn,
};
return result.accessToken;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to acquire Graph token: ${message}`);
}
}
/**
* Get token with expiration info
*/
async getTokenWithExpiry(): Promise<{ token: string; expiresOnISO: string }> {
const token = await this.getToken();
const expiresOn = this.cache?.expiresOn || Date.now();
return {
token,
expiresOnISO: new Date(expiresOn).toISOString(),
};
}
/**
* Check if token is cached and valid
*/
isTokenValid(): boolean {
if (!this.cache) return false;
const now = Date.now();
return this.cache.expiresOn - 60000 > now;
}
/**
* Clear cache (force refresh on next call)
*/
clearCache(): void {
this.cache = null;
}
}
/**
* PocketBase Token Validation (Backend)
* Validates and uses user PocketBase tokens
*/
export class PocketBaseValidator {
private pb: PocketBase;
constructor(pbUrl?: string) {
this.pb = new PocketBase(pbUrl || process.env.PB_DB!);
}
/**
* Set user token and validate it
*/
async validateUserToken(token: string): Promise<boolean> {
try {
this.pb.authStore.save(token, null);
await this.pb.collection('Users').authRefresh();
return true;
} catch (error) {
console.error('Token validation failed:', error instanceof Error ? error.message : error);
return false;
}
}
/**
* Get user record from token
*/
async getUserRecord(token: string): Promise<any> {
try {
this.pb.authStore.save(token, null);
const record = this.pb.authStore.record || this.pb.authStore.model;
return record;
} catch (error) {
console.error('Failed to get user record:', error);
return null;
}
}
/**
* Get PocketBase instance
*/
getPocketBase(): PocketBase {
return this.pb;
}
}
/**
* Combined backend auth manager
*/
export class BackendAuth {
graphTokenManager: GraphTokenManager;
pbValidator: PocketBaseValidator;
constructor(config: BackendAuthConfig, pbUrl?: string) {
this.graphTokenManager = new GraphTokenManager(config);
this.pbValidator = new PocketBaseValidator(pbUrl);
}
/**
* Middleware to validate PocketBase token in requests
* Usage: app.use('/*', (c, next) => backendAuth.validateTokenMiddleware(c, next))
*/
async validateTokenMiddleware(c: any, next: any): Promise<any> {
const token = c.req.header('Authorization')?.replace('Bearer ', '') ||
(await c.req.json().catch(() => ({})))?.pbToken;
if (token) {
const isValid = await this.pbValidator.validateUserToken(token);
if (!isValid) {
return c.json({ error: 'Invalid token' }, 401);
}
}
return next();
}
}
+250
View File
@@ -0,0 +1,250 @@
import PocketBase from 'pocketbase';
import type { AuthConfig, AuthState, AuthCallbacks } from './types';
/**
* PocketBase OAuth2 Frontend Module
* Handles user authentication and token management
*/
export class PocketBaseAuth {
private pb: PocketBase;
private config: Required<AuthConfig>;
private callbacks: AuthCallbacks;
private state: AuthState;
constructor(config: AuthConfig, callbacks?: Partial<AuthCallbacks>) {
this.config = {
pbUrl: config.pbUrl!,
collection: config.collection || 'Users',
provider: config.provider || 'microsoft',
loginContainerId: config.loginContainerId || 'loginContainer',
userDisplayNameId: config.userDisplayNameId || 'userDisplayName',
userEmailId: config.userEmailId || 'userEmailValue',
loginBtnId: config.loginBtnId || 'loginBtn',
loginErrorId: config.loginErrorId || 'loginError',
};
this.callbacks = {
onAuthSuccess: callbacks?.onAuthSuccess || (() => {}),
onAuthFailure: callbacks?.onAuthFailure || (() => {}),
onTokenUpdate: callbacks?.onTokenUpdate || (() => {}),
onUiUpdate: callbacks?.onUiUpdate || (() => {}),
};
this.pb = new PocketBase(this.config.pbUrl);
this.state = {
isAuthenticated: false,
user: null,
token: null,
};
this.init();
}
/**
* Initialize auth module
*/
private init(): void {
this.updateAuthUI();
if (this.pb.authStore.isValid && this.pb.authStore.token) {
this.ensureUserLogged();
}
this.setupLoginButton();
}
/**
* Setup login button click handler
*/
private setupLoginButton(): void {
const loginBtn = document.getElementById(this.config.loginBtnId);
if (!loginBtn) {
console.warn(`Login button with id "${this.config.loginBtnId}" not found`);
return;
}
loginBtn.addEventListener('click', async (e) => {
e.preventDefault();
await this.handleLogin();
});
}
/**
* Handle login button click
*/
private async handleLogin(): Promise<void> {
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
const loginError = document.getElementById(this.config.loginErrorId) as HTMLElement;
if (!loginBtn || !loginError) return;
loginBtn.disabled = true;
loginBtn.textContent = 'Checking session...';
loginError.classList.add('hidden');
try {
// Try to use existing token first
const hadToken = await this.ensureUserLogged();
if (hadToken) {
this.resetLoginButton();
return;
}
// Otherwise perform OAuth
loginBtn.textContent = 'Logging in...';
const authData = await this.pb.collection(this.config.collection).authWithOAuth2({
provider: this.config.provider,
});
this.updateState(authData);
this.updateAuthUI();
this.callbacks.onAuthSuccess?.(this.state);
console.log('✓ Logged in with', this.config.provider);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Login failed:', message);
loginError.textContent = `Login failed: ${message}`;
loginError.classList.remove('hidden');
this.callbacks.onAuthFailure?.(error);
} finally {
this.resetLoginButton();
}
}
/**
* Ensure user is logged in (check existing token)
*/
async ensureUserLogged(): Promise<boolean> {
if (!this.pb.authStore.isValid || !this.pb.authStore.token) {
return false;
}
try {
const refresh = await this.pb.collection(this.config.collection).authRefresh();
this.updateState(refresh);
this.updateAuthUI();
this.callbacks.onTokenUpdate?.(this.state);
console.log('✓ Token refreshed');
return true;
} catch (error) {
console.warn('Existing token invalid, clearing auth');
this.pb.authStore.clear();
this.updateState(null);
this.updateAuthUI();
return false;
}
}
/**
* Update internal auth state
*/
private updateState(data: any): void {
if (!data) {
this.state = {
isAuthenticated: false,
user: null,
token: null,
};
return;
}
const record = data.record || this.pb.authStore.record || this.pb.authStore.model;
const meta = data.meta || {};
const model = this.pb.authStore.model;
this.state = {
isAuthenticated: true,
user: {
id: record?.id || model?.id || 'unknown',
name: record?.name || model?.name || meta?.name || record?.email || model?.email || 'Unknown User',
email: record?.email || model?.email || meta?.email || '(no email)',
},
token: this.pb.authStore.token,
};
}
/**
* Update UI based on auth state
*/
updateAuthUI(): void {
const loginContainer = document.getElementById(this.config.loginContainerId);
const userDisplayName = document.getElementById(this.config.userDisplayNameId);
const userEmail = document.getElementById(this.config.userEmailId);
if (!loginContainer) return;
if (this.pb.authStore.isValid) {
loginContainer.classList.add('hidden');
if (this.state.user) {
if (userDisplayName) userDisplayName.textContent = this.state.user.name;
if (userEmail) userEmail.textContent = this.state.user.email;
}
} else {
loginContainer.classList.remove('hidden');
const loginError = document.getElementById(this.config.loginErrorId);
if (loginError) loginError.classList.add('hidden');
}
this.callbacks.onUiUpdate?.(this.state);
}
/**
* Check token status (for verification/display)
*/
async checkTokenStatus(): Promise<{ pbToken: boolean; tokenExpiry?: string }> {
const pbToken = !!this.pb.authStore.token;
return { pbToken };
}
/**
* Get current auth state
*/
getAuthState(): AuthState {
return { ...this.state };
}
/**
* Get PocketBase instance (for direct usage if needed)
*/
getPocketBase(): PocketBase {
return this.pb;
}
/**
* Logout
*/
logout(): void {
this.pb.authStore.clear();
this.updateState(null);
this.updateAuthUI();
console.log('✓ Logged out');
}
/**
* Reset login button to initial state
*/
private resetLoginButton(): void {
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
if (loginBtn) {
loginBtn.disabled = false;
loginBtn.textContent = 'Login with Microsoft';
}
}
}
/**
* Quick init function - fetches config from backend
*/
export async function initPocketBaseAuth(
backendUrl: string,
callbacks?: Partial<AuthCallbacks>
): Promise<PocketBaseAuth> {
// Fetch frontend config from backend
const response = await fetch(`${backendUrl}/api/auth/config`);
if (!response.ok) {
throw new Error('Failed to fetch auth configuration from backend');
}
const config = await response.json();
const auth = new PocketBaseAuth(config, callbacks);
return auth;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Frontend Auth Configuration
*/
export interface AuthConfig {
pbUrl: string; // PocketBase URL (required)
collection?: string;
provider?: string;
loginContainerId?: string;
userDisplayNameId?: string;
userEmailId?: string;
loginBtnId?: string;
loginErrorId?: string;
}
/**
* Frontend configuration provided by backend
*/
export interface FrontendConfig {
pbUrl: string;
provider?: string;
collection?: string;
}
/**
* Backend Auth Configuration
*/
export interface BackendAuthConfig {
clientId?: string;
tenantId?: string;
clientSecret?: string;
}
/**
* Auth state object
*/
export interface AuthState {
isAuthenticated: boolean;
user: {
id: string;
name: string;
email: string;
} | null;
token: string | null;
}
/**
* Graph token cache object
*/
export interface GraphTokenCache {
token: string;
expiresOn: number;
}
/**
* Auth event callbacks
*/
export interface AuthCallbacks {
onAuthSuccess?: (state: AuthState) => void;
onAuthFailure?: (error: any) => void;
onTokenUpdate?: (state: AuthState) => void;
onUiUpdate?: (state: AuthState) => void;
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts">
import { onMount } from 'svelte';
import { PocketBaseAuth } from '$lib/auth/frontend';
import type { AuthState } from '$lib/auth/types';
let auth: PocketBaseAuth | undefined;
let authState: AuthState = {
isAuthenticated: false,
user: null,
token: null,
};
let graphToken = '';
let pbTokenValid = false;
let comparisonData: string | null = null;
let loading = false;
let error = '';
onMount(async () => {
try {
const response = await fetch('/api/auth/config');
if (!response.ok) throw new Error('Failed to get config');
const config = await response.json();
auth = new PocketBaseAuth(config, {
onAuthSuccess: (state: AuthState) => {
authState = state;
console.log('Auth success:', state);
},
onAuthFailure: (err: unknown) => {
error = 'Authentication failed: ' + (err instanceof Error ? err.message : 'Unknown error');
console.error('Auth error:', err);
},
onTokenUpdate: (state: AuthState) => {
authState = state;
},
onUiUpdate: (state: AuthState) => {
authState = state;
},
});
} catch (err) {
error = err instanceof Error ? err.message : 'Unknown error';
console.error(err);
}
});
async function handleFetchGraphToken() {
loading = true;
error = '';
try {
const response = await fetch('/api/auth/graph/token');
if (!response.ok) throw new Error('Failed to fetch Graph token');
const data = await response.json();
graphToken = JSON.stringify(data, null, 2);
} catch (err) {
error = err instanceof Error ? err.message : 'Unknown error';
} finally {
loading = false;
}
}
async function handleRefreshGraphToken() {
loading = true;
error = '';
try {
const response = await fetch('/api/auth/refresh-graph', { method: 'POST' });
if (!response.ok) throw new Error('Failed to refresh');
const data = await response.json();
graphToken = JSON.stringify(data, null, 2);
} catch (err) {
error = err instanceof Error ? err.message : 'Unknown error';
} finally {
loading = false;
}
}
async function handleValidatePBToken() {
loading = true;
error = '';
try {
const pbToken = auth?.getPocketBase?.()?.authStore?.token;
if (!pbToken) {
error = 'No PocketBase token found. Please log in first.';
pbTokenValid = false;
return;
}
const response = await fetch('/api/auth/validate-pb-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pbToken }),
});
if (!response.ok) throw new Error('Validation failed');
const data = await response.json();
pbTokenValid = data.valid;
error = data.valid ? '' : 'Token validation failed';
} catch (err) {
error = err instanceof Error ? err.message : 'Unknown error';
pbTokenValid = false;
} finally {
loading = false;
}
}
async function handleCompareTokens() {
loading = true;
error = '';
try {
const pbToken = auth?.getPocketBase?.()?.authStore?.token;
const response = await fetch('/api/auth/compare-tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pbToken }),
});
if (!response.ok) throw new Error('Comparison failed');
const data = await response.json();
comparisonData = JSON.stringify(data, null, 2);
} catch (err) {
error = err instanceof Error ? err.message : 'Unknown error';
} finally {
loading = false;
}
}
function handleLogout() {
if (auth) {
auth.logout();
authState = {
isAuthenticated: false,
user: null,
token: null,
};
error = '';
}
}
</script>
<div style="min-height: 100vh; background: linear-gradient(to bottom, #0f172a, #1e293b); color: white; padding: 16px; padding-bottom: 100px;">
<div style="max-width: 800px; margin: 0 auto;">
<!-- Header -->
<div style="margin-bottom: 32px;">
<h1 style="font-size: 28px; font-weight: bold; margin: 0 0 8px 0; color: #60a5fa;">Mode2Svelte</h1>
<p style="font-size: 14px; color: #94a3b8; margin: 0;">Advanced Auth Testing Dashboard</p>
</div>
<!-- Error Display -->
{#if error}
<div style="background: rgba(127, 29, 29, 0.2); border: 1px solid #dc2626; color: #fca5a5; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px;">
{error}
</div>
{/if}
<!-- Auth Status -->
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 16px; border-radius: 8px; margin-bottom: 16px;">
<h2 style="font-size: 20px; font-weight: bold; margin: 0 0 12px 0;">Authentication Status</h2>
{#if authState.isAuthenticated && authState.user !== null}
<div style="display: flex; flex-direction: column; gap: 12px;">
<div style="font-size: 14px;">
<span style="color: #cbd5e1;">Status:</span>
<span style="margin-left: 8px; color: #4ade80; font-weight: bold;">✓ Authenticated</span>
</div>
<div style="font-size: 14px; word-break: break-all;">
<span style="color: #cbd5e1;">User:</span>
<span style="margin-left: 8px; color: #93c5fd;" id="userDisplayName">{authState.user?.name}</span>
</div>
<div style="font-size: 14px; word-break: break-all;">
<span style="color: #cbd5e1;">Email:</span>
<span style="margin-left: 8px; color: #93c5fd;" id="userEmailValue">{authState.user?.email}</span>
</div>
<button
on:click={handleLogout}
style="background: #dc2626; color: white; border: none; padding: 12px 16px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; margin-top: 8px; width: 100%;"
>
Logout
</button>
</div>
{:else}
<div id="loginContainer" style="display: flex; flex-direction: column; gap: 12px;">
<p style="font-size: 14px; color: #cbd5e1; margin: 0;">Not authenticated. Log in to begin testing.</p>
<button
id="loginBtn"
style="background: #2563eb; color: white; border: none; padding: 16px; border-radius: 6px; font-weight: bold; font-size: 16px; cursor: pointer; width: 100%;"
>
Login with Microsoft
</button>
<div id="loginError" style="color: #f87171; font-size: 12px; display: none;"></div>
</div>
{/if}
</div>
<!-- API Testing -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px;">
<!-- Graph Token -->
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px;">
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">Graph Token</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button
on:click={handleFetchGraphToken}
disabled={loading}
style="background: #4f46e5; color: white; border: none; padding: 10px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
>
{loading ? 'Loading...' : 'Fetch Token'}
</button>
<button
on:click={handleRefreshGraphToken}
disabled={loading}
style="background: #4f46e5; color: white; border: none; padding: 10px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
</div>
<!-- PocketBase Token -->
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px;">
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">PocketBase Token</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button
on:click={handleValidatePBToken}
disabled={loading}
style="background: #059669; color: white; border: none; padding: 10px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
>
{loading ? 'Loading...' : 'Validate'}
</button>
{#if pbTokenValid}
<p style="color: #4ade80; font-size: 12px; margin: 0;">✓ Token is valid</p>
{/if}
</div>
</div>
</div>
<!-- Compare Tokens -->
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<button
on:click={handleCompareTokens}
disabled={loading}
style="background: #9333ea; color: white; border: none; padding: 12px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; opacity: {loading ? 0.6 : 1}; width: 100%;"
>
{loading ? 'Loading...' : 'Compare Tokens'}
</button>
</div>
<!-- Output -->
{#if graphToken}
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">Graph Token Response</h3>
<pre style="background: #0f172a; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 11px; color: #cbd5e1; max-height: 200px; white-space: pre-wrap; word-wrap: break-word; margin: 0;">{graphToken}</pre>
</div>
{/if}
{#if comparisonData}
<div style="background: rgba(71, 85, 105, 0.3); border: 1px solid #475569; padding: 12px; border-radius: 8px;">
<h3 style="font-size: 16px; font-weight: bold; margin: 0 0 12px 0;">Token Comparison</h3>
<pre style="background: #0f172a; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 11px; color: #cbd5e1; max-height: 200px; white-space: pre-wrap; word-wrap: break-word; margin: 0;">{comparisonData}</pre>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,83 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { BackendAuth } from '$lib/auth/backend';
const backendAuth = new BackendAuth(
{
clientId: process.env.CLIENT_ID,
tenantId: process.env.TENANT_ID,
clientSecret: process.env.CLIENT_SECRET,
},
process.env.PB_DB
);
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const pbToken = body.pbToken;
// Get Graph token
const { token: graphToken, expiresOnISO: graphExpires } = await backendAuth.graphTokenManager.getTokenWithExpiry();
if (!graphToken) {
return json({ success: false, error: 'Failed to acquire Graph token' }, { status: 500 });
}
// Decode both tokens to compare
const decodeJWT = (token: string) => {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
return JSON.parse(Buffer.from(parts[1], 'base64').toString());
} catch {
return null;
}
};
const graphPayload = decodeJWT(graphToken);
// Validate PB token to get actual user
let authenticatedUser = 'Unknown';
if (pbToken) {
try {
const isValid = await backendAuth.pbValidator.validateUserToken(pbToken);
if (isValid) {
const userRecord = await backendAuth.pbValidator.getUserRecord(pbToken);
authenticatedUser = userRecord?.email || userRecord?.id || 'Unknown';
}
} catch {
authenticatedUser = 'Token invalid or expired';
}
}
return json({
success: true,
comparison: {
graph_token: {
issuer: graphPayload?.iss || 'N/A',
audience: graphPayload?.aud || 'N/A',
app: graphPayload?.app_displayname || 'N/A',
scopes: graphPayload?.scp || 'N/A',
issued_at: graphPayload?.iat ? new Date(graphPayload.iat * 1000).toISOString() : 'N/A',
expires_at: graphPayload?.exp ? new Date(graphPayload.exp * 1000).toISOString() : 'N/A',
type: 'Microsoft Graph Token (Backend/App-only)',
source: '@azure/msal-node',
purpose: 'Backend API calls to Microsoft Graph',
},
pocketbase_token: {
note: 'User token from PocketBase OAuth2',
type: 'PocketBase User Token (Frontend/User)',
source: 'PocketBase OAuth2 flow',
purpose: 'User authentication and authorization',
authenticated_user: authenticatedUser,
},
are_different: true,
explanation: 'These are completely different tokens from different systems: Graph token is for app-to-app Microsoft API access, PocketBase token is for user authentication in the application.',
},
});
} catch (error) {
return json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to compare tokens' },
{ status: 500 }
);
}
};
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { AuthConfigManager } from '$lib/auth/backend';
export async function GET() {
try {
const config = AuthConfigManager.getFrontendConfig();
return json(config);
} catch (error) {
return json(
{ error: 'Failed to get config', message: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,54 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { BackendAuth, AuthConfigManager } from '$lib/auth/backend';
const backendAuth = new BackendAuth(
{
clientId: process.env.CLIENT_ID,
tenantId: process.env.TENANT_ID,
clientSecret: process.env.CLIENT_SECRET,
},
process.env.PB_DB
);
export const GET: RequestHandler = async () => {
try {
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
if (!token) {
return json({ success: false, error: 'Failed to acquire Graph token' }, { status: 500 });
}
// Decode JWT to show token details (for verification)
const parts = token.split('.');
let payload: any = {};
if (parts.length === 3) {
try {
const decoded = JSON.parse(Buffer.from(parts[1], 'base64').toString());
payload = {
aud: decoded.aud,
iss: decoded.iss,
scp: decoded.scp,
app_displayname: decoded.app_displayname,
iat: new Date(decoded.iat * 1000).toISOString(),
exp: new Date(decoded.exp * 1000).toISOString(),
};
} catch (e) {
// Silent fail - just show truncated token
}
}
return json({
success: true,
token: token.substring(0, 50) + '...',
fullToken: token, // Include full token for testing
expiresOn: expiresOnISO,
isValid: backendAuth.graphTokenManager.isTokenValid(),
tokenDetails: Object.keys(payload).length > 0 ? payload : 'Could not decode',
});
} catch (error) {
return json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to acquire token' },
{ status: 500 }
);
}
};
@@ -0,0 +1,28 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { BackendAuth } from '$lib/auth/backend';
const backendAuth = new BackendAuth(
{
clientId: process.env.CLIENT_ID,
tenantId: process.env.TENANT_ID,
clientSecret: process.env.CLIENT_SECRET,
},
process.env.PB_DB
);
export const POST: RequestHandler = async () => {
try {
backendAuth.graphTokenManager.clearCache();
const { token, expiresOnISO } = await backendAuth.graphTokenManager.getTokenWithExpiry();
return json({
success: true,
refreshed: true,
expiresOn: expiresOnISO,
});
} catch (error) {
return json(
{ success: false, error: error instanceof Error ? error.message : 'Refresh failed' },
{ status: 500 }
);
}
};
@@ -0,0 +1,39 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { BackendAuth } from '$lib/auth/backend';
const backendAuth = new BackendAuth(
{
clientId: process.env.CLIENT_ID,
tenantId: process.env.TENANT_ID,
clientSecret: process.env.CLIENT_SECRET,
},
process.env.PB_DB
);
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const token = body.pbToken;
if (!token) {
return json({ valid: false, error: 'No token provided' }, { status: 400 });
}
const isValid = await backendAuth.pbValidator.validateUserToken(token);
const user = isValid ? await backendAuth.pbValidator.getUserRecord(token) : null;
return json({
valid: isValid,
user: user ? {
id: user.id,
email: user.email,
name: user.name,
} : null,
});
} catch (error) {
return json(
{ valid: false, error: error instanceof Error ? error.message : 'Validation failed' },
{ status: 500 }
);
}
};
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+16
View File
@@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter(),
// LOCKED: Always use port 3005 for Mode1Svelte service
// 3006 = Orchestrator, 3005 = Mode service
// https://ji-test.ccllc.pro proxies to these ports
paths: {
base: process.env.BASE_PATH || ''
}
}
};
export default config;
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+15
View File
@@ -0,0 +1,15 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
build: {
// Ensure asset paths are relative, not absolute
// This fixes proxy routing issues
rollupOptions: {
output: {
assetFileNames: 'assets/[name]-[hash][extname]'
}
}
}
});