DocsSign in with Astral

Sign in with Astral

Let a workspace site know which Astral person uses it, and call the Astral API as that person, inside what the workspace allows. Embedded and standalone modes, scopes, approvals, revocation.

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>
CallWhat 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()
NodePythonWhat 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, callApiexchange_code, refresh, revoke, call_apiServer-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 tableconst { 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 })

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
// Browserconst 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/sessionexport 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.