mirror of
https://github.com/zitadel/zitadel.git
synced 2025-02-28 23:27:23 +00:00
feat: device authorization RFC 8628 (#5646)
* device auth: implement the write events * add grant type device code * fix(init): check if default value implements stringer --------- Co-authored-by: adlerhurst <silvan.reusser@gmail.com>
This commit is contained in:
parent
3cd2cecfdf
commit
5819924275
@ -233,6 +233,8 @@ OIDC:
|
||||
Path: /oidc/v1/end_session
|
||||
Keys:
|
||||
Path: /oauth/v2/keys
|
||||
DeviceAuth:
|
||||
Path: /oauth/v2/device_authorization
|
||||
|
||||
SAML:
|
||||
ProviderConfig:
|
||||
|
@ -12,14 +12,13 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/saml/pkg/provider"
|
||||
|
||||
clockpkg "github.com/benbjohnson/clock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/zitadel/oidc/v2/pkg/op"
|
||||
"github.com/zitadel/saml/pkg/provider"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
@ -294,6 +293,7 @@ func startAPIs(
|
||||
return fmt.Errorf("unable to start login: %w", err)
|
||||
}
|
||||
apis.RegisterHandlerOnPrefix(login.HandlerPrefix, l.Handler())
|
||||
apis.HandleFunc(login.EndpointDeviceAuth, login.RedirectDeviceAuthToPrefix)
|
||||
|
||||
// handle grpc at last to be able to handle the root, because grpc and gateway require a lot of different prefixes
|
||||
apis.RouteGRPC()
|
||||
|
10
go.mod
10
go.mod
@ -45,6 +45,7 @@ require (
|
||||
github.com/minio/minio-go/v7 v7.0.50
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/muesli/gamut v0.3.1
|
||||
github.com/muhlemmer/gu v0.3.1
|
||||
github.com/nicksnyder/go-i18n/v2 v2.2.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
@ -57,7 +58,7 @@ require (
|
||||
github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203
|
||||
github.com/ttacon/libphonenumber v1.2.1
|
||||
github.com/zitadel/logging v0.3.4
|
||||
github.com/zitadel/oidc/v2 v2.2.6
|
||||
github.com/zitadel/oidc/v2 v2.4.0
|
||||
github.com/zitadel/saml v0.0.11
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.40.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.40.0
|
||||
@ -70,10 +71,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v0.37.0
|
||||
go.opentelemetry.io/otel/trace v1.14.0
|
||||
golang.org/x/crypto v0.7.0
|
||||
golang.org/x/net v0.8.0
|
||||
golang.org/x/oauth2 v0.6.0
|
||||
golang.org/x/net v0.9.0
|
||||
golang.org/x/oauth2 v0.7.0
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/text v0.8.0
|
||||
golang.org/x/text v0.9.0
|
||||
golang.org/x/tools v0.7.0
|
||||
google.golang.org/api v0.115.0
|
||||
google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd
|
||||
@ -90,7 +91,6 @@ require (
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/muhlemmer/gu v0.3.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.7 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.14.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
|
16
go.sum
16
go.sum
@ -1130,8 +1130,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM=
|
||||
github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0=
|
||||
github.com/zitadel/oidc/v2 v2.2.6 h1:L2k5q1X8Rucax5Ynp3B3lz7JQDJxUwfWCOmgc9Bh0BM=
|
||||
github.com/zitadel/oidc/v2 v2.2.6/go.mod h1:tGkj9lQk6KVj5hsM89XPadvi6I06666sMy3KtykvSFM=
|
||||
github.com/zitadel/oidc/v2 v2.4.0 h1:BKx61qOxDf+GjrY8T6lFxPjea0aMfkFvHD9pqyJGpFk=
|
||||
github.com/zitadel/oidc/v2 v2.4.0/go.mod h1:wBOrfB0m/tGXo6isym1F5k3VeXSUinGsAt2H8V/+Uks=
|
||||
github.com/zitadel/saml v0.0.11 h1:kObucnBrcu1PHCO7RGT0iVeuJL/5I50gUgr40S41nMs=
|
||||
github.com/zitadel/saml v0.0.11/go.mod h1:YGWAvPZRv4DbEZ78Ht/2P0AWzGn+6WGhFf90PMXl0Po=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
@ -1342,8 +1342,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@ -1360,8 +1360,8 @@ golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
|
||||
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -1477,8 +1477,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
@ -101,6 +101,12 @@ func (a *API) RegisterService(ctx context.Context, grpcServer server.Server) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleFunc allows registering a [http.HandlerFunc] on an exact
|
||||
// path, instead of prefix like RegisterHandlerOnPrefix.
|
||||
func (a *API) HandleFunc(path string, f http.HandlerFunc) {
|
||||
a.router.HandleFunc(path, f)
|
||||
}
|
||||
|
||||
// RegisterHandlerOnPrefix registers a http handler on a path prefix
|
||||
// the prefix will not be passed to the actual handler
|
||||
func (a *API) RegisterHandlerOnPrefix(prefix string, handler http.Handler) {
|
||||
|
@ -136,6 +136,8 @@ func OIDCGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app_pb.OIDCGra
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT
|
||||
case domain.OIDCGrantTypeRefreshToken:
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN
|
||||
case domain.OIDCGrantTypeDeviceCode:
|
||||
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE
|
||||
}
|
||||
}
|
||||
return oidcGrantTypes
|
||||
@ -154,6 +156,8 @@ func OIDCGrantTypesToDomain(grantTypes []app_pb.OIDCGrantType) []domain.OIDCGran
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit
|
||||
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN:
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken
|
||||
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE:
|
||||
oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode
|
||||
}
|
||||
}
|
||||
return oidcGrantTypes
|
||||
|
@ -99,15 +99,6 @@ func (a *AuthRequest) GetSubject() string {
|
||||
return a.UserID
|
||||
}
|
||||
|
||||
func (a *AuthRequest) Done() bool {
|
||||
for _, step := range a.PossibleSteps {
|
||||
if step.Type() == domain.NextStepRedirectToCallback {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *AuthRequest) oidc() *domain.AuthRequestOIDC {
|
||||
return a.Request.(*domain.AuthRequestOIDC)
|
||||
}
|
||||
|
@ -200,6 +200,8 @@ func grantTypeToOIDC(grantType domain.OIDCGrantType) oidc.GrantType {
|
||||
return oidc.GrantTypeImplicit
|
||||
case domain.OIDCGrantTypeRefreshToken:
|
||||
return oidc.GrantTypeRefreshToken
|
||||
case domain.OIDCGrantTypeDeviceCode:
|
||||
return oidc.GrantTypeDeviceCode
|
||||
default:
|
||||
return oidc.GrantTypeCode
|
||||
}
|
||||
|
176
internal/api/oidc/device_auth.go
Normal file
176
internal/api/oidc/device_auth.go
Normal file
@ -0,0 +1,176 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
"github.com/zitadel/oidc/v2/pkg/op"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
DeviceAuthDefaultLifetime = 5 * time.Minute
|
||||
DeviceAuthDefaultPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
type DeviceAuthorizationConfig struct {
|
||||
Lifetime time.Duration
|
||||
PollInterval time.Duration
|
||||
UserCode *UserCodeConfig
|
||||
}
|
||||
|
||||
type UserCodeConfig struct {
|
||||
CharSet string
|
||||
CharAmount int
|
||||
DashInterval int
|
||||
}
|
||||
|
||||
// toOPConfig converts DeviceAuthorizationConfig to a [op.DeviceAuthorizationConfig],
|
||||
// setting sane defaults for empty values.
|
||||
// Safe to call when c is nil.
|
||||
func (c *DeviceAuthorizationConfig) toOPConfig() op.DeviceAuthorizationConfig {
|
||||
out := op.DeviceAuthorizationConfig{
|
||||
Lifetime: DeviceAuthDefaultLifetime,
|
||||
PollInterval: DeviceAuthDefaultPollInterval,
|
||||
UserFormPath: login.EndpointDeviceAuth,
|
||||
UserCode: op.UserCodeBase20,
|
||||
}
|
||||
if c == nil {
|
||||
return out
|
||||
}
|
||||
if c.Lifetime != 0 {
|
||||
out.Lifetime = c.Lifetime
|
||||
}
|
||||
if c.PollInterval != 0 {
|
||||
out.PollInterval = c.PollInterval
|
||||
}
|
||||
|
||||
if c.UserCode == nil {
|
||||
return out
|
||||
}
|
||||
if c.UserCode.CharSet != "" {
|
||||
out.UserCode.CharSet = c.UserCode.CharSet
|
||||
}
|
||||
if c.UserCode.CharAmount != 0 {
|
||||
out.UserCode.CharAmount = c.UserCode.CharAmount
|
||||
}
|
||||
if c.UserCode.DashInterval != 0 {
|
||||
out.UserCode.DashInterval = c.UserCode.CharAmount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StoreDeviceAuthorization creates a new Device Authorization request.
|
||||
// Implements the op.DeviceAuthorizationStorage interface.
|
||||
func (o *OPStorage) StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) (err error) {
|
||||
const logMsg = "store device authorization"
|
||||
logger := logging.WithFields("client_id", clientID, "device_code", deviceCode, "user_code", userCode, "expires", expires, "scopes", scopes)
|
||||
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() {
|
||||
logger.OnError(err).Error(logMsg)
|
||||
span.EndWithError(err)
|
||||
}()
|
||||
|
||||
// TODO(muhlemmer): Remove the following code block with oidc v3
|
||||
// https://github.com/zitadel/oidc/issues/370
|
||||
client, err := o.GetClientByClientID(ctx, clientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !op.ValidateGrantType(client, oidc.GrantTypeDeviceCode) {
|
||||
return errors.ThrowPermissionDeniedf(nil, "OIDC-et1Ae", "grant type %q not allowed for client", oidc.GrantTypeDeviceCode)
|
||||
}
|
||||
|
||||
scopes, err = o.assertProjectRoleScopes(ctx, clientID, scopes)
|
||||
if err != nil {
|
||||
return errors.ThrowPreconditionFailed(err, "OIDC-She4t", "Errors.Internal")
|
||||
}
|
||||
aggrID, details, err := o.command.AddDeviceAuth(ctx, clientID, deviceCode, userCode, expires, scopes)
|
||||
if err == nil {
|
||||
logger.SetFields("aggregate_id", aggrID, "details", details).Debug(logMsg)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func newDeviceAuthorizationState(d *domain.DeviceAuth) *op.DeviceAuthorizationState {
|
||||
return &op.DeviceAuthorizationState{
|
||||
ClientID: d.ClientID,
|
||||
Scopes: d.Scopes,
|
||||
Expires: d.Expires,
|
||||
Done: d.State.Done(),
|
||||
Subject: d.Subject,
|
||||
Denied: d.State.Denied(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeviceAuthorizatonState retieves the current state of the Device Authorization process.
|
||||
// It implements the [op.DeviceAuthorizationStorage] interface and is used by devices that
|
||||
// are polling until they successfully receive a token or we indicate a denied or expired state.
|
||||
// As generated user codes are of low entropy, this implementation also takes care or
|
||||
// device authorization request cleanup, when it has been Approved, Denied or Expired.
|
||||
func (o *OPStorage) GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (state *op.DeviceAuthorizationState, err error) {
|
||||
const logMsg = "get device authorization state"
|
||||
logger := logging.WithFields("client_id", clientID, "device_code", deviceCode)
|
||||
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
logger.WithError(err).Error(logMsg)
|
||||
}
|
||||
span.EndWithError(err)
|
||||
}()
|
||||
|
||||
deviceAuth, err := o.query.DeviceAuthByDeviceCode(ctx, clientID, deviceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetFields(
|
||||
"expires", deviceAuth.Expires, "scopes", deviceAuth.Scopes,
|
||||
"subject", deviceAuth.Subject, "state", deviceAuth.State,
|
||||
).Debug("device authorization state")
|
||||
|
||||
// Cancel the request if it is expired, only if it wasn't Done meanwhile
|
||||
if !deviceAuth.State.Done() && deviceAuth.Expires.Before(time.Now()) {
|
||||
_, err = o.command.CancelDeviceAuth(ctx, deviceAuth.AggregateID, domain.DeviceAuthCanceledExpired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceAuth.State = domain.DeviceAuthStateExpired
|
||||
}
|
||||
|
||||
// When the request is more then initiated, it has been either Approved, Denied or Expired.
|
||||
// At this point we should remove it from the DB to avoid user code conflicts.
|
||||
if deviceAuth.State > domain.DeviceAuthStateInitiated {
|
||||
_, err = o.command.RemoveDeviceAuth(ctx, deviceAuth.AggregateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return newDeviceAuthorizationState(deviceAuth), nil
|
||||
}
|
||||
|
||||
// TODO(muhlemmer): remove the following methods with oidc v3.
|
||||
// They are actually not used, but are required by the oidc device storage interface.
|
||||
// https://github.com/zitadel/oidc/issues/371
|
||||
func (o *OPStorage) GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) DenyDeviceAuthorization(ctx context.Context, userCode string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO end.
|
@ -40,6 +40,7 @@ type Config struct {
|
||||
UserAgentCookieConfig *middleware.UserAgentCookieConfig
|
||||
Cache *middleware.CacheConfig
|
||||
CustomEndpoints *EndpointConfig
|
||||
DeviceAuth *DeviceAuthorizationConfig
|
||||
}
|
||||
|
||||
type EndpointConfig struct {
|
||||
@ -50,6 +51,7 @@ type EndpointConfig struct {
|
||||
Revocation *Endpoint
|
||||
EndSession *Endpoint
|
||||
Keys *Endpoint
|
||||
DeviceAuth *Endpoint
|
||||
}
|
||||
|
||||
type Endpoint struct {
|
||||
@ -108,6 +110,7 @@ func createOPConfig(config Config, defaultLogoutRedirectURI string, cryptoKey []
|
||||
GrantTypeRefreshToken: config.GrantTypeRefreshToken,
|
||||
RequestObjectSupported: config.RequestObjectSupported,
|
||||
SupportedUILocales: supportedLanguages,
|
||||
DeviceAuthorization: config.DeviceAuth.toOPConfig(),
|
||||
}
|
||||
if cryptoLength := len(cryptoKey); cryptoLength != 32 {
|
||||
return nil, caos_errs.ThrowInternalf(nil, "OIDC-D43gf", "crypto key must be 32 bytes, but is %d", cryptoLength)
|
||||
@ -165,6 +168,9 @@ func customEndpoints(endpointConfig *EndpointConfig) []op.Option {
|
||||
if endpointConfig.Keys != nil {
|
||||
options = append(options, op.WithCustomKeysEndpoint(op.NewEndpointWithURL(endpointConfig.Keys.Path, endpointConfig.Keys.URL)))
|
||||
}
|
||||
if endpointConfig.DeviceAuth != nil {
|
||||
options = append(options, op.WithCustomDeviceAuthorizationEndpoint(op.NewEndpointWithURL(endpointConfig.DeviceAuth.Path, endpointConfig.DeviceAuth.URL)))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
|
@ -63,14 +63,6 @@ func (a *AuthRequest) GetUserID() string {
|
||||
func (a *AuthRequest) GetUserName() string {
|
||||
return a.UserName
|
||||
}
|
||||
func (a *AuthRequest) Done() bool {
|
||||
for _, step := range a.PossibleSteps {
|
||||
if step.Type() == domain.NextStepRedirectToCallback {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AuthRequestFromBusiness(authReq *domain.AuthRequest) (_ models.AuthRequestInt, err error) {
|
||||
if _, ok := authReq.Request.(*domain.AuthRequestSAML); !ok {
|
||||
|
201
internal/api/ui/login/device_auth.go
Normal file
201
internal/api/ui/login/device_auth.go
Normal file
@ -0,0 +1,201 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
errs "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
tmplDeviceAuthUserCode = "device-usercode"
|
||||
tmplDeviceAuthAction = "device-action"
|
||||
)
|
||||
|
||||
func (l *Login) renderDeviceAuthUserCode(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var errID, errMessage string
|
||||
if err != nil {
|
||||
logging.WithError(err).Error()
|
||||
errID, errMessage = l.getErrorMessage(r, err)
|
||||
}
|
||||
|
||||
data := l.getBaseData(r, nil, "DeviceAuth.Title", "DeviceAuth.UserCode.Description", errID, errMessage)
|
||||
translator := l.getTranslator(r.Context(), nil)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthUserCode], data, nil)
|
||||
}
|
||||
|
||||
func (l *Login) renderDeviceAuthAction(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, scopes []string) {
|
||||
data := &struct {
|
||||
baseData
|
||||
AuthRequestID string
|
||||
Username string
|
||||
ClientID string
|
||||
Scopes []string
|
||||
}{
|
||||
baseData: l.getBaseData(r, authReq, "DeviceAuth.Title", "DeviceAuth.Action.Description", "", ""),
|
||||
AuthRequestID: authReq.ID,
|
||||
Username: authReq.UserName,
|
||||
ClientID: authReq.ApplicationID,
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
translator := l.getTranslator(r.Context(), authReq)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthAction], data, nil)
|
||||
}
|
||||
|
||||
const (
|
||||
deviceAuthAllowed = "allowed"
|
||||
deviceAuthDenied = "denied"
|
||||
)
|
||||
|
||||
// renderDeviceAuthDone renders success.html when the action was allowed and error.html when it was denied.
|
||||
func (l *Login) renderDeviceAuthDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, action string) {
|
||||
data := &struct {
|
||||
baseData
|
||||
Message string
|
||||
}{
|
||||
baseData: l.getBaseData(r, authReq, "DeviceAuth.Title", "DeviceAuth.Done.Description", "", ""),
|
||||
}
|
||||
|
||||
translator := l.getTranslator(r.Context(), authReq)
|
||||
switch action {
|
||||
case deviceAuthAllowed:
|
||||
data.Message = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Approved", nil)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplSuccess], data, nil)
|
||||
case deviceAuthDenied:
|
||||
data.ErrMessage = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Denied", nil)
|
||||
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplError], data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeviceUserCode serves the Device Authorization user code submission form.
|
||||
// The "user_code" may be submitted by URL (GET) or form (POST).
|
||||
// When a "user_code" is received and found through query,
|
||||
// handleDeviceAuthUserCode will create a new AuthRequest in the repository.
|
||||
// The user is then redirected to the /login endpoint to complete authentication.
|
||||
//
|
||||
// The agent ID from the context is set to the authentication request
|
||||
// to ensure the complete login flow is completed from the same browser.
|
||||
func (l *Login) handleDeviceAuthUserCode(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
userCode := r.Form.Get("user_code")
|
||||
if userCode == "" {
|
||||
if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" {
|
||||
err = errs.New(prompt)
|
||||
}
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
deviceAuth, err := l.query.DeviceAuthByUserCode(ctx, userCode)
|
||||
if err != nil {
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
|
||||
if !ok {
|
||||
l.renderDeviceAuthUserCode(w, r, errs.New("internal error: agent ID missing"))
|
||||
return
|
||||
}
|
||||
authRequest, err := l.authRepo.CreateAuthRequest(ctx, &domain.AuthRequest{
|
||||
CreationDate: time.Now(),
|
||||
AgentID: userAgentID,
|
||||
ApplicationID: deviceAuth.ClientID,
|
||||
InstanceID: authz.GetInstance(ctx).InstanceID(),
|
||||
Request: &domain.AuthRequestDevice{
|
||||
ID: deviceAuth.AggregateID,
|
||||
DeviceCode: deviceAuth.DeviceCode,
|
||||
UserCode: deviceAuth.UserCode,
|
||||
Scopes: deviceAuth.Scopes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
l.renderDeviceAuthUserCode(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, l.renderer.pathPrefix+EndpointLogin+"?authRequestID="+authRequest.ID, http.StatusFound)
|
||||
}
|
||||
|
||||
// redirectDeviceAuthStart redirects the user to the start point of
|
||||
// the device authorization flow. A prompt can be set to inform the user
|
||||
// of the reason why they are redirected back.
|
||||
func (l *Login) redirectDeviceAuthStart(w http.ResponseWriter, r *http.Request, prompt string) {
|
||||
values := make(url.Values)
|
||||
values.Set("prompt", url.QueryEscape(prompt))
|
||||
|
||||
url := url.URL{
|
||||
Path: l.renderer.pathPrefix + EndpointDeviceAuth,
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
http.Redirect(w, r, url.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleDeviceAuthAction is the handler where the user is redirected after login.
|
||||
// The authRequest is checked if the login was indeed completed.
|
||||
// When the action of "allowed" or "denied", the device authorization is updated accordingly.
|
||||
// Else the user is presented with a page where they can choose / submit either action.
|
||||
func (l *Login) handleDeviceAuthAction(w http.ResponseWriter, r *http.Request) {
|
||||
authReq, err := l.getAuthRequest(r)
|
||||
if authReq == nil {
|
||||
err = errors.ThrowInvalidArgument(err, "LOGIN-OLah8", "invalid or missing auth request")
|
||||
l.redirectDeviceAuthStart(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if !authReq.Done() {
|
||||
l.redirectDeviceAuthStart(w, r, "authentication not completed")
|
||||
return
|
||||
}
|
||||
authDev, ok := authReq.Request.(*domain.AuthRequestDevice)
|
||||
if !ok {
|
||||
l.redirectDeviceAuthStart(w, r, fmt.Sprintf("wrong auth request type: %T", authReq.Request))
|
||||
return
|
||||
}
|
||||
|
||||
action := mux.Vars(r)["action"]
|
||||
switch action {
|
||||
case deviceAuthAllowed:
|
||||
_, err = l.command.ApproveDeviceAuth(r.Context(), authDev.ID, authReq.UserID)
|
||||
case deviceAuthDenied:
|
||||
_, err = l.command.CancelDeviceAuth(r.Context(), authDev.ID, domain.DeviceAuthCanceledDenied)
|
||||
default:
|
||||
l.renderDeviceAuthAction(w, r, authReq, authDev.Scopes)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
l.redirectDeviceAuthStart(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
l.renderDeviceAuthDone(w, r, authReq, action)
|
||||
}
|
||||
|
||||
// deviceAuthCallbackURL creates the callback URL with which the user
|
||||
// is redirected back to the device authorization flow.
|
||||
func (l *Login) deviceAuthCallbackURL(authRequestID string) string {
|
||||
return l.renderer.pathPrefix + EndpointDeviceAuthAction + "?authRequestID=" + authRequestID
|
||||
}
|
||||
|
||||
// RedirectDeviceAuthToPrefix allows users to use https://domain.com/device without the /ui/login prefix
|
||||
// and redirects them to the prefixed endpoint.
|
||||
// [rfc 8628](https://www.rfc-editor.org/rfc/rfc8628#section-3.2) recommends the URL to be as short as possible.
|
||||
func RedirectDeviceAuthToPrefix(w http.ResponseWriter, r *http.Request) {
|
||||
target := gu.PtrCopy(r.URL)
|
||||
target.Path = HandlerPrefix + EndpointDeviceAuth
|
||||
http.Redirect(w, r, target.String(), http.StatusFound)
|
||||
}
|
@ -69,6 +69,8 @@ func (l *Login) authRequestCallback(ctx context.Context, authReq *domain.AuthReq
|
||||
return l.oidcAuthCallbackURL(ctx, authReq.ID), nil
|
||||
case *domain.AuthRequestSAML:
|
||||
return l.samlAuthCallbackURL(ctx, authReq.ID), nil
|
||||
case *domain.AuthRequestDevice:
|
||||
return l.deviceAuthCallbackURL(authReq.ID), nil
|
||||
default:
|
||||
return "", caos_errs.ThrowInternal(nil, "LOGIN-rhjQF", "Errors.AuthRequest.RequestTypeNotSupported")
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ import (
|
||||
|
||||
const (
|
||||
tmplError = "error"
|
||||
tmplSuccess = "success"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
@ -45,6 +46,7 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
|
||||
}
|
||||
tmplMapping := map[string]string{
|
||||
tmplError: "error.html",
|
||||
tmplSuccess: "success.html",
|
||||
tmplLogin: "login.html",
|
||||
tmplUserSelection: "select_user.html",
|
||||
tmplPassword: "password.html",
|
||||
@ -77,6 +79,8 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
|
||||
tmplExternalNotFoundOption: "external_not_found_option.html",
|
||||
tmplLoginSuccess: "login_success.html",
|
||||
tmplLDAPLogin: "ldap_login.html",
|
||||
tmplDeviceAuthUserCode: "device_usercode.html",
|
||||
tmplDeviceAuthAction: "device_action.html",
|
||||
}
|
||||
funcs := map[string]interface{}{
|
||||
"resourceUrl": func(file string) string {
|
||||
@ -323,6 +327,7 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq *
|
||||
func (l *Login) renderInternalError(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) {
|
||||
var msg string
|
||||
if err != nil {
|
||||
logging.WithError(err).WithField("auth_req_id", authReq.ID).Error()
|
||||
_, msg = l.getErrorMessage(r, err)
|
||||
}
|
||||
data := l.getBaseData(r, authReq, "Errors.Internal", "", "Internal", msg)
|
||||
|
@ -46,6 +46,9 @@ const (
|
||||
|
||||
EndpointResources = "/resources"
|
||||
EndpointDynamicResources = "/resources/dynamic"
|
||||
|
||||
EndpointDeviceAuth = "/device"
|
||||
EndpointDeviceAuthAction = "/device/{action}"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -107,5 +110,7 @@ func CreateRouter(login *Login, staticDir http.FileSystem, interceptors ...mux.M
|
||||
router.HandleFunc(EndpointLDAPLogin, login.handleLDAP).Methods(http.MethodGet)
|
||||
router.HandleFunc(EndpointLDAPCallback, login.handleLDAPCallback).Methods(http.MethodPost)
|
||||
router.SkipClean(true).Handle("", http.RedirectHandler(HandlerPrefix+"/", http.StatusMovedPermanently))
|
||||
router.HandleFunc(EndpointDeviceAuth, login.handleDeviceAuthUserCode).Methods(http.MethodGet, http.MethodPost)
|
||||
router.HandleFunc(EndpointDeviceAuthAction, login.handleDeviceAuthAction).Methods(http.MethodGet, http.MethodPost)
|
||||
return router
|
||||
}
|
||||
|
@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Geräteautorisierung
|
||||
UserCode:
|
||||
Label: Benutzercode
|
||||
Description: Geben Sie den auf dem Gerät angezeigten Benutzercode ein
|
||||
ButtonNext: weiter
|
||||
Action:
|
||||
Description: Gerätezugriff erlauben
|
||||
GrantDevice: Sie sind dabei, das Gerät zu erlauben
|
||||
AccessToScopes: Zugriff auf die folgenden Daten
|
||||
Button:
|
||||
Allow: erlauben
|
||||
Deny: verweigern
|
||||
Done:
|
||||
Description: Abgeschlossen
|
||||
Approved: Gerätezulassung genehmigt. Sie können jetzt zum Gerät zurückkehren.
|
||||
Denied: Geräteautorisierung verweigert. Sie können jetzt zum Gerät zurückkehren.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: AGB
|
||||
@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: Registrierung ist nicht erlaubt
|
||||
DeviceAuth:
|
||||
NotExisting: Benutzercode existiert nicht
|
||||
|
||||
optional: (optional)
|
||||
|
@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Device Authorization
|
||||
UserCode:
|
||||
Label: User Code
|
||||
Description: Enter the user code presented on the device.
|
||||
ButtonNext: next
|
||||
Action:
|
||||
Description: Grant device access.
|
||||
GrantDevice: you are about to grant device
|
||||
AccessToScopes: access to the following scopes
|
||||
Button:
|
||||
Allow: allow
|
||||
Deny: deny
|
||||
Done:
|
||||
Description: Done.
|
||||
Approved: Device authorization approved. You can now return to the device.
|
||||
Denied: Device authorization denied. You can now return to the device.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: TOS
|
||||
@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: Registration is not allowed
|
||||
DeviceAuth:
|
||||
NotExisting: User Code doesn't exist
|
||||
|
||||
optional: (optional)
|
||||
|
@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Autorisation de l'appareil
|
||||
UserCode:
|
||||
Label: Code d'utilisateur
|
||||
Description: Saisissez le code utilisateur présenté sur l'appareil.
|
||||
ButtonNext: suivant
|
||||
Action:
|
||||
Description: Accordez l'accès à l'appareil.
|
||||
GrantDevice: vous êtes sur le point d'accorder un appareil
|
||||
AccessToScopes: accès aux périmètres suivants
|
||||
Button:
|
||||
Allow: permettre
|
||||
Deny: refuser
|
||||
Done:
|
||||
Description: Fait.
|
||||
Approved: Autorisation de l'appareil approuvée. Vous pouvez maintenant retourner à l'appareil.
|
||||
Denied: Autorisation de l'appareil refusée. Vous pouvez maintenant retourner à l'appareil.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Promulgué par
|
||||
Tos: TOS
|
||||
@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: L'enregistrement n'est pas autorisé
|
||||
DeviceAuth:
|
||||
NotExisting: Le code utilisateur n'existe pas
|
||||
|
||||
optional: (facultatif)
|
||||
|
@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Autorizzazione del dispositivo
|
||||
UserCode:
|
||||
Label: Codice utente
|
||||
Description: Inserire il codice utente presentato sul dispositivo.
|
||||
ButtonNext: prossimo
|
||||
Action:
|
||||
Description: Concedi l'accesso al dispositivo.
|
||||
GrantDevice: stai per concedere il dispositivo
|
||||
AccessToScopes: accesso ai seguenti ambiti
|
||||
Button:
|
||||
Allow: permettere
|
||||
Deny: negare
|
||||
Done:
|
||||
Description: Fatto.
|
||||
Approved: Autorizzazione del dispositivo approvata. Ora puoi tornare al dispositivo.
|
||||
Denied: Autorizzazione dispositivo negata. Ora puoi tornare al dispositivo.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Alimentato da
|
||||
Tos: Termini di servizio
|
||||
@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: la registrazione non è consentita.
|
||||
DeviceAuth:
|
||||
NotExisting: Il codice utente non esiste
|
||||
|
||||
optional: (opzionale)
|
||||
|
@ -309,6 +309,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: デバイス認証
|
||||
UserCode:
|
||||
Label: ユーザーコード
|
||||
Description: デバイスに表示されたユーザー コードを入力します。
|
||||
ButtonNext: 次
|
||||
Action:
|
||||
Description: デバイスへのアクセスを許可します。
|
||||
GrantDevice: デバイスを許可しようとしています
|
||||
AccessToScopes: 次のスコープへのアクセス
|
||||
Button:
|
||||
Allow: 許可する
|
||||
Deny: 拒否
|
||||
Done:
|
||||
Description: 終わり。
|
||||
Approved: デバイス認証が承認されました。 これで、デバイスに戻ることができます。
|
||||
Denied: デバイス認証が拒否されました。 これで、デバイスに戻ることができます。
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: TOS
|
||||
@ -385,5 +403,7 @@ Errors:
|
||||
IAM:
|
||||
LockoutPolicy:
|
||||
NotExisting: ロックアウトポリシーが存在しません
|
||||
DeviceAuth:
|
||||
NotExisting: ユーザーコードが存在しません
|
||||
|
||||
optional: "(オプション)"
|
||||
|
@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: Autoryzacja urządzenia
|
||||
UserCode:
|
||||
Label: Kod użytkownika
|
||||
Description: Wprowadź kod użytkownika prezentowany na urządzeniu.
|
||||
ButtonNext: Następny
|
||||
Action:
|
||||
Description: Przyznaj dostęp do urządzenia.
|
||||
GrantDevice: zamierzasz przyznać urządzenie
|
||||
AccessToScopes: dostęp do następujących zakresów
|
||||
Button:
|
||||
Allow: umożliwić
|
||||
Deny: zaprzeczyć
|
||||
Done:
|
||||
Description: Zrobione.
|
||||
Approved: Zatwierdzono autoryzację urządzenia. Możesz teraz wrócić do urządzenia.
|
||||
Denied: Odmowa autoryzacji urządzenia. Możesz teraz wrócić do urządzenia.
|
||||
|
||||
Footer:
|
||||
PoweredBy: Obsługiwane przez
|
||||
Tos: TOS
|
||||
@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: Rejestracja nie jest dozwolona
|
||||
DeviceAuth:
|
||||
NotExisting: Kod użytkownika nie istnieje
|
||||
|
||||
optional: (opcjonalny)
|
||||
|
@ -317,6 +317,24 @@ ExternalNotFound:
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
DeviceAuth:
|
||||
Title: 设备授权
|
||||
UserCode:
|
||||
Label: 用户代码
|
||||
Description: 输入设备上显示的用户代码。
|
||||
ButtonNext: 下一个
|
||||
Action:
|
||||
Description: 授予设备访问权限。
|
||||
GrantDevice: 您即将授予设备
|
||||
AccessToScopes: 访问以下范围
|
||||
Button:
|
||||
Allow: 允许
|
||||
Deny: 否定
|
||||
Done:
|
||||
Description: 完毕。
|
||||
Approved: 设备授权已批准。 您现在可以返回设备。
|
||||
Denied: 设备授权被拒绝。 您现在可以返回设备。
|
||||
|
||||
Footer:
|
||||
PoweredBy: Powered By
|
||||
Tos: 服务条款
|
||||
@ -425,5 +443,7 @@ Errors:
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: 不允许注册
|
||||
DeviceAuth:
|
||||
NotExisting: 用户代码不存在
|
||||
|
||||
optional: (可选)
|
||||
|
18
internal/api/ui/login/static/templates/device_action.html
Normal file
18
internal/api/ui/login/static/templates/device_action.html
Normal file
@ -0,0 +1,18 @@
|
||||
{{template "main-top" .}}
|
||||
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>
|
||||
{{.Username}}, {{t "DeviceAuth.Action.GrantDevice"}} {{.ClientID}} {{t "DeviceAuth.Action.AccessToScopes"}}: {{.Scopes}}.
|
||||
</p>
|
||||
<form method="POST">
|
||||
{{ .CSRF }}
|
||||
<input type="hidden" name="authRequestID" value="{{.AuthRequestID}}">
|
||||
<button class="lgn-raised-button lgn-primary left" type="submit" formaction="./allowed">
|
||||
{{t "DeviceAuth.Action.Button.Allow"}}
|
||||
</button>
|
||||
<button class="lgn-raised-button lgn-warn right" type="submit" formaction="./denied">
|
||||
{{t "DeviceAuth.Action.Button.Deny"}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{template "main-bottom" .}}
|
21
internal/api/ui/login/static/templates/device_usercode.html
Normal file
21
internal/api/ui/login/static/templates/device_usercode.html
Normal file
@ -0,0 +1,21 @@
|
||||
{{template "main-top" .}}
|
||||
|
||||
<h1>{{.Title}}</h1>
|
||||
<form method="POST">
|
||||
|
||||
{{ .CSRF }}
|
||||
|
||||
<div class="fields">
|
||||
<label class="lgn-label" for="user_code">{{t "DeviceAuth.UserCode.Label"}}</label>
|
||||
<input class="lgn-input" id="user_code" name="user_code" autofocus required{{if .ErrMessage}} shake{{end}}>
|
||||
</div>
|
||||
|
||||
{{template "error-message" .}}
|
||||
|
||||
<div class="lgn-actions">
|
||||
<span class="fill-space"></span>
|
||||
<button id="submit-button" class="lgn-raised-button lgn-primary right" type="submit">{{t "DeviceAuth.UserCode.ButtonNext"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{template "main-bottom" .}}
|
12
internal/api/ui/login/static/templates/success.html
Normal file
12
internal/api/ui/login/static/templates/success.html
Normal file
@ -0,0 +1,12 @@
|
||||
{{template "main-top" .}}
|
||||
|
||||
<div class="lgn-head">
|
||||
<div class="lgn-actions">
|
||||
<i class="lgn-icon-check-solid lgn-primary"></i>
|
||||
<p class="lgn-error-message">
|
||||
{{ .Message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "main-bottom" .}}
|
@ -1,3 +1,3 @@
|
||||
package statik
|
||||
|
||||
//go:generate statik -src=../static -dest=.. -ns=login
|
||||
//go:generate statik -f -src=../static -dest=.. -ns=login
|
||||
|
@ -1446,7 +1446,7 @@ func linkingIDPConfigExistingInAllowedIDPs(linkingUsers []*domain.ExternalUser,
|
||||
func userGrantRequired(ctx context.Context, request *domain.AuthRequest, user *user_model.UserView, userGrantProvider userGrantProvider) (_ bool, err error) {
|
||||
var project *query.Project
|
||||
switch request.Request.Type() {
|
||||
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML:
|
||||
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML, domain.AuthRequestTypeDevice:
|
||||
project, err = userGrantProvider.ProjectByClientID(ctx, request.ApplicationID, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@ -1467,13 +1467,13 @@ func userGrantRequired(ctx context.Context, request *domain.AuthRequest, user *u
|
||||
func projectRequired(ctx context.Context, request *domain.AuthRequest, projectProvider projectProvider) (missingGrant bool, err error) {
|
||||
var project *query.Project
|
||||
switch request.Request.Type() {
|
||||
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML:
|
||||
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML, domain.AuthRequestTypeDevice:
|
||||
project, err = projectProvider.ProjectByClientID(ctx, request.ApplicationID, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
default:
|
||||
return false, errors.ThrowPreconditionFailed(nil, "EVENT-dfrw2", "Errors.AuthRequest.RequestTypeNotSupported")
|
||||
return false, errors.ThrowPreconditionFailed(nil, "EVENT-ku4He", "Errors.AuthRequest.RequestTypeNotSupported")
|
||||
}
|
||||
// if the user and project are part of the same organisation we do not need to check if the project exists on that org
|
||||
if !project.HasProjectCheck || project.ResourceOwner == request.UserOrgID {
|
||||
|
113
internal/command/device_auth.go
Normal file
113
internal/command/device_auth.go
Normal file
@ -0,0 +1,113 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/deviceauth"
|
||||
)
|
||||
|
||||
func (c *Commands) AddDeviceAuth(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) (string, *domain.ObjectDetails, error) {
|
||||
aggrID, err := c.idGenerator.Next()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
aggr := deviceauth.NewAggregate(aggrID, authz.GetInstance(ctx).InstanceID())
|
||||
model := NewDeviceAuthWriteModel(aggrID, aggr.ResourceOwner)
|
||||
|
||||
pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
aggr,
|
||||
clientID,
|
||||
deviceCode,
|
||||
userCode,
|
||||
expires,
|
||||
scopes,
|
||||
))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
err = AppendAndReduce(model, pushedEvents...)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return model.AggregateID, writeModelToObjectDetails(&model.WriteModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) ApproveDeviceAuth(ctx context.Context, id, subject string) (*domain.ObjectDetails, error) {
|
||||
model, err := c.getDeviceAuthWriteModelByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !model.State.Exists() {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound")
|
||||
}
|
||||
aggr := deviceauth.NewAggregate(model.AggregateID, model.InstanceID)
|
||||
|
||||
pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, aggr, subject))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = AppendAndReduce(model, pushedEvents...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return writeModelToObjectDetails(&model.WriteModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) CancelDeviceAuth(ctx context.Context, id string, reason domain.DeviceAuthCanceled) (*domain.ObjectDetails, error) {
|
||||
model, err := c.getDeviceAuthWriteModelByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !model.State.Exists() {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-gee5A", "Errors.DeviceAuth.NotFound")
|
||||
}
|
||||
aggr := deviceauth.NewAggregate(model.AggregateID, model.InstanceID)
|
||||
|
||||
pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewCanceledEvent(ctx, aggr, reason))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = AppendAndReduce(model, pushedEvents...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return writeModelToObjectDetails(&model.WriteModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) RemoveDeviceAuth(ctx context.Context, id string) (*domain.ObjectDetails, error) {
|
||||
model, err := c.getDeviceAuthWriteModelByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aggr := deviceauth.NewAggregate(model.AggregateID, model.InstanceID)
|
||||
|
||||
pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewRemovedEvent(ctx, aggr, model.ClientID, model.DeviceCode, model.UserCode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = AppendAndReduce(model, pushedEvents...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return writeModelToObjectDetails(&model.WriteModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) getDeviceAuthWriteModelByID(ctx context.Context, id string) (*DeviceAuthWriteModel, error) {
|
||||
model := &DeviceAuthWriteModel{WriteModel: eventstore.WriteModel{AggregateID: id}}
|
||||
err := c.eventstore.FilterToQueryReducer(ctx, model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return model, nil
|
||||
}
|
61
internal/command/device_auth_model.go
Normal file
61
internal/command/device_auth_model.go
Normal file
@ -0,0 +1,61 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/deviceauth"
|
||||
)
|
||||
|
||||
type DeviceAuthWriteModel struct {
|
||||
eventstore.WriteModel
|
||||
|
||||
ClientID string
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
Expires time.Time
|
||||
Scopes []string
|
||||
Subject string
|
||||
State domain.DeviceAuthState
|
||||
}
|
||||
|
||||
func NewDeviceAuthWriteModel(aggrID, resourceOwner string) *DeviceAuthWriteModel {
|
||||
return &DeviceAuthWriteModel{
|
||||
WriteModel: eventstore.WriteModel{
|
||||
AggregateID: aggrID,
|
||||
ResourceOwner: resourceOwner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DeviceAuthWriteModel) Reduce() error {
|
||||
for _, event := range m.Events {
|
||||
switch e := event.(type) {
|
||||
case *deviceauth.AddedEvent:
|
||||
m.ClientID = e.ClientID
|
||||
m.DeviceCode = e.DeviceCode
|
||||
m.UserCode = e.UserCode
|
||||
m.Expires = e.Expires
|
||||
m.Scopes = e.Scopes
|
||||
m.State = e.State
|
||||
case *deviceauth.ApprovedEvent:
|
||||
m.Subject = e.Subject
|
||||
m.State = domain.DeviceAuthStateApproved
|
||||
case *deviceauth.CanceledEvent:
|
||||
m.State = e.Reason.State()
|
||||
case *deviceauth.RemovedEvent:
|
||||
m.State = domain.DeviceAuthStateRemoved
|
||||
}
|
||||
}
|
||||
|
||||
return m.WriteModel.Reduce()
|
||||
}
|
||||
|
||||
func (m *DeviceAuthWriteModel) Query() *eventstore.SearchQueryBuilder {
|
||||
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||
AddQuery().
|
||||
AggregateTypes(deviceauth.AggregateType).
|
||||
AggregateIDs(m.AggregateID).
|
||||
Builder()
|
||||
}
|
481
internal/command/device_auth_test.go
Normal file
481
internal/command/device_auth_test.go
Normal file
@ -0,0 +1,481 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/repository"
|
||||
"github.com/zitadel/zitadel/internal/id"
|
||||
id_mock "github.com/zitadel/zitadel/internal/id/mock"
|
||||
"github.com/zitadel/zitadel/internal/repository/deviceauth"
|
||||
)
|
||||
|
||||
func TestCommands_AddDeviceAuth(t *testing.T) {
|
||||
ctx := authz.WithInstanceID(context.Background(), "instance1")
|
||||
idErr := errors.New("idErr")
|
||||
pushErr := errors.New("pushErr")
|
||||
now := time.Now()
|
||||
|
||||
unique := deviceauth.NewAddUniqueConstraints("client_id", "123", "456")
|
||||
require.Len(t, unique, 2)
|
||||
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
idGenerator id.Generator
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
clientID string
|
||||
deviceCode string
|
||||
userCode string
|
||||
expires time.Time
|
||||
scopes []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantID string
|
||||
wantDetails *domain.ObjectDetails
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "idGenerator error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t),
|
||||
idGenerator: func() id.Generator {
|
||||
m := id_mock.NewMockGenerator(gomock.NewController(t))
|
||||
m.EXPECT().Next().Return("", idErr)
|
||||
return m
|
||||
}(),
|
||||
},
|
||||
args: args{
|
||||
ctx: ctx,
|
||||
clientID: "client_id",
|
||||
deviceCode: "123",
|
||||
userCode: "456",
|
||||
expires: now,
|
||||
scopes: []string{"a", "b", "c"},
|
||||
},
|
||||
wantErr: idErr,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t, expectPush(
|
||||
[]*repository.Event{
|
||||
eventFromEventPusherWithInstanceID("instance1", deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
)),
|
||||
},
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[0]),
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[1]),
|
||||
)),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "1999"),
|
||||
},
|
||||
args: args{
|
||||
ctx: authz.WithInstanceID(context.Background(), "instance1"),
|
||||
clientID: "client_id",
|
||||
deviceCode: "123",
|
||||
userCode: "456",
|
||||
expires: now,
|
||||
scopes: []string{"a", "b", "c"},
|
||||
},
|
||||
wantID: "1999",
|
||||
wantDetails: &domain.ObjectDetails{
|
||||
ResourceOwner: "instance1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t, expectPushFailed(pushErr,
|
||||
[]*repository.Event{
|
||||
eventFromEventPusherWithInstanceID("instance1", deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
)),
|
||||
},
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[0]),
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[1]),
|
||||
)),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "1999"),
|
||||
},
|
||||
args: args{
|
||||
ctx: authz.WithInstanceID(context.Background(), "instance1"),
|
||||
clientID: "client_id",
|
||||
deviceCode: "123",
|
||||
userCode: "456",
|
||||
expires: now,
|
||||
scopes: []string{"a", "b", "c"},
|
||||
},
|
||||
wantErr: pushErr,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
idGenerator: tt.fields.idGenerator,
|
||||
}
|
||||
gotID, gotDetails, err := c.AddDeviceAuth(tt.args.ctx, tt.args.clientID, tt.args.deviceCode, tt.args.userCode, tt.args.expires, tt.args.scopes)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, gotID, tt.wantID)
|
||||
assert.Equal(t, gotDetails, tt.wantDetails)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands_ApproveDeviceAuth(t *testing.T) {
|
||||
ctx := authz.WithInstanceID(context.Background(), "instance1")
|
||||
now := time.Now()
|
||||
pushErr := errors.New("pushErr")
|
||||
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
id string
|
||||
subject string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantDetails *domain.ObjectDetails
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "not found error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusherWithInstanceID("instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
),
|
||||
eventFromEventPusherWithInstanceID("instance1",
|
||||
deviceauth.NewRemovedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999", "subj"},
|
||||
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound"),
|
||||
},
|
||||
{
|
||||
name: "push error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusherWithInstanceID(
|
||||
"instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
)),
|
||||
expectPushFailed(pushErr,
|
||||
[]*repository.Event{eventFromEventPusherWithInstanceID(
|
||||
"instance1", deviceauth.NewApprovedEvent(
|
||||
ctx, deviceauth.NewAggregate("1999", "instance1"), "subj",
|
||||
),
|
||||
)},
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999", "subj"},
|
||||
wantErr: pushErr,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusherWithInstanceID(
|
||||
"instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
)),
|
||||
expectPush([]*repository.Event{eventFromEventPusherWithInstanceID(
|
||||
"instance1", deviceauth.NewApprovedEvent(
|
||||
ctx, deviceauth.NewAggregate("1999", "instance1"), "subj",
|
||||
),
|
||||
)}),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999", "subj"},
|
||||
wantDetails: &domain.ObjectDetails{
|
||||
ResourceOwner: "instance1",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
}
|
||||
gotDetails, err := c.ApproveDeviceAuth(tt.args.ctx, tt.args.id, tt.args.subject)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, gotDetails, tt.wantDetails)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands_CancelDeviceAuth(t *testing.T) {
|
||||
ctx := authz.WithInstanceID(context.Background(), "instance1")
|
||||
now := time.Now()
|
||||
pushErr := errors.New("pushErr")
|
||||
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
id string
|
||||
reason domain.DeviceAuthCanceled
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantDetails *domain.ObjectDetails
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "not found error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusherWithInstanceID("instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
),
|
||||
eventFromEventPusherWithInstanceID("instance1",
|
||||
deviceauth.NewRemovedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999", domain.DeviceAuthCanceledDenied},
|
||||
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-gee5A", "Errors.DeviceAuth.NotFound"),
|
||||
},
|
||||
{
|
||||
name: "push error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusherWithInstanceID(
|
||||
"instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
)),
|
||||
expectPushFailed(pushErr,
|
||||
[]*repository.Event{eventFromEventPusherWithInstanceID(
|
||||
"instance1", deviceauth.NewCanceledEvent(
|
||||
ctx, deviceauth.NewAggregate("1999", "instance1"),
|
||||
domain.DeviceAuthCanceledDenied,
|
||||
),
|
||||
)},
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999", domain.DeviceAuthCanceledDenied},
|
||||
wantErr: pushErr,
|
||||
},
|
||||
{
|
||||
name: "success/denied",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusherWithInstanceID(
|
||||
"instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
)),
|
||||
expectPush([]*repository.Event{eventFromEventPusherWithInstanceID(
|
||||
"instance1", deviceauth.NewCanceledEvent(
|
||||
ctx, deviceauth.NewAggregate("1999", "instance1"),
|
||||
domain.DeviceAuthCanceledDenied,
|
||||
),
|
||||
)}),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999", domain.DeviceAuthCanceledDenied},
|
||||
wantDetails: &domain.ObjectDetails{
|
||||
ResourceOwner: "instance1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success/expired",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusherWithInstanceID(
|
||||
"instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
)),
|
||||
expectPush([]*repository.Event{eventFromEventPusherWithInstanceID(
|
||||
"instance1", deviceauth.NewCanceledEvent(
|
||||
ctx, deviceauth.NewAggregate("1999", "instance1"),
|
||||
domain.DeviceAuthCanceledExpired,
|
||||
),
|
||||
)}),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999", domain.DeviceAuthCanceledExpired},
|
||||
wantDetails: &domain.ObjectDetails{
|
||||
ResourceOwner: "instance1",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
}
|
||||
gotDetails, err := c.CancelDeviceAuth(tt.args.ctx, tt.args.id, tt.args.reason)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, gotDetails, tt.wantDetails)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands_RemoveDeviceAuth(t *testing.T) {
|
||||
ctx := authz.WithInstanceID(context.Background(), "instance1")
|
||||
now := time.Now()
|
||||
pushErr := errors.New("pushErr")
|
||||
|
||||
unique := deviceauth.NewRemoveUniqueConstraints("client_id", "123", "456")
|
||||
require.Len(t, unique, 2)
|
||||
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
id string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantDetails *domain.ObjectDetails
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "push error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusherWithInstanceID(
|
||||
"instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
)),
|
||||
expectPushFailed(pushErr,
|
||||
[]*repository.Event{eventFromEventPusherWithInstanceID(
|
||||
"instance1", deviceauth.NewRemovedEvent(
|
||||
ctx, deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456",
|
||||
),
|
||||
)},
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[0]),
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[1]),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999"},
|
||||
wantErr: pushErr,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusherWithInstanceID(
|
||||
"instance1",
|
||||
deviceauth.NewAddedEvent(
|
||||
ctx,
|
||||
deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456", now,
|
||||
[]string{"a", "b", "c"},
|
||||
),
|
||||
)),
|
||||
expectPush(
|
||||
[]*repository.Event{eventFromEventPusherWithInstanceID(
|
||||
"instance1", deviceauth.NewRemovedEvent(
|
||||
ctx, deviceauth.NewAggregate("1999", "instance1"),
|
||||
"client_id", "123", "456",
|
||||
),
|
||||
)},
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[0]),
|
||||
uniqueConstraintsFromEventConstraintWithInstanceID("instance1", unique[1]),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{ctx, "1999"},
|
||||
wantDetails: &domain.ObjectDetails{
|
||||
ResourceOwner: "instance1",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
}
|
||||
gotDetails, err := c.RemoveDeviceAuth(tt.args.ctx, tt.args.id)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, gotDetails, tt.wantDetails)
|
||||
})
|
||||
}
|
||||
}
|
@ -90,6 +90,7 @@ const (
|
||||
OIDCGrantTypeAuthorizationCode OIDCGrantType = iota
|
||||
OIDCGrantTypeImplicit
|
||||
OIDCGrantTypeRefreshToken
|
||||
OIDCGrantTypeDeviceCode
|
||||
)
|
||||
|
||||
type OIDCApplicationType int32
|
||||
|
@ -122,6 +122,8 @@ func NewAuthRequestFromType(requestType AuthRequestType) (*AuthRequest, error) {
|
||||
return &AuthRequest{Request: &AuthRequestOIDC{}}, nil
|
||||
case AuthRequestTypeSAML:
|
||||
return &AuthRequest{Request: &AuthRequestSAML{}}, nil
|
||||
case AuthRequestTypeDevice:
|
||||
return &AuthRequest{Request: &AuthRequestDevice{}}, nil
|
||||
}
|
||||
return nil, errors.ThrowInvalidArgument(nil, "DOMAIN-ds2kl", "invalid request type")
|
||||
}
|
||||
@ -184,3 +186,12 @@ func (a *AuthRequest) GetScopeOrgID() string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *AuthRequest) Done() bool {
|
||||
for _, step := range a.PossibleSteps {
|
||||
if step.Type() == NextStepRedirectToCallback {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
78
internal/domain/device_auth.go
Normal file
78
internal/domain/device_auth.go
Normal file
@ -0,0 +1,78 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
|
||||
)
|
||||
|
||||
// DeviceAuth describes a Device Authorization request.
|
||||
// It is used as input and output model in the command and query packages.
|
||||
type DeviceAuth struct {
|
||||
models.ObjectRoot
|
||||
|
||||
ClientID string
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
Expires time.Time
|
||||
Scopes []string
|
||||
Subject string
|
||||
State DeviceAuthState
|
||||
}
|
||||
|
||||
// DeviceAuthState describes the step the
|
||||
// the device authorization process is in.
|
||||
// We generate the Stringer implemntation for pretier
|
||||
// log output.
|
||||
//
|
||||
//go:generate stringer -type=DeviceAuthState -linecomment
|
||||
type DeviceAuthState uint
|
||||
|
||||
const (
|
||||
DeviceAuthStateUndefined DeviceAuthState = iota // undefined
|
||||
DeviceAuthStateInitiated // initiated
|
||||
DeviceAuthStateApproved // approved
|
||||
DeviceAuthStateDenied // denied
|
||||
DeviceAuthStateExpired // expired
|
||||
DeviceAuthStateRemoved // removed
|
||||
)
|
||||
|
||||
// Exists returns true when not Undefined and
|
||||
// any status lower than Removed.
|
||||
func (s DeviceAuthState) Exists() bool {
|
||||
return s > DeviceAuthStateUndefined && s < DeviceAuthStateRemoved
|
||||
}
|
||||
|
||||
// Done returns true when DeviceAuthState is Approved.
|
||||
// This implements the OIDC interface requirement of "Done"
|
||||
func (s DeviceAuthState) Done() bool {
|
||||
return s == DeviceAuthStateApproved
|
||||
}
|
||||
|
||||
// Denied returns true when DeviceAuthState is Denied, Expired or Removed.
|
||||
// This implements the OIDC interface requirement of "Denied".
|
||||
func (s DeviceAuthState) Denied() bool {
|
||||
return s >= DeviceAuthStateDenied
|
||||
}
|
||||
|
||||
// DeviceAuthCanceled is a subset of DeviceAuthState, allowed to
|
||||
// be used in the deviceauth.CanceledEvent.
|
||||
// The string type is used to make the eventstore more readable
|
||||
// on the reason of cancelation.
|
||||
type DeviceAuthCanceled string
|
||||
|
||||
const (
|
||||
DeviceAuthCanceledDenied = "denied"
|
||||
DeviceAuthCanceledExpired = "expired"
|
||||
)
|
||||
|
||||
func (c DeviceAuthCanceled) State() DeviceAuthState {
|
||||
switch c {
|
||||
case DeviceAuthCanceledDenied:
|
||||
return DeviceAuthStateDenied
|
||||
case DeviceAuthCanceledExpired:
|
||||
return DeviceAuthStateExpired
|
||||
default:
|
||||
return DeviceAuthStateUndefined
|
||||
}
|
||||
}
|
158
internal/domain/device_auth_test.go
Normal file
158
internal/domain/device_auth_test.go
Normal file
@ -0,0 +1,158 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceAuthState_Exists(t *testing.T) {
|
||||
tests := []struct {
|
||||
s DeviceAuthState
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
s: DeviceAuthStateUndefined,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateInitiated,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateApproved,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateDenied,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateExpired,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateRemoved,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.s.String(), func(t *testing.T) {
|
||||
if got := tt.s.Exists(); got != tt.want {
|
||||
t.Errorf("DeviceAuthState.Exists() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceAuthState_Done(t *testing.T) {
|
||||
tests := []struct {
|
||||
s DeviceAuthState
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
s: DeviceAuthStateUndefined,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateInitiated,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateApproved,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateDenied,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateExpired,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateRemoved,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.s.String(), func(t *testing.T) {
|
||||
if got := tt.s.Done(); got != tt.want {
|
||||
t.Errorf("DeviceAuthState.Done() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceAuthState_Denied(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s DeviceAuthState
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
s: DeviceAuthStateUndefined,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateInitiated,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateApproved,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateDenied,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateExpired,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
s: DeviceAuthStateRemoved,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.s.Denied(); got != tt.want {
|
||||
t.Errorf("DeviceAuthState.Denied() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceAuthCanceled_State(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
c DeviceAuthCanceled
|
||||
want DeviceAuthState
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
want: DeviceAuthStateUndefined,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
c: "foo",
|
||||
want: DeviceAuthStateUndefined,
|
||||
},
|
||||
{
|
||||
name: "denied",
|
||||
c: DeviceAuthCanceledDenied,
|
||||
want: DeviceAuthStateDenied,
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
c: DeviceAuthCanceledExpired,
|
||||
want: DeviceAuthStateExpired,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.c.State(); got != tt.want {
|
||||
t.Errorf("DeviceAuthCanceled.State() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
28
internal/domain/deviceauthstate_string.go
Normal file
28
internal/domain/deviceauthstate_string.go
Normal file
@ -0,0 +1,28 @@
|
||||
// Code generated by "stringer -type=DeviceAuthState -linecomment"; DO NOT EDIT.
|
||||
|
||||
package domain
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[DeviceAuthStateUndefined-0]
|
||||
_ = x[DeviceAuthStateInitiated-1]
|
||||
_ = x[DeviceAuthStateApproved-2]
|
||||
_ = x[DeviceAuthStateDenied-3]
|
||||
_ = x[DeviceAuthStateExpired-4]
|
||||
_ = x[DeviceAuthStateRemoved-5]
|
||||
}
|
||||
|
||||
const _DeviceAuthState_name = "undefinedinitiatedapproveddeniedexpiredremoved"
|
||||
|
||||
var _DeviceAuthState_index = [...]uint8{0, 9, 18, 26, 32, 39, 46}
|
||||
|
||||
func (i DeviceAuthState) String() string {
|
||||
if i >= DeviceAuthState(len(_DeviceAuthState_index)-1) {
|
||||
return "DeviceAuthState(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _DeviceAuthState_name[_DeviceAuthState_index[i]:_DeviceAuthState_index[i+1]]
|
||||
}
|
@ -22,6 +22,7 @@ type AuthRequestType int32
|
||||
const (
|
||||
AuthRequestTypeOIDC AuthRequestType = iota
|
||||
AuthRequestTypeSAML
|
||||
AuthRequestTypeDevice
|
||||
)
|
||||
|
||||
type AuthRequestOIDC struct {
|
||||
@ -56,3 +57,18 @@ func (a *AuthRequestSAML) Type() AuthRequestType {
|
||||
func (a *AuthRequestSAML) IsValid() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type AuthRequestDevice struct {
|
||||
ID string
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
func (*AuthRequestDevice) Type() AuthRequestType {
|
||||
return AuthRequestTypeDevice
|
||||
}
|
||||
|
||||
func (a *AuthRequestDevice) IsValid() bool {
|
||||
return a.DeviceCode != "" && a.UserCode != "" && len(a.Scopes) > 0
|
||||
}
|
||||
|
@ -304,3 +304,21 @@ func uniqueConstraintActionToRepository(action UniqueConstraintAction) repositor
|
||||
return repository.UniqueConstraintAdd
|
||||
}
|
||||
}
|
||||
|
||||
type BaseEventSetter[T any] interface {
|
||||
Event
|
||||
SetBaseEvent(*BaseEvent)
|
||||
*T
|
||||
}
|
||||
|
||||
func GenericEventMapper[T any, PT BaseEventSetter[T]](event *repository.Event) (Event, error) {
|
||||
e := PT(new(T))
|
||||
e.SetBaseEvent(BaseEventFromRepo(event))
|
||||
|
||||
err := json.Unmarshal(event.Data, e)
|
||||
if err != nil {
|
||||
return nil, errors.ThrowInternal(err, "V2-Thai6", "unable to unmarshal event")
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
@ -267,6 +267,7 @@ func (h *StatementHandler) executeStmt(tx *sql.Tx, stmt *handler.Statement) erro
|
||||
}
|
||||
err = stmt.Execute(tx, h.ProjectionName)
|
||||
if err != nil {
|
||||
logging.WithError(err).Error()
|
||||
_, rollbackErr := tx.Exec("ROLLBACK TO SAVEPOINT push_stmt")
|
||||
if rollbackErr != nil {
|
||||
return errors.ThrowInternal(rollbackErr, "CRDB-zzp3P", "rollback to savepoint failed")
|
||||
|
@ -377,6 +377,8 @@ func defaultValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return "'" + v + "'"
|
||||
case fmt.Stringer:
|
||||
return fmt.Sprintf("%#v", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
49
internal/eventstore/handler/crdb/init_test.go
Normal file
49
internal/eventstore/handler/crdb/init_test.go
Normal file
@ -0,0 +1,49 @@
|
||||
package crdb
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_defaultValue(t *testing.T) {
|
||||
type args struct {
|
||||
value interface{}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "string",
|
||||
args: args{
|
||||
value: "asdf",
|
||||
},
|
||||
want: "'asdf'",
|
||||
},
|
||||
{
|
||||
name: "primitive non string",
|
||||
args: args{
|
||||
value: 1,
|
||||
},
|
||||
want: "1",
|
||||
},
|
||||
{
|
||||
name: "stringer",
|
||||
args: args{
|
||||
value: testStringer(0),
|
||||
},
|
||||
want: "0",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := defaultValue(tt.args.value); got != tt.want {
|
||||
t.Errorf("defaultValue() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testStringer int
|
||||
|
||||
func (t testStringer) String() string {
|
||||
return "0529958243"
|
||||
}
|
141
internal/query/device_auth.go
Normal file
141
internal/query/device_auth.go
Normal file
@ -0,0 +1,141 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
errs "errors"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
var (
|
||||
deviceAuthTable = table{
|
||||
name: projection.DeviceAuthProjectionTable,
|
||||
instanceIDCol: projection.DeviceAuthColumnInstanceID,
|
||||
}
|
||||
DeviceAuthColumnID = Column{
|
||||
name: projection.DeviceAuthColumnID,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnClientID = Column{
|
||||
name: projection.DeviceAuthColumnClientID,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnDeviceCode = Column{
|
||||
name: projection.DeviceAuthColumnDeviceCode,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnUserCode = Column{
|
||||
name: projection.DeviceAuthColumnUserCode,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnExpires = Column{
|
||||
name: projection.DeviceAuthColumnExpires,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnScopes = Column{
|
||||
name: projection.DeviceAuthColumnScopes,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnState = Column{
|
||||
name: projection.DeviceAuthColumnState,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnSubject = Column{
|
||||
name: projection.DeviceAuthColumnSubject,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnCreationDate = Column{
|
||||
name: projection.DeviceAuthColumnCreationDate,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnChangeDate = Column{
|
||||
name: projection.DeviceAuthColumnChangeDate,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnSequence = Column{
|
||||
name: projection.DeviceAuthColumnSequence,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
DeviceAuthColumnInstanceID = Column{
|
||||
name: projection.DeviceAuthColumnInstanceID,
|
||||
table: deviceAuthTable,
|
||||
}
|
||||
)
|
||||
|
||||
func (q *Queries) DeviceAuthByDeviceCode(ctx context.Context, clientID, deviceCode string) (_ *domain.DeviceAuth, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
stmt, scan := prepareDeviceAuthQuery(ctx, q.client)
|
||||
eq := sq.Eq{
|
||||
DeviceAuthColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
|
||||
DeviceAuthColumnClientID.identifier(): clientID,
|
||||
DeviceAuthColumnDeviceCode.identifier(): deviceCode,
|
||||
}
|
||||
query, args, err := stmt.Where(eq).ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.ThrowInternal(err, "QUERY-uk1Oh", "Errors.Query.SQLStatement")
|
||||
}
|
||||
|
||||
return scan(q.client.QueryRowContext(ctx, query, args...))
|
||||
}
|
||||
|
||||
func (q *Queries) DeviceAuthByUserCode(ctx context.Context, userCode string) (_ *domain.DeviceAuth, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
stmt, scan := prepareDeviceAuthQuery(ctx, q.client)
|
||||
eq := sq.Eq{
|
||||
DeviceAuthColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
|
||||
DeviceAuthColumnUserCode.identifier(): userCode,
|
||||
}
|
||||
query, args, err := stmt.Where(eq).ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.ThrowInternal(err, "QUERY-Axu7l", "Errors.Query.SQLStatement")
|
||||
}
|
||||
|
||||
return scan(q.client.QueryRowContext(ctx, query, args...))
|
||||
}
|
||||
|
||||
var deviceAuthSelectColumns = []string{
|
||||
DeviceAuthColumnID.identifier(),
|
||||
DeviceAuthColumnClientID.identifier(),
|
||||
DeviceAuthColumnScopes.identifier(),
|
||||
DeviceAuthColumnExpires.identifier(),
|
||||
DeviceAuthColumnState.identifier(),
|
||||
DeviceAuthColumnSubject.identifier(),
|
||||
}
|
||||
|
||||
func prepareDeviceAuthQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*domain.DeviceAuth, error)) {
|
||||
return sq.Select(deviceAuthSelectColumns...).From(deviceAuthTable.identifier()).PlaceholderFormat(sq.Dollar),
|
||||
func(row *sql.Row) (*domain.DeviceAuth, error) {
|
||||
dst := new(domain.DeviceAuth)
|
||||
var scopes database.StringArray
|
||||
|
||||
err := row.Scan(
|
||||
&dst.AggregateID,
|
||||
&dst.ClientID,
|
||||
&scopes,
|
||||
&dst.Expires,
|
||||
&dst.State,
|
||||
&dst.Subject,
|
||||
)
|
||||
if errs.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.ThrowNotFound(err, "QUERY-Sah9a", "Errors.DeviceAuth.NotExisting")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.ThrowInternal(err, "QUERY-Voo3o", "Errors.Internal")
|
||||
}
|
||||
|
||||
dst.Scopes = scopes
|
||||
return dst, nil
|
||||
}
|
||||
}
|
158
internal/query/device_auth_test.go
Normal file
158
internal/query/device_auth_test.go
Normal file
@ -0,0 +1,158 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
|
||||
)
|
||||
|
||||
const (
|
||||
expectedDeviceAuthQueryC = `SELECT` +
|
||||
` projections.device_authorizations.id,` +
|
||||
` projections.device_authorizations.client_id,` +
|
||||
` projections.device_authorizations.scopes,` +
|
||||
` projections.device_authorizations.expires,` +
|
||||
` projections.device_authorizations.state,` +
|
||||
` projections.device_authorizations.subject` +
|
||||
` FROM projections.device_authorizations`
|
||||
expectedDeviceAuthWhereDeviceCodeQueryC = expectedDeviceAuthQueryC +
|
||||
` WHERE projections.device_authorizations.client_id = $1` +
|
||||
` AND projections.device_authorizations.device_code = $2` +
|
||||
` AND projections.device_authorizations.instance_id = $3`
|
||||
expectedDeviceAuthWhereUserCodeQueryC = expectedDeviceAuthQueryC +
|
||||
` WHERE projections.device_authorizations.instance_id = $1` +
|
||||
` AND projections.device_authorizations.user_code = $2`
|
||||
)
|
||||
|
||||
var (
|
||||
expectedDeviceAuthQuery = regexp.QuoteMeta(expectedDeviceAuthQueryC)
|
||||
expectedDeviceAuthWhereDeviceCodeQuery = regexp.QuoteMeta(expectedDeviceAuthWhereDeviceCodeQueryC)
|
||||
expectedDeviceAuthWhereUserCodeQuery = regexp.QuoteMeta(expectedDeviceAuthWhereUserCodeQueryC)
|
||||
expectedDeviceAuthValues = []driver.Value{
|
||||
"primary-id",
|
||||
"client-id",
|
||||
database.StringArray{"a", "b", "c"},
|
||||
testNow,
|
||||
domain.DeviceAuthStateApproved,
|
||||
"subject",
|
||||
}
|
||||
expectedDeviceAuth = &domain.DeviceAuth{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
AggregateID: "primary-id",
|
||||
},
|
||||
ClientID: "client-id",
|
||||
Scopes: []string{"a", "b", "c"},
|
||||
Expires: testNow,
|
||||
State: domain.DeviceAuthStateApproved,
|
||||
Subject: "subject",
|
||||
}
|
||||
)
|
||||
|
||||
func TestQueries_DeviceAuthByDeviceCode(t *testing.T) {
|
||||
client, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build mock client: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
mock.ExpectQuery(expectedDeviceAuthWhereDeviceCodeQuery).WillReturnRows(
|
||||
sqlmock.NewRows(deviceAuthSelectColumns).AddRow(expectedDeviceAuthValues...),
|
||||
)
|
||||
q := Queries{
|
||||
client: &database.DB{DB: client},
|
||||
}
|
||||
got, err := q.DeviceAuthByDeviceCode(context.TODO(), "123", "456")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedDeviceAuth, got)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestQueries_DeviceAuthByUserCode(t *testing.T) {
|
||||
client, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build mock client: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
mock.ExpectQuery(expectedDeviceAuthWhereUserCodeQuery).WillReturnRows(
|
||||
sqlmock.NewRows(deviceAuthSelectColumns).AddRow(expectedDeviceAuthValues...),
|
||||
)
|
||||
q := Queries{
|
||||
client: &database.DB{DB: client},
|
||||
}
|
||||
got, err := q.DeviceAuthByUserCode(context.TODO(), "789")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedDeviceAuth, got)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func Test_prepareDeviceAuthQuery(t *testing.T) {
|
||||
type want struct {
|
||||
sqlExpectations sqlExpectation
|
||||
err checkErr
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
want want
|
||||
object any
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
want: want{
|
||||
sqlExpectations: mockQueries(
|
||||
expectedDeviceAuthQuery,
|
||||
deviceAuthSelectColumns,
|
||||
[][]driver.Value{expectedDeviceAuthValues},
|
||||
),
|
||||
},
|
||||
object: expectedDeviceAuth,
|
||||
},
|
||||
{
|
||||
name: "not found error",
|
||||
want: want{
|
||||
sqlExpectations: mockQueryErr(
|
||||
expectedDeviceAuthQuery,
|
||||
sql.ErrNoRows,
|
||||
),
|
||||
err: func(err error) (error, bool) {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("err should be sql.ErrNoRows got: %w", err), false
|
||||
}
|
||||
return nil, true
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "other error",
|
||||
want: want{
|
||||
sqlExpectations: mockQueryErr(
|
||||
expectedDeviceAuthQuery,
|
||||
sql.ErrConnDone,
|
||||
),
|
||||
err: func(err error) (error, bool) {
|
||||
if !errors.Is(err, sql.ErrConnDone) {
|
||||
return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false
|
||||
}
|
||||
return nil, true
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assertPrepare(t, prepareDeviceAuthQuery, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...)
|
||||
})
|
||||
}
|
||||
}
|
161
internal/query/projection/device_auth.go
Normal file
161
internal/query/projection/device_auth.go
Normal file
@ -0,0 +1,161 @@
|
||||
package projection
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
|
||||
"github.com/zitadel/zitadel/internal/repository/deviceauth"
|
||||
)
|
||||
|
||||
const (
|
||||
DeviceAuthProjectionTable = "projections.device_authorizations"
|
||||
|
||||
DeviceAuthColumnID = "id"
|
||||
DeviceAuthColumnClientID = "client_id"
|
||||
DeviceAuthColumnDeviceCode = "device_code"
|
||||
DeviceAuthColumnUserCode = "user_code"
|
||||
DeviceAuthColumnExpires = "expires"
|
||||
DeviceAuthColumnScopes = "scopes"
|
||||
DeviceAuthColumnState = "state"
|
||||
DeviceAuthColumnSubject = "subject"
|
||||
|
||||
DeviceAuthColumnCreationDate = "creation_date"
|
||||
DeviceAuthColumnChangeDate = "change_date"
|
||||
DeviceAuthColumnSequence = "sequence"
|
||||
DeviceAuthColumnInstanceID = "instance_id"
|
||||
)
|
||||
|
||||
type deviceAuthProjection struct {
|
||||
crdb.StatementHandler
|
||||
}
|
||||
|
||||
func newDeviceAuthProjection(ctx context.Context, config crdb.StatementHandlerConfig) *deviceAuthProjection {
|
||||
p := new(deviceAuthProjection)
|
||||
config.ProjectionName = DeviceAuthProjectionTable
|
||||
config.Reducers = p.reducers()
|
||||
config.InitCheck = crdb.NewTableCheck(
|
||||
crdb.NewTable([]*crdb.Column{
|
||||
crdb.NewColumn(DeviceAuthColumnID, crdb.ColumnTypeText),
|
||||
crdb.NewColumn(DeviceAuthColumnClientID, crdb.ColumnTypeText),
|
||||
crdb.NewColumn(DeviceAuthColumnDeviceCode, crdb.ColumnTypeText),
|
||||
crdb.NewColumn(DeviceAuthColumnUserCode, crdb.ColumnTypeText),
|
||||
crdb.NewColumn(DeviceAuthColumnExpires, crdb.ColumnTypeTimestamp),
|
||||
crdb.NewColumn(DeviceAuthColumnScopes, crdb.ColumnTypeTextArray),
|
||||
crdb.NewColumn(DeviceAuthColumnState, crdb.ColumnTypeEnum, crdb.Default(domain.DeviceAuthStateInitiated)),
|
||||
crdb.NewColumn(DeviceAuthColumnSubject, crdb.ColumnTypeText, crdb.Default("")),
|
||||
crdb.NewColumn(DeviceAuthColumnCreationDate, crdb.ColumnTypeTimestamp),
|
||||
crdb.NewColumn(DeviceAuthColumnChangeDate, crdb.ColumnTypeTimestamp),
|
||||
crdb.NewColumn(DeviceAuthColumnSequence, crdb.ColumnTypeInt64),
|
||||
crdb.NewColumn(DeviceAuthColumnInstanceID, crdb.ColumnTypeText),
|
||||
},
|
||||
crdb.NewPrimaryKey(DeviceAuthColumnInstanceID, DeviceAuthColumnID),
|
||||
crdb.WithIndex(crdb.NewIndex("user_code", []string{DeviceAuthColumnInstanceID, DeviceAuthColumnUserCode})),
|
||||
crdb.WithIndex(crdb.NewIndex("device_code", []string{DeviceAuthColumnInstanceID, DeviceAuthColumnClientID, DeviceAuthColumnDeviceCode})),
|
||||
),
|
||||
)
|
||||
|
||||
p.StatementHandler = crdb.NewStatementHandler(ctx, config)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *deviceAuthProjection) reducers() []handler.AggregateReducer {
|
||||
return []handler.AggregateReducer{
|
||||
{
|
||||
Aggregate: deviceauth.AggregateType,
|
||||
EventRedusers: []handler.EventReducer{
|
||||
{
|
||||
Event: deviceauth.AddedEventType,
|
||||
Reduce: p.reduceAdded,
|
||||
},
|
||||
{
|
||||
Event: deviceauth.ApprovedEventType,
|
||||
Reduce: p.reduceAppoved,
|
||||
},
|
||||
{
|
||||
Event: deviceauth.CanceledEventType,
|
||||
Reduce: p.reduceCanceled,
|
||||
},
|
||||
{
|
||||
Event: deviceauth.RemovedEventType,
|
||||
Reduce: p.reduceRemoved,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *deviceAuthProjection) reduceAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*deviceauth.AddedEvent)
|
||||
if !ok {
|
||||
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-chu6O", "reduce.wrong.event.type %T != %s", event, deviceauth.AddedEventType)
|
||||
}
|
||||
return crdb.NewCreateStatement(
|
||||
e,
|
||||
[]handler.Column{
|
||||
handler.NewCol(DeviceAuthColumnID, e.Aggregate().ID),
|
||||
handler.NewCol(DeviceAuthColumnClientID, e.ClientID),
|
||||
handler.NewCol(DeviceAuthColumnDeviceCode, e.DeviceCode),
|
||||
handler.NewCol(DeviceAuthColumnUserCode, e.UserCode),
|
||||
handler.NewCol(DeviceAuthColumnExpires, e.Expires),
|
||||
handler.NewCol(DeviceAuthColumnScopes, e.Scopes),
|
||||
handler.NewCol(DeviceAuthColumnCreationDate, e.CreationDate()),
|
||||
handler.NewCol(DeviceAuthColumnChangeDate, e.CreationDate()),
|
||||
handler.NewCol(DeviceAuthColumnSequence, e.Sequence()),
|
||||
handler.NewCol(DeviceAuthColumnInstanceID, e.Aggregate().InstanceID),
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func (p *deviceAuthProjection) reduceAppoved(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*deviceauth.ApprovedEvent)
|
||||
if !ok {
|
||||
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-kei0A", "reduce.wrong.event.type %T != %s", event, deviceauth.ApprovedEventType)
|
||||
}
|
||||
return crdb.NewUpdateStatement(e,
|
||||
[]handler.Column{
|
||||
handler.NewCol(DeviceAuthColumnState, domain.DeviceAuthStateApproved),
|
||||
handler.NewCol(DeviceAuthColumnSubject, e.Subject),
|
||||
handler.NewCol(DeviceAuthColumnChangeDate, e.CreationDate()),
|
||||
handler.NewCol(DeviceAuthColumnSequence, e.Sequence()),
|
||||
},
|
||||
[]handler.Condition{
|
||||
handler.NewCond(DeviceAuthColumnInstanceID, e.Aggregate().InstanceID),
|
||||
handler.NewCond(DeviceAuthColumnID, e.Aggregate().ID),
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func (p *deviceAuthProjection) reduceCanceled(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*deviceauth.CanceledEvent)
|
||||
if !ok {
|
||||
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-eeS8d", "reduce.wrong.event.type %T != %s", event, deviceauth.CanceledEventType)
|
||||
}
|
||||
return crdb.NewUpdateStatement(e,
|
||||
[]handler.Column{
|
||||
handler.NewCol(DeviceAuthColumnState, e.Reason.State()),
|
||||
handler.NewCol(DeviceAuthColumnChangeDate, e.CreationDate()),
|
||||
handler.NewCol(DeviceAuthColumnSequence, e.Sequence()),
|
||||
},
|
||||
[]handler.Condition{
|
||||
handler.NewCond(DeviceAuthColumnInstanceID, e.Aggregate().InstanceID),
|
||||
handler.NewCond(DeviceAuthColumnID, e.Aggregate().ID),
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func (p *deviceAuthProjection) reduceRemoved(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*deviceauth.RemovedEvent)
|
||||
if !ok {
|
||||
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-AJi1u", "reduce.wrong.event.type %T != %s", event, deviceauth.RemovedEventType)
|
||||
}
|
||||
return crdb.NewDeleteStatement(e,
|
||||
[]handler.Condition{
|
||||
handler.NewCond(DeviceAuthColumnInstanceID, e.Aggregate().InstanceID),
|
||||
handler.NewCond(DeviceAuthColumnID, e.Aggregate().ID),
|
||||
},
|
||||
), nil
|
||||
}
|
@ -64,6 +64,7 @@ var (
|
||||
NotificationPolicyProjection *notificationPolicyProjection
|
||||
NotificationsProjection interface{}
|
||||
NotificationsQuotaProjection interface{}
|
||||
DeviceAuthProjection *deviceAuthProjection
|
||||
)
|
||||
|
||||
type projection interface {
|
||||
@ -139,6 +140,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es *eventstore.Eventsto
|
||||
KeyProjection = newKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["keys"]), keyEncryptionAlgorithm, certEncryptionAlgorithm)
|
||||
SecurityPolicyProjection = newSecurityPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["security_policies"]))
|
||||
NotificationPolicyProjection = newNotificationPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["notification_policies"]))
|
||||
DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"]))
|
||||
newProjectionsList()
|
||||
return nil
|
||||
}
|
||||
@ -234,5 +236,6 @@ func newProjectionsList() {
|
||||
KeyProjection,
|
||||
SecurityPolicyProjection,
|
||||
NotificationPolicyProjection,
|
||||
DeviceAuthProjection,
|
||||
}
|
||||
}
|
||||
|
19
internal/repository/deviceauth/aggregate.go
Normal file
19
internal/repository/deviceauth/aggregate.go
Normal file
@ -0,0 +1,19 @@
|
||||
package deviceauth
|
||||
|
||||
import "github.com/zitadel/zitadel/internal/eventstore"
|
||||
|
||||
const (
|
||||
AggregateType = "device_auth"
|
||||
AggregateVersion = "v1"
|
||||
)
|
||||
|
||||
func NewAggregate(aggrID, instanceID string) *eventstore.Aggregate {
|
||||
return &eventstore.Aggregate{
|
||||
ID: aggrID,
|
||||
Type: AggregateType,
|
||||
// we use the id because we don't know the resource owner yet
|
||||
ResourceOwner: instanceID,
|
||||
InstanceID: instanceID,
|
||||
Version: AggregateVersion,
|
||||
}
|
||||
}
|
46
internal/repository/deviceauth/constraints.go
Normal file
46
internal/repository/deviceauth/constraints.go
Normal file
@ -0,0 +1,46 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
const (
|
||||
UniqueUserCode = "user_code"
|
||||
UniqueDeviceCode = "device_code"
|
||||
DuplicateUserCode = "Errors.DeviceUserCode.AlreadyExists"
|
||||
DuplicateDeviceCode = "Errors.DeviceCode.AlreadyExists"
|
||||
)
|
||||
|
||||
func deviceCodeUniqueField(clientID, deviceCode string) string {
|
||||
return strings.Join([]string{clientID, deviceCode}, ":")
|
||||
}
|
||||
|
||||
func NewAddUniqueConstraints(clientID, deviceCode, userCode string) []*eventstore.EventUniqueConstraint {
|
||||
return []*eventstore.EventUniqueConstraint{
|
||||
eventstore.NewAddEventUniqueConstraint(
|
||||
UniqueDeviceCode,
|
||||
deviceCodeUniqueField(clientID, deviceCode),
|
||||
DuplicateDeviceCode,
|
||||
),
|
||||
eventstore.NewAddEventUniqueConstraint(
|
||||
UniqueUserCode,
|
||||
userCode,
|
||||
DuplicateUserCode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func NewRemoveUniqueConstraints(clientID, deviceCode, userCode string) []*eventstore.EventUniqueConstraint {
|
||||
return []*eventstore.EventUniqueConstraint{
|
||||
eventstore.NewRemoveEventUniqueConstraint(
|
||||
UniqueDeviceCode,
|
||||
deviceCodeUniqueField(clientID, deviceCode),
|
||||
),
|
||||
eventstore.NewRemoveEventUniqueConstraint(
|
||||
UniqueUserCode,
|
||||
userCode,
|
||||
),
|
||||
}
|
||||
}
|
141
internal/repository/deviceauth/device_auth.go
Normal file
141
internal/repository/deviceauth/device_auth.go
Normal file
@ -0,0 +1,141 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypePrefix eventstore.EventType = "device.authorization."
|
||||
AddedEventType = eventTypePrefix + "added"
|
||||
ApprovedEventType = eventTypePrefix + "approved"
|
||||
CanceledEventType = eventTypePrefix + "canceled"
|
||||
RemovedEventType = eventTypePrefix + "removed"
|
||||
)
|
||||
|
||||
type AddedEvent struct {
|
||||
*eventstore.BaseEvent
|
||||
|
||||
ClientID string
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
Expires time.Time
|
||||
Scopes []string
|
||||
State domain.DeviceAuthState
|
||||
}
|
||||
|
||||
func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) {
|
||||
e.BaseEvent = b
|
||||
}
|
||||
|
||||
func (e *AddedEvent) Data() any {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
|
||||
return NewAddUniqueConstraints(e.ClientID, e.DeviceCode, e.UserCode)
|
||||
}
|
||||
|
||||
func NewAddedEvent(
|
||||
ctx context.Context,
|
||||
aggregate *eventstore.Aggregate,
|
||||
clientID string,
|
||||
deviceCode string,
|
||||
userCode string,
|
||||
expires time.Time,
|
||||
scopes []string,
|
||||
) *AddedEvent {
|
||||
return &AddedEvent{
|
||||
eventstore.NewBaseEventForPush(
|
||||
ctx, aggregate, AddedEventType,
|
||||
),
|
||||
clientID, deviceCode, userCode, expires, scopes, domain.DeviceAuthStateInitiated}
|
||||
}
|
||||
|
||||
type ApprovedEvent struct {
|
||||
*eventstore.BaseEvent
|
||||
|
||||
Subject string
|
||||
}
|
||||
|
||||
func (e *ApprovedEvent) SetBaseEvent(b *eventstore.BaseEvent) {
|
||||
e.BaseEvent = b
|
||||
}
|
||||
|
||||
func (e *ApprovedEvent) Data() any {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ApprovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewApprovedEvent(
|
||||
ctx context.Context,
|
||||
aggregate *eventstore.Aggregate,
|
||||
subject string,
|
||||
) *ApprovedEvent {
|
||||
return &ApprovedEvent{
|
||||
eventstore.NewBaseEventForPush(
|
||||
ctx, aggregate, ApprovedEventType,
|
||||
),
|
||||
subject,
|
||||
}
|
||||
}
|
||||
|
||||
type CanceledEvent struct {
|
||||
*eventstore.BaseEvent
|
||||
Reason domain.DeviceAuthCanceled
|
||||
}
|
||||
|
||||
func (e *CanceledEvent) SetBaseEvent(b *eventstore.BaseEvent) {
|
||||
e.BaseEvent = b
|
||||
}
|
||||
|
||||
func (e *CanceledEvent) Data() any {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *CanceledEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewCanceledEvent(ctx context.Context, aggregate *eventstore.Aggregate, reason domain.DeviceAuthCanceled) *CanceledEvent {
|
||||
return &CanceledEvent{eventstore.NewBaseEventForPush(ctx, aggregate, CanceledEventType), reason}
|
||||
}
|
||||
|
||||
type RemovedEvent struct {
|
||||
*eventstore.BaseEvent
|
||||
|
||||
ClientID string
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
}
|
||||
|
||||
func (e *RemovedEvent) SetBaseEvent(b *eventstore.BaseEvent) {
|
||||
e.BaseEvent = b
|
||||
}
|
||||
|
||||
func (e *RemovedEvent) Data() any {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *RemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
|
||||
return NewRemoveUniqueConstraints(e.ClientID, e.DeviceCode, e.UserCode)
|
||||
}
|
||||
|
||||
func NewRemovedEvent(
|
||||
ctx context.Context,
|
||||
aggregate *eventstore.Aggregate,
|
||||
clientID, deviceCode, userCode string,
|
||||
) *RemovedEvent {
|
||||
return &RemovedEvent{
|
||||
eventstore.NewBaseEventForPush(
|
||||
ctx, aggregate, RemovedEventType,
|
||||
),
|
||||
clientID, deviceCode, userCode,
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ package org
|
||||
|
||||
import (
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/deviceauth"
|
||||
)
|
||||
|
||||
func RegisterEventMappers(es *eventstore.Eventstore) {
|
||||
@ -107,5 +108,9 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
|
||||
RegisterFilterEventMapper(AggregateType, MetadataRemovedAllType, MetadataRemovedAllEventMapper).
|
||||
RegisterFilterEventMapper(AggregateType, NotificationPolicyAddedEventType, NotificationPolicyAddedEventMapper).
|
||||
RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper).
|
||||
RegisterFilterEventMapper(AggregateType, NotificationPolicyRemovedEventType, NotificationPolicyRemovedEventMapper)
|
||||
RegisterFilterEventMapper(AggregateType, NotificationPolicyRemovedEventType, NotificationPolicyRemovedEventMapper).
|
||||
RegisterFilterEventMapper(AggregateType, deviceauth.AddedEventType, eventstore.GenericEventMapper[deviceauth.AddedEvent]).
|
||||
RegisterFilterEventMapper(AggregateType, deviceauth.ApprovedEventType, eventstore.GenericEventMapper[deviceauth.ApprovedEvent]).
|
||||
RegisterFilterEventMapper(AggregateType, deviceauth.CanceledEventType, eventstore.GenericEventMapper[deviceauth.CanceledEvent]).
|
||||
RegisterFilterEventMapper(AggregateType, deviceauth.RemovedEventType, eventstore.GenericEventMapper[deviceauth.RemovedEvent])
|
||||
}
|
||||
|
@ -180,6 +180,7 @@ enum OIDCGrantType{
|
||||
OIDC_GRANT_TYPE_AUTHORIZATION_CODE = 0;
|
||||
OIDC_GRANT_TYPE_IMPLICIT = 1;
|
||||
OIDC_GRANT_TYPE_REFRESH_TOKEN = 2;
|
||||
OIDC_GRANT_TYPE_DEVICE_CODE = 3;
|
||||
}
|
||||
|
||||
enum OIDCAppType {
|
||||
|
Loading…
x
Reference in New Issue
Block a user