Skip to content

Conversation

@jhodapp
Copy link
Member

@jhodapp jhodapp commented Jun 18, 2025

Description

This PR ensures that failed login attempts are returned as HTTP statuscode UNAUTHORIZED (401), not a 500 internal server error.

GitHub Issue: Relates to the frontend bug #122

Changes

  • Makes the EntityErrorKind::Unauthenticated actually pass through the domain layer

Testing Strategy

  1. Try logging in from the frontend with invalid credentials (try both a valid email/invalid password, and an invalid email/password set combination)
  2. Observe in the backend logging that you see [WARN] EntityErrorKind::Unauthenticated: Responding with 401 Unauthorized. Error: Domain(Error { source: Some(Error { source: None, error_kind: RecordUnauthenticated }), error_kind: Internal(Entity(Unauthenticated)) })
  3. If you're using the paired frontend branch, you should see it display the error Invalid email or password. Please try again.

Concerns

None

…UNAUTHORIZED (401), not a 500 internal server error.
@jhodapp jhodapp added this to the 1.0.0-beta1 milestone Jun 18, 2025
@jhodapp jhodapp requested a review from calebbourg June 18, 2025 01:18
@jhodapp jhodapp self-assigned this Jun 18, 2025
@jhodapp jhodapp added the bug fix Contains a fix to a known bug label Jun 18, 2025
Copy link
Collaborator

@calebbourg calebbourg left a comment

Choose a reason for hiding this comment

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

Looks great! Just the potential for a change depending on what you think.

Comment on lines +45 to +67
Ok(None) => {
// No user found - this should also be treated as an authentication error
return Err(WebError::from(domain::error::Error {
source: None,
error_kind: domain::error::DomainErrorKind::Internal(
domain::error::InternalErrorKind::Entity(
domain::error::EntityErrorKind::Unauthenticated,
),
),
}));
}
Err(auth_error) => {
// axum_login errors contain our entity_api::Error in the error field
warn!("Authentication failed: {:?}", auth_error);
return Err(WebError::from(domain::error::Error {
source: Some(Box::new(auth_error)),
error_kind: domain::error::DomainErrorKind::Internal(
domain::error::InternalErrorKind::Entity(
domain::error::EntityErrorKind::Unauthenticated,
),
),
}));
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is a great change! I think it makes sense to return a 401 in all non-success cases here for security purposes.

Here is a small suggestion and my thought process

Suggested change
Ok(None) => {
// No user found - this should also be treated as an authentication error
return Err(WebError::from(domain::error::Error {
source: None,
error_kind: domain::error::DomainErrorKind::Internal(
domain::error::InternalErrorKind::Entity(
domain::error::EntityErrorKind::Unauthenticated,
),
),
}));
}
Err(auth_error) => {
// axum_login errors contain our entity_api::Error in the error field
warn!("Authentication failed: {:?}", auth_error);
return Err(WebError::from(domain::error::Error {
source: Some(Box::new(auth_error)),
error_kind: domain::error::DomainErrorKind::Internal(
domain::error::InternalErrorKind::Entity(
domain::error::EntityErrorKind::Unauthenticated,
),
),
}));
}
Ok(None) => { return Err(StatusCode::UNAUTHORIZED.into_response()) }
Err(auth_error) => {
// axum_login errors contain our entity_api::Error in the error field
warn!("Authentication failed: {:?}", auth_error);
Err(auth_error)
}

To me this feels more correct semantically. The absence of a record, in my mind, is not really an error even though we want to translate it into a 401. The translation is more of an obfuscation similar to an actual error. This is as opposed to the alternatives:

  • The record does exist but is not able to be authenticated for whatever reason. More of a pure 401
  • There was an actual error somewhere along the call stack. Translated, obfuscated 401

The main reason this stands out to me is that we're reaching into the other abstraction layers and explicitly returning an error that is defined in entity_api. Even though it's re-exported through domain it feels slightly like we're leaking concepts between the boundaries. I think that returning the Err(StatusCode::UNAUTHORIZED.into_response() feels more appropriate for the something the web layer's responsibility. The entity_api and lower level constructs should, if possible, be returned from those layers and only translated in web.

For Err(auth_error) I think we could just return that as it already includes the information and would be just a pass through. I may be totally wrong there.

This is somewhat nit picky but I think it's important to try to maintain the boundaries if possible. Certainly open to your take and philosophy on the matter. In the end it's not really a blocker

Copy link
Member Author

Choose a reason for hiding this comment

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

@calebbourg Great suggestion Caleb. So for the Err case, it would either map to something that exists, or turn into a 500 internal server error if an error was raised that didn't map to something we anticipate.

The only risk here is that we are now exposing a security difference to the world on our front page. This conversation is public, this source code is public, so someone just needs to figure out how to trigger this situation and now they know a distinct difference. I've read a lot of informed thoughts about login forms and consistency if the highest value. They even recommend that we always respond with the same constant time whether there's a successful login vs an unsuccessful one because of this "having distinct knowledge" risk. Thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thank you and thank you for your thoughts as well!

I completely agree with all of the security implications.
I envision it either returning a 401 in the case where the user queried doesn't exist (theOk(None) case and then ensuring that auth_session.authenticate(creds.clone()) returns an error from the lower layers that web translates to a 401. Which would mean in all non-authenticated scenarios (error, user doesn't exist, etc.) we will always return a 401.

