Skip to content

Latest commit

 

History

History
352 lines (280 loc) · 18.2 KB

auth.md

File metadata and controls

352 lines (280 loc) · 18.2 KB

Auth & tokens

← README

Each service authenticates the way the real one does, and you construct none of it: the corpus generates every credential, and two of Backlot's own endpoints hand them out.

Every token on this page is what backlot import --bundled produces. They are derived, not random — hashed from the corpus's own email addresses — so these exact values are the ones on your machine too, and the commands below are copy-pasteable against backlot serve.

Where credentials come from

curl -s localhost:8000/_meta/users
{ "org": "acme", "admin_token": "admin-service-token",
  "admin_s3_access_key_id": "AKIA732S…", "admin_s3_secret_access_key": "l4sz5sXT…",
  "users": [{ "email": "ava.chen@acme.com", "name": "Ava Chen",
              "token": "usr-29b84da5703116c2a832",
              "s3_access_key_id": "AKIADNLO…", "s3_secret_access_key": "0FdhlUUQ…",
              "groups": ["handbook", "design", "product", "engineering"] }] }
curl -s localhost:8000/_meta/credentials   # for a Google client that wants a config, not a token
{ "org": "acme", "token_uri": "http://localhost:8000/oauth2/token",
  "oauth_client": { "client_id": "e8ae7a3acfbfffb01ddab8df6961c15d.apps.googleusercontent.com",
                    "client_secret": "GOCSPX-…" },
  "service_account": { "type": "service_account", "client_email": "…", "private_key": "…" } }

Both serve working credentials in the clear, and always answer: backlot mcp --user <email> resolves a person to their credentials through /_meta/users, so a switch that closed it would take that command's only credential input with it. The same values are in data/tokens.yaml.

The three identities used below

Identity Token Sees
admin / service admin-service-token Everything — ACL filtering is bypassed. Use it to crawl
ava.chen@acme.com usr-29b84da5703116c2a832 handbook, design, product, engineering
dana.whitfield@acme.com usr-94ef7da374a581b973c0 sales

The token is what authenticates. Atlassian's Basic is the one scheme that also checks the username, because the real service does: it has to be the token's own address (below). Send a user's token and every API filters to that user, which is what makes per-user access a test rather than an audit. Send the same request three times, changing only the token, and count what comes back:

for t in admin-service-token usr-29b84da5703116c2a832 usr-94ef7da374a581b973c0; do
  curl -s localhost:8000/drive/v3/files -H "Authorization: Bearer $t" | jq '.files | length'
done

The admin sees the whole corpus, ava sees what her four groups reach, and dana — in sales alone — sees least. Nothing about the request changes but the identity.

What each service expects

Service Header
Slack, Microsoft Teams, Notion, HubSpot, Fireflies Authorization: Bearer <token>
Gmail, Google Drive/Docs/Sheets/Slides Authorization: Bearer <token>, or OAuth <token>
GitHub Authorization: Bearer <token>, or the legacy token <token>
Jira, Confluence HTTP Basic with the token as the password, or a plain Bearer
Linear A bare Authorization: <token>; Bearer <token> is a 400
Amazon S3 AWS SigV4, SigV4a or Signature Version 2, signed with the identity's key pair
A Google client with a config Exchange it at POST /oauth2/token — see the Google section

Notion, HubSpot, Linear and Fireflies sit behind Cloudflare, which answers a request carrying Authorization twice, or one of If-None-Match, If-Match, If-Modified-Since, If-Unmodified-Since or If-Range twice, with a 400 HTML page of its own, there and here.

Slack — Bearer

curl -s localhost:8000/slack/api/auth.test \
  -H "Authorization: Bearer usr-29b84da5703116c2a832"

