refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View 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`);
}
});
}

View 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)',
);
}
}
}

View 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}`);
},
};
}

View 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>;
}

View 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();
});
});
});