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
86 changes: 86 additions & 0 deletions src/atproto-identity/atproto-identity.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ describe('AtprotoIdentityController', () => {
let pdsAccountService: PdsAccountService;
let pdsSessionService: PdsSessionService;
let blueskyService: BlueskyService;
let authBlueskyService: AuthBlueskyService;
let configService: ConfigService;

const mockIdentityEntity: Partial<UserAtprotoIdentityEntity> = {
id: 1,
Expand Down Expand Up @@ -179,6 +181,8 @@ describe('AtprotoIdentityController', () => {
pdsAccountService = module.get<PdsAccountService>(PdsAccountService);
pdsSessionService = module.get<PdsSessionService>(PdsSessionService);
blueskyService = module.get<BlueskyService>(BlueskyService);
authBlueskyService = module.get<AuthBlueskyService>(AuthBlueskyService);
configService = module.get<ConfigService>(ConfigService);
});

it('should be defined', () => {
Expand Down Expand Up @@ -1268,4 +1272,86 @@ describe('AtprotoIdentityController', () => {
).rejects.toThrow(BadRequestException);
});
});

describe('getIdentity - identity:handle is only required for our PDS', () => {
// identity:handle authorizes a handle rename. updateHandle rejects any
// identity that is not on our PDS, so an external identity is never
// required to hold it and must not be asked to reconnect for it.
const ourPdsNonCustodial: Partial<UserAtprotoIdentityEntity> = {
...mockIdentityEntity,
pdsCredentials: null,
isCustodial: false,
};

beforeEach(() => {
// Both cases hold a live OAuth session; only the host PDS differs.
jest.spyOn(blueskyService, 'tryResumeSession').mockResolvedValue({
sessionManager: {},
} as never);
jest.spyOn(configService, 'get').mockImplementation((key: string) => {
if (key === 'pds.url') return 'https://pds.openmeet.net';
if (key === 'pds.serviceHandleDomains') return '.opnmt.me';
if (key === 'ATPROTO_OAUTH_SCOPES')
return 'atproto account:email identity:handle';
return null;
});
});

it('should report identity:handle missing for an our-PDS identity', async () => {
// Arrange
jest
.spyOn(identityService, 'findByUserUlid')
.mockResolvedValue(ourPdsNonCustodial as UserAtprotoIdentityEntity);
jest
.spyOn(authBlueskyService, 'getScopeMismatch')
.mockResolvedValue(['identity:handle']);

// Act
const result = await controller.getIdentity(mockRequest);

// Assert
expect(result?.isOurPds).toBe(true);
expect(result?.scopeMismatch).toBe(true);
expect(result?.missingScopes).toEqual(['identity:handle']);
});

it('should not report identity:handle missing for an external-PDS identity', async () => {
// Arrange
jest
.spyOn(identityService, 'findByUserUlid')
.mockResolvedValue(
mockNonCustodialIdentity as UserAtprotoIdentityEntity,
);
jest
.spyOn(authBlueskyService, 'getScopeMismatch')
.mockResolvedValue(['identity:handle']);

// Act
const result = await controller.getIdentity(mockRequest);

// Assert
expect(result?.isOurPds).toBe(false);
expect(result?.scopeMismatch).toBe(false);
expect(result?.missingScopes).toEqual([]);
});

it('should still report other missing scopes for an external-PDS identity', async () => {
// Arrange: stored flag holds identity:handle alongside a real gap
jest
.spyOn(identityService, 'findByUserUlid')
.mockResolvedValue(
mockNonCustodialIdentity as UserAtprotoIdentityEntity,
);
jest
.spyOn(authBlueskyService, 'getScopeMismatch')
.mockResolvedValue(['identity:handle', 'account:email']);

// Act
const result = await controller.getIdentity(mockRequest);

// Assert
expect(result?.scopeMismatch).toBe(true);
expect(result?.missingScopes).toEqual(['account:email']);
});
});
});
17 changes: 14 additions & 3 deletions src/atproto-identity/atproto-identity.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { NullableType } from '../utils/types/nullable.type';
import { AllConfigType } from '../config/config.type';
import { UserAtprotoIdentityEntity } from '../user-atproto-identity/infrastructure/persistence/relational/entities/user-atproto-identity.entity';
import { AuthBlueskyService } from '../auth-bluesky/auth-bluesky.service';
import { getRequiredScopesForIdentity } from '../utils/check-scope-mismatch';
import { getAtprotoConfiguredScopes } from '../utils/bluesky';