The scheme is required: a bare token answers not_authed. As with the real Web API, an auth failure is HTTP 200 with ok: false — not_authed when the header is missing or unscheme'd, invalid_auth when the token resolves to nobody. The token is also accepted as a token form field, which is where Slack's own clients put it and which real reads ahead of the header. A token query parameter is refused, since Slack stopped taking one from apps created after February 24, 2021, the kind of app Backlot answers as: invalid_auth for a non-empty value, and for an empty one beside a token in the header or the form. A token form field, even an empty one, takes the parameter's place, so beside one the parameter is refused only when a token is read from the header or the form.

It is a user token, not a bot token. Slack has two kinds and they are not interchangeable; a Backlot token behaves as the first:

Real Slack Backlot
User token xoxp-… Acts as a person — sees every channel that person is in What a usr-… token is
Bot token xoxb-… Acts as the app — sees only channels the bot was invited to, and carries a bot_id Not modelled

Two consequences worth knowing before you trust a passing test here:

  • search.messages, search.all and search.files are user-token-only in real Slack. A bot token calling them gets not_allowed_token_type. Backlot serves all three, so search working here says nothing about whether it will work for a connector that authenticates as a bot in production.
  • There is no "invite the bot to the channel" step. Backlot scopes by per-document readers — the user model — so a caller reaches every channel their ACL allows. A bot in production starts with access to nothing until it is invited, which is a failure mode Backlot cannot reproduce.
  • A write needs no scope. Slack gates posting and reacting on chat:write and reactions:write, and refuses a token without them before it reads an argument. A Backlot token writes wherever its ACL lets it read (docs/write-overlay.md).

So if the connector under test uses a bot token in production, Backlot is the more permissive of the two on both counts. auth.test reflects the user shape it emulates: user, user_id, team, team_id, and no bot_id.

Gmail, Google Drive, Docs, Sheets, Slides — Bearer

curl -s "localhost:8000/gmail/v1/users/me/profile" \
  -H "Authorization: Bearer usr-29b84da5703116c2a832"

curl -s "localhost:8000/drive/v3/files?fields=files(id,name)" \
  -H "Authorization: Bearer usr-29b84da5703116c2a832"

How the header is read, spelling by spelling, and where a token in the query string is read instead, is in supported-sources.md; send the token in the header. me works as the mailbox id, in any case, resolving to whoever the token is; a user's token reads no other mailbox, and the admin token reads any.

A client carrying a config, not a token — POST /oauth2/token

For a connector that configures with an OAuth client or a service account rather than a raw token, token_uri from /_meta/credentials points back at Backlot, so the client's own refresh lands here and resolves to the same ACL:

curl -s localhost:8000/oauth2/token \
  -d grant_type=refresh_token \
  -d refresh_token=usr-29b84da5703116c2a832 \
  -d client_id=e8ae7a3acfbfffb01ddab8df6961c15d.apps.googleusercontent.com \
  -d client_secret=GOCSPX-…

A user's refresh token is their bearer token, so the exchange hands the same value straight back as the access_token. Expiry is cosmetic — a re-refresh returns the same token, so a long crawl never breaks. A signed service-account assertion works too, with its sub claim selecting the impersonated user; a bare service account with no sub resolves to the admin identity. See supported-sources.md.

GitHub — Bearer or token

curl -s localhost:8000/github/user/repos \
  -H "Authorization: Bearer usr-29b84da5703116c2a832"

Both schemes are accepted, as GitHub accepts both. user/repos is the token's own reach, so it is the quickest check that an identity resolves. rate_limit is the one route that answers with no credential at all, at the anonymous limits, as real serves it; a bearer that does not resolve is still the 401 there.

Jira and Confluence — Basic, or Bearer

Atlassian clients send HTTP Basic with an API token as the password, so that is what Backlot takes — and the username has to be that token's own address, which is what the real service matches on. Measured against a real Atlassian Cloud site (GET /rest/api/3/myself) with a user API token: email:token answers 200, while an empty password, a wrong password, and a valid token under someone else's address all answer 401.

curl -s -u "ava.chen@acme.com:usr-29b84da5703116c2a832" \
  localhost:8000/atlassian/rest/api/3/project/search

