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#

CredentialUsed byRequirements
Session cookieBrowser apps via the SDKhttpOnly cookies set by /auth/token; every state-changing call must include X-Tallpond-Csrf: 1 and use credentials: include.
Bearer tokenServer-to-serverAuthorization: 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#

  1. auth.signIn() → redirect to the gateway's GET /auth/authorize with 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 }.
  2. User authenticates and consents (see Consent).
  3. Ory redirects back with ?code=….
  4. auth.handleRedirectCallback()POST /auth/token exchanges the code; the gateway sets session cookies and returns { authenticated: true }.
  5. On a later 401, the SDK calls POST /auth/refresh once 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

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

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#

ts
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:

sh
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:

ts
const tallpond = createClient({ gatewayUrl: 'https://api.tallpond.com', accessToken: process.env.ALICE })
await tallpond.functions.invoke('publishPost', { args: { body: 'hi' } })

Properties to know: