/**
* Feishu Auth Service - Token Management
*
* Handles tenant_access_token acquisition and automatic refresh.
* Token is cached and refreshed 5 minutes before expiry.
*/
import axios from 'axios'
import {
FEISHU_API_BASE,
API_ENDPOINTS,
TOKEN_REFRESH_BUFFER_MS,
DEFAULT_TOKEN_EXPIRY_MS,
ERROR_MESSAGES,
} from '../constants.js'
import type { FeishuApiResponse, TokenResponse, CachedToken } from '../types.js'
export class AuthService {
private appId: string
private appSecret: string
private cachedToken: CachedToken | null = null
constructor() {
const appId = process.env.FEISHU_APP_ID
const appSecret = process.env.FEISHU_APP_SECRET
if (!appId || !appSecret) {
throw new Error(ERROR_MESSAGES.MISSING_CREDENTIALS)
}
this.appId = appId
this.appSecret = appSecret
}
/**
* Get a valid tenant access token.
* Returns cached token if still valid, otherwise fetches a new one.
*/
async getToken(): Promise<string> {
// Check if cached token is still valid
if (this.isTokenValid()) {
return this.cachedToken!.token
}
// Fetch new token
return this.refreshToken()
}
/**
* Check if cached token is valid (not expired with buffer)
*/
private isTokenValid(): boolean {
if (!this.cachedToken) {
return false
}
const now = Date.now()
return now < this.cachedToken.expiresAt - TOKEN_REFRESH_BUFFER_MS
}
/**
* Fetch a new tenant access token from Feishu API
*/
private async refreshToken(): Promise<string> {
try {
const response = await axios.post<FeishuApiResponse<TokenResponse>>(
`${FEISHU_API_BASE}${API_ENDPOINTS.TENANT_ACCESS_TOKEN}`,
{
app_id: this.appId,
app_secret: this.appSecret,
},
{
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
timeout: 10000,
}
)
// Feishu returns token at root level for this endpoint (not in data field)
const responseData = response.data as {
code: number
msg: string
tenant_access_token?: string
expire?: number
}
if (responseData.code !== 0) {
throw new Error(
`${ERROR_MESSAGES.TOKEN_FETCH_FAILED}: ${responseData.msg} (code: ${responseData.code})`
)
}
const token = responseData.tenant_access_token
const expire = responseData.expire ?? DEFAULT_TOKEN_EXPIRY_MS / 1000
if (!token) {
throw new Error(`${ERROR_MESSAGES.TOKEN_FETCH_FAILED}: No token in response`)
}
// Cache the token
this.cachedToken = {
token,
expiresAt: Date.now() + expire * 1000,
}
console.error(
`[AuthService] Token refreshed, expires in ${Math.round(expire / 60)} minutes`
)
return token
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
throw new Error(`${ERROR_MESSAGES.TOKEN_FETCH_FAILED}: Request timeout`)
}
if (!error.response) {
throw new Error(`${ERROR_MESSAGES.NETWORK_ERROR}`)
}
throw new Error(
`${ERROR_MESSAGES.TOKEN_FETCH_FAILED}: ${error.response.status} ${error.response.statusText}`
)
}
throw error
}
}
/**
* Force token refresh (useful after permission changes)
*/
async forceRefresh(): Promise<string> {
this.cachedToken = null
return this.refreshToken()
}
/**
* Clear cached token
*/
clearCache(): void {
this.cachedToken = null
}
}
// Singleton instance
let authServiceInstance: AuthService | null = null
export function getAuthService(): AuthService {
if (!authServiceInstance) {
authServiceInstance = new AuthService()
}
return authServiceInstance
}