#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright 2021 The Matrix.org Foundation C.I.C.
# Copyright 2014 - 2016 OpenMarket Ltd
# Copyright (C) 2023 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>.
#
# [This file includes modifications made by New Vector Limited]
#
#

"""Contains functions for registering clients."""

import logging
from typing import (
    TYPE_CHECKING,
    Iterable,
    TypedDict,
)

from prometheus_client import Counter

from synapse import types
from synapse.api.constants import (
    MAX_USERID_LENGTH,
    EventContentFields,
    EventTypes,
    JoinRules,
    LoginType,
)
from synapse.api.errors import (
    AuthError,
    Codes,
    ConsentNotGivenError,
    InvalidClientTokenError,
    SynapseError,
)
from synapse.appservice import ApplicationService
from synapse.config.server import is_threepid_reserved
from synapse.http.servlet import assert_params_in_dict
from synapse.metrics import SERVER_NAME_LABEL
from synapse.replication.http.login import RegisterDeviceReplicationServlet
from synapse.replication.http.register import (
    ReplicationPostRegisterActionsServlet,
)
from synapse.spam_checker_api import RegistrationBehaviour
from synapse.types import GUEST_USER_ID_PATTERN, RoomAlias, UserID, create_requester
from synapse.types.state import StateFilter

if TYPE_CHECKING:
    from synapse.server import HomeServer

logger = logging.getLogger(__name__)


registration_counter = Counter(
    "synapse_user_registrations_total",
    "Number of new users registered (since restart)",
    labelnames=["guest", "shadow_banned", "auth_provider", SERVER_NAME_LABEL],
)

login_counter = Counter(
    "synapse_user_logins_total",
    "Number of user logins (since restart)",
    labelnames=["guest", "auth_provider", SERVER_NAME_LABEL],
)


def init_counters_for_auth_provider(auth_provider_id: str, server_name: str) -> None:
    """Ensure the prometheus counters for the given auth provider are initialised

    This fixes a problem where the counters are not reported for a given auth provider
    until the user first logs in/registers.

    Args:
        auth_provider_id: The ID of the auth provider to initialise counters for.
        server_name: Our server name (used to label metrics) (this should be `hs.hostname`).
    """
    for is_guest in (True, False):
        login_counter.labels(
            guest=is_guest,
            auth_provider=auth_provider_id,
            **{SERVER_NAME_LABEL: server_name},
        )
        for shadow_banned in (True, False):
            registration_counter.labels(
                guest=is_guest,
                shadow_banned=shadow_banned,
                auth_provider=auth_provider_id,
                **{SERVER_NAME_LABEL: server_name},
            )


class LoginDict(TypedDict):
    device_id: str
    access_token: str
    valid_until_ms: int | None
    refresh_token: str | None


