Skip to content

Sign in with Configure over OpenID Connect ​

If you already have sign-in (Auth.js, Clerk, Auth0, Better Auth or your own), add Configure as one more OpenID Connect provider. The user signs in on Configure's hosted flow and comes back to your callback with an ID token. The token carries their Configure user id and, when you ask for it, a verified email. Then your server reads what they imported, with your own user id.

This server does the whole sign-in with Node's built-in modules and jose:

ts
import { createServer, type IncomingMessage } from "node:http";
import { createHash, randomBytes } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";

// Endpoints from https://api.configure.dev/.well-known/openid-configuration
const ISSUER = "https://api.configure.dev";
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));

const CLIENT_ID = process.env.CONFIGURE_CLIENT_ID ?? "";     // oc_..., from step 1
const CLIENT_SECRET = process.env.CONFIGURE_CLIENT_SECRET;   // ocs_..., confidential clients only
const REDIRECT_URI = "http://localhost:3000/callback";       // registered on the client, exact string
const COOKIE = "configure_oidc";
const SECURE = REDIRECT_URI.startsWith("https://") ? "; Secure" : "";

const random = () => randomBytes(32).toString("base64url");

function readCookie(req: IncomingMessage, name: string): string {
  for (const part of (req.headers.cookie ?? "").split(";")) {
    const [key, value] = part.trim().split("=");
    if (key === name && value) return value;
  }
  return "";
}

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", REDIRECT_URI);
  try {
    // 2. GET /login: make a PKCE verifier, a state and a nonce for this attempt.
    //    Keep all three in an httpOnly cookie, then send the browser to Configure.
    if (url.pathname === "/login") {
      const verifier = random();
      const state = random();
      const nonce = random();
      const authorize = new URL(`${ISSUER}/oauth/authorize`);
      authorize.search = new URLSearchParams({
        response_type: "code",
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        scope: "openid email profile",                        // always send it
        code_challenge: createHash("sha256").update(verifier).digest("base64url"),
        code_challenge_method: "S256",                         // required for every client
        state,
        nonce,
      }).toString();
      res.writeHead(302, {
        Location: authorize.toString(),
        "Set-Cookie": `${COOKIE}=${verifier}.${state}.${nonce}; HttpOnly; SameSite=Lax; Path=/callback; Max-Age=900${SECURE}`,
      });
      return res.end();
    }

    // 3. GET /callback?code=...&state=...: check the state against the cookie, then clear it.
    if (url.pathname === "/callback") {
      const [verifier = "", state = "", nonce = ""] = readCookie(req, COOKIE).split(".");
      res.setHeader("Set-Cookie", `${COOKIE}=; HttpOnly; SameSite=Lax; Path=/callback; Max-Age=0${SECURE}`);
      if (!state || url.searchParams.get("state") !== state) {
        res.writeHead(400);
        return res.end("Invalid sign-in state.");
      }
      if (url.searchParams.has("error")) {
        res.writeHead(400);                                    // access_denied: the user declined
        return res.end(`Sign-in ended: ${url.searchParams.get("error")}`);
      }

      // 4. Exchange the code at the token endpoint. The body is form-encoded.
      const headers: Record<string, string> = { "Content-Type": "application/x-www-form-urlencoded" };
      if (CLIENT_SECRET) {
        headers.Authorization = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
      }
      const tokenRes = await fetch(`${ISSUER}/oauth/token`, {
        method: "POST",
        headers,
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code: url.searchParams.get("code") ?? "",
          redirect_uri: REDIRECT_URI,
          client_id: CLIENT_ID,
          code_verifier: verifier,
        }),
      });
      const tokens = (await tokenRes.json()) as {
        access_token?: string;
        id_token?: string;
        error?: string;
        error_description?: string;
      };
      if (!tokenRes.ok || !tokens.id_token) {
        res.writeHead(400);
        return res.end(`Token exchange failed: ${tokens.error ?? "no id_token"}: ${tokens.error_description ?? ""}`);
      }

      // 5. Verify the ID token: the RS256 signature against the JWKS, iss, aud and exp.
      //    Then compare the nonce with the one from this attempt.
      const { payload } = await jwtVerify(tokens.id_token, JWKS, {
        issuer: ISSUER,
        audience: CLIENT_ID,
        algorithms: ["RS256"],
      });
      if (payload.nonce !== nonce) throw new Error("ID token nonce does not match.");

      // 6. Use the claims. Key your user on sub. Trust the email only when email_verified is true.
      const signedIn = {
        configureUserId: payload.sub,
        email: payload.email_verified === true ? payload.email : null,
        name: payload.name ?? null,
      };
      // Find or create your own user here. Keep tokens.access_token to link the two (step 7).
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(signedIn));
    }

    res.writeHead(404);
    res.end();
  } catch (err) {
    res.writeHead(500);
    res.end(`Sign-in failed: ${(err as Error).message}`);
  }
}).listen(3000, () => console.log("Open http://localhost:3000/login"));

