Skip to content

Commit 4e2d29e

Browse files
committed
add unique letter to access token based on type
1 parent 40d1067 commit 4e2d29e

File tree

6 files changed

+59
-10
lines changed

6 files changed

+59
-10
lines changed

integration-tests/tests/api/access-tokens/organization.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ test.concurrent('query GraphQL API on resources without access', async ({ expect
238238
expect(result.createOrganizationAccessToken.error).toEqual(null);
239239
const organizationAccessToken = result.createOrganizationAccessToken.ok!.privateAccessKey;
240240

241+
expect(organizationAccessToken).toMatch(/^hvo1\//);
242+
241243
const projectQuery = await execute({
242244
document: OrganizationProjectTargetQuery,
243245
variables: {

integration-tests/tests/api/access-tokens/personal.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,8 @@ test.concurrent('query GraphQL API on resources with access', async ({ expect })
289289
assertNonNullish(result.createPersonalAccessToken.ok);
290290
const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey;
291291

292+
expect(personalAccessToken).toMatch(/^hvu1\//);
293+
292294
const projectQuery = await execute({
293295
document: OrganizationProjectTargetQuery1,
294296
variables: {

integration-tests/tests/api/access-tokens/project.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ test.concurrent('query GraphQL API on resources with access', async ({ expect })
251251
expect(result.createProjectAccessToken.error).toEqual(null);
252252
const organizationAccessToken = result.createProjectAccessToken.ok!.privateAccessKey;
253253

254+
expect(organizationAccessToken).toMatch(/^hvp1\//);
255+
254256
expect(await fetchPermissions(organizationAccessToken)).toEqual([
255257
{
256258
level: 'PROJECT',

packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,25 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationA
108108
result.accessKey.id,
109109
this.logger,
110110
);
111+
111112
if (!organizationAccessToken) {
112113
return null;
113114
}
114115

116+
// access token uses wrong prefix
117+
// e.g. hvo1/ -> but has userId or hvp1/ but has no projectId
118+
if (
119+
(result.accessKey.category === OrganizationAccessKey.AccessTokenCategory.personal &&
120+
organizationAccessToken.userId == null) ||
121+
(result.accessKey.category === OrganizationAccessKey.AccessTokenCategory.project &&
122+
organizationAccessToken.projectId == null) ||
123+
(result.accessKey.category === OrganizationAccessKey.AccessTokenCategory.organization &&
124+
(organizationAccessToken.projectId || organizationAccessToken.userId))
125+
) {
126+
this.logger.debug('Someone is trying an access token with the wrong prefix.');
127+
return null;
128+
}
129+
115130
// let's hash it so we do not store the plain private key in memory
116131
const key = hashToken(accessToken);
117132
const isHashMatch = await this.organizationAccessTokenValidationCache.getOrSetForever({

packages/services/api/src/modules/organization/lib/organization-access-key.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,33 @@ type DecodedAccessKey = {
1414
id: string;
1515
/** string to compare against the hash within the database ("organization_access_tokens"."hash") */
1616
privateKey: string;
17+
/** The category of the access token */
18+
category: AccessTokenCategory;
1719
};
1820

21+
export const enum AccessTokenCategory {
22+
organization = 'o',
23+
project = 'p',
24+
personal = 'u',
25+
}
26+
27+
type KeyPrefix = `hv${AccessTokenCategory}1/`;
28+
1929
/**
2030
* Prefix for the organization access key.
2131
* We use this prefix so we can quickly identify whether an organization access token.
2232
*
2333
* **hv** -> Hive
24-
* **o** -> Organization
34+
* **o** or **p** or **u** -> Organization or Project or User
2535
* **1** -> Version 1
2636
*/
27-
const keyPrefix = 'hvo1/';
37+
const keyPrefix = (category: AccessTokenCategory): KeyPrefix =>
38+
`hv${category}1/` satisfies KeyPrefix;
2839
const decodeError = { type: 'error' as const, reason: 'Invalid access token.' };
2940

30-
function encode(recordId: string, secret: string) {
41+
function encode(recordId: string, secret: string, category: AccessTokenCategory) {
3142
const keyContents = [recordId, secret].join(':');
32-
return keyPrefix + btoa(keyContents);
43+
return keyPrefix(category) + btoa(keyContents);
3344
}
3445

3546
/**
@@ -38,11 +49,21 @@ function encode(recordId: string, secret: string) {
3849
export function decode(
3950
accessToken: string,
4051
): { type: 'error'; reason: string } | { type: 'ok'; accessKey: DecodedAccessKey } {
41-
if (!accessToken.startsWith(keyPrefix)) {
52+
let category: null | AccessTokenCategory = null;
53+
54+
if (accessToken.startsWith(keyPrefix(AccessTokenCategory.organization))) {
55+
category = AccessTokenCategory.organization;
56+
} else if (accessToken.startsWith(keyPrefix(AccessTokenCategory.project))) {
57+
category = AccessTokenCategory.organization;
58+
} else if (accessToken.startsWith(keyPrefix(AccessTokenCategory.personal))) {
59+
category = AccessTokenCategory.organization;
60+
}
61+
62+
if (category === null) {
4263
return decodeError;
4364
}
4465

45-
accessToken = accessToken.slice(keyPrefix.length);
66+
accessToken = accessToken.slice(keyPrefix(category).length);
4667

4768
let str: string;
4869

@@ -62,7 +83,7 @@ export function decode(
6283
const privateKey = parts.at(1);
6384

6485
if (id && privateKey) {
65-
return { type: 'ok', accessKey: { id, privateKey } } as const;
86+
return { type: 'ok', accessKey: { id, privateKey, category } } as const;
6687
}
6788

6889
return decodeError;
@@ -71,13 +92,13 @@ export function decode(
7192
/**
7293
* Creates a new organization access key/token for a provided UUID.
7394
*/
74-
export async function create(id: string) {
95+
export async function create(id: string, category: AccessTokenCategory) {
7596
const secret = Crypto.createHash('sha256')
7697
.update(Crypto.randomBytes(20).toString())
7798
.digest('hex');
7899

79100
const hash = await bcrypt.hash(secret, await bcrypt.genSalt());
80-
const privateAccessToken = encode(id, secret);
101+
const privateAccessToken = encode(id, secret, category);
81102
const firstCharacters = privateAccessToken.substr(0, 10);
82103

83104
return {

packages/services/api/src/modules/organization/providers/organization-access-tokens.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,14 @@ export class OrganizationAccessTokens {
403403
assignedResources: ResourceAssignmentGroup;
404404
}) {
405405
const id = crypto.randomUUID();
406-
const accessKey = await OrganizationAccessKey.create(id);
406+
const accessKey = await OrganizationAccessKey.create(
407+
id,
408+
args.userId
409+
? OrganizationAccessKey.AccessTokenCategory.personal
410+
: args.projectId
411+
? OrganizationAccessKey.AccessTokenCategory.project
412+
: OrganizationAccessKey.AccessTokenCategory.organization,
413+
);
407414

408415
const result = await this.pool.maybeOne<unknown>(sql`
409416
INSERT INTO "organization_access_tokens" (

0 commit comments

Comments
 (0)