Skip to content

Return 401 if authentication is not provided#1488

Merged
sadiqkhoja merged 8 commits intogetodk:masterfrom
sadiqkhoja:fixes/return-401-on-cookie-auth
Aug 8, 2025
Merged

Return 401 if authentication is not provided#1488
sadiqkhoja merged 8 commits intogetodk:masterfrom
sadiqkhoja:fixes/return-401-on-cookie-auth

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
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.

// 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.

Copy link
Contributor

Choose a reason for hiding this comment

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

Then shouldn't authBySessionToken() be returning the session itself, rather than an Option? And L111 would change from:

-const maybeSession = authBySessionToken(token, () => reject(Problem.user.authenticationFailed()));
+const session = authBySessionToken(token, () => reject(Problem.user.authenticationFailed()));

service.get(path, endpointByTestPath.openRosa(({ Projects, Forms }, { params, auth }) =>
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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Besides checking for other errors, calling getForm here performs authorization for this endpoint. Currently if an user calls HEAD/GET /v1/projects/1/forms/restricted-form and project 1 exists then they will always get 200 response, even if they don't have any authorization on the Form or even if Form doesn't exist.

I am open to revert this change as well and create a separate PR

Copy link
Member

Choose a reason for hiding this comment

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

I do think it'd be nice to separate it out if that's not too difficult. I was trying to think through the implications of the change. I don't even remember how this endpoint is used. Is there maybe a reason why it needs to be ultra-performant and shouldn't be doing as many checks? Though I guess if it's checking the project, it should probably check the form as well. 🤔 Anyway, I feel like discussing in a separate PR could be nice!

@alxndrsn

This comment was marked as resolved.

@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

This comment was marked as resolved.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed.

.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) =>

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 :?

.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.

Copy link
Contributor Author

@sadiqkhoja sadiqkhoja May 16, 2025

Choose a reason for hiding this comment

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

)).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.

@matthew-white
Copy link
Member

Even though I know getodk/central#1001 is a large part of the motivation for this PR, I think it'd be easiest to track this change on its own. I'll go ahead and add it to the project.

@matthew-white matthew-white moved this from 🕒 backlog to ✏️ in progress in ODK Central May 19, 2025
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.

Notes from reviewing changes to lib/, now onward to test/.

// fail the request unless we are under HTTPS.
if ((context.protocol !== 'https') && (context.headers['x-forwarded-proto'] !== 'https'))
return;
return reject(Problem.user.httpsOnly());
Copy link
Member

Choose a reason for hiding this comment

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

How about updating the comment above:

Cookie Auth, which is more relaxed about not doing anything on failures.

I guess cookie auth is now not as relaxed.

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 we can probably get rid of this line now. I don't think there are any conditions where the preprocessor would pass, but the endpoint would reject here:

.then(rejectIf((() => auth.actor.isEmpty()), noargs(Problem.user.openRosaAuthenticationRequired)))

Today, most endpoints return a 403 if no auth is provided. However, IIRC we specifically needed this endpoint to return a 401. I think a 401 causes Collect and/or Enketo to do something special like prompt for credentials or show a special error.

But now we will return a 401 from the preprocessor, so we don't need to do anything special here.

Copy link
Member

@matthew-white matthew-white May 19, 2025

Choose a reason for hiding this comment

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

Now that the preprocessor ensures that auth is present, what do you think of the following pattern in /v1/users/current? I feel like the .orElse() is impossible now, but I'm not totally sure how we'd do things differently.

// Returns the currently authed actor.
service.get('/users/current', endpoint(({ Auth, Users, UserPreferences }, { auth, queryOptions }) =>
  auth.actor.map((actor) =>
    { /* some code */ })
    .orElse(Problem.user.notFound())));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have refactored this function completely on async/await pattern.

Copy link
Member

Choose a reason for hiding this comment

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

Also related to /v1/users/current, I was remembering that pyODK uses that endpoint to check whether the session token is still active. If the session token isn't active, the endpoint would return a 404 before, but now it will return a 401. I took a look at the pyODK code to make sure that it wasn't looking specifically for a 404 (it's not): https://github.com/getodk/pyodk/blob/07a6fb568bd60a5c7657f05be3f9ab016fdfbf3e/pyodk/_endpoints/auth.py#L25-L34

Copy link
Contributor Author

@sadiqkhoja sadiqkhoja left a comment

Choose a reason for hiding this comment

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

I have removed checkAuth from user-preferences.js as well.

And added couple of additional tests for /users/current

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have refactored this function completely on async/await pattern.

];

