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
87 changes: 87 additions & 0 deletions packages/bitbucket/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { request } from 'corsair/http';
import {
BitbucketAPIError,
getValidBitbucketAccessToken,
makeAuthenticatedBitbucketRequest,
refreshBitbucketAccessToken,
} from './client';

jest.mock('corsair/http', () => {
const actual = jest.requireActual('corsair/http');
return { ...actual, request: jest.fn() };
});
const mockRequest = request as jest.MockedFunction<typeof request>;
describe('Bitbucket OAuth client', () => {
beforeEach(() => mockRequest.mockReset());
it('reuses an access token outside the expiry skew', async () => {
const result = await getValidBitbucketAccessToken({
accessToken: 'access',
expiresAt: String(Math.floor(Date.now() / 1000) + 1800),
});
expect(result).toMatchObject({ accessToken: 'access', refreshed: false });
expect(mockRequest).not.toHaveBeenCalled();
});
it('refreshes with HTTP Basic client authentication and rotates the refresh token', async () => {
mockRequest.mockResolvedValueOnce({
access_token: 'fresh',
refresh_token: 'rotated',
expires_in: 3600,
});
const result = await refreshBitbucketAccessToken(
'client',
'secret',
'refresh',
);
expect(result.refresh_token).toBe('rotated');
const [config, options] = mockRequest.mock.calls[0] ?? [];
const headers = config?.HEADERS as Record<string, string> | undefined;
expect(headers?.Authorization).toBe(
'Basic ' + Buffer.from('client:secret').toString('base64'),
);
expect(options?.body).toContain('grant_type=refresh_token');
expect(options?.body).toContain('refresh_token=refresh');
});
it('retries once with a refreshed token after a 401', async () => {
const refresh = jest.fn().mockResolvedValue('fresh');
mockRequest
.mockRejectedValueOnce(new BitbucketAPIError('unauthorized', 401))
.mockResolvedValueOnce({ ok: true });
const result = await makeAuthenticatedBitbucketRequest(
'/user',
{ key: 'stale', _refreshAuth: refresh },
{ method: 'GET', retrySafe: true },
);
expect(result).toEqual({ ok: true });
expect(refresh).toHaveBeenCalledTimes(1);
});
it('propagates a second 401 after a single refresh and retry', async () => {
const refresh = jest.fn().mockResolvedValue('fresh');
mockRequest
.mockRejectedValueOnce(new BitbucketAPIError('unauthorized', 401))
.mockRejectedValueOnce(new BitbucketAPIError('unauthorized', 401));
await expect(
makeAuthenticatedBitbucketRequest(
'/user',
{ key: 'stale', _refreshAuth: refresh },
{ method: 'GET', retrySafe: true },
),
).rejects.toThrow('unauthorized');
expect(refresh).toHaveBeenCalledTimes(1);
expect(mockRequest).toHaveBeenCalledTimes(2);
});
it('propagates a 500 without refreshing the token', async () => {
const refresh = jest.fn().mockResolvedValue('fresh');
mockRequest.mockRejectedValueOnce(
new BitbucketAPIError('server error', 500),
);
await expect(
makeAuthenticatedBitbucketRequest(
'/user',
{ key: 'stale', _refreshAuth: refresh },
{ method: 'GET', retrySafe: true },
),
).rejects.toThrow('server error');
expect(refresh).not.toHaveBeenCalled();
expect(mockRequest).toHaveBeenCalledTimes(1);
});
});
220 changes: 220 additions & 0 deletions packages/bitbucket/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import type {
ApiRequestOptions,
OpenAPIConfig,
RateLimitConfig,
} from 'corsair/http';
import { ApiError, request } from 'corsair/http';

export const BITBUCKET_API_BASE = 'https://api.bitbucket.org/2.0';
export const BITBUCKET_AUTH_URL = 'https://bitbucket.org/site/oauth2/authorize';
export const BITBUCKET_TOKEN_URL =
'https://bitbucket.org/site/oauth2/access_token';

function rateLimitConfig(retrySafe: boolean): RateLimitConfig {
return {
enabled: true,
maxRetries: retrySafe ? 3 : 0,
initialRetryDelay: 1000,
backoffMultiplier: 2,
headerNames: {
retryAfter: 'Retry-After',
resetTime: 'X-RateLimit-Reset',
remaining: 'X-RateLimit-Remaining',
limit: 'X-RateLimit-Limit',
},
};
}
export class BitbucketOAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'BitbucketOAuthError';
}
}
export class BitbucketAPIError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly retryAfter?: number,
) {
super(message);
this.name = 'BitbucketAPIError';
}
}
export class BitbucketSchemaError extends Error {
constructor(
message: string,
public readonly direction: 'input' | 'output',
public readonly issues: { path: string; message: string }[] = [],
) {
super(message);
this.name = 'BitbucketSchemaError';
}
}

