Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,8 @@ type Mutation
lnUsdInvoiceCreateOnBehalfOfRecipient(input: LnUsdInvoiceCreateOnBehalfOfRecipientInput!): LnInvoicePayload!
lnUsdInvoiceFeeProbe(input: LnUsdInvoiceFeeProbeInput!): CentAmountPayload!
merchantMapSuggest(input: MerchantMapSuggestInput!): MerchantPayload!
newUserEmailRegistrationInitiate(input: NewUserEmailRegistrationInitiateInput!): NewUserEmailRegistrationInitiatePayload!
newUserEmailRegistrationValidate(input: NewUserEmailRegistrationValidateInput!): AuthTokenPayload!
onChainAddressCreate(input: OnChainAddressCreateInput!): OnChainAddressPayload!
onChainAddressCurrent(input: OnChainAddressCurrentInput!): OnChainAddressPayload!
onChainPaymentSend(input: OnChainPaymentSendInput!): PaymentSendPayload!
Expand Down Expand Up @@ -1181,6 +1183,26 @@ enum Network
testnet @join__enumValue(graph: PUBLIC)
}

input NewUserEmailRegistrationInitiateInput
@join__type(graph: PUBLIC)
{
email: EmailAddress!
}

type NewUserEmailRegistrationInitiatePayload
@join__type(graph: PUBLIC)
{
emailFlowId: String
errors: [Error!]!
}

input NewUserEmailRegistrationValidateInput
@join__type(graph: PUBLIC)
{
code: OneTimeAuthCode!
emailFlowId: String!
}

scalar NotificationCategory
@join__type(graph: PUBLIC)

Expand Down
93 changes: 93 additions & 0 deletions dev/ory/email-template.html
Comment thread
islandbitcoin marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
color: #333;
}

.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

.header {
background-color: #007bff;
color: #ffffff;
text-align: center;
padding: 20px;
}

.header h1 {
margin: 0;
font-size: 24px;
}

.header img {
max-width: 100px;
margin-top: 10px;
}

.content {
padding: 20px;
text-align: center;
}

.content h2 {
font-size: 20px;
margin: 20px 0;
}

.content p {
font-size: 16px;
margin: 10px 0;
}

.code {
font-size: 24px;
font-weight: bold;
color: #007bff;
margin: 20px 0;
}

.footer {
background-color: #f4f4f4;
color: #777;
text-align: center;
padding: 10px;
font-size: 12px;
}
</style>
</head>

<body>
<div class="container">
<div class="header">
<h1>Welcome to Flash</h1>
<img src="https://getflash.io/assets/img/logo-white.png" alt="Welcome Image">
</div>
<div class="content">
<h2>Confirm Your Access</h2>
<p>Hi,</p>
<p>Confirm access to your Flash account using the following code:</p>
<div class="code">{{ .RecoveryCode }}</div>
<p>This code will only be used once. Do not share it with anyone.</p>
</div>
<div class="footer">
<p>&copy; 2024 Flash. All rights reserved.</p>
</div>
</div>
</body>

</html>
4 changes: 2 additions & 2 deletions dev/ory/kratos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,15 @@ courier:
recovery_code:
valid:
email:
subject: base64://eW91ciBjb2RlCg==
subject: base64://WW91ciBGbGFzaCB2ZXJpZmljYXRpb24gY29kZQ==
body:
# courier/template/courier/builtin/templates/recovery_code/valid/email.body.plaintext.gotmpl
# Hi,
# You can confirm access to your blink account by entering the following code:
# {{ .RecoveryCode }}
# Don't share this code with anyone. Our employee will never ask for this code
plaintext: base64://SGksCgpZb3UgY2FuIGNvbmZpcm0gYWNjZXNzIHRvIHlvdXIgYmxpbmsgYWNjb3VudCBieSBlbnRlcmluZyB0aGUgZm9sbG93aW5nIGNvZGU6Cgp7eyAuUmVjb3ZlcnlDb2RlIH19CgpEb24ndCBzaGFyZSB0aGlzIGNvZGUgd2l0aCBhbnlvbmUuIE91ciBlbXBsb3llZSB3aWxsIG5ldmVyIGFzayBmb3IgdGhpcyBjb2RlCg==
html: base64://SGksCgpZb3UgY2FuIGNvbmZpcm0gYWNjZXNzIHRvIHlvdXIgYmxpbmsgYWNjb3VudCBieSBlbnRlcmluZyB0aGUgZm9sbG93aW5nIGNvZGU6Cgp7eyAuUmVjb3ZlcnlDb2RlIH19CgpEb24ndCBzaGFyZSB0aGlzIGNvZGUgd2l0aCBhbnlvbmUuIE91ciBlbXBsb3llZSB3aWxsIG5ldmVyIGFzayBmb3IgdGhpcyBjb2RlCg==
html: file:///home/ory/email-template.html

