AI app sign-in checks
Every check your sign-in page and your server make for AI app sign-in, and every answer they handle.
This page needs your own installation with an Embed license.
To turn it on and write the page, see Let customers sign in AI apps.
Your four duties
Warning All four, every time. Anyone in the account can start a request and send someone the link; these stop it connecting an app to the wrong person or account.
- 1 · Sign the person in first
- With your own sign-in. Ask GoRunner nothing until they have.
- 2 · Check the account
- Take it from the request, never from the link. Turn the request down with
not_memberif the person doesn’t belong to it,roleto allow only editors, ornot_allowedfor a reason of your own. - 3 · Ask them to confirm
- Every time, even when they were signed in already, with Continue and Cancel.
- 4 · Redirect only to
continueUrl - With a
303, and never log it: it holds a one-use ticket.
Also send Content-Security-Policy: frame-ancestors 'none' (or X-Frame-Options: DENY) and Referrer-Policy: no-referrer on the page. For the page, step by step, see Write your sign-in page.
The sign-in address
An https address (plain http only on localhost, 127.0.0.1 or [::1]), up to 2,000 characters, not on the installation’s own host. An account’s own address, which only your server sets, must sit on a website allowed for that account: one of the installation’s allowed websites, or exactly one of the account’s own; another account’s never count. Otherwise the call stores nothing, not even a new account, and answers 422 SIGN_IN_ADDRESS_NOT_ALLOWED, naming the website to allow. To set one, see Give an account its own address.
Any other broken rule answers 400 VALIDATION_ERROR, with details.field aiSignIn.address and one of these messages. Spaces around the address are trimmed first.
"aiSignIn": {}- “Send the address, or null to clear it.”
- Not https, a space inside, a user name or password, or no host
- “Use an https address (http only for localhost).”
- A
#fragment - “This address can’t have a #fragment.”
- Over 2,000 characters
- “The address can be at most 2,000 characters.”
- The installation’s own host, on any port
- “This is your installation’s own address. Use the page in your product where people sign in.”
- An invisible or direction-changing character
- “The address can’t contain invisible or direction-changing characters.”
- A host that ends with a dot
- “A website address can’t end with a dot.”
- A host of numbers that isn’t an IP address, such as
127.1, or a wildcard - “That host name isn’t valid.”
- A port of 0, over 65535, or empty
- “That port isn’t valid.”
;in the query- “The address’s query can’t be read: separate its parameters with & (never ;).”
- A
%that doesn’t start an escape - “The address’s query can’t be read: every % must start an escape like %20 (write a % sign as %25).”
- A query parameter starting with
gr_, in any case - “Leave gr_request out of the address: parameters that start with gr_ are the installation’s own.”
The address is stored in a standard form: the host in lower case, an international name in its xn-- form, and no :443. Your query parameters are kept, but may be re-encoded or reordered when gr_request is added: read them as parameters, not as text.
How do I change or clear an account’s address?
Send another, or "address": null, "" or "aiSignIn": null to clear it. A call without aiSignIn leaves it as it is. On a deleted account the call answers 409 ACCOUNT_DELETED, even to clear it. A new account needs a name in the same call.
Changing it never affects AI apps already connected.
Which address a sign-in uses
Every sign-in checks again, and uses the first that applies:
- The account’s own address, while its website is still allowed for the account.
- The installation’s address.
- None: the person reads “Signing in from AI apps isn’t turned on”, and the embed offers tokens only.
If the website is removed later, the address stays stored but isn’t used; add the website back and it is. Get an account says which is used, in mcp.signInFrom: account, installation, or null when apps can’t sign in. mcp is null while Tokens or MCP is off for the account, or it is deleted.
{
"data": {
"id": "acct_42",
"aiSignIn": {
"address": "https://login.reseller.example/ai-sign-in?brand=north"
},
"mcp": {
"build": "https://automations.example.com/mcp/embed/0b5e…/build",
"tools": "https://automations.example.com/mcp/embed/0b5e…/tools",
"signIn": true,
"signInFrom": "account"
}
}
}The answers your page handles
The request expired, 15 minutes after the app asked, or never existed. Say “This connection request has expired. Go back to your AI app and connect again.”
The account you sent isn’t the request’s (
details.expected). Nothing was spent. Fix your code: send the request’s account.- 409
NOT_A_MEMBERWith
provision: false, the person isn’t a member. Redirect todetails.continueUrl. - 409
ROLE_DIFFERSWith
provision: false, the role you sent isn’t theirs. Redirect todetails.continueUrl. Already completed or turned down. Redirect to
details.continueUrl.- 403
FEATURE_OFFA switch sign-in needs is off for this account (
details.switch). Redirect todetails.continueUrl.
Every refusal after the request was found carries details.continueUrl, which tells the app it wasn’t allowed. GoRunner’s own pages explain the rest to the person: “That took too long”, “Finish in the browser you started in” and “That sign-in link has already been used”.
How long a connection lasts
It is judged on every request, like an embed session.
- A switch pauses it
- Turning off Tokens, MCP, MCP sign-in, or MCP building for an app that builds, for the installation or the account. It works again when the switch is back on, up to 180 days after it connected.
- Losing access ends it
- The person leaves the account or a workspace it reaches, or is deactivated, the account is deleted, or the partner key that signed it in is revoked.
- A lower role ends it
- Including a lower
rolein a later login code or Give a person a role: always send a person’s real role. A higher role changes nothing. - A suspended account
- Makes it read-only until you resume the account.
- A deleted workspace
- Narrows it to the workspaces left.
The person disconnects an app on the embed’s MCP page, even while it is paused. Your server lists and disconnects them with the AI apps endpoints; your staff do it on the customer’s record, or for everyone with Disconnect all AI apps. Before going back to an older release, see Versions and upgrades.
The whole route
A sign-in page for a Node server, which the end-to-end tests run against an installation. Replace signedIn and belongsTo with your own checks.
Show ai-sign-in.mjs, 116 lines
// GET /ai-sign-in?gr_request=… → your AI app sign-in address
//
// When someone connects ChatGPT, Claude or another AI app to their account,
// the installation sends them here. This route signs them in, reads the
// request, checks they belong to its account, asks them to confirm, and sends
// them back. Set its address in Platform › What customers get › Developers ›
// AI app sign-in address.
//
// GORUNNER_URL=https://automations.example.com GORUNNER_EMBED_KEY=gre_… node ai-sign-in.mjs
import { randomBytes } from "node:crypto"
import { createServer } from "node:http"
const GORUNNER_URL = process.env.GORUNNER_URL // the installation's web address
const GORUNNER_EMBED_KEY = process.env.GORUNNER_EMBED_KEY // Platform › Partner keys
const PORT = Number(process.env.PORT || 3000)
// Who is signed in to your app, for this request. Replace this with your own
// session lookup: your id for the person and their role. Answer null when
// nobody is signed in; this route then sends them to your sign-in page first.
function signedIn(req) {
return { user: { id: "ada-123", name: "Ada Lovelace", email: "ada.lovelace@example.com" }, role: "editor" }
}
// Whether that person belongs to the customer account the request is for
// (your id for it). Replace this with your own check.
function belongsTo(login, accountId) {
return accountId === "acct-bakery"
}
const EXPIRED = "This connection request has expired. Go back to your AI app and connect again."
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
async function gorunner(method, path, body) {
const answer = await fetch(`${GORUNNER_URL}/api/embed/v1/oauth-requests/${path}`, {
method,
headers: { Authorization: `Bearer ${GORUNNER_EMBED_KEY}`, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
})
return { status: answer.status, body: await answer.json().catch(() => ({})) }
}
function page(res, status, html, headers = {}) {
res.writeHead(status, {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "frame-ancestors 'none'",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Cache-Control": "no-store",
...headers,
})
res.end(`<!doctype html><meta charset="utf-8"><title>Connect an AI app</title>${html}`)
}
// Only ever to the continueUrl the installation answered, on its own origin.
function goBack(res, answer) {
const to = answer.body.data?.continueUrl ?? answer.body.error?.details?.continueUrl
if (answer.status === 404 && !to) return page(res, 404, `<p>${EXPIRED}</p>`)
if (!to || new URL(to).origin !== new URL(GORUNNER_URL).origin) return page(res, 502, "<p>Something went wrong. Go back to your AI app and try again.</p>")
res.writeHead(303, { Location: to, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" }).end()
}
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost")
if (url.pathname !== "/ai-sign-in") return page(res, 404, "<p>Not found</p>")
const login = signedIn(req)
if (!login) return res.writeHead(303, { Location: `/sign-in?next=${encodeURIComponent(req.url)}` }).end()
const cookie = /(?:^|;\s*)ai_csrf=([\w-]+)/.exec(req.headers.cookie || "")?.[1]
try {
if (req.method === "GET") {
// 1. Signed in. 2. Now read the request: its account comes from the installation, never from this link.
const id = url.searchParams.get("gr_request") || ""
const request = await gorunner("GET", encodeURIComponent(id))
if (request.status !== 200) return goBack(res, request)
const { account, client } = request.body.data
// 3. The person must belong to that account.
if (!belongsTo(login, account.id)) return goBack(res, await gorunner("POST", `${encodeURIComponent(id)}/deny`, { reason: "not_member" }))
// 4. Ask, every time, even when they were already signed in.
const csrf = cookie || randomBytes(32).toString("base64url")
return page(
res,
200,
`<h1>Connect ${esc(client.name)} (${esc(client.redirectHost)}) to ${esc(account.name)}?</h1>
<p>You’ll choose what it may do on the next page.</p>
<form method="post"><input type="hidden" name="request" value="${esc(id)}"><input type="hidden" name="csrf" value="${csrf}">
<button name="answer" value="cancel">Cancel</button> <button name="answer" value="continue">Continue</button></form>`,
{ "Set-Cookie": `ai_csrf=${csrf}; Path=/ai-sign-in; HttpOnly; SameSite=Strict` },
)
}
if (req.method === "POST") {
let raw = ""
for await (const chunk of req) raw += chunk
const form = new URLSearchParams(raw)
// Only a form this route drew: the same origin, and the CSRF value it set.
// (Under Referrer-Policy: no-referrer the browser sends Origin: null; Sec-Fetch-Site then decides.)
const origin = req.headers.origin !== "null" && req.headers.origin
const sameOrigin = origin ? origin.replace(/^https?:\/\//, "") === req.headers.host : req.headers["sec-fetch-site"] === "same-origin"
if (!sameOrigin || !cookie || form.get("csrf") !== cookie) return page(res, 403, "<p>Forbidden</p>")
const id = encodeURIComponent(form.get("request") || "")
// Read the request again: the account is the installation's, not the form's.
const request = await gorunner("GET", id)
if (request.status !== 200) return goBack(res, request)
const { account } = request.body.data
if (form.get("answer") !== "continue" || !belongsTo(login, account.id)) {
return goBack(res, await gorunner("POST", `${id}/deny`, { reason: form.get("answer") === "continue" ? "not_member" : "cancelled" }))
}
// 5. Continue: the person, their role, and the account from step 2.
return goBack(res, await gorunner("POST", `${id}/complete`, { user: login.user, account, role: login.role, provision: false }))
}
page(res, 405, "<p>Method not allowed</p>")
} catch (e) {
console.error("AI app sign-in failed:", e.message) // never log a continueUrl: it carries a one-use ticket
page(res, 502, "<p>Something went wrong. Go back to your AI app and try again.</p>")
}
}).listen(PORT)complete takes a login code’s fields, plus provision. With true, the person, account, workspace and membership are made as a login code makes them, and a membership it makes stays even if the browser never returns.