Обработка повторных входов в систему

Это третье пошаговое руководство из серии пошаговых руководств по дополнениям Classroom.

В этом пошаговом руководстве вы обрабатываете повторные посещения нашего дополнения, автоматически извлекая ранее предоставленные учётные данные пользователя. Затем вы перенаправляете пользователей на страницы, с которых они могут немедленно отправлять запросы к API. Это обязательное поведение для дополнений «Класс».

В ходе этого пошагового руководства вы выполните следующее:

  • Реализовать постоянное хранилище для учетных данных наших пользователей.
  • Получите и оцените параметр запроса дополнения login_hint . Это уникальный номер Google ID вошедшего в систему пользователя.

После завершения вы сможете полностью авторизовать пользователей в своем веб-приложении и отправлять вызовы API Google.

Понимание параметров запроса iframe

Classroom загружает URI настройки вложения вашего дополнения при открытии. Classroom добавляет к URI несколько параметров запроса GET , содержащих полезную контекстную информацию. Например, если ваш URI обнаружения вложения — https://example.com/addon , Classroom создаст iframe с исходным URL-адресом https://example.com/addon?courseId=XXX&itemId=YYY&itemType=courseWork&addOnToken=ZZZ , где XXX , YYY и ZZZ — строковые идентификаторы. Подробное описание этого сценария см. в руководстве по iframe.

Существует пять возможных параметров запроса для URL-адреса обнаружения:

  • courseId : идентификатор текущего курса в классе.
  • itemId : идентификатор элемента потока, который пользователь редактирует или создает.
  • itemType : тип элемента потока, который пользователь создает или редактирует: courseWork , courseWorkMaterial или announcement .
  • addOnToken : токен, используемый для авторизации определенных действий надстройки Classroom.
  • login_hint : Идентификатор Google текущего пользователя.

В этом пошаговом руководстве рассматривается login_hint . В зависимости от того, указан ли этот параметр запроса, пользователи перенаправляются либо в поток авторизации, если он отсутствует, либо на страницу обнаружения дополнений, если он присутствует.

Доступ к параметрам запроса

Параметры запроса передаются в ваше веб-приложение в строке URI. Сохраните эти значения в вашем сеансе; они используются в процессе авторизации, а также для хранения и извлечения информации о пользователе. Эти параметры запроса передаются только при первом открытии дополнения.

Питон

Перейдите к определениям маршрутов Flask ( routes.py , если вы следуете нашему примеру). В верхней части целевого маршрута дополнения ( /classroom-addon в нашем примере) извлеките и сохраните параметр запроса login_hint :

# If the login_hint query parameter is available, we'll store it in the session.
if flask.request.args.get("login_hint"):
    flask.session["login_hint"] = flask.request.args.get("login_hint")

Убедитесь, что login_hint (если есть) сохранён в сеансе. Это подходящее место для хранения этих значений: они недолговечны, и вы получаете новые значения при открытии дополнения.

# It's possible that we might return to this route later, in which case the
# parameters will not be passed in. Instead, use the values cached in the
# session.
login_hint = flask.session.get("login_hint")

# If there's still no login_hint query parameter, this must be their first
# time signing in, so send the user to the sign in page.
if login_hint is None:
    return start_auth_flow()

Ява

Перейдите к маршруту, по которому ведётся дополнение, в классе контроллера ( /addon-discovery в AuthController.java в приведённом примере). В начале этого маршрута извлеките и сохраните параметр запроса login_hint .

/** Retrieve the login_hint query parameter from the request URL if present. */
String login_hint = request.getParameter("login_hint");

Убедитесь, что login_hint (если есть) сохранён в сеансе. Это подходящее место для хранения этих значений: они недолговечны, и вы получаете новые значения при открытии дополнения.

/** If login_hint wasn't sent, use the values in the session. */
if (login_hint == null) {
    login_hint = (String) session.getAttribute("login_hint");
}

/** If the there is still no login_hint, route the user to the authorization
 *  page. */
if (login_hint == null) {
    return startAuthFlow(model);
}

/** If the login_hint query parameter is provided, add it to the session. */
else if (login_hint != null) {
    session.setAttribute("login_hint", login_hint);
}

Добавьте параметры запроса в поток авторизации.

