Skip to content
rw3iss Auth

Sign in with {Provider}

A complete “Sign in with {Provider}” button, built against a deployment of this auth-server. It covers all three paths and, more importantly, when each one applies — because shipping only one of them is the mistake that produces a button that works on the developer’s machine and nowhere else.

Nothing here hard-codes a provider name, an icon, or a set of available methods. All three are read at runtime from the provider document.

The shape of it

1. GET /api/v1/provider → name, icon, colours, methods[]
└── render the button
2. user clicks
3. methods has "fedcm" + browser supports it? → navigator.credentials.get()
↓ unavailable / dismissed / failed
4. methods has "oidc"? → popup or redirect + PKCE
↓ not offered
5. first-party app only? → password form
6. verify the token SERVER-SIDE, create your own session

Steps 3 and 4 are not alternatives you choose between. A correct integration attempts 3 and falls through to 4 — that fall-through is the normal path for every Safari and Firefox user you have.

Which path applies

FedCMOIDC popupOIDC redirectPassword
Third-party RPBestYesYesNo
Your own first-party appYesYesYesYes
Server-rendered / no JSNoNoYesYes
Safari · FirefoxNoYesYesYes
Keeps page state (no navigation)YesYesNoYes
Silent for a returning userYes, from visit 2NoNoNo
RP ever sees the passwordNoNoNoYes

The password path is for first-party apps only. It means the RP handles the user’s provider credentials, which is exactly the thing federation exists to avoid. If you do not own both sides, use OIDC.

0. Register the relying party

One-time, by a platform admin. FedCM and OIDC share this registration — there is no separate FedCM step.

Terminal window
curl -X POST https://auth.testorgname.com/api/v1/admin/oauth/clients \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"client_id": "rp-demo",
"name": "Demo RP",
"logo_url": "https://rp.example/logo.png",
"redirect_uris": ["https://rp.example/auth/callback"],
"post_logout_uris": ["https://rp.example/"],
"allowed_scopes": ["openid", "profile", "email"],
"public": true
}'

Three things that bite here:

  • redirect_uris is matched byte-for-byte. No wildcards, no prefix match, no trailing-slash tolerance. See the redirect rule.
  • redirect_uris is also the FedCM origin allow-list. FedCM never redirects, but the origins of these URIs are what authorise your page to ask.
  • "public": true for a browser app: no secret is issued and PKCE protects the exchange instead. A secret shipped to a browser is not a secret. If you do take one (server-side RP), it is returned exactly once and cannot be read back.

1. Read the provider document, render the button

const AUTH_ORIGIN = 'https://auth.testorgname.com';
const CLIENT_ID = 'rp-demo';
const provider = await fetch(`${AUTH_ORIGIN}/api/v1/provider`)
.then(r => r.json());
// { slug, name, shortName, iconUrl, backgroundColor, color, issuer, methods }
function renderButton(provider, narrow) {
const label = narrow ? (provider.shortName || provider.name) : provider.name;
const btn = document.createElement('button');
btn.className = `signin signin--${provider.slug}`;
btn.style.background = provider.backgroundColor || '';
btn.style.color = provider.color || '';
btn.innerHTML = `${provider.iconUrl ? `<img src="${provider.iconUrl}" alt="" width="20" height="20">` : ''}
<span>Sign in with ${label}</span>`;
btn.onclick = () => signIn(provider);
return btn;
}

Optional fields are omitted, not empty — hence the || fallbacks. slug is the machine id (config key, CSS hook); name / shortName is what a human reads. Never display the slug.

Cache the document if you like — it is served max-age=300 — but read it at least once per deployment rather than baking the values in. That is the whole point: the button follows the provider.

2. Path A — FedCM

Try it first when both the server offers it and the browser has it.

