Skip to content

Sign in to other services

Markdown

An application can act on a person’s account on another service, once the person has signed in there through it.

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:

  1. A link or a button leads to your GET /oauth/start, in the same tab, or in a window apigami.open() opened from the page of an application that calls yours.
  2. /oauth/start reads the person in x-apigami-user-ref, keeps a state and a PKCE verifier in a __Host- cookie, and sends the browser to the service with prompt=consent, or the service’s equivalent, so that connecting always shows the service’s own screen.
  3. The service sends the browser back to your GET /oauth/callback, which reads the person again, checks the state against the cookie, exchanges the code with the verifier and stores the tokens on that user_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.

oauth.ts
// 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` },
})
actions/settings.ts
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.