diff --git a/apps/consent/app/graphql/generated.ts b/apps/consent/app/graphql/generated.ts index 108647cdb6..e4d6c57ff8 100644 --- a/apps/consent/app/graphql/generated.ts +++ b/apps/consent/app/graphql/generated.ts @@ -72,6 +72,8 @@ export type Scalars = { SignedAmount: { input: number; output: number; } /** A string amount (of a currency) that can be negative (e.g. in a transaction) */ SignedDisplayMajorAmount: { input: string; output: string; } + /** Nonce provided by Telegram Passport to validate the login/upgrade flow */ + TelegramPassportNonce: { input: string; output: string; } /** Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch) */ Timestamp: { input: number; output: number; } /** A time-based one-time password */ @@ -1045,6 +1047,7 @@ export type Mutation = { readonly userEmailRegistrationValidate: UserEmailRegistrationValidatePayload; readonly userLogin: AuthTokenPayload; readonly userLoginUpgrade: UpgradePayload; + readonly userLoginUpgradeTelegram: UpgradePayload; readonly userLogout: SuccessPayload; readonly userPhoneDelete: UserPhoneDeletePayload; readonly userPhoneRegistrationInitiate: SuccessPayload; @@ -1283,6 +1286,11 @@ export type MutationUserLoginUpgradeArgs = { }; +export type MutationUserLoginUpgradeTelegramArgs = { + input: UserLoginUpgradeTelegramInput; +}; + + export type MutationUserLogoutArgs = { input?: InputMaybe; }; @@ -2078,6 +2086,11 @@ export type UserLoginUpgradeInput = { readonly phone: Scalars['Phone']['input']; }; +export type UserLoginUpgradeTelegramInput = { + readonly nonce: Scalars['TelegramPassportNonce']['input']; + readonly phone: Scalars['Phone']['input']; +}; + export type UserLogoutInput = { readonly deviceToken: Scalars['String']['input']; }; @@ -2346,4 +2359,4 @@ export function useGetUserIdSuspenseQuery(baseOptions?: Apollo.SkipToken | Apoll export type GetUserIdQueryHookResult = ReturnType; export type GetUserIdLazyQueryHookResult = ReturnType; export type GetUserIdSuspenseQueryHookResult = ReturnType; -export type GetUserIdQueryResult = Apollo.QueryResult; +export type GetUserIdQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/consent/codegen.yml b/apps/consent/codegen.yml index 73c2c50e2a..7567e509e1 100644 --- a/apps/consent/codegen.yml +++ b/apps/consent/codegen.yml @@ -65,3 +65,4 @@ generates: ContactHandle: "string" ContactType: "string" ContactDisplayName: "string" + TelegramPassportNonce: "string" diff --git a/apps/dashboard/codegen.yml b/apps/dashboard/codegen.yml index 2983bbfae2..02e6d2f1a1 100644 --- a/apps/dashboard/codegen.yml +++ b/apps/dashboard/codegen.yml @@ -74,3 +74,4 @@ generates: ContactHandle: "string" ContactType: "string" ContactDisplayName: "string" + TelegramPassportNonce: "string" diff --git a/apps/dashboard/services/graphql/generated.ts b/apps/dashboard/services/graphql/generated.ts index 712af87310..3e070c782c 100644 --- a/apps/dashboard/services/graphql/generated.ts +++ b/apps/dashboard/services/graphql/generated.ts @@ -75,6 +75,8 @@ export type Scalars = { SignedAmount: { input: number; output: number; } /** A string amount (of a currency) that can be negative (e.g. in a transaction) */ SignedDisplayMajorAmount: { input: string; output: string; } + /** Nonce provided by Telegram Passport to validate the login/upgrade flow */ + TelegramPassportNonce: { input: string; output: string; } /** Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch) */ Timestamp: { input: number; output: number; } /** A time-based one-time password */ @@ -1143,6 +1145,7 @@ export type Mutation = { readonly userEmailRegistrationValidate: UserEmailRegistrationValidatePayload; readonly userLogin: AuthTokenPayload; readonly userLoginUpgrade: UpgradePayload; + readonly userLoginUpgradeTelegram: UpgradePayload; readonly userLogout: SuccessPayload; readonly userPhoneDelete: UserPhoneDeletePayload; readonly userPhoneRegistrationInitiate: SuccessPayload; @@ -1396,6 +1399,11 @@ export type MutationUserLoginUpgradeArgs = { }; +export type MutationUserLoginUpgradeTelegramArgs = { + input: UserLoginUpgradeTelegramInput; +}; + + export type MutationUserLogoutArgs = { input?: InputMaybe; }; @@ -2267,6 +2275,11 @@ export type UserLoginUpgradeInput = { readonly phone: Scalars['Phone']['input']; }; +export type UserLoginUpgradeTelegramInput = { + readonly nonce: Scalars['TelegramPassportNonce']['input']; + readonly phone: Scalars['Phone']['input']; +}; + export type UserLogoutInput = { readonly deviceToken: Scalars['String']['input']; }; @@ -3761,6 +3774,7 @@ export type ResolversTypes = { SupportChatMessageAddPayload: ResolverTypeWrapper & { errors: ReadonlyArray }>; SupportMessage: ResolverTypeWrapper; SupportRole: SupportRole; + TelegramPassportNonce: ResolverTypeWrapper; Timestamp: ResolverTypeWrapper; TotpCode: ResolverTypeWrapper; TotpRegistrationId: ResolverTypeWrapper; @@ -3785,6 +3799,7 @@ export type ResolversTypes = { UserEmailRegistrationValidatePayload: ResolverTypeWrapper & { errors: ReadonlyArray, me?: Maybe }>; UserLoginInput: UserLoginInput; UserLoginUpgradeInput: UserLoginUpgradeInput; + UserLoginUpgradeTelegramInput: UserLoginUpgradeTelegramInput; UserLogoutInput: UserLogoutInput; UserPhoneDeletePayload: ResolverTypeWrapper & { errors: ReadonlyArray, me?: Maybe }>; UserPhoneRegistrationInitiateInput: UserPhoneRegistrationInitiateInput; @@ -3988,6 +4003,7 @@ export type ResolversParentTypes = { SupportChatMessageAddInput: SupportChatMessageAddInput; SupportChatMessageAddPayload: Omit & { errors: ReadonlyArray }; SupportMessage: SupportMessage; + TelegramPassportNonce: Scalars['TelegramPassportNonce']['output']; Timestamp: Scalars['Timestamp']['output']; TotpCode: Scalars['TotpCode']['output']; TotpRegistrationId: Scalars['TotpRegistrationId']['output']; @@ -4009,6 +4025,7 @@ export type ResolversParentTypes = { UserEmailRegistrationValidatePayload: Omit & { errors: ReadonlyArray, me?: Maybe }; UserLoginInput: UserLoginInput; UserLoginUpgradeInput: UserLoginUpgradeInput; + UserLoginUpgradeTelegramInput: UserLoginUpgradeTelegramInput; UserLogoutInput: UserLogoutInput; UserPhoneDeletePayload: Omit & { errors: ReadonlyArray, me?: Maybe }; UserPhoneRegistrationInitiateInput: UserPhoneRegistrationInitiateInput; @@ -4626,6 +4643,7 @@ export type MutationResolvers>; userLogin?: Resolver>; userLoginUpgrade?: Resolver>; + userLoginUpgradeTelegram?: Resolver>; userLogout?: Resolver>; userPhoneDelete?: Resolver; userPhoneRegistrationInitiate?: Resolver>; @@ -4967,6 +4985,10 @@ export type SupportMessageResolvers; }; +export interface TelegramPassportNonceScalarConfig extends GraphQLScalarTypeConfig { + name: 'TelegramPassportNonce'; +} + export interface TimestampScalarConfig extends GraphQLScalarTypeConfig { name: 'Timestamp'; } @@ -5304,6 +5326,7 @@ export type Resolvers = { SuccessPayload?: SuccessPayloadResolvers; SupportChatMessageAddPayload?: SupportChatMessageAddPayloadResolvers; SupportMessage?: SupportMessageResolvers; + TelegramPassportNonce?: GraphQLScalarType; Timestamp?: GraphQLScalarType; TotpCode?: GraphQLScalarType; TotpRegistrationId?: GraphQLScalarType; diff --git a/apps/map/codegen.yml b/apps/map/codegen.yml index 882eeef9b4..2709299b34 100644 --- a/apps/map/codegen.yml +++ b/apps/map/codegen.yml @@ -66,3 +66,4 @@ generates: ContactHandle: "string" ContactType: "string" ContactDisplayName: "string" + TelegramPassportNonce: "string" diff --git a/apps/map/services/galoy/graphql/generated.ts b/apps/map/services/galoy/graphql/generated.ts index 37fa8b5092..b60ebdf39d 100644 --- a/apps/map/services/galoy/graphql/generated.ts +++ b/apps/map/services/galoy/graphql/generated.ts @@ -72,6 +72,8 @@ export type Scalars = { SignedAmount: { input: number; output: number; } /** A string amount (of a currency) that can be negative (e.g. in a transaction) */ SignedDisplayMajorAmount: { input: string; output: string; } + /** Nonce provided by Telegram Passport to validate the login/upgrade flow */ + TelegramPassportNonce: { input: string; output: string; } /** Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch) */ Timestamp: { input: number; output: number; } /** A time-based one-time password */ @@ -1045,6 +1047,7 @@ export type Mutation = { readonly userEmailRegistrationValidate: UserEmailRegistrationValidatePayload; readonly userLogin: AuthTokenPayload; readonly userLoginUpgrade: UpgradePayload; + readonly userLoginUpgradeTelegram: UpgradePayload; readonly userLogout: SuccessPayload; readonly userPhoneDelete: UserPhoneDeletePayload; readonly userPhoneRegistrationInitiate: SuccessPayload; @@ -1283,6 +1286,11 @@ export type MutationUserLoginUpgradeArgs = { }; +export type MutationUserLoginUpgradeTelegramArgs = { + input: UserLoginUpgradeTelegramInput; +}; + + export type MutationUserLogoutArgs = { input?: InputMaybe; }; @@ -2078,6 +2086,11 @@ export type UserLoginUpgradeInput = { readonly phone: Scalars['Phone']['input']; }; +export type UserLoginUpgradeTelegramInput = { + readonly nonce: Scalars['TelegramPassportNonce']['input']; + readonly phone: Scalars['Phone']['input']; +}; + export type UserLogoutInput = { readonly deviceToken: Scalars['String']['input']; }; @@ -2360,4 +2373,4 @@ export function useBusinessMapMarkersSuspenseQuery(baseOptions?: Apollo.SkipToke export type BusinessMapMarkersQueryHookResult = ReturnType; export type BusinessMapMarkersLazyQueryHookResult = ReturnType; export type BusinessMapMarkersSuspenseQueryHookResult = ReturnType; -export type BusinessMapMarkersQueryResult = Apollo.QueryResult; +export type BusinessMapMarkersQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/pay/codegen.yml b/apps/pay/codegen.yml index 01702fbadc..a2272a5dee 100644 --- a/apps/pay/codegen.yml +++ b/apps/pay/codegen.yml @@ -74,3 +74,4 @@ generates: ContactHandle: "string" ContactType: "string" ContactDisplayName: "string" + TelegramPassportNonce: "string" diff --git a/apps/pay/lib/graphql/generated.ts b/apps/pay/lib/graphql/generated.ts index 86f2e4fb98..5ad50aa230 100644 --- a/apps/pay/lib/graphql/generated.ts +++ b/apps/pay/lib/graphql/generated.ts @@ -72,6 +72,8 @@ export type Scalars = { SignedAmount: { input: number; output: number; } /** A string amount (of a currency) that can be negative (e.g. in a transaction) */ SignedDisplayMajorAmount: { input: string; output: string; } + /** Nonce provided by Telegram Passport to validate the login/upgrade flow */ + TelegramPassportNonce: { input: string; output: string; } /** Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch) */ Timestamp: { input: number; output: number; } /** A time-based one-time password */ @@ -1046,6 +1048,7 @@ export type Mutation = { readonly userEmailRegistrationValidate: UserEmailRegistrationValidatePayload; readonly userLogin: AuthTokenPayload; readonly userLoginUpgrade: UpgradePayload; + readonly userLoginUpgradeTelegram: UpgradePayload; readonly userLogout: SuccessPayload; readonly userPhoneDelete: UserPhoneDeletePayload; readonly userPhoneRegistrationInitiate: SuccessPayload; @@ -1284,6 +1287,11 @@ export type MutationUserLoginUpgradeArgs = { }; +export type MutationUserLoginUpgradeTelegramArgs = { + input: UserLoginUpgradeTelegramInput; +}; + + export type MutationUserLogoutArgs = { input?: InputMaybe; }; @@ -2079,6 +2087,11 @@ export type UserLoginUpgradeInput = { readonly phone: Scalars['Phone']['input']; }; +export type UserLoginUpgradeTelegramInput = { + readonly nonce: Scalars['TelegramPassportNonce']['input']; + readonly phone: Scalars['Phone']['input']; +}; + export type UserLogoutInput = { readonly deviceToken: Scalars['String']['input']; }; @@ -3016,4 +3029,4 @@ export function usePriceSubscription(baseOptions: Apollo.SubscriptionHookOptions return Apollo.useSubscription(PriceDocument, options); } export type PriceSubscriptionHookResult = ReturnType; -export type PriceSubscriptionResult = Apollo.SubscriptionResult; +export type PriceSubscriptionResult = Apollo.SubscriptionResult; \ No newline at end of file diff --git a/bats/core/api/auth.bats b/bats/core/api/auth.bats index 8cd9b592b2..8b16560aba 100644 --- a/bats/core/api/auth.bats +++ b/bats/core/api/auth.bats @@ -153,6 +153,7 @@ generateTotpCode() { curl_request "http://${GALOY_ENDPOINT}/auth/telegram-passport/request-params" "$variables" nonce=$(curl_output '.nonce') [ -n "$nonce" ] || exit 1 + cache_value "telegram.nonce" "$nonce" # Step 2: Try to login with the nonce before Telegram Passport webhook is called variables="{\"nonce\": \"$nonce\", \"phone\": \"$phone\"}" @@ -186,6 +187,24 @@ generateTotpCode() { [[ "$error" =~ "Invalid nonce $nonce" ]] || exit 1 } +@test "auth: telegram upgrade fails with used nonce" { + local phone="$(read_value diana.phone)" + local nonce="$(read_value telegram.nonce)" + + variables=$( + jq -n \ + --arg phone "$phone" \ + --arg nonce "$nonce" \ + '{input: {phone: $phone, nonce: $nonce}}' + ) + + exec_graphql 'diana' 'user-login-upgrade-telegram' "$variables" + + [[ "$(graphql_output '.data.userLoginUpgradeTelegram.success')" == "false" ]] || exit 1 + [[ "$(graphql_output '.data.userLoginUpgradeTelegram.errors[0].code')" == "NOT_FOUND" ]] || exit 1 + [[ "$(graphql_output '.data.userLoginUpgradeTelegram.errors[0].message')" == "Invalid nonce ${nonce}" ]] || exit 1 +} + @test "auth: remove phone login" { email=$(read_value 'charlie.email') diff --git a/bats/core/api/device-account.bats b/bats/core/api/device-account.bats index c1ec2cce2b..be301ff8a9 100644 --- a/bats/core/api/device-account.bats +++ b/bats/core/api/device-account.bats @@ -1,6 +1,11 @@ load "../../helpers/_common.bash" load "../../helpers/cli.bash" load "../../helpers/user.bash" +load "../../helpers/telegram.bash" + +setup_file() { + clear_cache +} DEVICE_NAME="device-user" @@ -46,6 +51,32 @@ jwt="eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiOTdiMjIxLWNhMDgtNGViMi05ZDA5LWE1NzcwZmNjZWI [[ "$refetched_account_id" == "$account_id" ]] || exit 1 } +@test "device-account: upgrade fails if phone already exists" { + token_name="$DEVICE_NAME" + + create_user 'fran' + phone_number="$(read_value fran.phone)" + + code="000000" + variables=$( + jq -n \ + --arg phone "$phone_number" \ + --arg code "$code" \ + '{input: {phone: $phone, code: $code}}' + ) + exec_graphql "$token_name" 'user-login-upgrade' "$variables" + + err_code="$(graphql_output '.data.userLoginUpgrade.errors[0].code')" + if [[ "$err_code" != "PHONE_ALREADY_ATTACHED_ERROR" ]]; then + echo "Unexpected error code: $err_code" + exit 1 + fi + + exec_graphql "$token_name" 'account-details' + level="$(graphql_output '.data.me.defaultAccount.level')" + [[ "$level" == "ZERO" ]] || exit 1 +} + @test "device-account: upgrade" { token_name="$DEVICE_NAME" code="000000" @@ -78,3 +109,50 @@ jwt="eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiOTdiMjIxLWNhMDgtNGViMi05ZDA5LWE1NzcwZmNjZWI delete_success="$(graphql_output '.data.accountDelete.success')" [[ "$delete_success" == "true" ]] || exit 1 } + +@test "device-account: telegram upgrade success" { + # + # TODO: Remove skip method + # + skip + local token_name="device-user-telegram" + local appcheck_header="Appcheck: $jwt" + + local username="$(random_uuid)" + local password="$(random_uuid)" + local basic_token="$(echo -n $username:$password | base64 -w 0)" + local auth_header="Authorization: Basic $basic_token" + + curl_request "$url" "" "$auth_header" "$appcheck_header" + local new_token="$(echo $output | jq -r '.result')" + [[ "$new_token" != "null" && -n "$new_token" ]] || exit 1 + cache_value "$token_name" "$new_token" + + exec_graphql "$token_name" 'account-details' + [[ "$(graphql_output '.data.me.defaultAccount.level')" == "ZERO" ]] || exit 1 + + # Request nonce + local phone="$(random_phone)" + cache_value "$token_name.phone" "$phone" + curl_request "http://${GALOY_ENDPOINT}/auth/telegram-passport/request-params" "{\"phone\":\"$phone\"}" + local nonce="$(curl_output '.nonce')" + [ -n "$nonce" ] || exit 1 + + # Pending before webhook + local variables=$( + jq -n \ + --arg phone "$phone" \ + --arg nonce "$nonce" \ + '{input: {phone: $phone, nonce: $nonce}}' + ) + exec_graphql "$token_name" 'user-login-upgrade-telegram' "$variables" + [[ "$(graphql_output '.data.userLoginUpgradeTelegram.success')" == "false" ]] || exit 1 + + simulateTelegramPassportWebhook "$nonce" "${phone//+/}" + + exec_graphql "$token_name" 'user-login-upgrade-telegram' "$variables" + [[ "$(graphql_output '.data.userLoginUpgradeTelegram.success')" == "true" ]] || exit 1 + + exec_graphql "$token_name" 'account-details' + [[ "$(graphql_output '.data.me.defaultAccount.level')" == "ONE" ]] || exit 1 +} diff --git a/bats/gql/user-login-upgrade-telegram.gql b/bats/gql/user-login-upgrade-telegram.gql new file mode 100644 index 0000000000..7053696b5b --- /dev/null +++ b/bats/gql/user-login-upgrade-telegram.gql @@ -0,0 +1,10 @@ +mutation userLoginUpgradeTelegram($input: UserLoginUpgradeTelegramInput!) { + userLoginUpgradeTelegram(input: $input) { + success + authToken + errors { + code + message + } + } +} diff --git a/core/api/src/app/authentication/login.ts b/core/api/src/app/authentication/login.ts index f45384f505..771bcd2415 100644 --- a/core/api/src/app/authentication/login.ts +++ b/core/api/src/app/authentication/login.ts @@ -24,6 +24,7 @@ import { getAccountsOnboardConfig, getDefaultAccountsConfig } from "@/config" import { EmailUnverifiedError, IdentifierNotFoundError, + PhoneAlreadyExistsError, InvalidNoncePhoneTelegramPassportError, InvalidNonceTelegramPassportError, WaitingDataTelegramPassportError, @@ -41,13 +42,13 @@ import { AuthWithUsernamePasswordDeviceIdService, IdentityRepository, } from "@/services/kratos" -import { LedgerService } from "@/services/ledger" -import { WalletsRepository } from "@/services/mongoose" import { addAttributesToCurrentSpan, recordExceptionInCurrentSpan, } from "@/services/tracing" import { isPhoneCodeValid } from "@/services/phone-provider" +import { consumeLimiter } from "@/services/rate-limit" +import { RedisCacheService } from "@/services/cache" import { IPMetadataAuthorizer } from "@/domain/accounts-ips/ip-metadata-authorizer" @@ -59,13 +60,9 @@ import { import { IpFetcher } from "@/services/ipfetcher" import { IpFetcherServiceError } from "@/domain/ipfetcher" -import { PhoneAccountAlreadyExistsNeedToSweepFundsError } from "@/domain/kratos" import { RateLimitConfig } from "@/domain/rate-limit" import { RateLimiterExceededError } from "@/domain/rate-limit/errors" import { ErrorLevel } from "@/domain/shared" -import { consumeLimiter } from "@/services/rate-limit" - -import { RedisCacheService } from "@/services/cache" const redisCache = RedisCacheService() @@ -227,7 +224,6 @@ export const loginDeviceUpgradeWithPhone = async ({ const identities = IdentityRepository() const userId = await identities.getUserIdFromIdentifier(phone) - // Happy Path - phone account does not exist if (userId instanceof IdentifierNotFoundError) { // a. create kratos account // b. and c. migrate account/user collection in mongo via kratos/registration webhook @@ -251,26 +247,9 @@ export const loginDeviceUpgradeWithPhone = async ({ return { success } } - // Complex path - Phone account already exists - // is there still txns left over on the device account? - const deviceWallets = await WalletsRepository().listByAccountId(account.id) - if (deviceWallets instanceof Error) return deviceWallets - const ledger = LedgerService() - let deviceAccountHasBalance = false - for (const wallet of deviceWallets) { - const balance = await ledger.getWalletBalance(wallet.id) - if (balance instanceof Error) return balance - if (balance > 0) { - deviceAccountHasBalance = true - } - } - if (deviceAccountHasBalance) return new PhoneAccountAlreadyExistsNeedToSweepFundsError() + if (userId instanceof Error) return userId - // no txns on device account but phone account exists, just log the user in with the phone account - const authService = AuthWithPhonePasswordlessService() - const kratosResult = await authService.loginToken({ phone }) - if (kratosResult instanceof Error) return kratosResult - return { success: true, authToken: kratosResult.authToken } + return new PhoneAlreadyExistsError() } export const loginTelegramPassportNonceWithPhone = async ({ @@ -379,6 +358,84 @@ export const loginTelegramPassportNonceWithPhone = async ({ } } +export const loginDeviceUpgradeWithTelegramPassportNonce = async ({ + phone, + nonce, + ip, + account, +}: { + phone: PhoneNumber + nonce: TelegramPassportNonce + ip: IpAddress + account: Account +}): Promise => { + const isValidPhoneForChannel = checkedToChannel(phone, ChannelType.Telegram) + if (isValidPhoneForChannel instanceof Error) return isValidPhoneForChannel + + { + const limitOk = await checkFailedLoginAttemptPerIpLimits(ip) + if (limitOk instanceof Error) return limitOk + } + { + const limitOk = await checkLoginAttemptPerLoginIdentifierLimits(phone) + if (limitOk instanceof Error) return limitOk + } + + const loginKey = telegramPassportLoginKey(nonce) + const phoneNumberFromNonce = await redisCache.get({ key: loginKey }) + if (phoneNumberFromNonce instanceof Error) { + // if it is valid telegram has not sent data to the webhook + const requestKey = telegramPassportRequestKey(nonce) + const validRequestNonce = await redisCache.get({ key: requestKey }) + if (validRequestNonce instanceof Error) + return new InvalidNonceTelegramPassportError(nonce) + + return new WaitingDataTelegramPassportError(nonce) + } + + if (phoneNumberFromNonce !== phone) { + return new InvalidNoncePhoneTelegramPassportError(nonce) + } + + // invalidate login with the same nonce + await redisCache.clear({ key: loginKey }) + + await rewardFailedLoginAttemptPerIpLimits(ip) + + const identities = IdentityRepository() + const userId = await identities.getUserIdFromIdentifier(phone) + + if (userId instanceof IdentifierNotFoundError) { + // user is a new user + // this branch exists because we currently make no difference between a registration and login + addAttributesToCurrentSpan({ "login.newAccount": true }) + + const phoneMetadata = await isAllowedToOnboard({ ip, phone }) + if (phoneMetadata instanceof Error) return phoneMetadata + + const upgraded = await AuthWithUsernamePasswordDeviceIdService().upgradeToPhoneSchema( + { + phone, + userId: account.kratosUserId, + }, + ) + if (upgraded instanceof Error) return upgraded + + const res = await upgradeAccountFromDeviceToPhone({ + userId: account.kratosUserId, + phone, + phoneMetadata, + }) + if (res instanceof Error) return res + + return { success: true } + } + + if (userId instanceof Error) return userId + + return new PhoneAlreadyExistsError() +} + export const loginWithDevice = async ({ username: usernameRaw, password: passwordRaw, diff --git a/core/api/src/graphql/public/mutations.ts b/core/api/src/graphql/public/mutations.ts index 7271611474..0c55e1589f 100644 --- a/core/api/src/graphql/public/mutations.ts +++ b/core/api/src/graphql/public/mutations.ts @@ -52,6 +52,7 @@ import UserEmailDeleteMutation from "@/graphql/public/root/mutation/user-email-d import UserEmailRegistrationInitiateMutation from "@/graphql/public/root/mutation/user-email-registration-initiate" import UserEmailRegistrationValidateMutation from "@/graphql/public/root/mutation/user-email-registration-validate" import UserLoginUpgradeMutation from "@/graphql/public/root/mutation/user-login-upgrade" +import UserLoginUpgradeTelegramMutation from "@/graphql/public/root/mutation/user-login-upgrade-telegram" import UserLogoutMutation from "@/graphql/public/root/mutation/user-logout" import UserPhoneDeleteMutation from "@/graphql/public/root/mutation/user-phone-delete" import UserPhoneRegistrationInitiateMutation from "@/graphql/public/root/mutation/user-phone-registration-initiate" @@ -88,6 +89,7 @@ export const mutationFields = { authed: { atAccountLevel: { userLoginUpgrade: UserLoginUpgradeMutation, + userLoginUpgradeTelegram: UserLoginUpgradeTelegramMutation, userEmailRegistrationInitiate: UserEmailRegistrationInitiateMutation, userEmailRegistrationValidate: UserEmailRegistrationValidateMutation, userEmailDelete: UserEmailDeleteMutation, diff --git a/core/api/src/graphql/public/root/mutation/user-login-upgrade-telegram.ts b/core/api/src/graphql/public/root/mutation/user-login-upgrade-telegram.ts new file mode 100644 index 0000000000..6a90f6716d --- /dev/null +++ b/core/api/src/graphql/public/root/mutation/user-login-upgrade-telegram.ts @@ -0,0 +1,77 @@ +import { Authentication } from "@/app" + +import { GT } from "@/graphql/index" +import { IpMissingInContextError } from "@/graphql/error" +import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" +import UpgradePayload from "@/graphql/public/types/payload/upgrade-payload" +import Phone from "@/graphql/shared/types/scalar/phone" +import TelegramPassportNonce from "@/graphql/shared/types/scalar/telegram-passport-nonce" + +import { ErrorLevel } from "@/domain/shared" +import { baseLogger } from "@/services/logger" +import { recordExceptionInCurrentSpan } from "@/services/tracing" + +const UserLoginUpgradeTelegramInput = GT.Input({ + name: "UserLoginUpgradeTelegramInput", + fields: () => ({ + phone: { type: GT.NonNull(Phone) }, + nonce: { type: GT.NonNull(TelegramPassportNonce) }, + }), +}) + +const UserLoginUpgradeTelegramMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { + input: { + phone: PhoneNumber | InputValidationError + nonce: TelegramPassportNonce | InputValidationError + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(UpgradePayload), + args: { + input: { type: GT.NonNull(UserLoginUpgradeTelegramInput) }, + }, + resolve: async (_, args, { ip, domainAccount }) => { + const { phone, nonce } = args.input + + if (phone instanceof Error) { + return { errors: [{ message: phone.message }], success: false } + } + + if (nonce instanceof Error) { + return { errors: [{ message: nonce.message }], success: false } + } + + if (ip === undefined) { + const error = new IpMissingInContextError({ logger: baseLogger }) + recordExceptionInCurrentSpan({ + error, + level: ErrorLevel.Critical, + }) + return { + errors: [error], + success: false, + } + } + + const res = await Authentication.loginDeviceUpgradeWithTelegramPassportNonce({ + phone, + nonce, + ip, + account: domainAccount, + }) + + if (res instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(res)], success: false } + } + + return { errors: [], success: res.success, authToken: res.authToken } + }, +}) + +export default UserLoginUpgradeTelegramMutation diff --git a/core/api/src/graphql/public/schema.graphql b/core/api/src/graphql/public/schema.graphql index a9418e8ec1..dfad7af063 100644 --- a/core/api/src/graphql/public/schema.graphql +++ b/core/api/src/graphql/public/schema.graphql @@ -1052,6 +1052,7 @@ type Mutation { userEmailRegistrationValidate(input: UserEmailRegistrationValidateInput!): UserEmailRegistrationValidatePayload! userLogin(input: UserLoginInput!): AuthTokenPayload! userLoginUpgrade(input: UserLoginUpgradeInput!): UpgradePayload! + userLoginUpgradeTelegram(input: UserLoginUpgradeTelegramInput!): UpgradePayload! userLogout(input: UserLogoutInput): SuccessPayload! userPhoneDelete: UserPhoneDeletePayload! userPhoneRegistrationInitiate(input: UserPhoneRegistrationInitiateInput!): SuccessPayload! @@ -1474,6 +1475,9 @@ enum SupportRole { USER } +"""Nonce provided by Telegram Passport to validate the login/upgrade flow""" +scalar TelegramPassportNonce + """ Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch) """ @@ -1750,6 +1754,11 @@ input UserLoginUpgradeInput { phone: Phone! } +input UserLoginUpgradeTelegramInput { + nonce: TelegramPassportNonce! + phone: Phone! +} + input UserLogoutInput { deviceToken: String! } diff --git a/core/api/src/graphql/shared/types/scalar/telegram-passport-nonce.ts b/core/api/src/graphql/shared/types/scalar/telegram-passport-nonce.ts new file mode 100644 index 0000000000..62b1c3cbef --- /dev/null +++ b/core/api/src/graphql/shared/types/scalar/telegram-passport-nonce.ts @@ -0,0 +1,34 @@ +import { checkedToTelegramPassportNonce } from "@/domain/authentication" +import { InputValidationError } from "@/graphql/error" +import { GT } from "@/graphql/index" + +const TelegramPassportNonce = GT.Scalar({ + name: "TelegramPassportNonce", + description: "Nonce provided by Telegram Passport to validate the login/upgrade flow", + parseValue(value) { + if (typeof value !== "string") { + return new InputValidationError({ + message: "Invalid type for TelegramPassportNonce", + }) + } + return validNonceValue(value) + }, + parseLiteral(ast) { + if (ast.kind === GT.Kind.STRING) { + return validNonceValue(ast.value) + } + return new InputValidationError({ message: "Invalid type for TelegramPassportNonce" }) + }, +}) + +function validNonceValue(value: string) { + const nonce = checkedToTelegramPassportNonce(value) + if (nonce instanceof Error) { + return new InputValidationError({ + message: "Invalid Telegram Passport nonce", + }) + } + return nonce +} + +export default TelegramPassportNonce diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index af3cfad19a..fd541a95f4 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -1405,6 +1405,7 @@ type Mutation userEmailRegistrationValidate(input: UserEmailRegistrationValidateInput!): UserEmailRegistrationValidatePayload! @join__field(graph: PUBLIC) userLogin(input: UserLoginInput!): AuthTokenPayload! @join__field(graph: PUBLIC) userLoginUpgrade(input: UserLoginUpgradeInput!): UpgradePayload! @join__field(graph: PUBLIC) + userLoginUpgradeTelegram(input: UserLoginUpgradeTelegramInput!): UpgradePayload! @join__field(graph: PUBLIC) userLogout(input: UserLogoutInput): SuccessPayload! @join__field(graph: PUBLIC) userPhoneDelete: UserPhoneDeletePayload! @join__field(graph: PUBLIC) userPhoneRegistrationInitiate(input: UserPhoneRegistrationInitiateInput!): SuccessPayload! @join__field(graph: PUBLIC) @@ -2019,6 +2020,10 @@ enum SupportRole USER @join__enumValue(graph: PUBLIC) } +"""Nonce provided by Telegram Passport to validate the login/upgrade flow""" +scalar TelegramPassportNonce + @join__type(graph: PUBLIC) + """ Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch) """ @@ -2349,6 +2354,13 @@ input UserLoginUpgradeInput phone: Phone! } +input UserLoginUpgradeTelegramInput + @join__type(graph: PUBLIC) +{ + nonce: TelegramPassportNonce! + phone: Phone! +} + input UserLogoutInput @join__type(graph: PUBLIC) {