@ApiTags('AT Protocol Identity')
@Controller({
Expand Down Expand Up @@ -543,6 +545,7 @@ export class AtprotoIdentityController {
tenantId: string,
): Promise<AtprotoIdentityDto> {
const ourPdsUrl = this.configService.get('pds.url', { infer: true });
const isOurPds = identity.pdsUrl === ourPdsUrl;
const serviceHandleDomains =
this.configService.get('pds.serviceHandleDomains', { infer: true }) || '';
const validHandleDomains = serviceHandleDomains
Expand Down Expand Up @@ -599,8 +602,16 @@ export class AtprotoIdentityController {
identity.did,
);
if (mismatchData && mismatchData.length > 0) {
scopeMismatch = true;
missingScopes = mismatchData;
// The stored list was measured against the global scope list, so
// narrow it to what this identity is actually required to hold.
const requiredScopes = getRequiredScopesForIdentity(
getAtprotoConfiguredScopes(this.configService),
{ isOurPds },
).split(/\s+/);
missingScopes = mismatchData.filter((scope) =>
requiredScopes.includes(scope),
);
scopeMismatch = missingScopes.length > 0;
}
} catch (error) {
this.logger.warn('Failed to check scope mismatch', {
Expand All @@ -615,7 +626,7 @@ export class AtprotoIdentityController {
handle: identity.handle,
pdsUrl: identity.pdsUrl,
isCustodial: identity.isCustodial,
isOurPds: identity.pdsUrl === ourPdsUrl,
isOurPds,
hasActiveSession,
scopeMismatch,
missingScopes,
Expand Down
85 changes: 85 additions & 0 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,91 @@ describe('AuthService', () => {
expect(result?.atprotoIdentity?.scopeMismatch).toBe(false);
expect(result?.atprotoIdentity?.missingScopes).toEqual([]);
});

describe('identity:handle is only required for our PDS', () => {
// identity:handle authorizes a handle rename. updateHandle rejects any
// identity that is not on our PDS, so an external identity is never
// required to hold it and must not be asked to reconnect for it.
const ourPdsIdentity = {
...baseIdentity,
did: 'did:plc:ourpds456',
handle: 'test-user.opnmt.me',
pdsUrl: 'https://pds.openmeet.net',
};

const sessionGranting = (scope: string) => ({
sessionManager: {
getTokenInfo: jest.fn().mockResolvedValue({ scope }),
},
});

beforeEach(() => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'pds.url') return 'https://pds.openmeet.net';
if (key === 'pds.serviceHandleDomains') return '.opnmt.me';
if (key === 'ATPROTO_OAUTH_SCOPES')
return 'atproto identity:handle repo:community.lexicon.calendar.fake';
return null;
});
});

it('should report identity:handle missing for an our-PDS identity', async () => {
// Arrange: took ownership, session predates the identity:handle scope
mockUserAtprotoIdentityService.findByUserUlid.mockResolvedValue(
ourPdsIdentity,
);
mockBlueskyService.tryResumeSession.mockResolvedValue(
sessionGranting('atproto repo:community.lexicon.calendar.fake'),
);

// Act
const result = await authService.me(jwtPayload);

// Assert
expect(result?.atprotoIdentity?.isOurPds).toBe(true);
expect(result?.atprotoIdentity?.scopeMismatch).toBe(true);
expect(result?.atprotoIdentity?.missingScopes).toEqual([
'identity:handle',
]);
});

it('should not report identity:handle missing for an external-PDS identity', async () => {
// Arrange: same stale session shape, but hosted on bsky.social
mockUserAtprotoIdentityService.findByUserUlid.mockResolvedValue(
baseIdentity,
);
mockBlueskyService.tryResumeSession.mockResolvedValue(
sessionGranting('atproto repo:community.lexicon.calendar.fake'),
);

// Act
const result = await authService.me(jwtPayload);

// Assert
expect(result?.atprotoIdentity?.isOurPds).toBe(false);
expect(result?.atprotoIdentity?.scopeMismatch).toBe(false);
expect(result?.atprotoIdentity?.missingScopes).toEqual([]);
});

it('should still report other missing scopes for an external-PDS identity', async () => {
// Arrange: external identity missing identity:handle AND a real scope
mockUserAtprotoIdentityService.findByUserUlid.mockResolvedValue(
baseIdentity,
);
mockBlueskyService.tryResumeSession.mockResolvedValue(
sessionGranting('atproto'),
);

// Act
const result = await authService.me(jwtPayload);

// Assert
expect(result?.atprotoIdentity?.scopeMismatch).toBe(true);
expect(result?.atprotoIdentity?.missingScopes).toEqual([
'repo:community.lexicon.calendar.fake',
]);
});
});
});

