Skip to content

Return 401 if authentication is not provided #1488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

sadiqkhoja
Copy link
Contributor

@sadiqkhoja sadiqkhoja commented May 2, 2025

Prerequisite for getodk/central#1001

What has been done to verify that this works as intended?

All tests are passing.

Some tests are updated to reflect the correct requirement.

Why is this the best possible solution? Were any other approaches considered?

It is necessary to distinguish between 401 and 403, previously we were relying on resources to perform authorization even when authentication failed.

How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?

This PR shouldn't be merged until corresponding frontend changes are ready. There are some places where frontend code expects 403, that needs to be updated.

Does this change require updates to the API documentation? If so, please update docs/api.yaml as part of this PR.

No

Before submitting this PR, please make sure you have:

  • run make test and confirmed all checks still pass OR confirm CircleCI build passes
  • verified that any code from external sources are properly credited in comments or that everything is internally sourced

@sadiqkhoja sadiqkhoja force-pushed the fixes/return-401-on-cookie-auth branch from 3f2926d to 82217e7 Compare May 2, 2025 05:53
@@ -499,7 +507,7 @@ describe('/projects/:id/assignments', () => {
it('should assign the actor by role numeric id', testService((service) =>
service.login('bob', (asBob) =>
Promise.all([
service.get('/v1/roles/manager').expect(200).then(({ body }) => body.id),
asBob.get('/v1/roles/manager').expect(200).then(({ body }) => body.id),
Copy link
Contributor

Choose a reason for hiding this comment

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

This endpoint does not currently require authentication. How does that expectation changing relate to 401 vs 403 errors?

Copy link
Member

Choose a reason for hiding this comment

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

Those two changes (more 401 errors + requiring auth for /v1/roles) both touch on the theme of anonymity. However, I agree that they're also conceptually distinct. If it's easy to pull the two changes apart, that'd make this PR a little smaller, which sounds nice. I'm also OK with reviewing both changes as part of this PR. I like the idea of having the /v1/roles endpoints check auth.

@@ -128,9 +125,6 @@ const authHandler = ({ Sessions, Users, Auth }, context) => {

// if POST run authentication as usual but we'll have to check CSRF afterwards.
return maybeSession.then((cxt) => { // we have to use cxt rather than context for the linter
// if authentication failed anyway, just do nothing.
if ((cxt == null) || cxt.auth.session.isEmpty()) return;
Copy link
Contributor

Choose a reason for hiding this comment

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

Where is an "empty" session now handled?

Copy link
Member

Choose a reason for hiding this comment

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

I think the callback passed to authBySessionToken() handles that case.

const preprocessors = [ emptyAuthInjector, authHandler, queryOptionsHandler, userAgentHandler ];
const endpoint = builder(container, preprocessors);
const anonymousEndpoint = builder(container, without([authHandler], preprocessors));
const preprocessors = [ queryOptionsHandler, userAgentHandler ];
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const preprocessors = [ queryOptionsHandler, userAgentHandler ];
const commonPreprocessors = [ queryOptionsHandler, userAgentHandler ];


////////////////////////////////////////////////////////////////////////////////
// SUBMISSIONS (OPENROSA)

const openRosaSubmission = (path, draft, getForm) => {
const endpointByTestPath = path.startsWith('/test/:key') ? anonymousEndpoint : endpoint;
Copy link
Contributor

Choose a reason for hiding this comment

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

Would this code be more intuitive if the endpoint implementation was passed in rather than being derived from path?

e.g.

const openRosaSubmission = (endpoint, path, draft, getForm) => {

or

const openRosaSubmission = (path, { isDraft, requiresAuth }, getForm) => {

Copy link
Member

Choose a reason for hiding this comment

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

I like the idea of making this an explicit argument (either of the suggestions above). 👍

Projects.getById(params.projectId)
.then(getOrNotFound)
.then(() => (params.xmlFormId ? getForm(auth, params, params.xmlFormId, Forms) : resolve()))
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not obvious how this change is related to 401 responses; any pointers?

service.post('/v1/projects/1/app-users')
.send({ displayName: 'test1' })
.expect(403)));
.expect(401)));
Copy link
Contributor

Choose a reason for hiding this comment

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

Would there be value in a corresponding test case for authenticated users who are unauthorised?

Copy link
Member

Choose a reason for hiding this comment

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

Similar question from me. I'd expect every endpoint that calls auth.canOrReject() to have a test asserting a 403. This test now never reaches the actual endpoint logic: it's just being stopped by the preprocessor, which we test elsewhere.

@alxndrsn
Copy link
Contributor

alxndrsn commented May 2, 2025

From https://www.rfc-editor.org/rfc/rfc9110#status.401:

The server generating a 401 response MUST send a WWW-Authenticate header field (Section 11.6.1) containing at least one challenge applicable to the target resource.

Is this relevant, and if so is it covered by this PR?

@alxndrsn
Copy link
Contributor

alxndrsn commented May 2, 2025

Does this change require updates to the API documentation? If so, please update docs/api.yaml as part of this PR.

No

Checking one endpoint at random, it looks like some API docs might need updates:

central-backend/docs/api.yaml

Lines 1965 to 1970 in 889d208

403:
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/Error403'

@matthew-white
Copy link
Member

The server generating a 401 response MUST send a WWW-Authenticate header field …

Is this relevant, and if so is it covered by this PR?

I don't think we ever send that header, so I don't think this PR needs to do so. How about we continue discussing that header in #851?

Copy link
Member

Choose a reason for hiding this comment

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

For the GET /users endpoint, could you remove the auth.isAuthenticated check? Since the endpoint uses endpoint, we can be sure that it's authenticated.

Copy link
Member

Choose a reason for hiding this comment

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

Or maybe it's a nice safeguard? It sort of feels redundant to me now.

@@ -54,8 +54,11 @@ module.exports = (service, endpoint) => {
.then((token) => mail(savedUser.email, 'accountCreated', { token })))
.then(always(savedUser)))));

const endpointByInvalidateQuery = (cb) => (req, ...rest) =>
Copy link
Member

Choose a reason for hiding this comment

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

endpointByInvalidateQuery and endpointByTestPath above make me think that if/when we update endpoint to accept an options object, we should make the anonymous option take a callback.

Maybe it'd be reasonable to do something like that now? What if anonymousEndpoint accepted a function returning true/false, and then anonymousEndpoint either called authHandler or not based on the result? We could even make the function required, since Ramda F would be easy to pass in.

Just a thought, I'm also happy with the approach here. 👍

Comment on lines +481 to +482
service.login('alice', (asAlice) =>
service.login('bob', (asBob) =>
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't need to change, but just FYI, you can pass an array to service.login():

service.login(['alice', 'bob'], (asAlice, asBob) =>

@@ -181,7 +181,7 @@ describe('api: /sessions', () => {
describe('/:token DELETE', () => {
it('should return a 403 if the token does not exist', testService((service) =>
service.delete('/v1/sessions/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
.expect(403)));
.expect(401)));
Copy link
Member

@matthew-white matthew-white May 2, 2025

Choose a reason for hiding this comment

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

I feel like this test is looking at nonexistence, not lack of authentication. I think the test should have Alice send the request, then assert that it's a 403. This endpoint is unusual in that it returns a 403 in the nonexistence case instead of a 404.

@@ -162,7 +162,7 @@ describe('api: /sessions', () => {
service.get('/v1/sessions/restore')
.set('X-Forwarded-Proto', 'https')
.set('Cookie', 'session: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
Copy link
Member

Choose a reason for hiding this comment

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

This cookie looks unusual, shouldn't it be = instead of :?

@@ -162,7 +162,7 @@ describe('api: /sessions', () => {
service.get('/v1/sessions/restore')
.set('X-Forwarded-Proto', 'https')
.set('Cookie', 'session: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
.expect(404)));
.expect(401)));
Copy link
Member

@matthew-white matthew-white May 2, 2025

Choose a reason for hiding this comment

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

This is probably on your radar already, but the change from 404 to 401 will require a change to restoreSession() in Frontend.

@@ -301,7 +289,7 @@ describe('preprocessors', () => {
)
)).should.be.rejectedWith(Problem, { problemCode: 401.2 }));

it('should do nothing on cookie auth with incorrect session token for non-GET requests', () =>
it('should reject cookie auth with incorrect session token for non-GET requests', () =>
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we can remove the part of the test description about "for non-GET requests". We will now fail even GET requests.

Copy link
Member

@matthew-white matthew-white left a comment

Choose a reason for hiding this comment

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

The approach that's being taken here seems like a good one to me. I left some questions/thoughts above, but I think this will be a nice change for Central.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants