Skip to content

Opt-In Token

This example demonstrates how DCB can be leveraged to replace a Read Model when implementing a Double opt-in

Challenge

A Double opt-in process that requires users to confirm their email address before an account is created

Traditional approaches

  • Stateless: Store required data and expiration timestamp in an encrypted/signed token

    This works, but it can lead to very long tokens

  • Persisted token: The server generates and stores a unique token, tied to the specified email address. When the email address is confirmed, the token is verified and invalidated (e.g., deleted).

    This method allows the tokens to be short but adds infrastructure overhead and complexity, and may result in stale or unused tokens accumulating over time

DCB approach

With DCB, a short token (i.e. OTP) can be generated on the server and stored with the data of the initial Event (SignUpInitiated).

With that, a dedicated Decision Model can be created that verifies the token. The token is invalidated as soon as the sign up was finalized (SignUpConfirmed Event)

Feature 1: Simple One-Time Password (OTP)

model "Opt-in token"

// Types
tag type EmailAddress = string
tag type Otp = string

// Events
event SignUpInitiated { emailAddress: EmailAddress, otp: Otp, name: string }
event SignUpConfirmed { emailAddress: EmailAddress, otp: Otp, name: string }

// Projections
projection SignUpPending(emailAddress: EmailAddress, otp: Otp): boolean = false {
  on SignUpInitiated => set true
}

projection SignUpName(emailAddress: EmailAddress, otp: Otp): string = null {
  on SignUpInitiated => set event.data.name
}

projection OtpUsed(emailAddress: EmailAddress, otp: Otp): boolean = false {
  on SignUpConfirmed => set true
}

// Commands
command ConfirmSignUp(emailAddress: EmailAddress, otp: Otp) {
  read pending = SignUpPending(emailAddress, otp)
  read used = OtpUsed(emailAddress, otp)
  read name = SignUpName(emailAddress, otp)

  require pending is true
  require used is false

  emit SignUpConfirmed { emailAddress, otp, name }

  scenario "Confirm SignUp for non-existing OTP" {
    when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "000000" }
    then rejected by pending is true saw false
  }

  scenario "Confirm SignUp for OTP assigned to different email address" {
    given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "111111", name: "John Doe" }
    when ConfirmSignUp { emailAddress: "jane.doe@example.com", otp: "111111" }
    then rejected by pending is true saw false
  }

  scenario "Confirm SignUp for already used OTP" {
    given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe" }
    given SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe" }
    when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "222222" }
    then rejected by used is false saw true
  }

  scenario "Confirm SignUp for valid OTP" {
    given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe" }
    when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "444444" }
    then SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe" }
  }
}

ConfirmSignUp reads 2 types, 2 tags

Query ItemEvent TypesTags
pendingSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}
usedSignUpConfirmedEmailAddress:{emailAddress}, Otp:{otp}
nameSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}

AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read

Tags of the appended events: EmailAddress:{emailAddress}, Otp:{otp}

Feature 2: Expiring OTP

A requirement might be to expire tokens after a given time (for example: 60 minutes). The example can be easily adjusted to implement that feature:

Note

The notation has no clock, so time is data: SignUpInitiated stores when the OTP expires (expiresAt), and the current time is passed to ConfirmSignUp as now. For simplicity, both are plain numbers of minutes. Typically, they are timestamps, with expiresAt calculated from the time the sign-up was initiated.

model "Opt-in token (expiring)"

// Types
tag type EmailAddress = string
tag type Otp = string
type Minute = integer

// Events
event SignUpInitiated {
  emailAddress: EmailAddress
  otp: Otp
  name: string
  expiresAt: Minute
}

event SignUpConfirmed { emailAddress: EmailAddress, otp: Otp, name: string }

// Projections
projection SignUpPending(emailAddress: EmailAddress, otp: Otp): boolean = false {
  on SignUpInitiated => set true
}

projection SignUpName(emailAddress: EmailAddress, otp: Otp): string = null {
  on SignUpInitiated => set event.data.name
}

projection OtpUsed(emailAddress: EmailAddress, otp: Otp): boolean = false {
  on SignUpConfirmed => set true
}

projection OtpExpiresAt(emailAddress: EmailAddress, otp: Otp): Minute = 0 {
  on SignUpInitiated => set event.data.expiresAt
}

// Commands
command ConfirmSignUp(emailAddress: EmailAddress, otp: Otp, now: Minute) {
  read pending = SignUpPending(emailAddress, otp)
  read used = OtpUsed(emailAddress, otp)
  read expiresAt = OtpExpiresAt(emailAddress, otp)
  read name = SignUpName(emailAddress, otp)

  require pending is true
  require used is false
  require expiresAt > now

  emit SignUpConfirmed { emailAddress, otp, name }

  scenario "Confirm SignUp for non-existing OTP" {
    when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "000000", now: 100 }
    then rejected by pending is true saw false
  }

  scenario "Confirm SignUp for OTP assigned to different email address" {
    given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "111111", name: "John Doe", expiresAt: 160 }
    when ConfirmSignUp { emailAddress: "jane.doe@example.com", otp: "111111", now: 100 }
    then rejected by pending is true saw false
  }

  scenario "Confirm SignUp for already used OTP" {
    given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe", expiresAt: 160 }
    given SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe" }
    when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "222222", now: 100 }
    then rejected by used is false saw true
  }

  scenario "Confirm SignUp for valid OTP" {
    given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe", expiresAt: 160 }
    when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "444444", now: 100 }
    then SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe" }
  }

  scenario "Confirm SignUp for expired OTP" {
    given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "333333", name: "John Doe", expiresAt: 99 }
    when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "333333", now: 100 }
    then rejected by expiresAt > now saw 99, 100
  }
}

ConfirmSignUp reads 2 types, 2 tags

Query ItemEvent TypesTags
pendingSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}
usedSignUpConfirmedEmailAddress:{emailAddress}, Otp:{otp}
expiresAtSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}
nameSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}

AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read

Tags of the appended events: EmailAddress:{emailAddress}, Otp:{otp}

Conclusion

This example demonstrates, how DCB can be used to implement a simple double opt-in functionality without the need for additional Read Models or Cryptography