Users and security Advanced
Sign in with OAuth
Add "Sign in with Google" to the Task API with @zudojs/auth-oauth. Learn the OAuth 2 authorization code flow, state and PKCE, the provider presets, and the callback, all tested offline against a stand-in provider.
Let someone else check the password
In the Authentication lesson the Task API checked passwords itself. Many users would rather not create one more password. With "Sign in with Google", Google checks who they are and tells your server. Your server never sees their Google password.
The standard behind those buttons is OAuth 2. Four parties take part:
- the user, in a web browser;
- your app, the Task API. OAuth calls it the client;
- the provider, for example Google, which knows the user;
- the provider's API, which returns the user's profile.
The flow you will build is the authorization code flow. It has six steps:
- The user clicks "Sign in with Google". Your server builds a long URL on Google and redirects the browser there.
- The user signs in at Google, on Google's own page.
- Google asks: "Task API wants your email and name. Allow?" The user says yes.
- Google redirects the browser back to your callback URL with a short-lived
codein the query string. - Your server sends the code, plus its own client secret, directly to Google and gets an access token back.
- Your server uses the access token to ask Google for the user's profile. Then it creates its own session, exactly like after a password login.
Steps 5 and 6 happen server to server, so the access token and the client secret never pass through the browser. That is the point of the flow. @zudojs/auth-oauth does steps 1, 5 and 6, and adds two protections that you will study in detail: state and PKCE.
Install and register your app
In your task-api folder:
npm install @zudojs/auth-oauth added 1 package, and audited 12 packages in 4s found 0 vulnerabilities
To use a real provider you register your app with it once. For Google that is in the Google Cloud console under "APIs & Services", "Credentials", "OAuth client ID", type "Web application". You get two values:
- a client id. It is public: it appears in the URL of step 1;
- a client secret. It proves that a request really comes from your server. Treat it like the JWT secrets:
.envonly, never in code or git.
You also enter the exact redirect URI (the callback URL) there. For local development this lesson uses http://localhost:3000/auth/google/callback.
You do not need a Google account for this page. Every example runs offline against a small stand-in for Google, and the package never contacts the internet unless you ask it to.
The built-in providers
The package knows the addresses and habits of five providers. Print what it ships:
import { PROVIDER_PRESETS } from "@zudojs/auth-oauth";
for (const [name, preset] of Object.entries(PROVIDER_PRESETS)) {
console.log(name, {
login: preset.authorizeUrl ?? "(you supply it)",
scopes: preset.defaultScopes.join(" "),
secretSentIn: preset.clientAuth,
refresh: preset.supportsRefresh,
profile: preset.userInfoUrl ?? "none",
});
}
npx tsx providers.tsgoogle {
login: 'https://accounts.google.com/o/oauth2/v2/auth',
scopes: 'openid email profile',
secretSentIn: 'body',
refresh: true,
profile: 'https://openidconnect.googleapis.com/v1/userinfo'
}
github {
login: 'https://github.com/login/oauth/authorize',
scopes: 'read:user user:email',
secretSentIn: 'body',
refresh: false,
profile: 'https://api.github.com/user'
}
microsoft {
login: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
scopes: 'openid email profile offline_access User.Read',
secretSentIn: 'body',
refresh: true,
profile: 'https://graph.microsoft.com/v1.0/me'
}
apple {
login: 'https://appleid.apple.com/auth/authorize',
scopes: 'name email',
secretSentIn: 'body',
refresh: true,
profile: 'none'
}
discord {
login: 'https://discord.com/oauth2/authorize',
scopes: 'identify email',
secretSentIn: 'basic',
refresh: true,
profile: 'https://discord.com/api/v10/users/@me'
}
custom {
login: '(you supply it)',
scopes: '',
secretSentIn: 'body',
refresh: true,
profile: 'none'
}- Scopes are what you ask the user to allow.
openid email profilemeans "who you are, your email, your name and picture". Ask for as little as you need. secretSentInis how the client secret travels in step 5: as a form field (body) or in an HTTP BasicAuthorizationheader (basic). Never in a URL, where logs would keep it.- GitHub gives no refresh tokens and Apple has no profile endpoint. You will see both errors at the end of this lesson.
customis for any other OAuth 2 server: you supply its three URLs yourself.
Configuration from the environment
Every function in the package takes a plain configuration object. Build it in one place, reading both values from the environment:
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is not set. Add it to your .env file.`);
}
return value;
}
import type { FetchLike, OAuthConfig } from "@zudojs/auth-oauth";
import { requireEnv } from "./env.js";
export const CALLBACK_URL = "http://localhost:3000/auth/google/callback";
export function googleConfig(fetch?: FetchLike): OAuthConfig {
return {
provider: "google",
clientId: requireEnv("GOOGLE_CLIENT_ID"),
clientSecret: requireEnv("GOOGLE_CLIENT_SECRET"),
allowedRedirectUris: [CALLBACK_URL],
...(fetch === undefined ? {} : { fetch }),
};
}
allowedRedirectUrisis an allow-list. The package refuses to send Google any other callback address, so nobody can trick your server into sending users, and their codes, somewhere else.- The optional
fetchreplaces the function the package uses to call the provider. Your app never passes it. The examples on this page pass the stand-in for Google through it.
Without the environment variables:
import { createAuthorizationUrl, generateState } from "@zudojs/auth-oauth";
import { CALLBACK_URL, googleConfig } from "./google.js";
try {
googleConfig();
} catch (error) {
console.log("our check:", (error as Error).message);
}
try {
createAuthorizationUrl(
{
provider: "google",
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
allowedRedirectUris: [CALLBACK_URL],
},
{ state: generateState(), redirectUri: CALLBACK_URL },
);
} catch (error) {
console.log("the package:", (error as Error).name, "-", (error as Error).message);
}
npx tsx missing-env.tsour check: GOOGLE_CLIENT_ID is not set. Add it to your .env file. the package: OAuthConfigurationError - clientId is required and must be a non-empty string.
The package refuses to work with an empty client id or secret, even for step 1, which does not send the secret. Like in the last lesson, the other examples on this page import a demo-only file first, so they run without your .env:
/*
* DEMO ONLY. Lets the examples on this page run without a Google account.
* The client id is a made-up public name and the secret is random for this run,
* so nothing here can talk to the real Google. Never copy this into your app.
*/
import { randomBytes } from "node:crypto";
process.env.GOOGLE_CLIENT_ID ??= "task-api-demo.apps.googleusercontent.com";
process.env.GOOGLE_CLIENT_SECRET ??= randomBytes(24).toString("base64url");
process.env.JWT_ACCESS_SECRET ??= randomBytes(32).toString("base64url");
process.env.JWT_REFRESH_SECRET ??= randomBytes(32).toString("base64url");
Step 1: the authorization URL
import "./demo-env.js";
import { createAuthorizationUrl, generateState } from "@zudojs/auth-oauth";
import { CALLBACK_URL, googleConfig } from "./google.js";
const start = createAuthorizationUrl(googleConfig(), {
state: generateState(),
redirectUri: CALLBACK_URL,
});
const url = new URL(start.url);
console.log(url.origin + url.pathname);
for (const [name, value] of url.searchParams) {
console.log(` ${name} = ${value}`);
}
console.log("keep on the server:", Object.keys(start));
console.log("secret in the URL?", start.url.includes(process.env.GOOGLE_CLIENT_SECRET!));
console.log("verifier in the URL?", start.url.includes(start.codeVerifier));
npx tsx start.tshttps://accounts.google.com/o/oauth2/v2/auth access_type = offline prompt = consent response_type = code client_id = task-api-demo.apps.googleusercontent.com redirect_uri = http://localhost:3000/auth/google/callback scope = openid email profile state = GN0lrhektq-3AHYlpviVIs4koaN7HFrelwr9ewQNlRE code_challenge = nmrvjNoGTT0fjHO9-dkBYOrAzZ1V_h7aH1zwI9oXiFA code_challenge_method = S256 keep on the server: [ 'url', 'state', 'codeVerifier', 'codeChallenge' ] secret in the URL? false verifier in the URL? false
The browser will see this URL, so everything in it is public:
response_type=codeasks for the authorization code flow.client_id,redirect_uriandscopesay who is asking, where to come back to, and for what.access_type=offlineandprompt=consentcome from the Google preset. They make Google issue a refresh token.stateandcode_challengeare the two protections, explained next. They are random, so yours will differ.generateState()makes 43 random characters;createAuthorizationUrlrefuses a state shorter than 16, because a short one could be guessed.- The client secret and the
codeVerifierare not in the URL. The function returns the verifier to you, and you keep it on the server until step 5.
State: is this callback for a login I started?
Your callback URL is public. Anyone can make a browser open /auth/google/callback?code=.... Here is the attack that state prevents: an attacker signs in at Google with their own account, stops before the callback, and sends you a link with their code. If your server accepted it, you would be logged in to the attacker's account, and everything you then saved would be theirs to read. This is login CSRF (cross-site request forgery).
The fix: before step 1 your server creates a random state, stores it on the server for this browser, and sends it to Google. Google returns it unchanged in step 4. A callback whose state does not match a login this browser started is rejected. Each pending login is also used once and expires after 10 minutes:
import { randomBytes } from "node:crypto";
import { OAuthStateMismatchError, verifyState } from "@zudojs/auth-oauth";
interface PendingLogin {
readonly state: string;
readonly codeVerifier: string;
readonly expiresAt: number;
}
const pending = new Map<string, PendingLogin>();
const TEN_MINUTES = 10 * 60 * 1000;
export function savePendingLogin(state: string, codeVerifier: string): string {
const loginId = randomBytes(32).toString("base64url");
pending.set(loginId, { state, codeVerifier, expiresAt: Date.now() + TEN_MINUTES });
return loginId;
}
export function takeVerifiedLogin(loginId: string | undefined, receivedState: string): string {
const login = loginId === undefined ? undefined : pending.get(loginId);
if (loginId !== undefined) pending.delete(loginId);
if (login === undefined || login.expiresAt < Date.now() || !verifyState(login.state, receivedState)) {
throw new OAuthStateMismatchError();
}
return login.codeVerifier;
}
savePendingLoginstores the state and the PKCE verifier under a randomloginId. The browser only gets theloginId, in an HttpOnly cookie. The state and the verifier never leave the server.takeVerifiedLogindeletes the entry before checking it, so the same callback can never be used twice, even when the check fails.verifyStatecompares in constant time, so an attacker cannot guess the state one character at a time by measuring response times. It returnsfalsefor an empty or missing value. It does not throw, so you throwOAuthStateMismatchErroryourself.
Now try a forged state, a missing cookie, an empty state, the real callback, and a replay of it:
import { OAuthStateMismatchError, generateState } from "@zudojs/auth-oauth";
import { savePendingLogin, takeVerifiedLogin } from "./pending.js";
function callback(label: string, loginId: string | undefined, state: string): void {
try {
console.log(label, "-> ok, verifier", takeVerifiedLogin(loginId, state));
} catch (error) {
if (error instanceof OAuthStateMismatchError) console.log(label, "->", error.statusCode, error.message);
}
}
const state = generateState();
const loginId = savePendingLogin(state, "the-verifier");
callback("forged state", savePendingLogin(state, "v"), generateState());
callback("no login cookie", undefined, state);
callback("empty state", savePendingLogin(state, "v"), "");
callback("real callback", loginId, state);
callback("same callback again", loginId, state);
npx tsx try-state.tsforged state -> 400 OAuth state verification failed. no login cookie -> 400 OAuth state verification failed. empty state -> 400 OAuth state verification failed. real callback -> ok, verifier the-verifier same callback again -> 400 OAuth state verification failed.
Only the real callback passes, and only once. All failures give the same message, so an attacker learns nothing about which check failed.
COMMON MISTAKE
Comparing the state with=== against a value from a cookie that the browser also sends back. If both sides come from the browser, an attacker controls both. Keep the state on the server, like pending.ts does.PKCE: is this the server that started the login?
The code travels through the browser in step 4, and a URL can leak: browser history, a proxy log, a malicious browser extension. PKCE (Proof Key for Code Exchange, say "pixy") makes a stolen code useless:
- Before step 1 your server creates a random secret, the code verifier.
- It sends only its SHA-256 hash, the code challenge, in the URL. Google remembers it with the code.
- In step 5 your server sends the verifier itself. Google hashes it and compares it with the challenge. A thief has the code but not the verifier.
The challenge is base64url(SHA-256(verifier)). The standard that defines PKCE, RFC 7636, contains an example pair. Check the package against it, and against Node's own SHA-256:
import { createHash } from "node:crypto";
import { assertValidCodeVerifier, deriveCodeChallenge, generateCodeVerifier, OAuthError } from "@zudojs/auth-oauth";
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
console.log("package: ", deriveCodeChallenge(verifier));
console.log("by hand: ", createHash("sha256").update(verifier).digest("base64url"));
console.log("RFC 7636: ", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
const mine = generateCodeVerifier();
console.log(mine.length, "characters,", deriveCodeChallenge(mine) === deriveCodeChallenge(generateCodeVerifier()));
try {
assertValidCodeVerifier("too-short");
} catch (error) {
if (error instanceof OAuthError) console.log(error.code, "-", error.message);
}
npx tsx pkce.tspackage: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM by hand: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM RFC 7636: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM 64 characters, false OAUTH_PKCE_INVALID - PKCE code verifier must be 43-128 characters from the unreserved alphabet.
All three agree. A generated verifier is 64 random characters, and two verifiers never give the same challenge. The package only implements the S256 method. The older plain method sends the verifier itself in the URL, which protects nothing, so you cannot turn it on.
Steps 4 to 6: code, token, profile
To test the rest without the internet, here is a small stand-in for Google. signInAtGoogle plays steps 2 to 4: it takes the authorization URL and returns the callback URL with a new code. fakeGoogleFetch answers the token and profile requests, and checks PKCE exactly like Google: the verifier must hash to the challenge that came with the code, and each code works once.
import { createHash, randomBytes } from "node:crypto";
import type { FetchLike } from "@zudojs/auth-oauth";
/* A tiny stand-in for Google, for testing only. It never touches the network. */
const codes = new Map<string, { challenge: string; redirectUri: string }>();
const accessTokens = new Set<string>();
export function signInAtGoogle(authorizationUrl: string): string {
const params = new URL(authorizationUrl).searchParams;
const code = randomBytes(16).toString("hex");
const redirectUri = params.get("redirect_uri") ?? "";
codes.set(code, { challenge: params.get("code_challenge") ?? "", redirectUri });
return `${redirectUri}?code=${code}&state=${params.get("state")}`;
}
export const fakeGoogleFetch: FetchLike = async (url, init) => {
if (url === "https://oauth2.googleapis.com/token") {
const form = new URLSearchParams(String(init.body));
const grant = codes.get(form.get("code") ?? "");
codes.delete(form.get("code") ?? "");
const proof = createHash("sha256").update(form.get("code_verifier") ?? "").digest("base64url");
if (!grant || grant.challenge !== proof || grant.redirectUri !== form.get("redirect_uri")) {
return Response.json({ error: "invalid_grant" }, { status: 400 });
}
const accessToken = randomBytes(16).toString("hex");
accessTokens.add(accessToken);
return Response.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3599 });
}
const auth = new Headers(init.headers).get("authorization") ?? "";
if (!accessTokens.has(auth.replace("Bearer ", ""))) {
return Response.json({ error: "invalid_token" }, { status: 401 });
}
return Response.json({ sub: "10769150350006150715113082367", email: "ada@example.com", email_verified: true, name: "Ada Lovelace" });
};
Now the real package calls, exchangeCodeForToken (step 5) and fetchUserInfo (step 6), first with a wrong verifier, then correctly, then with the same code again:
import "./demo-env.js";
import { createAuthorizationUrl, exchangeCodeForToken, fetchUserInfo, generateState, OAuthProviderError } from "@zudojs/auth-oauth";
import { fakeGoogleFetch, signInAtGoogle } from "./fake-google.js";
import { CALLBACK_URL, googleConfig } from "./google.js";
const config = googleConfig(fakeGoogleFetch);
const start = createAuthorizationUrl(config, { state: generateState(), redirectUri: CALLBACK_URL });
const callback = new URL(signInAtGoogle(start.url));
const code = callback.searchParams.get("code") ?? "";
console.log("back at", callback.pathname, "with", [...callback.searchParams.keys()]);
try {
await exchangeCodeForToken(config, { code, codeVerifier: "x".repeat(64), redirectUri: CALLBACK_URL });
} catch (error) {
if (error instanceof OAuthProviderError) console.log("wrong verifier ->", error.message);
}
const second = new URL(signInAtGoogle(start.url)).searchParams.get("code") ?? "";
const tokens = await exchangeCodeForToken(config, { code: second, codeVerifier: start.codeVerifier, redirectUri: CALLBACK_URL });
console.log("token type:", tokens.tokenType, "expires in:", tokens.expiresIn);
const profile = await fetchUserInfo(config, tokens.accessToken);
console.log(profile.providerId, profile.email, profile.emailVerified, profile.name);
try {
await exchangeCodeForToken(config, { code: second, codeVerifier: start.codeVerifier, redirectUri: CALLBACK_URL });
} catch (error) {
if (error instanceof OAuthProviderError) console.log("same code again ->", error.statusCode, error.providerError);
}
npx tsx exchange.tsback at /auth/google/callback with [ 'code', 'state' ] wrong verifier -> The token endpoint returned HTTP 400 (invalid_grant). token type: Bearer expires in: 3599 10769150350006150715113082367 ada@example.com true Ada Lovelace same code again -> 502 invalid_grant
- With a wrong verifier the provider refuses the code. That is what happens to a thief who has only the code. Like Google, the stand-in then throws the code away, so the example signs in a second time.
fetchUserInfoturns each provider's own profile format into one shape:providerId,email,emailVerified,name,avatarUrl.- Provider errors are
OAuthProviderErrorwith status 502 (Bad Gateway: a service behind you failed). The message contains only the short OAuth error code, never a secret, a token or the provider's free-text description.
The whole flow in the Task API
After step 6 you know that Google vouches for this person. Now your app takes over. First, find or create the local account. Key it by provider and provider id, not by email: emails change, and an email that is not verified proves nothing. The role is chosen by your server, as always:
import { toUserId } from "@zudojs/auth";
import type { AuthUser, UserId } from "@zudojs/auth";
import type { OAuthProvider, OAuthUserInfo } from "@zudojs/auth-oauth";
const byProviderId = new Map<string, AuthUser>();
export function findOrCreateAccount(provider: OAuthProvider, profile: OAuthUserInfo): AuthUser {
const key = `${provider}:${profile.providerId}`;
const existing = byProviderId.get(key);
if (existing !== undefined) return existing;
const user: AuthUser = {
id: toUserId(`u-${byProviderId.size + 1}`),
email: profile.emailVerified === true && profile.email !== undefined ? profile.email : "",
name: profile.name,
roles: ["user"],
active: true,
createdAt: new Date(),
};
byProviderId.set(key, user);
return user;
}
export async function findAccountById(id: UserId): Promise<AuthUser | null> {
return [...byProviderId.values()].find((user) => user.id === id) ?? null;
}
Second, start a session. @zudojs/auth's login() needs a password, which an OAuth user does not have. auth.createSessionForUser(userId, { method }) creates the session and its tokens for a user your code has already authenticated in another way. The auth service then checks these tokens, and logs them out, exactly like password logins:
import { createAuthService, createMemorySessionStore } from "@zudojs/auth";
import { findAccountById } from "./accounts.js";
import { requireEnv } from "./env.js";
export const auth = createAuthService({
token: {
accessSecret: requireEnv("JWT_ACCESS_SECRET"),
refreshSecret: requireEnv("JWT_REFRESH_SECRET"),
},
sessionStore: createMemorySessionStore(),
sessionTtlSeconds: 30 * 60,
absoluteSessionTtlSeconds: 7 * 24 * 60 * 60,
findUser: async () => null,
findUserById: findAccountById,
verifyPassword: async () => false,
externalSessionMethods: ["oauth"],
});
findUserreturnsnullandverifyPasswordreturnsfalse, so password login is switched off for this service. In an app with both kinds of login, you pass your real functions.createSessionForUserchecks no password: it trusts your code. So it is switched off until you list the method inexternalSessionMethods. It still loads the user withfindUserByIdand refuses an unknown or deactivated one. Call it only after the callback has checked the state and exchanged the code, and never with a user id taken from the request.
Third, the two routes:
import { parseCookies } from "@zudojs/auth";
import { createAuthorizationUrl, exchangeCodeForToken, fetchUserInfo, generateState } from "@zudojs/auth-oauth";
import type { OAuthConfig } from "@zudojs/auth-oauth";
import { badRequest, createNodeHttpAdapter, createResponseContext, createRouter } from "@zudojs/http";
import { findOrCreateAccount } from "./accounts.js";
import { CALLBACK_URL } from "./google.js";
import { savePendingLogin, takeVerifiedLogin } from "./pending.js";
import { auth } from "./sessions.js";
export async function startServer(google: OAuthConfig, port = 0) {
const router = createRouter();
router.get("/auth/google", () => {
const state = generateState();
const { url, codeVerifier } = createAuthorizationUrl(google, { state, redirectUri: CALLBACK_URL });
const loginId = savePendingLogin(state, codeVerifier);
return createResponseContext()
.cookie("oauth_login", loginId, { maxAge: 600, path: "/auth/google" })
.redirect(url);
});
router.get("/auth/google/callback", async (ctx) => {
const loginId = parseCookies(ctx.request.getHeader("cookie")).oauth_login;
const state = typeof ctx.query.state === "string" ? ctx.query.state : "";
const codeVerifier = takeVerifiedLogin(loginId, state);
const code = ctx.query.code;
if (typeof code !== "string" || code === "") throw badRequest("Sign-in was cancelled");
const tokens = await exchangeCodeForToken(google, { code, codeVerifier, redirectUri: CALLBACK_URL });
const user = findOrCreateAccount("google", await fetchUserInfo(google, tokens.accessToken));
const session = await auth.createSessionForUser(user.id, { method: "oauth", metadata: { provider: "google" } });
return createResponseContext()
.cookie("oauth_login", "", { maxAge: 0, path: "/auth/google" })
.json({ userId: user.id, accessToken: session.tokens.accessToken });
});
const adapter = createNodeHttpAdapter({
host: "127.0.0.1",
port,
handler: async (request) => (await router.dispatch(request)).response,
});
await adapter.start();
return { url: `http://127.0.0.1:${adapter.address?.port}`, stop: () => adapter.stop() };
}
GET /auth/googlesaves the pending login, sets theoauth_logincookie (HttpOnly, Secure and SameSite=Lax by default, 10 minutes, only sent to/auth/google...) and redirects the browser to Google.SameSite=Laxstill sends the cookie when Google redirects the browser back, because that is a normal top-level navigation withGET.- The callback checks state first. A forged callback never reaches Google. If the user pressed "Cancel" at Google, there is no
codeand the answer is 400. - The Google access token is used once and thrown away. The client gets your token, from your session.
- Errors like
OAuthStateMismatchErrorcarry their status (400), so the HTTP layer answers with it. Configuration errors are 500 with a hidden message.
Test the whole flow: start, "sign in at Google", callback, then a replay of the same callback and a callback from a browser that never started a login:
import "./demo-env.js";
import { fakeGoogleFetch, signInAtGoogle } from "./fake-google.js";
import { googleConfig } from "./google.js";
import { startServer } from "./server.js";
import { auth } from "./sessions.js";
const server = await startServer(googleConfig(fakeGoogleFetch));
const start = await fetch(server.url + "/auth/google", { redirect: "manual" });
const cookie = (start.headers.get("set-cookie") ?? "").split(";")[0]!;
console.log(start.status, new URL(start.headers.get("location")!).host, cookie.split("=")[0]);
const callback = new URL(signInAtGoogle(start.headers.get("location")!));
const path = callback.pathname + callback.search;
const done = await fetch(server.url + path, { headers: { cookie } });
const body = (await done.json()) as { userId: string; accessToken: string };
console.log(done.status, body.userId, (await auth.verifyToken(body.accessToken)).sub);
const replay = await fetch(server.url + path, { headers: { cookie } });
console.log("replayed callback:", replay.status, await replay.text());
const noCookie = await fetch(server.url + path);
console.log("callback without cookie:", noCookie.status, await noCookie.text());
await server.stop();
npx tsx try-oauth.ts302 accounts.google.com oauth_login
200 u-1 u-1
replayed callback: 400 {"error":"OAuth state verification failed.","code":"OAUTH_STATE_MISMATCH"}
callback without cookie: 400 {"error":"OAuth state verification failed.","code":"OAUTH_STATE_MISMATCH"}The first request answers 302 (a redirect) to accounts.google.com and sets the cookie. After the callback, the Task API's own auth.verifyToken accepts the new token for user u-1. The replay and the cookie-less callback, the login CSRF attack from above, both get 400.
With the real Google
The only change is to leave out the stand-in and listen on port 3000, the port in CALLBACK_URL. This file needs real credentials, so it is not run on this page:
import { googleConfig } from "./google.js";
import { startServer } from "./server.js";
const server = await startServer(googleConfig(), 3000);
console.log(`Open http://localhost:3000/auth/google in your browser (server on ${server.url})`);
Put GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and the two JWT secrets in .env, add http://localhost:3000/auth/google/callback as an authorized redirect URI in the Google console, and run npx tsx --env-file=.env src/main.ts. Open the printed address, sign in, and the browser shows a JSON object with your new userId and accessToken. In a real web app you would put the token in an HttpOnly cookie and redirect to your home page instead of showing JSON.
IN PRODUCTION
Use anhttps:// callback URL. The package allows http only for localhost, 127.0.0.1 and [::1]. And keep pending logins in a shared store (your database or Redis) if you run more than one server: the callback may reach a different server than the start.Providers differ
Some differences show up as errors from the package, before any request is made:
import "./demo-env.js";
import { fetchUserInfo, normalizeUserInfo, OAuthError, refreshAccessToken } from "@zudojs/auth-oauth";
import { googleConfig } from "./google.js";
const google = googleConfig();
async function show(label: string, action: () => Promise<unknown>): Promise<void> {
try {
await action();
} catch (error) {
if (error instanceof OAuthError) console.log(label, "->", error.name, "-", error.message);
}
}
await show("GitHub refresh", () => refreshAccessToken({ ...google, provider: "github" }, "stored-refresh-token"));
await show("Apple profile", () => fetchUserInfo({ ...google, provider: "apple" }, "access-token"));
await show("token URL to cloud metadata", () =>
refreshAccessToken({ ...google, tokenUrl: "https://169.254.169.254/latest/meta-data" }, "stored-refresh-token"));
console.log(normalizeUserInfo("github", { id: 583231, login: "octocat", avatar_url: "https://avatars.githubusercontent.com/u/583231" }));
npx tsx limits.tsGitHub refresh -> OAuthConfigurationError - This provider does not issue refresh tokens for the authorization-code flow.
Apple profile -> OAuthConfigurationError - Apple has no user-info endpoint; read the profile from the id_token returned by the token exchange.
token URL to cloud metadata -> OAuthEndpointNotAllowedError - tokenUrl resolves to a non-public host, which is not allowed.
{
providerId: '583231',
name: 'octocat',
avatarUrl: 'https://avatars.githubusercontent.com/u/583231',
raw: {
id: 583231,
login: 'octocat',
avatar_url: 'https://avatars.githubusercontent.com/u/583231'
}
}- GitHub tokens do not expire and come with no refresh token, so there is nothing to refresh.
- Apple has no profile endpoint. The profile is inside the
idTokenfrom the token exchange, and this package does not check that token's signature. You must verify it yourself before trusting it. Apple also sends its callback as aPOSTform (response_mode=form_post), which aSameSite=Laxcookie does not follow. - Your server fetches the token and profile URLs itself. The package refuses private and cloud-metadata addresses there, so a bad configuration cannot make your server fetch internal secrets. This attack is called SSRF, server-side request forgery.
- A GitHub profile without a public email simply has no
email. The package never makes one up, so your code must handle an account without an email.
ACCOUNT TAKEOVER
Never link an OAuth login to an existing password account just because the emails match, unless the provider says the email is verified and the user proves they own the existing account (for example by logging in to it first). Otherwise anyone who creates a provider account with Ada's address could log in as Ada.Practice
TRY IT YOURSELF
Spot the mistakes
This callback handler works, but has three security problems. Find them.
router.get("/auth/google/callback", async (ctx) => {
const tokens = await exchangeCodeForToken(google, {
code: String(ctx.query.code), codeVerifier: String(ctx.query.verifier), redirectUri: CALLBACK_URL,
});
const profile = await fetchUserInfo(google, tokens.accessToken);
const user = usersByEmail.get(profile.email ?? "") ?? createUser(profile);
return createResponseContext().json({ googleToken: tokens.accessToken });
});Show a solution
- No state check. Anyone can send a victim a callback link with the attacker's code (login CSRF). Take the pending login from the server and call
verifyStatefirst. - The PKCE verifier comes from the query string, so it travelled through the browser next to the code. A thief who has one has both. Keep the verifier on the server, as in
pending.ts. - Accounts are matched by email, verified or not, which allows account takeover. And the handler returns Google's access token to the browser instead of starting your own session. Key accounts by
provider:providerIdand return your own token.
TRY IT YOURSELF
A GitHub config
Write githubConfig() like googleConfig(), reading GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, with the callback http://localhost:3000/auth/github/callback and only the read:user scope. Print the scope and redirect_uri of its authorization URL.
Show a solution
import { randomBytes } from "node:crypto";
import { createAuthorizationUrl, generateState } from "@zudojs/auth-oauth";
import type { OAuthConfig } from "@zudojs/auth-oauth";
import { requireEnv } from "./env.js";
process.env.GITHUB_CLIENT_ID ??= "demo-github-client-id";
process.env.GITHUB_CLIENT_SECRET ??= randomBytes(24).toString("base64url");
const GITHUB_CALLBACK = "http://localhost:3000/auth/github/callback";
function githubConfig(): OAuthConfig {
return {
provider: "github",
clientId: requireEnv("GITHUB_CLIENT_ID"),
clientSecret: requireEnv("GITHUB_CLIENT_SECRET"),
allowedRedirectUris: [GITHUB_CALLBACK],
scopes: ["read:user"],
};
}
const { url } = createAuthorizationUrl(githubConfig(), { state: generateState(), redirectUri: GITHUB_CALLBACK });
const params = new URL(url).searchParams;
console.log(new URL(url).host, params.get("scope"), params.get("redirect_uri"));
npx tsx github.tsgithub.com read:user http://localhost:3000/auth/github/callback
The two ??= lines are demo-only placeholders, like demo-env.ts. Without user:email, GitHub profiles will usually have no email.
Recap
- OAuth 2's authorization code flow lets a provider check who the user is. The code comes back through the browser; the token and the client secret stay between servers.
- The client id is public, the client secret lives in
.env, and redirect URIs are an allow-list. stateis stored on the server, checked withverifyStatebefore anything else, and used once. It stops login CSRF.- PKCE: the server keeps the verifier and sends only its SHA-256 challenge, so a stolen code is useless. The package always uses
S256. exchangeCodeForTokenandfetchUserInfofinish the flow. Then you find or create the account byprovider:providerIdand start your own session.- Providers differ: no refresh on GitHub, no profile endpoint on Apple, email is optional. Never link accounts on an unverified email.
You now know who is calling. Next, decide what they may do, in Permissions.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.