Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,14 @@ Before committing or pushing, run these checks:

## Related Repos

| Repo | Purpose | Docs |
| ----------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ |
| `canton` | Trading infrastructure, ADRs | `AGENTS.md` |
| `canton-explorer` | Next.js explorer UI | `AGENTS.md`, [cantonops.fairmint.com](https://cantonops.fairmint.com/) |
| `canton-fairmint-sdk` | Shared TypeScript utilities | `AGENTS.md` |
| `ocp-canton-sdk` | High-level OCP TypeScript SDK | `AGENTS.md`, [ocp.canton.fairmint.com](https://ocp.canton.fairmint.com/) |
| `ocp-equity-certificate` | Soulbound equity certificate smart contracts | `AGENTS.md` |
| `open-captable-protocol-daml` | DAML contracts (OCF impl) | `AGENTS.md` |
| Repo | Purpose | Docs |
| ----------------------------- | -------------------------------------------- | ------------------------------------------------------------------------ |
| `canton` | Trading infrastructure, ADRs | `AGENTS.md` |
| `canton-explorer` | Next.js explorer UI | `AGENTS.md`, [cantonops.fairmint.com](https://cantonops.fairmint.com/) |
| `canton-fairmint-sdk` | Shared TypeScript utilities | `AGENTS.md` |
| `ocp-canton-sdk` | High-level OCP TypeScript SDK | `AGENTS.md`, [ocp.canton.fairmint.com](https://ocp.canton.fairmint.com/) |
| `ocp-equity-certificate` | Soulbound equity certificate smart contracts | `AGENTS.md` |
| `open-captable-protocol-daml` | DAML contracts (OCF impl) | `AGENTS.md` |

## Docs

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
},
"dependencies": {
"@canton-network/wallet-sdk": "0.21.0",
"@hardlydifficult/rest-client": "1.0.0",
"@hardlydifficult/websocket": "1.0.2",
"@stellar/stellar-base": "14.0.4",
"axios": "1.13.5",
"dotenv": "17.2.4",
Expand Down
241 changes: 64 additions & 177 deletions src/core/auth/AuthenticationManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import axios from 'axios';
import { URLSearchParams } from 'url';
import { ApiError, AuthenticationError } from '../errors';
import {
AuthenticationManager as RestClientAuthManager,
type AuthConfig as RestClientAuthConfig,
type RestClientLogger,
} from '@hardlydifficult/rest-client';
import { type Logger } from '../logging';
import { type AuthConfig } from '../types';

Expand All @@ -11,205 +13,90 @@ export interface AuthResponse {
readonly scope?: string;
}

/** Manages OAuth2 authentication and token lifecycle */
/**
* Manages OAuth2 authentication and token lifecycle. Delegates to `@hardlydifficult/rest-client`'s
* AuthenticationManager internally.
*/
export class AuthenticationManager {
private bearerToken: string | null = null;
private tokenExpiry: number | null = null;
private tokenIssuedAt: number | null = null;
private readonly delegate: RestClientAuthManager;

constructor(
private readonly authUrl: string,
private readonly authConfig: AuthConfig,
private readonly logger?: Logger
) {}
constructor(authUrl: string, authConfig: AuthConfig, logger?: Logger) {
const restAuthConfig = convertAuthConfig(authUrl, authConfig);
this.delegate = new RestClientAuthManager(restAuthConfig, toRestLogger(logger));
}

public async authenticate(): Promise<string> {
// Check if we have a valid token
if (this.isTokenValid() && this.bearerToken) {
return this.bearerToken;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth errors no longer match Canton error hierarchy

High Severity

authenticate() delegates directly to this.delegate.authenticate() without wrapping errors. The rest-client's AuthenticationManager throws RestClientError (not AuthenticationError or ApiError). Since CantonError extends RestClientError (not the reverse), these errors fail instanceof CantonError, instanceof AuthenticationError, and instanceof ApiError checks. The documented error-handling pattern in docs/features.md (error instanceof AuthenticationError) will silently miss authentication failures, contradicting the PR's "no breaking changes" claim.

Fix in Cursor Fix in Web


// Check for static bearer token first
if (this.authConfig.bearerToken) {
this.bearerToken = this.authConfig.bearerToken;
return this.bearerToken;
}

// Check for token generator function
if (this.authConfig.tokenGenerator) {
this.bearerToken = await this.authConfig.tokenGenerator();
// Tokens from generator may have short expiry, so don't cache for long
this.tokenIssuedAt = Date.now();
this.tokenExpiry = this.tokenIssuedAt + 60 * 1000; // 1 minute cache
return this.bearerToken;
}

// Check if OAuth2 authentication credentials are provided
if (!this.authConfig.clientId || this.authConfig.clientId.trim() === '') {
// No authentication credentials provided, skip authentication
this.bearerToken = null;
return '';
}

// Validate required auth configuration for OAuth2
this.validateAuthConfig();

const formData = new URLSearchParams();
formData.append('grant_type', this.authConfig.grantType);
formData.append('client_id', this.authConfig.clientId);

if (this.authConfig.grantType === 'client_credentials' && this.authConfig.clientSecret) {
formData.append('client_secret', this.authConfig.clientSecret);
}
if (this.authConfig.audience) {
formData.append('audience', this.authConfig.audience);
}
if (this.authConfig.scope) {
formData.append('scope', this.authConfig.scope);
}
if (this.authConfig.grantType === 'password') {
formData.append('username', this.authConfig.username);
formData.append('password', this.authConfig.password);
}

const url = `${this.authUrl}/`;
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };

// Build a log-friendly representation of the request body
const requestBody: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
requestBody[key] = value;
}

try {
const response = await axios.post<AuthResponse>(url, formData.toString(), {
headers,
});

if (!response.data.access_token) {
throw new AuthenticationError(
`Authentication response missing access_token. Response: ${JSON.stringify(response.data, null, 2)}`
);
}

this.bearerToken = response.data.access_token;
this.tokenIssuedAt = Date.now();

// Set token expiry if provided
if (response.data.expires_in) {
this.tokenExpiry = this.tokenIssuedAt + response.data.expires_in * 1000;
}

// Log success
if (this.logger) {
await this.logger.logRequestResponse(url, { method: 'POST', headers, data: requestBody }, response.data);
}

return this.bearerToken;
} catch (error) {
// Log failure with context
if (this.logger) {
const errorPayload: unknown = axios.isAxiosError(error)
? (error.response?.data ?? error.message)
: error instanceof Error
? error.message
: String(error);
await this.logger.logRequestResponse(url, { method: 'POST', headers, data: requestBody }, errorPayload);
}

if (axios.isAxiosError(error)) {
const status = error.response?.status;
const statusText = error.response?.statusText;
const errorData = error.response?.data ? JSON.stringify(error.response.data, null, 2) : error.message;

throw new ApiError(
`Authentication failed for URL ${url} with status ${status} ${statusText}: ${errorData}`,
status,
statusText
);
}

throw new AuthenticationError(
`Authentication failed for URL ${url}: ${error instanceof Error ? error.message : String(error)}`
);
}
return this.delegate.authenticate();
}

public async getBearerToken(): Promise<string> {
return this.authenticate();
}

public clearToken(): void {
this.bearerToken = null;
this.tokenExpiry = null;
this.tokenIssuedAt = null;
this.delegate.clearToken();
}

/**
* Returns the token expiry timestamp in milliseconds since epoch, or null if not available. Use this to schedule
* proactive token refresh before expiration.
*/
public getTokenExpiryTime(): number | null {
return this.tokenExpiry;
return this.delegate.getTokenExpiryTime();
}

/** Returns the timestamp when the current token was issued, in milliseconds since epoch, or null if not available. */
public getTokenIssuedAt(): number | null {
return this.tokenIssuedAt;
return this.delegate.getTokenIssuedAt();
}

/**
* Returns the token lifetime in milliseconds, or null if not available. Calculated as the difference between token
* expiry time and issue time.
*/
public getTokenLifetimeMs(): number | null {
if (this.tokenIssuedAt === null || this.tokenExpiry === null) {
return null;
}
return this.tokenExpiry - this.tokenIssuedAt;
return this.delegate.getTokenLifetimeMs();
}
}

private validateAuthConfig(): void {
// Runtime validation for grantType (TypeScript only provides compile-time checks)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for external config sources
if (!this.authConfig.grantType) {
throw new AuthenticationError(`Authentication configuration incomplete. Missing required field: grantType.`);
}
if (!this.authConfig.clientId) {
throw new AuthenticationError(
`Authentication configuration incomplete. Missing required field: clientId. ` +
`Grant Type: ${this.authConfig.grantType}`
);
}
// Runtime validation for password grant type (TypeScript only provides compile-time checks)
if (this.authConfig.grantType === 'password') {
const missingFields: string[] = [];
if (!this.authConfig.username) {
missingFields.push('username');
}
if (!this.authConfig.password) {
missingFields.push('password');
}
if (missingFields.length > 0) {
throw new AuthenticationError(
`Authentication configuration incomplete for password grant type. Missing required field(s): ${missingFields.join(', ')}.`
);
}
}
/** Converts Canton's auth config format to rest-client's auth config format. */
function convertAuthConfig(authUrl: string, config: AuthConfig): RestClientAuthConfig {
if (config.bearerToken) {
return { type: 'bearer', token: config.bearerToken };
}

private isTokenValid(): boolean {
if (!this.bearerToken) {
return false;
}
if (config.tokenGenerator) {
return { type: 'generator', generate: config.tokenGenerator };
}

// If no expiry is set, assume token is valid
if (!this.tokenExpiry) {
return true;
}
if (!config.clientId || config.clientId.trim() === '') {
return { type: 'none' };
}

// Check if token has expired (with 5 minute buffer)
const bufferTime = 5 * 60 * 1000; // 5 minutes
return Date.now() < this.tokenExpiry - bufferTime;
const tokenUrl = authUrl.endsWith('/') ? authUrl : `${authUrl}/`;

if (config.grantType === 'password') {
return {
type: 'oauth2' as const,
tokenUrl,
clientId: config.clientId,
grantType: 'password' as const,
username: config.username,
password: config.password,
...(config.audience ? { audience: config.audience } : {}),
...(config.scope ? { scope: config.scope } : {}),
};
}

return {
type: 'oauth2' as const,
tokenUrl,
clientId: config.clientId,
grantType: 'client_credentials' as const,
...(config.clientSecret ? { clientSecret: config.clientSecret } : {}),
...(config.audience ? { audience: config.audience } : {}),
...(config.scope ? { scope: config.scope } : {}),
};
}

/** Adapts Canton's Logger to rest-client's RestClientLogger. */
function toRestLogger(logger: Logger | undefined): RestClientLogger | undefined {
if (!logger) return undefined;
const adapted: RestClientLogger = {};
if (logger.debug) adapted.debug = logger.debug.bind(logger);
if (logger.info) adapted.info = logger.info.bind(logger);
if (logger.warn) adapted.warn = logger.warn.bind(logger);
if (logger.error) adapted.error = logger.error.bind(logger);
return adapted;
}
19 changes: 9 additions & 10 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { RestClientError } from '@hardlydifficult/rest-client';

/** JSON-serializable context for error details. */
export type ErrorContext = Readonly<Record<string, unknown>>;

Expand All @@ -13,19 +15,16 @@ export const ErrorCode = {

export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];

/** Base error class for all Canton SDK errors. */
export class CantonError extends Error {
/**
* Base error class for all Canton SDK errors. Extends `@hardlydifficult/rest-client`'s `RestClientError` so errors are
* compatible with both hierarchies.
*/
export class CantonError extends RestClientError {
public override readonly name: string;

constructor(
message: string,
public readonly code: string,
public readonly context?: ErrorContext
) {
super(message);
constructor(message: string, code: string, context?: ErrorContext) {
super(message, code, context);
this.name = 'CantonError';
// Maintains proper prototype chain for ES5
Object.setPrototypeOf(this, new.target.prototype);
}
}

Expand Down
28 changes: 3 additions & 25 deletions src/core/ws/WebSocketClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { calculateTokenRefreshTime } from '@hardlydifficult/websocket';
import WebSocket, { type RawData } from 'ws';
import { type BaseClient } from '../BaseClient';
import { WebSocketErrorUtils } from './WebSocketErrorUtils';
Expand Down Expand Up @@ -43,31 +44,8 @@ export interface WebSocketOptions {
readonly onTokenRefreshNeeded?: () => { code?: number; reason?: string } | void;
}

/**
* Calculate when to schedule token refresh. Uses the later of:
*
* - 50% of token lifetime (protects short-lived tokens)
* - 2 minutes before expiry (ensures adequate buffer for longer tokens)
*
* Examples:
*
* - 60-second token: refresh at 30s (50% rule wins)
* - 5-minute token: refresh at 3min (2-min buffer wins)
* - 1-hour token: refresh at 58min (2-min buffer wins)
*
* @param tokenIssuedAt - Timestamp when token was issued (ms since epoch)
* @param tokenExpiresAt - Timestamp when token expires (ms since epoch)
* @returns Timestamp when refresh should be scheduled (ms since epoch)
*/
export function calculateTokenRefreshTime(tokenIssuedAt: number, tokenExpiresAt: number): number {
const lifetimeMs = tokenExpiresAt - tokenIssuedAt;
const twoMinutesMs = 2 * 60 * 1000;

const halfLifetime = tokenIssuedAt + Math.floor(lifetimeMs / 2);
const twoMinutesBefore = tokenExpiresAt - twoMinutesMs;

return Math.max(halfLifetime, twoMinutesBefore);
}
// calculateTokenRefreshTime is imported from @hardlydifficult/websocket
export { calculateTokenRefreshTime } from '@hardlydifficult/websocket';

/**
* Minimal WebSocket helper that:
Expand Down