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
This commit is contained in:
2026-01-16 03:49:52 +00:00
parent 864fcab1ce
commit 4c6058c656
4 changed files with 708 additions and 0 deletions
+244
View File
@@ -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
<!-- Login container (shown when not authenticated) -->
<div id="loginContainer" class="hidden">
<button id="loginBtn">Login with Microsoft</button>
<div id="loginError" class="hidden"></div>
</div>
<!-- User display (shown when authenticated) -->
<div id="userDisplayName"></div>
<div id="userEmailValue"></div>
```
### 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
+171
View File
@@ -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<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 || 'http://127.0.0.1:8090');
}
/**
* 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();
}
}
+240
View File
@@ -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<AuthConfig>;
private callbacks: AuthCallbacks;
private state: AuthState;
constructor(config: AuthConfig, callbacks?: Partial<AuthCallbacks>) {
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<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 for simple use cases
*/
export async function initPocketBaseAuth(config: AuthConfig): Promise<PocketBaseAuth> {
const auth = new PocketBaseAuth(config);
return auth;
}
+53
View File
@@ -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;
}