Параметр login_hint также должен быть передан серверам аутентификации Google. Это упрощает процесс аутентификации: если ваше приложение знает, какой пользователь пытается пройти аутентификацию, сервер использует подсказку для упрощения процесса входа, предварительно заполняя поле адреса электронной почты в форме входа.

Питон

Перейдите к маршруту авторизации в файле сервера Flask ( /authorize в нашем примере). Добавьте аргумент login_hint к вызову flow.authorization_url .

authorization_url, state = flow.authorization_url(
    # Enable offline access so that you can refresh an access token without
    # re-prompting the user for permission. Recommended for web server apps.
    access_type="offline",
    # Enable incremental authorization. Recommended as a best practice.
    include_granted_scopes="true",
    # The user will automatically be selected if we have the login_hint.
    login_hint=flask.session.get("login_hint"),

Ява

Перейдите к методу authorize() в классе AuthService.java . Добавьте login_hint в качестве параметра метода, а также login_hint и аргумент в конструктор URL-адресов авторизации.

String authUrl = flow
    .newAuthorizationUrl()
    .setState(state)
    .set("login_hint", login_hint)
    .setRedirectUri(REDIRECT_URI)
    .build();

Добавить постоянное хранилище для учетных данных пользователей

Если при загрузке дополнения вы получаете login_hint в качестве параметра запроса, это означает, что пользователь уже завершил процесс авторизации в нашем приложении. Вам следует получить его предыдущие учётные данные, а не заставлять его входить в систему заново.

Напоминаем, что вы получили токен обновления после завершения процесса авторизации. Сохраните этот токен; он будет использован повторно для получения токена доступа , который кратковременно активен и необходим для использования API Google. Вы ранее сохранили эти учётные данные в сеансе, но вам необходимо сохранить их для обработки повторных посещений.

Определите схему пользователя и настройте базу данных.

Настройте схему базы данных для User .

Питон

Определить схему пользователя

User содержит следующие атрибуты:

  • id : идентификатор Google пользователя. Он должен соответствовать значениям, указанным в параметре запроса login_hint .
  • display_name : Имя и фамилия пользователя, например, «Алекс Смит».
  • email : адрес электронной почты пользователя.
  • portrait_url : URL-адрес фотографии профиля пользователя.
  • refresh_token : Ранее полученный токен обновления.

В этом примере хранилище реализовано с использованием SQLite, встроенной поддержки Python. Для управления базой данных используется модуль flask_sqlalchemy .

Настройте базу данных

Сначала укажите расположение файла базы данных. Перейдите к файлу конфигурации сервера ( config.py в нашем примере) и добавьте следующее:

import os

# Point to a database file in the project root.
DATABASE_FILE_NAME = os.path.join(
    os.path.abspath(os.path.dirname(__file__)), 'data.sqlite')

class Config(object):
    SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_FILE_NAME}"
    SQLALCHEMY_TRACK_MODIFICATIONS = False

Это укажет Flask на файл data.sqlite в том же каталоге, что и файл main.py

Затем перейдите в каталог вашего модуля и создайте новый файл models.py . Если вы следуете нашему примеру, это будет webapp/models.py . Добавьте в новый файл следующее, чтобы определить таблицу User , заменив имя вашего модуля на имя webapp , если оно отличается.

from webapp import db

# Database model to represent a user.
class User(db.Model):
    # The user's identifying information:
    id = db.Column(db.String(120), primary_key=True)
    display_name = db.Column(db.String(80))
    email = db.Column(db.String(120), unique=True)
    portrait_url = db.Column(db.Text())

    # The user's refresh token, which will be used to obtain an access token.
    # Note that refresh tokens will become invalid if:
    # - The refresh token has not been used for six months.
    # - The user revokes your app's access permissions.
    # - The user changes passwords.
    # - The user belongs to a Google Cloud organization
    #   that has session control policies in effect.
    refresh_token = db.Column(db.Text())

Наконец, в файле __init__.py вашего модуля добавьте следующее, чтобы импортировать новые модели и создать базу данных.

from webapp import models
from os import path
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy(app)

# Initialize the database file if not created.
if not path.exists(config.DATABASE_FILE_NAME):
    db.create_all()

Ява

Определить схему пользователя

User содержит следующие атрибуты:

  • id : идентификатор Google пользователя. Он должен соответствовать значению, указанному в параметре запроса login_hint .
  • email : адрес электронной почты пользователя.