describe('validateSocialLogin - PDS Account Auto-Creation', () => {
Expand Down
12 changes: 9 additions & 3 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ import { MeResponse } from './dto/me-response.dto';
import { AtprotoIdentityDto } from '../atproto-identity/dto/atproto-identity.dto';
import { ElastiCacheService } from '../elasticache/elasticache.service';
import { getTenantConfig } from '../utils/tenant-config';
import { checkScopeMismatch } from '../utils/check-scope-mismatch';
import {
checkScopeMismatch,
getRequiredScopesForIdentity,
} from '../utils/check-scope-mismatch';
import { getAtprotoConfiguredScopes } from '../utils/bluesky';
import { OAuthSession } from '@atproto/oauth-client';
import { CreateLoginLinkResponseDto } from './dto/create-login-link-response.dto';
Expand Down Expand Up @@ -670,6 +673,7 @@ export class AuthService {
if (identity) {
// Map to DTO, explicitly excluding pdsCredentials
const ourPdsUrl = this.configService.get('pds.url', { infer: true });
const isOurPds = identity.pdsUrl === ourPdsUrl;
const serviceHandleDomains =
this.configService.get('pds.serviceHandleDomains', {
infer: true,
Expand Down Expand Up @@ -706,7 +710,9 @@ export class AuthService {
this.configService,
);
scopeMissingScopes = checkScopeMismatch(
configuredScopes,
getRequiredScopesForIdentity(configuredScopes, {
isOurPds,
}),
tokenInfo.scope,
);
scopeHasMismatch = scopeMissingScopes.length > 0;
Expand All @@ -726,7 +732,7 @@ export class AuthService {
handle: identity.handle,
pdsUrl: identity.pdsUrl,
isCustodial: identity.isCustodial,
isOurPds: identity.pdsUrl === ourPdsUrl,
isOurPds,
hasActiveSession,
scopeMismatch: scopeHasMismatch,
missingScopes: scopeMissingScopes,
Expand Down
33 changes: 32 additions & 1 deletion src/utils/bluesky.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createPlcFallbackFetch } from './bluesky';
import { ConfigService } from '@nestjs/config';
import { createPlcFallbackFetch, getAtprotoConfiguredScopes } from './bluesky';

describe('createPlcFallbackFetch', () => {
let mockGlobalFetch: jest.Mock;
Expand Down Expand Up @@ -183,3 +184,33 @@ describe('createPlcFallbackFetch', () => {
});
});
});

describe('getAtprotoConfiguredScopes', () => {
const configWith = (scopes?: string) =>
({ get: () => scopes }) as unknown as ConfigService;

it('should use the configured scopes when ATPROTO_OAUTH_SCOPES is set', () => {
const result = getAtprotoConfiguredScopes(configWith('atproto repo:a.b.c'));
expect(result).toBe('atproto repo:a.b.c');
});

it('should fall back to the defaults when ATPROTO_OAUTH_SCOPES is unset', () => {
const scopes = getAtprotoConfiguredScopes(configWith(undefined)).split(' ');
expect(scopes).toEqual(
expect.arrayContaining([
'atproto',
'account:email',
'repo:community.lexicon.calendar.event',
'repo:community.lexicon.calendar.rsvp',
]),
);
});

// Without this scope the PDS rejects com.atproto.identity.updateHandle with a
// 403 for anyone whose session is OAuth rather than custodial - so taking
// ownership of your own account costs you the ability to rename it.
it('should request identity:handle so users can change their own handle', () => {
const scopes = getAtprotoConfiguredScopes(configWith(undefined)).split(' ');
expect(scopes).toContain('identity:handle');
});
});
2 changes: 1 addition & 1 deletion src/utils/bluesky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { createRequestLock } from '../auth-bluesky/stores/redlock';
* Default OAuth scopes if ATPROTO_OAUTH_SCOPES env var is not set.
*/
const DEFAULT_ATPROTO_OAUTH_SCOPES =
'atproto account:email rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview repo:community.lexicon.calendar.event repo:community.lexicon.calendar.rsvp';
'atproto account:email identity:handle rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview repo:community.lexicon.calendar.event repo:community.lexicon.calendar.rsvp';

/**
* Get the configured ATProto OAuth scopes from env var or default.
Expand Down
Loading
Loading