refactor progress
This commit is contained in:
138
packages/api/src/bluesky/BlueskyOAuthController.tsx
Normal file
138
packages/api/src/bluesky/BlueskyOAuthController.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {BlueskyCallbackResult} from '@fluxer/api/src/bluesky/IBlueskyOAuthService';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {BlueskyOAuthCallbackFailedError} from '@fluxer/api/src/connection/errors/BlueskyOAuthCallbackFailedError';
|
||||
import {BlueskyOAuthNotEnabledError} from '@fluxer/api/src/connection/errors/BlueskyOAuthNotEnabledError';
|
||||
import {BlueskyOAuthStateInvalidError} from '@fluxer/api/src/connection/errors/BlueskyOAuthStateInvalidError';
|
||||
import {ConnectionAlreadyExistsError} from '@fluxer/api/src/connection/errors/ConnectionAlreadyExistsError';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {ConnectionRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/ConnectionRateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {ConnectionTypes} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import {
|
||||
BlueskyAuthorizeRequest,
|
||||
BlueskyAuthorizeResponse,
|
||||
} from '@fluxer/schema/src/domains/connection/BlueskyOAuthSchemas';
|
||||
|
||||
export function BlueskyOAuthController(app: HonoApp) {
|
||||
app.get('/connections/bluesky/client-metadata.json', async (ctx) => {
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!service) {
|
||||
return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404);
|
||||
}
|
||||
return ctx.json(service.clientMetadata);
|
||||
});
|
||||
|
||||
app.get('/connections/bluesky/jwks.json', async (ctx) => {
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!service) {
|
||||
return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404);
|
||||
}
|
||||
return ctx.json(service.jwks);
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/users/@me/connections/bluesky/authorize',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', BlueskyAuthorizeRequest),
|
||||
OpenAPI({
|
||||
operationId: 'authorize_bluesky_connection',
|
||||
summary: 'Start Bluesky OAuth flow',
|
||||
responseSchema: BlueskyAuthorizeResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Connections'],
|
||||
description: 'Initiates the Bluesky OAuth2 authorisation flow and returns a URL to redirect the user to.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!service) {
|
||||
throw new BlueskyOAuthNotEnabledError();
|
||||
}
|
||||
const {handle} = ctx.req.valid('json');
|
||||
const userId = ctx.get('user').id;
|
||||
|
||||
const connectionService = ctx.get('connectionService');
|
||||
const connections = await connectionService.getConnectionsForUser(userId);
|
||||
const lowerHandle = handle.toLowerCase();
|
||||
const existing = connections.find(
|
||||
(c) => c.connection_type === ConnectionTypes.BLUESKY && c.name.toLowerCase() === lowerHandle,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConnectionAlreadyExistsError();
|
||||
}
|
||||
|
||||
const result = await service.authorize(handle, userId);
|
||||
return ctx.json({authorize_url: result.authorizeUrl});
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/connections/bluesky/callback', async (ctx) => {
|
||||
const appUrl = Config.endpoints.webApp;
|
||||
const callbackUrl = `${appUrl}/connection-callback`;
|
||||
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!service) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=not_enabled`);
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(ctx.req.url.split('?')[1] ?? '');
|
||||
|
||||
let result: BlueskyCallbackResult;
|
||||
try {
|
||||
result = await service.callback(params);
|
||||
} catch (callbackError) {
|
||||
Logger.error({error: callbackError}, 'Bluesky OAuth callback error from upstream');
|
||||
if (
|
||||
callbackError instanceof Error &&
|
||||
(callbackError.message.toLowerCase().includes('state') ||
|
||||
callbackError.message.toLowerCase().includes('expired'))
|
||||
) {
|
||||
throw new BlueskyOAuthStateInvalidError();
|
||||
}
|
||||
throw new BlueskyOAuthCallbackFailedError();
|
||||
}
|
||||
|
||||
const connectionService = ctx.get('connectionService');
|
||||
await connectionService.createOrUpdateBlueskyConnection(result.userId, result.did, result.handle);
|
||||
|
||||
return ctx.redirect(`${callbackUrl}?status=connected`);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Bluesky OAuth callback failed');
|
||||
|
||||
if (error instanceof BlueskyOAuthStateInvalidError) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=state_invalid`);
|
||||
}
|
||||
if (error instanceof BlueskyOAuthCallbackFailedError) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=callback_failed`);
|
||||
}
|
||||
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=unknown`);
|
||||
}
|
||||
});
|
||||
}
|
||||
156
packages/api/src/bluesky/BlueskyOAuthService.tsx
Normal file
156
packages/api/src/bluesky/BlueskyOAuthService.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {isAbsolute} from 'node:path';
|
||||
import {Agent} from '@atproto/api';
|
||||
import {JoseKey} from '@bluesky-social/jwk-jose';
|
||||
import {NodeOAuthClient, requestLocalLock} from '@bluesky-social/oauth-client-node';
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createKVSessionStore, createKVStateStore} from '@fluxer/api/src/bluesky/BlueskyOAuthStores';
|
||||
import type {
|
||||
BlueskyAuthorizeResult,
|
||||
BlueskyCallbackResult,
|
||||
IBlueskyOAuthService,
|
||||
} from '@fluxer/api/src/bluesky/IBlueskyOAuthService';
|
||||
import type {BlueskyOAuthConfig} from '@fluxer/api/src/config/APIConfig';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
|
||||
|
||||
export class BlueskyOAuthService implements IBlueskyOAuthService {
|
||||
private client: NodeOAuthClient;
|
||||
|
||||
private constructor(client: NodeOAuthClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
static async create(
|
||||
config: BlueskyOAuthConfig,
|
||||
kvClient: IKVProvider,
|
||||
apiPublicEndpoint: string,
|
||||
): Promise<BlueskyOAuthService> {
|
||||
const baseUrl = apiPublicEndpoint.replace(/\/$/, '');
|
||||
|
||||
const keyset = await Promise.all(
|
||||
config.keys.map(async (key) => {
|
||||
const keyPath = key.private_key_path;
|
||||
if (!key.private_key && keyPath && !isAbsolute(keyPath)) {
|
||||
throw new Error(`Bluesky OAuth key path must be absolute, got: ${keyPath}`);
|
||||
}
|
||||
const keyData = key.private_key ?? (await readFile(keyPath!, 'utf-8'));
|
||||
return JoseKey.fromImportable(keyData, key.kid);
|
||||
}),
|
||||
);
|
||||
|
||||
const stateStore = createKVStateStore(kvClient, 3600);
|
||||
const sessionStore = createKVSessionStore(kvClient, 86400);
|
||||
|
||||
const client = new NodeOAuthClient({
|
||||
clientMetadata: {
|
||||
client_id: `${baseUrl}/connections/bluesky/client-metadata.json`,
|
||||
client_name: config.client_name,
|
||||
client_uri: config.client_uri || baseUrl,
|
||||
logo_uri: config.logo_uri || undefined,
|
||||
tos_uri: config.tos_uri || undefined,
|
||||
policy_uri: config.policy_uri || undefined,
|
||||
redirect_uris: [`${baseUrl}/connections/bluesky/callback`],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
scope: 'atproto',
|
||||
response_types: ['code'],
|
||||
application_type: 'web',
|
||||
token_endpoint_auth_method: 'private_key_jwt',
|
||||
token_endpoint_auth_signing_alg: 'ES256',
|
||||
dpop_bound_access_tokens: true,
|
||||
jwks_uri: `${baseUrl}/connections/bluesky/jwks.json`,
|
||||
},
|
||||
keyset,
|
||||
requestLock: requestLocalLock,
|
||||
stateStore,
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
return new BlueskyOAuthService(client);
|
||||
}
|
||||
|
||||
get clientMetadata(): Record<string, unknown> {
|
||||
return this.client.clientMetadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
get jwks(): Record<string, unknown> {
|
||||
return this.client.jwks as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async authorize(handle: string, userId: UserID): Promise<BlueskyAuthorizeResult> {
|
||||
const statePayload = JSON.stringify({userId: String(userId)});
|
||||
const url = await this.client.authorize(handle, {state: statePayload});
|
||||
return {authorizeUrl: url.toString()};
|
||||
}
|
||||
|
||||
async callback(params: URLSearchParams): Promise<BlueskyCallbackResult> {
|
||||
const {session, state} = await this.client.callback(params);
|
||||
const parsed = JSON.parse(state!) as {userId: string};
|
||||
const userId = createUserID(BigInt(parsed.userId));
|
||||
|
||||
const handle = await this.resolveHandle(session.did);
|
||||
|
||||
return {
|
||||
userId,
|
||||
did: session.did,
|
||||
handle,
|
||||
};
|
||||
}
|
||||
|
||||
async restoreAndVerify(did: string): Promise<{handle: string} | null> {
|
||||
try {
|
||||
await this.client.restore(did);
|
||||
const handle = await this.resolveHandle(did);
|
||||
return {handle};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
did,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
'Failed to restore and verify Bluesky session',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveHandle(did: string): Promise<string> {
|
||||
const agent = new Agent('https://public.api.bsky.app');
|
||||
const profile = await agent.getProfile({actor: did});
|
||||
return profile.data.handle;
|
||||
}
|
||||
|
||||
async revoke(did: string): Promise<void> {
|
||||
try {
|
||||
const session = await this.client.restore(did);
|
||||
await session.signOut();
|
||||
} catch (error) {
|
||||
Logger.debug(
|
||||
{
|
||||
did,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
'Failed to revoke Bluesky session (session may be expired or already revoked)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
packages/api/src/bluesky/BlueskyOAuthStores.tsx
Normal file
56
packages/api/src/bluesky/BlueskyOAuthStores.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {NodeSavedSession, NodeSavedState} from '@bluesky-social/oauth-client-node';
|
||||
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
|
||||
|
||||
const STATE_PREFIX = 'bsky:oauth:state:';
|
||||
const SESSION_PREFIX = 'bsky:oauth:session:';
|
||||
|
||||
export function createKVStateStore(kvClient: IKVProvider, ttlSeconds: number) {
|
||||
return {
|
||||
async set(key: string, internalState: NodeSavedState): Promise<void> {
|
||||
await kvClient.setex(`${STATE_PREFIX}${key}`, ttlSeconds, JSON.stringify(internalState));
|
||||
},
|
||||
async get(key: string): Promise<NodeSavedState | undefined> {
|
||||
const data = await kvClient.getdel(`${STATE_PREFIX}${key}`);
|
||||
if (!data) return undefined;
|
||||
return JSON.parse(data) as NodeSavedState;
|
||||
},
|
||||
async del(key: string): Promise<void> {
|
||||
await kvClient.del(`${STATE_PREFIX}${key}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createKVSessionStore(kvClient: IKVProvider, ttlSeconds: number) {
|
||||
return {
|
||||
async set(sub: string, session: NodeSavedSession): Promise<void> {
|
||||
await kvClient.setex(`${SESSION_PREFIX}${sub}`, ttlSeconds, JSON.stringify(session));
|
||||
},
|
||||
async get(sub: string): Promise<NodeSavedSession | undefined> {
|
||||
const data = await kvClient.get(`${SESSION_PREFIX}${sub}`);
|
||||
if (!data) return undefined;
|
||||
return JSON.parse(data) as NodeSavedSession;
|
||||
},
|
||||
async del(sub: string): Promise<void> {
|
||||
await kvClient.del(`${SESSION_PREFIX}${sub}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
39
packages/api/src/bluesky/IBlueskyOAuthService.tsx
Normal file
39
packages/api/src/bluesky/IBlueskyOAuthService.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
|
||||
export interface BlueskyAuthorizeResult {
|
||||
authorizeUrl: string;
|
||||
}
|
||||
|
||||
export interface BlueskyCallbackResult {
|
||||
userId: UserID;
|
||||
did: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export interface IBlueskyOAuthService {
|
||||
readonly clientMetadata: Record<string, unknown>;
|
||||
readonly jwks: Record<string, unknown>;
|
||||
authorize(handle: string, userId: UserID): Promise<BlueskyAuthorizeResult>;
|
||||
callback(params: URLSearchParams): Promise<BlueskyCallbackResult>;
|
||||
restoreAndVerify(did: string): Promise<{handle: string} | null>;
|
||||
revoke(did: string): Promise<void>;
|
||||
}
|
||||
239
packages/api/src/bluesky/tests/BlueskyOAuth.test.tsx
Normal file
239
packages/api/src/bluesky/tests/BlueskyOAuth.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
createBlueskyDid,
|
||||
createBlueskyHandle,
|
||||
listConnections,
|
||||
} from '@fluxer/api/src/connection/tests/ConnectionTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ConnectionTypes} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Bluesky OAuth', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('GET /connections/bluesky/client-metadata.json', () => {
|
||||
it('returns client metadata', async () => {
|
||||
const response = await harness.requestJson({
|
||||
path: '/connections/bluesky/client-metadata.json',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {client_id: string};
|
||||
expect(body).toHaveProperty('client_id');
|
||||
expect(body.client_id).toBe('https://test/metadata.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /connections/bluesky/jwks.json', () => {
|
||||
it('returns JWKS', async () => {
|
||||
const response = await harness.requestJson({
|
||||
path: '/connections/bluesky/jwks.json',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {keys: Array<unknown>};
|
||||
expect(body).toHaveProperty('keys');
|
||||
expect(body.keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /users/@me/connections/bluesky/authorize', () => {
|
||||
it('returns authorize_url for valid handle', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const result = await createBuilder<{authorize_url: string}>(harness, account.token)
|
||||
.post('/users/@me/connections/bluesky/authorize')
|
||||
.body({handle: 'alice.bsky.social'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
|
||||
expect(result.authorize_url).toBeTruthy();
|
||||
expect(result.authorize_url).toContain('https://');
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/connections/bluesky/authorize')
|
||||
.body({handle: 'alice.bsky.social'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns error for empty handle', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/connections/bluesky/authorize')
|
||||
.body({handle: ''})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('calls authorize with correct handle and userId', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = 'alice.bsky.social';
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/connections/bluesky/authorize')
|
||||
.body({handle})
|
||||
.expect(200)
|
||||
.execute();
|
||||
|
||||
expect(harness.mockBlueskyOAuthService.authorizeSpy).toHaveBeenCalledTimes(1);
|
||||
const callArgs = harness.mockBlueskyOAuthService.authorizeSpy.mock.calls[0];
|
||||
expect(callArgs[0]).toBe(handle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /connections/bluesky/callback', () => {
|
||||
it('redirects to app on successful callback', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
callbackResult: {userId, did, handle},
|
||||
});
|
||||
|
||||
const response = await harness.requestJson({
|
||||
path: '/connections/bluesky/callback?code=mock_code&state=mock_state&iss=mock_iss',
|
||||
method: 'GET',
|
||||
headers: {Authorization: account.token},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get('location') ?? '';
|
||||
expect(location).toContain('status=connected');
|
||||
});
|
||||
|
||||
it('redirects to app with error on failed callback', async () => {
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
shouldFailCallback: true,
|
||||
});
|
||||
|
||||
const response = await harness.requestJson({
|
||||
path: '/connections/bluesky/callback?code=bad&state=bad',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get('location') ?? '';
|
||||
expect(location).toContain('status=error');
|
||||
});
|
||||
|
||||
it('creates connection on successful callback', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
callbackResult: {userId, did, handle},
|
||||
});
|
||||
|
||||
await harness.requestJson({
|
||||
path: '/connections/bluesky/callback?code=mock_code&state=mock_state&iss=mock_iss',
|
||||
method: 'GET',
|
||||
headers: {Authorization: account.token},
|
||||
});
|
||||
|
||||
const connections = await listConnections(harness, account.token);
|
||||
expect(connections).toHaveLength(1);
|
||||
expect(connections[0].type).toBe(ConnectionTypes.BLUESKY);
|
||||
expect(connections[0].name).toBe(handle);
|
||||
expect(connections[0].verified).toBe(true);
|
||||
});
|
||||
|
||||
it('updates existing connection on re-authorisation', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const handle = createBlueskyHandle('testuser');
|
||||
const newHandle = 'newhandle.bsky.social';
|
||||
const did = createBlueskyDid('testuser');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
callbackResult: {userId, did, handle},
|
||||
});
|
||||
|
||||
await harness.requestJson({
|
||||
path: '/connections/bluesky/callback?code=mock_code&state=mock_state&iss=mock_iss',
|
||||
method: 'GET',
|
||||
headers: {Authorization: account.token},
|
||||
});
|
||||
|
||||
const connectionsBefore = await listConnections(harness, account.token);
|
||||
expect(connectionsBefore).toHaveLength(1);
|
||||
expect(connectionsBefore[0].name).toBe(handle);
|
||||
|
||||
harness.mockBlueskyOAuthService.configure({
|
||||
callbackResult: {userId, did, handle: newHandle},
|
||||
});
|
||||
|
||||
await harness.requestJson({
|
||||
path: '/connections/bluesky/callback?code=new_code&state=new_state&iss=new_iss',
|
||||
method: 'GET',
|
||||
headers: {Authorization: account.token},
|
||||
});
|
||||
|
||||
const connectionsAfter = await listConnections(harness, account.token);
|
||||
expect(connectionsAfter).toHaveLength(1);
|
||||
expect(connectionsAfter[0].name).toBe(newHandle);
|
||||
expect(connectionsAfter[0].verified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Old initiate endpoint rejects Bluesky', () => {
|
||||
it('returns BLUESKY_OAUTH_NOT_ENABLED for POST /users/@me/connections with bsky type', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/connections')
|
||||
.body({
|
||||
type: ConnectionTypes.BLUESKY,
|
||||
identifier: 'test.bsky.social',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.BLUESKY_OAUTH_NOT_ENABLED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user