Создайте файл schema.sql в каталоге resources модуля. Spring считывает этот файл и генерирует соответствующую схему для базы данных. Определите таблицу, указав имя, users и столбцы для представления атрибутов User , id и email .

CREATE TABLE IF NOT EXISTS users (
    id VARCHAR(255) PRIMARY KEY, -- user's unique Google ID
    email VARCHAR(255), -- user's email address
);

Создайте класс Java для определения модели User для базы данных. В приведённом примере это User.java .

Добавьте аннотацию @Entity , чтобы указать, что это объект POJO, который можно сохранить в базе данных. Добавьте аннотацию @Table с соответствующим именем таблицы, которое вы настроили в schema.sql .

Обратите внимание, что пример кода включает конструкторы и сеттеры для двух атрибутов. Конструктор и сеттеры используются в AuthController.java для создания или обновления пользователя в базе данных. Вы также можете включить геттеры и метод toString по своему усмотрению, но в данном пошаговом руководстве эти методы не используются и для краткости опущены в примере кода на этой странице.

/** An entity class that provides a model to store user information. */
@Entity
@Table(name = "users")
public class User {
    /** The user's unique Google ID. The @Id annotation specifies that this
     *   is the primary key. */
    @Id
    @Column
    private String id;

    /** The user's email address. */
    @Column
    private String email;

    /** Required User class no args constructor. */
    public User() {
    }

    /** The User class constructor that creates a User object with the
    *   specified parameters.
    *   @param id the user's unique Google ID
    *   @param email the user's email address
    */
    public User(String id, String email) {
        this.id = id;
        this.email = email;
    }

    public void setId(String id) { this.id = id; }

    public void setEmail(String email) { this.email = email; }
}

Создайте интерфейс UserRepository.java для обработки CRUD-операций в базе данных. Этот интерфейс расширяет интерфейс CrudRepository .

/** Provides CRUD operations for the User class by extending the
 *   CrudRepository interface. */
@Repository
public interface UserRepository extends CrudRepository<User, String> {
}

Класс контроллера обеспечивает взаимодействие между клиентом и репозиторием. Поэтому обновите конструктор класса контроллера, добавив класс UserRepository .

/** Declare UserRepository to be used in the Controller class constructor. */
private final UserRepository userRepository;

/**
*   ...
*   @param userRepository the class that interacts with User objects stored in
*   persistent storage.
*/
public AuthController(AuthService authService, UserRepository userRepository) {
    this.authService = authService;
    this.userRepository = userRepository;
}

Настройте базу данных

Для хранения информации о пользователях используйте базу данных H2, которая поддерживается Spring Boot. Эта база данных также используется в последующих пошаговых руководствах для хранения другой информации, связанной с классом. Для настройки базы данных H2 необходимо добавить следующую конфигурацию в application.properties .

# Enable configuration for persistent storage using an H2 database
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:file:./h2/userdb
spring.datasource.username=<USERNAME>
spring.datasource.password=<PASSWORD>
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false

Конфигурация spring.datasource.url создаёт каталог h2 с файлом userdb внутри. Добавьте путь к базе данных H2 в файл .gitignore . Необходимо обновить spring.datasource.username и spring.datasource.password перед запуском приложения, чтобы указать базе данных имя пользователя и пароль по вашему выбору. Чтобы обновить имя пользователя и пароль для базы данных после запуска приложения, удалите сгенерированный каталог h2 , обновите конфигурацию и перезапустите приложение.

Настройка конфигурации spring.jpa.hibernate.ddl-auto на update гарантирует сохранение данных в базе данных при перезапуске приложения. Чтобы очищать базу данных при каждом перезапуске приложения, установите для этой конфигурации настройку на create .

Установите для параметра spring.jpa.open-in-view значение false . Эта конфигурация включена по умолчанию и может приводить к проблемам с производительностью, которые сложно диагностировать в рабочей среде.

Как было описано ранее, необходимо иметь возможность извлекать учетные данные повторного пользователя. Этому способствует встроенная поддержка хранилища учетных данных, предоставляемая GoogleAuthorizationCodeFlow .

В классе AuthService.java определите путь к файлу, где хранится класс учётных данных. В этом примере файл создаётся в каталоге /credentialStore . Добавьте путь к хранилищу учётных данных в файл .gitignore . Этот каталог создаётся, когда пользователь начинает процесс авторизации.

private static final File dataDirectory = new File("credentialStore");

