feat: Add SMS country code selector to SMT OTP (LoginV1) (#11373)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# Which Problems Are Solved

- This adds rudimentary country code handling for LoginV1 SMS OTP
- Previously defaulting to CH with no prompt to user causing e.g UK
(+44) based numbers to be re-routed to CH. (+41)

# Additional Context

- Support for SMS OTP is not complete for LoginV2 (according to latest
support email)

Tested with Twilio on both UK + DE # . Appears to work. 

- Closes #8891 

<img width="544" height="485" alt="image"
src="https://github.com/user-attachments/assets/84d31bd8-8863-465f-8860-b51602439dfb"
/>

<img width="858" height="1298" alt="Screenshot 2026-01-16 at 11 23 22"
src="https://github.com/user-attachments/assets/4bf16f0b-2fec-4d63-95b0-f0b73f3a131a"
/>

---------

Co-authored-by: Livio Spring <[email protected]>
Co-authored-by: Silvan <[email protected]>
This commit is contained in:
Guy Sleigh
2026-01-22 06:47:54 +00:00
committed by GitHub
co-authored by Livio Spring Silvan
parent 30d91cde58
commit 597f385ca4
25 changed files with 241 additions and 27 deletions
+25 -10
View File
@@ -12,17 +12,20 @@ const (
type smsInitData struct {
userData
Edit bool
MFAType domain.MFAType
Phone string
Edit bool
MFAType domain.MFAType
Phone string
CountryCode string
CountryCodes []domain.CountryCode
}
type smsInitFormData struct {
Edit bool `schema:"edit"`
Resend bool `schema:"resend"`
Phone string `schema:"phone"`
NewPhone string `schema:"newPhone"`
Code string `schema:"code"`
Edit bool `schema:"edit"`
Resend bool `schema:"resend"`
Phone string `schema:"phone"`
NewPhone string `schema:"newPhone"`
Code string `schema:"code"`
CountryCode string `schema:"countryCode"`
}
// handleRegisterOTPSMS checks if the user has a verified phone number and will directly add OTP SMS as 2FA.
@@ -57,6 +60,10 @@ func (l *Login) renderRegisterSMS(w http.ResponseWriter, r *http.Request, authRe
data.baseData = l.getBaseData(r, authReq, translator, "InitMFAOTP.Title", "InitMFAOTP.Description", err)
data.profileData = l.getProfileData(authReq)
data.MFAType = domain.MFATypeOTPSMS
data.CountryCodes = domain.GetCountryCodes(translator.Lang(r))
if data.CountryCode == "" {
data.CountryCode = "CH" // default to Switzerland
}
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplMFASMSInit], data, nil)
}
@@ -75,7 +82,7 @@ func (l *Login) handleRegisterSMSCheck(w http.ResponseWriter, r *http.Request) {
ctx := setUserContext(r.Context(), authReq.UserID, authReq.UserOrgID)
// save the current state
data := &smsInitData{Phone: formData.Phone}
data := &smsInitData{Phone: formData.Phone, CountryCode: formData.CountryCode}
if formData.Edit {
data.Edit = true
@@ -95,7 +102,15 @@ func (l *Login) handleRegisterSMSCheck(w http.ResponseWriter, r *http.Request) {
if formData.Code == "" {
data.Phone = formData.NewPhone
if formData.NewPhone != formData.Phone {
_, err = l.command.ChangeUserPhone(ctx, authReq.UserID, formData.NewPhone, l.userCodeAlg)
// Normalize phone with selected country code before saving
normalizedPhone, normErr := domain.PhoneNumber(formData.NewPhone).NormalizeWithRegion(formData.CountryCode)
if normErr != nil {
data.Edit = true
l.renderRegisterSMS(w, r, authReq, data, normErr)
return
}
data.Phone = string(normalizedPhone)
_, err = l.command.ChangeUserPhone(ctx, authReq.UserID, string(normalizedPhone), l.userCodeAlg)
if err != nil {
// stay in edit more
data.Edit = true
@@ -115,6 +115,7 @@ InitMFAOTPSMS:
Title: 2-факторна проверка
DescriptionPhone: Създайте своя 2-фактор. Въведете телефонния си номер, за да го потвърдите.
DescriptionCode: Създайте своя 2-фактор. Въведете получения код, за да потвърдите своя телефонен номер.
CountryCodeLabel: Държава
PhoneLabel: Тайна
CodeLabel: Код
EditButtonText: редактиране
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: 2-Faktorové ověření
DescriptionPhone: Vytvořte svou 2-faktorovou autentizaci. Zadejte své telefonní číslo k jeho ověření.
DescriptionCode: Vytvořte svou 2-faktorovou autentizaci. Zadejte obdržený kód k ověření vašeho telefonního čísla.
CountryCodeLabel: Země
PhoneLabel: Telefon
CodeLabel: Kód
EditButtonText: Upravit
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: Zweitfaktor hinzufügen
DescriptionPhone: Erstelle deinen Zweitfaktor. Gib deine Telefonnummer ein, um sie zu verifizieren.
DescriptionCode: Erstelle deinen Zweitfaktor. Gib den erhaltenen Code ein, um deine Telefonnummer zu verifizieren.
CountryCodeLabel: Land
PhoneLabel: Telefonnummer
CodeLabel: Code
EditButtonText: Bearbeiten
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: 2-Factor Verification
DescriptionPhone: Create your 2-factor. Enter your phone number to verify it.
DescriptionCode: Create your 2-factor. Enter the received code to verify your phone number.
CountryCodeLabel: Country
PhoneLabel: Phone
CodeLabel: Code
EditButtonText: Edit
+2 -1
View File
@@ -114,10 +114,11 @@ InitMFAOTP:
NextButtonText: siguiente
CancelButtonText: cancelar
InitMFASMS:
InitMFAOTPSMS:
Title: Verificación de doble factor
DescriptionPhone: Crea tu doble factor de autenticación. Introduce tu número de teléfono para verificarlo.
DescriptionCode: Crea tu doble factor de autenticación. Ingrese el código recibido para verificar su número de teléfono.
CountryCodeLabel: País
PhoneLabel: Número de teléfono
CodeLabel: Código
EditButtonText: editar
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: Vérification à deux facteurs
DescriptionPhone: Créez votre 2-facteurs. Entrez votre numéro de téléphone pour le vérifier.
DescriptionCode: Créez votre 2-facteurs. Entrez le code reçu pour vérifier votre numéro de téléphone.
CountryCodeLabel: Pays
PhoneLabel: Numéro de téléphone
CodeLabel: Code
EditButtonText: Modifier
@@ -105,6 +105,7 @@ InitMFAOTPSMS:
Title: Kétfaktoros hitelesítés
DescriptionPhone: Hozd létre a kétfaktoros hitelesítést. Add meg a telefonszámod a hitelesítéshez.
DescriptionCode: Hozd létre a kétfaktoros hitelesítést. Add meg a kapott kódot a telefonszámod hitelesítéséhez.
CountryCodeLabel: Ország
PhoneLabel: Telefon
CodeLabel: Kód
EditButtonText: Szerkesztés
@@ -105,6 +105,7 @@ InitMFAOTPSMS:
Title: Verifikasi 2 Faktor
DescriptionPhone: 'Buat 2 faktor Anda. '
DescriptionCode: 'Buat 2 faktor Anda. '
CountryCodeLabel: Negara
PhoneLabel: Telepon
CodeLabel: Kode
EditButtonText: Sunting
+2 -1
View File
@@ -114,10 +114,11 @@ InitMFAOTP:
NextButtonText: Avanti
CancelButtonText: annulla
InitMFASMS:
InitMFAOTPSMS:
Title: Verificazione a 2 fattori
DescriptionPhone: Crea il tuo 2 fattori. Inserisci il tuo numero di telefono per verificarlo.
DescriptionCode: Crea il tuo 2 fattori. Inserisci il codice ricevuto per verificare il tuo numero di telefono.
CountryCodeLabel: Paese
PhoneLabel: Numero di telefono
CodeLabel: Codice
EditButtonText: Modifica
+2 -1
View File
@@ -114,10 +114,11 @@ InitMFAOTP:
NextButtonText: 次へ
CancelButtonText: キャンセル
InitMFASMS:
InitMFAOTPSMS:
Title: 二要素認証
DescriptionPhone: 二要素認証を作成します。確認するには電話番号を入力してください。
DescriptionCode: 二要素認証を作成します。受信したコードを入力して電話番号を確認します。
CountryCodeLabel: 国
PhoneLabel: 電話番号
CodeLabel: コード
EditButtonText: 編集
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: 2단계 인증
DescriptionPhone: 2단계 인증을 설정하세요. 전화번호를 입력하여 인증하세요.
DescriptionCode: 2단계 인증을 설정하세요. 받은 코드를 입력하여 전화번호를 인증하세요.
CountryCodeLabel: 국가
PhoneLabel: 전화번호
CodeLabel: 코드
EditButtonText: 수정
+2 -1
View File
@@ -114,10 +114,11 @@ InitMFAOTP:
NextButtonText: следно
CancelButtonText: откажи
InitMFASMS:
InitMFAOTPSMS:
Title: Потврда на 2-факторска автентикација
DescriptionPhone: Направете двофакторна автентикација. Внесете го вашиот телефонски број за да го потврдите.
DescriptionCode: Направете двофакторна автентикација. Внесете го примениот код за да го потврдите вашиот телефонски број.
CountryCodeLabel: Земја
PhoneLabel: Телефонски број
CodeLabel: Код
EditButtonText: Уредување
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: 2-Factor Verificatie
DescriptionPhone: Maak uw 2-factor aan. Voer uw telefoonnummer in om te verifiëren.
DescriptionCode: Maak uw 2-factor aan. Voer de ontvangen code in om uw telefoonnummer te verifiëren.
CountryCodeLabel: Land
PhoneLabel: Telefoon
CodeLabel: Code
EditButtonText: Bewerken
+2 -1
View File
@@ -114,10 +114,11 @@ InitMFAOTP:
NextButtonText: dalej
CancelButtonText: anuluj
InitMFASMS:
InitMFAOTPSMS:
Title: Weryfikacja 2-etapowa
DescriptionPhone: Utwórz uwierzytelnianie dwuskładnikowe. Wprowadź swój numer telefonu, aby go zweryfikować.
DescriptionCode: Utwórz uwierzytelnianie dwuskładnikowe. Wprowadź otrzymany kod, aby zweryfikować swój numer telefonu.
CountryCodeLabel: Kraj
PhoneLabel: Numer telefonu
CodeLabel: Kod
EditButtonText: edytować
+2 -1
View File
@@ -114,10 +114,11 @@ InitMFAOTP:
NextButtonText: próximo
CancelButtonText: cancelar
InitMFASMS:
InitMFAOTPSMS:
Title: Verificação de 2 fatores
DescriptionPhone: Crie sua verificação de 2 fatores. Digite seu número de telefone para verificá-lo.
DescriptionCode: Crie sua verificação de 2 fatores. Digite o código recebido para verificar seu número de telefone.
CountryCodeLabel: País
PhoneLabel: Número de telefone
CodeLabel: Código
EditButtonText: editar
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: Verificare în 2 pași
DescriptionPhone: Creează-ți autentificarea cu 2 factori. Introdu numărul tău de telefon pentru a-l verifica.
DescriptionCode: Creează-ți autentificarea cu 2 factori. Introdu codul primit pentru a-ți verifica numărul de telefon.
CountryCodeLabel: Țară
PhoneLabel: Telefon
CodeLabel: Cod
EditButtonText: Editează
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: Настройка двухфакторной аутентификации
DescriptionPhone: Введите номер телефона для его подтверждения.
DescriptionCode: Введите код из СМС для подтверждения номера телефона.
CountryCodeLabel: Страна
PhoneLabel: Номер телефона
CodeLabel: Код из СМС
EditButtonText: Изменить
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: Tvåfaktor verifiering
DescriptionPhone: Verifiera med en andra faktor. Ange ditt mobilnummer.
DescriptionCode: Verifiera med en andra faktor. Ange koden du fått på SMS för att bekräfta ditt mobilnr.
CountryCodeLabel: Land
PhoneLabel: Mobilnr
CodeLabel: Kod
EditButtonText: Ändra
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: 2-Faktör Doğrulama
DescriptionPhone: 2-faktörünüzü oluşturun. Doğrulamak için telefon numaranızı girin.
DescriptionCode: 2-faktörünüzü oluşturun. Telefon numaranızı doğrulamak için aldığınız kodu girin.
CountryCodeLabel: Ülke
PhoneLabel: Telefon
CodeLabel: Kod
EditButtonText: Düzenle
@@ -118,6 +118,7 @@ InitMFAOTPSMS:
Title: 2-факторна перевірка
DescriptionPhone: Створіть вашу 2-факторну автентифікацію. Введіть ваш номер телефону для підтвердження.
DescriptionCode: Створіть вашу 2-факторну автентифікацію. Введіть отриманий код для підтвердження вашого номера телефону.
CountryCodeLabel: Країна
PhoneLabel: Телефон
CodeLabel: Код
EditButtonText: Редагувати
+10 -9
View File
@@ -109,21 +109,22 @@ InitMFAOTP:
Title: 双因素验证
Description: 创建你的双因素。如果你还没有,请下载一个认证器应用程序。
OTPDescription: 使用您的身份验证器应用程序(例如 Google Authenticator)扫描代码或复制密码并在下方插入生成的代码。
SecretLabel: 秘钥
CodeLabel: 验证码
NextButtonText: 继续
CancelButtonText: 取消
InitMFAOTPSMS:
Title: 双因素验证
DescriptionPhone: 创建双因素身份验证。输入您的电话号码进行验证。
DescriptionCode: 创建双因素身份验证。输入收到的代码以验证您的电话号码。
CountryCodeLabel: 国家
PhoneLabel: 电话号码
CodeLabel: 验证码
EditButtonText: 编辑
ResendButtonText: 重发代码
NextButtonText: 继续
InitMFASMS:
Title: 双因素验证
DescriptionPhone: 创建双因素身份验证。输入您的电话号码进行验证。
DescriptionCode: 创建双因素身份验证。输入收到的代码以验证您的电话号码。
SecretLabel: 秘钥
CodeLabel: 验证码
NextButtonText: 继续
CancelButtonText: 取消
InitMFAU2F:
Title: 添加安全密钥
Description: 安全密钥是一种验证方法,可以内置在手机中、使用蓝牙或直接插入计算机的 USB 端口。
@@ -22,15 +22,24 @@
<div class="fields">
{{if .Edit}}
<div class="field">
<label class="lgn-label" for="countryCode">{{t "InitMFAOTPSMS.CountryCodeLabel"}}</label>
<select class="lgn-input" id="countryCode" name="countryCode" required>
{{range .CountryCodes}}
<option value="{{.ISOCode}}" {{if eq .ISOCode $.CountryCode}}selected{{end}}>{{.Name}} ({{.CallingCode}})</option>
{{end}}
</select>
</div>
<div class="field">
<label class="lgn-label" for="newPhone">{{t "InitMFAOTPSMS.PhoneLabel"}}</label>
<input class="lgn-input" type="tel" id="newPhone" name="newPhone" autocomplete="off" value="{{.Phone}}" autofocus required>
</div>
{{else}}
<input type="hidden" name="countryCode" value="{{ .CountryCode }}" />
<div class="field lgn-actions">
<p>{{.Phone}}</p>
<span class="fill-space"></span>
<button type="button" id="edit" name="edit" value="true" class="lgn-stroked-button" formnovalidate>{{t "InitMFAOTPSMS.EditButtonText"}}</button>
<button type="submit" id="edit" name="edit" value="true" class="lgn-stroked-button" formnovalidate>{{t "InitMFAOTPSMS.EditButtonText"}}</button>
</div>
{{end}}
{{if and .Phone (not .Edit) }}
+53 -1
View File
@@ -1,9 +1,13 @@
package domain
import (
"fmt"
"sort"
"time"
"github.com/ttacon/libphonenumber"
"golang.org/x/text/language"
"golang.org/x/text/language/display"
"github.com/zitadel/zitadel/internal/crypto"
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
@@ -15,10 +19,17 @@ const defaultRegion = "CH"
type PhoneNumber string
func (p PhoneNumber) Normalize() (PhoneNumber, error) {
return p.NormalizeWithRegion(defaultRegion)
}
func (p PhoneNumber) NormalizeWithRegion(region string) (PhoneNumber, error) {
if p == "" {
return p, zerrors.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty")
}
phoneNr, err := libphonenumber.Parse(string(p), defaultRegion)
if region == "" {
region = defaultRegion
}
phoneNr, err := libphonenumber.Parse(string(p), region)
if err != nil {
return p, zerrors.ThrowInvalidArgument(err, "PHONE-so0wa", "Errors.User.Phone.Invalid")
}
@@ -71,3 +82,44 @@ func (s PhoneState) Valid() bool {
func (s PhoneState) Exists() bool {
return s == PhoneStateActive
}
// CountryCode represents a country with its ISO code and calling code
type CountryCode struct {
ISOCode string
CallingCode string
Name string
}
// GetCountryCodes returns a list of country codes for phone number input using libphonenumber
func GetCountryCodes(lang language.Tag) []CountryCode {
regions := libphonenumber.GetSupportedRegions()
namer := display.Regions(lang)
codes := make([]CountryCode, 0, len(regions))
for region := range regions {
countryCode := libphonenumber.GetCountryCodeForRegion(region)
if countryCode == 0 {
continue
}
// Get the display name for the region
regionTag, err := language.ParseRegion(region)
name := region
if err == nil {
name = namer.Name(regionTag)
}
codes = append(codes, CountryCode{
ISOCode: region,
CallingCode: fmt.Sprintf("+%d", countryCode),
Name: name,
})
}
// Sort by country name
sort.Slice(codes, func(i, j int) bool {
return codes[i].Name < codes[j].Name
})
return codes
}
+117
View File
@@ -1,8 +1,12 @@
package domain
import (
"sort"
"strings"
"testing"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/zerrors"
)
@@ -104,3 +108,116 @@ func TestFormatPhoneNumber(t *testing.T) {
})
}
}
func TestGetCountryCodes(t *testing.T) {
codes := GetCountryCodes(language.English)
t.Run("returns non-empty list", func(t *testing.T) {
if len(codes) == 0 {
t.Error("GetCountryCodes() returned empty list")
}
})
t.Run("returns more than 200 countries", func(t *testing.T) {
// libphonenumber supports 240+ regions
if len(codes) < 200 {
t.Errorf("GetCountryCodes() returned %d countries, expected at least 200", len(codes))
}
})
t.Run("list is sorted by name", func(t *testing.T) {
sorted := sort.SliceIsSorted(codes, func(i, j int) bool {
return codes[i].Name < codes[j].Name
})
if !sorted {
t.Error("GetCountryCodes() did not return a list sorted by name")
}
})
t.Run("all entries have valid format", func(t *testing.T) {
for _, code := range codes {
if code.ISOCode == "" {
t.Errorf("country code has empty ISOCode: %+v", code)
}
if len(code.ISOCode) < 2 {
t.Errorf("country code has invalid ISOCode length: %+v", code)
}
if !strings.HasPrefix(code.CallingCode, "+") {
t.Errorf("country code CallingCode does not start with '+': %+v", code)
}
if code.Name == "" {
t.Errorf("country code has empty Name: %+v", code)
}
}
})
}
func TestGetCountryCodes_ContainsExpectedCountries(t *testing.T) {
codes := GetCountryCodes(language.English)
// Build a map for easier lookup
codeMap := make(map[string]CountryCode)
for _, code := range codes {
codeMap[code.ISOCode] = code
}
tests := []struct {
name string
isoCode string
callingCode string
countryName string
}{
{
name: "United States",
isoCode: "US",
callingCode: "+1",
countryName: "United States",
},
{
name: "Switzerland",
isoCode: "CH",
callingCode: "+41",
countryName: "Switzerland",
},
{
name: "United Kingdom",
isoCode: "GB",
callingCode: "+44",
countryName: "United Kingdom",
},
{
name: "Germany",
isoCode: "DE",
callingCode: "+49",
countryName: "Germany",
},
{
name: "Australia",
isoCode: "AU",
callingCode: "+61",
countryName: "Australia",
},
{
name: "Japan",
isoCode: "JP",
callingCode: "+81",
countryName: "Japan",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, found := codeMap[tt.isoCode]
if !found {
t.Errorf("GetCountryCodes() does not contain %s (%s)", tt.name, tt.isoCode)
return
}
if code.CallingCode != tt.callingCode {
t.Errorf("GetCountryCodes() %s CallingCode = %s, want %s", tt.isoCode, code.CallingCode, tt.callingCode)
}
if code.Name != tt.countryName {
t.Errorf("GetCountryCodes() %s Name = %s, want %s", tt.isoCode, code.Name, tt.countryName)
}
})
}
}