Add Google and GitHub sign-in without writing the OAuth callback

Three endpoints replace the provider dance: list providers, get an authorize URL, exchange the code. What state and redirect_uri still have to be right.

Social sign-in on Infrai is three calls and no provider SDKs. GET /v1/auth/oauth/providers tells you which providers are live, GET /v1/auth/oauth/authorize_url builds the redirect with a state already generated for you, and POST /v1/auth/oauth/callback exchanges the returned code for a session. You never register an app with Google, never store a client secret, never parse an ID token.

The part that surprises people: the consent screen carries Infrai’s brand, not yours. That’s the trade in exchange for skipping provider registration, and it’s the first thing to check against your requirements.

What’s actually available

curl -sS "https://api.infrai.cc/v1/auth/oauth/providers" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "providers": [
      {"id": "google", "ready": true},
      {"id": "github", "ready": true},
      {"id": "apple", "ready": true},
      {"id": "facebook", "ready": true}
    ],
    "consent_brand": "Infrai Account"
  }
}

Four providers, each with a ready flag, plus the consent_brand the user will see.

Read this at boot and render your login buttons from it. Hardcoding the list means a provider that goes live doesn’t appear until someone edits a template, and it also means a provider you never tested keeps its button long after you meant to drop it — both of which are the kind of drift that only shows up in a support ticket from the one customer whose whole company signs in with Apple.

Start the flow

curl -sS -G "https://api.infrai.cc/v1/auth/oauth/authorize_url" \
  --data-urlencode "provider=google" \
  --data-urlencode "redirect_uri=https://app.example.com/auth/callback" \
  --data-urlencode "return_to=/dashboard" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&state=oas_2f9c41bd7a",
    "state": "oas_2f9c41bd7a"
  }
}

provider is an enum — google, github, apple or facebook, and anything else is refused with 400 rather than redirecting somewhere surprising. return_to is your own post-login destination, carried through the flow and handed back at the end, which saves you stuffing it into a cookie.

Store the state against the browser session. You’ll need it in a moment, and it’s the one piece of the handshake you can’t delegate.

Finish it

import express from "express";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const REDIRECT_URI = "https://app.example.com/auth/callback";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const app = express();

app.get("/auth/callback", async (req, res) => {
  const { code, state } = req.query;
  // The state you stored when you built the authorize URL. Compare it here, in
  // your own process — the platform also checks it, but a mismatch caught
  // locally never becomes a request at all.
  if (!state || state !== req.session?.oauthState) {
    return res.status(400).send("state mismatch");
  }
  const upstream = await fetch(`${API}/v1/auth/oauth/callback`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({
      provider: "google",
      code: String(code),
      state: String(state),
      redirect_uri: REDIRECT_URI,
    }),
  });
  const payload = await upstream.json();
  if (!payload.ok) {
    return res.status(401).send(payload.error?.code ?? "oauth_failed");
  }
  const { user_id, access_token, refresh_token, created, return_to, email, name, avatar } = payload.data;
  if (created) console.log(`first sign-in for ${email} (${user_id})`);
  res.cookie("session", access_token, { httpOnly: true, secure: true, sameSite: "lax" });
  res.cookie("refresh", refresh_token, { httpOnly: true, secure: true, sameSite: "strict" });
  res.redirect(return_to ?? "/");
});

app.listen(3000, () => console.log("listening on :3000"));

The response is generous: user_id, session_id, both tokens, expires_at, plus email, name and avatar lifted from the provider profile — so you get the profile fields without a second call to a userinfo endpoint, and the avatar URL is there on the first request rather than arriving later as a second render.

created is the signup signal again — true the first time this provider identity resolves to a new user.

The two fields that cause every bug

redirect_uri must be byte-identical in the authorize call and the callback exchange. A trailing slash difference is a AUTH_OAUTH_REDIRECT_URI_INVALID, and it will look like a platform problem until you diff the two strings.

state must round-trip unchanged. If your framework re-encodes query strings, or you regenerate the session cookie mid-flow, you’ll get AUTH_OAUTH_STATE_MISMATCH — which is the check doing its job, since a missing state check is how CSRF gets into a login flow.

For a public client — a mobile app or an SPA where no secret can be kept — add code_verifier to the callback body and run PKCE.

The field is there for exactly that.

Linking rather than duplicating

A user who signed up with a password and later clicks “Sign in with Google” should land on the same account, not a second one. That’s POST /v1/auth/identity/resolve, which maps an external identity onto an existing user by address. Do that deliberately: silent auto-linking by email is convenient and is also how account takeover happens when a provider’s email isn’t verified.

ConcernThis surfaceRegistering your own OAuth app
Provider setupnoneper provider, per environment
Client secrets to storenoneone per provider
Consent screen brandingInfrai Accountyour brand
Custom scopesnot exposedwhatever you ask for
Extra provider profile dataemail, name, avataranything the scope allows

Where this isn’t the right tool

If the consent screen has to say your company’s name, or you need scopes beyond identity — reading someone’s repositories, calendar or Drive — then this isn’t a good fit and you should register your own app with the provider. Clerk and Auth0 both let you drop in your own client credentials while keeping the rest of their flow; that’s a real advantage and worth paying for if branding is non-negotiable.

Keep this when you want four providers working this afternoon with no secrets in your config, and when the follow-on work matters more than the consent logo. That follow-on is the honest argument: the welcome email after created: true goes out via POST /v1/email/send, the background provisioning job via POST /v1/queue/publish, and both run on the same key that just logged the user in.

All three OAuth routes report billing_class: free in discovery — the handshake isn’t billed per call. Identity is metered by monthly active user instead, the same model Auth0 and Clerk use, and GET /v1/account/usage is the live read for your own account (verified 2026-09-21). Platform rates move down over time, so trust that call over any number written in a guide.

References

Browse more auth developer guides