Skip to content

Commit cc640b2

Browse files
authored
feat(oauthserver): add authorization list and revoke endpoints (#2232)
## Summary This PR adds user-facing endpoints that allow users to view and revoke their OAuth 2.1 client authorizations. This will allow users control over which applications have access to their accounts. ## Changes ### New Endpoints #### `GET /user/oauth/authorizations` Lists all OAuth clients the authenticated user has authorized. **Response:** ```json { "authorized_clients": [ { "client_id": "uuid", "client_name": "Example App", "client_uri": "https://example.com", "logo_uri": "https://example.com/logo.png", "scopes": ["read", "write"], "granted_at": "2025-10-29T12:00:00Z" } ] } ``` #### DELETE /user/oauth/authorizations?client_id={client_id} Revokes authorization for a specific OAuth client. Actions performed: - Marks the user's consent as revoked - Deletes all active sessions associated with the OAuth client - Creates an audit log entry **Response**: 204 No Content on success
1 parent a6530d5 commit cc640b2

File tree

6 files changed

+477
-0
lines changed

6 files changed

+477
-0
lines changed

internal/api/api.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,14 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
261261
r.Get("/authorize", api.LinkIdentity)
262262
r.Delete("/{identity_id}", api.DeleteIdentity)
263263
})
264+
265+
// OAuth grant management endpoints (only if OAuth server is enabled)
266+
if globalConfig.OAuthServer.Enabled {
267+
r.Route("/oauth/grants", func(r *router) {
268+
r.Get("/", api.oauthServer.UserListOAuthGrants)
269+
r.Delete("/", api.oauthServer.UserRevokeOAuthGrant)
270+
})
271+
}
264272
})
265273

