# 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.

To turn it on and write the page, see [Let customers sign in AI apps](https://docs.gorunner.io/embed/ai-app-sign-in).

## 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](https://docs.gorunner.io/embed/api/ai-apps/get-oauth-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](https://docs.gorunner.io/embed/ai-app-sign-in#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`](https://docs.gorunner.io/errors#SIGN_IN_ADDRESS_NOT_ALLOWED), naming the website to allow. To set one, see [Give an account its own address](https://docs.gorunner.io/embed/ai-app-sign-in#per-account-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](https://docs.gorunner.io/embed/api/accounts/get-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

- 404 `OAUTH_REQUEST_NOT_FOUND`

  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.”

- 409 `OAUTH_REQUEST_ACCOUNT_MISMATCH`

  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_MEMBER`

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

- 409 `ROLE_DIFFERS`

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

- 409 `OAUTH_REQUEST_DONE`

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

- 403 `FEATURE_OFF`

  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](https://docs.gorunner.io/embed/api#ai-apps); 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](https://docs.gorunner.io/embed/versions#going-back).

## 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](https://docs.gorunner.io/embed/api/sign-in/issue-login-code) 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.

Related: [Let customers sign in AI apps](https://docs.gorunner.io/embed/ai-app-sign-in) · [If a partner key leaks](https://docs.gorunner.io/embed/security#if-a-key-leaks) · [Tokens and AI apps](https://docs.gorunner.io/embed/tokens-and-ai-apps)

Source: https://docs.gorunner.io/embed/ai-app-sign-in-checks
