Перейти к содержанию

Два приложения и единый вход

Единый вход проявляется особенно ясно, когда у пользователя два приложения. Добавим учебный «Читатель» по адресу localhost:5101 с отдельным OIDC-клиентом reader. Оба приложения доверяют одному realm professorweb, но сохраняют собственные локальные сессии. Новый результат — человек может начать вход во втором сервисе и использовать уже существующую SSO-сессию Keycloak. При этом первая cookie приложения не становится общей cookie всех сервисов и не заменяет новый протокольный обмен.

Состояние lesson-09 содержит два client в realm.json. Node-код остаётся одним, а второй экземпляр читатель запускает с PORT=5101, CLIENT_ID=reader и APP_ORIGIN=http://localhost:5101. Первый использует propusk и порт 5100. Никакой второй процесс или браузерная SSO-попытка при подготовке не создавались. Инструкции описывают самостоятельный маршрут. Полнота исходников не означает, что два процесса автоматически появились только после скачивания архива.

Общий провайдер, разные регистрации

У reader свой exact redirect URI http://localhost:5101/callback. Не стоит регистрировать один client с широким набором wildcard-адресов только ради быстрого подключения. Раздельные клиенты позволяют задавать разные политики, сроки, разрешения и дальнейшую аутентификацию клиентов. В нашей лаборатории оба public и используют PKCE S256. Это одинаковый профиль для понятного сравнения, но не требование, что все реальные приложения должны иметь одинаковые scopes и полномочия. Их объединяет provider, а не полная тождественность конфигурации.

Когда пользователь уже вошёл в Keycloak через «Пропуск», провайдер может узнать его при новом authorization request reader. Парольная форма тогда не появляется либо требуется лишь дополнительное действие согласно политике. Однако reader всё равно создаёт свои state, nonce и verifier, получает code на свой callback и обменивает его. Нельзя принимать id первого приложения как доказательство личности второго. SSO сокращает повторное подтверждение у провайдера, а не отменяет связь каждого клиента с собственной ожидаемой попыткой.

Провайдерская cookie относится к Keycloak. Приложения не читают её напрямую и не должны получать общий секрет браузера для ручного распознавания. Redirect-поток позволяет провайдеру самостоятельно обработать своё состояние и выдать предусмотренный результат. Такой порядок уменьшает тесную связность приложений: reader не знает внутреннюю Map первого сервера. При этом общий provider становится важной инфраструктурной зависимостью; его сбой может помешать новому входу в оба сервиса, хотя действующие локальные сессии ещё продолжаются по своим правилам.

Почему отличаются cookie

Cookie в браузере не разделяются по порту так, как некоторые другие механизмы origin. Поэтому имена у нас propusk_5100 и propusk_5101. Это предотвращает случайный поиск одного id двумя процессами на localhost. Pending cookie также включает соответствующее имя. Если оба сервера использовали бы session, второй вход мог бы перезаписать клиентское значение, а первый перестал бы находить свою запись. Такая ошибка выглядит как «SSO нестабильно», хотя причина находится в локальном именовании cookies.

Несмотря на разные имена, оба сервера получают HTTP Cookie-заголовок с подходящими localhost cookies согласно области. Код выбирает лишь своё имя и не использует соседнее как доказательство входа. В реальном продукте могут быть разные домены, поддомены или путь; область надо проектировать явно. Широкий Domain расширяет круг сервисов, связанных с cookie, и может стать нежелательной общей границей. Наша лаборатория не предлагает shared-domain session как обязательное условие SSO: у provider и клиентов отдельные состояния.

Map каждого процесса независим. Заявки тоже независимы: «Читатель» в этом учебном коде не открывает базу редакционных данных «Пропуска». Это помогает сохранить одну тему урока. Если потребуется общий API, нужен отдельный контракт токена, audience, полномочий и данных. Не следует считать, что одинаковый sub автоматически разрешает каждому приложению читать все записи другого. Личность помогает сопоставить пользователя, а доступ к ресурсу определяется владельцем этого ресурса и принятой бизнес-политикой.

Роли во втором приложении

В текущем realm mapper realm_roles добавлен обоим клиентам, поэтому Анна может получить editor в обоих экземплярах. Это учебное упрощение. У настоящего «Читателя» роль редактора может быть бессмысленна, а клиенту стоит передавать лишь необходимые сведения. Минимизация claims сокращает раскрытие внутренней структуры и облегчает понимание API. Для расширения серии можно заменить realm-role на client-role или scope конкретного сервиса, но тогда нужно изменить mapper, проверку и объяснение вместе, не оставляя прежний includes с новым происхождением данных.

Sub в одном realm обычно позволяет узнать одну принятую личность в этих двух учебных клиентах, но OIDC допускает и более сложные политики идентификаторов. Если реальный provider использует pairwise subject, разные клиенты могут получать разные sub. Поэтому интеграция общей пользовательской базы должна опираться на документированный контракт provider, а не на наблюдение одного локального примера. Наша лаборатория сохраняет простой Keycloak-профиль и не строит автоматическую федерацию учётных записей между независимыми организациями.

Проверка нового realm

