Skip to content

feat(client): redeem a platform embed token at createClient - #282

Open
amitbardos wants to merge 1 commit into
mainfrom
feat/embed-ott-exchange
Open

amitbardos wants to merge 1 commit into
mainfrom
feat/embed-ott-exchange

Conversation

@amitbardos

@amitbardos amitbardos commented Sep 14, 2026

Copy link
Copy Markdown

Apps embedded by a host platform arrive with a one-time token on the iframe URL, ?ott=. createClient takes it off the URL, trades it for an app-user session through the RFC 8693 token-exchange grant (POST /api/apps/{appId}/auth/embed/token), and keeps the result in memory only — the session lives and dies with the frame.

In the SDK rather than the app template so it reaches every existing app: the platform bumps sdk_min_version and the codemod pulls it in on the next boot.

What changes

  • createClient strips ?ott= and exchanges it. Everything that can wait for the session does — axios, functions.fetch, fetchWithAuth, actors. Nothing is persisted, and an embedded load never adopts a stored token: whatever an earlier visitor left is not this session.
  • One live getToken() replaces seven token || getAccessToken() closures (socket, agents, actors, aiGateway, functions). They captured a construction-time token or fell back to storage, so a memory-only session reached none of them. The auth module now owns the token and reports changes through onSessionChange — which is how the socket redials, and how logout drops a connection still running as the user who just left.
  • base44.auth.isEmbedded() (public). In a frame, redirectToLogin() and logout() show a "session ended" notice instead of navigating: only the platform can mint the next session, and the app's own login would mint a different identity.
  • getAccessToken() returns the ?ott= on an embedded load, so an app's existing if (appParams.token) gate still fires and it doesn't render signed-out while authenticated. This is the piece I'd most like a second opinion on — see the inline comment.

Compatibility

An app opened normally runs the code it ran before, storage fallback included. AgentsModuleConfig / AiGatewayModuleConfig (both @internal) take getToken; createClient is their only caller.

Testing

npm test (355 unit + type tests) and eslint pass on Node 26. New coverage: the URL strip, the frame-only report, the RFC 8693 request shape, refused / thrown / 429-retried / timed-out exchanges, and through createClient — a request issued mid-exchange carrying the exchanged bearer, nothing written to storage, a stale stored token ignored, and a normal app untouched.

Server side ships in base44-dev/apper#24167. Supersedes base44-dev/apper#24259.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/sdk@0.8.48-pr.282.094dd5b

Prefer not to change any import paths? Install using npm alias so your code still imports @base44/sdk:

npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.48-pr.282.094dd5b"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "@base44/sdk": "npm:@base44-preview/sdk@0.8.48-pr.282.094dd5b"
  }
}

Preview published to npm registry — try new features instantly!

