From bcc2138229e86f21c11feeaae37f52dfb4c5d9f4 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:28:58 -0700 Subject: [PATCH 1/2] Return 401 for expired OAuth access tokens When an OAuth access token expired, requests asserting a caller identity (via ?user_id= or :wallet) returned 403 because the bearer token failed to resolve to a wallet and the authorization check ran with an empty wallet. 403 implies the caller is authenticated but unauthorized, which prevents clients from realizing they need to refresh their token. Return 401 when a bearer token was supplied but no auth path could resolve it, so clients can refresh and retry. Co-Authored-By: Claude Opus 4.7 --- api/auth_middleware.go | 24 +++++++++++++++------- api/auth_middleware_test.go | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 6f303edc..70a1620e 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -255,6 +255,14 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { // 4. Signature headers - legacy method used for reads var wallet string + // Detect a supplied Bearer token up front so that, if no auth path can + // resolve a wallet from it, we can return 401 (auth attempted but invalid) + // instead of 403 (auth succeeded but unauthorized). + var bearerToken string + if authHeader := c.Get("Authorization"); authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + bearerToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + } + // Start by trying to get the API key/secret from the Authorization header signer, _ := app.getApiSigner(c) myId := app.getMyId(c) @@ -264,12 +272,6 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { } else { // The api secret couldn't be found, try other methods: - // Extract Bearer token once for the fallback checks below - var bearerToken string - if authHeader := c.Get("Authorization"); authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { - bearerToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) - } - if bearerToken != "" { // OAuth JWT fallback: when Bearer token is not api_access_key, try as OAuth JWT (Plans app) if wallet == "" && myId != 0 { @@ -324,6 +326,15 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { // A valid PKCE access token already proves the user authorized this client _, pkceAuthed := c.Locals("oauthScope").(string) + myWallet := c.Params("wallet") + + // A Bearer token was provided but no auth path could resolve it (expired, + // revoked, or otherwise invalid). Return 401 so clients know to refresh + // rather than 403, which implies an authorization (not authentication) failure. + if wallet == "" && bearerToken != "" && (myId != 0 || myWallet != "") { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid or expired access token") + } + // Not authorized to act on behalf of myId if myId != 0 && !pkceAuthed && !app.isAuthorizedRequest(c.Context(), myId, wallet) { return fiber.NewError( @@ -337,7 +348,6 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { } // Not authorized to act on behalf of myWallet - myWallet := c.Params("wallet") if myWallet != "" && !strings.EqualFold(myWallet, wallet) { return fiber.NewError( fiber.StatusForbidden, diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index d7edebe7..6ad08207 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -128,6 +128,47 @@ func TestRequireAuthMiddleware(t *testing.T) { assert.Equal(t, fiber.StatusUnauthorized, res.StatusCode) } +// An invalid/expired Bearer token used against an endpoint that asserts a +// caller identity (myId via ?user_id, or :wallet route param) must return +// 401 — the credential was supplied but couldn't be validated. Returning 403 +// here would imply the caller is authenticated but unauthorized, which would +// keep clients from realizing they need to refresh their token. +func TestAuthMiddlewareInvalidBearerReturns401(t *testing.T) { + app := testAppWithFixtures(t) + + testApp := fiber.New() + testApp.Get("/", app.resolveMyIdMiddleware, app.authMiddleware, func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + testApp.Get("/account/:wallet", app.resolveMyIdMiddleware, app.authMiddleware, func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + t.Run("invalid bearer with myId returns 401", func(t *testing.T) { + req := httptest.NewRequest("GET", "/?user_id=7eP5n", nil) + req.Header.Set("Authorization", "Bearer expired-or-invalid-token") + res, err := testApp.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusUnauthorized, res.StatusCode) + }) + + t.Run("invalid bearer with wallet param returns 401", func(t *testing.T) { + req := httptest.NewRequest("GET", "/account/0x111c616ae836ceca1effe00bd07f2fdbf9a082bc", nil) + req.Header.Set("Authorization", "Bearer expired-or-invalid-token") + res, err := testApp.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusUnauthorized, res.StatusCode) + }) + + t.Run("invalid bearer without myId or wallet passes through", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer expired-or-invalid-token") + res, err := testApp.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusOK, res.StatusCode) + }) +} + func TestWalletCache(t *testing.T) { app := emptyTestApp(t) From 72541edbd1f4282ba399b950fa9232154c009999 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:35:24 -0700 Subject: [PATCH 2/2] Distinguish invalid bearer (401) from valid-but-mismatched (403) A PKCE token belonging to user A used in a request for user B should still return 403 (the credential is valid, just unauthorized for the target). Track whether any auth path successfully decoded the bearer token; only return 401 when no path could validate it (expired, revoked, or undecodable). Fixes TestAuthMiddleware_PKCEToken_UserIDMismatch which expects 403 for valid-but-mismatched tokens. Co-Authored-By: Claude Opus 4.7 --- api/auth_middleware.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 70a1620e..05cc3bbe 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -256,9 +256,12 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { var wallet string // Detect a supplied Bearer token up front so that, if no auth path can - // resolve a wallet from it, we can return 401 (auth attempted but invalid) - // instead of 403 (auth succeeded but unauthorized). + // validate it, we can return 401 (auth attempted but invalid) instead of + // 403 (auth succeeded but unauthorized). bearerValidated tracks whether + // any path successfully decoded the token — a valid-but-mismatched token + // is still 403, only an undecodable/expired/revoked token is 401. var bearerToken string + var bearerValidated bool if authHeader := c.Get("Authorization"); authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { bearerToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) } @@ -269,6 +272,11 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { if signer != nil { app.logger.Debug("authMiddleware: resolved via app bearer/secret/oauth", zap.String("wallet", strings.ToLower(signer.Address))) wallet = strings.ToLower(signer.Address) + // signer != nil via Bearer means getApiSigner validated the credential + // (api_access_key, OAuth token + api_secret, or AudiusApiSecret path). + if bearerToken != "" { + bearerValidated = true + } } else { // The api secret couldn't be found, try other methods: @@ -276,6 +284,7 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { // OAuth JWT fallback: when Bearer token is not api_access_key, try as OAuth JWT (Plans app) if wallet == "" && myId != 0 { if oauthWallet, jwtUserId, err := app.validateOAuthJWTTokenToWalletAndUserId(c.Context(), bearerToken); err == nil { + bearerValidated = true if int32(jwtUserId) == myId { app.logger.Debug("authMiddleware: resolved via OAuth JWT", zap.String("wallet", oauthWallet), zap.Int32("myId", myId)) wallet = oauthWallet @@ -290,6 +299,7 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { // PKCE token fallback: resolve opaque Bearer token from oauth_tokens in case the getSigner fails because there's no secret stored in the api_keys table if wallet == "" { if entry, ok := app.lookupOAuthAccessToken(c, bearerToken); ok { + bearerValidated = true if myId == 0 || entry.UserID == myId { wallet = strings.ToLower(entry.ClientID) c.Locals("oauthScope", entry.Scope) @@ -328,10 +338,12 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { myWallet := c.Params("wallet") - // A Bearer token was provided but no auth path could resolve it (expired, - // revoked, or otherwise invalid). Return 401 so clients know to refresh + // A Bearer token was provided but no auth path could validate it (expired, + // revoked, or otherwise undecodable). Return 401 so clients know to refresh // rather than 403, which implies an authorization (not authentication) failure. - if wallet == "" && bearerToken != "" && (myId != 0 || myWallet != "") { + // A token that decoded successfully but didn't match the requested user is + // still 403 below — it's a permission issue, not a credential issue. + if wallet == "" && bearerToken != "" && !bearerValidated && (myId != 0 || myWallet != "") { return fiber.NewError(fiber.StatusUnauthorized, "Invalid or expired access token") }