From 4c6058c6566c622b6dbaaa0c1d91adbf08df8e62 Mon Sep 17 00:00:00 2001 From: aewing Date: Fri, 16 Jan 2026 03:49:52 +0000 Subject: [PATCH] Extract auth module: standalone PocketBase OAuth2 + Graph token management - Create reusable auth-module/ with frontend and backend components - Frontend: PocketBaseAuth class for OAuth2 login and token management - Backend: GraphTokenManager and PocketBaseValidator for token operations - Includes TypeScript types and comprehensive README - No project-specific dependencies - ready to use in other projects --- auth-module/README.md | 244 ++++++++++++++++++++++++++++++++++++++++ auth-module/backend.ts | 171 ++++++++++++++++++++++++++++ auth-module/frontend.ts | 240 +++++++++++++++++++++++++++++++++++++++ auth-module/types.ts | 53 +++++++++ 4 files changed, 708 insertions(+) create mode 100644 auth-module/README.md create mode 100644 auth-module/backend.ts create mode 100644 auth-module/frontend.ts create mode 100644 auth-module/types.ts diff --git a/auth-module/README.md b/auth-module/README.md new file mode 100644 index 0000000..ebcc7b8 --- /dev/null +++ b/auth-module/README.md @@ -0,0 +1,244 @@ +# Auth Module + +Standalone authentication module for PocketBase OAuth2 + Microsoft Graph integration. + +## Features + +- **Frontend**: PocketBase OAuth2 authentication with Microsoft provider +- **Backend**: Microsoft Graph token management with automatic caching +- **No dependencies on project-specific code** - Use in any project + +## Installation + +Copy the `auth-module` folder to your project: + +```bash +cp -r auth-module /path/to/your/project/ +``` + +Install dependencies: + +```bash +bun add pocketbase @azure/msal-node +``` + +## Frontend Usage + +### Basic Setup + +```typescript +import { PocketBaseAuth } from './auth-module/frontend'; + +const auth = new PocketBaseAuth({ + pbUrl: 'http://localhost:8090', + collection: 'Users', + provider: 'microsoft', + loginContainerId: 'loginContainer', + userDisplayNameId: 'userDisplayName', + userEmailId: 'userEmailValue', + loginBtnId: 'loginBtn', + loginErrorId: 'loginError', +}); +``` + +### HTML Required + +```html + + + + +
+
+``` + +### Event Callbacks + +```typescript +const auth = new PocketBaseAuth(config, { + onAuthSuccess: (state) => { + console.log('Logged in:', state.user.email); + // Initialize other systems here + }, + onAuthFailure: (error) => { + console.error('Login failed:', error); + }, + onTokenUpdate: (state) => { + console.log('Token refreshed'); + }, + onUiUpdate: (state) => { + console.log('UI updated'); + }, +}); +``` + +### Methods + +```typescript +// Get current auth state +const state = auth.getAuthState(); +console.log(state.isAuthenticated, state.user); + +// Check token status +const status = await auth.checkTokenStatus(); + +// Logout +auth.logout(); + +// Get raw PocketBase instance if needed +const pb = auth.getPocketBase(); +``` + +## Backend Usage + +### Setup (server.ts) + +```typescript +import { GraphTokenManager, PocketBaseValidator, BackendAuth } from './auth-module/backend'; + +// Option 1: Use individual managers +const graphMgr = new GraphTokenManager({ + clientId: process.env.CLIENT_ID, + tenantId: process.env.TENANT_ID, + clientSecret: process.env.CLIENT_SECRET, +}); + +const pbValidator = new PocketBaseValidator('http://127.0.0.1:8090'); + +// Option 2: Use combined manager +const backendAuth = new BackendAuth({ + clientId: process.env.CLIENT_ID, + tenantId: process.env.TENANT_ID, + clientSecret: process.env.CLIENT_SECRET, +}); +``` + +### Get Graph Token + +```typescript +// Get token string +const token = await graphMgr.getToken(); + +// Get token with expiration +const { token, expiresOnISO } = await graphMgr.getTokenWithExpiry(); + +// Check if cached and valid +if (graphMgr.isTokenValid()) { + console.log('Using cached token'); +} + +// Clear cache (force refresh) +graphMgr.clearCache(); +``` + +### Validate PocketBase Token + +```typescript +// Validate token +const isValid = await pbValidator.validateUserToken(token); + +// Get user record +const user = await pbValidator.getUserRecord(token); + +// Get PocketBase instance +const pb = pbValidator.getPocketBase(); +``` + +### Endpoint Example + +```typescript +app.get('/api/graph/status', async (c) => { + try { + const { token, expiresOnISO } = await graphMgr.getTokenWithExpiry(); + return c.json({ + active: true, + expiresOnISO, + }); + } catch (error) { + return c.json( + { active: false, error: error.message }, + 500 + ); + } +}); + +app.post('/api/submit', async (c) => { + const body = await c.req.json(); + const pbToken = body.pbToken; + + if (!pbToken) { + return c.json({ error: 'Missing pbToken' }, 401); + } + + const isValid = await pbValidator.validateUserToken(pbToken); + if (!isValid) { + return c.json({ error: 'Invalid token' }, 401); + } + + // Token is valid, proceed with business logic + // ... +}); +``` + +## Environment Variables + +Required in `.env` file: + +```env +CLIENT_ID=your-microsoft-app-id +TENANT_ID=your-azure-tenant-id +CLIENT_SECRET=your-microsoft-client-secret +PB_DB=http://127.0.0.1:8090 # PocketBase URL +``` + +## Configuration + +### Frontend Config Options + +```typescript +interface AuthConfig { + pbUrl?: string; // PocketBase URL (default: http://localhost:8090) + collection?: string; // Auth collection (default: Users) + provider?: string; // OAuth provider (default: microsoft) + loginContainerId?: string; // ID of login container element + userDisplayNameId?: string; // ID of user name display element + userEmailId?: string; // ID of user email display element + loginBtnId?: string; // ID of login button element + loginErrorId?: string; // ID of error message element +} +``` + +### Backend Config Options + +```typescript +interface BackendAuthConfig { + clientId?: string; // Microsoft app ID (or CLIENT_ID env var) + tenantId?: string; // Azure tenant ID (or TENANT_ID env var) + clientSecret?: string; // Client secret (or CLIENT_SECRET env var) +} +``` + +## TypeScript + +The module exports TypeScript types for type safety: + +```typescript +import { AuthState, AuthConfig, GraphTokenCache } from './auth-module/types'; + +const state: AuthState = auth.getAuthState(); +``` + +## Notes + +- **Frontend** manages user authentication only +- **Backend** manages Graph API tokens (never exposed to client) +- Tokens are cached in-memory on backend with automatic refresh +- Module does not include project-specific features (alerts, etc.) +- Each project can implement its own business logic on top + +## License + +Same as parent project diff --git a/auth-module/backend.ts b/auth-module/backend.ts new file mode 100644 index 0000000..41248ca --- /dev/null +++ b/auth-module/backend.ts @@ -0,0 +1,171 @@ +import { ConfidentialClientApplication } from '@azure/msal-node'; +import PocketBase from 'pocketbase'; +import { GraphTokenCache, BackendAuthConfig } from './types'; + +/** + * 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; + + 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 { + 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 || 'http://127.0.0.1:8090'); + } + + /** + * Set user token and validate it + */ + async validateUserToken(token: string): Promise { + 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 { + 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 { + 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(); + } +} diff --git a/auth-module/frontend.ts b/auth-module/frontend.ts new file mode 100644 index 0000000..db89fe8 --- /dev/null +++ b/auth-module/frontend.ts @@ -0,0 +1,240 @@ +import PocketBase from 'pocketbase'; +import { AuthConfig, AuthState, AuthCallbacks } from './types'; + +/** + * PocketBase OAuth2 Frontend Module + * Handles user authentication and token management + */ +export class PocketBaseAuth { + private pb: PocketBase; + private config: Required; + private callbacks: AuthCallbacks; + private state: AuthState; + + constructor(config: AuthConfig, callbacks?: Partial) { + this.config = { + pbUrl: config.pbUrl || 'http://localhost:8090', + 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 { + 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 { + 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 for simple use cases + */ +export async function initPocketBaseAuth(config: AuthConfig): Promise { + const auth = new PocketBaseAuth(config); + return auth; +} diff --git a/auth-module/types.ts b/auth-module/types.ts new file mode 100644 index 0000000..ef35043 --- /dev/null +++ b/auth-module/types.ts @@ -0,0 +1,53 @@ +/** + * Frontend Auth Configuration + */ +export interface AuthConfig { + pbUrl?: string; + collection?: string; + provider?: string; + loginContainerId?: string; + userDisplayNameId?: string; + userEmailId?: string; + loginBtnId?: string; + loginErrorId?: 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; +}