Authentication
tallpond uses OAuth 2.0 Authorization Code + PKCE against an Ory (Hydra/Kratos) issuer.
The gateway brokers the flow so that tokens never reach page scripts — they are held
in httpOnly cookies, and the browser only ever learns "signed in or not."
Most apps never call these endpoints directly; the SDK auth.* methods drive
the whole flow. They are documented here for completeness and for non-browser clients.
Session model#
| Credential | Used by | Requirements |
|---|---|---|
| Session cookie | Browser apps via the SDK | httpOnly cookies set by /auth/token; every state-changing call must include X-Tallpond-Csrf: 1 and use credentials: include. |
| Bearer token | Server-to-server | Authorization: Bearer <access_token>; no CSRF header. |
Token validation is cached briefly (60s) so a burst of requests doesn't hit Ory per call.
When ORY_API_KEY is configured the gateway introspects the token (learning the app's
OAuth client_id, which per-app budgets key on); otherwise it falls back to userinfo,
which proves the user but not the app (budgets are then unenforced).
The browser flow#
auth.signIn()→ redirect to the gateway'sGET /auth/authorizewith a PKCE challenge; the gateway 302s to Ory's/oauth2/auth. Apps never configure the issuer URL directly — only the gateway knows it (PROXY_BASE_URL/ORY_HYDRA_URL), so the SDK config is just{ gatewayUrl, clientId }.- User authenticates and consents (see Consent).
- Ory redirects back with
?code=…. auth.handleRedirectCallback()→POST /auth/tokenexchanges the code; the gateway sets session cookies and returns{ authenticated: true }.- On a later
401, the SDK callsPOST /auth/refreshonce and retries.
On platform-hosted app origins, the gateway is mounted at /_osg, so the same flow uses
/_osg/auth/authorize, /_osg/auth/token, and /_osg/auth/refresh. Session cookies are
host-only on the app origin; the refresh cookie path is /_osg/auth. The edge resolves
the app from the request host and rejects authenticated /_osg/v1/* calls whose token
audience does not match that hosted app.
Endpoints#
All are under /auth.
GET /auth/authorize#
Browser-facing OAuth entry point. Redirects (302) to the configured issuer's
/oauth2/auth, forwarding the query string untouched (client_id, redirect_uri,
code_challenge, state, …). Exists so apps depend only on the gateway, never the
issuer's URL. 503 if no issuer is configured.
POST /auth/token#
Exchanges an authorization code for a session. The gateway performs the exchange
server-to-server and sets httpOnly cookies; the token is never in the response body.
Body
{
"code": "…",
"code_verifier": "…",
"redirect_uri": "https://app.example.com",
"client_id": "…"
}
200 { "authenticated": true, "expires_in": 3600 } · 4xx/502 { "error": "…" }
POST /auth/refresh#
Rotates the session using the refresh cookie. Requires X-Tallpond-Csrf.
Body { "client_id": "…" } → 200 { "authenticated": true, "expires_in": 3600 }
· 401 if the refresh token is missing/invalid (cookies are cleared).
POST /auth/logout#
Best-effort revokes the refresh token at Ory, then clears cookies. Requires
X-Tallpond-Csrf. Always 200 { "ok": true } from the client's perspective.
Body { "client_id"?: "…" }
GET /auth/session#
Reports whether the session cookie is valid. Read-only.
200 { "authenticated": true, "user_id": "…" } or { "authenticated": false }
Consent endpoints#
Rendered by the tallpond dashboard's consent screen. Accepting consent makes the user a
member of the app's _app resource and credits a welcome balance ($5 default,
WELCOME_CREDIT_USD) if the user has never received one.
GET /auth/consent-request?challenge=…#
Returns the consent request details (subject, requested scope, is_new_user). If Ory
reports skip: true (already granted with remember), it auto-accepts and returns
{ "skip": true, "redirect_to": "…" } — and still grants the welcome credit to a
never-welcomed user, so a remembered consent can't skip past it.
POST /auth/consent/accept#
Body { "challenge": "…", "grant_scope": ["openid", …], "profile"?: { "displayName"?: "…", "handle"?: "…" } }
→ 200 { "redirect_to": "…", "welcomed": true|false }
The subject is taken only from Ory's record of the challenge, never from the body: the challenge is the proof of an authenticated in-flight login, and everything granted here (credit, membership, profile) must go to that verified subject.
profile is optional and onboarding sends at most a display name. A new account
with no handle gets one generated, seeded from that display name — see
Handles for why signup does not ask. An existing user who cleared
their handle chose that and is not reissued one.
Best-effort side effect: resolves the consenting client → its active deployment → joins
the user to the _app resource. Never blocks consent (the app may not have deployed a
schema yet).
POST /auth/consent/reject#
Body { "challenge": "…" } → 200 { "redirect_to": "…" }
GET /auth/kratos/flow?type=…&id=…#
Server-to-server proxy for Kratos self-service flow data (the browser can't call Ory
directly because of CORS + SameSite cookies).
SDK#
const tallpond = createClient({ gatewayUrl, clientId });
await tallpond.auth.signIn(); // → redirect to gateway /auth/authorize → Ory
await tallpond.auth.handleRedirectCallback(); // on return, exchanges ?code
const { authenticated, userId } = await tallpond.auth.getSession();
await tallpond.auth.signOut();
See sdk.md.
Test sessions (programmatic auth for e2e testing)#
Real sessions require a browser. For CI and coding agents that need to exercise a
deployed app end-to-end, a developer can mint sessions for synthetic test
users instead — no browser anywhere in the loop. Test sessions are
environment-only: the token is pinned to an env deployment (default dev;
deploy it first with tallpond dev) and can never read or write production
data — production moves only for real signed-in users:
ALICE=$(tallpond dev --user alice) # deploy dev env + mint alice, in one
BOB=$(tallpond test-session --user bob) # more users against the same env
curl -H "Authorization: Bearer $ALICE" https://api.tallpond.com/v1/users/me
Or with a dev token directly: POST /dev/apps/:appId/test-users
{ "name": "alice", "env": "dev" } (env is required; prod is rejected)
→ { user_id, token, expires_at, payer, env }. The token (tpt_…) works as a
bearer credential on every authed route — table ops, resources, functions,
files, AI — as that user, scoped to that app and environment. In scripts, pass
it to the SDK:
const tallpond = createClient({ gatewayUrl: 'https://api.tallpond.com', accessToken: process.env.ALICE })
await tallpond.functions.invoke('publishPost', { args: { body: 'hi' } })
Properties to know:
- Identity: test user ids are always
tptest:<appId>:<name>, composed server-side — a test session can never name a real user. Minting the same name again returns the same user (data and memberships intact) with a fresh token. - Billing: test users have no wallet. Everything they do bills your own
balance directly — the token carries you (the minting developer) as payer —
metering exactly like production traffic (the costs are real), for exactly
what the tests actually use. A per-env budget bounds the total: a
separate pot from the app's production budget, created lazily at a low
circuit-breaker default ($2/month), so a leaked token or runaway loop can
never burn more than that env's cap — and never touches the budget
protecting production. A
402on a test operation means your wallet (or that env budget) is short. - Limits: tokens expire after 24 hours; re-mint any time; names are unlimited
(a test user is just an id — nothing is provisioned).
404for apps you don't own. - Multi-user flows (invites, memberships, via-gated functions) work by minting two names and using both tokens.