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:
Tim Möhlmann 2023-04-19 11:46:02 +03:00 committed by GitHub
parent 3cd2cecfdf
commit 5819924275
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 2313 additions and 38 deletions

View File

@ -233,6 +233,8 @@ OIDC:
Path: /oidc/v1/end_session Path: /oidc/v1/end_session
Keys: Keys:
Path: /oauth/v2/keys Path: /oauth/v2/keys
DeviceAuth:
Path: /oauth/v2/device_authorization
SAML: SAML:
ProviderConfig: ProviderConfig:

View File

@ -12,14 +12,13 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/zitadel/saml/pkg/provider"
clockpkg "github.com/benbjohnson/clock" clockpkg "github.com/benbjohnson/clock"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/oidc/v2/pkg/op" "github.com/zitadel/oidc/v2/pkg/op"
"github.com/zitadel/saml/pkg/provider"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/net/http2/h2c" "golang.org/x/net/http2/h2c"
@ -294,6 +293,7 @@ func startAPIs(
return fmt.Errorf("unable to start login: %w", err) return fmt.Errorf("unable to start login: %w", err)
} }
apis.RegisterHandlerOnPrefix(login.HandlerPrefix, l.Handler()) 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 // handle grpc at last to be able to handle the root, because grpc and gateway require a lot of different prefixes
apis.RouteGRPC() apis.RouteGRPC()

10
go.mod
View File

@ -45,6 +45,7 @@ require (
github.com/minio/minio-go/v7 v7.0.50 github.com/minio/minio-go/v7 v7.0.50
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/muesli/gamut v0.3.1 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/nicksnyder/go-i18n/v2 v2.2.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.4.0 github.com/pquerna/otp v1.4.0
@ -57,7 +58,7 @@ require (
github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203
github.com/ttacon/libphonenumber v1.2.1 github.com/ttacon/libphonenumber v1.2.1
github.com/zitadel/logging v0.3.4 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 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/google.golang.org/grpc/otelgrpc v0.40.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp 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/sdk/metric v0.37.0
go.opentelemetry.io/otel/trace v1.14.0 go.opentelemetry.io/otel/trace v1.14.0
golang.org/x/crypto v0.7.0 golang.org/x/crypto v0.7.0
golang.org/x/net v0.8.0 golang.org/x/net v0.9.0
golang.org/x/oauth2 v0.6.0 golang.org/x/oauth2 v0.7.0
golang.org/x/sync v0.1.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 golang.org/x/tools v0.7.0
google.golang.org/api v0.115.0 google.golang.org/api v0.115.0
google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd 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/go-logr/stdr v1.2.2 // indirect
github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // 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 github.com/pelletier/go-toml/v2 v2.0.7 // indirect
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.14.0 // indirect go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.14.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect

16
go.sum
View File

@ -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/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 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM=
github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0= 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.4.0 h1:BKx61qOxDf+GjrY8T6lFxPjea0aMfkFvHD9pqyJGpFk=
github.com/zitadel/oidc/v2 v2.2.6/go.mod h1:tGkj9lQk6KVj5hsM89XPadvi6I06666sMy3KtykvSFM= 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 h1:kObucnBrcu1PHCO7RGT0iVeuJL/5I50gUgr40S41nMs=
github.com/zitadel/saml v0.0.11/go.mod h1:YGWAvPZRv4DbEZ78Ht/2P0AWzGn+6WGhFf90PMXl0Po= 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= 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.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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 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-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-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 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-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-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.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= 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-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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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.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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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-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-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

View File

@ -101,6 +101,12 @@ func (a *API) RegisterService(ctx context.Context, grpcServer server.Server) err
return nil 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 // RegisterHandlerOnPrefix registers a http handler on a path prefix
// the prefix will not be passed to the actual handler // the prefix will not be passed to the actual handler
func (a *API) RegisterHandlerOnPrefix(prefix string, handler http.Handler) { func (a *API) RegisterHandlerOnPrefix(prefix string, handler http.Handler) {

View File

@ -136,6 +136,8 @@ func OIDCGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app_pb.OIDCGra
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT
case domain.OIDCGrantTypeRefreshToken: case domain.OIDCGrantTypeRefreshToken:
oidcGrantTypes[i] = app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN 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 return oidcGrantTypes
@ -154,6 +156,8 @@ func OIDCGrantTypesToDomain(grantTypes []app_pb.OIDCGrantType) []domain.OIDCGran
oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN: case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN:
oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken
case app_pb.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE:
oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode
} }
} }
return oidcGrantTypes return oidcGrantTypes

