diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 6f303edc..05cc3bbe 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -255,25 +255,36 @@ 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 + // 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 ")) + } + // Start by trying to get the API key/secret from the Authorization header signer, _ := app.getApiSigner(c) myId := app.getMyId(c) 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: - // 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 { 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 @@ -288,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) @@ -324,6 +336,17 @@ 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 validate it (expired, + // revoked, or otherwise undecodable). Return 401 so clients know to refresh + // rather than 403, which implies an authorization (not authentication) failure. + // 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") + } + // Not authorized to act on behalf of myId if myId != 0 && !pkceAuthed && !app.isAuthorizedRequest(c.Context(), myId, wallet) { return fiber.NewError( @@ -337,7 +360,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)