Повторный импорт уже существующего realm не добавит reader магически. Для свежего lesson-09 используйте новый compose-проект либо вручную создайте второго клиента с описанными параметрами. Затем запускайте два Node-процесса из одного самостоятельного снимка, изменяя окружение второго. Если callback reader отвергается, сначала сопоставьте clientId, APP_ORIGIN, PORT и redirect URI. Не расширяйте маску без выяснения причины. Такая ошибка конфигурации не имеет отношения к паролю пользователя и не лечится созданием нового аккаунта.

При самостоятельном маршруте сначала войдите в «Пропуск», затем откройте «Читатель» и начните login. Ожидается возможность использовать provider SSO, но появится новая локальная cookie и новый id. Сценарий не выполнялся при подготовке. Закройте первый Node-процесс: локальный вход второго не обязан исчезнуть. Если перезапустить второй процесс, его Map пропадёт, хотя Keycloak SSO ещё существует. Эти варианты показывают, какие состояния действительно общие и какие лишь связаны через протокол.

Следующий урок добавит две кнопки выхода. Первая удалит только локальную сессию текущего приложения; вторая направит пользователя к provider logout. Даже после завершения SSO другое приложение может ещё хранить свою локальную запись. Этот эффект часто кажется ошибкой единого входа, пока не определён механизм back-channel или front-channel уведомления. Мы разберём его явно и не будем обещать мгновенный общий выход, которого текущий код не реализует.

Полный файл realm.json из состояния lesson-09:

{
  "realm": "professorweb",
  "enabled": true,
  "sslRequired": "none",
  "registrationAllowed": false,
  "resetPasswordAllowed": false,
  "accessTokenLifespan": 120,
  "ssoSessionIdleTimeout": 1800,
  "ssoSessionMaxLifespan": 3600,
  "roles": {
    "realm": [
      {
        "name": "editor"
      },
      {
        "name": "reader"
      }
    ]
  },
  "clients": [
    {
      "clientId": "propusk",
      "enabled": true,
      "protocol": "openid-connect",
      "publicClient": true,
      "fullScopeAllowed": true,
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "implicitFlowEnabled": false,
      "serviceAccountsEnabled": false,
      "redirectUris": [
        "http://localhost:5100/callback"
      ],
      "webOrigins": [],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "http://localhost:5100/"
      },
      "defaultClientScopes": [
        "profile",
        "email",
        "roles"
      ],
      "protocolMappers": [
        {
          "name": "realm-roles-for-propusk",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-usermodel-realm-role-mapper",
          "consentRequired": false,
          "config": {
            "claim.name": "realm_roles",
            "jsonType.label": "String",
            "multivalued": "true",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true"
          }
        }
      ]
    },
    {
      "clientId": "reader",
      "enabled": true,
      "protocol": "openid-connect",
      "publicClient": true,
      "fullScopeAllowed": true,
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "implicitFlowEnabled": false,
      "serviceAccountsEnabled": false,
      "redirectUris": [
        "http://localhost:5101/callback"
      ],
      "webOrigins": [],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "http://localhost:5101/"
      },
      "defaultClientScopes": [
        "profile",
        "email",
        "roles"
      ],
      "protocolMappers": [
        {
          "name": "realm-roles-for-propusk",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-usermodel-realm-role-mapper",
          "consentRequired": false,
          "config": {
            "claim.name": "realm_roles",
            "jsonType.label": "String",
            "multivalued": "true",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true"
          }
        }
      ]
    }
  ],
  "users": [
    {
      "username": "anna",
      "enabled": true,
      "firstName": "Анна",
      "lastName": "Редактор",
      "email": "anna@example.invalid",
      "emailVerified": true,
      "realmRoles": [
        "editor",
        "reader"
      ],
      "credentials": [
        {
          "type": "password",
          "value": "Local-Anna-only-2026!",
          "temporary": false
        }
      ]
    },
    {
      "username": "boris",
      "enabled": true,
      "firstName": "Борис",
      "lastName": "Читатель",
      "email": "boris@example.invalid",
      "emailVerified": true,
      "realmRoles": [
        "reader"
      ],
      "credentials": [
        {
          "type": "password",
          "value": "Local-Boris-only-2026!",
          "temporary": false
        }
      ]
    }
  ]
}

Полный файл config.mjs из состояния lesson-09:

import * as oidc from 'openid-client';

export const port = Number(process.env.PORT || 5100);
export const origin = process.env.APP_ORIGIN || `http://localhost:${port}`;
export const issuer = new URL(process.env.OIDC_ISSUER || 'http://localhost:8080/realms/professorweb');
export const clientId = process.env.CLIENT_ID || 'propusk';
if (!['http://localhost:5100', 'http://localhost:5101'].includes(origin)) {
  throw new Error('This laboratory accepts only its two localhost origins');
}
if (issuer.href !== 'http://localhost:8080/realms/professorweb') {
  throw new Error('Unexpected issuer for the local laboratory');
}
export const callback = `${origin}/callback`;
export const config = await oidc.discovery(issuer, clientId, undefined, oidc.None(), {
  execute: [oidc.allowInsecureRequests], // Local laboratory only. Use HTTPS outside localhost.
});

Оглавление серии · Исходники всех шагов · Учебные маршруты

Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.