widgettdc-api / apps /backend /src /services /showpad /showpad-auth.ts
Kraft102's picture
Update backend source
34367da verified
/**
* Showpad Authentication Service
*
* Handles secure authentication with TDC Showpad instance
* Supports both OAuth 2.0 (recommended) and username/password
*/
import { EventEmitter } from 'events';
interface ShowpadCredentials {
subdomain: string;
username?: string;
password?: string;
clientId?: string;
clientSecret?: string;
}
interface TokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
scope: string;
}
interface AuthState {
isAuthenticated: boolean;
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null;
scope: string[];
}
export class ShowpadAuthService extends EventEmitter {
private credentials: ShowpadCredentials;
private state: AuthState;
private refreshTimer: NodeJS.Timeout | null = null;
constructor(credentials: ShowpadCredentials) {
super();
this.credentials = credentials;
this.state = {
isAuthenticated: false,
accessToken: null,
refreshToken: null,
expiresAt: null,
scope: []
};
}
/**
* Authenticate using available credentials
* Tries OAuth first, falls back to username/password
*/
async authenticate(): Promise<void> {
try {
if (this.hasOAuthCredentials()) {
await this.authenticateWithOAuth();
} else if (this.hasPasswordCredentials()) {
await this.authenticateWithPassword();
} else {
throw new Error('No valid credentials provided');
}
this.emit('authenticated', { scope: this.state.scope });
this.scheduleTokenRefresh();
} catch (error) {
this.emit('auth_error', error);
throw error;
}
}
/**
* OAuth 2.0 Client Credentials Flow
* Best for server-to-server authentication
*/
private async authenticateWithOAuth(): Promise<void> {
const response = await fetch(
`https://${this.credentials.subdomain}.showpad.biz/api/v3/oauth2/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + Buffer.from(
`${this.credentials.clientId}:${this.credentials.clientSecret}`
).toString('base64')
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'read_content read_user_management'
})
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`OAuth authentication failed: ${error}`);
}
const tokenData: TokenResponse = await response.json();
this.updateTokenState(tokenData);
}
/**
* Username/Password Authentication (Legacy)
* Less secure, but works without OAuth setup
*/
private async authenticateWithPassword(): Promise<void> {
const response = await fetch(
`https://${this.credentials.subdomain}.showpad.biz/api/v3/oauth2/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'password',
username: this.credentials.username!,
password: this.credentials.password!,
scope: 'read_content read_user_management'
})
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Password authentication failed: ${error}`);
}
const tokenData: TokenResponse = await response.json();
this.updateTokenState(tokenData);
}
/**
* Refresh access token using refresh token
*/
async refreshAccessToken(): Promise<void> {
if (!this.state.refreshToken) {
throw new Error('No refresh token available');
}
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded'
};
if (this.hasOAuthCredentials()) {
headers['Authorization'] = 'Basic ' + Buffer.from(
`${this.credentials.clientId}:${this.credentials.clientSecret}`
).toString('base64');
}
const response = await fetch(
`https://${this.credentials.subdomain}.showpad.biz/api/v3/oauth2/token`,
{
method: 'POST',
headers,
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.state.refreshToken
})
}
);
if (!response.ok) {
// Refresh failed - need to re-authenticate
this.emit('token_refresh_failed');
await this.authenticate();
return;
}
const tokenData: TokenResponse = await response.json();
this.updateTokenState(tokenData);
this.emit('token_refreshed');
}
/**
* Update internal state with new token data
*/
private updateTokenState(tokenData: TokenResponse): void {
this.state.accessToken = tokenData.access_token;
this.state.refreshToken = tokenData.refresh_token;
this.state.expiresAt = Date.now() + (tokenData.expires_in * 1000);
this.state.scope = tokenData.scope.split(' ');
this.state.isAuthenticated = true;
}
/**
* Schedule automatic token refresh before expiration
*/
private scheduleTokenRefresh(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
if (!this.state.expiresAt) return;
// Refresh 5 minutes before expiration
const refreshIn = this.state.expiresAt - Date.now() - (5 * 60 * 1000);
if (refreshIn > 0) {
this.refreshTimer = setTimeout(() => {
this.refreshAccessToken().catch(error => {
this.emit('token_refresh_error', error);
});
}, refreshIn);
}
}
/**
* Get current access token (refreshes if expired)
*/
async getAccessToken(): Promise<string> {
if (!this.state.isAuthenticated) {
await this.authenticate();
}
// Check if token is expired or about to expire (1 minute buffer)
if (this.state.expiresAt && (this.state.expiresAt - Date.now()) < 60000) {
await this.refreshAccessToken();
}
if (!this.state.accessToken) {
throw new Error('No access token available');
}
return this.state.accessToken;
}
/**
* Make authenticated API request to Showpad
*/
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await this.getAccessToken();
const apiVersion = process.env.SHOWPAD_API_VERSION || 'v4';
const response = await fetch(
`https://${this.credentials.subdomain}.api.showpad.com/${apiVersion}${endpoint}`,
{
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers
}
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Showpad API error: ${response.status} - ${error}`);
}
return response.json();
}
/**
* Check if OAuth credentials are available
*/
private hasOAuthCredentials(): boolean {
return !!(this.credentials.clientId && this.credentials.clientSecret);
}
/**
* Check if password credentials are available
*/
private hasPasswordCredentials(): boolean {
return !!(this.credentials.username && this.credentials.password);
}
/**
* Get current authentication state
*/
getState(): AuthState {
return { ...this.state };
}
/**
* Check if currently authenticated
*/
isAuthenticated(): boolean {
return this.state.isAuthenticated &&
!!this.state.accessToken &&
(!this.state.expiresAt || this.state.expiresAt > Date.now());
}
/**
* Revoke current tokens and clear state
*/
async logout(): Promise<void> {
// Clear refresh timer
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
// Reset state
this.state = {
isAuthenticated: false,
accessToken: null,
refreshToken: null,
expiresAt: null,
scope: []
};
this.emit('logged_out');
}
/**
* Clean up resources
*/
destroy(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.removeAllListeners();
}
}
/**
* Factory function to create ShowpadAuthService from environment variables
*/
export function createShowpadAuthFromEnv(): ShowpadAuthService {
const credentials: ShowpadCredentials = {
subdomain: process.env.SHOWPAD_SUBDOMAIN || 'tdcerhverv',
username: process.env.SHOWPAD_USERNAME,
password: process.env.SHOWPAD_PASSWORD,
clientId: process.env.SHOWPAD_CLIENT_ID,
clientSecret: process.env.SHOWPAD_CLIENT_SECRET
};
if (!credentials.subdomain) {
throw new Error('SHOWPAD_SUBDOMAIN environment variable is required');
}
return new ShowpadAuthService(credentials);
}
// Export types for external use
export type { ShowpadCredentials, TokenResponse, AuthState };