GoRunner docs
Search or askSearch⌘K

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.

Embed · Self-hosted with an Embed license

Since v2026.10.23

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_member if the person doesn’t belong to it, role to allow only editors, or not_allowed for 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:

  1. The account’s own address, while its website is still allowed for the account.
  2. The installation’s address.
  3. 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.

json
{
  "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.

  • With provision: false, the person isn’t a member. Redirect to details.continueUrl.

  • With provision: false, the role you sent isn’t theirs. Redirect to details.continueUrl.

  • Already completed or turned down. Redirect to details.continueUrl.

  • A switch sign-in needs is off for this account (details.switch). Redirect to details.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 role in 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
ai-sign-in.mjs
// 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.