Create a client first (step 1). Then install jose and start the server with your client id:

bash
npm install jose
CONFIGURE_CLIENT_ID=oc_... npx tsx server/oidc.ts

Open http://localhost:3000/login. After the hosted flow, the page shows the user's Configure id, their email if it is verified, and their name.

The endpoints ​

Configure publishes its endpoints at the discovery URL:

bash
curl https://api.configure.dev/.well-known/openid-configuration

The fields you use:

json
{
  "issuer": "https://api.configure.dev",
  "authorization_endpoint": "https://api.configure.dev/oauth/authorize",
  "token_endpoint": "https://api.configure.dev/oauth/token",
  "jwks_uri": "https://api.configure.dev/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "profile"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"]
}

/oauth/token has no /v1 prefix.

If you use an auth library ​

Most auth libraries take an OpenID Connect provider as a set of values. Give yours these:

SettingValue
Issuerhttps://api.configure.dev
Discovery URLhttps://api.configure.dev/.well-known/openid-configuration
Client IDYour oc_ client id from step 1.
Client secretYour ocs_ secret. A public client has none.
Client authenticationnone, client_secret_basic or client_secret_post. Use the method you chose in step 1.
Scopeopenid email profile
PKCEOn, with S256. Configure refuses an authorize request without it.
Checksstate and nonce
ID token algorithmRS256
Callback URLThe redirect_uri you registered, as the exact same string.

Read the user's claims from the ID token. Do not rely on the userinfo endpoint for the scoped claims. The ID token has no auth_time, at_hash or azp claim. If your library can require them, turn that off.

1. Create a client ​

A client is your app's registration with Configure: an oc_ client id and the callbacks it may return to.

If your callback is on localhost, create the client with your publishable key. No other setup is needed:

bash
curl -X POST https://api.configure.dev/v1/sso/client \
  -H "Content-Type: application/json" \
  -d '{
    "publishable_key": "'"$CONFIGURE_PUBLISHABLE_KEY"'",
    "redirect_uri": "http://localhost:3000/callback"
  }'
json
{ "client_id": "oc_4bN8..." }

If your callback is on your own domain, register its origin once with your secret key:

bash
curl -X POST https://api.configure.dev/v1/sso/origins \
  -H "X-API-Key: $CONFIGURE_API_KEY" \
  -H "X-Agent: $CONFIGURE_AGENT" \
  -H "Content-Type: application/json" \
  -d '{ "origin": "https://yourapp.example" }'
json
{ "ok": true, "origin": "https://yourapp.example" }

Then create the client for that callback. A server-side app can ask for a confidential client:

bash
curl -X POST https://api.configure.dev/v1/sso/client \
  -H "Content-Type: application/json" \
  -d '{
    "publishable_key": "'"$CONFIGURE_PUBLISHABLE_KEY"'",
    "redirect_uri": "https://yourapp.example/callback",
    "token_endpoint_auth_method": "client_secret_basic"
  }'
json
{ "client_id": "oc_Qe71...", "client_secret": "ocs_Hc0v..." }
FieldRequiredNotes
publishable_keyyesYour pk_ key. This call needs no other auth header.
redirect_uriyesYour callback. https, or http on localhost or 127.0.0.1. No wildcards, fragments or credentials.
token_endpoint_auth_methodnonone (the default) for a public client. client_secret_basic or client_secret_post for a confidential client.

The rules:

  • Configure matches redirect_uri as an exact string.
  • A public client has no secret. The same redirect_uri gets the same client id back.
  • A confidential client gets an ocs_ secret. Configure shows the secret once. Store it on your server.
  • The client acts for your oldest active agent. Your agent is the handle that names your app on every request, the CONFIGURE_AGENT that npx configure setup wrote. You cannot choose another agent.
  • The client registers the openid, email and profile scopes.
  • To add a callback, run npx configure sso add-callback <url>. A client holds up to 25. npx configure sso lists them.

