Files
zitadel/backend/v3/domain/session_challenge_passkey.go
T
c7f92a6887 feat: session challenge passkey for the RT model (#11944)
# Which Problems Are Solved

Add Passkey challenge needed for Create/Set session in the RT model.

# How the Problems Are Solved

Introduce `PasskeyChallengeCommand` (validate/execute/events) for
session creation checks
Add a generic `BeginWebAuthNLogin` helper that returns session data +
assertion JSON bytes.
Add unit tests

# Additional Changes
N/A

# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/11035

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Marco A. <[email protected]>
2026-04-01 12:13:40 +02:00

224 lines
7.7 KiB
Go

package domain
import (
"context"
"encoding/json"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"google.golang.org/protobuf/types/known/structpb"
"github.com/zitadel/zitadel/backend/v3/storage/database"
old_domain "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/zerrors"
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
type ChallengeTypePasskey struct {
Domain string
UserVerificationRequirement old_domain.UserVerificationRequirement
}
var _ Commander = (*PasskeyChallengeCommand)(nil)
var _ Transactional = (*PasskeyChallengeCommand)(nil)
type beginLoginFn func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error)
// PasskeyChallengeCommand handles the passkey challenge creation during session creation.
// It supports both U2F and passwordless flows based on user verification requirements.
type PasskeyChallengeCommand struct {
ChallengeTypePasskey *ChallengeTypePasskey
SessionID string
InstanceID string
user *User
beginLogin beginLoginFn
challengePasskey *SessionChallengePasskey // the generated passkey challenge that is stored in the session.
webAuthNChallenge *session_grpc.Challenges_WebAuthN // challenge to be set in the CreateSessionResponse
}
// NewPasskeyChallengeCommand creates a new PasskeyChallengeCommand.
func NewPasskeyChallengeCommand(
sessionID,
instanceID string,
challengePasskey *ChallengeTypePasskey,
beginLoginFn beginLoginFn,
) (*PasskeyChallengeCommand, error) {
if beginLoginFn == nil {
if webauthnConfig == nil {
return nil, zerrors.ThrowInternal(nil, "DOM-jwk5Pe", "begin webauthn login function not set")
}
beginLoginFn = webauthnConfig.BeginWebAuthNLogin
}
return &PasskeyChallengeCommand{
ChallengeTypePasskey: challengePasskey,
SessionID: sessionID,
InstanceID: instanceID,
beginLogin: beginLoginFn,
}, nil
}
// RequiresTransaction implements [Transactional].
func (p *PasskeyChallengeCommand) RequiresTransaction() {}
// Validate implements [Commander].
// It retrieves the session, the user, and the user's passkeys based on the user verification requirement,
// and validates that the user is active.
func (p *PasskeyChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
if p.ChallengeTypePasskey == nil {
return nil
}
if p.SessionID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-EVo5yE", "Errors.Missing.SessionID")
}
if p.InstanceID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-sh8xvQ", "Errors.Missing.InstanceID")
}
// get session
sessionRepo := opts.sessionRepo
retrievedSession, err := sessionRepo.Get(
ctx,
opts.DB(),
database.WithCondition(sessionRepo.PrimaryKeyCondition(p.InstanceID, p.SessionID)),
)
if err := handleGetError(err, "DOM-zy4hYC", objectTypeSession); err != nil {
return err
}
if retrievedSession.UserID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-uVyrt2", "Errors.Missing.Session.UserID")
}
// retrieve user and their passkeys based on user verification requirement
passkeyType := determinePasskeyType(p.ChallengeTypePasskey.UserVerificationRequirement)
userRepo := opts.userRepo
retrievedUser, err := userRepo.Get(
ctx,
opts.DB(),
database.WithCondition(userRepo.PrimaryKeyCondition(p.InstanceID, retrievedSession.UserID)),
database.WithCondition(userRepo.Human().PasskeyConditions().TypeCondition(database.TextOperationEqual, passkeyType)),
)
if err := handleGetError(err, "DOM-8cGMtd", objectTypeUser); err != nil {
return err
}
// ensure the user is a human user
if retrievedUser.Human == nil {
return zerrors.ThrowPreconditionFailed(nil, "DOM-nd3f4", "Errors.User.NotHuman")
}
// ensure the user is active
if retrievedUser.State != UserStateActive {
return zerrors.ThrowPreconditionFailed(nil, "DOM-bnxBdS", "Errors.User.NotFound")
}
p.user = retrievedUser
return nil
}
// Execute implements [Commander].
// It begins the WebAuthN login process and updates the session with the passkey challenge.
func (p *PasskeyChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) {
if p.ChallengeTypePasskey == nil {
return nil
}
// begin webauthn login
sessionData, credentialAssertionData, rpID, err := p.beginWebAuthNLogin(ctx, p.ChallengeTypePasskey.UserVerificationRequirement)
if err != nil {
return err
}
// to set the challenge for CreateSessionResponse
webAuthNChallenge := &session_grpc.Challenges_WebAuthN{
PublicKeyCredentialRequestOptions: new(structpb.Struct),
}
if err = json.Unmarshal(credentialAssertionData, webAuthNChallenge.PublicKeyCredentialRequestOptions); err != nil {
return zerrors.ThrowInternal(err, "DOM-liSCA4", "Errors.Unmarshal")
}
// update the session with the passkey challenge
challengePasskey := &SessionChallengePasskey{
Challenge: sessionData.Challenge,
AllowedCredentialIDs: sessionData.AllowedCredentialIDs,
UserVerification: p.ChallengeTypePasskey.UserVerificationRequirement,
RPID: rpID,
LastChallengedAt: time.Now(),
}
sessionRepo := opts.sessionRepo
updated, err := sessionRepo.Update(
ctx,
opts.DB(),
sessionRepo.PrimaryKeyCondition(p.InstanceID, p.SessionID),
sessionRepo.SetChallenge(challengePasskey),
)
if err := handleUpdateError(err, expectedUpdatedRows, updated, "DOM-yd3f4", objectTypeSession); err != nil {
return err
}
p.webAuthNChallenge = webAuthNChallenge
p.challengePasskey = challengePasskey
return nil
}
// Events implements [Commander].
// It creates a WebAuthN challenged event if a passkey challenge was requested and created.
func (p *PasskeyChallengeCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) {
if p.ChallengeTypePasskey == nil {
return nil, nil
}
return []eventstore.Command{
session.NewWebAuthNChallengedEvent(
ctx,
&session.NewAggregate(p.SessionID, p.InstanceID).Aggregate,
p.challengePasskey.Challenge,
p.challengePasskey.AllowedCredentialIDs,
p.challengePasskey.UserVerification,
p.challengePasskey.RPID,
),
}, nil
}
func (p *PasskeyChallengeCommand) GetWebAuthNChallenge() *session_grpc.Challenges_WebAuthN {
return p.webAuthNChallenge
}
func (p *PasskeyChallengeCommand) String() string {
return "PasskeyChallengeCommand"
}
// beginWebAuthNLogin starts the WebAuthN login process for the user with the specified user verification requirement.
func (p *PasskeyChallengeCommand) beginWebAuthNLogin(ctx context.Context, userVerificationDomain old_domain.UserVerificationRequirement) (*webauthn.SessionData, []byte, string, error) {
webUser := &webAuthNUser{
userID: p.user.ID,
username: p.user.Username,
displayName: p.user.Human.DisplayName,
creds: PasskeysToCredentials(ctx, p.user.Human.Passkeys, p.ChallengeTypePasskey.Domain),
}
sessionData, credentialAssertionData, rpID, err := p.beginLogin(
ctx,
webUser,
p.ChallengeTypePasskey.Domain,
UserVerificationFromDomain(userVerificationDomain),
)
if err != nil {
return nil, nil, "", err
}
return sessionData, credentialAssertionData, rpID, nil
}
// determinePasskeyType determines the passkey type (U2F or Passwordless) based on the user verification requirement.
func determinePasskeyType(userVerificationRequirement old_domain.UserVerificationRequirement) PasskeyType {
passkeyType := PasskeyTypeU2F
if userVerificationRequirement == old_domain.UserVerificationRequirementRequired {
passkeyType = PasskeyTypePasswordless
}
return passkeyType
}