export type BitbucketTokenResult = {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
scopes?: string;
};
export async function refreshBitbucketAccessToken(
clientId: string,
clientSecret: string,
refreshToken: string,
): Promise<BitbucketTokenResult> {
const tokenUrl = new URL(BITBUCKET_TOKEN_URL);
const config: OpenAPIConfig = {
BASE: tokenUrl.origin,
VERSION: '2',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: undefined,
HEADERS: {
Authorization:
'Basic ' +
Buffer.from(clientId + ':' + clientSecret).toString('base64'),
Accept: 'application/json',
},
};
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString();
try {
return await request<BitbucketTokenResult>(
config,
{
method: 'POST',
url: tokenUrl.pathname,
body,
mediaType: 'application/x-www-form-urlencoded',
},
{ rateLimitConfig: rateLimitConfig(false) },
);
} catch (error) {
throw new BitbucketOAuthError(
'Failed to refresh Bitbucket access token: ' +
(error instanceof Error ? error.message : String(error)),
);
}
}
export async function getValidBitbucketAccessToken({
accessToken,
expiresAt,
refreshToken,
clientId,
clientSecret,
forceRefresh = false,
}: {
accessToken?: string | null;
expiresAt?: string | null;
refreshToken?: string | null;
clientId?: string | null;
clientSecret?: string | null;
forceRefresh?: boolean;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt: number;
refreshed: boolean;
}> {
const now = Math.floor(Date.now() / 1000);
if (
!forceRefresh &&
accessToken &&
(!expiresAt || Number(expiresAt) > now + 300)
)
return {
accessToken,
refreshToken: refreshToken ?? undefined,
expiresAt: expiresAt ? Number(expiresAt) : now + 3600,
refreshed: false,
};
if (!refreshToken || !clientId || !clientSecret)
throw new BitbucketOAuthError(
'Bitbucket refresh token and OAuth client credentials are required',
);
const token = await refreshBitbucketAccessToken(
clientId,
clientSecret,
refreshToken,
);
return {
accessToken: token.access_token,
refreshToken: token.refresh_token ?? refreshToken,
expiresAt: now + (token.expires_in ?? 3600),
refreshed: true,
};
}
export type BitbucketRequestOptions = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: unknown;
query?: Record<string, unknown>;
mediaType?: string;
retrySafe?: boolean;
};
export async function makeBitbucketRequest<T>(
endpoint: string,
accessToken: string,
options: BitbucketRequestOptions,
): Promise<T> {
const config: OpenAPIConfig = {
BASE: BITBUCKET_API_BASE,
VERSION: '2.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: undefined,
HEADERS: {
Authorization: 'Bearer ' + accessToken,
Accept: 'application/json',
},
};
const requestOptions: ApiRequestOptions = {
method: options.method,
url: endpoint,
body: options.body,
query: options.query,
mediaType:
options.body === undefined
? undefined
: (options.mediaType ?? 'application/json'),
};
try {
const response = await request<T | undefined>(config, requestOptions, {
rateLimitConfig: rateLimitConfig(options.retrySafe ?? false),
});
return (response === undefined ? null : response) as T;
} catch (error) {
if (error instanceof ApiError)
throw new BitbucketAPIError(
'Bitbucket API request failed with status ' + error.status,
error.status,
error.retryAfter,
);
throw error;
}
}
export type BitbucketAuthContext = {
key: string;
_refreshAuth?: () => Promise<string>;
};
export async function makeAuthenticatedBitbucketRequest<T>(
endpoint: string,
ctx: BitbucketAuthContext,
options: BitbucketRequestOptions,
): Promise<T> {
try {
return await makeBitbucketRequest<T>(endpoint, ctx.key, options);
} catch (error) {
if (
error instanceof BitbucketAPIError &&
error.status === 401 &&
ctx._refreshAuth
) {
const freshToken = await ctx._refreshAuth();
return await makeBitbucketRequest<T>(endpoint, freshToken, options);
}
throw error;
}
}
Loading
Loading