Better I18NBetter I18N

Authorization Flow

Step-by-step OAuth 2.0 authorization code flow with PKCE.

1. Build the authorize URL

Redirect the user to Better i18n's authorization endpoint:

https://dash.better-i18n.com/api/auth/mcp/authorize
  ?response_type=code
  &client_id=<your_client_id>
  &redirect_uri=<urlencoded_redirect_uri>
  &scope=org:read+projects:read+keys:read+keys:write+translations:write
  &state=<csrf_token>
  &code_challenge=<base64url_sha256_of_verifier>
  &code_challenge_method=S256

Use the narrowest scope set you need. Destructive scopes (translations:publish, projects:write, glossary:write) require an explicit user click and show a yellow warning on the consent screen.

2. User consents

The user sees our consent screen where they:

  • Pick one or more organizations they can administer
  • Toggle individual permissions on/off
  • Click Authorize

After consenting, the user lands on a "Connection complete" screen with a Continue to your app button. When they click it, we redirect to your redirect_uri with ?code=...&state=....

3. Exchange code for tokens

curl -X POST https://dash.better-i18n.com/api/auth/mcp/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=<the_code>" \
  --data-urlencode "redirect_uri=<same_as_step_1>" \
  --data-urlencode "client_id=<your_client_id>" \
  --data-urlencode "client_secret=<your_client_secret>"

client_secret is required for all confidential (server-side) clients. The server rejects the exchange without it. If you're using a PKCE-only public client, send code_verifier instead — see the SPA flow below.

Response

{
  "access_token":  "eyJhbGciOi...",
  "token_type":    "Bearer",
  "expires_in":    1800,
  "refresh_token": "rt_...",
  "grant_id":      "grt_8f3c..."
}

Persist the grant_id. You need it for every installation token mint and every revoke. Store it alongside the refresh_token in your database.

Full Node.js example

routes/connect.ts
// Start the flow
app.get("/connect/start", async (c) => {
  const state = crypto.randomUUID();

  await kv.set(`oauth_state:${state}`, { userId: c.user.id }, {
    expirationTtl: 600,
  });

  const url = new URL(
    "https://dash.better-i18n.com/api/auth/mcp/authorize",
  );
  url.searchParams.set("response_type", "code");
  url.searchParams.set("client_id", env.BETTER_I18N_CLIENT_ID);
  url.searchParams.set("redirect_uri", `${env.PUBLIC_URL}/connect/callback`);
  url.searchParams.set("scope", "org:read projects:read keys:write translations:write");
  url.searchParams.set("state", state);
  return c.redirect(url.toString());
});

// Handle callback
app.get("/connect/callback", async (c) => {
  const code = c.req.query("code");
  const state = c.req.query("state");
  const stored = await kv.get(`oauth_state:${state}`);
  if (!code || !stored) return c.text("Invalid state", 400);
  await kv.delete(`oauth_state:${state}`);

  const res = await fetch(
    "https://dash.better-i18n.com/api/auth/mcp/token",
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code,
        redirect_uri: `${env.PUBLIC_URL}/connect/callback`,
        client_id: env.BETTER_I18N_CLIENT_ID,
        client_secret: env.BETTER_I18N_CLIENT_SECRET,
      }),
    },
  );

  const tok = await res.json();
  await db.insert("connections", {
    userId: stored.userId,
    grantId: tok.grant_id,
    refreshToken: tok.refresh_token,
  });

  return c.redirect("/settings?connected=better-i18n");
});

SPA-only PKCE flow

This section applies only to public clients (type spa or native) that cannot store a client_secret. Public clients must be explicitly requested during partner onboarding — the default is a confidential web client with a secret.

If your app has no server (browser-only SPA, Electron, etc.), run the entire exchange in the browser. PKCE makes this safe — the code_verifier never leaves the popup, and the access token is short-lived.

lib/oauth.ts
const BASE_URL = import.meta.env.VITE_BETTER_I18N_URL ?? "https://dash.better-i18n.com";
const CLIENT_ID = import.meta.env.VITE_BETTER_I18N_CLIENT_ID;

// 1. Generate PKCE pair, persist verifier across the popup round-trip
async function buildAuthUrl(): Promise<string> {
  const verifier = crypto.randomUUID() + crypto.randomUUID();
  const challenge = await sha256Base64Url(verifier);
  const state = crypto.randomUUID();

  localStorage.setItem("pkce", JSON.stringify({ verifier, state }));

  const params = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: `${window.location.origin}/oauth/callback`,
    scope: "org:read projects:read keys:read keys:write translations:write",
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  });
  return `${BASE_URL}/api/auth/mcp/authorize?${params}`;
}

// 2. Open the popup
function startConnect() {
  const url = await buildAuthUrl();
  window.open(url, "better-i18n-oauth", "width=520,height=720");
}

// 3. Handle callback at /oauth/callback (popup page)
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get("code");
  const state = params.get("state");

  const stored = JSON.parse(localStorage.getItem("pkce") ?? "{}");
  localStorage.removeItem("pkce");
  if (!code || state !== stored.state) {
    window.opener?.postMessage({ type: "oauth-error", error: "state_mismatch" }, window.location.origin);
    return;
  }

  const res = await fetch(`${BASE_URL}/api/auth/mcp/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: `${window.location.origin}/oauth/callback`,
      client_id: CLIENT_ID,
      code_verifier: stored.verifier,
    }),
  });

  if (!res.ok) {
    window.opener?.postMessage({ type: "oauth-error", error: "exchange_failed" }, window.location.origin);
    return;
  }

  const tokens = await res.json();
  // Send tokens to the parent window (same-origin postMessage), then close
  window.opener?.postMessage({ type: "oauth-success", tokens }, window.location.origin);
  window.close();
}

async function sha256Base64Url(input: string): Promise<string> {
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

Pin postMessage to your own originwindow.opener.postMessage(data, window.location.origin). A wildcard target ("*") leaks tokens to whatever site is currently in the opener tab.

In the parent window, listen for the popup's message and ship the refresh_token + grant_id to your backend. Don't keep them in localStorage long-term; persist server-side and treat anything in the browser as ephemeral.

Next step

Now mint an installation token to start calling APIs.

On this page