Sign in to other services
MarkdownAn application can act on a person’s account on another service, once the person has signed in there through it.
Ask a person to set up or reconnect
Section titled “Ask a person to set up or reconnect”When an action cannot work until the person sets your application up, or reconnects an account that stopped working, a refused refresh token for instance, it says so with one response header:
return new Response(JSON.stringify({ error: 'Gmail is disconnected' }), { status: 409, headers: { 'Content-Type': 'application/json', 'X-Apigami-Settings': 'reconnect' },})An application that asks the person to set it up or reconnect needs a place where that happens: its home
or settings page, or, for a brick, the home of each application that calls it and its own settings page,
for its trials (Expose your settings). setup or reconnect records a need for this person and your application, whichever application
called yours: they see the same need under every application that uses yours, and repairing it once
clears it everywhere. ok clears it. Answer ok from the action that checks, the one your page calls
once the person has signed in again, or any action that just used the account. Apigami writes
only when the state changes, so ok on every successful answer costs nothing. Any other value is ignored.
Only your own answer counts, and only for the person the run serves: a run that serves nobody, such
as a trigger for the whole application, signals nothing. The header never reaches your caller, and
neither does any other X-Apigami-* header your code sets: those are Apigami speaking.
Under a run token, GET /v1/me lists settings_needed, the open needs of the called application and of
those it reaches, each with its app_id, name, kind and since. The link to give the person is always
the connect link of the application they connected to, /connect?app_id={that app_id}, whose home shows
what to set up: never one of your own pages, nor the link of a brick it calls. An assistant running your action with
app_run is told the same link.
A page that loads apigami.js shows it for you: when its application or one it calls needs the person, a
banner in Apigami’s words names it and links to that connect link, in a new tab from the side panel. It is
refreshed each time the person comes back to the tab and after an action fails, and closing it hides it
until the page reloads. A home or settings page shows no banner, since it is where the need gets fixed;
apigami.state.settings_needed lists every open need.
Checking that the account that comes back is the same one is your job: compare the account the service
names with the one you stored for this user_ref before you answer ok. A run serving a user_ref may
come from the root’s server, with its key, while the person is away: never take it as proof they are there.
Sign in to a third-party service from your app: OAuth, PKCE, callback
Section titled “Sign in to a third-party service from your app: OAuth, PKCE, callback”Look for the service’s OAuth before asking anyone for a key: /.well-known/oauth-authorization-server on its API,
or /.well-known/oauth-protected-resource on its MCP server.
When your actions need the person’s account on another service, an OAuth provider or an MCP server,
your server signs them in and your actions keep the tokens, on user_ref. At your app’s own address every
route knows the person (Who is behind a request), so it is the
classic web flow:
- A link or a button leads to your
GET /oauth/start, in the same tab, or in a windowapigami.open()opened from the page of an application that calls yours. /oauth/startreads the person inx-apigami-user-ref, keeps astateand a PKCE verifier in a__Host-cookie, and sends the browser to the service withprompt=consent, or the service’s equivalent, so that connecting always shows the service’s own screen.- The service sends the browser back to your
GET /oauth/callback, which reads the person again, checks thestateagainst the cookie, exchanges the code with the verifier and stores the tokens on thatuser_ref.
The cookie proves this browser started the sign-in, and the header names the person: a forwarded
/oauth/start link serves whoever opens it, under their own session. Never trust a person the URL names,
a user_ref in the query for instance: anyone can write that URL.
// GET /oauth/start, /oauth/callback and /oauth/client.json, routed in main.ts beside /healthz.const STATE_COOKIE = '__Host-oauth'const base64url = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')const cookieOf = (request: Request, name: string) => request.headers.get('Cookie')?.split(/;\s*/).find((pair) => pair.startsWith(`${name}=`))?.slice(name.length + 1) ?? null
export const startSignIn = async (request: Request, env) => { const userRef = request.headers.get('x-apigami-user-ref') if (!userRef) return page('Open this from the application, once connected.', 401) // `https://acme-basket.apigami.tech`, or its `--staging` host: a browser asked, so this is the public address. const base = new URL(request.url).origin const state = base64url(crypto.getRandomValues(new Uint8Array(16))) const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))) const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))) const location = `${AUTHORIZE_URL}?${new URLSearchParams({ // A registered client's id, else the URL of your client document (below). response_type: 'code', client_id: env.CLIENT_ID ?? `${base}/oauth/client.json`, redirect_uri: `${base}/oauth/callback`, code_challenge: challenge, code_challenge_method: 'S256', state, scope: 'read', prompt: 'consent', })}` // `__Host-`: no other application under apigami.tech can set it. `Lax`: it comes back with the service's redirect. return new Response(null, { status: 303, headers: { location, 'set-cookie': `${STATE_COOKIE}=${state}.${verifier}; Path=/; Max-Age=600; Secure; HttpOnly; SameSite=Lax`, } })}
export const finishSignIn = async (request: Request, env) => { const userRef = request.headers.get('x-apigami-user-ref') const params = new URL(request.url).searchParams const [state, verifier] = (cookieOf(request, STATE_COOKIE) ?? '').split('.') if (!userRef || !state || params.get('state') !== state || !params.get('code')) { return page('The sign-in expired or was refused: start again from the application.', 400) } const redirectUri = `${new URL(request.url).origin}/oauth/callback` // POST TOKEN_URL: grant_type=authorization_code, code, redirectUri, client_id, code_verifier=verifier, // and env.CLIENT_SECRET for a registered client. Then store the tokens in oauth_tokens, on userRef. return page('Connected. You can close this window.', 200)}
// The end of a sign-in. Connected, a window apigami.open() opened tells its opener, which reads its settings again,// and closes: the message carries nothing, so any opener may receive it.const page = (message: string, status: number) => new Response(`<!doctype html><p>${message}</p><p><a href="/">Back</a></p>${status === 200 ? '<script>if (opener) { opener.postMessage({ type: \'apigami:settings-changed\' }, \'*\'); close() }</script>' : ''}`, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'set-cookie': `${STATE_COOKIE}=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax` },})import { personOf } from './basket.ts'
// What a page asks, `ok` once the tokens are there. An action reads the person from its run token,// never from x-apigami-user-ref.export const settingsStatus = async (request: Request, env) => { const userRef = await personOf(env, request.headers.get('X-Apigami-Run') ?? '') if (!userRef) return Response.json({ error: 'this action serves one person' }, { status: 401 }) const tokens = await env.DB.prepare('select account from oauth_tokens where user_ref = ?1') .bind(userRef).first<{ account: string }>() const state = tokens ? 'ok' : 'setup' return Response.json({ state, account: tokens?.account ?? null }, { headers: { 'X-Apigami-Settings': state } })}env.DB and personOf are those of Its database.
A service that sends the code back by a form, response_mode=form_post, posts from its own site:
neither the session nor a SameSite=Lax cookie comes with that POST. Answer it with a 303 to a GET of
the same path carrying its fields, a top-level navigation, which carries both:
if (request.method === 'POST' && pathname === '/oauth/callback') { const fields = new URLSearchParams(await request.text()) return new Response(null, { status: 303, headers: { location: `/oauth/callback?${fields}` } })}The client. Register one with the service wherever it offers it, confidential: give it both
redirect URIs, https://{slug}.apigami.tech/oauth/callback and
https://{slug}--staging.apigami.tech/oauth/callback, and keep its secret in x-apigami.env with
"secret": true. A code caught on its way back is then useless to anyone without the secret. Both
addresses are in the address of any version you write, before your first deploy. A
domain of your own needs a third, https://app.example.com/oauth/callback,
registered before the domain goes active: from then on production’s pages open there, and so does
/oauth/start.
A public client works too, where the service accepts nothing else, such as an MCP authorization
server that takes a Client ID Metadata Document: the URL of a JSON document you serve as client_id,
which it fetches. The code comes back to your own host, which no other application serves. Its
client_id field is that same URL, built from the request’s own:
if (request.method === 'GET' && pathname === '/oauth/client.json') { const base = new URL(request.url).origin return Response.json({ client_id: `${base}/oauth/client.json`, client_name: 'My app', redirect_uris: [`${base}/oauth/callback`], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', })}A service may require the redirect_uri on the same origin as the client_id: one client.json per
origin, each naming its own callback, which building both from the request’s origin gives you.
Production and --staging are then two clients, and so is a domain of your own: store the client_id
a sign-in used with its tokens, since a refresh sends the same one.
The browser and the service reach /oauth/start, /oauth/client.json and /oauth/callback at
--staging as in production: you can sign in there before you publish. On a page of your own site, which
has no session, open the service’s sign-in in a popup on a click and send its code to one of your actions
with apigami.call() instead.
Keep the tokens fresh. Before each use, an action refreshes an access token about to expire with
its refresh_token, and stores what comes back. When the refresh is refused, the person has to sign in
again: answer 409 { "needs": "reconnect" } with X-Apigami-Settings: reconnect
(Ask a person to set up or reconnect); settingsStatus answers
ok once the callback has stored new ones.