feat(oauth): user can manage existing OAuth grant

master
Aaron Liu 5 days ago
parent 43d77d2319
commit c99a4ece90

@ -26,6 +26,9 @@ type (
UpsertGrant(ctx context.Context, userID, clientID int, scopes []string) error
// UpdateGrantLastUsedAt updates the last used at for an OAuth grant for a user and client.
UpdateGrantLastUsedAt(ctx context.Context, userID, clientID int) error
// DeleteGrantByUserAndClientGUID deletes an OAuth grant for a user by the client GUID.
// Returns true if the grant was deleted, false if it was not found.
DeleteGrantByUserAndClientGUID(ctx context.Context, userID int, clientGUID string) (bool, error)
// List returns a paginated list of OAuth clients.
List(ctx context.Context, args *ListOAuthClientArgs) (*ListOAuthClientResult, error)
// GetByID returns the OAuth client by its ID.
@ -38,6 +41,8 @@ type (
Delete(ctx context.Context, id int) error
// CountGrants returns the number of grants for an OAuth client.
CountGrants(ctx context.Context, id int) (int, error)
// GetGrantsByUserID returns the OAuth grants for a user.
GetGrantsByUserID(ctx context.Context, userID int) ([]*ent.OAuthGrant, error)
}
ListOAuthClientArgs struct {
@ -50,6 +55,8 @@ type (
*PaginationResults
Clients []*ent.OAuthClient
}
LoadOAuthGrantClient struct{}
)
func NewOAuthClientClient(client *ent.Client, dbType conf.DBType) OAuthClientClient {
@ -111,6 +118,35 @@ func (c *oauthClientClient) UpdateGrantLastUsedAt(ctx context.Context, userID, c
Exec(ctx)
}
func (c *oauthClientClient) GetGrantsByUserID(ctx context.Context, userID int) ([]*ent.OAuthGrant, error) {
return withOAuthGrantEagerLoadings(ctx, c.client.OAuthGrant.Query()).
Where(oauthgrant.UserID(userID)).
All(ctx)
}
func (c *oauthClientClient) DeleteGrantByUserAndClientGUID(ctx context.Context, userID int, clientGUID string) (bool, error) {
// First, get the client by GUID to get its ID
client, err := c.client.OAuthClient.Query().
Where(oauthclient.GUID(clientGUID)).
First(ctx)
if err != nil {
if ent.IsNotFound(err) {
return false, nil
}
return false, fmt.Errorf("failed to get OAuth client: %w", err)
}
// Delete the grant for this user and client
deleted, err := c.client.OAuthGrant.Delete().
Where(oauthgrant.UserID(userID), oauthgrant.ClientID(client.ID)).
Exec(ctx)
if err != nil {
return false, fmt.Errorf("failed to delete OAuth grant: %w", err)
}
return deleted > 0, nil
}
func (c *oauthClientClient) List(ctx context.Context, args *ListOAuthClientArgs) (*ListOAuthClientResult, error) {
query := c.client.OAuthClient.Query()
@ -233,3 +269,12 @@ func getOAuthClientOrderOption(args *ListOAuthClientArgs) []oauthclient.OrderOpt
return []oauthclient.OrderOption{oauthclient.ByID(orderTerm)}
}
}
func withOAuthGrantEagerLoadings(ctx context.Context, q *ent.OAuthGrantQuery) *ent.OAuthGrantQuery {
if v, ok := ctx.Value(LoadOAuthGrantClient{}).(bool); ok && v {
q.WithClient(func(ocq *ent.OAuthClientQuery) {
})
}
return q
}

@ -66,3 +66,15 @@ func OpenIDUserInfo(c *gin.Context) {
c.JSON(200, res)
}
func DeleteOAuthGrant(c *gin.Context) {
service := ParametersFromContext[*oauth.DeleteOAuthGrantService](c, oauth.DeleteOAuthGrantParamCtx{})
err := service.Delete(c)
if err != nil {
c.JSON(200, serializer.Err(c, err))
c.Abort()
return
}
c.JSON(200, serializer.Response{})
}

@ -338,6 +338,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}),
controllers.OpenIDUserInfo,
)
oauthRouter.DELETE("grant/:app_id",
middleware.LoginRequired(),
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
controllers.FromUri[oauth.DeleteOAuthGrantService](oauth.DeleteOAuthGrantParamCtx{}),
controllers.DeleteOAuthGrant,
)
}
authn := session.Group("authn")