session:
# TODO: check lifespan per schema
Expand Down
22 changes: 22 additions & 0 deletions src/app/accounts/create-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,25 @@ export const createAccountWithPhoneIdentifier = async ({

return account
}

export const createAccountWithEmailIdentifier = async ({
newAccountInfo: { kratosUserId, email },
config,
}: {
newAccountInfo: NewAccountWithEmailIdentifier
config: AccountsConfig
}): Promise<Account | RepositoryError> => {
const user = await UsersRepository().update({ id: kratosUserId, email })
if (user instanceof Error) return user

const accountNew = await AccountsRepository().persistNew(kratosUserId)
if (accountNew instanceof Error) return accountNew

const account = await initializeCreatedAccount({
account: accountNew,
config,
})
if (account instanceof Error) return account

return account
}
39 changes: 39 additions & 0 deletions src/app/accounts/upgrade-device-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,42 @@ export const upgradeAccountFromDeviceToPhone = async ({

return accountUpdated
}

/**
* Upgrades a TRIAL device account to an email-based account
*
* This function updates MongoDB records after Kratos schema has been upgraded
* from username_password_deviceid_v0 to email_no_password_v0
*
* Changes made:
* - Adds email to User record
* - Upgrades Account level from 0 (TRIAL) to 1 (verified)
* - Preserves deviceId field in User record
*
* @param userId - Kratos user ID
* @param email - Email address to add
* @returns Updated Account or RepositoryError
*/
export const upgradeAccountFromDeviceToEmail = async ({
userId,
email,
}: {
userId: UserId
email: EmailAddress
}): Promise<Account | RepositoryError> => {
// TODO: ideally both 1. and 2. should be done in a transaction,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe there's a way to make this atomic unless we completely rewrite our data model

// so that if one fails, the other is rolled back

// 1. Update user record with email (deviceId is preserved via spread)
const res = await UsersRepository().addEmail(userId, email)
if (res instanceof Error) return res

// 2. Update account level from TRIAL (0) to verified (1)
const accountDevice = await AccountsRepository().findByUserId(userId)
if (accountDevice instanceof Error) return accountDevice
accountDevice.level = AccountLevel.One
const accountUpdated = await AccountsRepository().update(accountDevice)
if (accountUpdated instanceof Error) return accountUpdated

return accountUpdated
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const createAccountFromRegistrationPayload = async ({
}

const { userId, phone, phoneMetadata } = regPayload
if (!phone) {
return new InvalidPhoneNumber("Phone is required for phone registration")
}
const account = await createAccountWithPhoneIdentifier({
newAccountInfo: { phone, kratosUserId: userId },
config: getDefaultAccountsConfig(),
Expand Down
41 changes: 40 additions & 1 deletion src/app/authentication/email.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { AccountAlreadyHasEmailError } from "@domain/authentication/errors"
import { AuthWithEmailPasswordlessService } from "@services/kratos"
import { AuthWithEmailPasswordlessService, IdentityRepository } from "@services/kratos"
import { SchemaIdType } from "@services/kratos/schema"
import { baseLogger } from "@services/logger"
import { UsersRepository } from "@services/mongoose"
import { upgradeAccountFromDeviceToEmail } from "@app/accounts"

/**
* Adds email to an authenticated user's identity
*
* Handles two flows:
* 1. TRIAL device account → Email account (upgrade)
* - Upgrades Kratos schema and MongoDB records
* - Sends verification email
*
* 2. Phone account → Phone+Email account (add email)
* - Adds email to existing phone account
* - Sends verification email
*
* @param email - Email address to add
* @param userId - Authenticated user's ID
* @returns Email registration ID and updated user, or error
*/
export const addEmailToIdentity = async ({
email,
userId,
Expand All @@ -12,16 +30,37 @@ export const addEmailToIdentity = async ({
}): Promise<AddEmailToIdentityResult | KratosError> => {
const authServiceEmail = AuthWithEmailPasswordlessService()

// Prevent duplicate emails
const hasEmail = await authServiceEmail.hasEmail({ kratosUserId: userId })
if (hasEmail instanceof Error) return hasEmail
if (hasEmail) return new AccountAlreadyHasEmailError()

// Detect if this is a device account upgrade or phone account email addition
const identityRepo = IdentityRepository()
const identity = await identityRepo.getIdentity(userId)
if (identity instanceof Error) return identity

const isDeviceAccountUpgrade =
identity.schema === SchemaIdType.UsernamePasswordDeviceIdV0

// Update Kratos identity (handles schema upgrade internally)
const res = await authServiceEmail.addUnverifiedEmailToIdentity({
email,
kratosUserId: userId,
})
if (res instanceof Error) return res

// For device account upgrades, also update MongoDB (account level, user email)
// For phone accounts, MongoDB is already updated via addUnverifiedEmailToIdentity
if (isDeviceAccountUpgrade) {
const accountUpgradeRes = await upgradeAccountFromDeviceToEmail({
userId,
email,
})
if (accountUpgradeRes instanceof Error) return accountUpgradeRes
}

// Send verification code to email
const emailRegistrationId = await authServiceEmail.sendEmailWithCode({ email })
if (emailRegistrationId instanceof Error) return emailRegistrationId

Expand Down
5 changes: 5 additions & 0 deletions src/app/authentication/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ type NewAccountWithPhoneIdentifier = {
phone: PhoneNumber
}

type NewAccountWithEmailIdentifier = {
kratosUserId: UserId
email: EmailAddress
}

type LoginDeviceUpgradeWithPhoneResult = {
success: true
authToken?: AuthToken
Expand Down
8 changes: 5 additions & 3 deletions src/app/authentication/request-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,12 @@ export const requestEmailCode = async ({
}

const authServiceEmail = AuthWithEmailPasswordlessService()
const flow = await authServiceEmail.sendEmailWithCode({ email })
if (flow instanceof Error) return flow

return flow
// Use createIdentityForEmailRegistration which handles both new and existing users
const result = await authServiceEmail.createIdentityForEmailRegistration({ email })
if (result instanceof Error) return result

return result.flowId
}

const checkRequestCodeAttemptPerIpLimits = async (
Expand Down
9 changes: 7 additions & 2 deletions src/domain/authentication/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,15 @@ type CallbackSecretValidator = {

type RegistrationPayload = {
userId: UserId
phone: PhoneNumber
phoneMetadata: PhoneMetadata | undefined
phone?: PhoneNumber
email?: EmailAddress
phoneMetadata?: PhoneMetadata | undefined
}
type RegistrationPayloadValidator = {
validate(rawBody: {
identity_id?: string
phone?: string
email?: string
schema_id?: string
transient_payload?: { phoneMetadata?: Record<string, Record<string, string>> }
}): RegistrationPayload | ValidationError
Expand Down Expand Up @@ -136,6 +138,9 @@ interface IAuthWithEmailPasswordlessService {
kratosUserId: UserId
email: EmailAddress
}): Promise<IdentityPhoneEmail | AuthenticationError>
createIdentityForEmailRegistration(args: {
email: EmailAddress
}): Promise<{ kratosUserId: UserId; flowId: EmailFlowId } | KratosError>
sendEmailWithCode(args: { email: EmailAddress }): Promise<EmailFlowId | KratosError>
hasEmail(args: { kratosUserId: UserId }): Promise<boolean | KratosError>
isEmailVerified(args: { email: EmailAddress }): Promise<boolean | KratosError>
Expand Down
Loading