Затем создайте в файле AuthService.java метод, который создаёт и возвращает объект FileDataStoreFactory . Это хранилище данных, в котором хранятся учётные данные.

/** Creates and returns FileDataStoreFactory object to store credentials.
 *   @return FileDataStoreFactory dataStore used to save and obtain users ids
 *   mapped to Credentials.
 *   @throws IOException if creating the dataStore is unsuccessful.
 */
public FileDataStoreFactory getCredentialDataStore() throws IOException {
    FileDataStoreFactory dataStore = new FileDataStoreFactory(dataDirectory);
    return dataStore;
}

Обновите метод getFlow() в AuthService.java , включив setDataStoreFactory в метод GoogleAuthorizationCodeFlow Builder() , и вызовите getCredentialDataStore() для установки хранилища данных.

GoogleAuthorizationCodeFlow authorizationCodeFlow =
    new GoogleAuthorizationCodeFlow.Builder(
        HTTP_TRANSPORT,
        JSON_FACTORY,
        getClientSecrets(),
        getScopes())
    .setAccessType("offline")
    .setDataStoreFactory(getCredentialDataStore())
    .build();

Затем обновите метод getAndSaveCredentials(String authorizationCode) . Ранее этот метод получал учётные данные, не сохраняя их где-либо. Обновите метод так, чтобы учётные данные сохранялись в хранилище данных, индексируемом по идентификатору пользователя.

Идентификатор пользователя можно получить из объекта TokenResponse с помощью id_token , но сначала его необходимо проверить. В противном случае клиентские приложения смогут выдавать себя за пользователей, отправляя изменённые идентификаторы на сервер. Рекомендуется использовать клиентские библиотеки Google API для проверки id_token . Подробнее см. на странице Google Identity о проверке токена Google ID.

// Obtaining the id_token will help determine which user signed in to the application.
String idTokenString = tokenResponse.get("id_token").toString();

// Validate the id_token using the GoogleIdTokenVerifier object.
GoogleIdTokenVerifier googleIdTokenVerifier = new GoogleIdTokenVerifier.Builder(
        HTTP_TRANSPORT,
        JSON_FACTORY)
    .setAudience(Collections.singletonList(
        googleClientSecrets.getWeb().getClientId()))
    .build();

GoogleIdToken idToken = googleIdTokenVerifier.verify(idTokenString);

if (idToken == null) {
    throw new Exception("Invalid ID token.");
}

После проверки id_token получите идентификатор userId для хранения вместе с полученными учетными данными.

// Obtain the user id from the id_token.
Payload payload = idToken.getPayload();
String userId = payload.getSubject();

Обновите вызов flow.createAndStoreCredential , включив в него userId .

// Save the user id and credentials to the configured FileDataStoreFactory.
Credential credential = flow.createAndStoreCredential(tokenResponse, userId);

Добавьте в класс AuthService.java метод, который возвращает учетные данные конкретного пользователя, если они существуют в хранилище данных.

/** Find credentials in the datastore based on a specific user id.
*   @param userId key to find in the file datastore.
*   @return Credential object to be returned if a matching key is found in the datastore. Null if
*   the key doesn't exist.
*   @throws Exception if building flow object or checking for userId key is unsuccessful. */
public Credential loadFromCredentialDataStore(String userId) throws Exception {
    try {
        GoogleAuthorizationCodeFlow flow = getFlow();
        Credential credential = flow.loadCredential(userId);
        return credential;
    } catch (Exception e) {
        e.printStackTrace();
        throw e;
    }
}

Получить учетные данные

Определите метод извлечения данных Users . В параметре запроса login_hint указан id , который можно использовать для извлечения конкретной записи о пользователе.

Питон

def get_credentials_from_storage(id):
    """
    Retrieves credentials from the storage and returns them as a dictionary.
    """
    return User.query.get(id)

Ява

В классе AuthController.java определите метод извлечения пользователя из базы данных на основе его идентификатора пользователя.

/** Retrieves stored credentials based on the user id.
*   @param id the id of the current user
*   @return User the database entry corresponding to the current user or null
*   if the user doesn't exist in the database.
*/
public User getUser(String id) {
    if (id != null) {
        Optional<User> user = userRepository.findById(id);
        if (user.isPresent()) {
            return user.get();
        }
    }
    return null;
}

Хранить учетные данные

