Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<User | null> = client.auth.completeIdentityConnectionSignInRedirect()
const passwordUser: Promise<User> = client.auth.signInWithPassword()
void client.auth.signUpWithPassword()
void client.auth.changePassword()
Expand Down Expand Up @@ -262,6 +263,7 @@ void passwordUser
void passwordResetRequested
void passwordLoginEnabled
void formUser
void connectionUser
void passwordSignUpStarted
void resetToken
void resetConfirmed
Expand Down
129 changes: 128 additions & 1 deletion src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): string {
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
Expand Down Expand Up @@ -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<string, string> })._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);
});
});
30 changes: 27 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -403,13 +409,24 @@ export function createClient(config: MitraClientConfig): MitraClient {
legacyBridge.connect();

let initialized = false;
let initFlight: Promise<void> | null = null;
let allowSignup = true;
let emailLoginEnabled = false;
let passwordLoginEnabled = false;

async function init(): Promise<void> {
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<void> {
if (initFlight) return initFlight;
if (initialized) return Promise.resolve();
initFlight = runInit().finally(() => {
initFlight = null;
});
return initFlight;
}

async function runInit(): Promise<void> {
const publicClient = new HttpClient({
baseUrl: codeStudioUrl,
getToken: () => null,
Expand All @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions src/legacy/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
Loading
Loading