# Sign in with Astral

A workspace site can sign a person in with their Astral account. The site then knows who the person is, and it can call the Astral API (`/api/v1`) **as that person**, only inside what the workspace allows.

Use it for any site that shows or changes per-person data. Do not build your own login for workspace members.

## How it works

- **Public clients only.** Each site gets one client id (`astral_site_…`) the first time it is used. There is no client secret. Never ask for one, and never store one.
- **Two modes, chosen for you.**
  - **Embedded:** the site is open inside Astral (for example from a sidebar link). The person is already signed in, so no click is needed. Tokens come from the Astral host through `postMessage`.
  - **Standalone:** the site is open in its own tab. The helper runs OAuth 2.1 authorization code + PKCE (S256) against Astral. The person sees a consent page the first time.
- **Members only.** Only members of the site's workspace can sign in. Guests and other workspaces are refused.
- **Redirects** go only to the site's own live origins (its platform hostname, verified custom domains, its https production URL) and the paths `/`, `/astral/callback`, `/api/auth/callback/astral` (Auth.js) and `/api/auth/oauth2/callback/astral` (Better Auth).

## The helper

Load one script. It has no dependencies and needs no build step.

```html
<script src="https://www.astralos.ai/sdk/sign-in.js"></script>
<div id="astral-sign-in"></div>
<script>
  Astral.renderButton(document.getElementById("astral-sign-in"))
  Astral.onChange((user) => render(user))
  Astral.init({ scope: "openid profile email tasks:read" }).then(render)
</script>
```

| Call | What it does |
|---|---|
| `Astral.init({ scope?, redirectPath?, issuer? })` | Starts the helper. Detects embedded or standalone mode and finishes a sign-in redirect. Returns the user or `null`. |
| `Astral.mode` | `"embedded"` or `"standalone"`. |
| `Astral.getUser()` | `{ sub, workspace_id, workspace_role, name?, picture?, email?, email_verified? }` or `null`. |
| `Astral.signIn({ scope? })` / `Astral.signOut()` | Start or end a sign-in. Sign out revokes the tokens. |
| `Astral.getAccessToken()` / `Astral.getIdToken()` | Current tokens. The helper refreshes them before they expire. |
| `Astral.astralFetch(path, init?)` | `fetch` to an `/api/v1/` path with the person's token. It handles approvals (below). |
| `Astral.tool("app.tool", input)` | Calls one Astral tool. Returns `data` or throws an error with `code`. |
| `Astral.onChange(cb)` / `Astral.renderButton(el)` | Listen for sign-in changes; draw a sign-in button. |

## Scopes and rights

- Identity: `openid` (always), `profile`, `email`.
- API: the API token grammar: `<app>:read`, `<app>:write`, `*:read`.
- A call has the person's own rights ∩ the scopes the person consented to ∩ the scopes the workspace allows for the site. A workspace allows `openid profile email *:read` by default (read-only).
- Ask for the narrowest scopes your site needs.
- Billing, credentials, agents, workspaces and other sensitive apps are never available to a site (`permission_denied`, reason `blocked_for_sites`).
- A site token cannot create or manage API tokens.

## Writes need a person's approval

A read is a tool that only reads. Every other call is a write. A write needs a write scope **and** the person's approval of that exact call.

- The first try answers `approval_required` with `approval_id`, `approval_url` and `expires_at`. Nothing runs.
- `astralFetch` and `tool` handle this for you: embedded, Astral shows its approval card; standalone, the helper opens `approval_url` in a popup and waits.
- After approval, the helper sends the same call once more with the `Astral-Approval` header. An approval works once, for that call only.

## Verify the person on your server

Send the ID token to your server and verify it with `jose` against the Astral JWKS. Never trust a user id that the browser sends without a token.

```js
import { createRemoteJWKSet, jwtVerify } from "jose"

const issuer = process.env.ASTRAL_ISSUER ?? "https://www.astralos.ai"
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`))

export async function astralUser(idToken) {
  const { payload } = await jwtVerify(idToken, jwks, {
    issuer,
    audience: process.env.ASTRAL_CLIENT_ID, // your site's client id
    algorithms: ["ES256"],
  })
  return { userId: payload.sub, workspaceId: payload.workspace_id, role: payload.workspace_role }
}
```

The client id is also at `GET https://www.astralos.ai/api/oauth/clients/lookup?origin=<your site origin>`. ID tokens live 5 minutes; access tokens 15 minutes (standalone) or 10 minutes (embedded).

## Endpoints

Standard OAuth 2.1 / OpenID Connect. Discovery: `https://www.astralos.ai/.well-known/openid-configuration`.

- `GET /oauth/authorize` — code + PKCE (S256) only; `state` and `nonce` required; `iss` in the response (RFC 9207).
- `POST /api/oauth/token` — `authorization_code` and `refresh_token` (rotated on each use).
- `GET /api/oauth/userinfo`, `POST /api/oauth/revoke` (RFC 7009), `GET /.well-known/jwks.json`.

