diff --git a/cmd/zitadel/startup.yaml b/cmd/zitadel/startup.yaml
index 73a81d25b6..01072dffb0 100644
--- a/cmd/zitadel/startup.yaml
+++ b/cmd/zitadel/startup.yaml
@@ -224,6 +224,8 @@ API:
DefaultLoginURL: $ZITADEL_ACCOUNTS/login?authRequestID=
DefaultAccessTokenLifetime: 12h
DefaultIdTokenLifetime: 12h
+ DefaultRefreshTokenIdleExpiration: 720h #30d
+ DefaultRefreshTokenExpiration: 2160h #90d
SigningKeyAlgorithm: RS256
UserAgentCookieConfig:
Name: caos.zitadel.useragent
diff --git a/docs/docs/apis/proto/auth.md b/docs/docs/apis/proto/auth.md
index 69cdf2efc7..8780902757 100644
--- a/docs/docs/apis/proto/auth.md
+++ b/docs/docs/apis/proto/auth.md
@@ -47,6 +47,36 @@ Returns the user sessions of the authorized user of the current useragent
+### ListMyRefreshTokens
+
+> **rpc** ListMyRefreshTokens([ListMyRefreshTokensRequest](#listmyrefreshtokensrequest))
+[ListMyRefreshTokensResponse](#listmyrefreshtokensresponse)
+
+Returns the refresh tokens of the authorized user
+
+
+
+
+### RevokeMyRefreshToken
+
+> **rpc** RevokeMyRefreshToken([RevokeMyRefreshTokenRequest](#revokemyrefreshtokenrequest))
+[RevokeMyRefreshTokenResponse](#revokemyrefreshtokenresponse)
+
+Revokes a single refresh token of the authorized user by its (token) id
+
+
+
+
+### RevokeAllMyRefreshTokens
+
+> **rpc** RevokeAllMyRefreshTokens([RevokeAllMyRefreshTokensRequest](#revokeallmyrefreshtokensrequest))
+[RevokeAllMyRefreshTokensResponse](#revokeallmyrefreshtokensresponse)
+
+Revokes all refresh tokens of the authorized user
+
+
+
+
### UpdateMyUserName
> **rpc** UpdateMyUserName([UpdateMyUserNameRequest](#updatemyusernamerequest))
@@ -636,6 +666,24 @@ This is an empty request
+### ListMyRefreshTokensRequest
+This is an empty request
+
+
+
+
+### ListMyRefreshTokensResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ListDetails | - | |
+| result | repeated zitadel.user.v1.RefreshToken | - | |
+
+
+
+
### ListMyUserChangesRequest
@@ -868,6 +916,40 @@ This is an empty request
+### RevokeAllMyRefreshTokensRequest
+This is an empty request
+
+
+
+
+### RevokeAllMyRefreshTokensResponse
+This is an empty response
+
+
+
+
+### RevokeMyRefreshTokenRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### RevokeMyRefreshTokenResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### SetMyEmailRequest
diff --git a/docs/docs/apis/proto/user.md b/docs/docs/apis/proto/user.md
index 2b8ce80a53..e4d841e12b 100644
--- a/docs/docs/apis/proto/user.md
+++ b/docs/docs/apis/proto/user.md
@@ -241,6 +241,24 @@ this query is always equals
+### RefreshToken
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | |
+| details | zitadel.v1.ObjectDetails | - | |
+| client_id | string | - | |
+| auth_time | google.protobuf.Timestamp | - | |
+| idle_expiration | google.protobuf.Timestamp | - | |
+| expiration | google.protobuf.Timestamp | - | |
+| scopes | repeated string | - | |
+| audience | repeated string | - | |
+
+
+
+
### SearchQuery
diff --git a/go.mod b/go.mod
index 29c7cf183b..72e178ba69 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
github.com/allegro/bigcache v1.2.1
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
github.com/caos/logging v0.0.2
- github.com/caos/oidc v0.14.7
+ github.com/caos/oidc v0.15.0
github.com/caos/orbos v1.5.14-0.20210428081839-983ffc569980
github.com/cockroachdb/cockroach-go/v2 v2.1.0
github.com/duo-labs/webauthn v0.0.0-20200714211715-1daaee874e43
diff --git a/go.sum b/go.sum
index 006922a536..2518ec435d 100644
--- a/go.sum
+++ b/go.sum
@@ -137,8 +137,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/caos/logging v0.0.2 h1:ebg5C/HN0ludYR+WkvnFjwSExF4wvyiWPyWGcKMYsoo=
github.com/caos/logging v0.0.2/go.mod h1:9LKiDE2ChuGv6CHYif/kiugrfEXu9AwDiFWSreX7Wp0=
github.com/caos/oidc v0.14.4/go.mod h1:H5Y2zw3YIrWqQOoy0wcmZva2a66bumDyU2iOhXiM9uA=
-github.com/caos/oidc v0.14.7 h1:HDxQhEWIOdmp1MoJAj8aRMP62Cga4yAk5M9UEJtdS1Y=
-github.com/caos/oidc v0.14.7/go.mod h1:JiK5RXSOgag66wiSOMEkS+yS4R46Baz6dGwfr60VfvI=
+github.com/caos/oidc v0.15.0 h1:lSykVX6yfUbWpJPAZ9/ZCuowo95h7AgfgPaC15lzf4Y=
+github.com/caos/oidc v0.15.0/go.mod h1:JiK5RXSOgag66wiSOMEkS+yS4R46Baz6dGwfr60VfvI=
github.com/caos/orbos v1.5.14-0.20210428081839-983ffc569980 h1:Fz0aYUwGMA2tsu5w7SryqFGjqGClJVHbyhBMT5SXtPU=
github.com/caos/orbos v1.5.14-0.20210428081839-983ffc569980/go.mod h1:2I8oiZb5SMRm/qTLvwpSmdV0M6ex8J/UKyxUGfKaqJo=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
@@ -436,6 +436,7 @@ github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2c
github.com/googleinterns/cloud-operations-api-mock v0.0.0-20200709193332-a1e58c29bdd3 h1:eHv/jVY/JNop1xg2J9cBb4EzyMpWZoNCP1BslSAIkOI=
github.com/googleinterns/cloud-operations-api-mock v0.0.0-20200709193332-a1e58c29bdd3/go.mod h1:h/KNeRx7oYU4SpA4SoY7W2/NxDKEEVuwA6j9A27L4OI=
github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/csrf v1.7.0 h1:mMPjV5/3Zd460xCavIkppUdvnl5fPXMpv2uz2Zyg7/Y=
@@ -577,6 +578,7 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
@@ -841,7 +843,9 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
diff --git a/internal/api/grpc/auth/refresh_token.go b/internal/api/grpc/auth/refresh_token.go
new file mode 100644
index 0000000000..219e65c612
--- /dev/null
+++ b/internal/api/grpc/auth/refresh_token.go
@@ -0,0 +1,58 @@
+package auth
+
+import (
+ "context"
+
+ "github.com/caos/zitadel/internal/api/authz"
+ "github.com/caos/zitadel/internal/api/grpc/object"
+ user_grpc "github.com/caos/zitadel/internal/api/grpc/user"
+ "github.com/caos/zitadel/internal/user/model"
+ "github.com/caos/zitadel/pkg/grpc/auth"
+)
+
+func (s *Server) ListMyRefreshTokens(ctx context.Context, req *auth.ListMyRefreshTokensRequest) (*auth.ListMyRefreshTokensResponse, error) {
+ res, err := s.repo.SearchMyRefreshTokens(ctx, authz.GetCtxData(ctx).UserID, ListMyRefreshTokensRequestToModel(req))
+ if err != nil {
+ return nil, err
+ }
+ return &auth.ListMyRefreshTokensResponse{
+ Result: user_grpc.RefreshTokensToPb(res.Result),
+ Details: object.ToListDetails(
+ res.TotalResult,
+ res.Sequence,
+ res.Timestamp,
+ ),
+ }, nil
+}
+
+func (s *Server) RevokeMyRefreshToken(ctx context.Context, req *auth.RevokeMyRefreshTokenRequest) (*auth.RevokeMyRefreshTokenResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ details, err := s.command.RevokeRefreshToken(ctx, ctxData.UserID, ctxData.ResourceOwner, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ return &auth.RevokeMyRefreshTokenResponse{
+ Details: object.DomainToChangeDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) RevokeAllMyRefreshTokens(ctx context.Context, _ *auth.RevokeAllMyRefreshTokensRequest) (*auth.RevokeAllMyRefreshTokensResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ res, err := s.repo.SearchMyRefreshTokens(ctx, ctxData.UserID, ListMyRefreshTokensRequestToModel(nil))
+ if err != nil {
+ return nil, err
+ }
+ tokenIDs := make([]string, len(res.Result))
+ for i, view := range res.Result {
+ tokenIDs[i] = view.ID
+ }
+ err = s.command.RevokeRefreshTokens(ctx, ctxData.UserID, ctxData.ResourceOwner, tokenIDs)
+ if err != nil {
+ return nil, err
+ }
+ return &auth.RevokeAllMyRefreshTokensResponse{}, nil
+}
+
+func ListMyRefreshTokensRequestToModel(_ *auth.ListMyRefreshTokensRequest) *model.RefreshTokenSearchRequest {
+ return &model.RefreshTokenSearchRequest{} //add sorting, queries, ... when possible
+}
diff --git a/internal/api/grpc/user/refresh_token.go b/internal/api/grpc/user/refresh_token.go
new file mode 100644
index 0000000000..315a07b632
--- /dev/null
+++ b/internal/api/grpc/user/refresh_token.go
@@ -0,0 +1,30 @@
+package user
+
+import (
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/caos/zitadel/internal/api/grpc/object"
+ "github.com/caos/zitadel/internal/user/model"
+ "github.com/caos/zitadel/pkg/grpc/user"
+)
+
+func RefreshTokensToPb(refreshTokens []*model.RefreshTokenView) []*user.RefreshToken {
+ tokens := make([]*user.RefreshToken, len(refreshTokens))
+ for i, token := range refreshTokens {
+ tokens[i] = RefreshTokenToPb(token)
+ }
+ return tokens
+}
+
+func RefreshTokenToPb(token *model.RefreshTokenView) *user.RefreshToken {
+ return &user.RefreshToken{
+ Id: token.ID,
+ Details: object.ToViewDetailsPb(token.Sequence, token.CreationDate, token.ChangeDate, token.ResourceOwner),
+ ClientId: token.ClientID,
+ AuthTime: timestamppb.New(token.AuthTime),
+ IdleExpiration: timestamppb.New(token.IdleExpiration),
+ Expiration: timestamppb.New(token.Expiration),
+ Scopes: token.Scopes,
+ Audience: token.Audience,
+ }
+}
diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go
index c10d08b30f..06de8b00d4 100644
--- a/internal/api/oidc/auth_request.go
+++ b/internal/api/oidc/auth_request.go
@@ -80,7 +80,7 @@ func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) (err error
return o.repo.DeleteAuthRequest(ctx, id)
}
-func (o *OPStorage) CreateToken(ctx context.Context, req op.TokenRequest) (_ string, _ time.Time, err error) {
+func (o *OPStorage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (_ string, _ time.Time, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
var userAgentID, applicationID, userOrgID string
@@ -107,6 +107,37 @@ func grantsToScopes(grants []*grant_model.UserGrantView) []string {
return scopes
}
+func (o *OPStorage) CreateAccessAndRefreshTokens(ctx context.Context, req op.TokenRequest, refreshToken string) (_, _ string, _ time.Time, err error) {
+ ctx, span := tracing.NewSpan(ctx)
+ defer func() { span.EndWithError(err) }()
+ var userAgentID, applicationID, userOrgID string
+ var authTime time.Time
+ var authMethodsReferences []string
+ authReq, ok := req.(*AuthRequest)
+ if ok {
+ userAgentID = authReq.AgentID
+ applicationID = authReq.ApplicationID
+ userOrgID = authReq.UserOrgID
+ authTime = authReq.AuthTime
+ authMethodsReferences = authReq.GetAMR()
+ }
+ resp, token, err := o.command.AddAccessAndRefreshToken(ctx, userOrgID, userAgentID, applicationID, req.GetSubject(),
+ refreshToken, req.GetAudience(), req.GetScopes(), authMethodsReferences, o.defaultAccessTokenLifetime,
+ o.defaultRefreshTokenIdleExpiration, o.defaultRefreshTokenExpiration, authTime) //PLANNED: lifetime from client
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+ return resp.TokenID, token, resp.Expiration, nil
+}
+
+func (o *OPStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) {
+ tokenView, err := o.repo.RefreshTokenByID(ctx, refreshToken)
+ if err != nil {
+ return nil, err
+ }
+ return RefreshTokenRequestFromBusiness(tokenView), nil
+}
+
func (o *OPStorage) TerminateSession(ctx context.Context, userID, clientID string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go
index 6a3fc44298..73756500b2 100644
--- a/internal/api/oidc/auth_request_converter.go
+++ b/internal/api/oidc/auth_request_converter.go
@@ -2,7 +2,6 @@ package oidc
import (
"context"
- "github.com/caos/zitadel/internal/domain"
"net"
"time"
@@ -11,7 +10,9 @@ import (
"golang.org/x/text/language"
http_utils "github.com/caos/zitadel/internal/api/http"
+ "github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/user/model"
)
const (
@@ -255,3 +256,39 @@ func AMRFromMFAType(mfaType domain.MFAType) string {
return ""
}
}
+
+func RefreshTokenRequestFromBusiness(tokenView *model.RefreshTokenView) op.RefreshTokenRequest {
+ return &RefreshTokenRequest{tokenView}
+}
+
+type RefreshTokenRequest struct {
+ *model.RefreshTokenView
+}
+
+func (r *RefreshTokenRequest) GetAMR() []string {
+ return r.AuthMethodsReferences
+}
+
+func (r *RefreshTokenRequest) GetAudience() []string {
+ return r.Audience
+}
+
+func (r *RefreshTokenRequest) GetAuthTime() time.Time {
+ return r.AuthTime
+}
+
+func (r *RefreshTokenRequest) GetClientID() string {
+ return r.ClientID
+}
+
+func (r *RefreshTokenRequest) GetScopes() []string {
+ return r.Scopes
+}
+
+func (r *RefreshTokenRequest) GetSubject() string {
+ return r.UserID
+}
+
+func (r *RefreshTokenRequest) SetCurrentScopes(scopes oidc.Scopes) {
+ r.Scopes = scopes
+}
diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go
index a6a3152baa..af175dd897 100644
--- a/internal/api/oidc/op.go
+++ b/internal/api/oidc/op.go
@@ -28,10 +28,12 @@ type OPHandlerConfig struct {
}
type StorageConfig struct {
- DefaultLoginURL string
- SigningKeyAlgorithm string
- DefaultAccessTokenLifetime types.Duration
- DefaultIdTokenLifetime types.Duration
+ DefaultLoginURL string
+ SigningKeyAlgorithm string
+ DefaultAccessTokenLifetime types.Duration
+ DefaultIdTokenLifetime types.Duration
+ DefaultRefreshTokenIdleExpiration types.Duration
+ DefaultRefreshTokenExpiration types.Duration
}
type EndpointConfig struct {
@@ -49,13 +51,15 @@ type Endpoint struct {
}
type OPStorage struct {
- repo repository.Repository
- command *command.Commands
- query *query.Queries
- defaultLoginURL string
- defaultAccessTokenLifetime time.Duration
- defaultIdTokenLifetime time.Duration
- signingKeyAlgorithm string
+ repo repository.Repository
+ command *command.Commands
+ query *query.Queries
+ defaultLoginURL string
+ defaultAccessTokenLifetime time.Duration
+ defaultIdTokenLifetime time.Duration
+ signingKeyAlgorithm string
+ defaultRefreshTokenIdleExpiration time.Duration
+ defaultRefreshTokenExpiration time.Duration
}
func NewProvider(ctx context.Context, config OPHandlerConfig, command *command.Commands, query *query.Queries, repo repository.Repository, keyConfig *crypto.KeyConfig, localDevMode bool) op.OpenIDProvider {
@@ -94,13 +98,15 @@ func NewProvider(ctx context.Context, config OPHandlerConfig, command *command.C
func newStorage(config StorageConfig, command *command.Commands, query *query.Queries, repo repository.Repository) *OPStorage {
return &OPStorage{
- repo: repo,
- command: command,
- query: query,
- defaultLoginURL: config.DefaultLoginURL,
- signingKeyAlgorithm: config.SigningKeyAlgorithm,
- defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime.Duration,
- defaultIdTokenLifetime: config.DefaultIdTokenLifetime.Duration,
+ repo: repo,
+ command: command,
+ query: query,
+ defaultLoginURL: config.DefaultLoginURL,
+ signingKeyAlgorithm: config.SigningKeyAlgorithm,
+ defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime.Duration,
+ defaultIdTokenLifetime: config.DefaultIdTokenLifetime.Duration,
+ defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration.Duration,
+ defaultRefreshTokenExpiration: config.DefaultRefreshTokenExpiration.Duration,
}
}
diff --git a/internal/auth/repository/eventsourcing/eventstore/refresh_token.go b/internal/auth/repository/eventsourcing/eventstore/refresh_token.go
new file mode 100644
index 0000000000..6c8d4907fc
--- /dev/null
+++ b/internal/auth/repository/eventsourcing/eventstore/refresh_token.go
@@ -0,0 +1,94 @@
+package eventstore
+
+import (
+ "context"
+ "time"
+
+ "github.com/caos/logging"
+
+ "github.com/caos/zitadel/internal/crypto"
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/eventstore/v1"
+ "github.com/caos/zitadel/internal/eventstore/v1/models"
+ usr_view "github.com/caos/zitadel/internal/user/repository/view"
+
+ "github.com/caos/zitadel/internal/auth/repository/eventsourcing/view"
+ "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/telemetry/tracing"
+ usr_model "github.com/caos/zitadel/internal/user/model"
+ "github.com/caos/zitadel/internal/user/repository/view/model"
+)
+
+type RefreshTokenRepo struct {
+ Eventstore v1.Eventstore
+ View *view.View
+ SearchLimit uint64
+ KeyAlgorithm crypto.EncryptionAlgorithm
+}
+
+func (r *RefreshTokenRepo) RefreshTokenByID(ctx context.Context, refreshToken string) (*usr_model.RefreshTokenView, error) {
+ userID, tokenID, token, err := domain.FromRefreshToken(refreshToken, r.KeyAlgorithm)
+ if err != nil {
+ return nil, err
+ }
+ tokenView, viewErr := r.View.RefreshTokenByID(tokenID)
+ if viewErr != nil && !errors.IsNotFound(viewErr) {
+ return nil, viewErr
+ }
+ if errors.IsNotFound(viewErr) {
+ tokenView = new(model.RefreshTokenView)
+ tokenView.ID = tokenID
+ tokenView.UserID = userID
+ }
+
+ events, esErr := r.getUserEvents(ctx, userID, tokenView.Sequence)
+ if errors.IsNotFound(viewErr) && len(events) == 0 {
+ return nil, errors.ThrowNotFound(nil, "EVENT-BHB52", "Errors.User.RefreshToken.Invalid")
+ }
+
+ if esErr != nil {
+ logging.Log("EVENT-AE462").WithError(viewErr).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("error retrieving new events")
+ return model.RefreshTokenViewToModel(tokenView), nil
+ }
+ viewToken := *tokenView
+ for _, event := range events {
+ err := tokenView.AppendEventIfMyRefreshToken(event)
+ if err != nil {
+ return model.RefreshTokenViewToModel(&viewToken), nil
+ }
+ }
+ if !tokenView.Expiration.After(time.Now()) || tokenView.Token != token {
+ return nil, errors.ThrowNotFound(nil, "EVENT-5Bm9s", "Errors.User.RefreshToken.Invalid")
+ }
+ return model.RefreshTokenViewToModel(tokenView), nil
+}
+
+func (r *RefreshTokenRepo) SearchMyRefreshTokens(ctx context.Context, userID string, request *usr_model.RefreshTokenSearchRequest) (*usr_model.RefreshTokenSearchResponse, error) {
+ err := request.EnsureLimit(r.SearchLimit)
+ if err != nil {
+ return nil, err
+ }
+ sequence, err := r.View.GetLatestRefreshTokenSequence()
+ logging.Log("EVENT-GBdn4").OnError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Warn("could not read latest refresh token sequence")
+ request.Queries = append(request.Queries, &usr_model.RefreshTokenSearchQuery{Key: usr_model.RefreshTokenSearchKeyUserID, Method: domain.SearchMethodEquals, Value: userID})
+ tokens, count, err := r.View.SearchRefreshTokens(request)
+ if err != nil {
+ return nil, err
+ }
+ return &usr_model.RefreshTokenSearchResponse{
+ Offset: request.Offset,
+ Limit: request.Limit,
+ TotalResult: count,
+ Sequence: sequence.CurrentSequence,
+ Timestamp: sequence.LastSuccessfulSpoolerRun,
+ Result: model.RefreshTokenViewsToModel(tokens),
+ }, nil
+}
+
+func (r *RefreshTokenRepo) getUserEvents(ctx context.Context, userID string, sequence uint64) ([]*models.Event, error) {
+ query, err := usr_view.UserByIDQuery(userID, sequence)
+ if err != nil {
+ return nil, err
+ }
+ return r.Eventstore.FilterEvents(ctx, query)
+}
diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go
index 7c59700081..0f60096f07 100644
--- a/internal/auth/repository/eventsourcing/handler/handler.go
+++ b/internal/auth/repository/eventsourcing/handler/handler.go
@@ -69,6 +69,7 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es
newProjectRole(handler{view, bulkLimit, configs.cycleDuration("ProjectRole"), errorCount, es}),
newLabelPolicy(handler{view, bulkLimit, configs.cycleDuration("LabelPolicy"), errorCount, es}),
newFeatures(handler{view, bulkLimit, configs.cycleDuration("Features"), errorCount, es}),
+ newRefreshToken(handler{view, bulkLimit, configs.cycleDuration("RefreshToken"), errorCount, es}),
}
}
diff --git a/internal/auth/repository/eventsourcing/handler/refresh_token.go b/internal/auth/repository/eventsourcing/handler/refresh_token.go
new file mode 100644
index 0000000000..e88a2b632d
--- /dev/null
+++ b/internal/auth/repository/eventsourcing/handler/refresh_token.go
@@ -0,0 +1,123 @@
+package handler
+
+import (
+ "encoding/json"
+
+ "github.com/caos/logging"
+
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/eventstore/v1"
+ es_models "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "github.com/caos/zitadel/internal/eventstore/v1/query"
+ "github.com/caos/zitadel/internal/eventstore/v1/spooler"
+ project_es_model "github.com/caos/zitadel/internal/project/repository/eventsourcing/model"
+ user_repo "github.com/caos/zitadel/internal/repository/user"
+ user_es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
+ view_model "github.com/caos/zitadel/internal/user/repository/view/model"
+)
+
+const (
+ refreshTokenTable = "auth.refresh_tokens"
+)
+
+type RefreshToken struct {
+ handler
+ subscription *v1.Subscription
+}
+
+func newRefreshToken(
+ handler handler,
+) *RefreshToken {
+ h := &RefreshToken{
+ handler: handler,
+ }
+
+ h.subscribe()
+
+ return h
+}
+
+func (t *RefreshToken) subscribe() {
+ t.subscription = t.es.Subscribe(t.AggregateTypes()...)
+ go func() {
+ for event := range t.subscription.Events {
+ query.ReduceEvent(t, event)
+ }
+ }()
+}
+
+func (t *RefreshToken) ViewModel() string {
+ return refreshTokenTable
+}
+
+func (t *RefreshToken) AggregateTypes() []es_models.AggregateType {
+ return []es_models.AggregateType{user_es_model.UserAggregate, project_es_model.ProjectAggregate}
+}
+
+func (t *RefreshToken) CurrentSequence() (uint64, error) {
+ sequence, err := t.view.GetLatestRefreshTokenSequence()
+ if err != nil {
+ return 0, err
+ }
+ return sequence.CurrentSequence, nil
+}
+
+func (t *RefreshToken) EventQuery() (*es_models.SearchQuery, error) {
+ sequence, err := t.view.GetLatestRefreshTokenSequence()
+ if err != nil {
+ return nil, err
+ }
+ return es_models.NewSearchQuery().
+ AggregateTypeFilter(user_es_model.UserAggregate, project_es_model.ProjectAggregate).
+ LatestSequenceFilter(sequence.CurrentSequence), nil
+}
+
+func (t *RefreshToken) Reduce(event *es_models.Event) (err error) {
+ switch eventstore.EventType(event.Type) {
+ case user_repo.HumanRefreshTokenAddedType:
+ token := new(view_model.RefreshTokenView)
+ err := token.AppendEvent(event)
+ if err != nil {
+ return err
+ }
+ return t.view.PutRefreshToken(token, event)
+ case user_repo.HumanRefreshTokenRenewedType:
+ e := new(user_repo.HumanRefreshTokenRenewedEvent)
+ if err := json.Unmarshal(event.Data, e); err != nil {
+ logging.Log("EVEN-DBbn4").WithError(err).Error("could not unmarshal event data")
+ return caos_errs.ThrowInternal(nil, "MODEL-BHn75", "could not unmarshal data")
+ }
+ token, err := t.view.RefreshTokenByID(e.TokenID)
+ if err != nil {
+ return err
+ }
+ err = token.AppendEvent(event)
+ if err != nil {
+ return err
+ }
+ return t.view.PutRefreshToken(token, event)
+ case user_repo.HumanRefreshTokenRemovedType:
+ e := new(user_repo.HumanRefreshTokenRemovedEvent)
+ if err := json.Unmarshal(event.Data, e); err != nil {
+ logging.Log("EVEN-BDbh3").WithError(err).Error("could not unmarshal event data")
+ return caos_errs.ThrowInternal(nil, "MODEL-Bz653", "could not unmarshal data")
+ }
+ return t.view.DeleteRefreshToken(e.TokenID, event)
+ case user_repo.UserLockedType,
+ user_repo.UserDeactivatedType,
+ user_repo.UserRemovedType:
+ return t.view.DeleteUserRefreshTokens(event.AggregateID, event)
+ default:
+ return t.view.ProcessedRefreshTokenSequence(event)
+ }
+}
+
+func (t *RefreshToken) OnError(event *es_models.Event, err error) error {
+ logging.LogWithFields("SPOOL-3jkl4", "id", event.AggregateID).WithError(err).Warn("something went wrong in token handler")
+ return spooler.HandleError(event, err, t.view.GetLatestTokenFailedEvent, t.view.ProcessedTokenFailedEvent, t.view.ProcessedTokenSequence, t.errorCountUntilSkip)
+}
+
+func (t *RefreshToken) OnSuccess() error {
+ return spooler.HandleSuccess(t.view.UpdateTokenSpoolerRunTimestamp)
+}
diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go
index e0710162c4..283fe1959d 100644
--- a/internal/auth/repository/eventsourcing/repository.go
+++ b/internal/auth/repository/eventsourcing/repository.go
@@ -36,6 +36,7 @@ type EsRepository struct {
eventstore.UserRepo
eventstore.AuthRequestRepo
eventstore.TokenRepo
+ eventstore.RefreshTokenRepo
eventstore.KeyRepository
eventstore.ApplicationRepo
eventstore.UserSessionRepo
@@ -110,6 +111,12 @@ func Start(conf Config, authZ authz.Config, systemDefaults sd.SystemDefaults, co
View: view,
Eventstore: es,
},
+ eventstore.RefreshTokenRepo{
+ View: view,
+ Eventstore: es,
+ SearchLimit: conf.SearchLimit,
+ KeyAlgorithm: keyAlgorithm,
+ },
eventstore.KeyRepository{
View: view,
Commands: command,
diff --git a/internal/auth/repository/eventsourcing/view/refresh_token.go b/internal/auth/repository/eventsourcing/view/refresh_token.go
new file mode 100644
index 0000000000..01acb1ba3e
--- /dev/null
+++ b/internal/auth/repository/eventsourcing/view/refresh_token.go
@@ -0,0 +1,86 @@
+package view
+
+import (
+ "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore/v1/models"
+ user_model "github.com/caos/zitadel/internal/user/model"
+ usr_view "github.com/caos/zitadel/internal/user/repository/view"
+ "github.com/caos/zitadel/internal/user/repository/view/model"
+ "github.com/caos/zitadel/internal/view/repository"
+)
+
+const (
+ refreshTokenTable = "auth.refresh_tokens"
+)
+
+func (v *View) RefreshTokenByID(tokenID string) (*model.RefreshTokenView, error) {
+ return usr_view.RefreshTokenByID(v.Db, refreshTokenTable, tokenID)
+}
+
+func (v *View) RefreshTokensByUserID(userID string) ([]*model.RefreshTokenView, error) {
+ return usr_view.RefreshTokensByUserID(v.Db, refreshTokenTable, userID)
+}
+
+func (v *View) SearchRefreshTokens(request *user_model.RefreshTokenSearchRequest) ([]*model.RefreshTokenView, uint64, error) {
+ return usr_view.SearchRefreshTokens(v.Db, refreshTokenTable, request)
+}
+
+func (v *View) PutRefreshToken(token *model.RefreshTokenView, event *models.Event) error {
+ err := usr_view.PutRefreshToken(v.Db, refreshTokenTable, token)
+ if err != nil {
+ return err
+ }
+ return v.ProcessedTokenSequence(event)
+}
+
+func (v *View) PutRefreshTokens(token []*model.RefreshTokenView, event *models.Event) error {
+ err := usr_view.PutRefreshTokens(v.Db, refreshTokenTable, token...)
+ if err != nil {
+ return err
+ }
+ return v.ProcessedRefreshTokenSequence(event)
+}
+
+func (v *View) DeleteRefreshToken(tokenID string, event *models.Event) error {
+ err := usr_view.DeleteRefreshToken(v.Db, refreshTokenTable, tokenID)
+ if err != nil && !errors.IsNotFound(err) {
+ return err
+ }
+ return v.ProcessedRefreshTokenSequence(event)
+}
+
+func (v *View) DeleteUserRefreshTokens(userID string, event *models.Event) error {
+ err := usr_view.DeleteUserRefreshTokens(v.Db, refreshTokenTable, userID)
+ if err != nil && !errors.IsNotFound(err) {
+ return err
+ }
+ return v.ProcessedRefreshTokenSequence(event)
+}
+
+func (v *View) DeleteApplicationRefreshTokens(event *models.Event, ids ...string) error {
+ err := usr_view.DeleteApplicationTokens(v.Db, refreshTokenTable, ids)
+ if err != nil && !errors.IsNotFound(err) {
+ return err
+ }
+ return v.ProcessedRefreshTokenSequence(event)
+}
+
+func (v *View) GetLatestRefreshTokenSequence() (*repository.CurrentSequence, error) {
+ return v.latestSequence(refreshTokenTable)
+}
+
+func (v *View) ProcessedRefreshTokenSequence(event *models.Event) error {
+ return v.saveCurrentSequence(refreshTokenTable, event)
+}
+
+func (v *View) UpdateRefreshTokenSpoolerRunTimestamp() error {
+ return v.updateSpoolerRunSequence(refreshTokenTable)
+}
+
+func (v *View) GetLatestRefreshTokenFailedEvent(sequence uint64) (*repository.FailedEvent, error) {
+ return v.latestFailedEvent(refreshTokenTable, sequence)
+}
+
+func (v *View) ProcessedRefreshTokenFailedEvent(failedEvent *repository.FailedEvent) error {
+ return v.saveFailedEvent(failedEvent)
+}
diff --git a/internal/auth/repository/refresh_token.go b/internal/auth/repository/refresh_token.go
new file mode 100644
index 0000000000..ea4c7010dd
--- /dev/null
+++ b/internal/auth/repository/refresh_token.go
@@ -0,0 +1,12 @@
+package repository
+
+import (
+ "context"
+
+ "github.com/caos/zitadel/internal/user/model"
+)
+
+type RefreshTokenRepository interface {
+ RefreshTokenByID(ctx context.Context, refreshToken string) (*model.RefreshTokenView, error)
+ SearchMyRefreshTokens(ctx context.Context, userID string, request *model.RefreshTokenSearchRequest) (*model.RefreshTokenSearchResponse, error)
+}
diff --git a/internal/auth/repository/repository.go b/internal/auth/repository/repository.go
index 28888ad678..6a78027e03 100644
--- a/internal/auth/repository/repository.go
+++ b/internal/auth/repository/repository.go
@@ -17,4 +17,5 @@ type Repository interface {
OrgRepository
IAMRepository
FeaturesRepository
+ RefreshTokenRepository
}
diff --git a/internal/command/command.go b/internal/command/command.go
index 913e9ec2c4..9261fae5f9 100644
--- a/internal/command/command.go
+++ b/internal/command/command.go
@@ -2,19 +2,20 @@ package command
import (
"context"
+ "time"
+
"github.com/caos/zitadel/internal/api/authz"
authz_repo "github.com/caos/zitadel/internal/authz/repository/eventsourcing"
"github.com/caos/zitadel/internal/config/types"
"github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/eventstore"
- "time"
"github.com/caos/zitadel/internal/api/http"
sd "github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/id"
iam_repo "github.com/caos/zitadel/internal/repository/iam"
- keypair "github.com/caos/zitadel/internal/repository/keypair"
+ "github.com/caos/zitadel/internal/repository/keypair"
"github.com/caos/zitadel/internal/repository/org"
proj_repo "github.com/caos/zitadel/internal/repository/project"
usr_repo "github.com/caos/zitadel/internal/repository/user"
diff --git a/internal/command/user.go b/internal/command/user.go
index 7c789e631a..75f9360e23 100644
--- a/internal/command/user.go
+++ b/internal/command/user.go
@@ -3,12 +3,14 @@ package command
import (
"context"
"fmt"
- "github.com/caos/zitadel/internal/eventstore"
"time"
+ "github.com/caos/zitadel/internal/eventstore"
+
"github.com/caos/zitadel/internal/eventstore/v1/models"
"github.com/caos/logging"
+
"github.com/caos/zitadel/internal/domain"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/repository/user"
@@ -209,50 +211,57 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string,
}
func (c *Commands) AddUserToken(ctx context.Context, orgID, agentID, clientID, userID string, audience, scopes []string, lifetime time.Duration) (*domain.Token, error) {
- if userID == "" {
- return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-55n8M", "Errors.IDMissing")
+ if orgID == "" || userID == "" {
+ return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Dbge4", "Errors.IDMissing")
}
-
- existingUser, err := c.userWriteModelByID(ctx, userID, orgID)
+ userWriteModel := NewUserWriteModel(userID, orgID)
+ event, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, lifetime)
if err != nil {
return nil, err
}
- if !isUserStateExists(existingUser.UserState) {
- return nil, caos_errs.ThrowNotFound(nil, "COMMAND-1d6Gg", "Errors.User.NotFound")
+ _, err = c.eventstore.PushEvents(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+ return accessToken, nil
+}
+
+func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteModel, agentID, clientID string, audience, scopes []string, lifetime time.Duration) (*user.UserTokenAddedEvent, *domain.Token, error) {
+ err := c.eventstore.FilterToQueryReducer(ctx, userWriteModel)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !isUserStateExists(userWriteModel.UserState) {
+ return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-1d6Gg", "Errors.User.NotFound")
}
audience = domain.AddAudScopeToAudience(audience, scopes)
preferredLanguage := ""
- existingHuman, err := c.getHumanWriteModelByID(ctx, userID, orgID)
+ existingHuman, err := c.getHumanWriteModelByID(ctx, userWriteModel.AggregateID, userWriteModel.ResourceOwner)
if existingHuman != nil {
preferredLanguage = existingHuman.PreferredLanguage.String()
}
expiration := time.Now().UTC().Add(lifetime)
tokenID, err := c.idGenerator.Next()
if err != nil {
- return nil, err
+ return nil, nil, err
}
- userAgg := UserAggregateFromWriteModel(&existingUser.WriteModel)
- _, err = c.eventstore.PushEvents(ctx,
- user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, audience, scopes, expiration))
- if err != nil {
- return nil, err
- }
-
- return &domain.Token{
- ObjectRoot: models.ObjectRoot{
- AggregateID: userID,
- },
- TokenID: tokenID,
- UserAgentID: agentID,
- ApplicationID: clientID,
- Audience: audience,
- Scopes: scopes,
- Expiration: expiration,
- PreferredLanguage: preferredLanguage,
- }, nil
+ userAgg := UserAggregateFromWriteModel(&userWriteModel.WriteModel)
+ return user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, audience, scopes, expiration),
+ &domain.Token{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: userWriteModel.AggregateID,
+ },
+ TokenID: tokenID,
+ UserAgentID: agentID,
+ ApplicationID: clientID,
+ Audience: audience,
+ Scopes: scopes,
+ Expiration: expiration,
+ PreferredLanguage: preferredLanguage,
+ }, nil
}
func (c *Commands) userDomainClaimed(ctx context.Context, userID string) (events []eventstore.EventPusher, _ *UserWriteModel, err error) {
diff --git a/internal/command/user_human_refresh_token.go b/internal/command/user_human_refresh_token.go
new file mode 100644
index 0000000000..5fd678cd9e
--- /dev/null
+++ b/internal/command/user_human_refresh_token.go
@@ -0,0 +1,139 @@
+package command
+
+import (
+ "context"
+ "time"
+
+ "github.com/caos/zitadel/internal/domain"
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/repository/user"
+)
+
+func (c *Commands) AddAccessAndRefreshToken(ctx context.Context, orgID, agentID, clientID, userID, refreshToken string, audience, scopes, authMethodsReferences []string, accessLifetime, refreshIdleExpiration, refreshExpiration time.Duration, authTime time.Time) (*domain.Token, string, error) {
+ userWriteModel := NewUserWriteModel(userID, orgID)
+ accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, accessLifetime)
+ if err != nil {
+ return nil, "", err
+ }
+
+ creator := func() (eventstore.EventPusher, string, error) {
+ return c.addRefreshToken(ctx, accessToken, authMethodsReferences, authTime, refreshIdleExpiration, refreshExpiration)
+ }
+ if refreshToken != "" {
+ creator = func() (eventstore.EventPusher, string, error) {
+ return c.renewRefreshToken(ctx, userID, orgID, refreshToken, refreshIdleExpiration)
+ }
+ }
+ refreshTokenEvent, token, err := creator()
+ if err != nil {
+ return nil, "", err
+ }
+ _, err = c.eventstore.PushEvents(ctx, accessTokenEvent, refreshTokenEvent)
+ if err != nil {
+ return nil, "", err
+ }
+ return accessToken, token, nil
+}
+
+func (c *Commands) addRefreshToken(ctx context.Context, accessToken *domain.Token, authMethodsReferences []string, authTime time.Time, idleExpiration, expiration time.Duration) (*user.HumanRefreshTokenAddedEvent, string, error) {
+ tokenID, err := c.idGenerator.Next()
+ if err != nil {
+ return nil, "", err
+ }
+ refreshToken, err := domain.NewRefreshToken(accessToken.AggregateID, tokenID, c.keyAlgorithm)
+ if err != nil {
+ return nil, "", err
+ }
+ refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(accessToken.AggregateID, accessToken.ResourceOwner, tokenID)
+ userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel)
+ return user.NewHumanRefreshTokenAddedEvent(ctx, userAgg, tokenID, accessToken.ApplicationID, accessToken.UserAgentID,
+ accessToken.PreferredLanguage, accessToken.Audience, accessToken.Scopes, authMethodsReferences, authTime, idleExpiration, expiration),
+ refreshToken, nil
+}
+
+func (c *Commands) renewRefreshToken(ctx context.Context, userID, orgID, refreshToken string, idleExpiration time.Duration) (event *user.HumanRefreshTokenRenewedEvent, newRefreshToken string, err error) {
+ if refreshToken == "" {
+ return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-DHrr3", "Errors.IDMissing")
+ }
+
+ tokenUserID, tokenID, token, err := domain.FromRefreshToken(refreshToken, c.keyAlgorithm)
+ if err != nil {
+ return nil, "", caos_errs.ThrowInvalidArgument(err, "COMMAND-Dbfe4", "Errors.User.RefreshToken.Invalid")
+ }
+ if tokenUserID != userID {
+ return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Ht2g2", "Errors.User.RefreshToken.Invalid")
+ }
+ refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(userID, orgID, tokenID)
+ err = c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel)
+ if err != nil {
+ return nil, "", err
+ }
+ if refreshTokenWriteModel.UserState != domain.UserStateActive {
+ return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-BHnhs", "Errors.User.RefreshToken.Invalid")
+ }
+ if refreshTokenWriteModel.RefreshToken != token ||
+ refreshTokenWriteModel.IdleExpiration.Before(time.Now()) ||
+ refreshTokenWriteModel.Expiration.Before(time.Now()) {
+ return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Vr43e", "Errors.User.RefreshToken.Invalid")
+ }
+
+ newToken, err := c.idGenerator.Next()
+ if err != nil {
+ return nil, "", err
+ }
+ newRefreshToken, err = domain.RefreshToken(userID, tokenID, newToken, c.keyAlgorithm)
+ if err != nil {
+ return nil, "", err
+ }
+ userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel)
+ return user.NewHumanRefreshTokenRenewedEvent(ctx, userAgg, tokenID, newToken, idleExpiration), newRefreshToken, nil
+}
+
+func (c *Commands) RevokeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*domain.ObjectDetails, error) {
+ removeEvent, refreshTokenWriteModel, err := c.removeRefreshToken(ctx, userID, orgID, tokenID)
+ if err != nil {
+ return nil, err
+ }
+ events, err := c.eventstore.PushEvents(ctx, removeEvent)
+ if err != nil {
+ return nil, err
+ }
+ err = AppendAndReduce(refreshTokenWriteModel, events...)
+ if err != nil {
+ return nil, err
+ }
+ return writeModelToObjectDetails(&refreshTokenWriteModel.WriteModel), nil
+}
+
+func (c *Commands) RevokeRefreshTokens(ctx context.Context, userID, orgID string, tokenIDs []string) (err error) {
+ if len(tokenIDs) == 0 {
+ return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Gfj42", "Errors.IDMissing")
+ }
+ events := make([]eventstore.EventPusher, len(tokenIDs))
+ for i, tokenID := range tokenIDs {
+ event, _, err := c.removeRefreshToken(ctx, userID, orgID, tokenID)
+ if err != nil {
+ return err
+ }
+ events[i] = event
+ }
+ _, err = c.eventstore.PushEvents(ctx, events...)
+ return err
+}
+
+func (c *Commands) removeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*user.HumanRefreshTokenRemovedEvent, *HumanRefreshTokenWriteModel, error) {
+ if userID == "" || orgID == "" || tokenID == "" {
+ return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-GVDgf", "Errors.IDMissing")
+ }
+ refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(userID, orgID, tokenID)
+ err := c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel)
+ if err != nil {
+ return nil, nil, err
+ }
+ if refreshTokenWriteModel.UserState != domain.UserStateActive {
+ return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-BHt2w", "Errors.User.RefreshToken.NotFound")
+ }
+ userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel)
+ return user.NewHumanRefreshTokenRemovedEvent(ctx, userAgg, tokenID), refreshTokenWriteModel, nil
+}
diff --git a/internal/command/user_human_refresh_token_model.go b/internal/command/user_human_refresh_token_model.go
new file mode 100644
index 0000000000..4992977a85
--- /dev/null
+++ b/internal/command/user_human_refresh_token_model.go
@@ -0,0 +1,89 @@
+package command
+
+import (
+ "time"
+
+ "github.com/caos/zitadel/internal/eventstore"
+
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/repository/user"
+)
+
+type HumanRefreshTokenWriteModel struct {
+ eventstore.WriteModel
+
+ TokenID string
+ RefreshToken string
+
+ UserState domain.UserState
+ IdleExpiration time.Time
+ Expiration time.Time
+}
+
+func NewHumanRefreshTokenWriteModel(userID, resourceOwner, tokenID string) *HumanRefreshTokenWriteModel {
+ return &HumanRefreshTokenWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: userID,
+ ResourceOwner: resourceOwner,
+ },
+ TokenID: tokenID,
+ }
+}
+
+func (wm *HumanRefreshTokenWriteModel) AppendEvents(events ...eventstore.EventReader) {
+ for _, event := range events {
+ switch e := event.(type) {
+ case *user.HumanRefreshTokenAddedEvent:
+ if wm.TokenID != e.TokenID {
+ continue
+ }
+ wm.WriteModel.AppendEvents(e)
+ case *user.HumanRefreshTokenRenewedEvent:
+ if wm.TokenID != e.TokenID {
+ continue
+ }
+ wm.WriteModel.AppendEvents(e)
+ case *user.HumanRefreshTokenRemovedEvent:
+ if wm.TokenID != e.TokenID {
+ continue
+ }
+ wm.WriteModel.AppendEvents(e)
+ }
+ }
+}
+
+func (wm *HumanRefreshTokenWriteModel) Reduce() error {
+ for _, event := range wm.Events {
+ switch e := event.(type) {
+ case *user.HumanRefreshTokenAddedEvent:
+ wm.TokenID = e.TokenID
+ wm.RefreshToken = e.TokenID
+ wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration)
+ wm.Expiration = e.CreationDate().Add(e.Expiration)
+ wm.UserState = domain.UserStateActive
+ case *user.HumanRefreshTokenRenewedEvent:
+ if wm.UserState == domain.UserStateActive {
+ wm.RefreshToken = e.RefreshToken
+ }
+ wm.RefreshToken = e.RefreshToken
+ wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration)
+ case *user.HumanRefreshTokenRemovedEvent:
+ wm.UserState = domain.UserStateDeleted
+ }
+ }
+ return wm.WriteModel.Reduce()
+}
+
+func (wm *HumanRefreshTokenWriteModel) Query() *eventstore.SearchQueryBuilder {
+ query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent, user.AggregateType).
+ AggregateIDs(wm.AggregateID).
+ EventTypes(
+ user.HumanRefreshTokenAddedType,
+ user.HumanRefreshTokenRenewedType,
+ user.HumanRefreshTokenRemovedType,
+ user.UserRemovedType)
+ if wm.ResourceOwner != "" {
+ query.ResourceOwner(wm.ResourceOwner)
+ }
+ return query
+}
diff --git a/internal/command/user_human_refresh_token_test.go b/internal/command/user_human_refresh_token_test.go
new file mode 100644
index 0000000000..9e93596ad3
--- /dev/null
+++ b/internal/command/user_human_refresh_token_test.go
@@ -0,0 +1,1103 @@
+package command
+
+import (
+ "context"
+ "encoding/base64"
+ "testing"
+ "time"
+
+ "github.com/caos/oidc/pkg/oidc"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "golang.org/x/text/language"
+
+ "github.com/caos/zitadel/internal/crypto"
+ "github.com/caos/zitadel/internal/domain"
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/eventstore/repository"
+ "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "github.com/caos/zitadel/internal/id"
+ id_mock "github.com/caos/zitadel/internal/id/mock"
+ "github.com/caos/zitadel/internal/repository/user"
+)
+
+func TestCommands_AddAccessAndRefreshToken(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ idGenerator id.Generator
+ iamDomain string
+ keyAlgorithm crypto.EncryptionAlgorithm
+ }
+ type args struct {
+ ctx context.Context
+ orgID string
+ agentID string
+ clientID string
+ userID string
+ refreshToken string
+ audience []string
+ scopes []string
+ authMethodsReferences []string
+ lifetime time.Duration
+ authTime time.Time
+ refreshIdleExpiration time.Duration
+ refreshExpiration time.Duration
+ }
+ type res struct {
+ token *domain.Token
+ refreshToken string
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "error access token, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(),
+ ),
+ },
+ args: args{},
+ res: res{
+ err: caos_errs.IsNotFound,
+ },
+ },
+ {
+ name: "add refresh token, user inactive, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(),
+ ),
+ },
+ args: args{},
+ res: res{
+ err: caos_errs.IsNotFound,
+ },
+ },
+ {
+ name: "renew refresh token, invalid token, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ refreshToken: "invalid",
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "renew refresh token, invalid token (invalid userID), error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "userID",
+ orgID: "orgID",
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID2:tokenID:token")),
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "renew refresh token, token inactive, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "applicationID",
+ "userAgentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 24*time.Hour,
+ )),
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ )),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "userID",
+ orgID: "orgID",
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:token")),
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "renew refresh token, token expired, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayname",
+ language.German,
+ domain.GenderUnspecified,
+ "email",
+ true,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "applicationID",
+ "userAgentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ -1*time.Hour,
+ 24*time.Hour,
+ )),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1"),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "userID",
+ orgID: "orgID",
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")),
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ //fails because of timestamp equality
+ //{
+ // name: "push failed, error",
+ // fields: fields{
+ // eventstore: eventstoreExpect(t,
+ // expectFilter(
+ // eventFromEventPusher(user.NewHumanAddedEvent(
+ // context.Background(),
+ // &user.NewAggregate("userID", "orgID").Aggregate,
+ // "username",
+ // "firstname",
+ // "lastname",
+ // "nickname",
+ // "displayname",
+ // language.German,
+ // domain.GenderUnspecified,
+ // "email",
+ // true,
+ // )),
+ // ),
+ // expectFilter(
+ // eventFromEventPusherWithCreationDateNow(user.NewHumanAddedEvent(
+ // context.Background(),
+ // &user.NewAggregate("userID", "orgID").Aggregate,
+ // "username",
+ // "firstname",
+ // "lastname",
+ // "nickname",
+ // "displayname",
+ // language.German,
+ // domain.GenderUnspecified,
+ // "email",
+ // true,
+ // )),
+ // ),
+ // expectFilter(
+ // eventFromEventPusherWithCreationDateNow(user.NewHumanRefreshTokenAddedEvent(
+ // context.Background(),
+ // &user.NewAggregate("userID", "orgID").Aggregate,
+ // "tokenID",
+ // "applicationID",
+ // "userAgentID",
+ // "de",
+ // []string{"clientID1"},
+ // []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ // []string{"password"},
+ // time.Now(),
+ // 1*time.Hour,
+ // 24*time.Hour,
+ // )),
+ // ),
+ // expectPushFailed(
+ // caos_errs.ThrowInternal(nil, "ERROR", "internal"),
+ // []*repository.Event{
+ // eventFromEventPusher(user.NewUserTokenAddedEvent(
+ // context.Background(),
+ // &user.NewAggregate("userID", "orgID").Aggregate,
+ // "accessTokenID1",
+ // "clientID",
+ // "agentID",
+ // "de",
+ // []string{"clientID1"},
+ // []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ // time.Now().Add(5*time.Minute),
+ // )),
+ // eventFromEventPusher(user.NewHumanRefreshTokenRenewedEvent(
+ // context.Background(),
+ // &user.NewAggregate("userID", "orgID").Aggregate,
+ // "tokenID",
+ // "refreshToken1",
+ // 1*time.Hour,
+ // )),
+ // },
+ // ),
+ // ),
+ // idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "accessTokenID1", "refreshToken1"),
+ // keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ // },
+ // args: args{
+ // ctx: context.Background(),
+ // orgID: "orgID",
+ // agentID: "agentID",
+ // clientID: "clientID",
+ // userID: "userID",
+ // refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")),
+ // audience: []string{"clientID1"},
+ // scopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ // authMethodsReferences: []string{"password"},
+ // lifetime: 5 * time.Minute,
+ // authTime: time.Now(),
+ // },
+ // res: res{
+ // err: caos_errs.IsInternal,
+ // },
+ //},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ eventstore: tt.fields.eventstore,
+ idGenerator: tt.fields.idGenerator,
+ iamDomain: tt.fields.iamDomain,
+ keyAlgorithm: tt.fields.keyAlgorithm,
+ }
+ got, gotRefresh, err := c.AddAccessAndRefreshToken(tt.args.ctx, tt.args.orgID, tt.args.agentID, tt.args.clientID, tt.args.userID, tt.args.refreshToken,
+ tt.args.audience, tt.args.scopes, tt.args.authMethodsReferences, tt.args.lifetime, tt.args.refreshIdleExpiration, tt.args.refreshExpiration, tt.args.authTime)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.token, got)
+ assert.Equal(t, tt.res.refreshToken, gotRefresh)
+ }
+ })
+ }
+}
+
+func TestCommands_RevokeRefreshToken(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ }
+ type args struct {
+ ctx context.Context
+ userID string
+ orgID string
+ tokenID string
+ }
+ type res struct {
+ want *domain.ObjectDetails
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ "missing param, error",
+ fields{
+ eventstore: eventstoreExpect(t),
+ },
+ args{},
+ res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ "token not active, error",
+ fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(),
+ ),
+ },
+ args{
+ context.Background(),
+ "userID",
+ "orgID",
+ "tokenID",
+ },
+ res{
+ err: caos_errs.IsNotFound,
+ },
+ },
+ {
+ "push failed, error",
+ fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "clientID",
+ "agentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 10*time.Hour,
+ )),
+ ),
+ expectPushFailed(caos_errs.ThrowInternal(nil, "ERROR", "internal"),
+ []*repository.Event{
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ )),
+ },
+ ),
+ ),
+ },
+ args{
+ context.Background(),
+ "userID",
+ "orgID",
+ "tokenID",
+ },
+ res{
+ err: caos_errs.IsInternal,
+ },
+ },
+ {
+ "revoke, ok",
+ fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "clientID",
+ "agentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 10*time.Hour,
+ )),
+ ),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ )),
+ },
+ ),
+ ),
+ },
+ args{
+ context.Background(),
+ "userID",
+ "orgID",
+ "tokenID",
+ },
+ res{
+ want: &domain.ObjectDetails{
+ ResourceOwner: "orgID",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ eventstore: tt.fields.eventstore,
+ }
+ got, err := c.RevokeRefreshToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.tokenID)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.want, got)
+ }
+ })
+ }
+}
+
+func TestCommands_RevokeRefreshTokens(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ }
+ type args struct {
+ ctx context.Context
+ userID string
+ orgID string
+ tokenIDs []string
+ }
+ type res struct {
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ "missing tokenIDs, error",
+ fields{
+ eventstore: eventstoreExpect(t),
+ },
+ args{
+ context.Background(),
+ "userID",
+ "orgID",
+ nil,
+ },
+ res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ "one token not active, error",
+ fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "clientID",
+ "agentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 10*time.Hour,
+ )),
+ ),
+ expectFilter(),
+ ),
+ },
+ args{
+ context.Background(),
+ "userID",
+ "orgID",
+ []string{"tokenID", "tokenID2"},
+ },
+ res{
+ err: caos_errs.IsNotFound,
+ },
+ },
+ {
+ "push failed, error",
+ fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "clientID",
+ "agentID",
+ "de",
+ []string{"clientID"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 10*time.Hour,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID2",
+ "clientID2",
+ "agentID",
+ "de",
+ []string{"clientID2"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 10*time.Hour,
+ )),
+ ),
+ expectPushFailed(caos_errs.ThrowInternal(nil, "ERROR", "internal"),
+ []*repository.Event{
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ )),
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID2",
+ )),
+ },
+ ),
+ ),
+ },
+ args{
+ context.Background(),
+ "userID",
+ "orgID",
+ []string{"tokenID", "tokenID2"},
+ },
+ res{
+ err: caos_errs.IsInternal,
+ },
+ },
+ {
+ "revoke, ok",
+ fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "clientID",
+ "agentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 10*time.Hour,
+ )),
+ ),
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID2",
+ "clientID2",
+ "agentID",
+ "de",
+ []string{"clientID2"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 10*time.Hour,
+ )),
+ ),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ )),
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID2",
+ )),
+ },
+ ),
+ ),
+ },
+ args{
+ context.Background(),
+ "userID",
+ "orgID",
+ []string{"tokenID", "tokenID2"},
+ },
+ res{
+ err: nil,
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ eventstore: tt.fields.eventstore,
+ }
+ err := c.RevokeRefreshTokens(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.tokenIDs)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ })
+ }
+}
+
+func refreshTokenEncryptionAlgorithm(ctrl *gomock.Controller) crypto.EncryptionAlgorithm {
+ mCrypto := crypto.NewMockEncryptionAlgorithm(ctrl)
+ mCrypto.EXPECT().Algorithm().AnyTimes().Return("enc")
+ mCrypto.EXPECT().EncryptionKeyID().AnyTimes().Return("id")
+ mCrypto.EXPECT().Encrypt(gomock.Any()).AnyTimes().DoAndReturn(
+ func(refrehToken []byte) ([]byte, error) {
+ return refrehToken, nil
+ },
+ )
+ mCrypto.EXPECT().Decrypt(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn(
+ func(refrehToken []byte, keyID string) ([]byte, error) {
+ if keyID != "id" {
+ return nil, caos_errs.ThrowInternal(nil, "id", "invalid key id")
+ }
+ return refrehToken, nil
+ },
+ )
+ return mCrypto
+}
+
+func TestCommands_addRefreshToken(t *testing.T) {
+ authTime := time.Now().Add(-1 * time.Hour)
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ idGenerator id.Generator
+ keyAlgorithm crypto.EncryptionAlgorithm
+ }
+ type args struct {
+ ctx context.Context
+ accessToken *domain.Token
+ authMethodsReferences []string
+ authTime time.Time
+ idleExpiration time.Duration
+ expiration time.Duration
+ }
+ type res struct {
+ event *user.HumanRefreshTokenAddedEvent
+ refreshToken string
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+
+ {
+ name: "add refresh Token",
+ fields: fields{
+ eventstore: eventstoreExpect(t),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "refreshTokenID"),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ accessToken: &domain.Token{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "userID",
+ ResourceOwner: "org1",
+ },
+ TokenID: "accessTokenID1",
+ ApplicationID: "clientID",
+ UserAgentID: "agentID",
+ Audience: []string{"clientID1"},
+ Expiration: time.Now().Add(5 * time.Minute),
+ Scopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ PreferredLanguage: "de",
+ },
+ authMethodsReferences: []string{"password"},
+ authTime: authTime,
+ idleExpiration: 1 * time.Hour,
+ expiration: 10 * time.Hour,
+ },
+ res: res{
+ event: user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "org1").Aggregate,
+ "refreshTokenID",
+ "clientID",
+ "agentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ authTime,
+ 1*time.Hour,
+ 10*time.Hour,
+ ),
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:refreshTokenID:refreshTokenID")),
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ eventstore: tt.fields.eventstore,
+ idGenerator: tt.fields.idGenerator,
+ keyAlgorithm: tt.fields.keyAlgorithm,
+ }
+ gotEvent, gotRefreshToken, err := c.addRefreshToken(tt.args.ctx, tt.args.accessToken, tt.args.authMethodsReferences, tt.args.authTime, tt.args.idleExpiration, tt.args.expiration)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.event, gotEvent)
+ assert.Equal(t, tt.res.refreshToken, gotRefreshToken)
+ }
+ })
+ }
+}
+
+func TestCommands_renewRefreshToken(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ idGenerator id.Generator
+ keyAlgorithm crypto.EncryptionAlgorithm
+ }
+ type args struct {
+ ctx context.Context
+ userID string
+ orgID string
+ refreshToken string
+ idleExpiration time.Duration
+ }
+ type res struct {
+ event *user.HumanRefreshTokenRenewedEvent
+ newRefreshToken string
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "empty token, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t),
+ },
+ args: args{
+ ctx: context.Background(),
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "invalid token, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ refreshToken: "invalid",
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "invalid token (invalid userID), error",
+ fields: fields{
+ eventstore: eventstoreExpect(t),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "userID",
+ orgID: "orgID",
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID2:tokenID:token")),
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "token inactive, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "applicationID",
+ "userAgentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 24*time.Hour,
+ )),
+ eventFromEventPusher(user.NewHumanRefreshTokenRemovedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ )),
+ ),
+ ),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "userID",
+ orgID: "orgID",
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:token")),
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "token expired, error",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "applicationID",
+ "userAgentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 24*time.Hour,
+ )),
+ ),
+ ),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "userID",
+ orgID: "orgID",
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")),
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "token renewed, ok",
+ fields: fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusherWithCreationDateNow(user.NewHumanRefreshTokenAddedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "applicationID",
+ "userAgentID",
+ "de",
+ []string{"clientID1"},
+ []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
+ []string{"password"},
+ time.Now(),
+ 1*time.Hour,
+ 24*time.Hour,
+ )),
+ ),
+ ),
+ keyAlgorithm: refreshTokenEncryptionAlgorithm(gomock.NewController(t)),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "refreshToken1"),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "userID",
+ orgID: "orgID",
+ refreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:tokenID")),
+ idleExpiration: 1 * time.Hour,
+ },
+ res: res{
+ event: user.NewHumanRefreshTokenRenewedEvent(
+ context.Background(),
+ &user.NewAggregate("userID", "orgID").Aggregate,
+ "tokenID",
+ "refreshToken1",
+ 1*time.Hour,
+ ),
+ newRefreshToken: base64.RawURLEncoding.EncodeToString([]byte("userID:tokenID:refreshToken1")),
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ eventstore: tt.fields.eventstore,
+ idGenerator: tt.fields.idGenerator,
+ keyAlgorithm: tt.fields.keyAlgorithm,
+ }
+ gotEvent, gotNewRefreshToken, err := c.renewRefreshToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.refreshToken, tt.args.idleExpiration)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.event, gotEvent)
+ assert.Equal(t, tt.res.newRefreshToken, gotNewRefreshToken)
+ }
+ })
+ }
+}
diff --git a/internal/domain/refresh_token.go b/internal/domain/refresh_token.go
new file mode 100644
index 0000000000..5a0d203015
--- /dev/null
+++ b/internal/domain/refresh_token.go
@@ -0,0 +1,37 @@
+package domain
+
+import (
+ "encoding/base64"
+ "strings"
+
+ "github.com/caos/zitadel/internal/crypto"
+ caos_errors "github.com/caos/zitadel/internal/errors"
+)
+
+func NewRefreshToken(userID, tokenID string, algorithm crypto.EncryptionAlgorithm) (string, error) {
+ return RefreshToken(userID, tokenID, tokenID, algorithm)
+}
+
+func RefreshToken(userID, tokenID, token string, algorithm crypto.EncryptionAlgorithm) (string, error) {
+ encrypted, err := algorithm.Encrypt([]byte(userID + ":" + tokenID + ":" + token))
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(encrypted), nil
+}
+
+func FromRefreshToken(refreshToken string, algorithm crypto.EncryptionAlgorithm) (userID, tokenID, token string, err error) {
+ decoded, err := base64.RawURLEncoding.DecodeString(refreshToken)
+ if err != nil {
+ return "", "", "", err
+ }
+ decrypted, err := algorithm.Decrypt(decoded, algorithm.EncryptionKeyID())
+ if err != nil {
+ return "", "", "", err
+ }
+ split := strings.Split(string(decrypted), ":")
+ if len(split) != 3 {
+ return "", "", "", caos_errors.ThrowInternal(nil, "DOMAIN-BGDhn", "Errors.User.RefreshToken.Invalid")
+ }
+ return split[0], split[1], split[2], nil
+}
diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go
index c95d98b371..64c54302ac 100644
--- a/internal/repository/user/eventstore.go
+++ b/internal/repository/user/eventstore.go
@@ -94,6 +94,9 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(HumanPasswordlessTokenBeginLoginType, HumanPasswordlessBeginLoginEventMapper).
RegisterFilterEventMapper(HumanPasswordlessTokenCheckSucceededType, HumanPasswordlessCheckSucceededEventMapper).
RegisterFilterEventMapper(HumanPasswordlessTokenCheckFailedType, HumanPasswordlessCheckFailedEventMapper).
+ RegisterFilterEventMapper(HumanRefreshTokenAddedType, HumanRefreshTokenAddedEventMapper).
+ RegisterFilterEventMapper(HumanRefreshTokenRenewedType, HumanRefreshTokenRenewedEventEventMapper).
+ RegisterFilterEventMapper(HumanRefreshTokenRemovedType, HumanRefreshTokenRemovedEventEventMapper).
RegisterFilterEventMapper(MachineAddedEventType, MachineAddedEventMapper).
RegisterFilterEventMapper(MachineChangedEventType, MachineChangedEventMapper).
RegisterFilterEventMapper(MachineKeyAddedEventType, MachineKeyAddedEventMapper).
diff --git a/internal/repository/user/human_refresh_token.go b/internal/repository/user/human_refresh_token.go
new file mode 100644
index 0000000000..7c4922dabf
--- /dev/null
+++ b/internal/repository/user/human_refresh_token.go
@@ -0,0 +1,187 @@
+package user
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/caos/zitadel/internal/eventstore"
+
+ "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore/repository"
+)
+
+const (
+ refreshTokenEventPrefix = humanEventPrefix + "refresh.token."
+ HumanRefreshTokenAddedType = refreshTokenEventPrefix + "added"
+ HumanRefreshTokenRenewedType = refreshTokenEventPrefix + "renewed"
+ HumanRefreshTokenRemovedType = refreshTokenEventPrefix + "removed"
+)
+
+type HumanRefreshTokenAddedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ TokenID string `json:"tokenId"`
+ ClientID string `json:"clientId"`
+ UserAgentID string `json:"userAgentId"`
+ Audience []string `json:"audience"`
+ Scopes []string `json:"scopes"`
+ AuthMethodsReferences []string `json:"authMethodReferences"`
+ AuthTime time.Time `json:"authTime"`
+ IdleExpiration time.Duration `json:"idleExpiration"`
+ Expiration time.Duration `json:"expiration"`
+ PreferredLanguage string `json:"preferredLanguage"`
+}
+
+func (e *HumanRefreshTokenAddedEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanRefreshTokenAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *HumanRefreshTokenAddedEvent) Assets() []*eventstore.Asset {
+ return nil
+}
+
+func NewHumanRefreshTokenAddedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ tokenID,
+ clientID,
+ userAgentID,
+ preferredLanguage string,
+ audience,
+ scopes,
+ authMethodsReferences []string,
+ authTime time.Time,
+ idleExpiration,
+ expiration time.Duration,
+) *HumanRefreshTokenAddedEvent {
+ return &HumanRefreshTokenAddedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanRefreshTokenAddedType,
+ ),
+ TokenID: tokenID,
+ ClientID: clientID,
+ UserAgentID: userAgentID,
+ Audience: audience,
+ Scopes: scopes,
+ AuthMethodsReferences: authMethodsReferences,
+ AuthTime: authTime,
+ IdleExpiration: idleExpiration,
+ Expiration: expiration,
+ PreferredLanguage: preferredLanguage,
+ }
+}
+
+func HumanRefreshTokenAddedEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ refreshTokenAdded := &HumanRefreshTokenAddedEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, refreshTokenAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-DGr14", "unable to unmarshal refresh token added")
+ }
+
+ return refreshTokenAdded, nil
+}
+
+type HumanRefreshTokenRenewedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ TokenID string `json:"tokenId"`
+ RefreshToken string `json:"refreshToken"`
+ IdleExpiration time.Duration `json:"idleExpiration"`
+}
+
+func (e *HumanRefreshTokenRenewedEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanRefreshTokenRenewedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *HumanRefreshTokenRenewedEvent) Assets() []*eventstore.Asset {
+ return nil
+}
+
+func NewHumanRefreshTokenRenewedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ tokenID,
+ refreshToken string,
+ idleExpiration time.Duration,
+) *HumanRefreshTokenRenewedEvent {
+ return &HumanRefreshTokenRenewedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanRefreshTokenRenewedType,
+ ),
+ TokenID: tokenID,
+ IdleExpiration: idleExpiration,
+ RefreshToken: refreshToken,
+ }
+}
+
+func HumanRefreshTokenRenewedEventEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ tokenAdded := &HumanRefreshTokenRenewedEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, tokenAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-GBt21", "unable to unmarshal refresh token renewed")
+ }
+
+ return tokenAdded, nil
+}
+
+type HumanRefreshTokenRemovedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ TokenID string `json:"tokenId"`
+}
+
+func (e *HumanRefreshTokenRemovedEvent) Data() interface{} {
+ return e
+}
+
+func (e *HumanRefreshTokenRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *HumanRefreshTokenRemovedEvent) Assets() []*eventstore.Asset {
+ return nil
+}
+
+func NewHumanRefreshTokenRemovedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ tokenID string,
+) *HumanRefreshTokenRemovedEvent {
+ return &HumanRefreshTokenRemovedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ HumanRefreshTokenRemovedType,
+ ),
+ TokenID: tokenID,
+ }
+}
+
+func HumanRefreshTokenRemovedEventEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ tokenAdded := &HumanRefreshTokenRemovedEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+ err := json.Unmarshal(event.Data, tokenAdded)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "USER-Dggs2", "unable to unmarshal refresh token removed")
+ }
+
+ return tokenAdded, nil
+}
diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go
index a7a6c8d14d..5fecdfe2a7 100644
--- a/internal/repository/user/user.go
+++ b/internal/repository/user/user.go
@@ -3,9 +3,10 @@ package user
import (
"context"
"encoding/json"
- "github.com/caos/zitadel/internal/eventstore"
"time"
+ "github.com/caos/zitadel/internal/eventstore"
+
"github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/eventstore/repository"
)
@@ -202,7 +203,7 @@ type UserTokenAddedEvent struct {
ApplicationID string `json:"applicationId"`
UserAgentID string `json:"userAgentId"`
Audience []string `json:"audience"`
- Scopes []string `json:"scopes""`
+ Scopes []string `json:"scopes"`
Expiration time.Time `json:"expiration"`
PreferredLanguage string `json:"preferredLanguage"`
}
diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml
index 72971c6432..1a987aa178 100644
--- a/internal/static/i18n/de.yaml
+++ b/internal/static/i18n/de.yaml
@@ -108,6 +108,9 @@ Errors:
BeginLoginFailed: Es ist ein Fehler beim WebAuthN Login aufgetreten
ValidateLoginFailed: Zugangsdaten konnten nicht validiert werden
CloneWarning: Authentifizierungsdaten wurden möglicherweise geklont
+ RefreshToken:
+ Invalid: Refresh Token ist ungültig
+ NotFound: Refresh Token nicht gefunden
Org:
AlreadyExist: Organisationsname existiert bereits
Invalid: Organisation ist ungültig
diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml
index 0e8b3eb332..2f2015c520 100644
--- a/internal/static/i18n/en.yaml
+++ b/internal/static/i18n/en.yaml
@@ -108,6 +108,9 @@ Errors:
BeginLoginFailed: WebAuthN begin login failed
ValidateLoginFailed: Error on validate login credentials
CloneWarning: Credentials may be cloned
+ RefreshToken:
+ Invalid: Refresh Token is invalid
+ NotFound: Refresh Token not found
Org:
AlreadyExist: Organisationname already taken
Invalid: Organisation is invalid
diff --git a/internal/user/model/refresh_token.go b/internal/user/model/refresh_token.go
new file mode 100644
index 0000000000..f7277049f5
--- /dev/null
+++ b/internal/user/model/refresh_token.go
@@ -0,0 +1,18 @@
+package model
+
+import (
+ es_models "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "time"
+)
+
+type RefreshToken struct {
+ es_models.ObjectRoot
+
+ TokenID string
+ ApplicationID string
+ UserAgentID string
+ Audience []string
+ Expiration time.Time
+ Scopes []string
+ PreferredLanguage string
+}
diff --git a/internal/user/model/refresh_token_view.go b/internal/user/model/refresh_token_view.go
new file mode 100644
index 0000000000..76688fe8de
--- /dev/null
+++ b/internal/user/model/refresh_token_view.go
@@ -0,0 +1,71 @@
+package model
+
+import (
+ "github.com/caos/zitadel/internal/domain"
+ caos_errors "github.com/caos/zitadel/internal/errors"
+
+ "time"
+)
+
+type RefreshTokenView struct {
+ ID string
+ CreationDate time.Time
+ ChangeDate time.Time
+ ResourceOwner string
+ UserID string
+ ClientID string
+ UserAgentID string
+ AuthMethodsReferences []string
+ Audience []string
+ AuthTime time.Time
+ IdleExpiration time.Time
+ Expiration time.Time
+ Scopes []string
+ Sequence uint64
+ Token string
+}
+
+type RefreshTokenSearchRequest struct {
+ Offset uint64
+ Limit uint64
+ SortingColumn RefreshTokenSearchKey
+ Asc bool
+ Queries []*RefreshTokenSearchQuery
+}
+
+type RefreshTokenSearchKey int32
+
+const (
+ RefreshTokenSearchKeyUnspecified RefreshTokenSearchKey = iota
+ RefreshTokenSearchKeyRefreshTokenID
+ RefreshTokenSearchKeyUserID
+ RefreshTokenSearchKeyApplicationID
+ RefreshTokenSearchKeyUserAgentID
+ RefreshTokenSearchKeyExpiration
+ RefreshTokenSearchKeyResourceOwner
+)
+
+type RefreshTokenSearchQuery struct {
+ Key RefreshTokenSearchKey
+ Method domain.SearchMethod
+ Value interface{}
+}
+
+type RefreshTokenSearchResponse struct {
+ Offset uint64
+ Limit uint64
+ TotalResult uint64
+ Sequence uint64
+ Timestamp time.Time
+ Result []*RefreshTokenView
+}
+
+func (r *RefreshTokenSearchRequest) EnsureLimit(limit uint64) error {
+ if r.Limit > limit {
+ return caos_errors.ThrowInvalidArgument(nil, "SEARCH-M0fse", "Errors.Limit.ExceedsDefault")
+ }
+ if r.Limit == 0 {
+ r.Limit = limit
+ }
+ return nil
+}
diff --git a/internal/user/repository/view/model/refresh_token.go b/internal/user/repository/view/model/refresh_token.go
new file mode 100644
index 0000000000..beed817751
--- /dev/null
+++ b/internal/user/repository/view/model/refresh_token.go
@@ -0,0 +1,154 @@
+package model
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/caos/logging"
+ "github.com/lib/pq"
+
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore"
+ es_models "github.com/caos/zitadel/internal/eventstore/v1/models"
+ user_repo "github.com/caos/zitadel/internal/repository/user"
+ usr_model "github.com/caos/zitadel/internal/user/model"
+)
+
+const (
+ RefreshTokenKeyTokenID = "id"
+ RefreshTokenKeyUserID = "user_id"
+ RefreshTokenKeyApplicationID = "application_id"
+ RefreshTokenKeyUserAgentID = "user_agent_id"
+ RefreshTokenKeyExpiration = "expiration"
+ RefreshTokenKeyResourceOwner = "resource_owner"
+)
+
+type RefreshTokenView struct {
+ ID string `json:"tokenId" gorm:"column:id"`
+ CreationDate time.Time `json:"-" gorm:"column:creation_date"`
+ ChangeDate time.Time `json:"-" gorm:"column:change_date"`
+ ResourceOwner string `json:"-" gorm:"column:resource_owner"`
+ Token string `json:"-" gorm:"column:token"`
+ UserID string `json:"-" gorm:"column:user_id;primary_key"`
+ ClientID string `json:"clientID" gorm:"column:client_id;primary_key"`
+ UserAgentID string `json:"userAgentId" gorm:"column:user_agent_id;primary_key"`
+ Audience pq.StringArray `json:"audience" gorm:"column:audience"`
+ Scopes pq.StringArray `json:"scopes" gorm:"column:scopes"`
+ AuthMethodsReferences pq.StringArray `json:"authMethodsReference" gorm:"column:amr"`
+ AuthTime time.Time `json:"authTime" gorm:"column:auth_time"`
+ IdleExpiration time.Time `json:"-" gorm:"column:idle_expiration"`
+ Expiration time.Time `json:"-" gorm:"column:expiration"`
+ Sequence uint64 `json:"-" gorm:"column:sequence"`
+}
+
+func RefreshTokenViewsToModel(tokens []*RefreshTokenView) []*usr_model.RefreshTokenView {
+ result := make([]*usr_model.RefreshTokenView, len(tokens))
+ for i, g := range tokens {
+ result[i] = RefreshTokenViewToModel(g)
+ }
+ return result
+}
+
+func RefreshTokenViewToModel(token *RefreshTokenView) *usr_model.RefreshTokenView {
+ return &usr_model.RefreshTokenView{
+ ID: token.ID,
+ CreationDate: token.CreationDate,
+ ChangeDate: token.ChangeDate,
+ ResourceOwner: token.ResourceOwner,
+ Token: token.Token,
+ UserID: token.UserID,
+ ClientID: token.ClientID,
+ UserAgentID: token.UserAgentID,
+ Audience: token.Audience,
+ Scopes: token.Scopes,
+ AuthMethodsReferences: token.AuthMethodsReferences,
+ AuthTime: token.AuthTime,
+ IdleExpiration: token.IdleExpiration,
+ Expiration: token.Expiration,
+ Sequence: token.Sequence,
+ }
+}
+
+func (t *RefreshTokenView) AppendEventIfMyRefreshToken(event *es_models.Event) (err error) {
+ view := new(RefreshTokenView)
+ switch eventstore.EventType(event.Type) {
+ case user_repo.HumanRefreshTokenAddedType:
+ view.setRootData(event)
+ err = view.appendAddedEvent(event)
+ if err != nil {
+ return err
+ }
+ case user_repo.HumanRefreshTokenRenewedType:
+ view.setRootData(event)
+ err = view.appendRenewedEvent(event)
+ if err != nil {
+ return err
+ }
+ case user_repo.HumanRefreshTokenRemovedType,
+ user_repo.UserRemovedType,
+ user_repo.UserDeactivatedType,
+ user_repo.UserLockedType:
+ view.appendRemovedEvent(event)
+ default:
+ return nil
+ }
+ if view.ID == t.ID {
+ return t.AppendEvent(event)
+ }
+ return nil
+}
+
+func (t *RefreshTokenView) AppendEvent(event *es_models.Event) error {
+ t.ChangeDate = event.CreationDate
+ t.Sequence = event.Sequence
+ switch eventstore.EventType(event.Type) {
+ case user_repo.HumanRefreshTokenAddedType:
+ t.setRootData(event)
+ return t.appendAddedEvent(event)
+ case user_repo.HumanRefreshTokenRenewedType:
+ t.setRootData(event)
+ return t.appendRenewedEvent(event)
+ }
+ return nil
+}
+
+func (t *RefreshTokenView) setRootData(event *es_models.Event) {
+ t.UserID = event.AggregateID
+ t.ResourceOwner = event.ResourceOwner
+}
+
+func (t *RefreshTokenView) appendAddedEvent(event *es_models.Event) error {
+ e := new(user_repo.HumanRefreshTokenAddedEvent)
+ if err := json.Unmarshal(event.Data, e); err != nil {
+ logging.Log("EVEN-Dbb31").WithError(err).Error("could not unmarshal event data")
+ return caos_errs.ThrowInternal(err, "MODEL-Bbr42", "could not unmarshal event")
+ }
+ t.ID = e.TokenID
+ t.CreationDate = event.CreationDate
+ t.AuthMethodsReferences = e.AuthMethodsReferences
+ t.AuthTime = e.AuthTime
+ t.Audience = e.Audience
+ t.ClientID = e.ClientID
+ t.Expiration = event.CreationDate.Add(e.Expiration)
+ t.IdleExpiration = event.CreationDate.Add(e.IdleExpiration)
+ t.Scopes = e.Scopes
+ t.Token = e.TokenID
+ t.UserAgentID = e.UserAgentID
+ return nil
+}
+
+func (t *RefreshTokenView) appendRenewedEvent(event *es_models.Event) error {
+ e := new(user_repo.HumanRefreshTokenRenewedEvent)
+ if err := json.Unmarshal(event.Data, e); err != nil {
+ logging.Log("EVEN-Vbbn2").WithError(err).Error("could not unmarshal event data")
+ return caos_errs.ThrowInternal(err, "MODEL-Bbrn4", "could not unmarshal event")
+ }
+ t.ID = e.TokenID
+ t.IdleExpiration = event.CreationDate.Add(e.IdleExpiration)
+ t.Token = e.RefreshToken
+ return nil
+}
+
+func (t *RefreshTokenView) appendRemovedEvent(event *es_models.Event) {
+ t.Expiration = event.CreationDate
+}
diff --git a/internal/user/repository/view/model/refresh_token_query.go b/internal/user/repository/view/model/refresh_token_query.go
new file mode 100644
index 0000000000..0ac1d439db
--- /dev/null
+++ b/internal/user/repository/view/model/refresh_token_query.go
@@ -0,0 +1,69 @@
+package model
+
+import (
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/user/model"
+ "github.com/caos/zitadel/internal/view/repository"
+)
+
+type RefreshTokenSearchRequest model.RefreshTokenSearchRequest
+type RefreshTokenSearchQuery model.RefreshTokenSearchQuery
+type RefreshTokenSearchKey model.RefreshTokenSearchKey
+
+func (req RefreshTokenSearchRequest) GetLimit() uint64 {
+ return req.Limit
+}
+
+func (req RefreshTokenSearchRequest) GetOffset() uint64 {
+ return req.Offset
+}
+
+func (req RefreshTokenSearchRequest) GetSortingColumn() repository.ColumnKey {
+ if req.SortingColumn == model.RefreshTokenSearchKeyUnspecified {
+ return nil
+ }
+ return RefreshTokenSearchKey(req.SortingColumn)
+}
+
+func (req RefreshTokenSearchRequest) GetAsc() bool {
+ return req.Asc
+}
+
+func (req RefreshTokenSearchRequest) GetQueries() []repository.SearchQuery {
+ result := make([]repository.SearchQuery, len(req.Queries))
+ for i, q := range req.Queries {
+ result[i] = RefreshTokenSearchQuery{Key: q.Key, Value: q.Value, Method: q.Method}
+ }
+ return result
+}
+
+func (req RefreshTokenSearchQuery) GetKey() repository.ColumnKey {
+ return RefreshTokenSearchKey(req.Key)
+}
+
+func (req RefreshTokenSearchQuery) GetMethod() domain.SearchMethod {
+ return req.Method
+}
+
+func (req RefreshTokenSearchQuery) GetValue() interface{} {
+ return req.Value
+}
+
+func (key RefreshTokenSearchKey) ToColumnName() string {
+ switch model.RefreshTokenSearchKey(key) {
+ case model.RefreshTokenSearchKeyRefreshTokenID:
+ return RefreshTokenKeyTokenID
+ case model.RefreshTokenSearchKeyUserAgentID:
+ return RefreshTokenKeyUserAgentID
+ case model.RefreshTokenSearchKeyUserID:
+ return RefreshTokenKeyUserID
+ case model.RefreshTokenSearchKeyApplicationID:
+ return RefreshTokenKeyApplicationID
+ case model.RefreshTokenSearchKeyExpiration:
+ return RefreshTokenKeyExpiration
+ case model.RefreshTokenSearchKeyResourceOwner:
+ return RefreshTokenKeyResourceOwner
+ default:
+ return ""
+ }
+}
diff --git a/internal/user/repository/view/refresh_token_view.go b/internal/user/repository/view/refresh_token_view.go
new file mode 100644
index 0000000000..ca342e87c0
--- /dev/null
+++ b/internal/user/repository/view/refresh_token_view.go
@@ -0,0 +1,79 @@
+package view
+
+import (
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/user/model"
+ usr_model "github.com/caos/zitadel/internal/user/repository/view/model"
+ "github.com/caos/zitadel/internal/view/repository"
+ "github.com/jinzhu/gorm"
+ "github.com/lib/pq"
+)
+
+func RefreshTokenByID(db *gorm.DB, table, tokenID string) (*usr_model.RefreshTokenView, error) {
+ token := new(usr_model.RefreshTokenView)
+ query := repository.PrepareGetByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyRefreshTokenID), tokenID)
+ err := query(db, token)
+ if errors.IsNotFound(err) {
+ return nil, errors.ThrowNotFound(nil, "VIEW-6ub3p", "Errors.RefreshToken.NotFound")
+ }
+ return token, err
+}
+
+func RefreshTokensByUserID(db *gorm.DB, table, userID string) ([]*usr_model.RefreshTokenView, error) {
+ tokens := make([]*usr_model.RefreshTokenView, 0)
+ userIDQuery := &model.RefreshTokenSearchQuery{
+ Key: model.RefreshTokenSearchKeyUserID,
+ Method: domain.SearchMethodEquals,
+ Value: userID,
+ }
+ query := repository.PrepareSearchQuery(table, usr_model.RefreshTokenSearchRequest{
+ Queries: []*model.RefreshTokenSearchQuery{userIDQuery},
+ })
+ _, err := query(db, &tokens)
+ return tokens, err
+}
+
+func PutRefreshToken(db *gorm.DB, table string, token *usr_model.RefreshTokenView) error {
+ save := repository.PrepareSave(table)
+ return save(db, token)
+}
+
+func PutRefreshTokens(db *gorm.DB, table string, tokens ...*usr_model.RefreshTokenView) error {
+ save := repository.PrepareBulkSave(table)
+ t := make([]interface{}, len(tokens))
+ for i, token := range tokens {
+ t[i] = token
+ }
+ return save(db, t...)
+}
+
+func SearchRefreshTokens(db *gorm.DB, table string, req *model.RefreshTokenSearchRequest) ([]*usr_model.RefreshTokenView, uint64, error) {
+ tokens := make([]*usr_model.RefreshTokenView, 0)
+ query := repository.PrepareSearchQuery(table, usr_model.RefreshTokenSearchRequest{Limit: req.Limit, Offset: req.Offset, Queries: req.Queries})
+ count, err := query(db, &tokens)
+ return tokens, count, err
+}
+
+func DeleteRefreshToken(db *gorm.DB, table, tokenID string) error {
+ delete := repository.PrepareDeleteByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyRefreshTokenID), tokenID)
+ return delete(db)
+}
+
+func DeleteSessionRefreshTokens(db *gorm.DB, table, agentID, userID string) error {
+ delete := repository.PrepareDeleteByKeys(table,
+ repository.Key{Key: usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyUserAgentID), Value: agentID},
+ repository.Key{Key: usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyUserID), Value: userID},
+ )
+ return delete(db)
+}
+
+func DeleteUserRefreshTokens(db *gorm.DB, table, userID string) error {
+ delete := repository.PrepareDeleteByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyUserID), userID)
+ return delete(db)
+}
+
+func DeleteApplicationRefreshTokens(db *gorm.DB, table string, appIDs []string) error {
+ delete := repository.PrepareDeleteByKey(table, usr_model.RefreshTokenSearchKey(model.RefreshTokenSearchKeyApplicationID), pq.StringArray(appIDs))
+ return delete(db)
+}
diff --git a/migrations/cockroach/V1.43__refresh_tokens.sql b/migrations/cockroach/V1.43__refresh_tokens.sql
new file mode 100644
index 0000000000..0227d12f30
--- /dev/null
+++ b/migrations/cockroach/V1.43__refresh_tokens.sql
@@ -0,0 +1,21 @@
+CREATE TABLE auth.refresh_tokens (
+ id TEXT,
+
+ creation_date TIMESTAMPTZ,
+ change_date TIMESTAMPTZ,
+
+ resource_owner TEXT,
+ token TEXT,
+ client_id TEXT,
+ user_agent_id TEXT,
+ user_id TEXT,
+ auth_time TIMESTAMPTZ,
+ idle_expiration TIMESTAMPTZ,
+ expiration TIMESTAMPTZ,
+ sequence BIGINT,
+ scopes TEXT ARRAY,
+ audience TEXT ARRAY,
+ amr TEXT ARRAY,
+
+ PRIMARY KEY (client_id, user_agent_id, user_id)
+);
diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto
index 749d07b9c9..b7329a3247 100644
--- a/proto/zitadel/auth.proto
+++ b/proto/zitadel/auth.proto
@@ -82,6 +82,39 @@ service AuthService {
};
}
+ // Returns the refresh tokens of the authorized user
+ rpc ListMyRefreshTokens(ListMyRefreshTokensRequest) returns (ListMyRefreshTokensResponse) {
+ option (google.api.http) = {
+ post: "/users/me/tokens/refresh/_search"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Revokes a single refresh token of the authorized user by its (token) id
+ rpc RevokeMyRefreshToken(RevokeMyRefreshTokenRequest) returns (RevokeMyRefreshTokenResponse) {
+ option (google.api.http) = {
+ delete: "/users/me/tokens/refresh/{id}"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Revokes all refresh tokens of the authorized user
+ rpc RevokeAllMyRefreshTokens(RevokeAllMyRefreshTokensRequest) returns (RevokeAllMyRefreshTokensResponse) {
+ option (google.api.http) = {
+ post: "/users/me/tokens/refresh/_revoke_all"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
// Change the user name of the authorize user
rpc UpdateMyUserName(UpdateMyUserNameRequest) returns (UpdateMyUserNameResponse) {
option (google.api.http) = {
@@ -489,6 +522,28 @@ message ListMyUserSessionsResponse {
repeated zitadel.user.v1.Session result = 1;
}
+//This is an empty request
+message ListMyRefreshTokensRequest {}
+
+message ListMyRefreshTokensResponse {
+ zitadel.v1.ListDetails details = 1;
+ repeated zitadel.user.v1.RefreshToken result = 2;
+}
+
+message RevokeMyRefreshTokenRequest {
+ string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+}
+
+message RevokeMyRefreshTokenResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
+//This is an empty request
+message RevokeAllMyRefreshTokensRequest {}
+
+//This is an empty response
+message RevokeAllMyRefreshTokensResponse {}
+
message UpdateMyUserNameRequest {
string user_name = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
}
diff --git a/proto/zitadel/user.proto b/proto/zitadel/user.proto
index 7a6fe06c9c..6b6e7ee33c 100644
--- a/proto/zitadel/user.proto
+++ b/proto/zitadel/user.proto
@@ -2,6 +2,7 @@ syntax = "proto3";
import "zitadel/object.proto";
import "validate/validate.proto";
+import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
@@ -516,6 +517,48 @@ enum SessionState {
SESSION_STATE_TERMINATED = 2;
}
+message RefreshToken {
+ string id = 1 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ example: "\"69629023906489455\""
+ }
+ ];
+ zitadel.v1.ObjectDetails details = 2;
+ string client_id = 3 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ example: "\"69629023906488334@ZITADEL\"";
+ description: "oauth2/oidc client_id of the authorized application";
+ }
+ ];
+ google.protobuf.Timestamp auth_time = 4 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "\"time when the user authenticated, does not have to be the same time the token was created\""
+ }
+ ];
+ google.protobuf.Timestamp idle_expiration = 5 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "\"time the refresh token will expire if not used, the user will have to reauthenticate\""
+ }
+ ];
+ google.protobuf.Timestamp expiration = 6 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "\"time the refresh token will expire, the user will have to reauthenticate\""
+ }
+ ];
+ repeated string scopes = 7 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ example: "[\"openid\",\"email\",\"profile\",\"offline_access\"]";
+ description: "scopes of the initial auth request, access tokens created by this refresh token can have a subset of these scopes";
+ }
+ ];
+ repeated string audience = 8 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ example: "[\"69629023906488334@ZITADEL\", \"69629023906481256\"]"
+ description: "audience of the initial auth request and of all access tokens created by this refresh token";
+ }
+ ];
+}
+
message UserGrant {
string id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {