Skip to content

Commit 243f30d

Browse files
authored
Feat/argon2id apikey hashing 222 (#408)
* feat(backend): add OIDC JWT issuance and revocation * Suppress CodeQL false positive for API key fingerprint * Fix OIDC CI checks * Add OIDC token coverage * Move CodeQL suppression before fingerprint hash * Hash API keys with Argon2id * Update generated OpenAPI spec for OAuth routes
1 parent df93da3 commit 243f30d

21 files changed

Lines changed: 1531 additions & 49 deletions

app/backend/cache/redis.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
2424
this.client?.disconnect();
2525
}
2626

27+
getClient(): Redis {
28+
return this.client;
29+
}
30+
2731
/**
2832
* Retrieve and deserialise a cached value.
2933
* Returns `null` on cache miss or if Redis is unavailable.
@@ -93,4 +97,4 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
9397
return 0;
9498
}
9599
}
96-
}
100+
}

app/backend/package-lock.json

Lines changed: 133 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@sendgrid/mail": "^8.1.5",
4949
"@stellar/stellar-sdk": "^14.6.1",
5050
"@willsoto/nestjs-prometheus": "^6.0.2",
51+
"argon2": "^0.45.1",
5152
"axios": "^1.13.6",
5253
"bull": "^4.16.5",
5354
"bullmq": "^5.79.0",
@@ -57,6 +58,7 @@
5758
"dotenv": "^17.2.3",
5859
"helmet": "^8.1.0",
5960
"ioredis": "^5.9.2",
61+
"jose": "^4",
6062
"openai": "^6.33.0",
6163
"pino": "^10.3.0",
6264
"pino-http": "^11.0.0",
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import argon2 from 'argon2';
2+
import type { HashOptions } from 'argon2';
3+
4+
const ARGON2ID_OPTIONS: HashOptions = {
5+
type: argon2.argon2id,
6+
memoryCost: 65536,
7+
timeCost: 3,
8+
parallelism: 1,
9+
hashLength: 32,
10+
};
11+
12+
export function maskApiKeyPreview(rawKey: string): string {
13+
const prefix = rawKey.slice(0, 6);
14+
const suffix = rawKey.slice(-4);
15+
return `${prefix}...${suffix}`;
16+
}
17+
18+
export function hashApiKey(rawKey: string): Promise<string> {
19+
return argon2.hash(rawKey, ARGON2ID_OPTIONS);
20+
}
21+
22+
export function isArgon2idHash(
23+
value: string | null | undefined,
24+
): value is string {
25+
return typeof value === 'string' && value.startsWith('$argon2id$');
26+
}
27+
28+
export async function verifyApiKeyHash(
29+
hash: string | null | undefined,
30+
rawKey: string,
31+
): Promise<boolean> {
32+
if (!isArgon2idHash(hash)) return false;
33+
34+
try {
35+
return await argon2.verify(hash, rawKey);
36+
} catch {
37+
return false;
38+
}
39+
}

app/backend/src/api-keys/api-keys.service.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
33
import { ApiKeysService } from './api-keys.service';
44
import { PrismaService } from '../prisma/prisma.service';
55
import { AppRole } from '../auth/app-role.enum';
6+
import { verifyApiKeyHash } from './api-key-hash.util';
67

78
describe('ApiKeysService', () => {
89
let service: ApiKeysService;
@@ -55,6 +56,11 @@ describe('ApiKeysService', () => {
5556

5657
expect(result.id).toBe('k1');
5758
expect(result.apiKey).toMatch(/^s2s_/);
59+
const createCall = mockPrisma.apiKey.create.mock.calls[0][0];
60+
expect(createCall.data.keyHash).toMatch(/^\$argon2id\$/);
61+
await expect(
62+
verifyApiKeyHash(createCall.data.keyHash, result.apiKey),
63+
).resolves.toBe(true);
5864
});
5965

6066
it('requires ngoId for NGO role', async () => {
@@ -177,6 +183,11 @@ describe('ApiKeysService', () => {
177183

178184
expect(result.replacement.id).toBe('new');
179185
expect(result.apiKey).toMatch(/^s2s_/);
186+
const createCall = tx.apiKey.create.mock.calls[0][0];
187+
expect(createCall.data.keyHash).toMatch(/^\$argon2id\$/);
188+
await expect(
189+
verifyApiKeyHash(createCall.data.keyHash, result.apiKey),
190+
).resolves.toBe(true);
180191
expect(tx.apiKey.update).toHaveBeenCalledWith(
181192
expect.objectContaining({
182193
where: { id: 'old' },

0 commit comments

Comments
 (0)