curl -s -u "ava.chen@acme.com:usr-29b84da5703116c2a832" \
  localhost:8000/atlassian/wiki/rest/api/space

The admin/service token is the exception, and the only one: it resolves to no address, so there is nothing to match and any username goes through. That is what lets an Atlassian client send the placeholder its config demands — svc@example.com:admin-service-token works.

A plain Authorization: Bearer <token> is accepted too, which is easier when you are driving the API by hand rather than through an Atlassian SDK — spelt exactly that way. The real site reads the scheme case-sensitively and wants exactly one space after it, so bearer <token>, BEARER <token>, token <token> and Bearer <token> are not credentials to it and Backlot does not take them either.

A Bearer it does read and cannot resolve is the one refusal Jira gives here, and it comes before the route: 403 {"error": "Failed to parse Connect Session Auth Token"}, on every Jira path including serverInfo. A <site>.atlassian.net reads a bearer as a Connect session JWT, and a Backlot token is an opaque string, so that is the answer the real site gives one.

The two APIs disagree about a credential that resolves to nobody, and Backlot follows each. Jira does not refuse it: the read is served to an anonymous caller, and since a corpus grants its documents to the org and to groups and addresses inside it, an anonymous caller reaches none of them — project/search answers 200 with an empty values, a search answers 200 with no issues, and an issue answers Jira's 404. The failure is reported in the X-Seraph-LoginReason: AUTHENTICATED_FAILED header rather than in the status, so a Basic credential is checked on Jira by reading that header — not by the status, and not by whether anything came back, since an empty listing is also the honest answer for a working credential over a corpus it can read nothing in. A misspelt bearer scheme has neither signal: it is not a credential to the site, so the answer is the anonymous one with no header at all. Check a bearer against Confluence, or against a Jira read you know the account can see. Confluence does refuse it, with a 403 in its own envelope, or a 401 when the Basic value is not one username and one password (an empty password included). Measured against a public Atlassian Cloud site and a private one on 2026-09-04, with a wrong password, an empty one, a value that is not base64, an unknown scheme, and no header at all.

Microsoft Teams — Bearer

curl -s "localhost:8000/msgraph/v1.0/me/joinedTeams" \
  -H "Authorization: Bearer usr-29b84da5703116c2a832"

Graph reads the token after Bearer in any case, or with no scheme at all, with any run of spaces or tabs between; Backlot reads it the same way. Measured against graph.microsoft.com in October 2026, every refusal is a 401 with error.code InvalidAuthenticationToken, the same WWW-Authenticate: Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000" header, and an innerError of date, request-id and client-request-id:

What was sent error.message
No Authorization header Access token is empty.
Authorization: Bearer with nothing after it UnableToParseTokens
A JWT (three base64url segments) that names no application NoAppIdInToken
A token that resolves to nobody, or any token under another scheme (token …, Basic …) or with a word after it Protocol 'Bearer' failed to validate because The token could not be read.

Two Authorization headers are a 400 HTML page from the front Graph sits behind, before either is read. The credential is read before the path and the method: an unknown path, or POST/DELETE on /me, with no valid token is the same 401. error.code is the stable contract; human-readable messages can change.

Notion — Bearer

curl -s localhost:8000/notion/v1/users/me \
  -H "Authorization: Bearer usr-29b84da5703116c2a832" \
  -H "Notion-Version: 2025-09-03"

Notion-Version is required, on every route, as real Notion requires it on every REST request: a request without one is answered 400 missing_version, after the 401 a request without a usable token gets, and so is one naming a version Notion does not publish — the seven are 2021-05-11, 2021-05-13, 2021-08-16, 2022-02-22, 2022-06-28, 2025-09-03 and 2026-03-11. The value also picks the query path — POST data_sources/{id}/query is served under 2025-09-03 and later, POST databases/{id}/query under the versions before it, and each refuses the other's versions with invalid_request_url — and the shape of a database, which carries a data_sources array from 2025-09-03 and its properties schema inline before it.