View File

@ -99,15 +99,6 @@ func (a *AuthRequest) GetSubject() string {
return a.UserID 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 { func (a *AuthRequest) oidc() *domain.AuthRequestOIDC {
return a.Request.(*domain.AuthRequestOIDC) return a.Request.(*domain.AuthRequestOIDC)
} }

View File

@ -200,6 +200,8 @@ func grantTypeToOIDC(grantType domain.OIDCGrantType) oidc.GrantType {
return oidc.GrantTypeImplicit return oidc.GrantTypeImplicit
case domain.OIDCGrantTypeRefreshToken: case domain.OIDCGrantTypeRefreshToken:
return oidc.GrantTypeRefreshToken return oidc.GrantTypeRefreshToken
case domain.OIDCGrantTypeDeviceCode:
return oidc.GrantTypeDeviceCode
default: default:
return oidc.GrantTypeCode return oidc.GrantTypeCode
} }

View 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.

View File

@ -40,6 +40,7 @@ type Config struct {
UserAgentCookieConfig *middleware.UserAgentCookieConfig UserAgentCookieConfig *middleware.UserAgentCookieConfig
Cache *middleware.CacheConfig Cache *middleware.CacheConfig
CustomEndpoints *EndpointConfig CustomEndpoints *EndpointConfig
DeviceAuth *DeviceAuthorizationConfig
} }
type EndpointConfig struct { type EndpointConfig struct {
@ -50,6 +51,7 @@ type EndpointConfig struct {
Revocation *Endpoint Revocation *Endpoint
EndSession *Endpoint EndSession *Endpoint
Keys *Endpoint Keys *Endpoint
DeviceAuth *Endpoint
} }
type Endpoint struct { type Endpoint struct {
@ -108,6 +110,7 @@ func createOPConfig(config Config, defaultLogoutRedirectURI string, cryptoKey []
GrantTypeRefreshToken: config.GrantTypeRefreshToken, GrantTypeRefreshToken: config.GrantTypeRefreshToken,
RequestObjectSupported: config.RequestObjectSupported, RequestObjectSupported: config.RequestObjectSupported,
SupportedUILocales: supportedLanguages, SupportedUILocales: supportedLanguages,
DeviceAuthorization: config.DeviceAuth.toOPConfig(),
} }
if cryptoLength := len(cryptoKey); cryptoLength != 32 { if cryptoLength := len(cryptoKey); cryptoLength != 32 {
return nil, caos_errs.ThrowInternalf(nil, "OIDC-D43gf", "crypto key must be 32 bytes, but is %d", cryptoLength) 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 { if endpointConfig.Keys != nil {
options = append(options, op.WithCustomKeysEndpoint(op.NewEndpointWithURL(endpointConfig.Keys.Path, endpointConfig.Keys.URL))) 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 return options
} }

View File

@ -63,14 +63,6 @@ func (a *AuthRequest) GetUserID() string {
func (a *AuthRequest) GetUserName() string { func (a *AuthRequest) GetUserName() string {
return a.UserName 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) { func AuthRequestFromBusiness(authReq *domain.AuthRequest) (_ models.AuthRequestInt, err error) {
if _, ok := authReq.Request.(*domain.AuthRequestSAML); !ok { if _, ok := authReq.Request.(*domain.AuthRequestSAML); !ok {

View 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)
}

View File

@ -69,6 +69,8 @@ func (l *Login) authRequestCallback(ctx context.Context, authReq *domain.AuthReq
return l.oidcAuthCallbackURL(ctx, authReq.ID), nil return l.oidcAuthCallbackURL(ctx, authReq.ID), nil
case *domain.AuthRequestSAML: case *domain.AuthRequestSAML:
return l.samlAuthCallbackURL(ctx, authReq.ID), nil return l.samlAuthCallbackURL(ctx, authReq.ID), nil
case *domain.AuthRequestDevice:
return l.deviceAuthCallbackURL(authReq.ID), nil
default: default:
return "", caos_errs.ThrowInternal(nil, "LOGIN-rhjQF", "Errors.AuthRequest.RequestTypeNotSupported") return "", caos_errs.ThrowInternal(nil, "LOGIN-rhjQF", "Errors.AuthRequest.RequestTypeNotSupported")
} }

View File

@ -25,7 +25,8 @@ import (
) )
const ( const (
tmplError = "error" tmplError = "error"
tmplSuccess = "success"
) )
type Renderer struct { type Renderer struct {
@ -45,6 +46,7 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
} }
tmplMapping := map[string]string{ tmplMapping := map[string]string{
tmplError: "error.html", tmplError: "error.html",
tmplSuccess: "success.html",
tmplLogin: "login.html", tmplLogin: "login.html",
tmplUserSelection: "select_user.html", tmplUserSelection: "select_user.html",
tmplPassword: "password.html", tmplPassword: "password.html",
@ -77,6 +79,8 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
tmplExternalNotFoundOption: "external_not_found_option.html", tmplExternalNotFoundOption: "external_not_found_option.html",
tmplLoginSuccess: "login_success.html", tmplLoginSuccess: "login_success.html",
tmplLDAPLogin: "ldap_login.html", tmplLDAPLogin: "ldap_login.html",
tmplDeviceAuthUserCode: "device_usercode.html",
tmplDeviceAuthAction: "device_action.html",
} }
funcs := map[string]interface{}{ funcs := map[string]interface{}{
"resourceUrl": func(file string) string { "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) { func (l *Login) renderInternalError(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) {
var msg string var msg string
if err != nil { if err != nil {
logging.WithError(err).WithField("auth_req_id", authReq.ID).Error()
_, msg = l.getErrorMessage(r, err) _, msg = l.getErrorMessage(r, err)
} }
data := l.getBaseData(r, authReq, "Errors.Internal", "", "Internal", msg) data := l.getBaseData(r, authReq, "Errors.Internal", "", "Internal", msg)

View File

@ -46,6 +46,9 @@ const (
EndpointResources = "/resources" EndpointResources = "/resources"
EndpointDynamicResources = "/resources/dynamic" EndpointDynamicResources = "/resources/dynamic"
EndpointDeviceAuth = "/device"
EndpointDeviceAuthAction = "/device/{action}"
) )
var ( 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(EndpointLDAPLogin, login.handleLDAP).Methods(http.MethodGet)
router.HandleFunc(EndpointLDAPCallback, login.handleLDAPCallback).Methods(http.MethodPost) router.HandleFunc(EndpointLDAPCallback, login.handleLDAPCallback).Methods(http.MethodPost)
router.SkipClean(true).Handle("", http.RedirectHandler(HandlerPrefix+"/", http.StatusMovedPermanently)) 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 return router
} }

View File

@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語 Japanese: 日本語
Spanish: Español 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: Footer:
PoweredBy: Powered By PoweredBy: Powered By
Tos: AGB Tos: AGB
@ -425,5 +443,7 @@ Errors:
Org: Org:
LoginPolicy: LoginPolicy:
RegistrationNotAllowed: Registrierung ist nicht erlaubt RegistrationNotAllowed: Registrierung ist nicht erlaubt
DeviceAuth:
NotExisting: Benutzercode existiert nicht
optional: (optional) optional: (optional)

View File

@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語 Japanese: 日本語
Spanish: Español 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: Footer:
PoweredBy: Powered By PoweredBy: Powered By
Tos: TOS Tos: TOS
@ -425,5 +443,7 @@ Errors:
Org: Org:
LoginPolicy: LoginPolicy:
RegistrationNotAllowed: Registration is not allowed RegistrationNotAllowed: Registration is not allowed
DeviceAuth:
NotExisting: User Code doesn't exist
optional: (optional) optional: (optional)

View File

@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語 Japanese: 日本語
Spanish: Español 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: Footer:
PoweredBy: Promulgué par PoweredBy: Promulgué par
Tos: TOS Tos: TOS
@ -425,5 +443,7 @@ Errors:
Org: Org:
LoginPolicy: LoginPolicy:
RegistrationNotAllowed: L'enregistrement n'est pas autorisé RegistrationNotAllowed: L'enregistrement n'est pas autorisé
DeviceAuth:
NotExisting: Le code utilisateur n'existe pas
optional: (facultatif) optional: (facultatif)

View File

@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語 Japanese: 日本語
Spanish: Español 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: Footer:
PoweredBy: Alimentato da PoweredBy: Alimentato da
Tos: Termini di servizio Tos: Termini di servizio
@ -425,5 +443,7 @@ Errors:
Org: Org:
LoginPolicy: LoginPolicy:
RegistrationNotAllowed: la registrazione non è consentita. RegistrationNotAllowed: la registrazione non è consentita.
DeviceAuth:
NotExisting: Il codice utente non esiste
optional: (opzionale) optional: (opzionale)

View File

@ -309,6 +309,24 @@ ExternalNotFound:
Japanese: 日本語 Japanese: 日本語
Spanish: Español Spanish: Español
DeviceAuth:
Title: デバイス認証
UserCode:
Label: ユーザーコード
Description: デバイスに表示されたユーザー コードを入力します。
ButtonNext:
Action:
Description: デバイスへのアクセスを許可します。
GrantDevice: デバイスを許可しようとしています
AccessToScopes: 次のスコープへのアクセス
Button:
Allow: 許可する
Deny: 拒否
Done:
Description: 終わり。
Approved: デバイス認証が承認されました。 これで、デバイスに戻ることができます。
Denied: デバイス認証が拒否されました。 これで、デバイスに戻ることができます。
Footer: Footer:
PoweredBy: Powered By PoweredBy: Powered By
Tos: TOS Tos: TOS
@ -385,5 +403,7 @@ Errors:
IAM: IAM:
LockoutPolicy: LockoutPolicy:
NotExisting: ロックアウトポリシーが存在しません NotExisting: ロックアウトポリシーが存在しません
DeviceAuth:
NotExisting: ユーザーコードが存在しません
optional: "(オプション)" optional: "(オプション)"

View File

@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語 Japanese: 日本語
Spanish: Español 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: Footer:
PoweredBy: Obsługiwane przez PoweredBy: Obsługiwane przez
Tos: TOS Tos: TOS
@ -425,5 +443,7 @@ Errors:
Org: Org:
LoginPolicy: LoginPolicy:
RegistrationNotAllowed: Rejestracja nie jest dozwolona RegistrationNotAllowed: Rejestracja nie jest dozwolona
DeviceAuth:
NotExisting: Kod użytkownika nie istnieje
optional: (opcjonalny) optional: (opcjonalny)

View File

@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語 Japanese: 日本語
Spanish: Español Spanish: Español
DeviceAuth:
Title: 设备授权
UserCode:
Label: 用户代码
Description: 输入设备上显示的用户代码。
ButtonNext: 下一个
Action:
Description: 授予设备访问权限。
GrantDevice: 您即将授予设备
AccessToScopes: 访问以下范围
Button:
Allow: 允许
Deny: 否定
Done:
Description: 完毕。
Approved: 设备授权已批准。 您现在可以返回设备。
Denied: 设备授权被拒绝。 您现在可以返回设备。
Footer: Footer:
PoweredBy: Powered By PoweredBy: Powered By
Tos: 服务条款 Tos: 服务条款
@ -425,5 +443,7 @@ Errors:
Org: Org:
LoginPolicy: LoginPolicy:
RegistrationNotAllowed: 不允许注册 RegistrationNotAllowed: 不允许注册
DeviceAuth:
NotExisting: 用户代码不存在
optional: (可选) optional: (可选)

View 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" .}}

View 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" .}}

View 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" .}}

View File

@ -1,3 +1,3 @@
package statik package statik
//go:generate statik -src=../static -dest=.. -ns=login //go:generate statik -f -src=../static -dest=.. -ns=login

View File

@ -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) { func userGrantRequired(ctx context.Context, request *domain.AuthRequest, user *user_model.UserView, userGrantProvider userGrantProvider) (_ bool, err error) {
var project *query.Project var project *query.Project
switch request.Request.Type() { switch request.Request.Type() {
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML: case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML, domain.AuthRequestTypeDevice:
project, err = userGrantProvider.ProjectByClientID(ctx, request.ApplicationID, false) project, err = userGrantProvider.ProjectByClientID(ctx, request.ApplicationID, false)
if err != nil { if err != nil {
return false, err 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) { func projectRequired(ctx context.Context, request *domain.AuthRequest, projectProvider projectProvider) (missingGrant bool, err error) {
var project *query.Project var project *query.Project
switch request.Request.Type() { switch request.Request.Type() {
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML: case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML, domain.AuthRequestTypeDevice:
project, err = projectProvider.ProjectByClientID(ctx, request.ApplicationID, false) project, err = projectProvider.ProjectByClientID(ctx, request.ApplicationID, false)
if err != nil { if err != nil {
return false, err return false, err
} }
default: 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 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 { if !project.HasProjectCheck || project.ResourceOwner == request.UserOrgID {

View 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
}

View 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()
}

View 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)
})
}
}

View File

@ -90,6 +90,7 @@ const (
OIDCGrantTypeAuthorizationCode OIDCGrantType = iota OIDCGrantTypeAuthorizationCode OIDCGrantType = iota
OIDCGrantTypeImplicit OIDCGrantTypeImplicit
OIDCGrantTypeRefreshToken OIDCGrantTypeRefreshToken
OIDCGrantTypeDeviceCode
) )
type OIDCApplicationType int32 type OIDCApplicationType int32

View File

@ -122,6 +122,8 @@ func NewAuthRequestFromType(requestType AuthRequestType) (*AuthRequest, error) {
return &AuthRequest{Request: &AuthRequestOIDC{}}, nil return &AuthRequest{Request: &AuthRequestOIDC{}}, nil
case AuthRequestTypeSAML: case AuthRequestTypeSAML:
return &AuthRequest{Request: &AuthRequestSAML{}}, nil return &AuthRequest{Request: &AuthRequestSAML{}}, nil
case AuthRequestTypeDevice:
return &AuthRequest{Request: &AuthRequestDevice{}}, nil
} }
return nil, errors.ThrowInvalidArgument(nil, "DOMAIN-ds2kl", "invalid request type") return nil, errors.ThrowInvalidArgument(nil, "DOMAIN-ds2kl", "invalid request type")
} }
@ -184,3 +186,12 @@ func (a *AuthRequest) GetScopeOrgID() string {
} }
return "" return ""
} }
func (a *AuthRequest) Done() bool {
for _, step := range a.PossibleSteps {
if step.Type() == NextStepRedirectToCallback {
return true
}
}
return false
}

View 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
}
}

View 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)
}
})
}
}

