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;
+}