## Revocation

- Owners and admins see every site that uses Sign in with Astral in workspace settings. They can narrow its scopes or revoke it. A revoke stops every token of the site at once.
- A person can see and revoke their own sign-ins.
- A person who leaves the workspace, or a deleted site, stops working at once.
- Every sign-in, consent, scope change and revoke is in the Event Log.


## Already have your own login?

Keep your own users and your own database. Add Astral as one more way to sign in, and link each Astral person to one of your users, once. After that, the person signs in with no click when they open your site inside Astral.

### The server helper

Copy one file into your server code. It has no build step.

- Node, Bun, Deno or an edge runtime: `curl -o lib/astral-server.mjs https://www.astralos.ai/sdk/astral-server.mjs`, then `npm i jose`.
- Python 3.9+: `curl -o astral_server.py https://www.astralos.ai/sdk/astral_server.py`, then `pip install "pyjwt[crypto]"`.

```js
import { createAstralAuth, sessionCookie } from "./lib/astral-server.mjs"

// Reads ASTRAL_CLIENT_ID and ASTRAL_ISSUER. Astral sets both on the site.
const astral = createAstralAuth()
```

| Node | Python | What it does |
|---|---|---|
| `verifyAstralPass(token)` / `verifyIdToken(token, { nonce? })` | `verify_astral_pass(token)` / `verify_id_token(token, nonce=None)` | Checks the signature against the JWKS (ES256), the issuer, `aud` and `azp` = your client id, and the expiry. Returns `{ sub, workspaceId, workspaceRole, name, email, … }`. |
| `identityFromRequest(req)` | `identity_from_header(value)` | The identity from `Authorization: Bearer <Astral pass>`, or `null`. |
| `signIn({ idToken, findUserByAstralSub })` | `sign_in(id_token, find_user_by_astral_sub)` | `signed_in` with your user id, or `link_required`. |
| `linkAccount({ userId, identity, findUserByAstralSub, saveAstralSub })` | `link_account(…)` | Stores the Astral `sub` on your user. Refuses `already_linked` when it is on another user. |
| `exchangeCode`, `refresh`, `revoke`, `callApi` | `exchange_code`, `refresh`, `revoke`, `call_api` | Server-side tokens: call `/api/v1` as the person (below). |
| `sessionCookie(name, value, { maxAge })` | `session_cookie(name, value, max_age)` | A `Set-Cookie` value that works inside a frame. |

The "Astral pass" is the ID token that `/sdk/sign-in.js` gives the page (`Astral.getIdToken()`). It lives 5 minutes. Use it to start your own session. Do not keep it as the session.

### Astral as an extra OIDC provider

The same facts for every library:

- Issuer and discovery: `https://www.astralos.ai` and `https://www.astralos.ai/.well-known/openid-configuration`.
- Client id: `ASTRAL_CLIENT_ID`. There is **no client secret**: token endpoint auth method `none`, with `client_id` in the form body.
- Code flow with PKCE S256, `state` and `nonce`. ID tokens are ES256.
- Callback URLs on your site's own origins: `/astral/callback`, `/api/auth/callback/astral` (Auth.js) and `/api/auth/oauth2/callback/astral` (Better Auth). No other path works.

**Auth.js (NextAuth v5)**, `auth.ts`:

```ts
import NextAuth from "next-auth"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [{
    id: "astral",
    name: "Astral",
    type: "oidc",
    issuer: "https://www.astralos.ai",
    clientId: process.env.ASTRAL_CLIENT_ID,
    client: { token_endpoint_auth_method: "none" },
    checks: ["pkce", "state", "nonce"],
    authorization: { params: { scope: "openid profile email" } },
  }],
  // With a database adapter, Auth.js stores the Astral sub in the accounts
  // table (provider "astral", providerAccountId = sub): that is the link.
})
```

**Better Auth**, with the generic OAuth plugin:

```ts
import { betterAuth } from "better-auth"
import { genericOAuth } from "better-auth/plugins"

export const auth = betterAuth({
  plugins: [genericOAuth({
    config: [{
      providerId: "astral",
      discoveryUrl: "https://www.astralos.ai/.well-known/openid-configuration",
      clientId: process.env.ASTRAL_CLIENT_ID!,
      clientSecret: "",
      pkce: true,
      scopes: ["openid", "profile", "email"],
    }],
  })],
})
```

**Supabase Auth and Clerk.** Their OAuth callback is on their own domain, and their custom providers need a client secret. Astral redirects only to your site's own origins and has no secret. So use a bridge: sign in with `/sdk/sign-in.js`, verify the pass on your server, and start the provider's session for the linked user.

Supabase (server code, service role key):

```ts
const identity = await astral.verifyAstralPass(pass)
const userId = await findUserByAstralSub(identity.sub) // your own link table
const { data: { user } } = await admin.auth.admin.getUserById(userId)
const { data } = await admin.auth.admin.generateLink({ type: "magiclink", email: user.email })
// A server client from @supabase/ssr sets the session cookies.
await supabase.auth.verifyOtp({ token_hash: data.properties.hashed_token, type: "email" })
```

Clerk:

```ts
// Server: a one-use ticket for the linked Clerk user.
const clerk = await clerkClient()
const { token } = await clerk.signInTokens.createSignInToken({ userId, expiresInSeconds: 60 })
// Browser:
const attempt = await signIn.create({ strategy: "ticket", ticket: token })
await setActive({ session: attempt.createdSessionId })
```

### Link accounts once

Store the Astral `sub` on your own user. It never changes for a person.

```sql
alter table users add column astral_sub text unique;
```

1. The person opens your site. Your server verifies the Astral pass and looks up `astral_sub`.
2. Found: start your own session for that user.
3. Not found: answer `link_required`. The person signs in with your own login (or makes a new account) one time. Then call `linkAccount` for that signed-in user.

- Link only while the person is signed in with your own login. That proves they own both accounts.
- Never link by email alone. You can use a verified email (`email_verified`) to suggest an account, but the person must still sign in to it.
- To unlink, set `astral_sub` to null.

### Embedded silent sign-in

Inside Astral, the helper gets the pass with no click. Send it to your server once, and start your own session:

```js
// Browser
const user = await Astral.init({ scope: "openid profile email" })
if (user) {
  const res = await fetch("/api/astral/session", {
    method: "POST",
    headers: { Authorization: `Bearer ${await Astral.getIdToken()}` },
  })
  const body = await res.json()
  if (body.status === "link_required") showLinkYourAccount()
}
```

```js
// Server: POST /api/astral/session
export async function POST(request) {
  const pass = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""
  let result
  try {
    result = await astral.signIn({ idToken: pass, findUserByAstralSub })
  } catch {
    return Response.json({ status: "invalid_pass" }, { status: 401 })
  }
  if (result.status === "link_required") return Response.json({ status: "link_required" })
  const session = await createSession(result.userId) // your own sessions
  return Response.json({ status: "signed_in" }, {
    headers: { "Set-Cookie": sessionCookie("__Host-session", session.id, { maxAge: 86400 }) },
  })
}
```

### Call /api/v1 as the person from your server

- Embedded pages: call `/api/v1` from the browser with `Astral.astralFetch` or `Astral.tool`. Embedded mode gives no refresh token.
- Server work (jobs, webhooks, server pages): run a standalone sign-in, then keep the person's tokens on your server, encrypted, next to your user. Never send the refresh token to the browser.

```js
const tokens = await astral.exchangeCode({ code, codeVerifier, redirectUri, nonce })
await saveTokens(user.id, tokens)

const res = await astral.callApi({
  tokens: await loadTokens(user.id),
  saveTokens: (next) => (next ? saveTokens(user.id, next) : deleteTokens(user.id)),
  path: "/api/v1/tools/tasks.list",
  init: { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" },
})
```

- **Rotation.** Each refresh returns a new refresh token, and the old one stops. Save the new set before you use it. Refresh one person at a time (take a lock): a used refresh token sent again after 20 seconds ends the sign-in.
- **Revocation.** `invalid_grant` means Astral ended the sign-in: the person signed out, an admin revoked the site, or the person left the workspace. `callApi` calls `saveTokens(null)`: delete the tokens and ask the person to sign in again. The error has `revoked: true`.
- **Writes.** A write answers `approval_required` with `approval_id` and `approval_url`. Send the person to `approval_url`. After they approve, send the same call once more with the header `Astral-Approval: <approval_id>`.
- **Sign out.** Call `revoke(refreshToken)`. It ends the whole sign-in.

### Cookies inside a frame

Inside Astral, your site runs in a frame of another site. Browsers treat its cookies as third-party cookies. Safari blocks them by default, and Chrome and Edge block them for many people.

- A `SameSite=Lax` or `Strict` cookie (the default of most libraries) never reaches the frame. Your login "works" in a tab and fails inside Astral.
- Set session cookies with `SameSite=None; Secure; Partitioned; HttpOnly; Path=/` and a `__Host-` name. `sessionCookie()` does this. `Partitioned` keeps the cookie for your site inside Astral, separate from your site in its own tab.
- A browser can still drop a frame cookie. The safe way is the Astral pass: send `Authorization: Bearer <Astral.getIdToken()>` with each request from the frame, and check it with `identityFromRequest`. Then no cookie is needed.
- Let Astral frame your site: send `Content-Security-Policy: frame-ancestors 'self' https://www.astralos.ai https://astralos.ai`, and no `X-Frame-Options: DENY`.