View 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]]
}

View File

@ -22,6 +22,7 @@ type AuthRequestType int32
const ( const (
AuthRequestTypeOIDC AuthRequestType = iota AuthRequestTypeOIDC AuthRequestType = iota
AuthRequestTypeSAML AuthRequestTypeSAML
AuthRequestTypeDevice
) )
type AuthRequestOIDC struct { type AuthRequestOIDC struct {
@ -56,3 +57,18 @@ func (a *AuthRequestSAML) Type() AuthRequestType {
func (a *AuthRequestSAML) IsValid() bool { func (a *AuthRequestSAML) IsValid() bool {
return true 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
}

View File

@ -304,3 +304,21 @@ func uniqueConstraintActionToRepository(action UniqueConstraintAction) repositor
return repository.UniqueConstraintAdd 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
}

View File

@ -267,6 +267,7 @@ func (h *StatementHandler) executeStmt(tx *sql.Tx, stmt *handler.Statement) erro
} }
err = stmt.Execute(tx, h.ProjectionName) err = stmt.Execute(tx, h.ProjectionName)
if err != nil { if err != nil {
logging.WithError(err).Error()
_, rollbackErr := tx.Exec("ROLLBACK TO SAVEPOINT push_stmt") _, rollbackErr := tx.Exec("ROLLBACK TO SAVEPOINT push_stmt")
if rollbackErr != nil { if rollbackErr != nil {
return errors.ThrowInternal(rollbackErr, "CRDB-zzp3P", "rollback to savepoint failed") return errors.ThrowInternal(rollbackErr, "CRDB-zzp3P", "rollback to savepoint failed")

View File

@ -377,6 +377,8 @@ func defaultValue(value interface{}) string {
switch v := value.(type) { switch v := value.(type) {
case string: case string:
return "'" + v + "'" return "'" + v + "'"
case fmt.Stringer:
return fmt.Sprintf("%#v", v)
default: default:
return fmt.Sprintf("%v", v) return fmt.Sprintf("%v", v)
} }

View 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"
}

View 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
}
}

View 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...)
})
}
}

View 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
}

View File

@ -64,6 +64,7 @@ var (
NotificationPolicyProjection *notificationPolicyProjection NotificationPolicyProjection *notificationPolicyProjection
NotificationsProjection interface{} NotificationsProjection interface{}
NotificationsQuotaProjection interface{} NotificationsQuotaProjection interface{}
DeviceAuthProjection *deviceAuthProjection
) )
type projection interface { 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) KeyProjection = newKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["keys"]), keyEncryptionAlgorithm, certEncryptionAlgorithm)
SecurityPolicyProjection = newSecurityPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["security_policies"])) SecurityPolicyProjection = newSecurityPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["security_policies"]))
NotificationPolicyProjection = newNotificationPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["notification_policies"])) NotificationPolicyProjection = newNotificationPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["notification_policies"]))
DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"]))
newProjectionsList() newProjectionsList()
return nil return nil
} }
@ -234,5 +236,6 @@ func newProjectionsList() {
KeyProjection, KeyProjection,
SecurityPolicyProjection, SecurityPolicyProjection,
NotificationPolicyProjection, NotificationPolicyProjection,
DeviceAuthProjection,
} }
} }

View 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,
}
}

View 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,
),
}
}

View 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,
}
}

View File

@ -2,6 +2,7 @@ package org
import ( import (
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/deviceauth"
) )
func RegisterEventMappers(es *eventstore.Eventstore) { func RegisterEventMappers(es *eventstore.Eventstore) {
@ -107,5 +108,9 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(AggregateType, MetadataRemovedAllType, MetadataRemovedAllEventMapper). RegisterFilterEventMapper(AggregateType, MetadataRemovedAllType, MetadataRemovedAllEventMapper).
RegisterFilterEventMapper(AggregateType, NotificationPolicyAddedEventType, NotificationPolicyAddedEventMapper). RegisterFilterEventMapper(AggregateType, NotificationPolicyAddedEventType, NotificationPolicyAddedEventMapper).
RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper). 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])
} }

View File

@ -180,6 +180,7 @@ enum OIDCGrantType{
OIDC_GRANT_TYPE_AUTHORIZATION_CODE = 0; OIDC_GRANT_TYPE_AUTHORIZATION_CODE = 0;
OIDC_GRANT_TYPE_IMPLICIT = 1; OIDC_GRANT_TYPE_IMPLICIT = 1;
OIDC_GRANT_TYPE_REFRESH_TOKEN = 2; OIDC_GRANT_TYPE_REFRESH_TOKEN = 2;
OIDC_GRANT_TYPE_DEVICE_CODE = 3;
} }
enum OIDCAppType { enum OIDCAppType {