async function tryFedCM(provider) {
if (!provider.methods.includes('fedcm')) return null; // server doesn't offer it
if (!('IdentityCredential' in window)) return null; // browser doesn't have it
const nonce = crypto.randomUUID();
sessionStorage.setItem('signin_nonce', nonce);
try {
const cred = await navigator.credentials.get({
identity: {
providers: [{
configURL: `${provider.issuer}/fedcm/config.json`,
clientId: CLIENT_ID,
nonce,
}],
},
});
return cred?.token ?? null; // an RS256 id_token
} catch (err) {
// Dismissed, unsupported, no session, not registered — all the same
// to us: this path is unavailable, use the next one.
console.debug('FedCM unavailable:', err?.name);
return null;
}
}

Two limits to design around, both covered in FedCM → the limits:

  • First contact always needs a tap. There is no silent identity for a new visitor to your site. If your UX assumes “the page loads and we already know who you are”, it will not work.
  • Returning users can be auto-reauthenticated. Once someone has granted your RP through FedCM, later visits can resolve without a tap. That is the payoff, and it arrives on visit two.

If nothing happens at all in Chrome with a valid session, the cause is almost always one of three silent misconfigurations at the provider, not in this code: SameSite, the login-status bit, or the eTLD+1 well-known file.

3. Path B — OIDC popup or redirect

The universal fallback. Works in every browser, and it is the path most of your users will actually take today.

Both variants are the same authorization-code + PKCE flow; they differ only in where it happens. Popup keeps the page’s state — good for a signed-out modal on a busy page. Redirect is the only one that works with no JavaScript, in a server-rendered app, or inside an embedded webview that blocks popups.

PKCE

const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
async function pkce() {
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return { verifier, challenge: b64url(digest) };
}

S256 only — the server refuses plain. PKCE is required for public clients and is on by default for everything else.

Build the authorization URL

async function authorizeUrl(provider) {
const { verifier, challenge } = await pkce();
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('signin_state', state);
sessionStorage.setItem('signin_nonce', nonce);
const qs = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: 'https://rp.example/auth/callback', // EXACT match
response_type: 'code',
scope: 'openid profile email',
state, nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return `${provider.issuer}/api/v1/oauth/authorize?${qs}`;
}

The redirect_uri must be byte-identical to a registered one. A trailing slash is a different URI.

Redirect variant

async function signInRedirect(provider) {
location.assign(await authorizeUrl(provider));
}

The provider bounces back to /auth/callback?code=…&state=…. Compare state against the stored one, then exchange the code (step 4).

async function signInPopup(provider) {
const url = await authorizeUrl(provider);
const win = window.open(url, 'signin', 'width=480,height=720');
if (!win) return signInRedirect(provider); // popup blocked → redirect
return new Promise((resolve, reject) => {
const onMessage = (e) => {
if (e.origin !== location.origin) return; // same-origin callback page only
if (e.data?.type !== 'signin_result') return;
window.removeEventListener('message', onMessage);
clearInterval(poll);
e.data.code ? resolve(e.data) : reject(new Error(e.data.error));
};
window.addEventListener('message', onMessage);
const poll = setInterval(() => {
if (win.closed) { // user closed it
clearInterval(poll);
window.removeEventListener('message', onMessage);
reject(new Error('closed'));
}
}, 500);
});
}

Your /auth/callback page — on your origin, since it is your registered redirect URI — posts the result back to the opener:

<script>
const p = new URLSearchParams(location.search);
window.opener?.postMessage({
type: 'signin_result',
code: p.get('code'),
state: p.get('state'),
error: p.get('error'),
}, location.origin);
window.close();
</script>

Always check e.origin on the receiving end, and always pass an explicit target origin to postMessage — never '*', which would broadcast an authorization code to any frame listening.

4. Exchange the code

POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<the code>
&redirect_uri=https://rp.example/auth/callback
&client_id=rp-demo
&code_verifier=<the stored verifier>
{ "access_token": "…", "refresh_token": "…", "id_token": "…",
"token_type": "Bearer", "expires_in": 900, "scope": "openid profile email" }

Read the returned scope — it is what was granted, which may be narrower than what you asked for.

A confidential (server-side) client adds its secret here via client_secret_post or client_secret_basic. A public client sends none and relies on the code_verifier.

5. Path C — password

Only when you are first-party — your app, the provider’s own users, one operator. The RP handles the credentials, so this is not a federation path at all; it is the ordinary login documented in User registration & login.

import { createAuthClient } from '@rw3iss/auth-client';
const client = createAuthClient({
apiBaseUrl: `${AUTH_ORIGIN}/api/v1`,
appCode: 'my-app',
});
await client.ready();
await client.auth.login({ email, password });

provider.methods always contains password, so its presence tells you nothing about whether you should offer it. That is your call, and it turns on whether you are first-party.

If your login page is the provider’s own login UI and lives on a different origin from the auth server, it must pass cookie_mode: true to get the cross-site session cookie FedCM later depends on — and that only works from a first-party origin. See condition 2.

6. Put it together

async function signIn(provider) {
// A — the browser API, when both sides have it.
const fedcmToken = await tryFedCM(provider);
if (fedcmToken) return completeSignIn({ idToken: fedcmToken });
// B — the universal fallback.
if (provider.methods.includes('oidc')) {
const { code, state } = await signInPopup(provider);
if (state !== sessionStorage.getItem('signin_state')) {
throw new Error('state mismatch'); // possible CSRF — stop
}
return completeSignIn({ code, verifier: sessionStorage.getItem('pkce_verifier') });
}
// C — first-party only.
return showPasswordForm(provider);
}

completeSignIn posts to your own backend. Path A hands you an id_token directly; path B hands you a code your backend exchanges.

7. Verify server-side — the step that matters

FedCM → id_token straight from the browser
OIDC → code → POST /oauth/token → id_token
↓
verify on YOUR server, then create YOUR session

Whatever the path, the id_token is verified on your server:

  1. Read kid from the JWT header.
  2. Fetch ${issuer}/.well-known/jwks.json (cache by kid; re-fetch on an unknown one). RS256, 2048-bit RSA.
  3. Verify the signature.
  4. Check iss == the provider’s issuer, aud == your client_id, exp is in the future, and nonce equals the one you generated.
  5. sub is the user’s stable id at this provider. Key your own account record on (issuer, sub) — not on the email, which can change.
  6. Create your own session. Then throw the id_token away.

Every one of those checks is load-bearing. A token whose signature you did not verify is a string the user typed; without the aud check, a token minted for a different RP is accepted; without nonce, an old token can be replayed.

8. Signing out

GET {issuer}/api/v1/oauth/logout
?client_id=rp-demo
&post_logout_redirect_uri=https://rp.example/

Destroy your own session first — that is the one that matters to your app. post_logout_redirect_uri must be in the client’s post_logout_uris (exact match); anything else gets a JSON {"status":"logged_out"} instead of a redirect, rather than becoming an open redirect.

To make the browser forget that this person connected your RP via FedCM, that is POST /fedcm/disconnect — issued by the browser, not by you. It clears the consent without signing them out of the provider.

Troubleshooting

SymptomCause
FedCM never shows a dialog, valid session existsProvider-side: SameSite, the login-status bit, or the eTLD+1 well-known. Check in that order.
403 from every /fedcm/* in curlMissing Sec-Fetch-Dest: webidentity. Working as designed.
400 INVALID_INPUT, field redirect_uriNot byte-identical to a registered URI — usually a trailing slash.
400 INVALID_INPUT, field client_idUnknown or inactive client.
400 invalid_grant at the token endpointCode expired/reused, or a redirect_uri differing from the authorize call.
invalid_request mentioning code_challenge_methodAnything but S256.
Granted scopes narrower than requestedIntersected with the client’s allowed_scopes and the user’s consent. Read the response’s scope.
Signature verification always failsVerifying an RS256 id_token with the HS256 shared secret.
  • Provider identity — the document driving step 1.
  • FedCM — path A in full, with its limits and provider-side prerequisites.
  • OIDC provider — paths B’s endpoints, scopes, and client registration.
  • auth-client — the browser SDK, including OidcClientManager for administering relying parties.