A client that you made in the dashboard or with npx configure setup also works. Setup writes its id as CONFIGURE_OAUTH_CLIENT_ID and its secret as CONFIGURE_OAUTH_CLIENT_SECRET. Use them as CONFIGURE_CLIENT_ID and CONFIGURE_CLIENT_SECRET in the example. This client has no OpenID Connect scopes registered. Always send scope=openid email profile with it. Without openid, Configure returns no ID token.

2. Send the user to Configure ​

/login in the example sends the browser to /oauth/authorize with these parameters:

ParameterRequiredValue
response_typeyescode. No other value is accepted.
client_idyesYour oc_ client id.
redirect_uriyesA callback registered on the client.
code_challengeyesThe base64url SHA-256 hash of your PKCE verifier.
code_challenge_methodyesS256. Every client needs PKCE, confidential clients too.
scopesend itopenid email profile. Without it, the client's registered OpenID Connect scopes apply.
staterecommendedA random value for this attempt. Configure sends it back unchanged.
noncerecommendedA random value for this attempt, up to 512 characters. Configure puts it in the ID token.
requirednoWhat the user must connect or import before they continue. See Require a verified mailbox.
resourcenoLeave it out.

Configure ignores prompt, login_hint, max_age and other parameters.

The user then sees the hosted flow, which opens on the import screen. What the hosted flow asks for shows each screen. The user has 15 minutes to finish.

3. Handle the callback ​

When the user finishes, Configure redirects the browser to your callback:

text
http://localhost:3000/callback?code=...&state=...
  • Compare state with the value in your cookie. Refuse the request if they differ. Then clear the cookie.
  • The cookie is SameSite=Lax, so the browser sends it on this redirect from Configure.
  • If the user declines, the callback gets ?error=access_denied&state=... and no code.
  • The redirect has no iss parameter.

4. Exchange the code ​

/callback in the example sends this request. The body is form-encoded, not JSON:

bash
curl -X POST https://api.configure.dev/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=http://localhost:3000/callback" \
  --data-urlencode "client_id=$CONFIGURE_CLIENT_ID" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
json
{
  "access_token": "mcp_at_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "mcp_rt_...",
  "scope": "profile.read profile.search profile.remember profile.commit openid email profile",
  "resource": "https://mcp.configure.dev",
  "id_token": "eyJhbGciOiJSUzI1NiIs..."
}
  • The code lasts 5 minutes and works once. A second exchange of the same code fails.
  • The access token lasts 1 hour.
  • id_token is in the response when openid was in the scope.
  • A confidential client also authenticates. Use Basic auth (-u "$CONFIGURE_CLIENT_ID:$CONFIGURE_CLIENT_SECRET") or send client_secret in the body.

5. Verify the ID token ​

The ID token is a JWT signed with RS256. Its header names the signing key in kid. Check it in this order:

  1. The signature, with the key from https://api.configure.dev/.well-known/jwks.json that matches kid.
  2. iss is https://api.configure.dev.
  3. aud is your client id.
  4. exp is in the future. The token lasts 1 hour.
  5. nonce is the nonce you sent for this attempt.

jwtVerify from jose does the first four checks. The example compares the nonce after it. A verified token decodes to this:

json
{
  "iss": "https://api.configure.dev",
  "sub": "efc419f4-797e-4e19-9615-70c7e2945444",
  "aud": "oc_4bN8...",
  "iat": 1790294400,
  "exp": 1790298000,
  "nonce": "23LG6O47fgh9twF8UxRixuhyGVyhfHvEqM9yg2ArY5A",
  "email": "dana@example.com",
  "name": "Dana Reyes",
  "given_name": "Dana",
  "family_name": "Reyes",
  "email_verified": true
}

6. Read the claims ​

Each scope adds claims to the ID token:

ScopeClaims
openidsub: the Configure user id. It is the same for every client.
emailemail and email_verified, when the account has an email.
profilename, given_name, family_name, picture, birthdate and location, each when the profile holds it.
  • Each claim in this table except sub can be missing. Treat it as optional.
  • There is no phone claim.
  • The location claim is location. Configure sends no locale claim.
  • birthdate holds the text the profile has. Do not parse it as a date.
  • Key your link to the user on sub. The email on an account can change.

What email_verified means ​

email_verified is true only when Configure has proof that the user controls the address:

  • A connected Gmail or Outlook mailbox that owns the address.
  • A six-digit code that Configure emailed to the address.
  • An address that came from Google or GitHub.

It is false for an address that the user typed. An email that another app gave Configure never makes it true. Configure checks again each time it issues an ID token.

