# Runtime Sign-In & BYOA

Projects launched from the FullStacked Cloud Client GUI already receive the user's token as `process.env.AUTH_TOKEN`: most projects never need to sign anyone in. This page covers the two other cases: a standalone runtime app that signs a user in itself (PKCE through `fullstacked/auth` and the auth page at https://auth.fullstacked.cloud), and an enterprise backend that validates the FullStacked tokens of users signed in through its own identity provider (Bring Your Own Auth).

## Interactive sign-in from a runtime app

`fullstacked/auth` exports one function:

```ts
authenticate(url: string, opts?: { windowFeatures?: string }): Promise<any>
```

It adds `auth=1` to `url`, opens it in the platform's auth surface (system auth session on Apple and Android, an auth window or browser popup elsewhere) and resolves with the object the page posts back: `{ exchange_token }` on success. A denial resolves `{ error }` (Android rejects with an `Error` instead). Dismissing the auth surface rejects on Apple, Android and Linux; on Windows and in the browser window of the npm CLI the promise may never settle, so keep a way to cancel in your UI. There is no `authorize` export in the runtime.

Flow:

1. Create a PKCE pair: `code_verifier` (32 random bytes, base64url) and `code_challenge` = base64url(SHA-256(verifier)).
2. ``authenticate(`${AUTH_URL}/?code_challenge=<challenge>&code_challenge_method=S256`)``. The user signs in and approves on the consent screen.
3. Redeem: `POST ${AUTH_URL}/v1/auth/exchange/token` with `{ exchangeToken, codeVerifier }`. The response carries the session token.

```ts
import { authenticate } from "fullstacked/auth";
import { createHash, randomBytes } from "node:crypto";

export async function signIn(authUrl: string): Promise<string> {
    const verifier = randomBytes(32).toString("base64url");
    const challenge = createHash("sha256").update(verifier).digest("base64url");

    const result = await authenticate(
        `${authUrl}/?code_challenge=${challenge}&code_challenge_method=S256`
    );
    if (result?.error) throw new Error(result.error);
    if (!result?.exchange_token?.startsWith("fsc_exc_")) throw new Error("Invalid authentication response");

    const res = await fetch(`${authUrl}/v1/auth/exchange/token`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ exchangeToken: result.exchange_token, codeVerifier: verifier }),
    });
    if (!res.ok) throw new Error(`Exchange failed (${res.status}): ${await res.text()}`);
    const session = await res.json();
    return session.token; // fsc_ses_... (or fsc_ath_<slug>_... from an enterprise authority)
}
```

Call `signIn` from a click handler so the window can open. Use node `crypto` (core-backed), not `crypto.subtle`.

Exchange response (`200`):

```json
{
  "token": "fsc_ses_...",
  "tokenType": "Bearer",
  "userId": "usr_4f1c9a2b7d3e8f6a5b0c1d2e",
  "sessionId": "tok_...",
  "user": { "userId": "usr_4f1c9a2b7d3e8f6a5b0c1d2e", "email": "alice@example.com", "name": "Alice Martin", "slug": "alice", "createdAt": "2026-09-20T14:32:00.000Z" },
  "session": { "sessionId": "tok_...", "userId": "usr_4f1c9a2b7d3e8f6a5b0c1d2e", "expiresAt": 1792000000000 },
  "issuedAt": 1790000000,
  "expiresAt": 1792000000,
  "restricted": false
}
```

Rules:

