feat: resource restriction in presign token (#2150)

This commit is contained in:
Ted Liang
2025-12-08 12:55:54 +11:00
committed by GitHub
parent f70b76d8b8
commit dcaecf1fc5
11 changed files with 166 additions and 80 deletions
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
const result = await verifyEmbeddingPresignToken({ token, scope: `documentId:${id}` }).catch(
() => null,
);
if (!result) {
throw new Error('Invalid token');
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
const result = await verifyEmbeddingPresignToken({ token, scope: `templateId:${id}` }).catch(
() => null,
);
if (!result) {
throw new Error('Invalid token');
@@ -1,6 +1,9 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from 'playwright-core';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { CreateEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import type { VerifyEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -17,18 +20,7 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
const response = await createPresignToken(request, token);
const responseData = await response.json();
@@ -54,19 +46,9 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 120, // 2 hours
},
},
);
const response = await createPresignToken(request, token, {
expiresIn: 120, // 2 hours
});
const responseData = await response.json();
@@ -92,19 +74,9 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 0, // Immediate expiration
},
},
);
const response = await createPresignToken(request, token, {
expiresIn: 0, // Immediate expiration
});
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
@@ -129,18 +101,7 @@ test.describe('Embedding Presign API', () => {
});
// First create a token
const createResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
const createResponse = await createPresignToken(request, token);
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
@@ -150,18 +111,9 @@ test.describe('Embedding Presign API', () => {
const presignToken = createResponseData.token;
// Then verify it
const verifyResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: presignToken,
},
},
);
const verifyResponse = await verifyPresignToken(request, token, {
token: presignToken,
});
expect(verifyResponse.ok()).toBeTruthy();
expect(verifyResponse.status()).toBe(200);
@@ -183,18 +135,87 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: 'invalid-token',
},
},
);
const response = await verifyPresignToken(request, token, {
token: 'invalid-token',
});
const responseData = await response.json();
console.log('Invalid token response:', responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.success).toBe(false);
});
test('verifyEmbeddingPresignToken: should verify a valid scoped token', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// First create a token
const createResponse = await createPresignToken(request, token, {
scope: 'documentId:1',
});
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
console.log('Create response:', createResponseData);
const presignToken = createResponseData.token;
// Then verify it
const verifyResponse = await verifyPresignToken(request, token, {
token: presignToken,
scope: 'documentId:1',
});
expect(verifyResponse.ok()).toBeTruthy();
expect(verifyResponse.status()).toBe(200);
const verifyResponseData = await verifyResponse.json();
console.log('Verify response:', verifyResponseData);
expect(verifyResponseData.success).toBe(true);
});
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// First create a token
const createResponse = await createPresignToken(request, token, {
scope: 'documentId:1',
});
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
console.log('Create response:', createResponseData);
const presignToken = createResponseData.token;
// Then verify it
const response = await verifyPresignToken(request, token, {
token: presignToken,
scope: 'documentId:2',
});
const responseData = await response.json();
@@ -206,3 +227,40 @@ test.describe('Embedding Presign API', () => {
expect(responseData.success).toBe(false);
});
});
const createPresignToken = async (
request: APIRequestContext,
apiToken: string,
data?: Partial<CreateEmbeddingPresignTokenOptions>,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data: {
apiToken,
...data,
},
},
);
};
const verifyPresignToken = async (
request: APIRequestContext,
apiToken: string,
data: VerifyEmbeddingPresignTokenOptions,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data,
},
);
};
@@ -12,11 +12,13 @@ export type CreateEmbeddingPresignTokenOptions = {
* In development mode, can be set to 0 to create a token that expires immediately (for testing)
*/
expiresIn?: number;
scope?: string;
};
export const createEmbeddingPresignToken = async ({
apiToken,
expiresIn,
scope,
}: CreateEmbeddingPresignTokenOptions) => {
try {
// Validate the API token
@@ -40,6 +42,7 @@ export const createEmbeddingPresignToken = async ({
const token = await new SignJWT({
aud: String(validatedToken.teamId ?? validatedToken.userId),
sub: String(validatedToken.id),
scope,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now.toJSDate())
@@ -7,10 +7,12 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type VerifyEmbeddingPresignTokenOptions = {
token: string;
scope?: string;
};
export const verifyEmbeddingPresignToken = async ({
token,
scope,
}: VerifyEmbeddingPresignTokenOptions) => {
// First decode the JWT to get the claims without verification
let decodedToken: JWTPayload;
@@ -81,6 +83,12 @@ export const verifyEmbeddingPresignToken = async ({
});
}
if (decodedToken.scope && scope && decodedToken.scope !== scope) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Presign token scope not matched',
});
}
// Now verify the token with the actual secret
const secret = new TextEncoder().encode(apiToken.token);
@@ -29,7 +29,7 @@ export const createEmbeddingPresignTokenRoute = procedure
});
}
const { expiresIn } = input;
const { expiresIn, scope } = input;
if (IS_BILLING_ENABLED()) {
const token = await getApiTokenByToken({ token: apiToken });
@@ -54,6 +54,7 @@ export const createEmbeddingPresignTokenRoute = procedure
const presignToken = await createEmbeddingPresignToken({
apiToken,
expiresIn,
scope,
});
return { ...presignToken };
@@ -21,6 +21,10 @@ export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
.optional()
.default(60)
.describe('Expiration time in minutes (default: 60, max: 10,080)'),
scope: z
.string()
.optional()
.describe('Resource restriction. Example: documentId:1, templateId:2'),
});
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
@@ -34,7 +34,10 @@ export const updateEmbeddingDocumentRoute = procedure
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const apiToken = await verifyEmbeddingPresignToken({
token: presignToken,
scope: `documentId:${input.documentId}`,
});
const { documentId, title, externalId, recipients, meta } = input;
@@ -33,7 +33,10 @@ export const updateEmbeddingTemplateRoute = procedure
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const apiToken = await verifyEmbeddingPresignToken({
token: presignToken,
scope: `templateId:${input.templateId}`,
});
const { templateId, title, externalId, recipients, meta } = input;
@@ -17,10 +17,11 @@ export const verifyEmbeddingPresignTokenRoute = procedure
.output(ZVerifyEmbeddingPresignTokenResponseSchema)
.mutation(async ({ input }) => {
try {
const { token } = input;
const { token, scope } = input;
const apiToken = await verifyEmbeddingPresignToken({
token,
scope,
}).catch(() => null);
return { success: !!apiToken };
@@ -18,6 +18,7 @@ export const ZVerifyEmbeddingPresignTokenRequestSchema = z.object({
.string()
.min(1, { message: 'Token is required' })
.describe('The presign token to verify'),
scope: z.string().optional().describe('The scope to verify'),
});
export const ZVerifyEmbeddingPresignTokenResponseSchema = z.object({