From 66487247415b9a60d1ac70cc975cb9d1b9d9330a Mon Sep 17 00:00:00 2001 From: Bruno Pina Date: Wed, 28 Oct 2026 15:05:42 -0300 Subject: [PATCH] feat(auth): conclui o login por conexao de identidade (Sankhya Pass) a partir do codigo no fragmento (#189) Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 43 +++++ README.md | 22 ++- package-lock.json | 4 +- package.json | 2 +- scripts/smoke-package.mjs | 2 + src/client.test.ts | 129 ++++++++++++- src/client.ts | 30 ++- src/legacy/bridge.test.ts | 32 ++++ src/legacy/bridge.ts | 9 +- src/modules/agent-outbox.test.ts | 1 + src/modules/agent-session.test.ts | 1 + src/modules/agent-tasks.test.ts | 1 + src/modules/auth.test.ts | 191 +++++++++++++++++++ src/modules/auth.ts | 188 ++++++++++++++---- src/modules/identity-connection-flow.test.ts | 161 ++++++++++++++++ src/modules/identity-connection-flow.ts | 81 ++++++++ src/test-utils.ts | 10 +- 17 files changed, 856 insertions(+), 51 deletions(-) create mode 100644 src/modules/identity-connection-flow.test.ts create mode 100644 src/modules/identity-connection-flow.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 906d038..4f88b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ All notable changes to this project are documented in this file. +## 1.9.0-beta.0 + +Sign-in through an identity connection, starting with Sankhya Pass, for an app opened inside the +host system's iframe. + +- `auth.completeIdentityConnectionSignInRedirect()` reads `codeMitra`, `stateMitra` and + `connectionMitra` from the fragment the platform bridge page (`sankhya-auth.html`) leaves on the + app's origin, and redeems the single-use code at + `POST /api/v1/auth/identity-connections/{slug}/exchange` with `{ appId, code }`. The response is + read like every other exchange, `allTokens` included. Tokens never travel in the URL or by + `postMessage`. +- It returns `null`, without a request and without touching the URL, when the fragment has no + `connectionMitra` or its `stateMitra` does not start with `connection.`, so the other completions + still read their own fragment. From there on the fragment belongs to this flow and is removed + before anything can fail. It rejects in a top-level tab (the bridge only runs in an iframe), for + a connection slug outside `[a-z0-9-]{1,64}`, for an `errorMitra`, for a missing code and when IAM + refuses the code (`401 INVALID_EXCHANGE_CODE`). +- `init()` calls it in a browser after reading the app info, so an app that already calls + `await mitra.init()` opens signed in with no change. A refused code rejects `init()`; the + fragment is already gone, so a reload does not repeat the failure. Without the fragment `init()` + makes no extra request. A call to `init()` while another one runs now waits for it instead of + reading the app info a second time, so a double boot (React `StrictMode`) redeems the code once. +- A session from an identity connection is kept in `sessionStorage` under `mitra_auth_{appId}` + instead of `localStorage`, and lives only as long as its tab: two accounts of the host system + (two Sankhya demo bases) open in one browser no longer hand each other's session to the next + reload. On startup the tab's session wins over the shared one, which is left in place for the + other tabs of the app; a broken tab session is dropped alone and the shared one is restored. + `signOut()` in that tab ends only the tab session. Another sign-in or `setSession()` in the same + tab goes back to `localStorage` and removes the tab copy; after `setToken()` the session always + goes to `localStorage`, and the tab copy is removed even with `setToken(token, false)`. A renewal + keeps the session where it is. +- When the identity connection sign-in fails, the session the client restored at startup is + dropped from memory and stays stored where it was (`localStorage`, or `sessionStorage` for a tab + session), so the iframe does not stay signed in as someone else after a refused Sankhya Pass + login. `init()` still rejects. +- The deprecated `mitra-interactions-sdk` exports get only the access token of a tab session. + Its refresh token is not handed over, because the legacy SDK writes every refresh token it + receives to `localStorage` (`mitra-session`), where it would outlive the tab. So in a tab session + the legacy exports do not renew on their own, and this SDK renews only before one of its own + requests, in `ensureFreshSession()` or in `refreshSession()`, with no timer. An app that uses only the legacy exports gets + `401` once the access token expires, unless it makes a call through this SDK or calls + `refreshSession()`; the renewed token then reaches the legacy SDK at once. + ## 1.8.6 - Depend on `@mitralab.io/sdk-core@0.3.7`: the turn's step list from T3 (Claude tasks and todos, diff --git a/README.md b/README.md index 789e274..ffd5db3 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,22 @@ if (token) await mitra.auth.confirmPasswordReset({ token, newPassword }) A troca e o reset encerram todas as sessões da conta. Depois do reset, a pessoa entra de novo com a senha nova. +### Login por conexão de identidade (Sankhya Pass) + +Para app aberto dentro do iframe de outro sistema, como o SKW da Sankhya. O sistema hospedeiro abre a página de ponte da Mitra (`sankhya-auth.html`), que faz o login no IAM com o token dele e manda o iframe para a origem do app com um código de uso único no fragmento (`#codeMitra=...&stateMitra=connection.*&connectionMitra=sankhya-pass`). Nenhum token passa pela URL ou por `postMessage`. + +O `await mitra.init()` troca esse código pela sessão sozinho, então o app abre logado sem código novo. Se a troca falhar, o `init()` rejeita; o fragmento já saiu da URL, e um reload não repete o erro. Fora desse fluxo, o `init()` não faz requisição a mais. Quem não chama `init()` usa `completeIdentityConnectionSignInRedirect()` no startup, que devolve `null` quando a URL não é dele. + +O código só é aceito dentro de iframe: aberto numa aba de topo, a troca é recusada. + +A sessão desse login fica no `sessionStorage`, na mesma chave `mitra_auth_{appId}`, e dura só enquanto a aba existe. É assim porque o sistema hospedeiro abre o app com a conta de cada base, e duas bases demo da Sankhya abertas no mesmo browser, dividindo o `localStorage`, trocavam a sessão e o alias uma da outra no reload, como já acontecia no legado. + +- No startup, a sessão da aba vale antes da sessão compartilhada do `localStorage`. A sessão compartilhada não é apagada: ela é das outras abas do app, logadas do jeito normal. Se a aba não tem sessão própria, ela usa a compartilhada. +- `signOut()` numa aba assim encerra só a sessão da aba. +- Um login por outro método ou `setSession()` na mesma aba volta a gravar no `localStorage` e apaga a cópia da aba. Depois de `setToken()` o destino é sempre o `localStorage`, e a cópia da aba é apagada mesmo com `setToken(token, false)`. +- Se o login pela conexão for recusado, o `init()` rejeita e a sessão que o app tinha restaurado no startup deixa de valer naquela página, para o iframe não seguir logado com outra pessoa. Ela continua guardada onde estava: no `localStorage`, ou no `sessionStorage` se era uma sessão por aba. +- Os exports legados do `mitra-interactions-sdk` recebem dessa sessão só o access token. O refresh token não vai para eles, porque o SDK legado grava o refresh token no `localStorage` (`mitra-session`), onde ele sobreviveria à aba. Por isso, numa sessão por aba, os exports legados não renovam sozinhos, e o SDK novo também não renova em segundo plano: ele renova antes de uma requisição dele mesmo, em `ensureFreshSession()` ou em `refreshSession()`. Um app que só usa os exports legados recebe `401` quando o access token vence, a menos que faça uma chamada pelo SDK novo ou chame `refreshSession()`; o token renovado chega ao legado na hora. + Chat com agente: ```typescript @@ -144,9 +160,9 @@ Falha de rede chega como o erro do próprio `fetch` (`TypeError`), sem passar po ## Boas práticas - Chame `await mitra.init()` no startup, antes de montar a tela de login: `allowSignup`, `emailLoginEnabled`, `passwordLoginEnabled` e o login por e-mail dependem dele. -- Com `mode: "redirect"`, chame no startup o `completeGoogleSignInRedirect()`, `completeMicrosoftSignInRedirect()` ou `completeEmailSignInRedirect()` do método que você usa. Cada um devolve `null` quando a URL não é dele. O link do e-mail completa o login no mesmo browser que pediu o código; em outro aparelho, a pessoa digita o código. -- A sessão fica no `localStorage` do domínio do app, na chave `mitra_auth_{appId}`, e é renovada sozinha antes de vencer. `signOut()` limpa e, em app com contas próprias, encerra a sessão no servidor: o refresh token deixa de renovar, e o access token já emitido vale até vencer. -- Alguns apps recebem tokens extras junto da sessão, em `auth.allTokens`, liberados app por app pela Mitra. O de backoffice é o mais sensível: ele carrega os acessos que a pessoa tem no backoffice da Mitra, vale uma hora e fica no mesmo `localStorage` da sessão, onde qualquer script do domínio do app consegue ler. Leia ele por `auth.ensureFreshBackofficeToken()`, que renova o token quando está perto de vencer e devolve `null` quando o app ou a pessoa não têm esse acesso. Use só contra o backoffice e não repasse para outro serviço. +- Com `mode: "redirect"`, chame no startup o `completeGoogleSignInRedirect()`, `completeMicrosoftSignInRedirect()` ou `completeEmailSignInRedirect()` do método que você usa. Cada um devolve `null` quando a URL não é dele. O link do e-mail completa o login no mesmo browser que pediu o código; em outro aparelho, a pessoa digita o código. O login por conexão de identidade (Sankhya Pass) dispensa essa chamada: o `init()` já completa. +- A sessão fica no `localStorage` do domínio do app, na chave `mitra_auth_{appId}`, e é renovada sozinha antes de vencer. A do login por conexão de identidade fica no `sessionStorage` da aba, na mesma chave. `signOut()` limpa a sessão e, em app com contas próprias, encerra a sessão no servidor: o refresh token deixa de renovar, e o access token já emitido vale até vencer. +- Alguns apps recebem tokens extras junto da sessão, em `auth.allTokens`, liberados app por app pela Mitra. O de backoffice é o mais sensível: ele carrega os acessos que a pessoa tem no backoffice da Mitra, vale uma hora e fica no mesmo storage da sessão, onde qualquer script do domínio do app consegue ler. Leia ele por `auth.ensureFreshBackofficeToken()`, que renova o token quando está perto de vencer e devolve `null` quando o app ou a pessoa não têm esse acesso. Use só contra o backoffice e não repasse para outro serviço. - O SDK só repete uma requisição depois de renovar o token num `401`. Fora isso, quem decide repetir é o app. - Nunca coloque API key em código que vai para o browser, nem passe uma em `createClient({ apiKey })` num app de browser: o bundle entrega a chave a quem abrir o app. O `signInWithApiKey()` recusa rodar no browser, mas isso não tira do bundle uma chave escrita nele. Para processo sem pessoa, como cron ou coletor, use `@mitralab.io/functions-sdk` com `createClientFromApiKey`. diff --git a/package-lock.json b/package-lock.json index a304a58..ff7914d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mitralab.io/platform-sdk", - "version": "1.8.6", + "version": "1.9.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mitralab.io/platform-sdk", - "version": "1.8.6", + "version": "1.9.0-beta.0", "license": "MIT", "dependencies": { "@mitralab.io/sdk-core": "0.3.7", diff --git a/package.json b/package.json index 2a7bdbf..cb62dfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mitralab.io/platform-sdk", - "version": "1.8.6", + "version": "1.9.0-beta.0", "description": "JavaScript/TypeScript SDK for building apps on the Mitra Platform", "type": "module", "sideEffects": false, diff --git a/scripts/smoke-package.mjs b/scripts/smoke-package.mjs index 16976ce..df175b8 100644 --- a/scripts/smoke-package.mjs +++ b/scripts/smoke-package.mjs @@ -186,6 +186,7 @@ void client.auth.completeGoogleSignInRedirect() void client.auth.signInWithEmail({ mode: "popup" }) void client.auth.verifyEmailCode(emailCodeVerification) void client.auth.completeEmailSignInRedirect() +const connectionUser: Promise = client.auth.completeIdentityConnectionSignInRedirect() const passwordUser: Promise = client.auth.signInWithPassword() void client.auth.signUpWithPassword() void client.auth.changePassword() @@ -262,6 +263,7 @@ void passwordUser void passwordResetRequested void passwordLoginEnabled void formUser +void connectionUser void passwordSignUpStarted void resetToken void resetConfirmed diff --git a/src/client.test.ts b/src/client.test.ts index 00d5d37..ee926b8 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { createClient } from './client'; -import { mockFetch, mockFetchSequence, mockLocalStorage } from './test-utils'; +import { mockBrowser, mockFetch, mockFetchSequence, mockLocalStorage } from './test-utils'; function jwt(payload: Record): string { const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); @@ -373,3 +373,130 @@ describe('createClient', () => { ]); }); }); + +describe('createClient init with an identity connection fragment', () => { + const APP_ID = 'app-1'; + const APP_INFO_URL = `https://api.mitra.io/code-studio/api/v1/apps/${APP_ID}/info`; + const EXCHANGE_URL = 'https://api.mitra.io/iam/api/v1/auth/identity-connections/sankhya-pass/exchange'; + const FRAGMENT = + '#codeMitra=code-1&stateMitra=connection.0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a&connectionMitra=sankhya-pass'; + const appInfo = { dataSourceId: 'ds-1', allowSignup: false }; + const allTokens = { + platform: { accessToken: 'session-access', refreshToken: 'session-refresh', tokenType: 'Bearer' }, + mitraSpace: { token: 'space-token', tokenType: 'Bearer' }, + b2bToken: null, + backoffice: null, + }; + const currentUser = { + id: 'u1', + tenant: { + id: 't1', + shortId: 'AAAAAAAAAAAAAAAAAAAAEA', + legacyId: null, + slug: 'test-tenant', + clusterType: 'SHARED', + name: 'Test Tenant', + description: null, + hexColor: null, + icon: null, + infraStatus: 'ACTIVE', + active: true, + }, + email: 'user@test.com', + name: 'Test User', + imageUrl: null, + planId: 'plan-1', + onboardingCompleted: false, + language: 'pt-BR', + }; + + function framedAt(fragment: string) { + const browser = mockBrowser(undefined, { framed: true }); + browser.window.location.hash = fragment; + return browser; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should make no request beyond the app info when the URL carries no fragment', async () => { + framedAt(''); + const fetchMock = mockFetchSequence([{ body: appInfo }]); + const mitra = createClient({ appId: APP_ID, apiUrl: 'https://api.mitra.io' }); + + await mitra.init(); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([APP_INFO_URL]); + expect(mitra.auth.isAuthenticated).toBe(false); + }); + + it('should finish the sign-in after reading the app info, once for concurrent calls', async () => { + const browser = framedAt(FRAGMENT); + const fetchMock = mockFetchSequence([ + { body: appInfo }, + { + body: { + accessToken: jwt({ app_id: APP_ID }), + refreshToken: jwt({ app_id: APP_ID }), + tokenType: 'Bearer', + allTokens, + }, + }, + { body: currentUser }, + ]); + const mitra = createClient({ appId: APP_ID, apiUrl: 'https://api.mitra.io' }); + + await Promise.all([mitra.init(), mitra.init()]); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + APP_INFO_URL, + EXCHANGE_URL, + 'https://api.mitra.io/iam/api/v1/auth/me', + ]); + expect(mitra.allowSignup).toBe(false); + expect(mitra.auth.currentUser).toMatchObject({ id: 'u1' }); + expect(mitra.auth.allTokens).toEqual(allTokens); + expect(browser.window.history.replaceState).toHaveBeenCalledTimes(1); + }); + + it('should reject init when the exchange is refused, and not repeat it', async () => { + const browser = framedAt(FRAGMENT); + const fetchMock = mockFetchSequence([ + { body: appInfo }, + { body: { message: 'Invalid code', error_code: 'INVALID_EXCHANGE_CODE' }, status: 401 }, + ]); + const mitra = createClient({ appId: APP_ID, apiUrl: 'https://api.mitra.io' }); + + await expect(mitra.init()).rejects.toMatchObject({ status: 401 }); + browser.window.location.hash = ''; + await expect(mitra.init()).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mitra.allowSignup).toBe(false); + expect(mitra.auth.isAuthenticated).toBe(false); + }); + + it('should stop using the restored shared session when the exchange is refused, keeping it stored', async () => { + framedAt(FRAGMENT); + const sharedSession = JSON.stringify({ + user: { id: 'u2', tenantId: 't1', email: 'other@test.com', name: null }, + token: jwt({ app_id: APP_ID, sub: 'u2' }), + refreshToken: jwt({ app_id: APP_ID, sub: 'u2' }), + }); + (localStorage as unknown as { _store: Record })._store[`mitra_auth_${APP_ID}`] = sharedSession; + mockFetchSequence([ + { body: appInfo }, + { body: { message: 'Invalid code', error_code: 'INVALID_EXCHANGE_CODE' }, status: 401 }, + ]); + const mitra = createClient({ appId: APP_ID, apiUrl: 'https://api.mitra.io' }); + expect(mitra.auth.isAuthenticated).toBe(true); + + await expect(mitra.init()).rejects.toMatchObject({ status: 401 }); + + expect(mitra.auth.isAuthenticated).toBe(false); + expect(mitra.auth.accessToken).toBeNull(); + expect(mitra.auth.currentUser).toBeNull(); + expect(localStorage.getItem(`mitra_auth_${APP_ID}`)).toBe(sharedSession); + }); +}); diff --git a/src/client.ts b/src/client.ts index 74ac3a2..10327b0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -155,7 +155,13 @@ export interface MitraClient { * passwordLoginEnabled, and the brand `requestEmailCode()` sends with, from * the public app info endpoint. * - * Safe to call multiple times. Subsequent calls are no-ops. + * In a browser it also finishes a sign-in through an identity connection + * (see `auth.completeIdentityConnectionSignInRedirect()`) when the URL + * carries one, and rejects when that sign-in fails. Without such a fragment + * it makes no extra request. + * + * Safe to call multiple times. A call made while another runs waits for it; + * once the app info is read, later calls are no-ops. * * @example * ```typescript @@ -403,13 +409,24 @@ export function createClient(config: MitraClientConfig): MitraClient { legacyBridge.connect(); let initialized = false; + let initFlight: Promise | null = null; let allowSignup = true; let emailLoginEnabled = false; let passwordLoginEnabled = false; - async function init(): Promise { - if (initialized) return; + // A second call while the first runs (React StrictMode mounts twice) joins + // it: the code in the fragment is single-use, and a second boot returning + // before the first one stored the session would render the app signed out. + function init(): Promise { + if (initFlight) return initFlight; + if (initialized) return Promise.resolve(); + initFlight = runInit().finally(() => { + initFlight = null; + }); + return initFlight; + } + async function runInit(): Promise { const publicClient = new HttpClient({ baseUrl: codeStudioUrl, getToken: () => null, @@ -432,6 +449,13 @@ export function createClient(config: MitraClientConfig): MitraClient { passwordLoginEnabled = appInfo.passwordLoginEnabled; initialized = true; + + // The app info is in place first, as an app reads it right after init(). + // A failed redemption rejects init(); the fragment is already gone, so a + // reload starts clean instead of failing the same way. + if (globalThis.window !== undefined) { + await authModule.completeIdentityConnectionSignInRedirect(); + } } return { diff --git a/src/legacy/bridge.test.ts b/src/legacy/bridge.test.ts index b7ec7d2..b774324 100644 --- a/src/legacy/bridge.test.ts +++ b/src/legacy/bridge.test.ts @@ -148,6 +148,38 @@ describe('LegacySessionBridge', () => { expect(getConfig().refreshToken).toBe(appScopedRefreshToken); }); + it('should hand a tab session to the legacy SDK without its refresh token, and keep handing the renewed access token', async () => { + const otherRefreshToken = fakeJwt({ app_id: APP_ID, sub: 'u2' }); + storage._store['mitra-session'] = JSON.stringify({ refreshToken: otherRefreshToken }); + const tabStorage = mockSessionStorage(); + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: { id: 'u1', tenantId: 't1', email: 'user@test.com', name: null }, + token: 'tab-access-token', + refreshToken: appScopedRefreshToken, + }); + + const mitra = createClient({ appId: APP_ID, apiUrl: API_URL }); + + expect(getConfig().token).toBe('tab-access-token'); + expect(getConfig().refreshToken).toBeUndefined(); + expect(JSON.parse(storage._store['mitra-session'])).toEqual({ refreshToken: otherRefreshToken }); + + mockFetchSequence([ + { + body: { + accessToken: 'new-access-token', + refreshToken: appScopedRefreshToken, + tokenType: 'Bearer', + }, + }, + ]); + await expect(mitra.auth.refreshSession()).resolves.toBe(true); + + expect(getConfig().token).toBe('new-access-token'); + expect(getConfig().refreshToken).toBeUndefined(); + expect(storage._store['mitra-session']).not.toContain(appScopedRefreshToken); + }); + it('should leave the legacy session empty when nothing is persisted', () => { createClient({ appId: APP_ID, apiUrl: API_URL }); diff --git a/src/legacy/bridge.ts b/src/legacy/bridge.ts index 113dfac..b9d6852 100644 --- a/src/legacy/bridge.ts +++ b/src/legacy/bridge.ts @@ -61,6 +61,11 @@ export class LegacySessionBridge { } private syncToLegacy(session: { token: string | null; refreshToken: string | null }): void { + // The legacy SDK writes every refresh token it is given to localStorage + // ('mitra-session'), where it would outlive a session kept for its tab + // only. It gets the access token alone: this SDK keeps renewing it and + // hands each new one over through onSessionChange. + const refreshToken = this.auth.isTabScoped() ? null : session.refreshToken; try { const authPageUrl = resolveAuthPageUrl( this.apiUrl, @@ -73,7 +78,7 @@ export class LegacySessionBridge { projectId: this.appId, onTokenRefresh: (session) => this.rotate(session), ...(session.token ? { token: session.token } : {}), - ...(session.refreshToken ? { refreshToken: session.refreshToken } : {}), + ...(refreshToken ? { refreshToken } : {}), }); // The legacy package has no public sign-out API. `getConfig()` returns its @@ -81,7 +86,7 @@ export class LegacySessionBridge { // calls and refresh without coupling to its private localStorage key. const legacyConfig = getConfig(); if (!session.token) delete legacyConfig.token; - if (!session.refreshToken) delete legacyConfig.refreshToken; + if (!refreshToken) delete legacyConfig.refreshToken; } catch { // The legacy SDK rejects configurations this client cannot fix, such as a // blank apiUrl. The new surface keeps working without the bridge. diff --git a/src/modules/agent-outbox.test.ts b/src/modules/agent-outbox.test.ts index 78cc571..af94719 100644 --- a/src/modules/agent-outbox.test.ts +++ b/src/modules/agent-outbox.test.ts @@ -72,6 +72,7 @@ const auth: AuthSessionPort = { ensureFreshSession: vi.fn().mockResolvedValue(true), handleUnauthorized: vi.fn().mockResolvedValue(false), readSessionTokens: vi.fn().mockReturnValue({ token: 'access', refreshToken: 'refresh' }), + isTabScoped: vi.fn().mockReturnValue(false), onSessionChange: vi.fn().mockReturnValue(() => undefined), adoptSession: vi.fn(), rotateSession: vi.fn(), diff --git a/src/modules/agent-session.test.ts b/src/modules/agent-session.test.ts index a860de7..7f01e7d 100644 --- a/src/modules/agent-session.test.ts +++ b/src/modules/agent-session.test.ts @@ -47,6 +47,7 @@ function auth(): AuthSessionPort & { ensureFreshSession: vi.fn().mockResolvedValue(true), handleUnauthorized: vi.fn().mockResolvedValue(false), readSessionTokens: vi.fn().mockReturnValue({ token: 'app-access', refreshToken: 'refresh' }), + isTabScoped: vi.fn().mockReturnValue(false), onSessionChange: vi.fn().mockReturnValue(() => undefined), adoptSession: vi.fn(), rotateSession: vi.fn(), diff --git a/src/modules/agent-tasks.test.ts b/src/modules/agent-tasks.test.ts index f77ee69..f7a7a9e 100644 --- a/src/modules/agent-tasks.test.ts +++ b/src/modules/agent-tasks.test.ts @@ -35,6 +35,7 @@ const auth: AuthSessionPort = { ensureFreshSession: vi.fn().mockResolvedValue(true), handleUnauthorized: vi.fn().mockResolvedValue(false), readSessionTokens: vi.fn().mockReturnValue({ token: 'access', refreshToken: 'refresh' }), + isTabScoped: vi.fn().mockReturnValue(false), onSessionChange: vi.fn().mockReturnValue(() => undefined), adoptSession: vi.fn(), rotateSession: vi.fn(), diff --git a/src/modules/auth.test.ts b/src/modules/auth.test.ts index 0bcadcd..bcfc32a 100644 --- a/src/modules/auth.test.ts +++ b/src/modules/auth.test.ts @@ -1460,6 +1460,197 @@ describe('AuthModule', () => { ); }); }); + + describe('identity connection sign-in', () => { + const CONNECTION_FRAGMENT = + '#codeMitra=code-1&stateMitra=connection.0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a&connectionMitra=sankhya-pass'; + + function framedBrowser(fragment = CONNECTION_FRAGMENT) { + const browser = mockBrowser(undefined, { framed: true }); + browser.window.location.hash = fragment; + const tabStorage = browser.window.sessionStorage as unknown as ReturnType; + return { browser, tabStorage }; + } + + it('should return null without a request when the URL is not from an identity connection', async () => { + framedBrowser('#codeMitra=code-1&stateMitra=google.abc'); + const fetchMock = mockFetchSequence([]); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + await expect(auth.completeIdentityConnectionSignInRedirect()).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(auth.isAuthenticated).toBe(false); + }); + + it('should keep the session with allTokens in this tab and leave the shared one alone', async () => { + const { browser, tabStorage } = framedBrowser(); + storedSession(browser.localStorage, 'shared-access', 'shared-refresh'); + const sharedSession = browser.localStorage._store[STORAGE_KEY]; + mockFetchSequence([ + { body: { ...fakeTokenResponse, allTokens: fakeAllTokens } }, + { body: currentUserResponse }, + ]); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + await expect(auth.completeIdentityConnectionSignInRedirect()).resolves.toEqual(apiUser); + + expect(auth.allTokens).toEqual(fakeAllTokens); + expect(JSON.parse(tabStorage._store[STORAGE_KEY])).toEqual({ + user: apiUser, + token: 'access-123', + refreshToken: 'refresh-456', + allTokens: fakeAllTokens, + }); + expect(browser.localStorage._store[STORAGE_KEY]).toBe(sharedSession); + }); + + it('should restore the tab session before the shared one and keep it in the tab when refreshed', async () => { + const { browser, tabStorage } = framedBrowser(''); + storedSession(browser.localStorage, 'shared-access', 'shared-refresh'); + const sharedSession = browser.localStorage._store[STORAGE_KEY]; + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: fakeUser, + token: 'tab-access', + refreshToken: 'tab-refresh', + allTokens: fakeAllTokens, + }); + const fetchMock = mockFetchSequence([ + { body: { accessToken: 'new-access', refreshToken: 'new-refresh', tokenType: 'Bearer' } }, + ]); + + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + expect(auth.accessToken).toBe('tab-access'); + expect(auth.allTokens).toEqual(fakeAllTokens); + + await expect(auth.refreshSession()).resolves.toBe(true); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ refreshToken: 'tab-refresh' }); + expect(JSON.parse(tabStorage._store[STORAGE_KEY])).toMatchObject({ + token: 'new-access', + refreshToken: 'new-refresh', + }); + expect(browser.localStorage._store[STORAGE_KEY]).toBe(sharedSession); + }); + + it('should drop only a broken tab session and restore the shared one', () => { + const { browser, tabStorage } = framedBrowser(''); + storedSession(browser.localStorage, 'shared-access', 'shared-refresh'); + const sharedSession = browser.localStorage._store[STORAGE_KEY]; + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: fakeUser, + token: jwt({ app_id: 'another-app' }), + refreshToken: 'tab-refresh', + }); + + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + expect(auth.accessToken).toBe('shared-access'); + expect(tabStorage._store[STORAGE_KEY]).toBeUndefined(); + expect(browser.localStorage._store[STORAGE_KEY]).toBe(sharedSession); + }); + + it('should move the next sign-in of another method back to the shared storage', async () => { + const { browser, tabStorage } = framedBrowser(''); + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: fakeUser, + token: 'tab-access', + refreshToken: 'tab-refresh', + }); + mockFetchSequence([{ body: fakeTokenResponse }, { body: currentUserResponse }]); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + const signIn = auth.signInWithEmail({ mode: 'popup' }); + const state = browser.getStartUrl().searchParams.get('state')!; + browser.dispatchMessage(authPageResult(state, { code: 'exchange-code' })); + await signIn; + + expect(JSON.parse(browser.localStorage._store[STORAGE_KEY])).toMatchObject({ token: 'access-123' }); + expect(tabStorage._store[STORAGE_KEY]).toBeUndefined(); + }); + + it.each([ + ['setSession', (auth: AuthModule) => auth.setSession({ accessToken: 'adopted-access', refreshToken: 'adopted-refresh' })], + ['setToken', (auth: AuthModule) => auth.setToken('adopted-access')], + ])('should keep a session replaced by %s in the shared storage', (_method, replace) => { + const { browser, tabStorage } = framedBrowser(''); + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: fakeUser, + token: 'tab-access', + refreshToken: 'tab-refresh', + }); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + replace(auth); + + expect(JSON.parse(browser.localStorage._store[STORAGE_KEY])).toMatchObject({ token: 'adopted-access' }); + expect(tabStorage._store[STORAGE_KEY]).toBeUndefined(); + }); + + it('should stop using the restored session, keeping it stored, when the bridge reports an error', async () => { + const { browser } = framedBrowser( + '#errorMitra=access_denied&stateMitra=connection.0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a&connectionMitra=sankhya-pass' + ); + storedSession(browser.localStorage, 'shared-access', 'shared-refresh'); + const sharedSession = browser.localStorage._store[STORAGE_KEY]; + const fetchMock = mockFetchSequence([]); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + expect(auth.isAuthenticated).toBe(true); + + await expect(auth.completeIdentityConnectionSignInRedirect()).rejects.toThrow('access_denied'); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(auth.isAuthenticated).toBe(false); + expect(browser.localStorage._store[STORAGE_KEY]).toBe(sharedSession); + }); + + it('should drop the tab copy when a token set by hand is not saved', () => { + const { browser, tabStorage } = framedBrowser(''); + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: fakeUser, + token: 'tab-access', + refreshToken: 'tab-refresh', + }); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + auth.setToken('manual-access', false); + + expect(tabStorage._store[STORAGE_KEY]).toBeUndefined(); + expect(browser.localStorage._store[STORAGE_KEY]).toBeUndefined(); + expect(getAuthSessionPort(auth).isTabScoped()).toBe(false); + }); + + it('should keep a rotated tab session in the tab', () => { + const { browser, tabStorage } = framedBrowser(''); + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: fakeUser, + token: 'tab-access', + refreshToken: 'tab-refresh', + }); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + getAuthSessionPort(auth).rotateSession({ token: 'rotated-access', refreshToken: 'rotated-refresh' }); + + expect(JSON.parse(tabStorage._store[STORAGE_KEY])).toMatchObject({ token: 'rotated-access' }); + expect(browser.localStorage._store[STORAGE_KEY]).toBeUndefined(); + expect(getAuthSessionPort(auth).isTabScoped()).toBe(true); + }); + + it('should end only the tab session on sign-out', () => { + const { browser, tabStorage } = framedBrowser(''); + storedSession(browser.localStorage, 'shared-access', 'shared-refresh'); + const sharedSession = browser.localStorage._store[STORAGE_KEY]; + tabStorage._store[STORAGE_KEY] = JSON.stringify({ + user: fakeUser, + token: 'tab-access', + refreshToken: 'tab-refresh', + }); + const auth = new AuthModule(APP_ID, IAM_URL, { apiUrl: API_URL }); + + auth.signOut(); + + expect(tabStorage._store[STORAGE_KEY]).toBeUndefined(); + expect(browser.localStorage._store[STORAGE_KEY]).toBe(sharedSession); + }); + }); }); describe('AuthModule password methods', () => { diff --git a/src/modules/auth.ts b/src/modules/auth.ts index df94be7..ea71d24 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -10,6 +10,7 @@ import { PASSWORD_CHANGED_SIGN_IN_AGAIN, } from './auth-page-flow'; import { EmailCodeFlow, resolveLanguage } from './email-code-flow'; +import { IdentityConnectionFlow } from './identity-connection-flow'; import { PasswordFormFlow, passwordChangedSignInAgain } from './password-form-flow'; import type { User, @@ -98,6 +99,8 @@ export interface AuthSessionPort { ensureFreshSession(minValidityMs?: number): Promise; handleUnauthorized(requestToken: string | null): Promise; readSessionTokens(): AuthSessionTokens; + /** Whether the current session is kept for its tab only (`sessionStorage`). */ + isTabScoped(): boolean; onSessionChange(callback: AuthSessionChangeCallback): () => void; adoptSession(session: { token: string; refreshToken?: string | null }): boolean; rotateSession(session: { token: string; refreshToken?: string | null }): boolean; @@ -208,7 +211,8 @@ function isDefinitiveRefreshFailure(error: unknown): boolean { * Handles Google and Microsoft SSO, email and password sign-in, trusted session adoption, sign-out, * and automatic token refresh. * Auth state is persisted to localStorage with key `mitra_auth_{appId}` - * and restored on page reload. + * and restored on page reload; a session from an identity connection is kept + * in sessionStorage under the same key instead, so it lives in its tab only. * * @example * ```typescript @@ -238,7 +242,16 @@ export class AuthModule { private readonly passwordAuth: AuthPageFlow; private readonly emailCode: EmailCodeFlow; private readonly passwordForm: PasswordFormFlow; + private readonly identityConnection: IdentityConnectionFlow; private brand: string | null = null; + /** + * Whether the session lives in `sessionStorage`, scoped to its tab, rather + * than in `localStorage`. Set for a session from an identity connection: the + * host system opens one app per account it signed in, and two of them in one + * browser (two Sankhya demo bases, for instance) sharing `localStorage` would + * hand each other's session, and identity attributes, to the next reload. + */ + private sessionScoped = false; constructor(appId: string, iamBaseUrl: string, options: AuthModuleOptions = {}) { this.appId = appId; @@ -296,6 +309,7 @@ export class AuthModule { client: this.publicClient, exchange: this.passwordAuth, }); + this.identityConnection = new IdentityConnectionFlow({ appId, client: this.publicClient }); this.loadFromStorage(); const readAccessToken = () => this.#accessToken; sessionPorts.set(this, { @@ -305,6 +319,7 @@ export class AuthModule { ensureFreshSession: (minValidityMs) => this.ensureFreshSession(minValidityMs), handleUnauthorized: (requestToken) => this.handleUnauthorized(requestToken), readSessionTokens: () => this.readSessionTokens(), + isTabScoped: () => this.sessionScoped, onSessionChange: (callback) => this.onSessionChange(callback), adoptSession: (session) => this.adoptSession(session), rotateSession: (session) => this.rotateSession(session), @@ -612,6 +627,39 @@ export class AuthModule { return tokenResponse ? this.establishSession(tokenResponse) : null; } + /** + * Completes a sign-in made through an identity connection, such as Sankhya + * Pass, from the single-use code the platform bridge page leaves in the + * fragment (`#codeMitra`, `#stateMitra=connection.*`, `#connectionMitra`). + * + * `init()` already calls it, so an application only calls it itself when it + * does not call `init()`. The session is kept in `sessionStorage`, not + * `localStorage`, and so lives only as long as its tab. + * + * @returns The authenticated user, or `null` when the URL carries no + * identity connection result. A fragment of another sign-in method is left + * untouched. + * @throws When the fragment belongs to an identity connection but cannot be + * redeemed: outside an iframe, with an invalid connection, an error from the + * bridge, no code, or a code IAM refuses. The fragment is removed first, and + * the session this page held is dropped from memory, though not from storage. + */ + async completeIdentityConnectionSignInRedirect(): Promise { + let tokenResponse: AuthTokenResponse | null; + try { + tokenResponse = await this.identityConnection.completeRedirect(); + } catch (error) { + // The host system signed someone in here and it was refused. The session + // restored at startup may be another person's, so it stops being used in + // this page; it stays stored for the tabs it belongs to. This covers every + // failure of a fragment of this flow on purpose, a forged one in a + // top-level tab included, not only a code IAM refused. + this.clearAuthState({ keepStorage: true }); + throw error; + } + return tokenResponse ? this.establishSession(tokenResponse, true) : null; + } + /** * @deprecated Use signUpWithPassword(), which opens the Mitra auth page, * startPasswordSignUp() in an app with the form mode on, signInWithEmail() or SSO. @@ -983,7 +1031,7 @@ export class AuthModule { } /** - * Signs out the current user, clearing all auth state and localStorage. + * Signs out the current user, clearing all auth state and the stored session. * * In an app whose accounts are its own, the session also ends on the server: * the refresh token is sent to IAM's logout, so it can no longer be renewed. @@ -1133,6 +1181,9 @@ export class AuthModule { * * Call `me()` afterwards to fetch the associated user data. {@link allTokens} * goes back to `null`, because it belongs to the session being replaced. + * The token starts a session of its own, so it is kept in `localStorage` + * even when the one it replaces lived in its tab, and that tab copy is + * removed in any case. * * @param token - JWT access token. * @param saveToStorage - Whether to persist to localStorage (default: true). @@ -1151,6 +1202,10 @@ export class AuthModule { this.invalidatePendingRefreshes(); this.#accessToken = token; this.#allTokens = null; + // A token set by hand is a new session, not the identity connection's one, + // and the tab copy of that one would come back on the next reload. + this.sessionScoped = false; + removeItem(sessionStorageOrNull(), this.storageKey); if (saveToStorage) { this.saveToStorage(); } @@ -1289,7 +1344,7 @@ export class AuthModule { * @internal */ private adoptSession(session: { token: string; refreshToken?: string | null }): boolean { - return this.applySession(session, false); + return this.applySession(session, { keepAllTokens: false, replacesSession: true }); } /** @@ -1302,7 +1357,10 @@ export class AuthModule { private rotateSession(session: { token: string; refreshToken?: string | null }): boolean { // A legacy refresh started for one person can land after another person // signed in to the same app; keeping allTokens then would mix the two. - return this.applySession(session, this.isSamePerson(session.token)); + return this.applySession(session, { + keepAllTokens: this.isSamePerson(session.token), + replacesSession: false, + }); } private isSamePerson(token: string): boolean { @@ -1312,7 +1370,7 @@ export class AuthModule { private applySession( session: { token: string; refreshToken?: string | null }, - keepAllTokens: boolean + { keepAllTokens, replacesSession }: { keepAllTokens: boolean; replacesSession: boolean } ): boolean { if ( !this.belongsToConfiguredApp(session.token) @@ -1326,6 +1384,9 @@ export class AuthModule { } this.invalidatePendingRefreshes(); if (!keepAllTokens) this.#allTokens = null; + // An adopted session is not the identity connection's one, so it goes to + // the shared storage; a rotation renews the session in place, wherever it is. + if (replacesSession) this.sessionScoped = false; this.#accessToken = session.token; if (session.refreshToken !== undefined) { this.#refreshToken = session.refreshToken; @@ -1424,7 +1485,10 @@ export class AuthModule { return true; } - private async establishSession(tokenResponse: AuthTokenResponse): Promise { + private async establishSession( + tokenResponse: AuthTokenResponse, + sessionScoped: boolean = false + ): Promise { if ( !this.belongsToConfiguredApp(tokenResponse.accessToken) || !this.belongsToConfiguredApp(tokenResponse.refreshToken) @@ -1437,6 +1501,7 @@ export class AuthModule { this.#accessToken = tokenResponse.accessToken; this.#refreshToken = tokenResponse.refreshToken; this.#allTokens = discardEmptyAllTokens(tokenResponse.allTokens); + this.sessionScoped = sessionScoped; try { const user = await this.getCurrentUser(); @@ -1467,7 +1532,7 @@ export class AuthModule { return { ...user, tenantId: user.tenant.id }; } - private clearAuthState(): void { + private clearAuthState({ keepStorage = false }: { keepStorage?: boolean } = {}): void { const hadAuthState = this._currentUser !== null || this.#accessToken !== null || this.#refreshToken !== null; @@ -1476,7 +1541,8 @@ export class AuthModule { this.#accessToken = null; this.#refreshToken = null; this.#allTokens = null; - this.removeFromStorage(); + if (!keepStorage) this.removeFromStorage(); + this.sessionScoped = false; if (hadAuthState) { this.notifySessionListeners(); this.notifyListeners(); @@ -1533,10 +1599,11 @@ export class AuthModule { } private saveToStorage(): void { - if (typeof localStorage === 'undefined') return; + const target = this.sessionScoped ? sessionStorageOrNull() : localStorageOrNull(); + if (!target) return; try { - localStorage.setItem( + target.setItem( this.storageKey, JSON.stringify({ user: this._currentUser, @@ -1550,46 +1617,91 @@ export class AuthModule { ); } catch { // Storage might be full or disabled + return; } + // A tab session left behind would win over this one on the next reload. + // The other way round the shared copy stays: it belongs to the other tabs + // of this app, signed in their usual way. + if (!this.sessionScoped) removeItem(sessionStorageOrNull(), this.storageKey); } private loadFromStorage(): void { - if (typeof localStorage === 'undefined') return; + // The tab's own session wins over the one every tab shares. + if (this.restoreFrom(sessionStorageOrNull(), true)) return; + this.restoreFrom(localStorageOrNull(), false); + } + + /** + * Restores the session kept in one storage, reporting whether it did. An + * unusable entry is removed from that storage only, so a broken tab session + * does not take the shared one with it. + */ + private restoreFrom(storage: Storage | null, sessionScoped: boolean): boolean { + const stored = readItem(storage, this.storageKey); + if (!stored) return false; try { - const stored = localStorage.getItem(this.storageKey); - if (stored) { - const { user, token, refreshToken, allTokens } = JSON.parse(stored); - if ( - typeof token !== 'string' - || (refreshToken !== null && refreshToken !== undefined && typeof refreshToken !== 'string') - || !this.belongsToConfiguredApp(token) - || ( - typeof refreshToken === 'string' - && !this.belongsToConfiguredApp(refreshToken) - ) - ) { - this.clearAuthState(); - return; - } - this._currentUser = user; - this.#accessToken = token; - this.#refreshToken = refreshToken ?? null; - this.#allTokens = discardEmptyAllTokens(normalizeAllTokens(allTokens)); - this.invalidatePendingRefreshes(); + const { user, token, refreshToken, allTokens } = JSON.parse(stored); + if ( + typeof token !== 'string' + || (refreshToken !== null && refreshToken !== undefined && typeof refreshToken !== 'string') + || !this.belongsToConfiguredApp(token) + || ( + typeof refreshToken === 'string' + && !this.belongsToConfiguredApp(refreshToken) + ) + ) { + removeItem(storage, this.storageKey); + return false; } + this._currentUser = user; + this.#accessToken = token; + this.#refreshToken = refreshToken ?? null; + this.#allTokens = discardEmptyAllTokens(normalizeAllTokens(allTokens)); + this.sessionScoped = sessionScoped; + this.invalidatePendingRefreshes(); + return true; } catch { - this.removeFromStorage(); + removeItem(storage, this.storageKey); + return false; } } private removeFromStorage(): void { - if (typeof localStorage === 'undefined') return; + // Ending a tab session leaves the shared one to the tabs it belongs to. + removeItem(sessionStorageOrNull(), this.storageKey); + if (!this.sessionScoped) removeItem(localStorageOrNull(), this.storageKey); + } +} - try { - localStorage.removeItem(this.storageKey); - } catch { - // Storage might be disabled - } +function localStorageOrNull(): Storage | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage; + } catch { + return null; + } +} + +function sessionStorageOrNull(): Storage | null { + try { + return typeof sessionStorage === 'undefined' ? null : sessionStorage; + } catch { + return null; + } +} + +function readItem(storage: Storage | null, key: string): string | null { + try { + return storage?.getItem(key) ?? null; + } catch { + return null; + } +} + +function removeItem(storage: Storage | null, key: string): void { + try { + storage?.removeItem(key); + } catch { + // Storage might be disabled } } diff --git a/src/modules/identity-connection-flow.test.ts b/src/modules/identity-connection-flow.test.ts new file mode 100644 index 0000000..b115911 --- /dev/null +++ b/src/modules/identity-connection-flow.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MOCK_API_URL as API_URL, mockBrowser, mockFetchSequence, type BrowserHarness } from '../test-utils'; +import { HttpClient } from '../utils/http-client'; +import { IdentityConnectionFlow } from './identity-connection-flow'; + +const APP_ID = '11111111-1111-1111-1111-111111111111'; +const IAM_URL = `${API_URL}/iam`; +const EXCHANGE_URL = `${IAM_URL}/api/v1/auth/identity-connections/sankhya-pass/exchange`; +const STATE = 'connection.0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a'; +const ALL_TOKENS = { + platform: { accessToken: 'session-access', refreshToken: 'session-refresh', tokenType: 'Bearer' }, + mitraSpace: { token: 'space-token', tokenType: 'Bearer' }, + b2bToken: null, + backoffice: null, +}; + +function flow(): IdentityConnectionFlow { + return new IdentityConnectionFlow({ + appId: APP_ID, + client: new HttpClient({ baseUrl: IAM_URL, getToken: () => null }), + }); +} + +function framedWithFragment(fragment: string): BrowserHarness { + const browser = mockBrowser(undefined, { framed: true }); + browser.window.location.hash = fragment; + return browser; +} + +function expectFragmentCleared(browser: BrowserHarness): void { + expect(browser.window.history.replaceState).toHaveBeenCalledWith({}, '', '/orders?status=open'); +} + +describe('IdentityConnectionFlow', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should return null without a request when the URL carries no fragment', async () => { + const browser = framedWithFragment(''); + const fetchMock = mockFetchSequence([]); + + await expect(flow().completeRedirect()).resolves.toBeNull(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(browser.window.history.replaceState).not.toHaveBeenCalled(); + }); + + it.each([ + ['another flow state', `#codeMitra=code-1&stateMitra=google.${'0a'.repeat(16)}`], + ['no connection', `#codeMitra=code-1&stateMitra=${STATE}`], + ['a connection without the connection state', '#codeMitra=code-1&stateMitra=google.abc&connectionMitra=sankhya-pass'], + ['a connection without state', '#codeMitra=code-1&connectionMitra=sankhya-pass'], + ])('should leave the fragment untouched for %s', async (_case, fragment) => { + const browser = framedWithFragment(fragment); + const fetchMock = mockFetchSequence([]); + + await expect(flow().completeRedirect()).resolves.toBeNull(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(browser.window.history.replaceState).not.toHaveBeenCalled(); + }); + + it('should refuse a code in a top-level tab after clearing the fragment', async () => { + const browser = mockBrowser(); + browser.window.location.hash = `#codeMitra=code-1&stateMitra=${STATE}&connectionMitra=sankhya-pass`; + const fetchMock = mockFetchSequence([]); + + await expect(flow().completeRedirect()).rejects.toThrow('only accepted inside the host system iframe'); + + expectFragmentCleared(browser); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['Sankhya-Pass', '../admin', 'a'.repeat(65), ''])( + 'should refuse the invalid connection slug %j', + async (slug) => { + const browser = framedWithFragment( + `#codeMitra=code-1&stateMitra=${STATE}&connectionMitra=${encodeURIComponent(slug)}` + ); + const fetchMock = mockFetchSequence([]); + + await expect(flow().completeRedirect()).rejects.toThrow('invalid connection'); + + expectFragmentCleared(browser); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('should report the error the bridge sent instead of redeeming', async () => { + const browser = framedWithFragment( + `#errorMitra=access_denied&stateMitra=${STATE}&connectionMitra=sankhya-pass` + ); + const fetchMock = mockFetchSequence([]); + + await expect(flow().completeRedirect()).rejects.toThrow('access_denied'); + + expectFragmentCleared(browser); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should refuse a fragment without code', async () => { + const browser = framedWithFragment(`#codeMitra=%20&stateMitra=${STATE}&connectionMitra=sankhya-pass`); + const fetchMock = mockFetchSequence([]); + + await expect(flow().completeRedirect()).rejects.toThrow('missing code'); + + expectFragmentCleared(browser); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should redeem the code at the connection exchange with the app id', async () => { + const browser = framedWithFragment( + `#codeMitra=code-1&stateMitra=${STATE}&connectionMitra=sankhya-pass` + ); + const fetchMock = mockFetchSequence([ + { + body: { + accessToken: 'access-123', + refreshToken: 'refresh-456', + tokenType: 'Bearer', + allTokens: ALL_TOKENS, + }, + }, + ]); + + const response = await flow().completeRedirect(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, request] = fetchMock.mock.calls[0]; + expect(url).toBe(EXCHANGE_URL); + expect(request.method).toBe('POST'); + expect(JSON.parse(request.body)).toEqual({ appId: APP_ID, code: 'code-1' }); + expect(response).toEqual({ + accessToken: 'access-123', + refreshToken: 'refresh-456', + tokenType: 'Bearer', + allTokens: ALL_TOKENS, + }); + expectFragmentCleared(browser); + }); + + it('should clear the fragment before a refused exchange fails', async () => { + const browser = framedWithFragment( + `#codeMitra=code-1&stateMitra=${STATE}&connectionMitra=sankhya-pass` + ); + mockFetchSequence([ + { body: { message: 'Invalid code', error_code: 'INVALID_EXCHANGE_CODE' }, status: 401 }, + ]); + + await expect(flow().completeRedirect()).rejects.toMatchObject({ status: 401 }); + + expectFragmentCleared(browser); + }); + + it('should require a browser', async () => { + vi.stubGlobal('window', undefined); + + await expect(flow().completeRedirect()).rejects.toThrow('only available in a browser'); + }); +}); diff --git a/src/modules/identity-connection-flow.ts b/src/modules/identity-connection-flow.ts new file mode 100644 index 0000000..814f269 --- /dev/null +++ b/src/modules/identity-connection-flow.ts @@ -0,0 +1,81 @@ +import type { HttpClient } from '../utils/http-client'; +import { expectAuthTokenResponse } from './auth-page-flow'; +import type { AuthTokenResponse } from './auth.types'; + +const STATE_PREFIX = 'connection.'; +/** Same rule IAM applies to a connection slug; anything else would only build a strange path. */ +const SLUG_PATTERN = /^[a-z0-9-]{1,64}$/; + +interface IdentityConnectionFlowConfig { + appId: string; + client: HttpClient; +} + +/** + * Finishes a sign-in made through an identity connection, such as Sankhya Pass. + * + * The host system embeds the platform bridge page in an iframe and hands it its + * own token. The bridge signs in at IAM and sends the iframe to this app's + * origin with a single-use code in the fragment, which is redeemed here for the + * app session. Tokens never travel in the URL or by `postMessage`. + */ +export class IdentityConnectionFlow { + private readonly appId: string; + private readonly client: HttpClient; + + constructor(config: IdentityConnectionFlowConfig) { + this.appId = config.appId; + this.client = config.client; + } + + /** + * Redeems the code the bridge put in the fragment, or returns `null` when the + * fragment belongs to no identity connection. A fragment of another flow is + * left exactly as it was found, so its own completion can still read it. + * + * A fragment of this flow is removed from the URL before anything can fail, + * so a failure is reported once instead of on every reload, and the code is + * never redeemed twice. + */ + async completeRedirect(): Promise { + if (globalThis.window === undefined) { + throw new Error('Identity connection sign-in is only available in a browser.'); + } + const browserWindow = globalThis.window; + const params = new URLSearchParams(browserWindow.location.hash.replace(/^#/, '')); + const slug = params.get('connectionMitra'); + const state = params.get('stateMitra'); + if (slug === null || !state?.startsWith(STATE_PREFIX)) return null; + + const code = params.get('codeMitra'); + const error = params.get('errorMitra'); + browserWindow.history.replaceState( + {}, + '', + `${browserWindow.location.pathname}${browserWindow.location.search}` + ); + + // The bridge only runs inside the host system's iframe, so a code in a + // top-level tab did not come through it. The fragment carries no state this + // browser generated, and redeeming such a code would sign this browser in as + // whoever obtained it. + if (browserWindow.self === browserWindow.top) { + throw new Error('Identity connection sign-in is only accepted inside the host system iframe.'); + } + if (!SLUG_PATTERN.test(slug)) { + throw new Error('Identity connection sign-in redirect names an invalid connection.'); + } + if (error !== null) { + throw new Error(error.trim() || 'Identity connection sign-in failed.'); + } + if (!code?.trim()) { + throw new Error('Identity connection sign-in redirect is missing code.'); + } + + const response = await this.client.post( + `/api/v1/auth/identity-connections/${slug}/exchange`, + { appId: this.appId, code } + ); + return expectAuthTokenResponse(response); + } +} diff --git a/src/test-utils.ts b/src/test-utils.ts index 106c6f4..acbc195 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -100,8 +100,13 @@ export interface BrowserHarness { * * @param injectedAuthPageUrl - Value for `window.__mitraEnv.authPageUrl`. * Omitted leaves `__mitraEnv` out of the window entirely. + * @param options.framed - Whether the window is inside an iframe, so that + * `window.top` is another window. Defaults to a top-level tab. */ -export function mockBrowser(injectedAuthPageUrl?: unknown): BrowserHarness { +export function mockBrowser( + injectedAuthPageUrl?: unknown, + options: { framed?: boolean } = {} +): BrowserHarness { const listeners = new Set<(event: MessageEvent) => void>(); const popup = { closed: false, @@ -141,6 +146,9 @@ export function mockBrowser(injectedAuthPageUrl?: unknown): BrowserHarness { ? { __mitraEnv: { authPageUrl: injectedAuthPageUrl } } : {}), } as unknown as BrowserHarness['window']; + const frames = browserWindow as unknown as { self: Window; top: Window }; + frames.self = browserWindow; + frames.top = options.framed ? ({} as Window) : browserWindow; vi.stubGlobal('window', browserWindow); vi.stubGlobal('crypto', {