@github-actions github-actions Bot added the docs-draft PR has auto-drafted documentation suggestions label Sep 14, 2026
@amitbardos
amitbardos force-pushed the feat/embed-ott-exchange branch from c505512 to ca4dff7 Compare September 14, 2026 09:10
Comment thread src/utils/auth-utils.ts
// one-time token is redeemed nowhere else, so a top-level load still
// carrying one (a URL rewrite the browser refused) must not have it
// applied as a session — and never saved as one.
if (paramName === DEFAULT_TOKEN_PARAM && isFramed()) {

@amitbardos amitbardos Sep 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer note — the one decision here I'd like a second opinion on.

This returns the ?ott= so an existing app's auth gate still fires:

// templates/apps_template/src/lib/AuthContext.jsx
if (appParams.token) { await checkUserAuth(); }

app-params.js calls getAccessToken() before createClient exists, so there is nothing else to ask at that moment. Return null and the app renders signed-out while every request it makes is authenticated.

The trade-off. It buys out a template change, and pays for it by handing app code a value that looks like a session token and isn't — createClient then has to defend against getting its own utility's output back (const token = embedOtt ? undefined : config.token). Two coupled changes in two files, and a reader of either one can't tell why.

The alternative is one line in that same file, which already imports the client:

if (appParams.token || base44.auth.isEmbedded()) {

Cleaner — but it only reaches apps the codemod can patch, not ones that just get the sdk_min_version bump.

@netanelgilad — can the codemod edit template files, or only bump the version? That decides which way this goes. Separately, worth confirming in base44-dev/apper#24167 that the OTT is rejected as a bearer everywhere except the exchange, so an app that naively sends it gets a 401 rather than a compromise.

@amitbardos
amitbardos marked this pull request as ready for review September 15, 2026 14:43
Apps embedded by a host platform arrive with a one-time token on the
iframe URL, `?ott=`, minted by the platform's server. `createClient` now
takes it off the URL as the client is created, trades it for an app-user
session through the OAuth token-exchange grant (RFC 8693,
`POST /api/apps/{appId}/auth/embed/token`), and seeds every module with
the result — in memory only. The session lives and dies with the frame.

Doing this in the SDK rather than in the app template is what makes it
reach every existing app: the platform bumps `sdk_min_version` and its
sandbox codemod pulls the new version in on each app's next boot.

The exchange

- The token is stripped from the URL before any other code can read
  `location.href`, and reported back only inside a frame — the only place
  one is redeemed. A top-level load still has it taken off the address
  bar, history and any shared link.
- `authReady` gates everything that can wait: an axios request
  interceptor on the two user clients, `functions.fetch`,
  `fetchWithAuth`, and the actors mint and dial. It never rejects, so one
  failed exchange does not become a rejection on every request. The POST
  aborts after 15s rather than leaving every request parked behind it.
- A 429 is retried once: the server's limiter refuses before the token is
  redeemed, so it is still valid.
- An embedded load never reads a stored token. Whatever an earlier
  visitor left in `localStorage` is not this session.

One live token

`getToken()` replaces the seven `token || getAccessToken()` closures
(actors auth and mint, `functions.fetch`, agents URLs, aiGateway,
socket). Each had captured a construction-time token and could otherwise
only fall back to storage — so a session held in memory reached none of
them. The auth module now owns the current token and reports every
change through `onSessionChange`, which is how the realtime socket learns
to redial, and how `logout` drops a connection still running as the user
who just left.

- `base44.auth.isEmbedded()` — public: true when a platform embedded this
  load. The session is memory-only, so a reload inside the frame ends it
  and this returns false there.
- `redirectToLogin()` and `logout()` in a frame show a "session ended"
  notice instead of navigating. Only the platform can mint the next
  session; the app's own login would work in the frame but would mint a
  different, app-level identity.
- `logout()` now clears `Authorization` on both axios instances. Missing
  the functions one was masked by the redirect tearing the page down.
- The socket takes `getToken` and asks on every connect, replacing a
  static token it had to be handed.
- `getAccessToken()` returns the `?ott=` value on an embedded load — not
  stored, not stripped. Every app's `app-params.js` calls it once before
  `createClient` runs, and `AuthContext` gates its auth check on that
  snapshot, so without it an embedded app renders signed out while every
  request it makes is authenticated.

Backward compatibility

An app opened normally is unaffected: with no `?ott=`, `createClient`
runs the code it ran before, and `getToken()` still falls back to storage
so a login in another tab is picked up on reconnect. `AgentsModuleConfig`
and `AiGatewayModuleConfig` (both `@internal`) take `getToken`;
`createClient` is their only caller.

Tests

`tests/unit/embed-session.test.ts` and `tests/unit/client-embed.test.ts`
cover the URL strip, the frame-only report, the RFC 8693 request shape,
refused / thrown / 429-retried / timed-out exchanges, the notice overlay,
and through `createClient`: a request issued mid-exchange carrying the
exchanged bearer, nothing written to storage, a stale stored token
ignored, the socket connecting with the exchanged session, and a normal
app left untouched. `npm test` and `eslint` pass on Node 26.

Server side ships in base44-dev/apper#24167; this supersedes
base44-dev/apper#24259.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-draft PR has auto-drafted documentation suggestions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant