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
49 changed files with 2313 additions and 38 deletions

View File

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

View File

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

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