Search Composal documentation

Search public documentation, commands, and release notes.

Verify · Credentials

Better Auth sign-in

Let Verify runs sign in to your Better Auth app as test personas without passwords or saved cookies.

On this page

How Composal sign-in works

Composal sign-in lets Verify runs sign in to your Better Auth app as test personas, with no password, authenticator code or copied cookies. Your app trusts Composal as an OpenID Connect provider. When a run starts, Composal issues a short-lived ID token for the run's persona. The test browser presents it to your app's Better Auth sign-in endpoint and receives a normal session.

Each persona is a separate test user, such as an admin, a member on the free plan or a billing owner. Create as many as your scenarios need. All of them use one provider configuration in your app.

Personas always sign in with a Composal-owned address of the form <user>.<organization-slug>@cred.composal.ai, for example billing-admin.acme@cred.composal.ai. Composal never asserts any other email, so a persona can never sign in as one of your real users, and your app can admit every persona with a single suffix check.

You need:

  • Better Auth 1.7.0 or later, with the Generic OAuth plugin. Earlier versions do not accept ID tokens from a Generic OAuth provider.
  • An app that can reach https://composal.ai when it starts, to read Composal's provider configuration.
  • An organization administrator in Composal to create personas, and a developer who can change your app's auth configuration.

Create test personas

  1. Open your project's Credentials and choose Add Credential → Composal sign-in. Composal sign-in is available for project credentials only.
  2. Enter a Name for the persona, such as Billing admin. Your app receives it as the user's name.
  3. Enter the App URL, for example https://app.example.com. Runs sign in here before the scenario starts. The environment you test must allow this origin.
  4. Enter the Persona Email user, such as billing-admin, using lowercase letters, numbers, dashes or underscores. Composal adds your organization's suffix, so this persona signs in as billing-admin.<organization-slug>@cred.composal.ai.
  5. Optional: open Better Auth settings and instructions if your app differs from the defaults.
    • Provider ID is the providerId in your Generic OAuth configuration. The default is composal.
    • Better Auth URL is where your app serves Better Auth. The default is the app origin followed by /api/auth. Use an absolute URL when Better Auth runs on another origin, such as https://api.example.com/api/auth; the environment must allow that origin too.
    • Client ID is the clientId in your Generic OAuth configuration. Composal uses it as the token audience. The default is the app origin.
  6. Choose Save test account. Composal shows Connect your app, with your organization's discovery URL and the Better Auth configuration to copy. You can return to it later by editing the persona.

Like any other project credential, a persona works on every environment in its project, and you can choose it as a scenario persona's Credential. Each persona's subject (sub) is its Composal account ID, so your app links every run to the same user. Disabling the credential stops future runs from signing in as that persona.

You can also create personas through the API. Send login_method: "oidc_identity", the persona's user part as username, the app URL as login_url, and optional oidc: { provider_id, auth_url, client_id } to POST /api/v1/organizations/{organization_id}/verification/projects/{project_id}/credentials. Responses include the full address as username, and the discovery URL, suffix and settings under identity_provider.

Add the provider to Better Auth

Add Composal as a Generic OAuth provider, with a hook that admits only your organization's personas. Connect your app shows this configuration with your values filled in. The discovery URL has the form https://composal.ai/api/oidc/<organization-id>/.well-known/openid-configuration, and it is the same for every persona in your organization.

TS
import { betterAuth } from "better-auth"
import { APIError, createAuthMiddleware } from "better-auth/api"
import { genericOAuth } from "better-auth/plugins"
import { decodeJwt } from "jose"

export const auth = betterAuth({
  // ...your existing options
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "composal",
          discoveryUrl: "https://composal.ai/api/oidc/<organization-id>/.well-known/openid-configuration",
          clientId: "https://app.example.com",
          requireIdTokenVerification: true,
        },
      ],
    }),
  ],
  hooks: {
    // Admit only Composal test personas through this provider.
    before: createAuthMiddleware(async (ctx) => {
      const body = ctx.body as
        | { provider?: string; providerId?: string; idToken?: { token?: string } }
        | undefined
      if (body?.provider !== "composal" && body?.providerId !== "composal") return
      if (ctx.path !== "/sign-in/social") throw new APIError("FORBIDDEN")
      let email = ""
      try {
        email = String(decodeJwt(body.idToken?.token ?? "").email)
      } catch {}
      if (!email.endsWith(".<organization-slug>@cred.composal.ai")) throw new APIError("FORBIDDEN")
    }),
  },
})

