package command import ( "context" "fmt" "time" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/deviceauth" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) func (c *Commands) AddDeviceAuth(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes, audience []string, needRefreshToken bool) (*domain.ObjectDetails, error) { aggr := deviceauth.NewAggregate(deviceCode, authz.GetInstance(ctx).InstanceID()) model := NewDeviceAuthWriteModel(deviceCode, aggr.ResourceOwner) pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewAddedEvent( ctx, aggr, clientID, deviceCode, userCode, expires, scopes, audience, needRefreshToken, )) if err != nil { return nil, err } err = AppendAndReduce(model, pushedEvents...) if err != nil { return nil, err } return writeModelToObjectDetails(&model.WriteModel), nil } func (c *Commands) ApproveDeviceAuth( ctx context.Context, deviceCode, userID, userOrgID string, authMethods []domain.UserAuthMethodType, authTime time.Time, preferredLanguage *language.Tag, userAgent *domain.UserAgent, ) (*domain.ObjectDetails, error) { model, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, deviceCode) if err != nil { return nil, err } if !model.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound") } pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, model.aggregate, userID, userOrgID, authMethods, authTime, preferredLanguage, userAgent)) 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.getDeviceAuthWriteModelByDeviceCode(ctx, id) if err != nil { return nil, err } if !model.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-gee5A", "Errors.DeviceAuth.NotFound") } pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewCanceledEvent(ctx, model.aggregate, 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) getDeviceAuthWriteModelByDeviceCode(ctx context.Context, deviceCode string) (*DeviceAuthWriteModel, error) { model := &DeviceAuthWriteModel{ WriteModel: eventstore.WriteModel{AggregateID: deviceCode}, } err := c.eventstore.FilterToQueryReducer(ctx, model) if err != nil { return nil, err } model.aggregate = deviceauth.NewAggregate(model.AggregateID, model.InstanceID) return model, nil } type DeviceAuthStateError domain.DeviceAuthState func (e DeviceAuthStateError) Error() string { return fmt.Sprintf("device auth state not approved: %s", domain.DeviceAuthState(e).String()) } // CreateOIDCSessionFromDeviceAuth creates a new OIDC session if the device authorization // flow is completed (user logged in). // A [DeviceAuthStateError] is returned if the device authorization was not approved, // containing a [domain.DeviceAuthState] which can be used to inform the client about the state. // // As devices can poll at various intervals, an explicit state takes precedence over expiry. // This is to prevent cases where users might approve or deny the authorization on time, but the next poll // happens after expiry. func (c *Commands) CreateOIDCSessionFromDeviceAuth(ctx context.Context, deviceCode string) (_ *OIDCSession, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() deviceAuthModel, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, deviceCode) if err != nil { return nil, err } switch deviceAuthModel.State { case domain.DeviceAuthStateApproved: break case domain.DeviceAuthStateUndefined: return nil, zerrors.ThrowNotFound(nil, "COMMAND-ua1Vo", "Errors.DeviceAuth.NotFound") case domain.DeviceAuthStateInitiated: if deviceAuthModel.Expires.Before(time.Now()) { c.asyncPush(ctx, deviceauth.NewCanceledEvent(ctx, deviceAuthModel.aggregate, domain.DeviceAuthCanceledExpired)) return nil, DeviceAuthStateError(domain.DeviceAuthStateExpired) } fallthrough case domain.DeviceAuthStateDenied, domain.DeviceAuthStateExpired, domain.DeviceAuthStateDone: fallthrough default: return nil, DeviceAuthStateError(deviceAuthModel.State) } cmd, err := c.newOIDCSessionAddEvents(ctx, deviceAuthModel.UserOrgID) if err != nil { return nil, err } cmd.AddSession(ctx, deviceAuthModel.UserID, deviceAuthModel.UserOrgID, "", deviceAuthModel.ClientID, deviceAuthModel.Audience, deviceAuthModel.Scopes, deviceAuthModel.UserAuthMethods, deviceAuthModel.AuthTime, "", deviceAuthModel.PreferredLanguage, deviceAuthModel.UserAgent, ) if err = cmd.AddAccessToken(ctx, deviceAuthModel.Scopes, deviceAuthModel.UserID, deviceAuthModel.UserOrgID, domain.TokenReasonAuthRequest, nil); err != nil { return nil, err } if deviceAuthModel.NeedRefreshToken { if err = cmd.AddRefreshToken(ctx, deviceAuthModel.UserID); err != nil { return nil, err } } cmd.DeviceAuthRequestDone(ctx, deviceAuthModel.aggregate) return cmd.PushEvents(ctx) } func (cmd *OIDCSessionEvents) DeviceAuthRequestDone(ctx context.Context, deviceAuthAggregate *eventstore.Aggregate) { cmd.events = append(cmd.events, deviceauth.NewDoneEvent(ctx, deviceAuthAggregate)) }