Skip to content

fix(cli): stop masking auth errors as token expiry#2945

Merged
migmartri merged 1 commit intochainloop-dev:mainfrom
waveywaves:fix/auth-error-masking
Apr 1, 2026
Merged

fix(cli): stop masking auth errors as token expiry#2945
migmartri merged 1 commit intochainloop-dev:mainfrom
waveywaves:fix/auth-error-masking

Conversation

@waveywaves
Copy link
Copy Markdown
Contributor

@waveywaves waveywaves commented Mar 27, 2026

Summary

  • Fix all 401/UNAUTHORIZED auth errors being incorrectly displayed as "your authentication token has expired"
  • Reorder switch cases so isUnmatchedAuthErr fallback does not shadow org-membership error cases
  • Add unit tests for isWrappedErr and isUnmatchedAuthErr helper functions

Root Cause Analysis

The Kratos errors.Is() implementation only compares Code and Reason fields:

func (e *Error) Is(err error) bool {
    if se := new(Error); errors.As(err, &se) {
        return se.Code == e.Code && se.Reason == e.Reason
    }
    return false
}

All JWT middleware errors (ErrTokenExpired, ErrTokenInvalid, ErrTokenParseFail, ErrMissingJwtToken) are created with errors.Unauthorized("UNAUTHORIZED", "<message>"), giving them identical Code: 401 and Reason: "UNAUTHORIZED" fields. Only the Message field differs. This caused errors.Is() to match any auth error against ErrTokenExpired first in the switch statement, masking the real error type.

Changes

  • Remove the errors.Is() short-circuit in isWrappedErr — keep only the Code + Message comparison, which correctly distinguishes between different JWT error types
  • Add explicit case branches for ErrTokenInvalid ("token is invalid") and ErrTokenParseFail ("failed to parse token") so users see accurate error messages
  • Add a fallback (isUnmatchedAuthErr) for unmatched 401 errors that surfaces the server's original message instead of silently dropping it
  • Reorder switch cases so org-membership checks (IsUserNotMemberOfOrgErrorNotInOrg, IsUserWithNoMembershipErrorNotInOrg) come BEFORE the generic isUnmatchedAuthErr fallback. Note: org-membership errors use gRPC code 7 (PermissionDenied), not code 16 (Unauthenticated), so shadowing is structurally impossible — but the ordering is kept for readability/clarity
  • Add explanatory comment on isWrappedErr documenting why we use Message comparison instead of Kratos errors.Is()
  • Add unit tests covering:
    • isWrappedErr correctly matches each JWT error sentinel against itself
    • isWrappedErr does NOT cross-match different JWT error types
    • isUnmatchedAuthErr catches generic 401/Unauthenticated errors
    • isUnmatchedAuthErr does NOT catch non-401 errors (PermissionDenied, Internal, NotFound, OK)
    • Regression test demonstrating the Kratos errors.Is() masking bug and proving our fix avoids it
    • gRPC wire round-trip test — serializes each JWT sentinel to gRPC status proto bytes, deserializes back, and verifies isWrappedErr still works correctly (tests the full server-to-client error path)
    • Sentinel message canary assertions — asserts the exact Message strings of all four JWT sentinels (including ErrTokenParseFail's trailing space). If Kratos updates and changes these strings, the test fails immediately and alerts us to update our matching

Test Plan

  • go test ./app/cli/ -v — all 23 test cases pass (5 test functions)
  • go vet ./app/cli/... passes
  • Test with an expired JWT token: confirms "token has expired" message
  • Test with an invalid/revoked JWT token: confirms "token is invalid" message (previously showed "token has expired")
  • Test with a malformed JWT token: confirms "failed to parse" message
  • Test with missing JWT token: confirms "authentication required" message
  • Test with an unrecognized 401 error: confirms server's original message is shown

Fixes #2922

@waveywaves waveywaves force-pushed the fix/auth-error-masking branch 3 times, most recently from 093b98b to 948c8f5 Compare March 28, 2026 04:15
@waveywaves waveywaves marked this pull request as ready for review March 31, 2026 12:58
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 4 files

@waveywaves waveywaves force-pushed the fix/auth-error-masking branch from 948c8f5 to 6dd81dd Compare March 31, 2026 13:22
The Kratos errors.Is() function matches only on Code and Reason (not
Message). Since all JWT middleware errors share Code=401 and
Reason="UNAUTHORIZED", every authentication error was incorrectly
matching ErrTokenExpired and showing "your authentication token has
expired" regardless of the actual cause.

Fix by removing the errors.Is() short-circuit in isWrappedErr and
keeping only the Code+Message comparison, which carries the
distinguishing text for each JWT error type. Also add explicit case
branches for ErrTokenInvalid and ErrTokenParseFail, and a fallback
for unmatched 401 errors that surfaces the server's original message.

Closes chainloop-dev#2922

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
@waveywaves waveywaves force-pushed the fix/auth-error-masking branch from 6dd81dd to 9be4c21 Compare March 31, 2026 13:25
@migmartri migmartri merged commit 496af27 into chainloop-dev:main Apr 1, 2026
14 checks passed
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.

Auth token expiry error masks other underlying errors (e.g. invalid credentials)

2 participants