HubSpot — Bearer

curl -s "localhost:8000/hubspot/crm/v3/objects/contacts" \
  -H "Authorization: Bearer usr-29b84da5703116c2a832"

Spelt exactly that way. The real API reads the scheme case-sensitively and wants one space after it, so bearer <token>, BEARER <token>, token <token> and Bearer <token> are refused with a 401 of category INVALID_AUTHENTICATION, there and here. Space or tab around the whole header value is ignored by both.

Linear — bare

Linear's personal API keys go in the header with no scheme, and a Backlot token is one:

curl -s localhost:8000/linear/graphql \
  -H "Authorization: usr-29b84da5703116c2a832" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ viewer { id name } }"}'

Bearer is how Linear carries an OAuth access token. An API key sent as Bearer <token> or bearer <token> is refused with a 400 that says to drop the prefix, there and here, before the body is read. Linear tells an API key there by its shape, lin_api_ and forty ASCII letters and digits, so a value in that shape draws the 400 whether or not Linear issued it; Backlot gives the same 400 to its own tokens, which stand for API keys. Any other value is the key itself, so Token <token> or BEARER <token> is a key that does not resolve: a 401. Space or tab around the whole header value is ignored by both.

Fireflies — Bearer

curl -s localhost:8000/fireflies/graphql \
  -H "Authorization: Bearer usr-29b84da5703116c2a832" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ users { name } }"}'

The scheme is read in any case and followed by spaces, and space, tab or U+00A0 around the token is ignored. A credential that is refused is a 500 with an auth_failed entry, there and here, in one of two messages: the Bearer prefix is missing, or the key did not authenticate. No Authorization, or an empty one, is the second.

Amazon S3 — SigV4

S3 does not take a bearer token. Each identity carries an s3_access_key_id / s3_secret_access_key pair, derived from that identity's token — which is what Backlot's verifier resolves back to a user — so hand them to any AWS client:

import boto3, httpx
from botocore.config import Config

ava = next(u for u in httpx.get("http://localhost:8000/_meta/users").json()["users"]
           if u["email"] == "ava.chen@acme.com")

s3 = boto3.client(
    "s3",
    endpoint_url="http://localhost:8000/s3",
    aws_access_key_id=ava["s3_access_key_id"],
    aws_secret_access_key=ava["s3_secret_access_key"],
    region_name="us-east-1",                       # the region Backlot presents; others are refused
    config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
print([b["Name"] for b in s3.list_buckets()["Buckets"]])

An unsigned request is an anonymous caller's, as on real S3, and reads nothing; what it and a refused credential get is in supported-sources.md.

Signature Version 2 is verified too, in the header and in the query, since real S3 still verifies it; botocore signs that way with Config(signature_version="s3"), though its signature for a bucket's own operations is the mismatch real answers it with. The two refuse differently and each refuses as real does. Reading an object's ?annotation takes SigV4 or SigV4a, as on real (supported-sources.md).

So is SigV4a (AWS4-ECDSA-P256-SHA256), in the header and in the query, with the key botocore's CRT signer derives from the same secret: install awscrt and pass Config(signature_version="s3v4a"). The region set it signs has to name us-east-1.

Path addressing is not optional. Backlot serves /s3/{bucket}/{key}, so virtual-hosted addressing — which puts the bucket in the host, acme-artifacts.localhost:8000 — reaches nothing. boto3's default picks path for this endpoint today, so the setting looks redundant right up until someone carries a virtual-hosted config over from real S3.

The pair is read at runtime rather than pasted here, and that is worth knowing rather than a formality: these keys are AWS-shaped on purpose — AKIA + 16, and a 40-character secret — because botocore validates the shape before it signs. Secret scanners cannot tell them from live credentials, so GitHub push protection rejects a commit containing one. Keep them out of anything you commit, yours included.

Per-service, runnable

One script per service, driving the vendor's own SDK against Backlot: examples/using-official-sdk/.