requireIdTokenVerification makes Better Auth refuse to load the provider unless it can verify tokens against Composal's published keys. No client secret is needed: runs never use the redirect flow. jose is already a Better Auth dependency; add it to your own package.json to import it directly.

Better Auth reads the discovery document once, when it initializes (usually when your server starts). Restart or redeploy after adding the provider. If composal.ai is unreachable at that moment, Better Auth skips the provider until the next start. Signing keys are fetched as needed and refreshed automatically when Composal rotates them.

The app URL's origin must be one of your Better Auth trustedOrigins. That is the default when Better Auth and your app share an origin. For a preview deployment, add the preview origin as well.

The discovery URL uses your organization's ID and never changes. The persona suffix uses your organization's slug: if you rename the organization, update the hook's suffix in your app, then edit and save each persona to move it to the new suffix.

Allow only your test personas

Composal issues tokens only for the personas your organization saves, and only with your organization's cred.composal.ai suffix. The hook keeps your app the final authority over who signs in through the provider:

  • It admits only addresses that end in your organization's suffix.
  • It allows the provider only for ID-token sign-in. Better Auth's redirect sign-in and account-linking paths for the provider are refused.

Reading the email before verification is safe here: Better Auth still verifies the token's signature, issuer, audience and expiry after the hook. To admit only some personas, compare against a list of their full addresses instead of the suffix.

Users, roles and organizations

The first sign-in as a persona creates a user with its address, marked verified, and links a composal account to it. Grant the user its role, plan or organization membership in your app as you would for any new user. Later runs sign in as the same user. To require users to exist first, set disableImplicitSignUp: true on the provider and create the users with the personas' addresses yourself.

With the Better Auth organization plugin, a new session has no active organization. Have the scenario choose one, or set it in a session hook for your test users.

What happens during a run

  1. When the run's browser is about to start, Composal signs an ID token for the persona. The token is issued by your organization's issuer and addressed to your client ID. It carries the persona's subject, address and name, and expires after two minutes.
  2. The browser posts it to <Better Auth URL>/sign-in/social from the app origin. Better Auth verifies it and sets your normal session cookie in that browser.
  3. The scenario starts signed in. The agent never sees the token or the session cookie.
  4. When the run finishes, the browser signs out through <Better Auth URL>/sign-out and its cookies are cleared, so the session does not outlive the run.

PR previews work without another persona. When the app URL or Better Auth URL uses the origin of the environment a preview is based on, runs on the preview sign in through the preview's origin instead. The preview must run the same provider configuration and trust its own origin.

Troubleshooting

  • The run fails with oidc_sign_in_rejected. Your app refused the token. Check that the server restarted after you added the provider, that providerId and clientId match the persona's settings, and that your hook's suffix matches the persona's address.
  • The run fails with oidc_sign_in_without_session. Sign-in succeeded but no cookie applies to the app URL. When Better Auth runs on another origin, configure its cookies for the app's domain, for example with Better Auth's cross-subdomain cookies.
  • Better Auth logs Discovery fetch failed for "composal" or Provider skipped. Better Auth could not read the discovery document when it started, so the provider is missing and sign-in is rejected. Check outbound access to composal.ai and restart.
  • Your server logs Invalid origin. Add the app URL's origin to trustedOrigins.
  • Run preparation says the environment does not allow the origin. Add the app URL's origin, and the Better Auth URL's origin if it differs, to the environment, or update the persona.

Security model

  • Personas can only assert cred.composal.ai addresses under your organization's slug, never an address on your domain or another organization's.
  • Composal signs tokens only for personas saved in your organization, and only when one of your organization's runs starts. Tokens are short-lived, bound to your organization's issuer and your client ID, and carry a fresh nonce.
  • No password, cookie or reusable secret for your app is stored in Composal. The persona settings are encrypted with your other credentials.
  • Composal publishes public signing keys only. Each organization has its own issuer URL.
  • Your app decides who can sign in. Keep the hook in place, and remove the provider to revoke Composal's access entirely.

Next, connect a private network if your app is not public, or compare the other sign-in methods in Credentials.