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
46 changes: 16 additions & 30 deletions src/extensions/openProjectApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,21 @@ export class OpenProjectApi implements Extension {
* Authenticate the user by validating the token and document access
*/
async onAuthenticate(data: onAuthenticatePayload) {
const { token, documentName, requestParameters } = data;
const documentId = requestParameters.get("document_id");
const opBasePath = requestParameters.get("openproject_base_path");
const { token, documentName } = data;
const resourceUrl = documentName;

if (!token) {
throw new Error('Unauthorized: Token missing.');
}
const decryptedToken = decryptToken(token);

if (!opBasePath) {
throw new Error('Unauthorized: Base URL missing.');
}

// Validate opBasePath against allowed domains
const allowedDomains = process.env.ALLOWED_DOMAINS?.split(',') || [];
if (allowedDomains.length <= 0) {
throw new Error('Unauthorized: No allowed domains configured.');
}

try {
const url = new URL(opBasePath);
const url = new URL(resourceUrl);
const isAllowed = allowedDomains.some(domain =>
url.hostname === domain.trim() || url.hostname.endsWith('.' + domain.trim())
);
Expand All @@ -57,8 +51,7 @@ export class OpenProjectApi implements Extension {
throw error;
}

const targetUrl = `${opBasePath}/api/v3/documents/${documentId}`;
const response = await fetch(targetUrl, {
const response = await fetch(resourceUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Expand All @@ -67,18 +60,13 @@ export class OpenProjectApi implements Extension {
});

if (!response.ok) {
throw new Error('Unauthorized: Invalid token.');
throw new Error('Unauthorized: Invalid token or document access denied.');
}
const jsonData = await response.json() as ApiResponseDocument;

if (!jsonData.title || jsonData.title !== documentName) {
throw new Error('Unauthorized: Document access denied.');
}

data.documentName = jsonData.title;
data.context.documentId = documentId;
// data.documentName = resourceUrl;
data.context.resourceUrl = resourceUrl;
data.context.token = decryptedToken;
data.context.opBasePath = opBasePath;
if (!jsonData._links?.update) {
// https://tiptap.dev/docs/hocuspocus/guides/auth#read-only-mode
data.connectionConfig.readOnly = true;
Expand All @@ -90,12 +78,11 @@ export class OpenProjectApi implements Extension {
* Retrieve data from the API. This should return the YDoc data
*/
async onLoadDocument(data: onLoadDocumentPayload) {
const { documentId, opBasePath } = data.context;
const { resourceUrl } = data.context;

const targetUrl = `${opBasePath}/api/v3/documents/${documentId}`;
console.log(`GET ${targetUrl}`);
console.log(`GET ${resourceUrl}`);

const response = await fetch(targetUrl, {
const response = await fetch(resourceUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Expand All @@ -119,19 +106,18 @@ export class OpenProjectApi implements Extension {
* Store data to the API. The data is a YDoc update
*/
async onStoreDocument(data: onStoreDocumentPayload): Promise<void> {
const { documentId, opBasePath, readonly } = data.context;
const { resourceUrl, readonly } = data.context;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so much more elegant 😍


if (!documentId || !opBasePath) {
console.warn("Missing documentId or opBasePath in context. Skipping store.");
if (!resourceUrl) {
console.warn("Missing parameters in context. Skipping store.");
return;
}
if (readonly) {
console.warn("Readonly user cannot make requests to store the document");
return;
}

const targetUrl = `${opBasePath}/api/v3/documents/${documentId}`;
console.log(`PATCH ${targetUrl}`);
console.log(`PATCH ${resourceUrl}`);

const base64Data = Buffer.from(Y.encodeStateAsUpdate(data.document)).toString("base64");

Expand All @@ -144,7 +130,7 @@ export class OpenProjectApi implements Extension {
// @ts-expect-error BlockNote types are complicated
const markdownData = await editor.blocksToMarkdownLossy(editorData);

const response = await fetch(targetUrl, {
const response = await fetch(resourceUrl, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Expand Down
95 changes: 28 additions & 67 deletions test/extensions/openProjectApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,17 @@ describe("OpenProjectApi", () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: null,
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Token missing.");
});

test("when the opBasePath is not present throw an error", async () => {
test("when the token is invalid", async () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
requestParameters: new URLSearchParams({ document_id: "121" }),
// Invalid token, generated with a different secret

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this comment!

token: "5Sm4blMLhP8PFS67xw==--br8L/7YDX3rbTLpT--HHEi+SnNdmHmH90N3mHY9A==",
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Base URL missing.");
).rejects.toThrowError("Unsupported state or unable to authenticate data");
});

test("when ALLOWED_DOMAINS is not configured throw an error", async () => {
Expand All @@ -44,58 +43,45 @@ describe("OpenProjectApi", () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: No allowed domains configured.");
});

test("when opBasePath has invalid format throw an error", async () => {
test("when the resourceUrl has invalid format throw an error", async () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "not-a-valid-url" }),
documentName: "not a valid url",
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Invalid base URL format.");
});

test("when opBasePath domain is not in ALLOWED_DOMAINS throw an error", async () => {
test("when the resourceUrl domain is not in ALLOWED_DOMAINS throw an error", async () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://malicious.com" }),
documentName: "https://malicious.com/something/1",
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Invalid base URL domain.");
});

test("when opBasePath subdomain matches ALLOWED_DOMAINS it should be accepted", async () => {
test("when the resourceUrl subdomain matches ALLOWED_DOMAINS it should be accepted", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ title: "TheDocName" }),
json: () => Promise.resolve({}),
});

const data = {
context: {},
connectionConfig: {},
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "TheDocName",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://subdomain.test.api" }),
documentName: "https://subdomain.test.api/api/v3/documents/1",
} as unknown as onAuthenticatePayload;

await new OpenProjectApi().onAuthenticate(data);

expect(data.context.opBasePath).toEqual("https://subdomain.test.api");
});

test("when the token is invalid", async () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
// Invalid token, generated with a different secret
token: "5Sm4blMLhP8PFS67xw==--br8L/7YDX3rbTLpT--HHEi+SnNdmHmH90N3mHY9A==",
documentName: "TheDocName",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unsupported state or unable to authenticate data");
expect(data.context.resourceUrl).toEqual("https://subdomain.test.api/api/v3/documents/1");
});

test("when the server does not authorize the request throw an error", async () => {
Expand All @@ -107,10 +93,9 @@ describe("OpenProjectApi", () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "TheDocName",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
documentName: "https://test.api/api/v3/documents/121",
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Invalid token.");
).rejects.toThrowError("Unauthorized: Invalid token or document access denied.");

expect(fetchMock).toHaveBeenCalledWith(
"https://test.api/api/v3/documents/121",
Expand All @@ -124,51 +109,32 @@ describe("OpenProjectApi", () => {
);
});

test("when the document title does not match the requested documentName, throw an error", async () => {
test("when the token is valid set the context", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ title: "DifferentDocName" }),
});

await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "TheDocName",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Document access denied.");
});

test("when the token is valid and document title matches, set the document_id, token and opBasePath on the context", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ title: "TheDocName" }),
json: () => Promise.resolve({}),
});

const data = {
context: {},
connectionConfig: {},
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "TheDocName",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
documentName: "https://test.api/api/v3/documents/121",
} as unknown as onAuthenticatePayload;

await new OpenProjectApi().onAuthenticate(data);

expect(data.context.documentId).toEqual("121");
expect(data.context.resourceUrl).toEqual("https://test.api/api/v3/documents/121");
expect(data.context.token).toEqual("valid_token");
expect(data.context.opBasePath).toEqual("https://test.api");
expect(data.documentName).toEqual("TheDocName");
expect(data.documentName).toEqual("https://test.api/api/v3/documents/121");
});

test("when there is no update link, setup the connection as readonly", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({
title: "TheDocName",
_links: {
self: { href: "/api/v3/documents/121" }
}
Expand All @@ -179,8 +145,7 @@ describe("OpenProjectApi", () => {
context: {},
connectionConfig: {},
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "TheDocName",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
documentName: "https://test.api/api/v3/documents/121",
} as unknown as onAuthenticatePayload;

await new OpenProjectApi().onAuthenticate(data);
Expand All @@ -206,8 +171,7 @@ describe("OpenProjectApi", () => {
context: {},
connectionConfig: {},
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "TheDocName",
requestParameters: new URLSearchParams({ document_id: "121", openproject_base_path: "https://test.api" }),
documentName: "https://test.api/api/v3/documents/121",
} as unknown as onAuthenticatePayload;

await new OpenProjectApi().onAuthenticate(data);
Expand All @@ -228,14 +192,12 @@ describe("OpenProjectApi", () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({
contentBinary: base64Update
}),
json: () => Promise.resolve({ contentBinary: base64Update }),
});

const targetDoc = new Y.Doc();
const data = {
context: { documentId: "121", token: "testToken", opBasePath: "https://test.api" },
context: { token: "superValidToken", resourceUrl: "https://test.api/api/v3/documents/121" },
document: targetDoc,
} as onLoadDocumentPayload;

Expand All @@ -248,7 +210,7 @@ describe("OpenProjectApi", () => {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": expect.stringContaining("Bearer"),
"Authorization": "Bearer superValidToken",
},
}
);
Expand All @@ -265,7 +227,7 @@ describe("OpenProjectApi", () => {
});

const data = {
context: { documentId: "121", token: "testToken", opBasePath: "https://test.api" },
context: { token: "superValidToken", resourceUrl: "https://test.api/api/v3/documents/121" },
document: new Y.Doc(),
} as onLoadDocumentPayload;

Expand Down Expand Up @@ -305,10 +267,9 @@ describe("OpenProjectApi", () => {

const data = {
context: {
documentId: "121",
token: "testToken",
opBasePath: "https://test.api",
readonly: false
token: "superValidToken",
resourceUrl: "https://test.api/api/v3/documents/121",
readonly: false,
},
document,
} as onStoreDocumentPayload;
Expand Down