Repository: solid/community-server (ts)

Issue:
401 responses MUST contain WWW-Authenticate header
> The server generating a 401 response MUST send
   a WWW-Authenticate header field (Section 4.1) containing at least one
   challenge applicable to the target resource.

—https://tools.ietf.org/html/rfc7235#section-3.1

The legacy solid-auth-client [requires](https://github.com/solid/solid-auth-client/blob/main/src/webid-oidc.js#L208-L217) the value to be exactly `Bearer scope="openid webid"`, which is a pain because that's not forward compatible. But we need to set it in any case.

Probably depends on #654.

Pull request:
feat: Create WWW-Authenticate metadata writer
Closes #655.

Depends on #760.

Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 5a80fbcce..090bc1134 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -41,6 +41,7 @@ module.exports = {
     '@typescript-eslint/no-invalid-void-type': 'off',
     // Problems with optional parameters
     '@typescript-eslint/no-unnecessary-condition': 'off',
+    '@typescript-eslint/prefer-optional-chain': 'error',
     '@typescript-eslint/space-before-function-paren': [ 'error', 'never' ],
     '@typescript-eslint/unbound-method': 'off',
     '@typescript-eslint/unified-signatures': 'off',
diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json
index 93a1be725..98f2d0e6c 100644
--- a/config/ldp/metadata-writer/default.json
+++ b/config/ldp/metadata-writer/default.json
@@ -4,7 +4,8 @@
     "files-scs:config/ldp/metadata-writer/writers/constant.json",
     "files-scs:config/ldp/metadata-writer/writers/link-rel.json",
     "files-scs:config/ldp/metadata-writer/writers/mapped.json",
-    "files-scs:config/ldp/metadata-writer/writers/wac-allow.json"
+    "files-scs:config/ldp/metadata-writer/writers/wac-allow.json",
+    "files-scs:config/ldp/metadata-writer/writers/www-auth.json"
   ],
   "@graph": [
     {
@@ -15,7 +16,8 @@
         { "@id": "urn:solid-server:default:MetadataWriter_Constant" },
         { "@id": "urn:solid-server:default:MetadataWriter_Mapped" },
         { "@id": "urn:solid-server:default:MetadataWriter_LinkRel" },
-        { "@id": "urn:solid-server:default:MetadataWriter_WacAllow" }
+        { "@id": "urn:solid-server:default:MetadataWriter_WacAllow" },
+        { "@id": "urn:solid-server:default:MetadataWriter_WwwAuth" }
       ]
     }
   ]
diff --git a/config/ldp/metadata-writer/writers/www-auth.json b/config/ldp/metadata-writer/writers/www-auth.json
new file mode 100644
index 000000000..af206aa04
--- /dev/null
+++ b/config/ldp/metadata-writer/writers/www-auth.json
@@ -0,0 +1,11 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "comment": "Adds the WWW-Authenticate header on 401 responses. The current auth value is required for the legacy solid-auth-client.",
+      "@id": "urn:solid-server:default:MetadataWriter_WwwAuth",
+      "@type": "WwwAuthMetadataWriter",
+      "auth": "Bearer scope=\"openid webid\""
+    }
+  ]
+}
diff --git a/src/index.ts b/src/index.ts
index 6084c0141..13408156f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -97,6 +97,7 @@ export * from './ldp/http/metadata/MetadataParser';
 export * from './ldp/http/metadata/MetadataWriter';
 export * from './ldp/http/metadata/SlugParser';
 export * from './ldp/http/metadata/WacAllowMetadataWriter';
+export * from './ldp/http/metadata/WwwAuthMetadataWriter';
 
 // LDP/HTTP/Response
 export * from './ldp/http/response/CreatedResponseDescription';
