Skip to content
Merged
4 changes: 2 additions & 2 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,7 @@
"description": "Malformed publicKey, signature, or label"
},
"401": {
"description": "Missing token, an invalid identity token, or a signature that does not verify"
"description": "Missing token, an invalid or already-spent identity token, or a signature that does not verify"
},
"409": {
"description": "Key or identity already claimed elsewhere, or the device limit is reached"
Expand Down Expand Up @@ -2305,7 +2305,7 @@
},
"identityToken": {
"type": "string",
"description": "CipherBox identity token this device signed in with; binds the account to the identity a pre-reconstruction device can present"
"description": "CipherBox identity token this device signed in with; binds the account to the identity a pre-reconstruction device can present. A successful registration spends it"
},
"label": {
"type": "string",
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/auth/auth.module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AuthMethod } from './entities/auth-method.entity';
import { AcceleratorToken } from './entities/accelerator-token.entity';
import { IdentitySubject } from './entities/identity-subject.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { SpentIdentityToken } from './entities/spent-identity-token.entity';
import { User } from './entities/user.entity';
import { IdentityController } from './identity.controller';
import { EmailOtpService } from './services/email-otp.service';
Expand Down Expand Up @@ -81,7 +82,14 @@ describe('AuthModule dependency graph', () => {
AuthModule,
],
});
for (const entity of [User, AuthMethod, RefreshToken, AcceleratorToken, IdentitySubject]) {
for (const entity of [
User,
AuthMethod,
RefreshToken,
AcceleratorToken,
IdentitySubject,
SpentIdentityToken,
]) {
builder.overrideProvider(getRepositoryToken(entity)).useValue({});
}

Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AuthMethod } from './entities/auth-method.entity';
import { AcceleratorToken } from './entities/accelerator-token.entity';
import { IdentitySubject } from './entities/identity-subject.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { SpentIdentityToken } from './entities/spent-identity-token.entity';
import { User } from './entities/user.entity';
import { GatewayController } from './gateway.controller';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
Expand Down Expand Up @@ -51,7 +52,14 @@ export function buildJwtOptions(configService: ConfigService) {

@Module({
imports: [
TypeOrmModule.forFeature([User, AuthMethod, RefreshToken, AcceleratorToken, IdentitySubject]),
TypeOrmModule.forFeature([
User,
AuthMethod,
RefreshToken,
AcceleratorToken,
IdentitySubject,
SpentIdentityToken,
]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand Down
4 changes: 0 additions & 4 deletions apps/api/src/auth/entities/identity-subject.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ export class IdentitySubject {
@Column({ name: 'identifier_hash', type: 'varchar', length: 64 })
identifierHash: string;

/** Truncated human-readable identifier for account-settings display. */
@Column({ name: 'identifier_display', type: 'varchar', length: 255, nullable: true })
identifierDisplay: string | null;

@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
lastUsedAt: Date | null;

Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/auth/entities/spent-identity-token.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';

/**
* An identity token a device registration has spent, kept until the token
* expires: the token is a bearer, so a replay is refused by its `jti` here.
*/
@Entity('spent_identity_tokens')
export class SpentIdentityToken {
@PrimaryColumn({
name: 'token_id',
type: 'uuid',
primaryKeyConstraintName: 'pk_spent_identity_tokens',
})
tokenId: string;

/** Indexed to drive the expired-row sweep that runs beside each spend. */
@Index('idx_spent_identity_tokens_expires_at')
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
}
15 changes: 15 additions & 0 deletions apps/api/src/auth/identity.http.itest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,21 @@ describe('identity exchange HTTP flows (real Postgres)', () => {
expect(await userCount()).toBe(0);
});

it('keeps no display form of the identifier and no account on the subject row', async () => {
const columns: { column_name: string }[] = await db.dataSource.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'identity_subjects'`
);

expect(columns.map((row) => row.column_name).sort()).toEqual([
'created_at',
'id',
'identifier_hash',
'kind',
'last_used_at',
]);
});

it('does not cross-link methods that share an email', async () => {
const shared = freshEmail();
const viaEmail = await emailGrant(shared);
Expand Down
16 changes: 4 additions & 12 deletions apps/api/src/auth/services/identity-exchange.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class IdentityExchangeService {

async fromGoogleToken(idToken: string): Promise<IdentityGrant> {
const identity = await this.google.verify(idToken);
return this.mint('google', identity.subject, truncateEmail(identity.email), identity.email);
return this.mint('google', identity.subject, identity.email);
}

sendEmailCode(email: string): Promise<void> {
Expand All @@ -48,7 +48,7 @@ export class IdentityExchangeService {

async fromEmailCode(email: string, code: string): Promise<IdentityGrant> {
const address = this.emailOtp.verify(email, code);
return this.mint('email', address, truncateEmail(address), address);
return this.mint('email', address, address);
}

async fromWalletSignature(message: string, signature: `0x${string}`): Promise<IdentityGrant> {
Expand All @@ -63,24 +63,16 @@ export class IdentityExchangeService {
nonce,
SIWE_LOGIN_STATEMENT
);
return this.mint('wallet', address, this.siwe.truncateWalletAddress(address), null);
return this.mint('wallet', address, null);
}

private async mint(
method: IdentitySubjectKind,
identifier: string,
identifierDisplay: string,
email: string | null
): Promise<IdentityGrant> {
const verifierId = await this.subjects.resolve(method, identifier, identifierDisplay);
const verifierId = await this.subjects.resolve(method, identifier);
const { token, expiresAt } = await this.tokens.sign({ subject: verifierId, method });
return { token, verifierId, email, expiresAt };
}
}

/** Truncated address for display, e.g. "al***@example.com". */
function truncateEmail(email: string): string {
const [local, domain] = email.split('@');
if (!domain) return '***';
return `${local.slice(0, 2)}***@${domain}`;
}
37 changes: 26 additions & 11 deletions apps/api/src/auth/services/identity-subject.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ interface Row {
id: string;
kind: IdentitySubjectKind;
identifierHash: string;
identifierDisplay: string | null;
lastUsedAt: Date | null;
}

Expand Down Expand Up @@ -85,7 +84,7 @@ describe('IdentitySubjectService', () => {
it('mints a subject on first sight and returns the inserted id', async () => {
const repository = new FakeSubjectRepository();

const id = await subjectService(repository).resolve('google', 'google-subject', 'me***@x.com');
const id = await subjectService(repository).resolve('google', 'google-subject');

expect(repository.rows).toHaveLength(1);
expect(id).toBe(repository.rows[0].id);
Expand All @@ -94,20 +93,36 @@ describe('IdentitySubjectService', () => {
it('stores the identifier only as its hash, never in plaintext', async () => {
const repository = new FakeSubjectRepository();

await subjectService(repository).resolve('email', 'member@example.com', 'me***@example.com');
await subjectService(repository).resolve('email', 'member@example.com');

expect(repository.rows[0].identifierHash).toBe(
new IdentityService().hashIdentifier('member@example.com')
);
expect(JSON.stringify(repository.rows)).not.toContain('member@example.com');
});

it('keeps no display form of the identifier beside its hash', async () => {
const repository = new FakeSubjectRepository();

await subjectService(repository).resolve(
'wallet',
'0x52908400098527886E0F7030069857D2E4169EE7'
);

expect(Object.keys(repository.rows[0]).sort()).toEqual([
'id',
'identifierHash',
'kind',
'lastUsedAt',
]);
});

it('returns the standing subject for an identity already seen', async () => {
const repository = new FakeSubjectRepository();
const service = subjectService(repository);

const first = await service.resolve('wallet', '0xabc', '0xab***');
const second = await service.resolve('wallet', '0xabc', '0xab***');
const first = await service.resolve('wallet', '0xabc');
const second = await service.resolve('wallet', '0xabc');

expect(second).toBe(first);
expect(repository.rows).toHaveLength(1);
Expand All @@ -117,8 +132,8 @@ describe('IdentitySubjectService', () => {
const repository = new FakeSubjectRepository();
const service = subjectService(repository);

const viaEmail = await service.resolve('email', 'member@example.com', 'me***@example.com');
const viaGoogle = await service.resolve('google', 'member@example.com', 'me***@example.com');
const viaEmail = await service.resolve('email', 'member@example.com');
const viaGoogle = await service.resolve('google', 'member@example.com');

expect(viaGoogle).not.toBe(viaEmail);
expect(repository.rows).toHaveLength(2);
Expand All @@ -131,7 +146,7 @@ describe('IdentitySubjectService', () => {
const service = subjectService(repository);

const resolved = await Promise.all(
Array.from({ length: 8 }, () => service.resolve('google', 'google-subject', 'me***@x.com'))
Array.from({ length: 8 }, () => service.resolve('google', 'google-subject'))
);

expect(new Set(resolved).size).toBe(1);
Expand All @@ -153,8 +168,8 @@ describe('IdentitySubjectService', () => {
};
repository.createQueryBuilder = () => builder;

await expect(
subjectService(repository).resolve('google', 'google-subject', 'me***@x.com')
).rejects.toThrow(InternalServerErrorException);
await expect(subjectService(repository).resolve('google', 'google-subject')).rejects.toThrow(
InternalServerErrorException
);
});
});
8 changes: 2 additions & 6 deletions apps/api/src/auth/services/identity-subject.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ export class IdentitySubjectService {
* the loser, and the follow-up read returns the single winning row — so one
* provider identity can never end up with two vaults.
*/
async resolve(
kind: IdentitySubjectKind,
identifier: string,
identifierDisplay: string | null
): Promise<string> {
async resolve(kind: IdentitySubjectKind, identifier: string): Promise<string> {
const identifierHash = this.identityService.hashIdentifier(identifier);
const now = this.clock.now();

Expand All @@ -46,7 +42,7 @@ export class IdentitySubjectService {
.createQueryBuilder()
.insert()
.into(IdentitySubject)
.values({ kind, identifierHash, identifierDisplay, lastUsedAt: now })
.values({ kind, identifierHash, lastUsedAt: now })
.orIgnore()
.returning('id')
.execute();
Expand Down
Loading
Loading