diff --git a/api/server.go b/api/server.go index 5029e2d4..7c429ed8 100644 --- a/api/server.go +++ b/api/server.go @@ -556,7 +556,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Post("/oauth/authorize", app.v1OAuthAuthorize) g.Post("/oauth/token", app.v1OAuthToken) g.Post("/oauth/revoke", app.v1OAuthRevoke) - g.Get("/oauth/me", app.requireAuthMiddleware, app.v1OAuthMe) + g.Get("/me", app.requireAuthMiddleware, app.v1Me) // Rewards g.Post("/rewards/claim", app.v1ClaimRewards) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 7791f0c0..67baf9aa 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -63,6 +63,8 @@ tags: description: Rewards related operations - name: prizes description: Prize claiming related operations + - name: oauth + description: OAuth 2.0 authorization paths: /challenges/undisbursed: get: @@ -9748,6 +9750,32 @@ paths: application/json: schema: $ref: "#/components/schemas/transaction_history_count_response" + /me: + get: + tags: + - oauth + summary: Get authenticated user + description: Returns the full profile of the currently authenticated user based on the Bearer access token obtained via OAuth. + operationId: Get Authenticated User + security: + - OAuth2: + - read + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/user_response_single" + "401": + description: Missing or invalid access token + content: {} + "404": + description: User not found + content: {} + "500": + description: Server error + content: {} components: schemas: create_access_key_response: diff --git a/api/v1_me.go b/api/v1_me.go new file mode 100644 index 00000000..5a680ebc --- /dev/null +++ b/api/v1_me.go @@ -0,0 +1,36 @@ +package api + +import ( + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +// v1Me handles GET /v1/me +// Returns the authenticated user's profile. Supports any auth method that +// resolves to a user (OAuth PKCE token, wallet signature, JWT, or API key). +func (app *ApiServer) v1Me(c *fiber.Ctx) error { + userId := app.getMyId(c) + if userId == 0 { + wallet := app.getAuthedWallet(c) + id, err := app.getUserIDFromWallet(c.Context(), wallet) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Could not resolve authenticated user") + } + userId = int32(id) + } + + users, err := app.queries.Users(c.Context(), dbv1.GetUsersParams{ + Ids: []int32{userId}, + MyID: userId, + }) + if err != nil { + app.logger.Error("Failed to query user for /me", zap.Error(err)) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get user info") + } + if len(users) == 0 { + return fiber.NewError(fiber.StatusNotFound, "User not found") + } + + return c.JSON(fiber.Map{"data": users[0]}) +} diff --git a/api/v1_oauth.go b/api/v1_oauth.go index bafdbe58..334be08e 100644 --- a/api/v1_oauth.go +++ b/api/v1_oauth.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "api.audius.co/api/dbv1" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" "go.uber.org/zap" @@ -539,53 +538,6 @@ func (app *ApiServer) v1OAuthRevoke(c *fiber.Ctx) error { return c.JSON(fiber.Map{}) } -// v1OAuthMe handles GET /v1/oauth/me -// Returns the authenticated user's profile based on Bearer access token. -func (app *ApiServer) v1OAuthMe(c *fiber.Ctx) error { - // Extract Bearer token - authHeader := c.Get("Authorization") - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - return oauthError(c, fiber.StatusUnauthorized, "invalid_token", "Missing or invalid Authorization header") - } - token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) - if token == "" { - return oauthError(c, fiber.StatusUnauthorized, "invalid_token", "Bearer token is empty") - } - - // Look up the access token (try cache first) - entry, ok := app.lookupOAuthAccessToken(c, token) - if !ok { - return oauthError(c, fiber.StatusUnauthorized, "invalid_token", "Invalid or expired access token") - } - - // Fetch user via the standard query helper (includes rendezvous-based image URLs) - users, err := app.queries.Users(c.Context(), dbv1.GetUsersParams{ - Ids: []int32{entry.UserID}, - }) - if err != nil { - app.logger.Error("Failed to query user for /oauth/me", zap.Error(err)) - return oauthError(c, fiber.StatusInternalServerError, "server_error", "Failed to get user info") - } - if len(users) == 0 { - return oauthError(c, fiber.StatusNotFound, "invalid_token", "User not found") - } - - user := users[0] - - response := fiber.Map{ - "userId": user.ID, - "name": user.Name.String, - "handle": user.Handle.String, - "verified": user.IsVerified, - "sub": user.ID, - "iat": time.Now().Unix(), - } - if user.ProfilePicture != nil { - response["profilePicture"] = user.ProfilePicture - } - - return c.JSON(response) -} // --- Helper methods --- diff --git a/api/v1_oauth_test.go b/api/v1_oauth_test.go index 022ec96a..7ceb84df 100644 --- a/api/v1_oauth_test.go +++ b/api/v1_oauth_test.go @@ -901,7 +901,7 @@ func TestOAuthRevoke_MissingToken(t *testing.T) { jsonAssert(t, body, map[string]any{"error": "invalid_request"}) } -// --- /oauth/me --- +// --- /me --- func TestOAuthMe(t *testing.T) { app := emptyTestApp(t) @@ -910,25 +910,21 @@ func TestOAuthMe(t *testing.T) { familyID := "test-family-me" accessToken, _ := insertTestTokens(t, app, clientID, 100, "read", familyID, time.Hour, 30*24*time.Hour) - status, body := oauthGetWithBearer(t, app, "/v1/oauth/me", accessToken) + status, body := oauthGetWithBearer(t, app, "/v1/me", accessToken) assert.Equal(t, 200, status) - assert.True(t, gjson.GetBytes(body, "userId").Exists()) + assert.True(t, gjson.GetBytes(body, "data.id").Exists()) jsonAssert(t, body, map[string]any{ - "handle": "oauthuser", - "name": "OAuth User", - "verified": false, + "data.handle": "oauthuser", + "data.name": "OAuth User", + "data.is_verified": false, }) - assert.Equal(t, - gjson.GetBytes(body, "userId").String(), - gjson.GetBytes(body, "sub").String(), - ) } func TestOAuthMe_InvalidToken(t *testing.T) { app := emptyTestApp(t) - status, _ := oauthGetWithBearer(t, app, "/v1/oauth/me", "invalid-token") + status, _ := oauthGetWithBearer(t, app, "/v1/me", "invalid-token") // requireAuthMiddleware intercepts before the handler runs assert.Equal(t, 401, status) @@ -952,7 +948,7 @@ func TestOAuthMe_ExpiredToken(t *testing.T) { }, }) - status, _ := oauthGetWithBearer(t, app, "/v1/oauth/me", expiredToken) + status, _ := oauthGetWithBearer(t, app, "/v1/me", expiredToken) // requireAuthMiddleware intercepts before the handler runs assert.Equal(t, 401, status) @@ -977,7 +973,7 @@ func TestOAuthMe_RevokedToken(t *testing.T) { }, }) - status, _ := oauthGetWithBearer(t, app, "/v1/oauth/me", revokedToken) + status, _ := oauthGetWithBearer(t, app, "/v1/me", revokedToken) // requireAuthMiddleware intercepts before the handler runs assert.Equal(t, 401, status) @@ -986,7 +982,7 @@ func TestOAuthMe_RevokedToken(t *testing.T) { func TestOAuthMe_MissingAuthHeader(t *testing.T) { app := emptyTestApp(t) - req := httptest.NewRequest("GET", "/v1/oauth/me", nil) + req := httptest.NewRequest("GET", "/v1/me", nil) res, err := app.Test(req, -1) require.NoError(t, err) @@ -1015,9 +1011,9 @@ func TestOAuthFullFlow(t *testing.T) { refreshToken := gjson.GetBytes(body, "refresh_token").String() // Step 2: Use access token to get user profile - status, body = oauthGetWithBearer(t, app, "/v1/oauth/me", accessToken) + status, body = oauthGetWithBearer(t, app, "/v1/me", accessToken) assert.Equal(t, 200, status) - jsonAssert(t, body, map[string]any{"handle": "oauthuser"}) + jsonAssert(t, body, map[string]any{"data.handle": "oauthuser"}) // Step 3: Refresh the token status, body = oauthPostJSON(t, app, "/v1/oauth/token", map[string]string{ @@ -1032,9 +1028,9 @@ func TestOAuthFullFlow(t *testing.T) { assert.NotEqual(t, refreshToken, newRefreshToken) // Step 4: New access token works - status, body = oauthGetWithBearer(t, app, "/v1/oauth/me", newAccessToken) + status, body = oauthGetWithBearer(t, app, "/v1/me", newAccessToken) assert.Equal(t, 200, status) - jsonAssert(t, body, map[string]any{"handle": "oauthuser"}) + jsonAssert(t, body, map[string]any{"data.handle": "oauthuser"}) // Step 5: Revoke status, _ = oauthPostJSON(t, app, "/v1/oauth/revoke", map[string]string{ @@ -1044,7 +1040,7 @@ func TestOAuthFullFlow(t *testing.T) { assert.Equal(t, 200, status) // Step 6: Revoked tokens no longer work - status, _ = oauthGetWithBearer(t, app, "/v1/oauth/me", newAccessToken) + status, _ = oauthGetWithBearer(t, app, "/v1/me", newAccessToken) assert.Equal(t, 401, status) // Refreshing with the new refresh token also fails (family revoked)