Hello! Welcome to our next lesson.
In our previous session, we successfully built the "happy path" for our Google OAuth flow. We can now receive a user's profile from Google, find or create a corresponding user in our system, and issue our own application-specific JWT to manage their session.
Today, we will focus on making this flow robust by handling the "unhappy paths." A user might cancel the login process, or a network issue could disrupt communication with Google's servers. Our goal is to handle user-initiated cancellation and provider-side errors during the OAuth flow gracefully. This ensures a predictable and user-friendly experience, even when things don't go as planned.
We will address two primary failure points:
- Errors returned in the redirect from Google (e.g., the user cancels the consent screen).
- Errors that occur during the server-side code-for-token exchange.
By the end of this lesson, our authentication callback will be resilient to common OAuth errors.
1. How Google Communicates Errors
When an error occurs during the authorization phase (before your server gets involved), Google doesn't just leave the user on an error page. It redirects back to your specified redirect_uri and communicates the error via a query parameter.
Using OAuth 2.0 for Web Server Applications | Authorization
Google's official documentation, 'Using OAuth 2.0 for Web Server Applications', clearly explains how the server responds with an error. This is the primary mechanism we need to handle.
Please read the section 'Step 4: Handle the OAuth 2.0 server response'. Focus on the example of an error response, which shows the error parameter in the URL's query string. This is the key piece of information our server will look for.
As the resource shows, an error response looks like this:https://oauth2.example.com/auth?error=access_denied
The access_denied value is particularly important as it directly corresponds to a user-initiated cancellation.
2. Handling User Cancellation (access_denied)
The most common "error" is not a technical fault but a user's choice to cancel the login. Let's see what Google's documentation says about this specific case.
The 'Handle Errors' guide from Google provides more context on different error types. It explicitly mentions that access_denied occurs when the user denies the OAuth request.
Read the section 'OAuth Error Responses'. It confirms that access_denied is the error code for when a user denies the request, which is exactly the 'user-initiated cancellation' we need to handle.
In our callback.ts file from the last lesson, we already have a placeholder for this logic. Let's make it more explicit and user-friendly. Instead of just passing the raw error, we can redirect the user back to our login page with a clean, understandable message in the URL.
Let's modify the if (query.error) block in src/routes/api/auth/google/callback.ts:
// Existing code...
deleteCookie(event, "oauth_state", cookieOptions);
deleteCookie(event, "pkce_code_verifier", cookieOptions);
// --- MODIFIED BLOCK ---
if (query.error) {
// Handle user cancellation or other errors from Google
console.warn(`OAuth Error: ${query.error} - ${query.error_description || 'No description'}`);
// Provide a user-friendly error code for the frontend to display
const errorCode = query.error === "access_denied" ? "login_cancelled" : "authentication_failed";
return sendRedirect(event, `/?error=${errorCode}`);
}
//... rest of the file
This change does two things:
- It logs the technical error from Google on the server for debugging purposes.
- It translates the most common error,
access_denied, into a simplelogin_cancelledcode for our front end. Any other error results in a genericauthentication_failed. This allows the UI to display a message like "Login was cancelled." or "Authentication failed. Please try again."
3. Handling Provider-Side Errors
The second major failure point is during the server-to-server communication when we exchange the authorization code for an access token. This happens inside the try...catch block in our handler. A common error here is invalid_grant.
Using OAuth 2.0 for Web Server Applications | Authorization
Let's return to the 'Using OAuth 2.0 for Web Server Applications' guide to understand this server-side error.
Read the short section on 'Errors' under 'Step 5: Exchange authorization code...'. It describes the invalid_grant error, which can happen if the code is expired or already used. This is a classic provider-side error.
Our existing try...catch block already prevents the application from crashing. However, we can improve the logging to give us more insight when these errors occur. The google-auth-library often wraps the response from Google in the error object, which we can inspect.
Let's enhance the catch block:
//... inside the try block
try {
// ... code to get tokens and user profile
} catch (error: any) {
// --- MODIFIED BLOCK ---
// Log detailed error for debugging
console.error(
"Authentication process failed:",
error.response?.data || error.message
);
// Redirect with a generic error for the user
return sendRedirect(event, "/?error=authentication_failed");
}
//...
By logging error.response?.data, we attempt to capture the specific JSON error response from Google's API, which is invaluable for debugging, while still presenting a simple, generic failure message to the end-user.
4. Graceful Handling of Partial Consent
A more subtle scenario is when the user proceeds with the login but doesn't grant all the permissions (scopes) your application requested. Your application shouldn't crash; it should handle this gracefully.
Using OAuth 2.0 for Web Server Applications | Authorization
The same Google guide also covers this 'granular permissions' scenario.
Read 'Step 6: Check which scopes users granted'. The key takeaway is that the response from the token exchange includes a scope property that lists the scopes the user actually granted. Your application must verify this list.
Let's say our application has a critical dependency on the user's email. We can add a check after getting the tokens to ensure this scope was granted. If not, we treat it as a failed login.
Here's how we can integrate this check into our try block:
//... inside the try block
const { tokens } = await oauth2Client.getToken({
code: authCode,
codeVerifier: storedCodeVerifier,
});
// --- NEW SCOPE CHECK ---
const grantedScopes = tokens.scope?.split(" ") || [];
const requiredScope = "https://www.googleapis.com/auth/userinfo.email";
if (!grantedScopes.includes(requiredScope)) {
throw new Error(`Required scope not granted: ${requiredScope}`);
}
// --- END OF SCOPE CHECK ---
if (!tokens.id_token) {
throw new Error("ID token not found");
}
//...
This check ensures that if the user revokes permission to see their email, the process stops with a clear error message in our server logs, which is then caught by our catch block and results in a safe redirect. For non-critical scopes, you could proceed and simply disable the corresponding features in your application's UI.
5. The Complete, Robust Callback Handler
Here is the fully updated src/routes/api/auth/google/callback.ts file, incorporating all our error handling improvements.
import {
eventHandler,
getQuery,
getCookie,
deleteCookie,
setCookie,
sendRedirect,
} from "vinxi/http";
import { oauth2Client } from "../../../../lib/google-auth";
import * as jose from "jose";
// --- In-memory user store (simulation) ---
interface User {
id: string;
googleId: string;
email: string;
name: string;
}
const users: User[] = [];
let nextUserId = 1;
async function findOrCreateUser(profile: {
sub: string;
email?: string | null;
name?: string | null;
}): Promise<User> {
let user = users.find((u) => u.googleId === profile.sub);
if (user) {
return user;
}
const newUser: User = {
id: (nextUserId++).toString(),
googleId: profile.sub,
email: profile.email || "No Email",
name: profile.name || "No Name",
};
users.push(newUser);
return newUser;
}
// --- End of simulation ---
export default eventHandler(async (event) => {
const query = getQuery(event);
const storedState = getCookie(event, "oauth_state");
if (!query.state || !storedState || query.state !== storedState) {
event.node.res.statusCode = 400;
return { error: "State mismatch" };
}
const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === "production", path: "/" };
deleteCookie(event, "oauth_state", cookieOptions);
deleteCookie(event, "pkce_code_verifier", cookieOptions);
if (query.error) {
// Handle user cancellation or other errors from Google
console.warn(`OAuth Error: ${query.error} - ${query.error_description || 'No description'}`);
const errorCode = query.error === "access_denied" ? "login_cancelled" : "authentication_failed";
return sendRedirect(event, `/?error=${errorCode}`);
}
const authCode = query.code as string;
const storedCodeVerifier = getCookie(event, "pkce_code_verifier");
if (!authCode || !storedCodeVerifier) {
event.node.res.statusCode = 400;
return { error: "Missing authorization code or PKCE verifier" };
}
try {
const { tokens } = await oauth2Client.getToken({
code: authCode,
codeVerifier: storedCodeVerifier,
});
// Verify that all critical scopes were granted
const grantedScopes = tokens.scope?.split(" ") || [];
const requiredScopes = [
"openid",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
];
const hasAllRequiredScopes = requiredScopes.every(scope => grantedScopes.includes(scope));
if (!hasAllRequiredScopes) {
throw new Error(`One or more required scopes were not granted. Granted: ${tokens.scope}`);
}
if (!tokens.id_token) {
throw new Error("ID token not found");
}
const ticket = await oauth2Client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload) {
throw new Error("Failed to get token payload");
}
const user = await findOrCreateUser(payload);
const secret = new TextEncoder().encode(process.env.VITE_JWT_SECRET);
const alg = "HS256";
const appJwt = await new jose.SignJWT({
sub: user.id,
email: user.email,
name: user.name,
})
.setProtectedHeader({ alg })
.setIssuedAt()
.setIssuer("urn:example:issuer")
.setAudience("urn:example:audience")
.setExpirationTime("2h")
.sign(secret);
setCookie(event, "auth_token", appJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 2,
});
return sendRedirect(event, "/");
} catch (error: any) {
console.error("Authentication process failed:", error.response?.data || error.message);
return sendRedirect(event, "/?error=authentication_failed");
}
});
Conclusion
Excellent work. Our Google OAuth flow is no longer a fragile "happy path" but a robust feature that can gracefully handle the most common failure scenarios.
Key Takeaways:
- Errors during the user consent phase are communicated back via the
errorquery parameter in the redirect URL. We must check for this parameter first. error=access_deniedspecifically means the user cancelled the login, and we can provide a tailored message for this case.- Errors during the server-side token exchange (e.g.,
invalid_grant) are caught in atry...catchblock. Detailed server-side logging is crucial for debugging these issues. - Graceful error handling also includes verifying that the user has granted all critical scopes required for your application to function.
Next Lesson Preview:
Our authentication flow is now solid. The next step is to refine our user management logic. Currently, our findOrCreateUser function is simple. What if a user already has an account in our system (e.g., they signed up with an email and password) and now wants to log in with Google using the same email? In the next lesson, we will implement a strategy for linking an OAuth identity to an existing local user account, a common requirement for production applications.
Can't find a good explanation? Sign up and we'll make it for you
Sign up