- The challenge is mandatory and must be S256 (43 base64url characters); without it the auth page refuses to start.
- The exchange token (`fsc_exc_...`) is single use and expires after 300 s. A wrong or missing verifier burns it (`400` / `401`). The endpoint is limited to 30 requests per minute per IP (`429 RATE_LIMITED`).
- `body` keys also accept `exchange_token` / `code_verifier`.
- Sign out with `POST ${AUTH_URL}/v1/auth/logout` and `Authorization: Bearer <token>` (returns `{ "status": "revoked" }`).
- Store the token like a password (for example `fullstacked/config` is plain text in the project directory: acceptable on the user's own device only).

## Enterprise Bring Your Own Auth (BYOA)

An organization can run its own sign-in for FullStacked users through a hosted **authority** (`ath_<slug>`, created by FullStacked Cloud operators, with a secret key `sec_auth_<env>_...` shown once). Its users get session tokens `fsc_ath_<slug>_<64 hex>` that work like any FullStacked token (`/api/v1/me`, projects, data, tunnels). On `/api/v1/me` their `id` is `<authorityId>:<externalUserId>` and `authority_id` is set.

### Minting a sign-in for your user (your IdP backend)

After your identity provider authenticates the user, your backend mints an exchange token bound to the runtime's PKCE challenge and hands it back the same way the FullStacked auth page does. The runtime app opens your sign-in page with `authenticate("<your page>?code_challenge=<challenge>&code_challenge_method=S256")`: the page receives `code_challenge` (the S256 challenge to mint with) and `auth=1`, and native shells also add `native=1` and `callback_scheme=fullstacked-auth`.

- Native (`native=1`): redirect to `<callback_scheme>://auth?exchange_token=fsc_exc_...` (on denial `?error=<message>`), that is `fullstacked-auth://auth?...`.
- Popup (no `native`): `window.opener.postMessage({ exchange_token }, <opener origin>)` (on denial `{ error }`), then close the window.

The runtime app then redeems it at `https://auth.fullstacked.cloud/v1/auth/exchange/token` exactly as above and receives `fsc_ath_<slug>_...`.

```http
POST /v1/authorities/ath_acme/token HTTP/1.1
Host: auth.fullstacked.cloud
X-FullStacked-Authority-Key: sec_auth_...
Content-Type: application/json

{ "userId": "alice", "codeChallenge": "<S256 challenge from the runtime>", "email": "alice@example.com", "name": "Alice Martin", "groups": ["devops"] }
```

Response: `{ "exchangeToken": "fsc_exc_...", "tokenType": "ExchangeToken", "authorityId": "ath_acme", "userId": "alice", "expiresAt": ... }` (single use, 300 s). Optional body fields: `ttl`, `metadata`. Limit: 50 mints per second per authority.

### Validating a user's token in your backend

When a project calls one of your services with `Authorization: Bearer ${process.env.AUTH_TOKEN}`, the service validates it:

```http
POST /v1/authorities/ath_acme/validate HTTP/1.1
Host: auth.fullstacked.cloud
X-FullStacked-Authority-Key: sec_auth_...
Content-Type: application/json

{ "token": "fsc_ath_acme_..." }
```

`200`:

```json
{
  "valid": true,
  "authorityId": "ath_acme",
  "userId": "alice",
  "email": "alice@example.com",
  "name": "Alice Martin",
  "groups": ["devops"],
  "tokenClass": "ath",
  "tokenId": "tok_...",
  "issuedAt": 1790000000,
  "expiresAt": 1790086400,
  "metadata": {}
}
```

| Response | Meaning |
| :--- | :--- |
| `401 { "valid": false, "error": "token_invalid_or_expired" }` | Not an `fsc_ath_` token, or unknown, expired or revoked. |
| `400` | Token issued by another authority, or `token` missing. |
| `401` | Wrong authority key. |
| `403` | Authority suspended. |
| `429 RATE_LIMITED` | Over 100 validations per second per authority. |

Only `fsc_ath_` tokens of your own authority validate here. To check a platform user's token (`fsc_ses_`, `fsc_pat_`) instead, call `GET https://auth.fullstacked.cloud/api/v1/me` with it: `200` means valid, and the body says who it is.

```ts
// Express-style middleware in your own backend (Node.js, not the FullStacked runtime)
export async function fullstackedUser(req, res, next) {
    const token = req.headers.authorization?.replace(/^Bearer\s+/i, "");
    const r = await fetch(`${process.env.FSC_AUTH_URL}/v1/authorities/${process.env.FSC_AUTHORITY_ID}/validate`, {
        method: "POST",
        headers: { "Content-Type": "application/json", "X-FullStacked-Authority-Key": process.env.FSC_AUTHORITY_KEY! },
        body: JSON.stringify({ token }),
    });
    const body = await r.json();
    if (!r.ok || !body.valid) return res.status(401).json({ error: "unauthorized" });
    req.user = body;
    next();
}
```

Keep the authority key on your server only: never ship it in a project.

## Endpoints

### POST /v1/auth/exchange/token

Redeems a single-use fsc_exc_ exchange token with its PKCE code_verifier: a BYOA sign-in returns an fsc_ath_<slug>_ session token, a session handoff a fresh fsc_ses_ token.

- **Service**: `auth` (`https://auth.fullstacked.cloud`)
- **Auth**: Public (PKCE code_verifier)
- **In project code**: `${process.env.AUTH_URL}/v1/auth/exchange/token`

Request:

```json
{
  "exchangeToken": "fsc_exc_...",
  "codeVerifier": "..."
}
```

### POST /v1/authorities/:id/token

Server-to-server: an enterprise IdP mints a PKCE-bound fsc_exc_ exchange token for { userId, email?, name?, groups?, ttl?, codeChallenge, metadata? }.

- **Service**: `auth` (`https://auth.fullstacked.cloud`)
- **Auth**: X-FullStacked-Authority-Key, or Bearer fsc_* of an authority administrator

### POST /v1/authorities/:id/validate

Validates an fsc_ath_<slug>_ token issued for this authority and returns its claims (userId, email, name, groups, tokenId, issuedAt, expiresAt, metadata).

- **Service**: `auth` (`https://auth.fullstacked.cloud`)
- **Auth**: X-FullStacked-Authority-Key, or Bearer fsc_* of an authority administrator

Request:

```json
{
  "token": "fsc_ath_acme_..."
}
```

curl:

```bash
curl -sS -X POST -H "X-FullStacked-Authority-Key: $AUTHORITY_KEY" -H "Content-Type: application/json" -d '{"token":"fsc_ath_..."}' "https://auth.fullstacked.cloud/v1/authorities/<authorityId>/validate"
```

### POST /v1/authorities/:id/revoke

Revokes the authority's tokens by { userId }, by { token }, or { all: true }.

- **Service**: `auth` (`https://auth.fullstacked.cloud`)
- **Auth**: X-FullStacked-Authority-Key, or Bearer fsc_* of an authority administrator
