diff --git a/src/modules/atproto/domain/services/IIdentityResolutionService.ts b/src/modules/atproto/domain/services/IIdentityResolutionService.ts index cbe95393..bad3ad8a 100644 --- a/src/modules/atproto/domain/services/IIdentityResolutionService.ts +++ b/src/modules/atproto/domain/services/IIdentityResolutionService.ts @@ -6,4 +6,9 @@ import { Handle } from '../Handle'; export interface IIdentityResolutionService { resolveToDID(identifier: DIDOrHandle): Promise>; resolveToHandle(identifier: DIDOrHandle): Promise>; + /** + * Resolve a DID to its atproto signing key (multibase format). + * Used for JWT verification in XRPC endpoints. + */ + resolveAtprotoKey(did: string): Promise>; } diff --git a/src/modules/atproto/infrastructure/services/ATProtoIdentityResolutionService.ts b/src/modules/atproto/infrastructure/services/ATProtoIdentityResolutionService.ts index b327a390..18f7d75a 100644 --- a/src/modules/atproto/infrastructure/services/ATProtoIdentityResolutionService.ts +++ b/src/modules/atproto/infrastructure/services/ATProtoIdentityResolutionService.ts @@ -4,11 +4,16 @@ import { DID } from '../../domain/DID'; import { DIDOrHandle } from '../../domain/DIDOrHandle'; import { Handle } from '../../domain/Handle'; import { IAgentService } from '../../application/IAgentService'; +import { IdResolver } from '@atproto/identity'; export class ATProtoIdentityResolutionService implements IIdentityResolutionService { - constructor(private readonly agentService: IAgentService) {} + private readonly idResolver: IdResolver; + + constructor(private readonly agentService: IAgentService) { + this.idResolver = new IdResolver(); + } async resolveToDID(identifier: DIDOrHandle): Promise> { try { @@ -129,4 +134,18 @@ export class ATProtoIdentityResolutionService ); } } + + async resolveAtprotoKey(did: string): Promise> { + try { + // Use IdResolver to get the atproto signing key + const atprotoKey = await this.idResolver.did.resolveAtprotoKey(did); + return ok(atprotoKey); + } catch (error) { + return err( + new Error( + `Error resolving atproto key for DID ${did}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + } } diff --git a/src/modules/atproto/infrastructure/services/CachedATProtoIdentityResolutionService.ts b/src/modules/atproto/infrastructure/services/CachedATProtoIdentityResolutionService.ts index fba28618..3cee43d6 100644 --- a/src/modules/atproto/infrastructure/services/CachedATProtoIdentityResolutionService.ts +++ b/src/modules/atproto/infrastructure/services/CachedATProtoIdentityResolutionService.ts @@ -9,7 +9,8 @@ export class CachedATProtoIdentityResolutionService implements IIdentityResolutionService { private readonly CACHE_TTL_SECONDS = 900; // 15 minutes - private readonly CACHE_KEY_PREFIX = 'handle-to-did:'; + private readonly HANDLE_TO_DID_PREFIX = 'handle-to-did:'; + private readonly DID_TO_KEY_PREFIX = 'did-to-key:'; constructor( private readonly identityResolutionService: IIdentityResolutionService, @@ -29,7 +30,7 @@ export class CachedATProtoIdentityResolutionService return err(new Error('Invalid handle in identifier')); } - const cacheKey = this.getCacheKey(handle.value); + const cacheKey = this.getHandleToDIDCacheKey(handle.value); try { // Try to get DID from cache @@ -96,8 +97,48 @@ export class CachedATProtoIdentityResolutionService return this.identityResolutionService.resolveToHandle(identifier); } - private getCacheKey(handle: string): string { - return `${this.CACHE_KEY_PREFIX}${handle}`; + async resolveAtprotoKey(did: string): Promise> { + const cacheKey = this.getDIDToKeyCacheKey(did); + + try { + // Try to get key from cache + const cachedKey = await this.redis.get(cacheKey); + + if (cachedKey) { + return ok(cachedKey); + } + } catch (redisError) { + // If Redis read fails, log and continue to fetch from service + console.warn( + `Redis error when fetching cached atproto key for DID ${did}:`, + redisError, + ); + } + + // Cache miss - fetch from underlying service + const result = await this.identityResolutionService.resolveAtprotoKey(did); + + if (result.isErr()) { + return result; + } + + // Cache the key + try { + await this.redis.setex(cacheKey, this.CACHE_TTL_SECONDS, result.value); + } catch (cacheError) { + // Log cache error but don't fail the request + console.warn(`Failed to cache atproto key for DID ${did}:`, cacheError); + } + + return result; + } + + private getHandleToDIDCacheKey(handle: string): string { + return `${this.HANDLE_TO_DID_PREFIX}${handle}`; + } + + private getDIDToKeyCacheKey(did: string): string { + return `${this.DID_TO_KEY_PREFIX}${did}`; } /** @@ -105,7 +146,7 @@ export class CachedATProtoIdentityResolutionService */ async invalidateHandle(handle: string): Promise { try { - await this.redis.del(this.getCacheKey(handle)); + await this.redis.del(this.getHandleToDIDCacheKey(handle)); } catch (error) { console.warn( `Failed to invalidate DID cache for handle ${handle}:`, diff --git a/src/modules/cards/tests/utils/FakeIdentityResolutionService.ts b/src/modules/cards/tests/utils/FakeIdentityResolutionService.ts index f612f676..37b34092 100644 --- a/src/modules/cards/tests/utils/FakeIdentityResolutionService.ts +++ b/src/modules/cards/tests/utils/FakeIdentityResolutionService.ts @@ -9,6 +9,7 @@ export class FakeIdentityResolutionService { private handleToDIDMap: Map = new Map(); private didToHandleMap: Map = new Map(); + private didToKeyMap: Map = new Map(); private shouldFail = false; async resolveToDID(identifier: DIDOrHandle): Promise> { @@ -101,12 +102,40 @@ export class FakeIdentityResolutionService } } + async resolveAtprotoKey(did: string): Promise> { + if (this.shouldFail) { + return err(new Error('Identity resolution service failed')); + } + + try { + // Check if we have a mapping for this DID + const mappedKey = this.didToKeyMap.get(did); + if (mappedKey) { + return ok(mappedKey); + } + + // Return a default fake key if no mapping exists + // Format: did:key:z... (multibase format) + return ok(`did:key:zFakeKey${did.slice(-10)}`); + } catch (error) { + return err( + new Error( + `Error resolving atproto key for DID ${did}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + } + // Test helper methods addHandleMapping(handle: string, did: string): void { this.handleToDIDMap.set(handle, did); this.didToHandleMap.set(did, handle); } + addKeyMapping(did: string, key: string): void { + this.didToKeyMap.set(did, key); + } + setShouldFail(shouldFail: boolean): void { this.shouldFail = shouldFail; } @@ -114,6 +143,7 @@ export class FakeIdentityResolutionService clear(): void { this.handleToDIDMap.clear(); this.didToHandleMap.clear(); + this.didToKeyMap.clear(); this.shouldFail = false; } } diff --git a/src/modules/search/infrastructure/http/controllers/PagePartsSearchController.ts b/src/modules/search/infrastructure/http/controllers/PagePartsSearchController.ts index 539b0286..454fcdf1 100644 --- a/src/modules/search/infrastructure/http/controllers/PagePartsSearchController.ts +++ b/src/modules/search/infrastructure/http/controllers/PagePartsSearchController.ts @@ -3,18 +3,16 @@ import { Response } from 'express'; import { XrpcMentionSearchUseCase } from '../../../application/useCases/queries/PagePartsSearchUseCase'; import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; import { parseReqNsid, verifyJwt } from '@atproto/xrpc-server'; -import { IdResolver } from '@atproto/identity'; +import { IIdentityResolutionService } from '../../../../../modules/atproto/domain/services/IIdentityResolutionService'; export class XrpcMentionSearchController extends Controller { - private idResolver: IdResolver; - constructor( private xrpcMentionSearchUseCase: XrpcMentionSearchUseCase, private appUrl: string, private serviceDid: string, + private identityResolutionService: IIdentityResolutionService, ) { super(); - this.idResolver = new IdResolver(); } private async validateAuth(req: any): Promise { @@ -31,11 +29,14 @@ export class XrpcMentionSearchController extends Controller { this.serviceDid, nsid, async (did: string) => { - const didDoc = await this.idResolver.did.resolve(did); - if (!didDoc) { - throw new Error('Could not resolve DID'); + const keyResult = + await this.identityResolutionService.resolveAtprotoKey(did); + if (keyResult.isErr()) { + throw new Error( + `Could not resolve atproto key: ${keyResult.error.message}`, + ); } - return await this.idResolver.did.resolveAtprotoKey(did); + return keyResult.value; }, ); return parsed.iss; diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index a2cb30b6..b6bbf309 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -372,6 +372,7 @@ export class ControllerFactory { useCases.xrpcMentionSearchUseCase, appUrl, serviceDid, + services.identityResolutionService, ), // Notification controllers getMyNotificationsController: new GetMyNotificationsController(