Let me know if that makes sense and if you think it's feasible.

It may already be accomplished here:

async fn authenticate_user(creds: Credentials, user: Model) -> Result<Option<Model>, Error> {
match password_auth::verify_password(creds.password, &user.password) {
Ok(_) => Ok(Some(user)),
Err(_) => Err(Error {
source: None,
error_kind: EntityApiErrorKind::RecordUnauthenticated,
}),
}
}

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll look into this a little more deeply this evening. Thanks for your thoughts.

Copy link
Member Author

@jhodapp jhodapp Jun 18, 2025

Choose a reason for hiding this comment

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

@calebbourg It looks like I must provide an Ok(None) => {} branch to satisfy the let user = match auth_session.authenticate(creds.clone()).await {}…it's expected and if it's not there rustc complains.

Hopefully I'm not completely misunderstanding what you're intending here.

Copy link
Collaborator

@calebbourg calebbourg Jun 19, 2025

Choose a reason for hiding this comment

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

@jhodapp no worries! I may not be describing my suggestion very well.

My hypothesis is that the goal I outlined can be accomplished by something like this (adding comments):

Suggested change
Ok(None) => {
// No user found - this should also be treated as an authentication error
return Err(WebError::from(domain::error::Error {
source: None,
error_kind: domain::error::DomainErrorKind::Internal(
domain::error::InternalErrorKind::Entity(
domain::error::EntityErrorKind::Unauthenticated,
),
),
}));
}
Err(auth_error) => {
// axum_login errors contain our entity_api::Error in the error field
warn!("Authentication failed: {:?}", auth_error);
return Err(WebError::from(domain::error::Error {
source: Some(Box::new(auth_error)),
error_kind: domain::error::DomainErrorKind::Internal(
domain::error::InternalErrorKind::Entity(
domain::error::EntityErrorKind::Unauthenticated,
),
),
}));
}
Ok(None) => {
// here we use StatusCode explicitly because it belongs within the web abstraction layer
// whereas EntityApiErrorKind and it's cousins have less of an explicit place here.
return Err(StatusCode::UNAUTHORIZED.into_response())
}
Err(auth_error) => {
// Here we simply pass through the error that is emitted from the lower layer (entity_api) which I think
// is EntityApiErrorKind::RecordUnauthenticated based on what authenticate_user() emits.
// EntityApiErrorKind::RecordUnauthenticated gets automatically translated to a 401 by the web layer and so we can just make it a pass through here and avoid any explicit naming of the actual underlying EntityApiErrorKind::RecordUnauthenticated in this part of the code thus avoiding the explicit direct dependency on entity_api.
// We only indirectly depend on it via domain which is ok and part of our design
warn!("Authentication failed: {:?}", auth_error);
return Err(auth_error);
}

Let me know if that helps clarify my thought process. I haven't tested the error translation yet and so it may be that either authenticate_user() needs to be updated to actually return an error that the web layer will automatically translate to a 401 or, if that isn't possible, leave the code like this for now.

@jhodapp jhodapp merged commit 3eb12c6 into main Jun 20, 2025
6 of 8 checks passed
@jhodapp jhodapp deleted the 122-fe-bug-unsuccessful-login-returns-500 branch June 20, 2025 00:04
@github-project-automation github-project-automation bot moved this from Review to ✅ Done in Refactor Coaching Platform Jun 20, 2025
jhodapp added a commit that referenced this pull request Nov 1, 2025
This commit adds an enhanced user-scoped coaching sessions endpoint that
supports optional batch loading of related resources to eliminate N+1 queries.

Backend changes:
- Add EnrichedSession and IncludeOptions to entity_api layer
- Implement find_by_user_with_includes with efficient batch loading
- Add IncludeParam enum for query parameter validation
- Add EnrichedCoachingSession response DTO
- Update user coaching_session_controller to use enhanced endpoint

Technical improvements:
- Single API call replaces 3+ separate queries
- Batch loads relationships, users, organizations, goals, agreements
- Validates include parameters (organization requires relationship)
- Maintains backward compatibility with existing endpoints

Related to Today's Sessions UI feature (Issue #160)
jhodapp added a commit that referenced this pull request Nov 6, 2025
This commit adds an enhanced user-scoped coaching sessions endpoint that
supports optional batch loading of related resources to eliminate N+1 queries.

Backend changes:
- Add EnrichedSession and IncludeOptions to entity_api layer
- Implement find_by_user_with_includes with efficient batch loading
- Add IncludeParam enum for query parameter validation
- Add EnrichedCoachingSession response DTO
- Update user coaching_session_controller to use enhanced endpoint

Technical improvements:
- Single API call replaces 3+ separate queries
- Batch loads relationships, users, organizations, goals, agreements
- Validates include parameters (organization requires relationship)
- Maintains backward compatibility with existing endpoints

Related to Today's Sessions UI feature (Issue #160)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug fix Contains a fix to a known bug

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

3 participants