Существует два сценария хранения учётных данных. Если id пользователя уже есть в базе данных, обновите существующую запись новыми значениями. В противном случае создайте новую запись User и добавьте её в базу данных.

Питон

Сначала определите вспомогательный метод, реализующий поведение хранения или обновления.

def save_user_credentials(credentials=None, user_info=None):
    """
    Updates or adds a User to the database. A new user is added only if both
    credentials and user_info are provided.

    Args:
        credentials: An optional Credentials object.
        user_info: An optional dict containing user info returned by the
            OAuth 2.0 API.
    """

    existing_user = get_credentials_from_storage(
        flask.session.get("login_hint"))

    if existing_user:
        if user_info:
            existing_user.id = user_info.get("id")
            existing_user.display_name = user_info.get("name")
            existing_user.email = user_info.get("email")
            existing_user.portrait_url = user_info.get("picture")

        if credentials and credentials.refresh_token is not None:
            existing_user.refresh_token = credentials.refresh_token

    elif credentials and user_info:
        new_user = User(id=user_info.get("id"),
                        display_name=user_info.get("name"),
                        email=user_info.get("email"),
                        portrait_url=user_info.get("picture"),
                        refresh_token=credentials.refresh_token)

        db.session.add(new_user)

    db.session.commit()

Сохранение учётных данных в базе данных возможно в двух случаях: когда пользователь возвращается в приложение после завершения процесса авторизации и при выполнении вызова API. В этих случаях мы ранее задали ключ credentials сеанса.

Вызовите save_user_credentials в конце маршрута callback . Сохраните объект user_info вместо того, чтобы просто извлекать имя пользователя.

# The flow is complete! We'll use the credentials to fetch the user's info.
user_info_service = googleapiclient.discovery.build(
    serviceName="oauth2", version="v2", credentials=credentials)

user_info = user_info_service.userinfo().get().execute()

flask.session["username"] = user_info.get("name")

save_user_credentials(credentials, user_info)

Вам также следует обновлять учётные данные после вызовов API. В этом случае вы можете передать обновлённые учётные данные в качестве аргументов метода save_user_credentials .

# Save credentials in case access token was refreshed.
flask.session["credentials"] = credentials_to_dict(credentials)
save_user_credentials(credentials)

Ява

Сначала определите метод, который сохраняет или обновляет объект User в базе данных H2.

/** Adds or updates a user in the database.
*   @param credential the credentials object to save or update in the database.
*   @param userinfo the userinfo object to save or update in the database.
*   @param session the current session.
*/
public void saveUser(Credential credential, Userinfo userinfo, HttpSession session) {
    User storedUser = null;
    if (session != null && session.getAttribute("login_hint") != null) {
        storedUser = getUser(session.getAttribute("login_hint").toString());
    }

    if (storedUser != null) {
        if (userinfo != null) {
            storedUser.setId(userinfo.getId());
            storedUser.setEmail(userinfo.getEmail());
        }
        userRepository.save(storedUser);
    } else if (credential != null && userinfo != null) {
        User newUser = new User(
            userinfo.getId(),
            userinfo.getEmail(),
        );
        userRepository.save(newUser);
    }
}

Сохранение учётных данных в базе данных возможно в двух случаях: когда пользователь возвращается в приложение после завершения процесса авторизации и при выполнении вызова API. В этих случаях мы ранее задали ключ credentials сеанса.

Вызовите saveUser в конце маршрута /callback . Вместо извлечения адреса электронной почты пользователя следует сохранить объект user_info .

/** This is the end of the auth flow. We should save user info to the database. */
Userinfo userinfo = authService.getUserInfo(credentials);
saveUser(credentials, userinfo, session);

Вам также следует обновлять учётные данные после вызовов API. В этом случае вы можете передать обновлённые учётные данные в качестве аргументов метода saveUser .

/** Save credentials in case access token was refreshed. */
saveUser(credentials, null, session);

Просроченные учетные данные

Обратите внимание, что токены обновления могут стать недействительными по нескольким причинам. Вот некоторые из них:

  • Токен обновления не использовался в течение шести месяцев.
  • Пользователь отзывает разрешения на доступ вашего приложения.
  • Пользователь меняет пароли.
  • Пользователь принадлежит к организации Google Cloud, в которой действуют политики управления сеансами.

Получайте новые токены, повторно отправляя пользователя через процесс авторизации, если его учетные данные стали недействительными.

