diff --git a/src/extensions/openProjectApi.ts b/src/extensions/openProjectApi.ts index e9b1a10..0dadd58 100644 --- a/src/extensions/openProjectApi.ts +++ b/src/extensions/openProjectApi.ts @@ -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()) ); @@ -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", @@ -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; @@ -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", @@ -119,10 +106,10 @@ export class OpenProjectApi implements Extension { * Store data to the API. The data is a YDoc update */ async onStoreDocument(data: onStoreDocumentPayload): Promise { - const { documentId, opBasePath, readonly } = data.context; + const { resourceUrl, readonly } = data.context; - 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) { @@ -130,8 +117,7 @@ export class OpenProjectApi implements Extension { 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"); @@ -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", diff --git a/test/extensions/openProjectApi.test.ts b/test/extensions/openProjectApi.test.ts index 2e3c710..1ecaa8e 100644 --- a/test/extensions/openProjectApi.test.ts +++ b/test/extensions/openProjectApi.test.ts @@ -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 + 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 () => { @@ -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 () => { @@ -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", @@ -124,43 +109,25 @@ 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 () => { @@ -168,7 +135,6 @@ describe("OpenProjectApi", () => { ok: true, status: 200, json: () => Promise.resolve({ - title: "TheDocName", _links: { self: { href: "/api/v3/documents/121" } } @@ -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); @@ -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); @@ -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; @@ -248,7 +210,7 @@ describe("OpenProjectApi", () => { method: "GET", headers: { "Content-Type": "application/json", - "Authorization": expect.stringContaining("Bearer"), + "Authorization": "Bearer superValidToken", }, } ); @@ -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; @@ -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;