Theory is behind us. This is where TaskFlow gets a real, working "Log in with Okta" button. We're going to build the entire flow by hand: the login route, the callback route, and secure cookie-based token storage — so you understand every moving part, not just import a black-box function.
A Quick Design Decision, Explained
Since our Okta Application is registered as a Web Application (Phase 1, Step 2) — meaning it has a Client Secret it can keep private on the server — the correct place to run the token exchange (step 6–7 of the flow from Lecture 2) is inside a Next.js Route Handler, on the server, never in browser JavaScript. This is important: it means we generate the PKCE code verifier, redirect to Okta, and exchange the code for tokens entirely on the server side, using Node's built-in crypto module for the PKCE math and plain fetch calls to Okta's endpoints. We'll bring in @okta/okta-auth-js starting next lecture for token verification and profile/session helpers — but the actual redirect-and-exchange dance is clearer, and more correct for a confidential server-side app like ours, written directly against Okta's endpoints first. This also means you'll actually understand the flow instead of trusting a library to do it invisibly.
Step 1: Install What We Need
npm install jose
That's it for now. jose is a small, well-maintained library for verifying signed JWTs (JSON Web Tokens) — we'll use it to verify Okta's ID token signature in the next post. We'll add @okta/okta-auth-js in Phase 3 when we cover silent token renewal and profile fetching, where it genuinely saves you work.
Step 2: A Small Helper for PKCE
Recall from Lecture 2: PKCE requires a random code verifier, and a hashed code challenge derived from it. Let's write that as a small utility.
Create src/lib/pkce.ts:
import crypto from "crypto";
export function generateCodeVerifier(): string { return crypto.randomBytes(32).toString("base64url"); }
export function generateCodeChallenge(verifier: string): string { return crypto .createHash("sha256") .update(verifier) .digest("base64url"); }
export function generateState(): string { return crypto.randomBytes(16).toString("base64url"); }
The state value isn't part of PKCE itself — it's a separate, older CSRF protection that OAuth 2.0 has always recommended: a random value your app generates before redirecting, which Okta echoes back unchanged. If the state you receive on callback doesn't match what you sent, you reject the login — it means the request didn't originate from your app.
Step 3: The Login Route
Create src/app/api/auth/login/route.ts:
import { NextRequest, NextResponse } from "next/server"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/pkce";
export async function GET(request: NextRequest) { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState();
const authorizeUrl = new URL(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/authorize`); authorizeUrl.searchParams.set("client_id", process.env.OKTA_CLIENT_ID!); authorizeUrl.searchParams.set("response_type", "code"); authorizeUrl.searchParams.set("scope", "openid profile email"); authorizeUrl.searchParams.set("redirect_uri", "http://localhost:3000/api/auth/callback"); authorizeUrl.searchParams.set("state", state); authorizeUrl.searchParams.set("code_challenge", codeChallenge); authorizeUrl.searchParams.set("code_challenge_method", "S256");
const response = NextResponse.redirect(authorizeUrl.toString());
// Store the verifier and state temporarily so the callback route can use them. // httpOnly means client-side JS can never read these — only the server can. response.cookies.set("pkce_verifier", codeVerifier, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 600, // 10 minutes — plenty of time to complete a login path: "/", }); response.cookies.set("oauth_state", state, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 600, path: "/", });
return response; }
Walk through what this does against the flow from Lecture 2: it's step 2 and step 3 combined. We build the /authorize URL with our Client ID, requested scopes, redirect URI, the state, and the code_challenge — then redirect the browser there. We temporarily stash the code_verifier and state in short-lived, httpOnly cookies, because our callback route (running as a separate request, possibly even a different server process) needs them again in step 6, and it has no other memory of this request.
Note notice /oauth2/default/ in the URL — that's us explicitly using the default Custom Authorization Server we configured in Phase 1, not the Org Authorization Server, exactly per the reasoning from Lecture 2.
Step 4: The Callback Route
Create src/app/api/auth/callback/route.ts:
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const code = searchParams.get("code"); const returnedState = searchParams.get("state"); const error = searchParams.get("error");
if (error) { return NextResponse.redirect(new URL(`/login?error=${error}`, request.url)); }
const storedState = request.cookies.get("oauth_state")?.value; const codeVerifier = request.cookies.get("pkce_verifier")?.value;
if (!code || !returnedState || returnedState !== storedState || !codeVerifier) { return NextResponse.redirect(new URL("/login?error=invalid_state", request.url)); }
// Exchange the authorization code for tokens — this is a direct // server-to-server request, never visible to the browser (step 6-7 from Lecture 2). const tokenResponse = await fetch(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", client_id: process.env.OKTA_CLIENT_ID!, client_secret: process.env.OKTA_CLIENT_SECRET!, redirect_uri: "http://localhost:3000/api/auth/callback", code, code_verifier: codeVerifier, }), });
if (!tokenResponse.ok) { const errorBody = await tokenResponse.text(); console.error("Token exchange failed:", errorBody); return NextResponse.redirect(new URL("/login?error=token_exchange_failed", request.url)); }
const tokens = await tokenResponse.json(); // tokens now contains: access_token, id_token, expires_in, token_type, scope
const response = NextResponse.redirect(new URL("/dashboard", request.url));
// Clean up the temporary PKCE cookies — their job is done. response.cookies.delete("pkce_verifier"); response.cookies.delete("oauth_state");
// Store the real session tokens. We'll verify the ID token's signature // properly in the next post — for now we store it as-is to get login working end to end. response.cookies.set("id_token", tokens.id_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: tokens.expires_in, path: "/", }); response.cookies.set("access_token", tokens.access_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: tokens.expires_in, path: "/", });
return response; }
This is steps 5 through 8 from Lecture 2, written out literally: read the authorization code Okta sent back, confirm state matches (rejecting the request otherwise — this is the CSRF check), exchange the code plus the original code_verifier for tokens via a direct POST to Okta's /token endpoint, then store the resulting tokens in httpOnly cookies so client-side JavaScript can never read them directly.
Step 5: A Login Button
Create a simple login page at src/app/login/page.tsx:
export default function LoginPage() { return ( <div className="flex min-h-screen items-center justify-center bg-slate-50"> <div className="rounded-lg bg-white p-8 shadow-md text-center"> <h1 className="text-2xl font-semibold text-slate-800 mb-2">TaskFlow</h1> <p className="text-slate-500 mb-6">Sign in to continue</p> <a href="/api/auth/login" className="inline-block rounded-md bg-blue-600 px-6 py-2 text-white font-medium hover:bg-blue-700 transition" > Log in with Okta </a> </div> </div > ); }
Note this is a plain <a> tag, not a client-side router push — we genuinely want a full page navigation to /api/auth/login, since that route immediately issues a server-side redirect to Okta.
Step 6: A Placeholder Dashboard
Just so the flow has somewhere to land, create src/app/dashboard/page.tsx:
export default function DashboardPage() {return (<div className="p-8"><h1 className="text-2xl font-semibold text-slate-800">Welcome to your dashboard</h1><p className="text-slate-500 mt-2">If you're seeing this, login worked.</p></div>);}
Try It
Restart your dev server (npm run dev), visit http://localhost:3000/login, and click Log in with Okta. You should be redirected to Okta's hosted login page, log in with the credentials of a user assigned to your app (your own admin account works fine for testing), and land back on /dashboard.
If you get an error instead, two things are worth checking first: that your Sign-in redirect URI in the Okta Application settings exactly matches http://localhost:3000/api/auth/callback (a trailing slash mismatch is a common cause), and that the access policy you added to the default authorization server back in Phase 1 is actually Active.
What's Next
You now have a real, working login flow — no library hiding the mechanics from you. But there are two important gaps: we're storing the ID token without verifying its signature (meaning right now we're trusting Okta blindly rather than proving the token is genuine), and there's no logout or route protection yet.
No comments:
Post a Comment