Skip to content

Commit 92aa8c4

Browse files
rickyromboclaude
andauthored
Return 401 for expired OAuth access tokens (#768)
## Summary - Expired OAuth access tokens were causing the API to return **403** on writes (e.g. `POST /v1/tracks?user_id=…`) because the bearer token failed to resolve to a wallet and the downstream authorization check ran with an empty wallet. - 403 implies the caller is authenticated but unauthorized, which keeps clients from realizing they need to refresh their token. Now we return **401 "Invalid or expired access token"** whenever a Bearer token was supplied but no auth path resolved it (and the route asserts an identity via `?user_id=` or `:wallet`). - Anonymous/unauthenticated requests are unchanged. Endpoints with no identity assertion still pass through silently if a stale bearer is sent. ## Test plan - [x] `go test ./api/ -run "TestAuthMiddlewareInvalidBearerReturns401|TestAuthorized|TestRequireAuthMiddleware|TestRequireWriteScope|TestGetApiSigner"` passes - [x] New `TestAuthMiddlewareInvalidBearerReturns401` covers the three cases: invalid bearer + `myId` → 401, invalid bearer + `:wallet` → 401, invalid bearer with no identity assertion → pass-through - [ ] Manually verify a real expired OAuth token against `POST /v1/tracks` returns 401 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c5647da commit 92aa8c4

File tree

2 files changed

+70
-7
lines changed

2 files changed

+70
-7
lines changed

api/auth_middleware.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -255,25 +255,36 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error {
255255
// 4. Signature headers - legacy method used for reads
256256
var wallet string
257257

258+
// Detect a supplied Bearer token up front so that, if no auth path can
259+
// validate it, we can return 401 (auth attempted but invalid) instead of
260+
// 403 (auth succeeded but unauthorized). bearerValidated tracks whether
261+
// any path successfully decoded the token — a valid-but-mismatched token
262+
// is still 403, only an undecodable/expired/revoked token is 401.
263+
var bearerToken string
264+
var bearerValidated bool
265+
if authHeader := c.Get("Authorization"); authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
266+
bearerToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
267+
}
268+
258269
// Start by trying to get the API key/secret from the Authorization header
259270
signer, _ := app.getApiSigner(c)
260271
myId := app.getMyId(c)
261272
if signer != nil {
262273
app.logger.Debug("authMiddleware: resolved via app bearer/secret/oauth", zap.String("wallet", strings.ToLower(signer.Address)))
263274
wallet = strings.ToLower(signer.Address)
275+
// signer != nil via Bearer means getApiSigner validated the credential
276+
// (api_access_key, OAuth token + api_secret, or AudiusApiSecret path).
277+
if bearerToken != "" {
278+
bearerValidated = true
279+
}
264280
} else {
265281
// The api secret couldn't be found, try other methods:
266282

267-
// Extract Bearer token once for the fallback checks below
268-
var bearerToken string
269-
if authHeader := c.Get("Authorization"); authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
270-
bearerToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
271-
}
272-
273283
if bearerToken != "" {
274284
// OAuth JWT fallback: when Bearer token is not api_access_key, try as OAuth JWT (Plans app)
275285
if wallet == "" && myId != 0 {
276286
if oauthWallet, jwtUserId, err := app.validateOAuthJWTTokenToWalletAndUserId(c.Context(), bearerToken); err == nil {
287+
bearerValidated = true
277288
if int32(jwtUserId) == myId {
278289
app.logger.Debug("authMiddleware: resolved via OAuth JWT", zap.String("wallet", oauthWallet), zap.Int32("myId", myId))
279290
wallet = oauthWallet
@@ -288,6 +299,7 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error {
288299
// 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
289300
if wallet == "" {
290301
if entry, ok := app.lookupOAuthAccessToken(c, bearerToken); ok {
302+
bearerValidated = true
291303
if myId == 0 || entry.UserID == myId {
292304
wallet = strings.ToLower(entry.ClientID)
293305
c.Locals("oauthScope", entry.Scope)
@@ -324,6 +336,17 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error {
324336
// A valid PKCE access token already proves the user authorized this client
325337
_, pkceAuthed := c.Locals("oauthScope").(string)
326338

339+
myWallet := c.Params("wallet")
340+
341+
// A Bearer token was provided but no auth path could validate it (expired,
342+
// revoked, or otherwise undecodable). Return 401 so clients know to refresh
343+
// rather than 403, which implies an authorization (not authentication) failure.
344+
// A token that decoded successfully but didn't match the requested user is
345+
// still 403 below — it's a permission issue, not a credential issue.
346+
if wallet == "" && bearerToken != "" && !bearerValidated && (myId != 0 || myWallet != "") {
347+
return fiber.NewError(fiber.StatusUnauthorized, "Invalid or expired access token")
348+
}
349+
327350
// Not authorized to act on behalf of myId
328351
if myId != 0 && !pkceAuthed && !app.isAuthorizedRequest(c.Context(), myId, wallet) {
329352
return fiber.NewError(
@@ -337,7 +360,6 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error {
337360
}
338361

339362
// Not authorized to act on behalf of myWallet
340-
myWallet := c.Params("wallet")
341363
if myWallet != "" && !strings.EqualFold(myWallet, wallet) {
342364
return fiber.NewError(
343365
fiber.StatusForbidden,

api/auth_middleware_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,47 @@ func TestRequireAuthMiddleware(t *testing.T) {
128128
assert.Equal(t, fiber.StatusUnauthorized, res.StatusCode)
129129
}
130130

131+
// An invalid/expired Bearer token used against an endpoint that asserts a
132+
// caller identity (myId via ?user_id, or :wallet route param) must return
133+
// 401 — the credential was supplied but couldn't be validated. Returning 403
134+
// here would imply the caller is authenticated but unauthorized, which would
135+
// keep clients from realizing they need to refresh their token.
136+
func TestAuthMiddlewareInvalidBearerReturns401(t *testing.T) {
137+
app := testAppWithFixtures(t)
138+
139+
testApp := fiber.New()
140+
testApp.Get("/", app.resolveMyIdMiddleware, app.authMiddleware, func(c *fiber.Ctx) error {
141+
return c.SendStatus(fiber.StatusOK)
142+
})
143+
testApp.Get("/account/:wallet", app.resolveMyIdMiddleware, app.authMiddleware, func(c *fiber.Ctx) error {
144+
return c.SendStatus(fiber.StatusOK)
145+
})
146+
147+
t.Run("invalid bearer with myId returns 401", func(t *testing.T) {
148+
req := httptest.NewRequest("GET", "/?user_id=7eP5n", nil)
149+
req.Header.Set("Authorization", "Bearer expired-or-invalid-token")
150+
res, err := testApp.Test(req, -1)
151+
assert.NoError(t, err)
152+
assert.Equal(t, fiber.StatusUnauthorized, res.StatusCode)
153+
})
154+
155+
t.Run("invalid bearer with wallet param returns 401", func(t *testing.T) {
156+
req := httptest.NewRequest("GET", "/account/0x111c616ae836ceca1effe00bd07f2fdbf9a082bc", nil)
157+
req.Header.Set("Authorization", "Bearer expired-or-invalid-token")
158+
res, err := testApp.Test(req, -1)
159+
assert.NoError(t, err)
160+
assert.Equal(t, fiber.StatusUnauthorized, res.StatusCode)
161+
})
162+
163+
t.Run("invalid bearer without myId or wallet passes through", func(t *testing.T) {
164+
req := httptest.NewRequest("GET", "/", nil)
165+
req.Header.Set("Authorization", "Bearer expired-or-invalid-token")
166+
res, err := testApp.Test(req, -1)
167+
assert.NoError(t, err)
168+
assert.Equal(t, fiber.StatusOK, res.StatusCode)
169+
})
170+
}
171+
131172
func TestWalletCache(t *testing.T) {
132173
app := emptyTestApp(t)
133174

0 commit comments

Comments
 (0)