UNPKG

@timesheet/sdk

Version:
4 lines 197 kB
{ "version": 3, "sources": ["../src/http/ApiClient.ts", "../src/exceptions/TimesheetApiError.ts", "../src/exceptions/TimesheetAuthError.ts", "../src/exceptions/TimesheetRateLimitError.ts", "../src/version.ts", "../src/auth/ApiKeyAuth.ts", "../src/auth/OAuth2Auth.ts", "../src/auth/OAuth21Auth.ts", "../src/auth/pkce.ts", "../src/auth/OAuthDiscovery.ts", "../src/config/RetryConfig.ts", "../src/models/Page.ts", "../src/models/Webhook.ts", "../src/resources/Resource.ts", "../src/resources/OrganizationResource.ts", "../src/resources/TeamResource.ts", "../src/resources/ProjectResource.ts", "../src/utils/date.ts", "../src/resources/TaskResource.ts", "../src/resources/RateResource.ts", "../src/resources/TagResource.ts", "../src/resources/ExpenseResource.ts", "../src/resources/NoteResource.ts", "../src/resources/PauseResource.ts", "../src/resources/ProfileResource.ts", "../src/resources/SettingsResource.ts", "../src/resources/AutomationResource.ts", "../src/resources/DocumentResource.ts", "../src/resources/TimerResource.ts", "../src/resources/TodoResource.ts", "../src/resources/WebhookResource.ts", "../src/resources/EventResource.ts", "../src/resources/AbsenceResource.ts", "../src/resources/AbsenceTypeResource.ts", "../src/resources/ContractResource.ts", "../src/resources/ContractTemplateResource.ts", "../src/resources/reports/DocumentReportResource.ts", "../src/resources/reports/TaskReportResource.ts", "../src/resources/reports/ExpenseReportResource.ts", "../src/resources/reports/NoteReportResource.ts", "../src/resources/reports/ExportResource.ts", "../src/resources/reports/ReportsClient.ts", "../src/index.ts"], "sourcesContent": ["import type { AxiosInstance, AxiosRequestConfig } from 'axios';\nimport axios from 'axios';\nimport type { ClientConfig } from '../config';\nimport { TimesheetApiError, TimesheetAuthError, TimesheetRateLimitError } from '../exceptions';\nimport { SDK_VERSION } from '../version';\n\ninterface ErrorResponseData {\n message?: string;\n code?: string;\n}\n\n/**\n * HTTP client for making API requests.\n */\nexport class ApiClient {\n private readonly config: ClientConfig;\n private readonly httpClient: AxiosInstance;\n\n constructor(config: ClientConfig) {\n this.config = config;\n this.httpClient =\n config.httpClient ||\n axios.create({\n baseURL: config.baseUrl,\n timeout: 30000,\n headers: {\n 'User-Agent': `Timesheet-TypeScript-SDK/${SDK_VERSION}`,\n 'Content-Type': 'application/json',\n },\n });\n\n // Request interceptor for authentication\n this.httpClient.interceptors.request.use(async (config) => {\n // Add authentication headers if configured\n if (config.url !== '/oauth/token' && this.config.authentication) {\n const authHeaders = await this.config.authentication.getAuthHeaders();\n if (authHeaders) {\n // Set headers directly to avoid type issues\n for (const [key, value] of Object.entries(authHeaders)) {\n config.headers.set(key, value);\n }\n }\n }\n\n return config;\n });\n }\n\n /**\n * Makes an HTTP request with retry support.\n */\n async request<T>(config: AxiosRequestConfig): Promise<T> {\n let lastError: Error | null = null;\n const retryConfig = this.config.retryConfig;\n\n for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {\n try {\n const response = await this.httpClient.request<T>(config);\n return response.data;\n } catch (error) {\n lastError = error as Error;\n\n // Handle authentication errors\n if (axios.isAxiosError<ErrorResponseData>(error) && error.response?.status === 401) {\n const errorData = error.response.data;\n throw new TimesheetAuthError(\n errorData?.message || 'Authentication failed',\n 401,\n JSON.stringify(errorData),\n );\n }\n\n // Handle rate limit errors\n if (axios.isAxiosError(error) && error.response?.status === 429) {\n const retryAfter = error.response.headers['retry-after'] as string | undefined;\n throw new TimesheetRateLimitError('Rate limit exceeded', retryAfter);\n }\n\n // Check if we should retry\n const status = axios.isAxiosError(error) ? error.response?.status : undefined;\n if (\n attempt < retryConfig.maxRetries &&\n status &&\n retryConfig.retryableStatusCodes.includes(status)\n ) {\n // Calculate delay with exponential backoff\n const delay = Math.min(\n retryConfig.initialDelay * Math.pow(retryConfig.backoffMultiplier, attempt),\n retryConfig.maxDelay,\n );\n\n await this.sleep(delay);\n continue;\n }\n\n // Non-retryable error\n if (axios.isAxiosError<ErrorResponseData>(error)) {\n const errorData = error.response?.data;\n throw new TimesheetApiError(\n errorData?.message || error.message,\n error.response?.status,\n JSON.stringify(errorData),\n errorData?.code,\n );\n } else if (error instanceof Error) {\n throw new TimesheetApiError(error.message);\n } else {\n throw new TimesheetApiError('Unknown error occurred');\n }\n }\n }\n\n // This should never happen, but TypeScript needs it\n throw lastError || new Error('Unknown error');\n }\n\n /**\n * GET request.\n */\n async get<T, P = Record<string, unknown>>(path: string, params?: P): Promise<T> {\n return this.request<T>({\n method: 'GET',\n url: path,\n params: params as Record<string, unknown>,\n });\n }\n\n /**\n * POST request.\n */\n async post<T, P = Record<string, unknown>>(path: string, data?: unknown, params?: P): Promise<T> {\n return this.request<T>({\n method: 'POST',\n url: path,\n data,\n params: params as Record<string, unknown>,\n });\n }\n\n /**\n * PUT request.\n */\n async put<T, P = Record<string, unknown>>(path: string, data?: unknown, params?: P): Promise<T> {\n return this.request<T>({\n method: 'PUT',\n url: path,\n data,\n params: params as Record<string, unknown>,\n });\n }\n\n /**\n * DELETE request. Supports an optional request body for endpoints that\n * accept a payload (e.g. batch deletions).\n */\n async delete<T, P = Record<string, unknown>>(\n path: string,\n params?: P,\n data?: unknown,\n ): Promise<T> {\n return this.request<T>({\n method: 'DELETE',\n url: path,\n data,\n params: params as Record<string, unknown>,\n });\n }\n\n /**\n * POST multipart/form-data request for file uploads.\n * @param path API endpoint path\n * @param formData FormData containing file and optional JSON data\n */\n async postMultipart<T>(path: string, formData: FormData): Promise<T> {\n return this.request<T>({\n method: 'POST',\n url: path,\n data: formData,\n headers: {\n 'Content-Type': 'multipart/form-data',\n },\n });\n }\n\n /**\n * Sleep for the specified number of milliseconds.\n */\n private sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n\n /**\n * Get the base URL for the API.\n */\n getBaseUrl(): string {\n return this.config.baseUrl;\n }\n\n /**\n * Get authentication headers for SSE connections.\n * @returns Promise resolving to auth headers or undefined\n */\n async getAuthHeaders(): Promise<Record<string, string> | undefined> {\n if (this.config.authentication) {\n return this.config.authentication.getAuthHeaders();\n }\n return undefined;\n }\n}\n", "/**\n * Base exception for all Timesheet API errors.\n */\nexport class TimesheetApiError extends Error {\n public readonly statusCode?: number;\n public readonly responseBody?: string;\n public readonly errorCode?: string;\n\n /**\n * Creates a new TimesheetApiError.\n *\n * @param message The error message\n * @param statusCode Optional HTTP status code\n * @param responseBody Optional response body\n * @param errorCode Optional API error code\n */\n constructor(message: string, statusCode?: number, responseBody?: string, errorCode?: string) {\n const fullMessage = statusCode\n ? errorCode\n ? `${message} (HTTP ${statusCode}, Code: ${errorCode})`\n : `${message} (HTTP ${statusCode})`\n : message;\n\n super(fullMessage);\n\n this.name = 'TimesheetApiError';\n this.statusCode = statusCode;\n this.responseBody = responseBody;\n this.errorCode = errorCode;\n\n // Maintains proper stack trace for where our error was thrown\n if (typeof Error.captureStackTrace === 'function') {\n Error.captureStackTrace(this, TimesheetApiError);\n }\n }\n}\n", "import { TimesheetApiError } from './TimesheetApiError';\n\n/**\n * Exception thrown when authentication fails.\n */\nexport class TimesheetAuthError extends TimesheetApiError {\n /**\n * Creates a new authentication exception.\n *\n * @param message The error message\n * @param statusCode The HTTP status code (usually 401)\n * @param responseBody The response body\n */\n constructor(message: string, statusCode: number = 401, responseBody?: string) {\n super(message, statusCode, responseBody, 'authentication_error');\n this.name = 'TimesheetAuthError';\n }\n}\n", "import { TimesheetApiError } from './TimesheetApiError';\n\n/**\n * Exception thrown when rate limit is exceeded.\n */\nexport class TimesheetRateLimitError extends TimesheetApiError {\n public readonly retryAfter?: string;\n\n /**\n * Creates a new rate limit exception.\n *\n * @param message The error message\n * @param retryAfter The retry-after header value\n */\n constructor(message: string, retryAfter?: string) {\n super(message, 429, undefined, 'rate_limit_exceeded');\n this.name = 'TimesheetRateLimitError';\n this.retryAfter = retryAfter;\n }\n\n /**\n * Parses the retry-after value as a Date.\n *\n * @returns The retry-after time as a Date, or null if parsing fails\n */\n getRetryAfterDate(): Date | null {\n if (!this.retryAfter) {\n return null;\n }\n\n // Try to parse as epoch seconds\n const epochSeconds = Number(this.retryAfter);\n if (!isNaN(epochSeconds)) {\n return new Date(epochSeconds * 1000);\n }\n\n // Try to parse as ISO string\n const date = new Date(this.retryAfter);\n if (!isNaN(date.getTime())) {\n return date;\n }\n\n return null;\n }\n}\n", "// Generated by scripts/sync-version.mjs. Do not edit by hand.\nexport const SDK_VERSION = '1.2.0';\n", "import type { AxiosRequestConfig } from 'axios';\nimport type { Authentication } from './Authentication';\n\n/**\n * API Key authentication implementation.\n *\n * Uses the ApiKey scheme in the Authorization header.\n * Format: `Authorization: ApiKey {api_key}`\n */\nexport class ApiKeyAuth implements Authentication {\n constructor(private readonly apiKey: string) {\n if (apiKey === null) {\n throw new Error('API key cannot be null');\n }\n if (apiKey === undefined) {\n throw new Error('API key cannot be undefined');\n }\n if (!apiKey) {\n throw new Error('API key cannot be empty');\n }\n if (typeof apiKey !== 'string') {\n throw new Error('API key must be a string');\n }\n if (apiKey.trim().length === 0) {\n throw new Error('API key cannot be empty or whitespace');\n }\n\n // Validate API key format: should be ts_{prefix}.{secret}\n if (!this.isValidFormat(apiKey)) {\n throw new Error('Invalid API key format');\n }\n }\n\n private isValidFormat(apiKey: string): boolean {\n // API key format: ts_{prefix}.{secret}\n const apiKeyPattern = /^ts_[a-zA-Z0-9]+\\.[a-zA-Z0-9]+$/;\n return apiKeyPattern.test(apiKey);\n }\n\n applyAuth(config: AxiosRequestConfig): void {\n if (!config.headers) {\n config.headers = {};\n }\n config.headers['Authorization'] = `ApiKey ${this.apiKey}`;\n }\n\n needsRefresh(): boolean {\n // API keys don't need refresh\n return false;\n }\n\n // eslint-disable-next-line @typescript-eslint/require-await\n async refresh(): Promise<void> {\n throw new Error('API keys cannot be refreshed');\n }\n\n // eslint-disable-next-line @typescript-eslint/require-await\n async getAuthHeaders(): Promise<Record<string, string>> {\n return {\n Authorization: `ApiKey ${this.apiKey}`,\n };\n }\n\n isValid(): boolean {\n return (\n typeof this.apiKey === 'string' &&\n this.apiKey.trim().length > 0 &&\n this.isValidFormat(this.apiKey)\n );\n }\n}\n", "import type { AxiosRequestConfig } from 'axios';\nimport axios from 'axios';\nimport jwt from 'jsonwebtoken';\nimport type { Authentication } from './Authentication';\n\n/**\n * OAuth2 authentication implementation with automatic token refresh.\n *\n * Supports both simple bearer token authentication and full OAuth2 flow with refresh tokens.\n */\nexport class OAuth2Auth implements Authentication {\n private static readonly TOKEN_ENDPOINT = 'https://api.timesheet.io/oauth2/token';\n\n private accessToken: string;\n private refreshToken?: string;\n private readonly clientId?: string;\n private readonly clientSecret?: string;\n private tokenExpiry?: Date;\n private refreshPromise?: Promise<void>;\n\n /**\n * Creates OAuth2 authentication with a simple bearer token.\n *\n * @param accessToken The OAuth2 access token\n */\n constructor(accessToken: string);\n\n /**\n * Creates OAuth2 authentication with refresh capability.\n *\n * @param clientId The OAuth2 client ID\n * @param clientSecret The OAuth2 client secret\n * @param refreshToken The OAuth2 refresh token\n */\n constructor(clientId: string, clientSecret: string, refreshToken: string);\n\n constructor(accessTokenOrClientId: string, clientSecret?: string, refreshToken?: string) {\n if (clientSecret && refreshToken) {\n // OAuth2 with refresh\n this.clientId = accessTokenOrClientId;\n this.clientSecret = clientSecret;\n this.refreshToken = refreshToken;\n this.accessToken = ''; // Will be set by refresh()\n // Don't call refresh here - let it happen lazily when needed\n } else {\n // Simple bearer token\n this.accessToken = accessTokenOrClientId;\n this.parseTokenExpiry();\n }\n }\n\n applyAuth(config: AxiosRequestConfig): void {\n if (!config.headers) {\n config.headers = {};\n }\n config.headers['Authorization'] = `Bearer ${this.accessToken}`;\n }\n\n needsRefresh(): boolean {\n // If we have refresh capabilities but no access token, we need to refresh\n if (this.refreshToken && !this.accessToken) {\n return true;\n }\n\n if (!this.refreshToken || !this.tokenExpiry) {\n return false;\n }\n // Refresh if token expires in less than 5 minutes\n const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);\n return fiveMinutesFromNow >= this.tokenExpiry;\n }\n\n async refresh(): Promise<void> {\n if (!this.refreshToken) {\n throw new Error('Cannot refresh without refresh token');\n }\n\n // Prevent multiple concurrent refresh attempts\n if (this.refreshPromise) {\n return this.refreshPromise;\n }\n\n this.refreshPromise = this.performRefresh();\n try {\n await this.refreshPromise;\n } finally {\n this.refreshPromise = undefined;\n }\n }\n\n private async performRefresh(): Promise<void> {\n interface TokenResponse {\n access_token: string;\n refresh_token?: string;\n token_type?: string;\n expires_in?: number;\n }\n\n try {\n const response = await axios.post<TokenResponse>(\n OAuth2Auth.TOKEN_ENDPOINT,\n new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: this.refreshToken!,\n client_id: this.clientId!,\n client_secret: this.clientSecret!,\n }),\n {\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n },\n );\n\n this.accessToken = response.data.access_token;\n\n if (response.data.refresh_token) {\n this.refreshToken = response.data.refresh_token;\n }\n\n this.parseTokenExpiry();\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`Failed to refresh OAuth2 token: ${message}`);\n }\n }\n\n async getAuthHeaders(): Promise<Record<string, string>> {\n // Ensure we have a valid token before returning headers\n if (this.needsRefresh()) {\n await this.refresh();\n }\n\n return {\n Authorization: `Bearer ${this.accessToken}`,\n };\n }\n\n /**\n * Performs the OAuth2 authorization code flow.\n *\n * @param clientId The OAuth2 client ID\n * @param clientSecret The OAuth2 client secret\n * @param authorizationCode The authorization code from the OAuth2 flow\n * @param redirectUri The redirect URI used in the authorization request\n * @returns A new OAuth2Auth instance with the obtained tokens\n */\n static async fromAuthorizationCode(\n clientId: string,\n clientSecret: string,\n authorizationCode: string,\n redirectUri: string,\n ): Promise<OAuth2Auth> {\n interface TokenResponse {\n access_token: string;\n refresh_token?: string;\n token_type?: string;\n expires_in?: number;\n }\n\n try {\n const response = await axios.post<TokenResponse>(\n OAuth2Auth.TOKEN_ENDPOINT,\n new URLSearchParams({\n grant_type: 'authorization_code',\n code: authorizationCode,\n redirect_uri: redirectUri,\n client_id: clientId,\n client_secret: clientSecret,\n }),\n {\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n },\n );\n\n const accessToken = response.data.access_token;\n const refreshToken = response.data.refresh_token;\n\n if (refreshToken) {\n return new OAuth2Auth(clientId, clientSecret, refreshToken);\n } else {\n return new OAuth2Auth(accessToken);\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`Failed to exchange authorization code: ${message}`);\n }\n }\n\n /**\n * Builds the OAuth2 authorization URL.\n *\n * @param clientId The OAuth2 client ID\n * @param redirectUri The redirect URI for the OAuth2 flow\n * @param state Optional state parameter for CSRF protection\n * @returns The authorization URL\n */\n static buildAuthorizationUrl(clientId: string, redirectUri: string, state?: string): string {\n const params = new URLSearchParams({\n client_id: clientId,\n redirect_uri: redirectUri,\n response_type: 'code',\n });\n\n if (state) {\n params.append('state', state);\n }\n\n return `https://api.timesheet.io/oauth2/auth?${params.toString()}`;\n }\n\n private parseTokenExpiry(): void {\n interface JWTPayload {\n exp?: number;\n iat?: number;\n sub?: string;\n [key: string]: unknown;\n }\n\n try {\n const decoded = jwt.decode(this.accessToken) as JWTPayload | null;\n if (decoded && decoded.exp) {\n this.tokenExpiry = new Date(decoded.exp * 1000);\n }\n } catch {\n // If we can't parse the JWT, assume it expires in 1 hour\n this.tokenExpiry = new Date(Date.now() + 60 * 60 * 1000);\n }\n }\n}\n", "import type { AxiosRequestConfig } from 'axios';\nimport axios from 'axios';\nimport jwt from 'jsonwebtoken';\nimport type { Authentication } from './Authentication';\nimport {\n generatePkceCodePair,\n isValidCodeVerifier,\n type CodeChallengeMethod,\n type PkceCodePair,\n} from './pkce';\n\n/**\n * Options for building an OAuth 2.1 authorization URL.\n */\nexport interface AuthorizationUrlOptions {\n /** The OAuth2 client ID */\n clientId: string;\n /** The redirect URI for the OAuth2 flow */\n redirectUri: string;\n /** The PKCE code challenge (required for OAuth 2.1) */\n codeChallenge: string;\n /** The code challenge method (default: 'S256') */\n codeChallengeMethod?: CodeChallengeMethod;\n /** Optional state parameter for CSRF protection */\n state?: string;\n /** Optional scope parameter */\n scope?: string;\n /** Optional resource indicator (RFC 8707) */\n resource?: string;\n /** Custom authorization endpoint URL (overrides default) */\n authorizationEndpoint?: string;\n}\n\n/**\n * Options for exchanging an authorization code for tokens.\n */\nexport interface TokenExchangeOptions {\n /** The OAuth2 client ID */\n clientId: string;\n /** The OAuth2 client secret (optional for public clients with PKCE) */\n clientSecret?: string;\n /** The authorization code from the OAuth2 flow */\n authorizationCode: string;\n /** The redirect URI used in the authorization request */\n redirectUri: string;\n /** The PKCE code verifier (required for OAuth 2.1) */\n codeVerifier: string;\n /** Optional resource indicator (RFC 8707) */\n resource?: string;\n /** Custom token endpoint URL (overrides default) */\n tokenEndpoint?: string;\n}\n\n/**\n * Options for creating OAuth21Auth with refresh capability.\n */\nexport interface OAuth21RefreshOptions {\n /** The OAuth2 client ID */\n clientId: string;\n /** The OAuth2 client secret (optional for public clients) */\n clientSecret?: string;\n /** The OAuth2 refresh token */\n refreshToken: string;\n /** Optional resource indicator (RFC 8707) */\n resource?: string;\n /** Custom token endpoint URL (overrides default) */\n tokenEndpoint?: string;\n}\n\n/**\n * Token response from the OAuth 2.1 server.\n */\ninterface TokenResponse {\n access_token: string;\n refresh_token?: string;\n token_type?: string;\n expires_in?: number;\n scope?: string;\n}\n\n/**\n * OAuth 2.1 authentication implementation with PKCE support.\n *\n * OAuth 2.1 is the next evolution of OAuth 2.0, consolidating best practices:\n * - PKCE is REQUIRED for all clients (not just public clients)\n * - Implicit flow is removed\n * - Resource Owner Password Credentials grant is removed\n *\n * This implementation supports:\n * - Authorization code flow with PKCE (S256)\n * - Refresh token flow\n * - Automatic token refresh\n * - Resource indicators (RFC 8707)\n *\n * @example\n * ```typescript\n * // Step 1: Generate PKCE parameters\n * import { generatePkceCodePair } from '@timesheet/sdk';\n * const pkce = generatePkceCodePair();\n *\n * // Step 2: Build authorization URL and redirect user\n * const authUrl = OAuth21Auth.buildAuthorizationUrl({\n * clientId: 'your-client-id',\n * redirectUri: 'https://your-app.com/callback',\n * codeChallenge: pkce.codeChallenge,\n * codeChallengeMethod: pkce.codeChallengeMethod,\n * state: 'random-state-for-csrf',\n * });\n *\n * // Step 3: After user authorizes, exchange code for tokens\n * const auth = await OAuth21Auth.fromAuthorizationCode({\n * clientId: 'your-client-id',\n * authorizationCode: codeFromCallback,\n * redirectUri: 'https://your-app.com/callback',\n * codeVerifier: pkce.codeVerifier,\n * });\n *\n * // Step 4: Use with TimesheetClient\n * const client = new TimesheetClient({ auth });\n * ```\n */\nexport class OAuth21Auth implements Authentication {\n private static readonly DEFAULT_TOKEN_ENDPOINT = 'https://api.timesheet.io/oauth2/token';\n private static readonly DEFAULT_AUTH_ENDPOINT = 'https://api.timesheet.io/oauth2/auth';\n\n private accessToken: string;\n private refreshToken?: string;\n private readonly clientId?: string;\n private readonly clientSecret?: string;\n private readonly resource?: string;\n private readonly tokenEndpoint: string;\n private tokenExpiry?: Date;\n private refreshPromise?: Promise<void>;\n\n /**\n * Creates OAuth 2.1 authentication with a simple bearer token.\n * Use this when you already have an access token.\n *\n * @param accessToken The OAuth2 access token\n */\n constructor(accessToken: string);\n\n /**\n * Creates OAuth 2.1 authentication with refresh capability.\n *\n * @param options The refresh options including clientId, refreshToken, etc.\n */\n constructor(options: OAuth21RefreshOptions);\n\n constructor(accessTokenOrOptions: string | OAuth21RefreshOptions) {\n if (typeof accessTokenOrOptions === 'string') {\n // Simple bearer token\n this.accessToken = accessTokenOrOptions;\n this.tokenEndpoint = OAuth21Auth.DEFAULT_TOKEN_ENDPOINT;\n this.parseTokenExpiry();\n } else {\n // OAuth 2.1 with refresh\n const options = accessTokenOrOptions;\n this.clientId = options.clientId;\n this.clientSecret = options.clientSecret;\n this.refreshToken = options.refreshToken;\n this.resource = options.resource;\n this.tokenEndpoint = options.tokenEndpoint ?? OAuth21Auth.DEFAULT_TOKEN_ENDPOINT;\n this.accessToken = ''; // Will be set by refresh()\n }\n }\n\n applyAuth(config: AxiosRequestConfig): void {\n if (!config.headers) {\n config.headers = {};\n }\n config.headers['Authorization'] = `Bearer ${this.accessToken}`;\n }\n\n needsRefresh(): boolean {\n // If we have refresh capabilities but no access token, we need to refresh\n if (this.refreshToken && !this.accessToken) {\n return true;\n }\n\n if (!this.refreshToken || !this.tokenExpiry) {\n return false;\n }\n // Refresh if token expires in less than 5 minutes\n const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);\n return fiveMinutesFromNow >= this.tokenExpiry;\n }\n\n async refresh(): Promise<void> {\n if (!this.refreshToken) {\n throw new Error('Cannot refresh without refresh token');\n }\n\n // Prevent multiple concurrent refresh attempts\n if (this.refreshPromise) {\n return this.refreshPromise;\n }\n\n this.refreshPromise = this.performRefresh();\n try {\n await this.refreshPromise;\n } finally {\n this.refreshPromise = undefined;\n }\n }\n\n private async performRefresh(): Promise<void> {\n try {\n const params = new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: this.refreshToken!,\n client_id: this.clientId!,\n });\n\n // Client secret is optional for public clients using PKCE\n if (this.clientSecret) {\n params.append('client_secret', this.clientSecret);\n }\n\n // Include resource indicator if specified\n if (this.resource) {\n params.append('resource', this.resource);\n }\n\n const response = await axios.post<TokenResponse>(this.tokenEndpoint, params, {\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n });\n\n this.accessToken = response.data.access_token;\n\n if (response.data.refresh_token) {\n this.refreshToken = response.data.refresh_token;\n }\n\n this.parseTokenExpiry();\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`Failed to refresh OAuth 2.1 token: ${message}`);\n }\n }\n\n async getAuthHeaders(): Promise<Record<string, string>> {\n // Ensure we have a valid token before returning headers\n if (this.needsRefresh()) {\n await this.refresh();\n }\n\n return {\n Authorization: `Bearer ${this.accessToken}`,\n };\n }\n\n /**\n * Performs the OAuth 2.1 authorization code flow with PKCE.\n *\n * @param options The token exchange options\n * @returns A new OAuth21Auth instance with the obtained tokens\n * @throws Error if the code verifier is invalid or token exchange fails\n */\n static async fromAuthorizationCode(options: TokenExchangeOptions): Promise<OAuth21Auth> {\n const {\n clientId,\n clientSecret,\n authorizationCode,\n redirectUri,\n codeVerifier,\n resource,\n tokenEndpoint,\n } = options;\n\n const effectiveTokenEndpoint = tokenEndpoint ?? OAuth21Auth.DEFAULT_TOKEN_ENDPOINT;\n\n // Validate code verifier\n if (!isValidCodeVerifier(codeVerifier)) {\n throw new Error(\n 'Invalid code verifier: must be 43-128 characters using only A-Z, a-z, 0-9, -, ., _, ~',\n );\n }\n\n try {\n const params = new URLSearchParams({\n grant_type: 'authorization_code',\n code: authorizationCode,\n redirect_uri: redirectUri,\n client_id: clientId,\n code_verifier: codeVerifier,\n });\n\n // Client secret is optional for public clients using PKCE\n if (clientSecret) {\n params.append('client_secret', clientSecret);\n }\n\n // Include resource indicator if specified\n if (resource) {\n params.append('resource', resource);\n }\n\n const response = await axios.post<TokenResponse>(effectiveTokenEndpoint, params, {\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n });\n\n const accessToken = response.data.access_token;\n const refreshToken = response.data.refresh_token;\n\n if (refreshToken) {\n return new OAuth21Auth({\n clientId,\n clientSecret,\n refreshToken,\n resource,\n tokenEndpoint: effectiveTokenEndpoint,\n });\n } else {\n return new OAuth21Auth(accessToken);\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`Failed to exchange authorization code: ${message}`);\n }\n }\n\n /**\n * Builds the OAuth 2.1 authorization URL with PKCE.\n *\n * @param options The authorization URL options\n * @returns The authorization URL\n */\n static buildAuthorizationUrl(options: AuthorizationUrlOptions): string {\n const {\n clientId,\n redirectUri,\n codeChallenge,\n codeChallengeMethod = 'S256',\n state,\n scope,\n resource,\n authorizationEndpoint,\n } = options;\n\n const effectiveAuthEndpoint = authorizationEndpoint ?? OAuth21Auth.DEFAULT_AUTH_ENDPOINT;\n\n const params = new URLSearchParams({\n response_type: 'code',\n client_id: clientId,\n redirect_uri: redirectUri,\n code_challenge: codeChallenge,\n code_challenge_method: codeChallengeMethod,\n });\n\n if (state) {\n params.append('state', state);\n }\n\n if (scope) {\n params.append('scope', scope);\n }\n\n if (resource) {\n params.append('resource', resource);\n }\n\n return `${effectiveAuthEndpoint}?${params.toString()}`;\n }\n\n /**\n * Generates a complete PKCE code pair for use in OAuth 2.1 flow.\n *\n * This is a convenience method that wraps the generatePkceCodePair function.\n *\n * @param method The challenge method ('S256' or 'plain', default: 'S256')\n * @returns A PKCE code pair with verifier, challenge, and method\n *\n * @example\n * ```typescript\n * const pkce = OAuth21Auth.generatePkce();\n *\n * // Store verifier securely (e.g., in session)\n * sessionStorage.setItem('pkce_verifier', pkce.codeVerifier);\n *\n * // Use challenge in authorization URL\n * const authUrl = OAuth21Auth.buildAuthorizationUrl({\n * clientId: 'your-client-id',\n * redirectUri: 'https://your-app.com/callback',\n * codeChallenge: pkce.codeChallenge,\n * codeChallengeMethod: pkce.codeChallengeMethod,\n * });\n * ```\n */\n static generatePkce(method: CodeChallengeMethod = 'S256'): PkceCodePair {\n return generatePkceCodePair(method);\n }\n\n private parseTokenExpiry(): void {\n interface JWTPayload {\n exp?: number;\n iat?: number;\n sub?: string;\n [key: string]: unknown;\n }\n\n try {\n const decoded = jwt.decode(this.accessToken) as JWTPayload | null;\n if (decoded && decoded.exp) {\n this.tokenExpiry = new Date(decoded.exp * 1000);\n }\n } catch {\n // If we can't parse the JWT, assume it expires in 1 hour\n this.tokenExpiry = new Date(Date.now() + 60 * 60 * 1000);\n }\n }\n}\n", "import { randomBytes, createHash } from 'crypto';\n\n/**\n * PKCE (Proof Key for Code Exchange) utilities for OAuth 2.1.\n *\n * Implements RFC 7636 with S256 challenge method as required by OAuth 2.1.\n */\n\n/**\n * Supported PKCE code challenge methods.\n * OAuth 2.1 requires S256 - plain is only for legacy compatibility.\n */\nexport type CodeChallengeMethod = 'S256' | 'plain';\n\n/**\n * PKCE code pair containing verifier and challenge.\n */\nexport interface PkceCodePair {\n /** The code verifier - a cryptographically random string (43-128 chars) */\n codeVerifier: string;\n /** The code challenge - derived from the verifier using the challenge method */\n codeChallenge: string;\n /** The challenge method used (S256 or plain) */\n codeChallengeMethod: CodeChallengeMethod;\n}\n\n/**\n * Generates a cryptographically random code verifier.\n *\n * The verifier is a high-entropy random string between 43-128 characters\n * using unreserved URI characters (A-Z, a-z, 0-9, -, ., _, ~).\n *\n * @param length The length of the verifier (default: 64, min: 43, max: 128)\n * @returns A random code verifier string\n */\nexport function generateCodeVerifier(length: number = 64): string {\n if (length < 43 || length > 128) {\n throw new Error('Code verifier length must be between 43 and 128 characters');\n }\n\n // Generate random bytes and encode as base64url\n const buffer = randomBytes(Math.ceil((length * 3) / 4));\n return base64UrlEncode(buffer).slice(0, length);\n}\n\n/**\n * Generates a code challenge from a code verifier.\n *\n * For S256 (recommended by OAuth 2.1):\n * code_challenge = BASE64URL(SHA256(code_verifier))\n *\n * For plain (legacy, not recommended):\n * code_challenge = code_verifier\n *\n * @param codeVerifier The code verifier string\n * @param method The challenge method ('S256' or 'plain', default: 'S256')\n * @returns The code challenge string\n */\nexport function generateCodeChallenge(\n codeVerifier: string,\n method: CodeChallengeMethod = 'S256',\n): string {\n if (method === 'plain') {\n return codeVerifier;\n }\n\n // S256: BASE64URL(SHA256(ASCII(code_verifier)))\n const hash = createHash('sha256').update(codeVerifier, 'ascii').digest();\n return base64UrlEncode(hash);\n}\n\n/**\n * Generates a complete PKCE code pair (verifier and challenge).\n *\n * This is the recommended way to generate PKCE parameters for OAuth 2.1.\n *\n * @param method The challenge method ('S256' or 'plain', default: 'S256')\n * @param verifierLength The length of the verifier (default: 64)\n * @returns A complete PKCE code pair\n *\n * @example\n * ```typescript\n * const pkce = generatePkceCodePair();\n *\n * // Use codeChallenge in authorization request\n * const authUrl = OAuth21Auth.buildAuthorizationUrl({\n * clientId: 'your-client-id',\n * redirectUri: 'https://your-app.com/callback',\n * codeChallenge: pkce.codeChallenge,\n * codeChallengeMethod: pkce.codeChallengeMethod,\n * });\n *\n * // Later, use codeVerifier in token exchange\n * const auth = await OAuth21Auth.fromAuthorizationCode({\n * clientId: 'your-client-id',\n * authorizationCode: code,\n * redirectUri: 'https://your-app.com/callback',\n * codeVerifier: pkce.codeVerifier,\n * });\n * ```\n */\nexport function generatePkceCodePair(\n method: CodeChallengeMethod = 'S256',\n verifierLength: number = 64,\n): PkceCodePair {\n const codeVerifier = generateCodeVerifier(verifierLength);\n const codeChallenge = generateCodeChallenge(codeVerifier, method);\n\n return {\n codeVerifier,\n codeChallenge,\n codeChallengeMethod: method,\n };\n}\n\n/**\n * Validates a code verifier format.\n *\n * @param codeVerifier The code verifier to validate\n * @returns True if valid, false otherwise\n */\nexport function isValidCodeVerifier(codeVerifier: string): boolean {\n if (codeVerifier.length < 43 || codeVerifier.length > 128) {\n return false;\n }\n\n // Must only contain unreserved characters: A-Z, a-z, 0-9, -, ., _, ~\n const validChars = /^[A-Za-z0-9\\-._~]+$/;\n return validChars.test(codeVerifier);\n}\n\n/**\n * Base64URL encodes a buffer (RFC 4648 Section 5).\n *\n * @param buffer The buffer to encode\n * @returns Base64URL encoded string (no padding)\n */\nfunction base64UrlEncode(buffer: Buffer): string {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n", "import axios from 'axios';\nimport type {\n AuthorizationServerMetadata,\n OpenIdConfiguration,\n ProtectedResourceMetadata,\n OAuthDiscoveryResult,\n} from './OAuthMetadata';\n\n/**\n * Options for OAuth discovery\n */\nexport interface OAuthDiscoveryOptions {\n /** Cache TTL in milliseconds (default: 1 hour) */\n cacheTtl?: number;\n\n /** Request timeout in milliseconds (default: 10000) */\n timeout?: number;\n\n /** Whether to also fetch OpenID configuration (default: false) */\n fetchOpenIdConfig?: boolean;\n\n /** Whether to also fetch protected resource metadata (default: false) */\n fetchProtectedResource?: boolean;\n}\n\n/**\n * Cached metadata entry\n */\ninterface CacheEntry {\n result: OAuthDiscoveryResult;\n expiresAt: Date;\n}\n\n/**\n * OAuth 2.1 Discovery Service\n *\n * Implements RFC 8414 (OAuth 2.0 Authorization Server Metadata) for automatic\n * discovery of OAuth endpoints from well-known URLs.\n *\n * Supports:\n * - /.well-known/oauth-authorization-server (RFC 8414)\n * - /.well-known/oauth-protected-resource (RFC 9728)\n * - /.well-known/openid-configuration (OpenID Connect Discovery)\n *\n * @example\n * ```typescript\n * // Discover OAuth endpoints from issuer URL\n * const discovery = new OAuthDiscovery();\n * const metadata = await discovery.discover('https://api.timesheet.io');\n *\n * console.log(metadata.authorizationServer.authorization_endpoint);\n * console.log(metadata.authorizationServer.token_endpoint);\n * ```\n */\nexport class OAuthDiscovery {\n private static readonly DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour\n private static readonly DEFAULT_TIMEOUT = 10000; // 10 seconds\n\n private readonly cache: Map<string, CacheEntry> = new Map();\n private readonly options: Required<OAuthDiscoveryOptions>;\n\n constructor(options: OAuthDiscoveryOptions = {}) {\n this.options = {\n cacheTtl: options.cacheTtl ?? OAuthDiscovery.DEFAULT_CACHE_TTL,\n timeout: options.timeout ?? OAuthDiscovery.DEFAULT_TIMEOUT,\n fetchOpenIdConfig: options.fetchOpenIdConfig ?? false,\n fetchProtectedResource: options.fetchProtectedResource ?? false,\n };\n }\n\n /**\n * Discover OAuth metadata from an issuer URL.\n *\n * @param issuerUrl The OAuth authorization server issuer URL\n * @returns Discovery result containing all fetched metadata\n * @throws Error if discovery fails\n *\n * @example\n * ```typescript\n * const discovery = new OAuthDiscovery();\n * const result = await discovery.discover('https://api.timesheet.io');\n *\n * // Use discovered endpoints\n * const authUrl = result.authorizationServer.authorization_endpoint;\n * const tokenUrl = result.authorizationServer.token_endpoint;\n * ```\n */\n async discover(issuerUrl: string): Promise<OAuthDiscoveryResult> {\n const normalizedUrl = this.normalizeIssuerUrl(issuerUrl);\n\n // Check cache first\n const cached = this.getCached(normalizedUrl);\n if (cached) {\n return cached;\n }\n\n // Fetch authorization server metadata (required)\n const authServerMetadata = await this.fetchAuthorizationServerMetadata(normalizedUrl);\n\n const result: OAuthDiscoveryResult = {\n issuer: authServerMetadata.issuer,\n authorizationServer: authServerMetadata,\n fetchedAt: new Date(),\n };\n\n // Optionally fetch OpenID configuration\n if (this.options.fetchOpenIdConfig) {\n try {\n result.openIdConfiguration = await this.fetchOpenIdConfiguration(normalizedUrl);\n } catch {\n // OpenID configuration is optional, continue without it\n }\n }\n\n // Optionally fetch protected resource metadata\n if (this.options.fetchProtectedResource) {\n try {\n result.protectedResource = await this.fetchProtectedResourceMetadata(normalizedUrl);\n } catch {\n // Protected resource metadata is optional, continue without it\n }\n }\n\n // Cache the result\n this.setCache(normalizedUrl, result);\n\n return result;\n }\n\n /**\n * Fetch Authorization Server Metadata (RFC 8414)\n *\n * @param issuerUrl The OAuth authorization server issuer URL\n * @returns Authorization server metadata\n */\n async fetchAuthorizationServerMetadata(issuerUrl: string): Promise<AuthorizationServerMetadata> {\n const url = `${this.normalizeIssuerUrl(issuerUrl)}/.well-known/oauth-authorization-server`;\n\n try {\n const response = await axios.get<AuthorizationServerMetadata>(url, {\n timeout: this.options.timeout,\n headers: {\n Accept: 'application/json',\n },\n });\n\n this.validateAuthorizationServerMetadata(response.data);\n return response.data;\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`Failed to fetch authorization server metadata from ${url}: ${message}`);\n }\n }\n\n /**\n * Fetch OpenID Connect Discovery Configuration\n *\n * @param issuerUrl The OpenID Provider issuer URL\n * @returns OpenID Connect configuration\n */\n async fetchOpenIdConfiguration(issuerUrl: string): Promise<OpenIdConfiguration> {\n const url = `${this.normalizeIssuerUrl(issuerUrl)}/.well-known/openid-configuration`;\n\n try {\n const response = await axios.get<OpenIdConfiguration>(url, {\n timeout: this.options.timeout,\n headers: {\n Accept: 'application/json',\n },\n });\n\n this.validateOpenIdConfiguration(response.data);\n return response.data;\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`Failed to fetch OpenID configuration from ${url}: ${message}`);\n }\n }\n\n /**\n * Fetch Protected Resource Metadata (RFC 9728)\n *\n * @param resourceUrl The protected resource URL\n * @returns Protected resource metadata\n */\n async fetchProtectedResourceMetadata(resourceUrl: string): Promise<ProtectedResourceMetadata> {\n const url = `${this.normalizeIssuerUrl(resourceUrl)}/.well-known/oauth-protected-resource`;\n\n try {\n const response = await axios.get<ProtectedResourceMetadata>(url, {\n timeout: this.options.timeout,\n headers: {\n Accept: 'application/json',\n },\n });\n\n this.validateProtectedResourceMetadata(response.data);\n return response.data;\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`Failed to fetch protected resource metadata from ${url}: ${message}`);\n }\n }\n\n /**\n * Clear the metadata cache\n */\n clearCache(): void {\n this.cache.clear();\n }\n\n /**\n * Clear a specific issuer from the cache\n */\n clearCacheForIssuer(issuerUrl: string): void {\n this.cache.delete(this.normalizeIssuerUrl(issuerUrl));\n }\n\n /**\n * Check if a specific issuer's metadata is cached\n */\n isCached(issuerUrl: string): boolean {\n const cached = this.getCached(this.normalizeIssuerUrl(issuerUrl));\n return cached !== null;\n }\n\n private normalizeIssuerUrl(url: string): string {\n // Remove trailing slash\n return url.replace(/\\/+$/, '');\n }\n\n private getCached(issuerUrl: string): OAuthDiscoveryResult | null {\n const entry = this.cache.get(issuerUrl);\n if (!entry) {\n return null;\n }\n\n if (new Date() >= entry.expiresAt) {\n this.cache.delete(issuerUrl);\n return null;\n }\n\n return entry.result;\n }\n\n private setCache(issuerUrl: string, result: OAuthDiscoveryResult): void {\n const expiresAt = new Date(Date.now() + this.options.cacheTtl);\n this.cache.set(issuerUrl, { result, expiresAt });\n }\n\n private validateAuthorizationServerMetadata(metadata: AuthorizationServerMetadata): void {\n if (!metadata.issuer) {\n throw new Error('Authorization server metadata missing required field: issuer');\n }\n if (!metadata.authorization_endpoint) {\n throw new Error(\n 'Authorization server metadata missing required field: authorization_endpoint',\n );\n }\n if (!metadata.token_endpoint) {\n throw new Error('Authorization server metadata missing required field: token_endpoint');\n }\n if (!metadata.response_types_supported || metadata.response_types_supported.length === 0) {\n throw new Error(\n 'Authorization server metadata missing required field: response_types_supported',\n );\n }\n }\n\n private validateOpenIdConfiguration(config: OpenIdConfiguration): void {\n if (!config.issuer) {\n throw new Error('OpenID configuration missing required field: issuer');\n }\n if (!config.authorization_endpoint) {\n throw new Error('OpenID configuration missing required field: authorization_endpoint');\n }\n if (!config.token_endpoint) {\n throw new Error('OpenID configuration missing required field: token_endpoint');\n }\n if (!config.jwks_uri) {\n throw new Error('OpenID configuration missing required field: jwks_uri');\n }\n }\n\n private validateProtectedResourceMetadata(metadata: ProtectedResourceMetadata): void {\n if (!metadata.resource) {\n throw new Error('Protected resource metadata missing required field: resource');\n }\n if (!metadata.authorization_servers || metadata.authorization_servers.length === 0) {\n throw new Error('Protected resource metadata missing required field: authorization_servers');\n }\n }\n}\n\n/**\n * Singleton instance for convenience\n */\nlet defaultDiscovery: OAuthDiscovery | null = null;\n\n/**\n * Get the default OAuthDiscovery instance\n */\nexport function getDefaultDiscovery(): OAuthDiscovery {\n if (!defaultDiscovery) {\n defaultDiscovery = new OAuthDiscovery();\n }\n return defaultDiscovery;\n}\n\n/**\n * Convenience function to discover OAuth metadata\n *\n * @param issuerUrl The OAuth authorization server issuer URL\n * @param options Discovery options\n * @returns Discovery result\n *\n * @example\n * ```typescript\n * import { discoverOAuth } from '@timesheet/sdk';\n *\n * const metadata = await discoverOAuth('https://api.timesheet.io');\n * console.log(metadata.authorizationServer.token_endpoint);\n * ```\n */\nexport async function discoverOAuth(\n issuerUrl: string,\n options?: OAuthDiscoveryOptions,\n): Promise<OAuthDiscoveryResult> {\n const discovery = options ? new OAuthDiscovery(options) : getDefaultDiscovery();\n return discovery.discover(issuerUrl);\n}\n", "/**\n * Configuration for automatic retry behavior.\n */\nexport class RetryConfig {\n public readonly maxRetries: number;\n public readonly initialDelay: number;\n public readonly maxDelay: number;\n public readonly backoffMultiplier: number;\n public readonly retryableStatusCodes: number[];\n\n constructor(options: Partial<RetryConfigOptions> = {}) {\n this.maxRetries = options.maxRetries ?? 3;\n this.initialDelay = options.initialDelay ?? 100;\n this.maxDelay = options.maxDelay ?? 10000;\n this.backoffMultiplier = options.backoffMultiplier ?? 2.0;\n this.retryableStatusCodes = options.retryableStatusCodes ?? [429, 502, 503, 504];\n }\n\n /**\n * Returns the default retry configuration.\n */\n static default(): RetryConfig {\n return new RetryConfig();\n }\n}\n\n/**\n * Options for RetryConfig constructor.\n */\nexport interface RetryConfigOptions {\n /**\n * Maximum number of retry attempts.\n * @default 3\n */\n maxRetries: number;\n\n /**\n * Initial delay before the first retry in milliseconds.\n * @default 100\n */\n initialDelay: number;\n\n /**\n * Maximum delay between retries in milliseconds.\n * @default 10000\n */\n maxDelay: number;\n\n /**\n * Backoff multiplier for exponential backoff.\n * @default 2.0\n */\n backoffMultiplier: number;\n\n /**\n * HTTP status codes that should trigger a retry.\n * @default [429, 502, 503, 504]\n */\n retryableStatusCodes: number[];\n}\n", "import type { ListParams } from './common';\n\n/**\n * Represents a page of results with pagination support.\n */\nexport interface Page<T> {\n /**\n * The items in this page.\n */\n items: T[];\n\n /**\n * Pagination and sorting parameters.\n */\n params: ListParams;\n}\n\n/**\n * Extended page with navigation methods.\n */\nexport class NavigablePage<T> implements Page<T> {\n items: T[];\n params: ListParams;\n\n private readonly nextPageLoader?: (page: number) => Promise<NavigablePage<T>>;\n\n constructor(data: Page<T>, nextPageLoader?: (page: number) => Promise<NavigablePage<T>>) {\n this.items = data.items;\n this.params = data.params;\n this.nextPageLoader = nextPageLoader;\n }\n\n /**\n * Gets the total number of pages.\n */\n get totalPages(): number {\n return Math.ceil((this.params?.count || 0) / (this.params?.limit || 25));\n }\n\n /**\n * Checks if