describe('anonymous endpoints', () => {
anonymousEndpoints.filter(e => !e.skipForOidc).forEach(({ method, url }) => {
Copy link
Member

Choose a reason for hiding this comment

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

I may be missing something, but I don't see how the tests for which skipForOidc is true would ever be run. They're filtered out here, and I don't see anywhere else where they are used. I feel like process.env.TEST_AUTH needs to be used somehow, something like:

const endpointsToTest = process.env.TEST_AUTH === 'oidc'
  ? anonymousEndpoints.filter(e => !e.skipForOidc)
  : anonymousEndpoints;

Comment on lines 344 to +347
it('should fail the request if invalidation is requested but not allowed', testService((service) =>
service.post('/v1/users/reset/initiate?invalidate=true')
.send({ email: 'alice@getodk.org' })
.expect(403)));
.expect(401)));
Copy link
Member

Choose a reason for hiding this comment

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

How about adding a test that Chelsa gets a 403 if she specifies invalidate=true to try to invalidate Alice's password? I took a quick look, but I didn't see an existing test that was exactly like that.

{ method: 'PUT', url: '/v1/users/1/password', skipForOidc: true },
{ method: 'GET', url: '/v1/users/current' },
{ method: 'POST', url: '/v1/users/reset/verify', skipForOidc: true }
];
Copy link
Member

Choose a reason for hiding this comment

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

How about adding url: '/v1/users/reset/initiate?invalidate=true to this list? Unless it's already covered sufficiently elsewhere?

Comment on lines -320 to +323
it('should return a 404 if no token was specified', testService(service =>
it('should return a 401 if no token was specified', testService(service =>
service.delete('/v1/sessions/current')
.expect(404)));
.expect(401)));
Copy link
Member

Choose a reason for hiding this comment

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

Looking at the endpoint, I feel like it's still possible for a 404 to happen. If the user authenticates using Basic auth, then auth.actor will exist, but auth.session will not. In that case, it won't be a 401 (because there is auth), but it will be a 404 (because there's no current session).

@sadiqkhoja sadiqkhoja force-pushed the fixes/return-401-on-cookie-auth branch 2 times, most recently from 30e59d0 to dcc9bb4 Compare May 27, 2025 14:16
@sadiqkhoja sadiqkhoja force-pushed the fixes/return-401-on-cookie-auth branch from dcc9bb4 to e8c16d0 Compare May 27, 2025 14:18
@lognaturel lognaturel requested a review from matthew-white June 5, 2025 22:04
@matthew-white matthew-white moved this from ✏️ in progress to 🕒 backlog in ODK Central Jun 12, 2025
@lognaturel lognaturel moved this from 🕒 backlog to ✏️ in progress in ODK Central Jul 1, 2025
@matthew-white
Copy link
Member

I feel like there are a few more places where auth.actor.map() could go away, but that could also happen in a follow-up PR (feel free just to file an issue if that seems better).

.with({ createdBy: auth.actor.map((actor) => actor.id).orNull() });

.with({ createdBy: auth.actor.map((actor) => actor.id).orNull() });

Comments.create(auth.actor.map((actor) => actor.id).orNull(),

@matthew-white
Copy link
Member

I guess I'm just proposing auth.actor.get().id. I was thinking of this related comment: https://github.com/getodk/central-backend/pull/1488/files#r2095918985. Not a big deal the more I think about it, doesn't even need to change.

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.

LGTM 🚀

Thank you for making this change @sadiqkhoja, it's definitely a great improvement to the codebase.

Copy link
Member

Choose a reason for hiding this comment

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

Have any new endpoints been committed to master since this PR was created? If so, they could be added to the list below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

no new endpoint has been added since then

service.get('/projects/:projectId/formList', endpoint.openRosa(({ Forms, Projects, env }, { auth, params, originalUrl, queryOptions }) =>
Projects.getById(params.projectId)
.then(getOrNotFound)
.then(rejectIf((() => auth.actor.isEmpty()), noargs(Problem.user.openRosaAuthenticationRequired)))
Copy link
Member

Choose a reason for hiding this comment

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

How about removing this Problem from lib/util/problem.js? I guess there's no harm in leaving it, but it doesn't look like it's used anywhere else.

.then(always(savedUser)))));

const endpointByInvalidateQuery = (cb) => (req, ...rest) =>
(req.query.invalidate ? endpoint(cb) : anonymousEndpoint(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.

Suggested change
(req.query.invalidate ? endpoint(cb) : anonymousEndpoint(cb))(req, ...rest);
(isTrue(req.query.invalidate) ? endpoint(cb) : anonymousEndpoint(cb))(req, ...rest);

I think we should use isTrue() here to exactly match how query.invalidate is used elsewhere in the endpoint. To avoid subtle differences between truthiness vs. isTrue().

Comment on lines 1664 to +1665
await service.get(`/v1/form-links/${enketoId}/form`)
.expect(404);
.expect(401);
Copy link
Member

Choose a reason for hiding this comment

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

I don't 100% understand why it was 404 before and not 403. I think that enketoId must be null here, because we create the simple form without an enketoId (fixtures/02-forms.js). Maybe the test would be better if it created a new form that does have an enketoId. It's an existing test though, so I don't think we need to change that in this PR. And most importantly, 401 is the correct error code going forward.

const should = require('should');
const { getOrNotFound } = require(appRoot + '/lib/util/promise');
const { testService } = require('../setup');
const { describe } = require('mocha');
Copy link
Member

Choose a reason for hiding this comment

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

Since these tests existed before and use describe(), I feel like this addition probably isn't needed?


describe('authentication', () => {
const anonymousEndpoints = [
{ method: 'POST', url: '/v1/sessions', skipForOidc: true },
Copy link
Member

Choose a reason for hiding this comment

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

I don't feel like it's especially helpful to include /v1/sessions in this list. The test is asserting that a request to the endpoint doesn't return a 401. But the endpoint is capable of returning a 401 if the email or password is incorrect.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the anonymousEndpoints array lists endpoints that don't look for any authentication

return;
// actually try to authenticate with it. no Problem on failure.
const maybeSession = authBySessionToken(token);
return reject(Problem.user.authenticationFailed());
Copy link
Member

@matthew-white matthew-white Jul 29, 2025

Choose a reason for hiding this comment

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

I don't think I see a test of this change, where:

  • There is a Cookie header (context.headers.cookie != null)
  • But there is no session cookie (context.cookies[SESSION_COOKIE].token == null)
  • In that case, it should be a 401.

I see similar tests:

  • The Cookie header is invalid
  • The token is invalid

But I'm not sure I see a test that gets at the sequence I described. I guess I want tests that get at the difference between these two:

  • There's no cookie of any kind at all
  • There is some Cookie header, just not a session cookie

Could you check that tests verify that? If they don't, could you add a test along those lines?

// SUBMISSIONS (OPENROSA)

const openRosaSubmission = (path, draft, getForm) => {
const openRosaSubmission = (path, endpointToUse, draft, getForm) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nits: arg order, arg name

Suggested change
const openRosaSubmission = (path, endpointToUse, draft, getForm) => {
const openRosaSubmission = (_endpoint, path, draft, getForm) => {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have renamed endpointToUse to _endpoint but I would like to keep path as the first arg, it's easy to scan.

@sadiqkhoja sadiqkhoja merged commit 0b39288 into getodk:master Aug 8, 2025
6 checks passed
@github-project-automation github-project-automation bot moved this from ✏️ in progress to ✅ done in ODK Central Aug 8, 2025
@alxndrsn

This comment was marked as resolved.

alxndrsn pushed a commit to alxndrsn/odk-central-backend that referenced this pull request Aug 8, 2025
* Return 401 if authentication is not provided

* Make /oidc/login anonymous

* Incorporated PR Feedback

* Added tests for authentication in one place

* More PR Feedback

* PR feedback related to tests

* Remove check for Form existence from GET /submissions openrosa endpoint

* Incorporated PR Feedback
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: ✅ done

Development

Successfully merging this pull request may close these issues.

3 participants