266274
r.With(api.requireAuthentication).Route("/factors", func(r *router) {

internal/api/apierrors/errorcode.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,5 @@ const (
100100

101101
ErrorCodeOAuthClientNotFound ErrorCode = "oauth_client_not_found"
102102
ErrorCodeOAuthAuthorizationNotFound ErrorCode = "oauth_authorization_not_found"
103+
ErrorCodeOAuthConsentNotFound ErrorCode = "oauth_consent_not_found"
103104
)

internal/api/oauthserver/handlers.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,133 @@ func (s *Server) handleRefreshTokenGrant(ctx context.Context, w http.ResponseWri
538538
func (s *Server) getTokenService() *tokens.Service {
539539
return s.tokenService
540540
}
541+
542+
// UserOAuthGrantResponse represents an OAuth grant that a user has authorized
543+
type UserOAuthGrantResponse struct {
544+
ClientID string `json:"client_id"`
545+
ClientName string `json:"client_name,omitempty"`
546+
ClientURI string `json:"client_uri,omitempty"`
547+
LogoURI string `json:"logo_uri,omitempty"`
548+
Scopes []string `json:"scopes"`
549+
GrantedAt time.Time `json:"granted_at"`
550+
}
551+
552+
// UserOAuthGrantsListResponse represents the response for listing user's OAuth grants
553+
type UserOAuthGrantsListResponse struct {
554+
Grants []UserOAuthGrantResponse `json:"grants"`
555+
}
556+
557+
// UserListOAuthGrants handles GET /user/oauth/grants
558+
// Lists all OAuth grants that the authenticated user has authorized (active consents)
559+
func (s *Server) UserListOAuthGrants(w http.ResponseWriter, r *http.Request) error {
560+
ctx := r.Context()
561+
user := shared.GetUser(ctx)
562+
563+
if user == nil {
564+
return apierrors.NewForbiddenError(apierrors.ErrorCodeBadJWT, "authentication required")
565+
}
566+
567+
db := s.db.WithContext(ctx)
568+
569+
// Get all active (non-revoked) consents for this user
570+
consents, err := models.FindOAuthServerConsentsByUser(db, user.ID, false)
571+
if err != nil {
572+
return apierrors.NewInternalServerError("Error fetching OAuth grants").WithInternalError(err)
573+
}
574+
575+
// Build response with client information
576+
grants := make([]UserOAuthGrantResponse, 0, len(consents))
577+
578+
for _, consent := range consents {
579+
// Fetch client details
580+
client, err := models.FindOAuthServerClientByID(db, consent.ClientID)
581+
if err != nil {
582+
// Skip clients that no longer exist or are deleted
583+
if models.IsNotFoundError(err) {
584+
continue
585+
}
586+
return apierrors.NewInternalServerError("Error fetching client details").WithInternalError(err)
587+
}
588+
589+
response := UserOAuthGrantResponse{
590+
ClientID: client.ID.String(),
591+
ClientName: utilities.StringValue(client.ClientName),
592+
ClientURI: utilities.StringValue(client.ClientURI),
593+
LogoURI: utilities.StringValue(client.LogoURI),
594+
Scopes: consent.GetScopeList(),
595+
GrantedAt: consent.GrantedAt,
596+
}
597+
598+
grants = append(grants, response)
599+
}
600+
601+
response := UserOAuthGrantsListResponse{
602+
Grants: grants,
603+
}
604+
605+
return shared.SendJSON(w, http.StatusOK, response)
606+
}
607+
608+
// UserRevokeOAuthGrant handles DELETE /user/oauth/grants?client_id=...
609+
// Revokes the user's OAuth grant for a specific client
610+
func (s *Server) UserRevokeOAuthGrant(w http.ResponseWriter, r *http.Request) error {
611+
ctx := r.Context()
612+
user := shared.GetUser(ctx)
613+
614+
if user == nil {
615+
return apierrors.NewForbiddenError(apierrors.ErrorCodeBadJWT, "authentication required")
616+
}
617+
618+
clientIDStr := r.URL.Query().Get("client_id")
619+
if clientIDStr == "" {
620+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "client_id query parameter is required")
621+
}
622+
623+
// Parse client_id as UUID
624+
clientID, err := uuid.FromString(clientIDStr)
625+
if err != nil {
626+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "invalid client_id format")
627+
}
628+
629+
db := s.db.WithContext(ctx)
630+
631+
// Find the active consent for this user and client
632+
consent, err := models.FindActiveOAuthServerConsentByUserAndClient(db, user.ID, clientID)
633+
if err != nil {
634+
return apierrors.NewInternalServerError("Error finding consent").WithInternalError(err)
635+
}
636+
637+
if consent == nil {
638+
return apierrors.NewNotFoundError(apierrors.ErrorCodeOAuthConsentNotFound, "No active grant found for this client")
639+
}
640+
641+
// Revoke the consent in a transaction
642+
err = db.Transaction(func(tx *storage.Connection) error {
643+
if terr := consent.Revoke(tx); terr != nil {
644+
return terr
645+
}
646+
647+
// Delete all sessions associated with this OAuth client for this user
648+
// This will invalidate all refresh tokens for those sessions
649+
if terr := models.RevokeOAuthSessions(tx, user.ID, clientID); terr != nil {
650+
return terr
651+
}
652+
653+
// Create audit log entry
654+
if terr := models.NewAuditLogEntry(s.config.AuditLog, r, tx, user, models.TokenRevokedAction, "", map[string]interface{}{
655+
"oauth_client_id": clientID.String(),
656+
"action": "revoke_oauth_grant",
657+
}); terr != nil {
658+
return terr
659+
}
660+
661+
return nil
662+
})
663+
664+
if err != nil {
665+
return apierrors.NewInternalServerError("Error revoking grant").WithInternalError(err)
666+
}
667+
668+
w.WriteHeader(http.StatusNoContent)
669+
return nil
670+
}

internal/api/oauthserver/handlers_test.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http/httptest"
99
"testing"
1010

11+
"github.com/gofrs/uuid"
1112
"github.com/stretchr/testify/assert"
1213
"github.com/stretchr/testify/require"
1314
"github.com/stretchr/testify/suite"
@@ -437,3 +438,228 @@ func (ts *OAuthClientTestSuite) TestHandlerValidation() {
437438
require.Error(ts.T(), err)
438439
assert.Contains(ts.T(), err.Error(), "invalid redirect_uri")
439440
}
441+
442+
// Helper function to create a test user
443+
func (ts *OAuthClientTestSuite) createTestUser(email string) *models.User {
444+
user, err := models.NewUser("", email, "password123", "authenticated", nil)
445+
require.NoError(ts.T(), err)
446+
require.NotNil(ts.T(), user)
447+
448+
err = ts.DB.Create(user)
449+
require.NoError(ts.T(), err)
450+
451+
return user
452+
}
453+
454+
// Helper function to create a test OAuth consent
455+
func (ts *OAuthClientTestSuite) createTestConsent(userID, clientID string, scopes []string) *models.OAuthServerConsent {
456+
userUUID, err := uuid.FromString(userID)
457+
require.NoError(ts.T(), err)
458+
459+
clientUUID, err := uuid.FromString(clientID)
460+
require.NoError(ts.T(), err)
461+
462+
consent := models.NewOAuthServerConsent(userUUID, clientUUID, scopes)
463+
require.NoError(ts.T(), models.UpsertOAuthServerConsent(ts.DB, consent))
464+
465+
return consent
466+
}
467+
468+
// Helper function to create a test session for OAuth
469+
func (ts *OAuthClientTestSuite) createTestSession(userID, clientID string) *models.Session {
470+
userUUID, err := uuid.FromString(userID)
471+
require.NoError(ts.T(), err)
472+
473+
clientUUID, err := uuid.FromString(clientID)
474+
require.NoError(ts.T(), err)
475+
476+
session, err := models.NewSession(userUUID, nil)
477+
require.NoError(ts.T(), err)
478+
session.OAuthClientID = &clientUUID
479+
480+
err = ts.DB.Create(session)
481+
require.NoError(ts.T(), err)
482+
483+
return session
484+
}
485+
486+
func (ts *OAuthClientTestSuite) TestUserListOAuthGrants() {
487+
// Create test user
488+
user := ts.createTestUser("[email protected]")
489+
490+
// Create test OAuth clients
491+
client1, _ := ts.createTestOAuthClient()
492+
client2, _ := ts.createTestOAuthClient()
493+
494+
// Create consents for the user
495+
ts.createTestConsent(user.ID.String(), client1.ID.String(), []string{"read", "write"})
496+
ts.createTestConsent(user.ID.String(), client2.ID.String(), []string{"read"})
497+
498+
// Create HTTP request
499+
req := httptest.NewRequest(http.MethodGet, "/user/oauth/grants", nil)
500+
501+
// Add user to context (normally done by requireAuthentication middleware)
502+
ctx := shared.WithUser(req.Context(), user)
503+
req = req.WithContext(ctx)
504+
505+
w := httptest.NewRecorder()
506+
507+
// Call handler
508+
err := ts.Server.UserListOAuthGrants(w, req)
509+
require.NoError(ts.T(), err)
510+
511+
// Check response
512+
assert.Equal(ts.T(), http.StatusOK, w.Code)
513+
514+
var response UserOAuthGrantsListResponse
515+
err = json.Unmarshal(w.Body.Bytes(), &response)
516+
require.NoError(ts.T(), err)
517+
518+
// Should have 2 grants
519+
assert.Len(ts.T(), response.Grants, 2)
520+
521+
// Verify client details are included
522+
for _, grant := range response.Grants {
523+
assert.NotEmpty(ts.T(), grant.ClientID)
524+
assert.Equal(ts.T(), "Test Client", grant.ClientName)
525+
assert.NotEmpty(ts.T(), grant.Scopes)
526+
assert.NotEmpty(ts.T(), grant.GrantedAt)
527+
}
528+
529+
// Check that client1 (with read and write scopes) is in the response
530+
found := false
531+
for _, grant := range response.Grants {
532+
if grant.ClientID == client1.ID.String() {
533+
found = true
534+
assert.Contains(ts.T(), grant.Scopes, "read")
535+
assert.Contains(ts.T(), grant.Scopes, "write")
536+
}
537+
}
538+
assert.True(ts.T(), found, "client1 should be in the grants list")
539+
}
540+
541+
func (ts *OAuthClientTestSuite) TestUserListOAuthGrantsEmpty() {
542+
// Create test user with no grants
543+
user := ts.createTestUser("[email protected]")
544+
545+
req := httptest.NewRequest(http.MethodGet, "/user/oauth/grants", nil)
546+
ctx := shared.WithUser(req.Context(), user)
547+
req = req.WithContext(ctx)
548+
549+
w := httptest.NewRecorder()
550+
551+
err := ts.Server.UserListOAuthGrants(w, req)
552+
require.NoError(ts.T(), err)
553+
554+
assert.Equal(ts.T(), http.StatusOK, w.Code)
555+
556+
var response UserOAuthGrantsListResponse
557+
err = json.Unmarshal(w.Body.Bytes(), &response)
558+
require.NoError(ts.T(), err)
559+
560+
// Should have 0 grants
561+
assert.Len(ts.T(), response.Grants, 0)
562+
}
563+
564+
func (ts *OAuthClientTestSuite) TestUserListOAuthGrantsNoAuth() {
565+
// Test without user in context (unauthenticated)
566+
req := httptest.NewRequest(http.MethodGet, "/user/oauth/grants", nil)
567+
w := httptest.NewRecorder()
568+
569+
err := ts.Server.UserListOAuthGrants(w, req)
570+
require.Error(ts.T(), err)
571+
assert.Contains(ts.T(), err.Error(), "authentication required")
572+
}
573+
574+
func (ts *OAuthClientTestSuite) TestUserRevokeOAuthGrant() {
575+
// Create test user
576+
user := ts.createTestUser("[email protected]")
577+
578+
// Create a client and consent
579+
client, _ := ts.createTestOAuthClient()
580+
ts.createTestConsent(user.ID.String(), client.ID.String(), []string{"read", "write"})
581+
582+
// Create a session for this OAuth client
583+
session := ts.createTestSession(user.ID.String(), client.ID.String())
584+
585+
// Create HTTP request with query parameter
586+
req := httptest.NewRequest(http.MethodDelete, "/user/oauth/grants?client_id="+client.ID.String(), nil)
587+
588+
// Add user to context
589+
ctx := shared.WithUser(req.Context(), user)
590+
req = req.WithContext(ctx)
591+
592+
w := httptest.NewRecorder()
593+
594+
// Call handler - should succeed
595+
err := ts.Server.UserRevokeOAuthGrant(w, req)
596+
require.NoError(ts.T(), err)
597+
598+
// Check response
599+
assert.Equal(ts.T(), http.StatusNoContent, w.Code)
600+
assert.Empty(ts.T(), w.Body.String())
601+
602+
// Verify consent was revoked
603+
consent, err := models.FindOAuthServerConsentByUserAndClient(ts.DB, user.ID, client.ID)
604+
require.NoError(ts.T(), err)
605+
assert.NotNil(ts.T(), consent.RevokedAt, "consent should be revoked")
606+
607+
// Verify session was deleted
608+
deletedSession, err := models.FindSessionByID(ts.DB, session.ID, false)
609+
assert.Error(ts.T(), err, "session should be deleted")
610+
assert.Nil(ts.T(), deletedSession)
611+
}
612+
613+
func (ts *OAuthClientTestSuite) TestUserRevokeOAuthGrantNotFound() {
614+
// Create test user
615+
user := ts.createTestUser("[email protected]")
616+
617+
// Create a client but don't create a consent
618+
client, _ := ts.createTestOAuthClient()
619+
620+
// Create HTTP request with query parameter
621+
req := httptest.NewRequest(http.MethodDelete, "/user/oauth/grants?client_id="+client.ID.String(), nil)
622+
623+
// Add user to context
624+
ctx := shared.WithUser(req.Context(), user)
625+
req = req.WithContext(ctx)
626+
627+
w := httptest.NewRecorder()
628+
629+
// Call handler - should return error
630+
err := ts.Server.UserRevokeOAuthGrant(w, req)
631+
require.Error(ts.T(), err)
632+
assert.Contains(ts.T(), err.Error(), "No active grant found")
633+
}
634+
635+
func (ts *OAuthClientTestSuite) TestUserRevokeOAuthGrantInvalidClientID() {
636+
// Create test user
637+
user := ts.createTestUser("[email protected]")
638+
639+
// Create HTTP request with invalid client ID query parameter
640+
req := httptest.NewRequest(http.MethodDelete, "/user/oauth/grants?client_id=invalid-uuid", nil)
641+
642+
// Add user to context
643+
ctx := shared.WithUser(req.Context(), user)
644+
req = req.WithContext(ctx)
645+
646+
w := httptest.NewRecorder()
647+
648+
// Call handler - should return error
649+
err := ts.Server.UserRevokeOAuthGrant(w, req)
650+
require.Error(ts.T(), err)
651+
assert.Contains(ts.T(), err.Error(), "invalid client_id format")
652+
}
653+
654+
func (ts *OAuthClientTestSuite) TestUserRevokeOAuthGrantNoAuth() {
655+
// Test without user in context (unauthenticated)
656+
client, _ := ts.createTestOAuthClient()
657+
658+
req := httptest.NewRequest(http.MethodDelete, "/user/oauth/grants?client_id="+client.ID.String(), nil)
659+
660+
w := httptest.NewRecorder()
661+
662+
err := ts.Server.UserRevokeOAuthGrant(w, req)
663+
require.Error(ts.T(), err)
664+
assert.Contains(ts.T(), err.Error(), "authentication required")
665+
}

0 commit comments

Comments
 (0)