Do this in the callback, because the access token lasts 1 hour. Send the access_token from step 4 to POST /v1/profile/link with your secret key and your own user id. Link an OIDC user shows the request and its response.

After the link, read the profile with your own id:

bash
curl https://api.configure.dev/v1/profile \
  -H "X-API-Key: $CONFIGURE_API_KEY" \
  -H "X-Agent: $CONFIGURE_AGENT" \
  -H "X-User-Id: your-own-user-id"

In X-Agent, send the agent that your client acts for. The user approved that agent when they signed in.

Require a verified mailbox ​

The email scope asks the hosted flow for a proven mailbox. If the account has none, the flow asks for Gmail or Outlook after the code.

To ask for the mailbox first, add required to the authorize request. In the example, add this line to /login, after authorize.search is set:

ts
authorize.searchParams.set("required", "gmail|outlook"); // or "email", the short form

The hosted flow then opens on the mailbox page. Its title reads "Connect your apps and assistants to" and your app's name. The line under the title reads "First, connect Gmail or Outlook to secure your account." The page has a Continue with Gmail and a Continue with Outlook button, and no Continue button. The import screen follows, then the phone number and the code.

Smry, a reading app built on Configure, signs its readers in this way. Its authorize request carries required=gmail|outlook, so a new reader sees the mailbox page first.

Check email_verified in your callback anyway. The hosted page enforces required. The approval behind it does not check for an email. Step 6 in the example sets email to null unless email_verified is true.

Refresh tokens ​

You need a refresh token only if your server calls Configure with the access token after the first hour. After step 7, your secret key and X-User-Id reach the profile without it.

bash
curl -X POST https://api.configure.dev/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN" \
  --data-urlencode "client_id=$CONFIGURE_CLIENT_ID"

The response has the same fields as step 4, without id_token.

  • Each refresh returns a new refresh token. Store it, and discard the old one.
  • A refresh token expires after 90 days without use.
  • If a refresh response is lost, you can retry with the same token within 60 seconds.
  • A later reuse of an old refresh token revokes the whole chain. The next refresh fails with invalid_grant.
  • To revoke a token, send it to POST /oauth/revoke as the form field token. The response is 200 with an empty body.

A confidential client authenticates here as in step 4.

Errors ​

/oauth/authorize and /oauth/token answer an error as JSON:

json
{ "error": "invalid_grant", "error_description": "PKCE verification failed." }

An error on /oauth/authorize shows in the user's browser. Configure does not send it to your callback. Only a declined sign-in reaches your callback, as error=access_denied.

Creating a client ​

StatusMessageWhat to do
403Origin <origin> is not approved. Register it once with your secret key: POST /v1/sso/origins.Register the origin, then create the client again.
403Unknown or non-publishable key.Send a pk_ key that is not revoked.
400publishable_key (pk_...) is required.Add publishable_key.
400a valid redirect_uri is required.Send an absolute URL.
400This developer has no active agent to bind SSO to.Create an agent on your account first.
400origin must be an https:// origin (or localhost).Register an https origin.

Signing in ​

errorerror_descriptionWhat to do
invalid_requestcode_challenge_method=S256 is required.Send code_challenge and code_challenge_method=S256.
invalid_clientUnknown OAuth client.Check client_id.
invalid_requestredirect_uri must exactly match client registration. ...Register the callback with npx configure sso add-callback <url>.
access_denied(on your callback)The user declined. Show your sign-in page again.
invalid_grantAuthorization code was already used.A reload replayed the callback. Start again at /login.
invalid_grantAuthorization code expired.More than 5 minutes passed. Start again at /login.
invalid_grantPKCE verification failed.Send the verifier from the same attempt as the challenge.
invalid_grantredirect_uri does not match authorization code.Send the same redirect_uri as on the authorize request.
invalid_clientClient authentication required. (401)A confidential client must send its secret.
invalid_grantRefresh token reuse detected.The chain is revoked. Sign the user in again.

/oauth and /v1/sso share a limit of 100 requests a minute per IP address. Over it, you get 429.

Linking ​

StatuscodeWhat to do
401invalid_access_token: "Invalid or expired Configure access token."Link in the callback, within the hour.
403client_not_owned: "The OAuth client that issued this token does not belong to this developer account."Use the secret key of the account that owns the client.
400link_requires_external_userSend X-User-Id.
400agent_token_not_allowedSend the access token from step 4, not an agent token.

Where this fits ​

Personalization infrastructure for agents