Автоматически маршрутизировать пользователя

Измените маршрут перехода к дополнению, чтобы определить, авторизовал ли пользователь наше приложение ранее. Если да, перенаправьте его на главную страницу дополнения. В противном случае предложите ему войти в систему.

Питон

Убедитесь, что файл базы данных создан при запуске приложения. Вставьте следующий код в инициализатор модуля (например, webapp/__init__.py в нашем примере) или в основной метод, запускающий сервер.

# Initialize the database file if not created.
if not os.path.exists(DATABASE_FILE_NAME):
    db.create_all()

Затем ваш метод должен обработать параметр запроса login_hint , как описано выше . Затем , если это повторный посетитель, загрузите учётные данные магазина. Вы узнаете, что это повторный посетитель, если получили login_hint . Извлеките все сохранённые учётные данные для этого пользователя и загрузите их в сеанс.

stored_credentials = get_credentials_from_storage(login_hint)

# If we have stored credentials, store them in the session.
if stored_credentials:
    # Load the client secrets file contents.
    client_secrets_dict = json.load(
        open(CLIENT_SECRETS_FILE)).get("web")

    # Update the credentials in the session.
    if not flask.session.get("credentials"):
        flask.session["credentials"] = {}

    flask.session["credentials"] = {
        "token": stored_credentials.access_token,
        "refresh_token": stored_credentials.refresh_token,
        "token_uri": client_secrets_dict["token_uri"],
        "client_id": client_secrets_dict["client_id"],
        "client_secret": client_secrets_dict["client_secret"],
        "scopes": SCOPES
    }

    # Set the username in the session.
    flask.session["username"] = stored_credentials.display_name

Наконец, если у нас нет учётных данных, перенаправляем пользователя на страницу входа. Если есть, перенаправляем на главную страницу дополнения.

if "credentials" not in flask.session or \
    flask.session["credentials"]["refresh_token"] is None:
    return flask.render_template("authorization.html")

return flask.render_template(
    "addon-discovery.html",
    message="You've reached the addon discovery page.")

Ява

Перейдите к маршруту назначения дополнения ( /addon-discovery в приведённом примере). Как обсуждалось выше , именно здесь вы обрабатывали параметр запроса login_hint .

Сначала проверьте наличие учётных данных в сеансе. Если их нет, перенаправьте пользователя через процесс аутентификации, вызвав метод startAuthFlow .

/** Check if the credentials exist in the session. The session could have
 *   been cleared when the user clicked the Sign-Out button, and the expected
 *   behavior after sign-out would be to display the sign-in page when the
 *   iframe is opened again. */
if (session.getAttribute("credentials") == null) {
    return startAuthFlow(model);
}

Затем загрузите пользователя из базы данных H2 , если это повторный посетитель . Это повторный посетитель, если вы получаете параметр запроса login_hint . Если пользователь существует в базе данных H2, загрузите учётные данные из ранее настроенного хранилища учётных данных и установите их в сеансе. Если учётные данные не были получены из хранилища учётных данных, направьте пользователя через поток аутентификации, вызвав startAuthFlow .

/** At this point, we know that credentials exist in the session, but we
 *   should update the session credentials with the credentials in persistent
 *   storage in case they were refreshed. If the credentials in persistent
 *   storage are null, we should navigate the user to the authorization flow
 *   to obtain persisted credentials. */

User storedUser = getUser(login_hint);

if (storedUser != null) {
    Credential credential = authService.loadFromCredentialDataStore(login_hint);
    if (credential != null) {
        session.setAttribute("credentials", credential);
    } else {
        return startAuthFlow(model);
    }
}

Наконец, перенаправьте пользователя на целевую страницу дополнения.

/** Finally, if there are credentials in the session and in persistent
 *   storage, direct the user to the addon-discovery page. */
return "addon-discovery";

Протестируйте дополнение

Войдите в Google Класс как один из ваших тестовых пользователей Teacher . Перейдите на вкладку «Задания» и создайте новое задание . Нажмите кнопку «Дополнения» под текстовой областью, затем выберите дополнение. Откроется iframe, и дополнение загрузит URI настройки вложения , указанный вами на странице конфигурации приложения в Google Workspace Marketplace SDK.

Поздравляем! Вы готовы перейти к следующему шагу: созданию вложений и определению роли пользователя .