mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 03:37:34 +00:00
feat: dynamic issuer (#3481)
* feat: dynamic issuer * dynamic domain handling * key rotation durations * feat: dynamic issuer * make webauthn displayname dynamic
This commit is contained in:
@@ -6,8 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/caos/logging"
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
"github.com/caos/oidc/pkg/op"
|
||||
"github.com/caos/oidc/v2/pkg/oidc"
|
||||
"github.com/caos/oidc/v2/pkg/op"
|
||||
|
||||
"github.com/caos/zitadel/internal/api/authz"
|
||||
"github.com/caos/zitadel/internal/api/http/middleware"
|
||||
|
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
"github.com/caos/oidc/pkg/op"
|
||||
"github.com/caos/oidc/v2/pkg/oidc"
|
||||
"github.com/caos/oidc/v2/pkg/op"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/caos/zitadel/internal/api/authz"
|
||||
|
@@ -5,8 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
"github.com/caos/oidc/pkg/op"
|
||||
"github.com/caos/oidc/v2/pkg/oidc"
|
||||
"github.com/caos/oidc/v2/pkg/op"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/caos/zitadel/internal/api/authz"
|
||||
@@ -43,7 +43,7 @@ func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Cl
|
||||
if err != nil {
|
||||
return nil, errors.ThrowInternal(err, "OIDC-mPxqP", "Errors.Internal")
|
||||
}
|
||||
projectRoles, err := o.query.SearchProjectRoles(context.TODO(), &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}})
|
||||
projectRoles, err := o.query.SearchProjectRoles(ctx, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@@ -4,8 +4,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
"github.com/caos/oidc/pkg/op"
|
||||
"github.com/caos/oidc/v2/pkg/oidc"
|
||||
"github.com/caos/oidc/v2/pkg/op"
|
||||
|
||||
"github.com/caos/zitadel/internal/domain"
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
|
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/caos/logging"
|
||||
"github.com/caos/oidc/v2/pkg/op"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/caos/zitadel/internal/api/authz"
|
||||
@@ -20,99 +21,121 @@ import (
|
||||
const (
|
||||
locksTable = "projections.locks"
|
||||
signingKey = "signing_key"
|
||||
oidcUser = "OIDC"
|
||||
|
||||
retryBackoff = 500 * time.Millisecond
|
||||
retryCount = 3
|
||||
lockDuration = retryCount * retryBackoff * 5
|
||||
gracefulPeriod = 10 * time.Minute
|
||||
)
|
||||
|
||||
func (o *OPStorage) GetKeySet(ctx context.Context) (_ *jose.JSONWebKeySet, err error) {
|
||||
//SigningKey wraps the query.PrivateKey to implement the op.SigningKey interface
|
||||
type SigningKey struct {
|
||||
algorithm jose.SignatureAlgorithm
|
||||
id string
|
||||
key interface{}
|
||||
}
|
||||
|
||||
func (s *SigningKey) SignatureAlgorithm() jose.SignatureAlgorithm {
|
||||
return s.algorithm
|
||||
}
|
||||
|
||||
func (s *SigningKey) Key() interface{} {
|
||||
return s.key
|
||||
}
|
||||
|
||||
func (s *SigningKey) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
//PublicKey wraps the query.PublicKey to implement the op.Key interface
|
||||
type PublicKey struct {
|
||||
key query.PublicKey
|
||||
}
|
||||
|
||||
func (s *PublicKey) Algorithm() jose.SignatureAlgorithm {
|
||||
return jose.SignatureAlgorithm(s.key.Algorithm())
|
||||
}
|
||||
|
||||
func (s *PublicKey) Use() string {
|
||||
return s.key.Use().String()
|
||||
}
|
||||
|
||||
func (s *PublicKey) Key() interface{} {
|
||||
return s.key.Key()
|
||||
}
|
||||
|
||||
func (s *PublicKey) ID() string {
|
||||
return s.key.ID()
|
||||
}
|
||||
|
||||
//KeySet implements the op.Storage interface
|
||||
func (o *OPStorage) KeySet(ctx context.Context) (keys []op.Key, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
keys, err := o.query.ActivePublicKeys(ctx, time.Now())
|
||||
err = retry(func() error {
|
||||
publicKeys, err := o.query.ActivePublicKeys(ctx, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys = make([]op.Key, len(publicKeys.Keys))
|
||||
for i, key := range publicKeys.Keys {
|
||||
keys[i] = &PublicKey{key}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return keys, err
|
||||
}
|
||||
|
||||
//SignatureAlgorithms implements the op.Storage interface
|
||||
func (o *OPStorage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) {
|
||||
key, err := o.SigningKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webKeys := make([]jose.JSONWebKey, len(keys.Keys))
|
||||
for i, key := range keys.Keys {
|
||||
webKeys[i] = jose.JSONWebKey{
|
||||
KeyID: key.ID(),
|
||||
Algorithm: key.Algorithm(),
|
||||
Use: key.Use().String(),
|
||||
Key: key.Key(),
|
||||
}
|
||||
}
|
||||
return &jose.JSONWebKeySet{Keys: webKeys}, nil
|
||||
return []jose.SignatureAlgorithm{key.SignatureAlgorithm()}, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) GetSigningKey(ctx context.Context, keyCh chan<- jose.SigningKey) {
|
||||
renewTimer := time.NewTimer(0)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-o.keyChan:
|
||||
if !renewTimer.Stop() {
|
||||
<-renewTimer.C
|
||||
}
|
||||
checkAfter := o.resetTimer(renewTimer, true)
|
||||
logging.Infof("requested next signing key check in %s", checkAfter)
|
||||
case <-renewTimer.C:
|
||||
o.getSigningKey(ctx, renewTimer, keyCh)
|
||||
}
|
||||
//SigningKey implements the op.Storage interface
|
||||
func (o *OPStorage) SigningKey(ctx context.Context) (key op.SigningKey, err error) {
|
||||
err = retry(func() error {
|
||||
key, err = o.getSigningKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}()
|
||||
if key == nil {
|
||||
return errors.ThrowInternal(nil, "test", "test")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return key, err
|
||||
}
|
||||
|
||||
func (o *OPStorage) getSigningKey(ctx context.Context, renewTimer *time.Timer, keyCh chan<- jose.SigningKey) {
|
||||
keys, err := o.query.ActivePrivateSigningKey(ctx, time.Now().Add(o.signingKeyGracefulPeriod))
|
||||
func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) {
|
||||
keys, err := o.query.ActivePrivateSigningKey(ctx, time.Now().Add(gracefulPeriod))
|
||||
if err != nil {
|
||||
checkAfter := o.resetTimer(renewTimer, true)
|
||||
logging.Infof("next signing key check in %s", checkAfter)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
if len(keys.Keys) == 0 {
|
||||
var sequence uint64
|
||||
if keys.LatestSequence != nil {
|
||||
sequence = keys.LatestSequence.Sequence
|
||||
}
|
||||
o.refreshSigningKey(ctx, keyCh, o.signingKeyAlgorithm, sequence)
|
||||
checkAfter := o.resetTimer(renewTimer, true)
|
||||
logging.Infof("next signing key check in %s", checkAfter)
|
||||
return
|
||||
if len(keys.Keys) > 0 {
|
||||
return o.privateKeyToSigningKey(selectSigningKey(keys.Keys))
|
||||
}
|
||||
err = o.exchangeSigningKey(selectSigningKey(keys.Keys), keyCh)
|
||||
logging.OnError(err).Error("could not exchange signing key")
|
||||
checkAfter := o.resetTimer(renewTimer, err != nil)
|
||||
logging.Infof("next signing key check in %s", checkAfter)
|
||||
var sequence uint64
|
||||
if keys.LatestSequence != nil {
|
||||
sequence = keys.LatestSequence.Sequence
|
||||
}
|
||||
return nil, o.refreshSigningKey(ctx, o.signingKeyAlgorithm, sequence)
|
||||
}
|
||||
|
||||
func (o *OPStorage) resetTimer(timer *time.Timer, shortRefresh bool) (nextCheck time.Duration) {
|
||||
nextCheck = o.signingKeyRotationCheck
|
||||
defer func() { timer.Reset(nextCheck) }()
|
||||
if shortRefresh || o.currentKey == nil {
|
||||
return nextCheck
|
||||
}
|
||||
maxLifetime := time.Until(o.currentKey.Expiry())
|
||||
if maxLifetime < o.signingKeyGracefulPeriod+2*o.signingKeyRotationCheck {
|
||||
return nextCheck
|
||||
}
|
||||
return maxLifetime - o.signingKeyGracefulPeriod - o.signingKeyRotationCheck
|
||||
}
|
||||
|
||||
func (o *OPStorage) refreshSigningKey(ctx context.Context, keyCh chan<- jose.SigningKey, algorithm string, sequence uint64) {
|
||||
if o.currentKey != nil && o.currentKey.Expiry().Before(time.Now().UTC()) {
|
||||
logging.Info("unset current signing key")
|
||||
keyCh <- jose.SigningKey{}
|
||||
}
|
||||
func (o *OPStorage) refreshSigningKey(ctx context.Context, algorithm string, sequence uint64) error {
|
||||
ok, err := o.ensureIsLatestKey(ctx, sequence)
|
||||
if err != nil {
|
||||
logging.New().WithError(err).Error("could not ensure latest key")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
logging.Warn("view not up to date, retrying later")
|
||||
return
|
||||
if err != nil || !ok {
|
||||
return errors.ThrowInternal(err, "OIDC-ASfh3", "cannot ensure that projection is up to date")
|
||||
}
|
||||
err = o.lockAndGenerateSigningKeyPair(ctx, algorithm)
|
||||
logging.OnError(err).Warn("could not create signing key")
|
||||
if err != nil {
|
||||
return errors.ThrowInternal(err, "OIDC-ADh31", "could not create signing key")
|
||||
}
|
||||
return errors.ThrowInternal(nil, "OIDC-Df1bh", "")
|
||||
}
|
||||
|
||||
func (o *OPStorage) ensureIsLatestKey(ctx context.Context, sequence uint64) (bool, error) {
|
||||
@@ -123,29 +146,20 @@ func (o *OPStorage) ensureIsLatestKey(ctx context.Context, sequence uint64) (boo
|
||||
return sequence == maxSequence, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) exchangeSigningKey(key query.PrivateKey, keyCh chan<- jose.SigningKey) (err error) {
|
||||
if o.currentKey != nil && o.currentKey.ID() == key.ID() {
|
||||
logging.Info("no new signing key")
|
||||
return nil
|
||||
}
|
||||
func (o *OPStorage) privateKeyToSigningKey(key query.PrivateKey) (_ op.SigningKey, err error) {
|
||||
keyData, err := crypto.Decrypt(key.Key(), o.encAlg)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
privateKey, err := crypto.BytesToPrivateKey(keyData)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
keyCh <- jose.SigningKey{
|
||||
Algorithm: jose.SignatureAlgorithm(key.Algorithm()),
|
||||
Key: jose.JSONWebKey{
|
||||
KeyID: key.ID(),
|
||||
Key: privateKey,
|
||||
},
|
||||
}
|
||||
o.currentKey = key
|
||||
logging.WithFields("keyID", key.ID()).Info("exchanged signing key")
|
||||
return nil
|
||||
return &SigningKey{
|
||||
algorithm: jose.SignatureAlgorithm(key.Algorithm()),
|
||||
key: privateKey,
|
||||
id: key.ID(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm string) error {
|
||||
@@ -154,7 +168,7 @@ func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errs := o.locker.Lock(ctx, o.signingKeyRotationCheck*2, authz.GetInstance(ctx).InstanceID())
|
||||
errs := o.locker.Lock(ctx, lockDuration, authz.GetInstance(ctx).InstanceID())
|
||||
err, ok := <-errs
|
||||
if err != nil || !ok {
|
||||
if errors.IsErrorAlreadyExists(err) {
|
||||
@@ -164,13 +178,13 @@ func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm
|
||||
return err
|
||||
}
|
||||
|
||||
return o.command.GenerateSigningKeyPair(ctx, algorithm)
|
||||
return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), algorithm)
|
||||
}
|
||||
|
||||
func (o *OPStorage) getMaxKeySequence(ctx context.Context) (uint64, error) {
|
||||
return o.eventstore.LatestSequence(ctx,
|
||||
eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence).
|
||||
ResourceOwner("system"). //TODO: change with multi issuer
|
||||
ResourceOwner(authz.GetInstance(ctx).InstanceID()).
|
||||
AddQuery().
|
||||
AggregateTypes(keypair.AggregateType).
|
||||
Builder(),
|
||||
@@ -180,3 +194,18 @@ func (o *OPStorage) getMaxKeySequence(ctx context.Context) (uint64, error) {
|
||||
func selectSigningKey(keys []query.PrivateKey) query.PrivateKey {
|
||||
return keys[len(keys)-1]
|
||||
}
|
||||
|
||||
func setOIDCCtx(ctx context.Context) context.Context {
|
||||
return authz.SetCtxData(ctx, authz.CtxData{UserID: oidcUser, OrgID: authz.GetInstance(ctx).InstanceID()})
|
||||
}
|
||||
|
||||
func retry(retryable func() error) (err error) {
|
||||
for i := 0; i < retryCount; i++ {
|
||||
time.Sleep(retryBackoff)
|
||||
err = retryable()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/caos/oidc/pkg/op"
|
||||
"github.com/caos/oidc/v2/pkg/op"
|
||||
"github.com/rakyll/statik/fs"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/caos/zitadel/internal/api/ui/login"
|
||||
"github.com/caos/zitadel/internal/auth/repository"
|
||||
"github.com/caos/zitadel/internal/command"
|
||||
"github.com/caos/zitadel/internal/config/systemdefaults"
|
||||
"github.com/caos/zitadel/internal/crypto"
|
||||
caos_errs "github.com/caos/zitadel/internal/errors"
|
||||
"github.com/caos/zitadel/internal/eventstore"
|
||||
@@ -74,26 +73,23 @@ type OPStorage struct {
|
||||
defaultRefreshTokenIdleExpiration time.Duration
|
||||
defaultRefreshTokenExpiration time.Duration
|
||||
encAlg crypto.EncryptionAlgorithm
|
||||
keyChan <-chan interface{}
|
||||
currentKey query.PrivateKey
|
||||
signingKeyRotationCheck time.Duration
|
||||
signingKeyGracefulPeriod time.Duration
|
||||
locker crdb.Locker
|
||||
assetAPIPrefix string
|
||||
}
|
||||
|
||||
func NewProvider(ctx context.Context, config Config, issuer, defaultLogoutRedirectURI string, command *command.Commands, query *query.Queries, repo repository.Repository, keyConfig systemdefaults.KeyConfig, encryptionAlg crypto.EncryptionAlgorithm, cryptoKey []byte, es *eventstore.Eventstore, projections *sql.DB, keyChan <-chan interface{}, userAgentCookie, instanceHandler func(http.Handler) http.Handler) (op.OpenIDProvider, error) {
|
||||
opConfig, err := createOPConfig(config, issuer, defaultLogoutRedirectURI, cryptoKey)
|
||||
func NewProvider(ctx context.Context, config Config, defaultLogoutRedirectURI string, externalSecure bool, command *command.Commands, query *query.Queries, repo repository.Repository, encryptionAlg crypto.EncryptionAlgorithm, cryptoKey []byte, es *eventstore.Eventstore, projections *sql.DB, userAgentCookie, instanceHandler func(http.Handler) http.Handler) (op.OpenIDProvider, error) {
|
||||
opConfig, err := createOPConfig(config, defaultLogoutRedirectURI, cryptoKey)
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "OIDC-EGrqd", "cannot create op config: %w")
|
||||
}
|
||||
storage := newStorage(config, command, query, repo, keyConfig, encryptionAlg, es, projections, keyChan)
|
||||
options, err := createOptions(config, userAgentCookie, instanceHandler)
|
||||
storage := newStorage(config, command, query, repo, encryptionAlg, es, projections)
|
||||
options, err := createOptions(config, externalSecure, userAgentCookie, instanceHandler)
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "OIDC-D3gq1", "cannot create options: %w")
|
||||
}
|
||||
provider, err := op.NewOpenIDProvider(
|
||||
provider, err := op.NewDynamicOpenIDProvider(
|
||||
ctx,
|
||||
HandlerPrefix,
|
||||
opConfig,
|
||||
storage,
|
||||
options...,
|
||||
@@ -104,17 +100,12 @@ func NewProvider(ctx context.Context, config Config, issuer, defaultLogoutRedire
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func Issuer(domain string, port uint16, externalSecure bool) string {
|
||||
return http_utils.BuildHTTP(domain, port, externalSecure) + HandlerPrefix
|
||||
}
|
||||
|
||||
func createOPConfig(config Config, issuer, defaultLogoutRedirectURI string, cryptoKey []byte) (*op.Config, error) {
|
||||
func createOPConfig(config Config, defaultLogoutRedirectURI string, cryptoKey []byte) (*op.Config, error) {
|
||||
supportedLanguages, err := getSupportedLanguages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opConfig := &op.Config{
|
||||
Issuer: issuer,
|
||||
DefaultLogoutRedirectURI: defaultLogoutRedirectURI,
|
||||
CodeMethodS256: config.CodeMethodS256,
|
||||
AuthMethodPost: config.AuthMethodPost,
|
||||
@@ -130,21 +121,26 @@ func createOPConfig(config Config, issuer, defaultLogoutRedirectURI string, cryp
|
||||
return opConfig, nil
|
||||
}
|
||||
|
||||
func createOptions(config Config, userAgentCookie, instanceHandler func(http.Handler) http.Handler) ([]op.Option, error) {
|
||||
func createOptions(config Config, externalSecure bool, userAgentCookie, instanceHandler func(http.Handler) http.Handler) ([]op.Option, error) {
|
||||
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount}
|
||||
interceptor := op.WithHttpInterceptors(
|
||||
middleware.MetricsHandler(metricTypes),
|
||||
middleware.TelemetryHandler(),
|
||||
middleware.NoCacheInterceptor,
|
||||
instanceHandler,
|
||||
userAgentCookie,
|
||||
http_utils.CopyHeadersToContext,
|
||||
)
|
||||
endpoints := customEndpoints(config.CustomEndpoints)
|
||||
if len(endpoints) == 0 {
|
||||
return []op.Option{interceptor}, nil
|
||||
options := []op.Option{
|
||||
op.WithHttpInterceptors(
|
||||
middleware.MetricsHandler(metricTypes),
|
||||
middleware.TelemetryHandler(),
|
||||
middleware.NoCacheInterceptor,
|
||||
instanceHandler,
|
||||
userAgentCookie,
|
||||
http_utils.CopyHeadersToContext,
|
||||
),
|
||||
}
|
||||
return append(endpoints, interceptor), nil
|
||||
if !externalSecure {
|
||||
options = append(options, op.WithAllowInsecure())
|
||||
}
|
||||
endpoints := customEndpoints(config.CustomEndpoints)
|
||||
if len(endpoints) != 0 {
|
||||
options = append(options, endpoints...)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func customEndpoints(endpointConfig *EndpointConfig) []op.Option {
|
||||
@@ -176,7 +172,7 @@ func customEndpoints(endpointConfig *EndpointConfig) []op.Option {
|
||||
return options
|
||||
}
|
||||
|
||||
func newStorage(config Config, command *command.Commands, query *query.Queries, repo repository.Repository, keyConfig systemdefaults.KeyConfig, encAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, projections *sql.DB, keyChan <-chan interface{}) *OPStorage {
|
||||
func newStorage(config Config, command *command.Commands, query *query.Queries, repo repository.Repository, encAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, projections *sql.DB) *OPStorage {
|
||||
return &OPStorage{
|
||||
repo: repo,
|
||||
command: command,
|
||||
@@ -189,10 +185,7 @@ func newStorage(config Config, command *command.Commands, query *query.Queries,
|
||||
defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration,
|
||||
defaultRefreshTokenExpiration: config.DefaultRefreshTokenExpiration,
|
||||
encAlg: encAlg,
|
||||
signingKeyGracefulPeriod: keyConfig.SigningKeyGracefulPeriod,
|
||||
signingKeyRotationCheck: keyConfig.SigningKeyRotationCheck,
|
||||
locker: crdb.NewLocker(projections, locksTable, signingKey),
|
||||
keyChan: keyChan,
|
||||
assetAPIPrefix: assets.HandlerPrefix,
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user