@ -224,6 +224,38 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error)
return resp, nil
}
type (
DeleteOAuthGrantParamCtx struct{}
DeleteOAuthGrantService struct {
AppID string `uri:"app_id" binding:"required"`
}
)
func (s *DeleteOAuthGrantService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
oAuthClient := dep.OAuthClientClient()
// Delete the grant - the method validates that the grant belongs to the current user
deleted, err := oAuthClient.DeleteGrantByUserAndClientGUID(c, user.ID, s.AppID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete OAuth grant", err)
}
if !deleted {
return serializer.NewError(serializer.CodeNotFound, "OAuth grant not found", nil)
}
dep.AuditRecorder().Record(c, &types.LogEntry{
Category: types.AuditLogTypeOAuthGrantRevoke,
Exts: map[string]string{
"client_id": s.AppID,
},
})
return nil
}
type (
UserInfoParamCtx struct{}
UserInfoService struct{}

@ -22,17 +22,18 @@ type PreparePasskeyLoginResponse struct {
}
type UserSettings struct {
VersionRetentionEnabled bool `json:"version_retention_enabled"`
VersionRetentionExt []string `json:"version_retention_ext,omitempty"`
VersionRetentionMax int `json:"version_retention_max,omitempty"`
Paswordless bool `json:"passwordless"`
TwoFAEnabled bool `json:"two_fa_enabled"`
Passkeys []Passkey `json:"passkeys,omitempty"`
DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"`
VersionRetentionEnabled bool `json:"version_retention_enabled"`
VersionRetentionExt []string `json:"version_retention_ext,omitempty"`
VersionRetentionMax int `json:"version_retention_max,omitempty"`
Paswordless bool `json:"passwordless"`
TwoFAEnabled bool `json:"two_fa_enabled"`
Passkeys []Passkey `json:"passkeys,omitempty"`
DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"`
OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
}
func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser) *UserSettings {
func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser, grants []*ent.OAuthGrant) *UserSettings {
return &UserSettings{
VersionRetentionEnabled: u.Settings.VersionRetention,
VersionRetentionExt: u.Settings.VersionRetentionExt,
@ -44,6 +45,9 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
}),
DisableViewSync: u.Settings.DisableViewSync,
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant {
return BuildOauthGrant(item)
}),
}
}
@ -172,6 +176,31 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User {
}
}
type OauthGrant struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
ClientLogo string `json:"client_logo"`
Scopes []string `json:"scopes"`
LastUsedAt *time.Time `json:"last_used_at"`
}
func BuildOauthGrant(grant *ent.OAuthGrant) OauthGrant {
res := OauthGrant{
Scopes: grant.Scopes,
LastUsedAt: grant.LastUsedAt,
}
if grant.Edges.Client != nil {
res.ClientID = grant.Edges.Client.GUID
res.ClientName = grant.Edges.Client.Name
if grant.Edges.Client.Props != nil {
res.ClientLogo = grant.Edges.Client.Props.Icon
}
}
return res
}
func BuildGroup(group *ent.Group, idEncoder hashid.Encoder) *Group {
if group == nil {
return nil

@ -131,7 +131,13 @@ func GetUserSettings(c *gin.Context) (*UserSettings, error) {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user passkey", err)
}
return BuildUserSettings(u, passkeys, dep.UAParser()), nil
ctx := context.WithValue(c, inventory.LoadOAuthGrantClient{}, true)
grants, err := dep.OAuthClientClient().GetGrantsByUserID(ctx, u.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user OAuth grants", err)
}
return BuildUserSettings(u, passkeys, dep.UAParser(), grants), nil
// 用户组有效期

Loading…
Cancel
Save