diff --git a/src/ldp/http/metadata/WwwAuthMetadataWriter.ts b/src/ldp/http/metadata/WwwAuthMetadataWriter.ts
new file mode 100644
index 000000000..92aa630f9
--- /dev/null
+++ b/src/ldp/http/metadata/WwwAuthMetadataWriter.ts
@@ -0,0 +1,24 @@
+import type { HttpResponse } from '../../../server/HttpResponse';
+import { addHeader } from '../../../util/HeaderUtil';
+import { HTTP } from '../../../util/Vocabularies';
+import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
+import { MetadataWriter } from './MetadataWriter';
+
+/**
+ * Adds the `WWW-Authenticate` header with the injected value in case the response status code is 401.
+ */
+export class WwwAuthMetadataWriter extends MetadataWriter {
+  private readonly auth: string;
+
+  public constructor(auth: string) {
+    super();
+    this.auth = auth;
+  }
+
+  public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
+    const statusLiteral = input.metadata.get(HTTP.terms.statusCodeNumber);
+    if (statusLiteral?.value === '401') {
+      addHeader(input.response, 'WWW-Authenticate', this.auth);
+    }
+  }
+}


Test patch:
diff --git a/test/integration/LdpHandlerWithAuth.test.ts b/test/integration/LdpHandlerWithAuth.test.ts
index 05c4eb1d1..b8570981c 100644
--- a/test/integration/LdpHandlerWithAuth.test.ts
+++ b/test/integration/LdpHandlerWithAuth.test.ts
@@ -188,4 +188,16 @@ describe.each(stores)('An LDP handler with auth using %s', (name, { storeConfig,
     // Close response
     await response.text();
   });
+
+  it('returns the legacy WWW-Authenticate header on 401 requests.', async(): Promise<void> => {
+    await aclHelper.setSimpleAcl(baseUrl, {
+      permissions: {},
+      agentClass: 'agent',
+      accessTo: true,
+    });
+
+    const response = await fetch(`${baseUrl}.acl`);
+    expect(response.status).toBe(401);
+    expect(response.headers.get('www-authenticate')).toBe('Bearer scope="openid webid"');
+  });
 });
diff --git a/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts b/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts
index 9788bdbf1..874b18fd4 100644
--- a/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts
+++ b/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts
@@ -4,7 +4,7 @@ import { RepresentationMetadata } from '../../../../../src/ldp/representation/Re
 import type { HttpResponse } from '../../../../../src/server/HttpResponse';
 import { ACL, AUTH } from '../../../../../src/util/Vocabularies';
 
-describe('WacAllowMetadataWriter', (): void => {
+describe('A WacAllowMetadataWriter', (): void => {
   const writer = new WacAllowMetadataWriter();
   let response: HttpResponse;
 
diff --git a/test/unit/ldp/http/metadata/WwwAuthMetadataWriter.test.ts b/test/unit/ldp/http/metadata/WwwAuthMetadataWriter.test.ts
new file mode 100644
index 000000000..31af4f1e8
--- /dev/null
+++ b/test/unit/ldp/http/metadata/WwwAuthMetadataWriter.test.ts
@@ -0,0 +1,36 @@
+import { createResponse } from 'node-mocks-http';
+import { WwwAuthMetadataWriter } from '../../../../../src/ldp/http/metadata/WwwAuthMetadataWriter';
+import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata';
+import type { HttpResponse } from '../../../../../src/server/HttpResponse';
+import { HTTP } from '../../../../../src/util/Vocabularies';
+
+describe('A WwwAuthMetadataWriter', (): void => {
+  const auth = 'Bearer scope="openid webid"';
+  const writer = new WwwAuthMetadataWriter(auth);
+  let response: HttpResponse;
+
+  beforeEach(async(): Promise<void> => {
+    response = createResponse();
+  });
+
+  it('adds no header if there is no relevant metadata.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata();
+    await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
+    expect(response.getHeaders()).toEqual({ });
+  });
+
+  it('adds no header if the status code is not 401.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ [HTTP.statusCodeNumber]: '403' });
+    await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
+    expect(response.getHeaders()).toEqual({ });
+  });
+
+  it('adds a WWW-Authenticate header if the status code is 401.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ [HTTP.statusCodeNumber]: '401' });
+    await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
+
+    expect(response.getHeaders()).toEqual({
+      'www-authenticate': auth,
+    });
+  });
+});