class RegistrationHandler:
    def __init__(self, hs: "HomeServer"):
        self.server_name = hs.hostname
        self.store = hs.get_datastores().main
        self._storage_controllers = hs.get_storage_controllers()
        self.clock = hs.get_clock()
        self.hs = hs
        self.auth = hs.get_auth()
        self.auth_blocking = hs.get_auth_blocking()
        self._auth_handler = hs.get_auth_handler()
        self.profile_handler = hs.get_profile_handler()
        self.user_directory_handler = hs.get_user_directory_handler()
        self.identity_handler = self.hs.get_identity_handler()
        self.ratelimiter = hs.get_registration_ratelimiter()
        self.macaroon_gen = hs.get_macaroon_generator()
        self._account_validity_handler = hs.get_account_validity_handler()
        self._user_consent_version = self.hs.config.consent.user_consent_version
        self._server_notices_mxid = hs.config.servernotices.server_notices_mxid
        self._user_types_config = hs.config.user_types

        self._spam_checker_module_callbacks = hs.get_module_api_callbacks().spam_checker

        if hs.config.worker.worker_app:
            self._register_device_client = RegisterDeviceReplicationServlet.make_client(
                hs
            )
            self._post_registration_client = (
                ReplicationPostRegisterActionsServlet.make_client(hs)
            )
        else:
            self.device_handler = hs.get_device_handler()
            self._register_device_client = self.register_device_inner
            self.pusher_pool = hs.get_pusherpool()

        self.session_lifetime = hs.config.registration.session_lifetime
        self.nonrefreshable_access_token_lifetime = (
            hs.config.registration.nonrefreshable_access_token_lifetime
        )
        self.refreshable_access_token_lifetime = (
            hs.config.registration.refreshable_access_token_lifetime
        )
        self.refresh_token_lifetime = hs.config.registration.refresh_token_lifetime

        init_counters_for_auth_provider(
            auth_provider_id="", server_name=self.server_name
        )

    async def check_username(
        self,
        localpart: str,
        guest_access_token: str | None = None,
        assigned_user_id: str | None = None,
        inhibit_user_in_use_error: bool = False,
    ) -> None:
        if types.contains_invalid_mxid_characters(localpart):
            raise SynapseError(
                400,
                "User ID can only contain characters a-z, 0-9, or '=_-./+'",
                Codes.INVALID_USERNAME,
            )

        if not localpart:
            raise SynapseError(400, "User ID cannot be empty", Codes.INVALID_USERNAME)

        if (
            localpart[0] == "_"
            and not self.hs.config.registration.allow_underscore_prefixed_localpart
        ):
            raise SynapseError(
                400, "User ID may not begin with _", Codes.INVALID_USERNAME
            )

        user = UserID(localpart, self.hs.hostname)
        user_id = user.to_string()

        if assigned_user_id:
            if user_id == assigned_user_id:
                return
            else:
                raise SynapseError(
                    400,
                    "A different user ID has already been registered for this session",
                )

        self.check_user_id_not_appservice_exclusive(user_id)

        if len(user_id) > MAX_USERID_LENGTH:
            raise SynapseError(
                400,
                "User ID may not be longer than %s characters" % (MAX_USERID_LENGTH,),
                Codes.INVALID_USERNAME,
            )

        users = await self.store.get_users_by_id_case_insensitive(user_id)
        if users:
            if not inhibit_user_in_use_error and not guest_access_token:
                raise SynapseError(
                    400, "User ID already taken.", errcode=Codes.USER_IN_USE
                )
            if guest_access_token:
                user_data = await self.auth.get_user_by_access_token(guest_access_token)
                if not user_data.is_guest or user_data.user.localpart != localpart:
                    raise AuthError(
                        403,
                        "Cannot register taken user ID without valid guest "
                        "credentials for that user.",
                        errcode=Codes.FORBIDDEN,
                    )

        if guest_access_token is None and GUEST_USER_ID_PATTERN.fullmatch(localpart):
            raise SynapseError(
                400,
                "Numeric user IDs are reserved for guest users.",
                errcode=Codes.INVALID_USERNAME,
            )

    async def register_user(
        self,
        localpart: str | None = None,
        password_hash: str | None = None,
        guest_access_token: str | None = None,
        make_guest: bool = False,
        admin: bool = False,
        threepid: dict | None = None,
        user_type: str | None = None,
        default_display_name: str | None = None,
        address: str | None = None,
        bind_emails: Iterable[str] | None = None,
        by_admin: bool = False,
        user_agent_ips: list[tuple[str, str]] | None = None,
        auth_provider_id: str | None = None,
        approved: bool = False,
    ) -> str:
        """Registers a new client on the server.

        Args:
            localpart: The local part of the user ID to register. If None,
              one will be generated.
            password_hash: The hashed password to assign to this user so they can
              login again. This can be None which means they cannot login again
              via a password (e.g. the user is an application service user).
            guest_access_token: The access token used when this was a guest
                account.
            make_guest: True if the the new user should be guest,
                false to add a regular user account.
            admin: True if the user should be registered as a server admin.
            threepid: The threepid used for registering, if any.
            user_type: type of user. One of the values from
              api.constants.UserTypes, or None for a normal user.
            default_display_name: if set, the new user's displayname
              will be set to this. Defaults to 'localpart'.
            address: the IP address used to perform the registration.
            bind_emails: list of emails to bind to this account.
            by_admin: True if this registration is being made via the
              admin api, otherwise False.
            user_agent_ips: Tuples of user-agents and IP addresses used
                during the registration process.
            auth_provider_id: The SSO IdP the user used, if any.
            approved: True if the new user should be considered already
                approved by an administrator.
        Returns:
            The registered user_id.
        Raises:
            SynapseError if there was a problem registering.
        """
        bind_emails = bind_emails or []

        await self.check_registration_ratelimit(address)

        result = await self._spam_checker_module_callbacks.check_registration_for_spam(
            threepid,
            localpart,
            user_agent_ips or [],
            auth_provider_id=auth_provider_id,
        )

        if result == RegistrationBehaviour.DENY:
            logger.info(
                "Blocked registration of %r",
                localpart,
            )
            # We return a 429 to make it not obvious that they've been
            # denied.
            raise SynapseError(429, "Rate limited")

        shadow_banned = result == RegistrationBehaviour.SHADOW_BAN
        if shadow_banned:
            logger.info(
                "Shadow banning registration of %r",
                localpart,
            )

        # do not check_auth_blocking if the call is coming through the Admin API
        if not by_admin:
            await self.auth_blocking.check_auth_blocking(threepid=threepid)

        if localpart is not None:
            await self.check_username(localpart, guest_access_token=guest_access_token)

            was_guest = guest_access_token is not None

            user = UserID(localpart, self.hs.hostname)
            user_id = user.to_string()

            if was_guest:
                # If the user was a guest then they already have a profile
                default_display_name = None

            elif default_display_name is None:
                default_display_name = localpart

            if user_type is None:
                user_type = self._user_types_config.default_user_type

            await self.register_with_store(
                user_id=user_id,
                password_hash=password_hash,
                was_guest=was_guest,
                make_guest=make_guest,
                create_profile_with_displayname=default_display_name,
                admin=admin,
                user_type=user_type,
                address=address,
                shadow_banned=shadow_banned,
                approved=approved,
            )

            profile = await self.store.get_profileinfo(user)
            await self.user_directory_handler.handle_local_profile_change(
                user_id, profile
            )

        else:
            # autogen a sequential user ID
            fail_count = 0
            # If a default display name is not given, generate one.
            generate_display_name = default_display_name is None
            # This breaks on successful registration *or* errors after 10 failures.
            while True:
                # Fail after being unable to find a suitable ID a few times
                if fail_count > 10:
                    raise SynapseError(500, "Unable to find a suitable guest user ID")

                generated_localpart = await self.store.generate_user_id()
                user = UserID(generated_localpart, self.hs.hostname)
                user_id = user.to_string()
                self.check_user_id_not_appservice_exclusive(user_id)
                if generate_display_name:
                    default_display_name = generated_localpart
                try:
                    await self.register_with_store(
                        user_id=user_id,
                        password_hash=password_hash,
                        make_guest=make_guest,
                        create_profile_with_displayname=default_display_name,
                        address=address,
                        shadow_banned=shadow_banned,
                    )

                    # Successfully registered
                    break
                except SynapseError:
                    # if user id is taken, just generate another
                    fail_count += 1

        registration_counter.labels(
            guest=make_guest,
            shadow_banned=shadow_banned,
            auth_provider=(auth_provider_id or ""),
            **{SERVER_NAME_LABEL: self.server_name},
        ).inc()

        # If the user does not need to consent at registration, auto-join any
        # configured rooms.
        if not self.hs.config.consent.user_consent_at_registration:
            if (
                not self.hs.config.registration.auto_join_rooms_for_guests
                and make_guest
            ):
                logger.info(
                    "Skipping auto-join for %s because auto-join for guests is disabled",
                    user_id,
                )
            else:
                await self._auto_join_rooms(user_id)
        else:
            logger.info(
                "Skipping auto-join for %s because consent is required at registration",
                user_id,
            )

        # Bind any specified emails to this account
        current_time = self.hs.get_clock().time_msec()
        for email in bind_emails:
            # generate threepid dict
            threepid_dict = {
                "medium": "email",
                "address": email,
                "validated_at": current_time,
            }

            # Bind email to new account
            await self._register_email_threepid(user_id, threepid_dict, None)

        return user_id

    async def _create_and_join_rooms(self, user_id: str) -> None:
        """
        Create the auto-join rooms and join or invite the user to them.

        This should only be called when the first "real" user registers.

        Args:
            user_id: The user to join
        """
        # Getting the handlers during init gives a dependency loop.
        room_creation_handler = self.hs.get_room_creation_handler()
        room_member_handler = self.hs.get_room_member_handler()

        # Generate a stub for how the rooms will be configured.
        stub_config = {
            "preset": self.hs.config.registration.autocreate_auto_join_room_preset,
        }

        # If the configuration provides a user ID to create rooms with, use
        # that instead of the first user registered.
        requires_join = False
        if self.hs.config.registration.auto_join_user_id:
            fake_requester = create_requester(
                self.hs.config.registration.auto_join_user_id,
                authenticated_entity=self.server_name,
            )

            # If the room requires an invite, add the user to the list of invites.
            if self.hs.config.registration.auto_join_room_requires_invite:
                stub_config["invite"] = [user_id]

            # If the room is being created by a different user, the first user
            # registered needs to join it. Note that in the case of an invitation
            # being necessary this will occur after the invite was sent.
            requires_join = True
        else:
            fake_requester = create_requester(
                user_id, authenticated_entity=self.server_name
            )

        # Choose whether to federate the new room.
        if not self.hs.config.registration.autocreate_auto_join_rooms_federated:
            stub_config["creation_content"] = {EventContentFields.FEDERATE: False}

        for r in self.hs.config.registration.auto_join_rooms:
            logger.info("Auto-joining %s to %s", user_id, r)

            try:
                room_alias = RoomAlias.from_string(r)

                if self.hs.hostname != room_alias.domain:
                    # If the alias is remote, try to join the room. This might fail
                    # because the room might be invite only, but we don't have any local
                    # user in the room to invite this one with, so at this point that's
                    # the best we can do.
                    logger.info(
                        "Cannot automatically create room with alias %s as it isn't"
                        " local, trying to join the room instead",
                        r,
                    )

                    (
                        room,
                        remote_room_hosts,
                    ) = await room_member_handler.lookup_room_alias(room_alias)
                    room_id = room.to_string()

                    await room_member_handler.update_membership(
                        requester=create_requester(
                            user_id, authenticated_entity=self.server_name
                        ),
                        target=UserID.from_string(user_id),
                        room_id=room_id,
                        remote_room_hosts=remote_room_hosts,
                        action="join",
                        ratelimit=False,
                    )
                else:
                    # A shallow copy is OK here since the only key that is
                    # modified is room_alias_name.
                    config = stub_config.copy()
                    # create room expects the localpart of the room alias
                    config["room_alias_name"] = room_alias.localpart

                    room_id, _, _ = await room_creation_handler.create_room(
                        fake_requester,
                        config=config,
                        ratelimit=False,
                    )

                    # If the room does not require an invite, but another user
                    # created it, then ensure the first user joins it.
                    if requires_join:
                        await room_member_handler.update_membership(
                            requester=create_requester(
                                user_id, authenticated_entity=self.server_name
                            ),
                            target=UserID.from_string(user_id),
                            room_id=room_id,
                            # Since it was just created, there are no remote hosts.
                            remote_room_hosts=[],
                            action="join",
                            ratelimit=False,
                        )
            except Exception as e:
                logger.exception("Failed to join new user to %r: %r", r, e)

    async def _join_rooms(self, user_id: str) -> None:
        """
        Join or invite the user to the auto-join rooms.

        Args:
            user_id: The user to join
        """
        room_member_handler = self.hs.get_room_member_handler()

        for r in self.hs.config.registration.auto_join_rooms:
            logger.info("Auto-joining %s to %s", user_id, r)

            try:
                room_alias = RoomAlias.from_string(r)

                if RoomAlias.is_valid(r):
                    (
                        room,
                        remote_room_hosts,
                    ) = await room_member_handler.lookup_room_alias(room_alias)
                    room_id = room.to_string()
                else:
                    raise SynapseError(
                        400, "%s was not legal room ID or room alias" % (r,)
                    )

                # Calculate whether the room requires an invite or can be
                # joined directly. By default, we consider the room as requiring an
                # invite if the homeserver is in the room (unless told otherwise by the
                # join rules). Otherwise we consider it as being joinable, at the risk of
                # failing to join, but in this case there's little more we can do since
                # we don't have a local user in the room to craft up an invite with.
                requires_invite = await self.store.is_host_joined(
                    room_id,
                    self.server_name,
                )

                if requires_invite:
                    # If the server is in the room, check if the room is public.
                    state = await self._storage_controllers.state.get_current_state_ids(
                        room_id, StateFilter.from_types([(EventTypes.JoinRules, "")])
                    )

                    event_id = state.get((EventTypes.JoinRules, ""))
                    if event_id:
                        join_rules_event = await self.store.get_event(
                            event_id, allow_none=True
                        )
                        if join_rules_event:
                            join_rule = join_rules_event.content.get("join_rule", None)
                            requires_invite = (
                                join_rule is not None and join_rule != JoinRules.PUBLIC
                            )

                # Send the invite, if necessary.
                if requires_invite:
                    # If an invite is required, there must be a auto-join user ID.
                    assert self.hs.config.registration.auto_join_user_id

                    await room_member_handler.update_membership(
                        requester=create_requester(
                            self.hs.config.registration.auto_join_user_id,
                            authenticated_entity=self.server_name,
                        ),
                        target=UserID.from_string(user_id),
                        room_id=room_id,
                        remote_room_hosts=remote_room_hosts,
                        action="invite",
                        ratelimit=False,
                    )

                # Send the join.
                await room_member_handler.update_membership(
                    requester=create_requester(
                        user_id, authenticated_entity=self.server_name
                    ),
                    target=UserID.from_string(user_id),
                    room_id=room_id,
                    remote_room_hosts=remote_room_hosts,
                    action="join",
                    ratelimit=False,
                )

            except ConsentNotGivenError as e:
                # Technically not necessary to pull out this error though
                # moving away from bare excepts is a good thing to do.
                logger.error("Failed to join new user to %r: %r", r, e)
            except Exception as e:
                logger.exception("Failed to join new user to %r: %r", r, e)

    async def _auto_join_rooms(self, user_id: str) -> None:
        """Automatically joins users to auto join rooms - creating the room in the first place
        if the user is the first to be created.

        Args:
            user_id: The user to join
        """
        # If there are no rooms to auto-join, just bail.
        if not self.hs.config.registration.auto_join_rooms:
            return

        # auto-join the user to any rooms we're supposed to dump them into

        # try to create the room if we're the first real user on the server. Note
        # that an auto-generated support or bot user is not a real user and will never be
        # the user to create the room
        should_auto_create_rooms = False
        if (
            self.hs.config.registration.autocreate_auto_join_rooms
            and await self.store.is_real_user(user_id)
        ):
            count = await self.store.count_real_users()
            should_auto_create_rooms = count == 1

        if should_auto_create_rooms:
            await self._create_and_join_rooms(user_id)
        else:
            await self._join_rooms(user_id)

    async def post_consent_actions(self, user_id: str) -> None:
        """A series of registration actions that can only be carried out once consent
        has been granted

        Args:
            user_id: The user to join
        """
        await self._auto_join_rooms(user_id)

    async def appservice_register(
        self, user_localpart: str, as_token: str
    ) -> tuple[str, ApplicationService]:
        user = UserID(user_localpart, self.hs.hostname)
        user_id = user.to_string()
        service = self.store.get_app_service_by_token(as_token)
        if not service:
            raise InvalidClientTokenError()
        if not service.is_interested_in_user(user_id):
            raise SynapseError(
                400,
                "Invalid user localpart for this application service.",
                errcode=Codes.EXCLUSIVE,
            )

        service_id = service.id if service.is_exclusive_user(user_id) else None

        self.check_user_id_not_appservice_exclusive(user_id, allowed_appservice=service)

        await self.register_with_store(
            user_id=user_id,
            password_hash="",
            appservice_id=service_id,
            create_profile_with_displayname=user.localpart,
        )
        return (user_id, service)

    def check_user_id_not_appservice_exclusive(
        self, user_id: str, allowed_appservice: ApplicationService | None = None
    ) -> None:
        # don't allow people to register the server notices mxid
        if self._server_notices_mxid is not None:
            if user_id == self._server_notices_mxid:
                raise SynapseError(
                    400, "This user ID is reserved.", errcode=Codes.EXCLUSIVE
                )

        # valid user IDs must not clash with any user ID namespaces claimed by
        # application services.
        services = self.store.get_app_services()
        interested_services = [
            s
            for s in services
            if s.is_interested_in_user(user_id) and s != allowed_appservice
        ]
        for service in interested_services:
            if service.is_exclusive_user(user_id):
                raise SynapseError(
                    400,
                    "This user ID is reserved by an application service.",
                    errcode=Codes.EXCLUSIVE,
                )

    async def check_registration_ratelimit(self, address: str | None) -> None:
        """A simple helper method to check whether the registration rate limit has been hit
        for a given IP address

        Args:
            address: the IP address used to perform the registration. If this is
                None, no ratelimiting will be performed.

        Raises:
            LimitExceededError: If the rate limit has been exceeded.
        """
        if not address:
            return

        await self.ratelimiter.ratelimit(None, address)

    async def register_with_store(
        self,
        user_id: str,
        password_hash: str | None = None,
        was_guest: bool = False,
        make_guest: bool = False,
        appservice_id: str | None = None,
        create_profile_with_displayname: str | None = None,
        admin: bool = False,
        user_type: str | None = None,
        address: str | None = None,
        shadow_banned: bool = False,
        approved: bool = False,
    ) -> None:
        """Register user in the datastore.

        Args:
            user_id: The desired user ID to register.
            password_hash: Optional. The password hash for this user.
            was_guest: Optional. Whether this is a guest account being
                upgraded to a non-guest account.
            make_guest: True if the the new user should be guest,
                false to add a regular user account.
            appservice_id: The ID of the appservice registering the user.
            create_profile_with_displayname: Optionally create a
                profile for the user, setting their displayname to the given value
            admin: is an admin user?
            user_type: type of user. One of the values from
                api.constants.UserTypes, or None for a normal user.
            address: the IP address used to perform the registration.
            shadow_banned: Whether to shadow-ban the user
            approved: Whether to mark the user as approved by an administrator
        """
        await self.store.register_user(
            user_id=user_id,
            password_hash=password_hash,
            was_guest=was_guest,
            make_guest=make_guest,
            appservice_id=appservice_id,
            create_profile_with_displayname=create_profile_with_displayname,
            admin=admin,
            user_type=user_type,
            shadow_banned=shadow_banned,
            approved=approved,
        )

        await self._account_validity_handler.on_user_registration(user_id)

    async def register_device(
        self,
        user_id: str,
        device_id: str | None,
        initial_display_name: str | None,
        is_guest: bool = False,
        is_appservice_ghost: bool = False,
        auth_provider_id: str | None = None,
        should_issue_refresh_token: bool = False,
        auth_provider_session_id: str | None = None,
    ) -> tuple[str, str, int | None, str | None]:
        """Register a device for a user and generate an access token.

        The access token will be limited by the homeserver's session_lifetime config.

        Args:
            user_id: full canonical @user:id
            device_id: The device ID to check, or None to generate a new one.
            initial_display_name: An optional display name for the device.
            is_guest: Whether this is a guest account
            auth_provider_id: The SSO IdP the user used, if any.
            should_issue_refresh_token: Whether it should also issue a refresh token
            auth_provider_session_id: The session ID received during login from the SSO IdP.
        Returns:
            Tuple of device ID, access token, access token expiration time and refresh token
        """
        res = await self._register_device_client(
            user_id=user_id,
            device_id=device_id,
            initial_display_name=initial_display_name,
            is_guest=is_guest,
            is_appservice_ghost=is_appservice_ghost,
            should_issue_refresh_token=should_issue_refresh_token,
            auth_provider_id=auth_provider_id,
            auth_provider_session_id=auth_provider_session_id,
        )

        login_counter.labels(
            guest=is_guest,
            auth_provider=(auth_provider_id or ""),
            **{SERVER_NAME_LABEL: self.server_name},
        ).inc()

        return (
            res["device_id"],
            res["access_token"],
            res["valid_until_ms"],
            res["refresh_token"],
        )

    async def register_device_inner(
        self,
        user_id: str,
        device_id: str | None,
        initial_display_name: str | None,
        is_guest: bool = False,
        is_appservice_ghost: bool = False,
        should_issue_refresh_token: bool = False,
        auth_provider_id: str | None = None,
        auth_provider_session_id: str | None = None,
    ) -> LoginDict:
        """Helper for register_device

        Does the bits that need doing on the main process. Not for use outside this
        class and RegisterDeviceReplicationServlet.
        """
        assert not self.hs.config.worker.worker_app
        now_ms = self.clock.time_msec()
        access_token_expiry = None
        if self.session_lifetime is not None:
            if is_guest:
                raise Exception(
                    "session_lifetime is not currently implemented for guest access"
                )
            access_token_expiry = now_ms + self.session_lifetime

        if self.nonrefreshable_access_token_lifetime is not None:
            if access_token_expiry is not None:
                # Don't allow the non-refreshable access token to outlive the
                # session.
                access_token_expiry = min(
                    now_ms + self.nonrefreshable_access_token_lifetime,
                    access_token_expiry,
                )
            else:
                access_token_expiry = now_ms + self.nonrefreshable_access_token_lifetime

        refresh_token = None
        refresh_token_id = None

        registered_device_id = await self.device_handler.check_device_registered(
            user_id,
            device_id,
            initial_display_name,
            auth_provider_id=auth_provider_id,
            auth_provider_session_id=auth_provider_session_id,
        )
        if is_guest:
            assert access_token_expiry is None
            access_token = self.macaroon_gen.generate_guest_access_token(user_id)
        else:
            if should_issue_refresh_token:
                # A refreshable access token lifetime must be configured
                # since we're told to issue a refresh token (the caller checks
                # that this value is set before setting this flag).
                assert self.refreshable_access_token_lifetime is not None

                # Set the expiry time of the refreshable access token
                access_token_expiry = now_ms + self.refreshable_access_token_lifetime

                # Set the refresh token expiry time (if configured)
                refresh_token_expiry = None
                if self.refresh_token_lifetime is not None:
                    refresh_token_expiry = now_ms + self.refresh_token_lifetime

                # Set an ultimate session expiry time (if configured)
                ultimate_session_expiry_ts = None
                if self.session_lifetime is not None:
                    ultimate_session_expiry_ts = now_ms + self.session_lifetime

                    # Also ensure that the issued tokens don't outlive the
                    # session.
                    # (It would be weird to configure a homeserver with a shorter
                    # session lifetime than token lifetime, but may as well handle
                    # it.)
                    access_token_expiry = min(
                        access_token_expiry, ultimate_session_expiry_ts
                    )
                    if refresh_token_expiry is not None:
                        refresh_token_expiry = min(
                            refresh_token_expiry, ultimate_session_expiry_ts
                        )

                (
                    refresh_token,
                    refresh_token_id,
                ) = await self._auth_handler.create_refresh_token_for_user_id(
                    user_id,
                    device_id=registered_device_id,
                    expiry_ts=refresh_token_expiry,
                    ultimate_session_expiry_ts=ultimate_session_expiry_ts,
                )

            access_token = await self._auth_handler.create_access_token_for_user_id(
                user_id,
                device_id=registered_device_id,
                valid_until_ms=access_token_expiry,
                is_appservice_ghost=is_appservice_ghost,
                refresh_token_id=refresh_token_id,
            )

        return {
            "device_id": registered_device_id,
            "access_token": access_token,
            "valid_until_ms": access_token_expiry,
            "refresh_token": refresh_token,
        }

    async def post_registration_actions(
        self, user_id: str, auth_result: dict, access_token: str | None
    ) -> None:
        """A user has completed registration

        Args:
            user_id: The user ID that consented
            auth_result: The authenticated credentials of the newly registered user.
            access_token: The access token of the newly logged in device, or
                None if `inhibit_login` enabled.
        """
        # TODO: 3pid registration can actually happen on the workers. Consider
        # refactoring it.
        if self.hs.config.worker.worker_app:
            await self._post_registration_client(
                user_id=user_id, auth_result=auth_result, access_token=access_token
            )
            return

        if auth_result and LoginType.EMAIL_IDENTITY in auth_result:
            threepid = auth_result[LoginType.EMAIL_IDENTITY]
            # Necessary due to auth checks prior to the threepid being
            # written to the db
            if is_threepid_reserved(
                self.hs.config.server.mau_limits_reserved_threepids, threepid
            ):
                await self.store.upsert_monthly_active_user(user_id)

            await self._register_email_threepid(user_id, threepid, access_token)

        if auth_result and LoginType.MSISDN in auth_result:
            threepid = auth_result[LoginType.MSISDN]
            await self._register_msisdn_threepid(user_id, threepid)

        if auth_result and LoginType.TERMS in auth_result:
            # The terms type should only exist if consent is enabled.
            assert self._user_consent_version is not None
            await self._on_user_consented(user_id, self._user_consent_version)

    async def _on_user_consented(self, user_id: str, consent_version: str) -> None:
        """A user consented to the terms on registration

        Args:
            user_id: The user ID that consented.
            consent_version: version of the policy the user has consented to.
        """
        logger.info("%s has consented to the privacy policy", user_id)
        await self.store.user_set_consent_version(user_id, consent_version)
        await self.post_consent_actions(user_id)

    async def _register_email_threepid(
        self, user_id: str, threepid: dict, token: str | None
    ) -> None:
        """Add an email address as a 3pid identifier

        Also adds an email pusher for the email address, if configured in the
        HS config

        Must be called on master.

        Args:
            user_id: id of user
            threepid: m.login.email.identity auth response
            token: access_token for the user, or None if not logged in.
        """
        reqd = ("medium", "address", "validated_at")
        if any(x not in threepid for x in reqd):
            # This will only happen if the ID server returns a malformed response
            logger.info("Can't add incomplete 3pid")
            return

        await self._auth_handler.add_threepid(
            user_id,
            threepid["medium"],
            threepid["address"],
            threepid["validated_at"],
        )

        # And we add an email pusher for them by default, but only
        # if email notifications are enabled (so people don't start
        # getting mail spam where they weren't before if email
        # notifs are set up on a homeserver)
        if (
            self.hs.config.email.email_enable_notifs
            and self.hs.config.email.email_notif_for_new_users
            and token
        ):
            # Pull the ID of the access token back out of the db
            # It would really make more sense for this to be passed
            # up when the access token is saved, but that's quite an
            # invasive change I'd rather do separately.
            user_tuple = await self.store.get_user_by_access_token(token)
            # The token better still exist.
            assert user_tuple
            device_id = user_tuple.device_id

            await self.pusher_pool.add_or_update_pusher(
                user_id=user_id,
                device_id=device_id,
                kind="email",
                app_id="m.email",
                app_display_name="Email Notifications",
                device_display_name=threepid["address"],
                pushkey=threepid["address"],
                lang=None,
                data={},
            )

    async def _register_msisdn_threepid(self, user_id: str, threepid: dict) -> None:
        """Add a phone number as a 3pid identifier

        Must be called on master.

        Args:
            user_id: id of user
            threepid: m.login.msisdn auth response
        """
        try:
            assert_params_in_dict(threepid, ["medium", "address", "validated_at"])
        except SynapseError as ex:
            if ex.errcode == Codes.MISSING_PARAM:
                # This will only happen if the ID server returns a malformed response
                logger.info("Can't add incomplete 3pid")
                return None
            raise

        await self._auth_handler.add_threepid(
            user_id,
            threepid["medium"],
            threepid["address"],
            threepid["validated_at"],
        )
