Lectures

▼

Phase 3: User Profile & Session Management

Right now TaskFlow logs in and out correctly, but it has a real usability problem: Okta's access and ID tokens are short-lived (an hour, by default), and once one expires, our Middleware from Phase 2 just kicks the user back to /login — even though they never actually "logged out." This phase fixes that properly, and also lets users see and update their own profile.

What Each Token Is Actually For — A Precise Recap

We touched on this back in Lecture 2, but now that you've handled these tokens in real code, the distinctions matter more:

  • ID Token — A signed statement of identity, valid for a short window (default: 1 hour on Okta). Meant to be read by your app, never sent to an external API. We use it to know who's logged in.
  • Access Token — A signed permission slip, also short-lived (default: 1 hour). Meant to be sent to APIs (Okta's own APIs, or your own protected Next.js Route Handlers, which we'll build in Phase 5) as proof of what the bearer can do.
  • Refresh Token — A separate, much longer-lived credential (Okta's default for confidential web apps like ours is persistent, not single-use, unlike Single-Page Apps which get rotating short-lived refresh tokens). Its only job is to be exchanged for a fresh Access Token and ID Token, without forcing the user to log in again.

Short-lived access/ID tokens exist for a good reason: if one ever leaks, the damage window is small. The Refresh Token compensates for that short lifespan by quietly getting new tokens in the background — this is called silent renewal, and it's what we build next.

Step 1: Actually Requesting a Refresh Token

Two things must both be true for Okta to hand out a refresh token, and we haven't done either yet:

  1. Your Okta Application must have Refresh Token enabled as an allowed grant type.
  2. Your /authorize request must include the offline_access scope — this is the specific scope that tells Okta "I want a refresh token too," and per Okta's rules it must be requested here, at authorization time, not later at token exchange time.

Go back to the Okta Admin Console → Applications → Applications → your TaskFlow app → General tab → Edit (in the Grant type section) and check Refresh Token alongside the existing Authorization Code grant. Save.

Now update src/app/api/auth/login/route.ts from Phase 2 — change one line:


    authorizeUrl.searchParams.set("scope", "openid profile email offline_access");

And update the callback route's cookie-setting section in src/app/api/auth/callback/route.ts to also store the refresh token:


    if (tokens.refresh_token) {
        response.cookies.set("refresh_token", tokens.refresh_token, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: 60 * 60 * 24 * 30, // Okta's default persistent refresh token lifetime is generous; 30 days is a reasonable cookie ceiling
            path: "/",
        });
    }

Step 2: A Refresh Helper

Create src/lib/refreshTokens.ts:


    interface RefreshedTokens {
        access_token: string;
        id_token: string;
        refresh_token?: string;
        expires_in: number;
    }

    export async function refreshTokens(refreshToken: string): Promise<RefreshedTokens | null> {
        const response = 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: "refresh_token",
                client_id: process.env.OKTA_CLIENT_ID!,
                client_secret: process.env.OKTA_CLIENT_SECRET!,
                refresh_token: refreshToken,
                scope: "openid profile email offline_access",
            }),
        });

        if (!response.ok) return null;
        return response.json();
    }

This does exactly what its name says: takes a refresh token, sends the refresh_token grant to Okta's /token endpoint (the same endpoint we used for the initial exchange in Phase 2, just a different grant_type), and returns a fresh set of tokens.

Step 3: Wiring Silent Renewal Into Middleware

Now update src/middleware.ts from Phase 2 so that an expired ID token triggers a refresh attempt before giving up:


    import { NextRequest, NextResponse } from "next/server";
    import { verifyIdToken } from "@/lib/verifyToken";
    import { refreshTokens } from "@/lib/refreshTokens";

    export async function middleware(request: NextRequest) {
        const idToken = request.cookies.get("id_token")?.value;
        const refreshToken = request.cookies.get("refresh_token")?.value;

        if (idToken) {
            try {
                await verifyIdToken(idToken);
                return NextResponse.next(); // Still valid, nothing to do.
            } catch {
                // Fall through to the refresh attempt below.
            }
        }

        if (refreshToken) {
            const refreshed = await refreshTokens(refreshToken);
            if (refreshed) {
                const response = NextResponse.next();
                response.cookies.set("id_token", refreshed.id_token, {
                    httpOnly: true,
                    secure: process.env.NODE_ENV === "production",
                    sameSite: "lax",
                    maxAge: refreshed.expires_in,
                    path: "/",
                });
                response.cookies.set("access_token", refreshed.access_token, {
                    httpOnly: true,
                    secure: process.env.NODE_ENV === "production",
                    sameSite: "lax",
                    maxAge: refreshed.expires_in,
                    path: "/",
                });
                if (refreshed.refresh_token) {
                    response.cookies.set("refresh_token", refreshed.refresh_token, {
                        httpOnly: true,
                        secure: process.env.NODE_ENV === "production",
                        sameSite: "lax",
                        maxAge: 60 * 60 * 24 * 30,
                        path: "/",
                    });
                }
                return response;
            }
        }

        // No valid session and no working refresh token — genuinely logged out.
        const response = NextResponse.redirect(new URL("/login", request.url));
        response.cookies.delete("id_token");
        response.cookies.delete("access_token");
        response.cookies.delete("refresh_token");
        return response;
    }

    export const config = {
        matcher: ["/dashboard/:path*"],
    };

The logic now reads cleanly against what we just learned: try the existing ID token first; if it's expired or invalid, attempt a silent refresh using the refresh token; only redirect to /login if that refresh also fails (meaning the refresh token itself expired or was revoked — a genuine logout condition). Notice we conditionally update the refresh_token cookie too — this matters because if Okta ever rotates it, using the old one again would fail.

Step 4: Displaying the User's Profile

You already display name and email on the dashboard, pulled from ID token claims. For a fuller profile view, the standard OIDC way is the /userinfo endpoint, which returns whatever claims your granted scopes allow, keyed off the access token — useful because it reflects Okta's current stored profile, not a snapshot frozen at login time.

Create src/app/profile/page.tsx:


    import { cookies } from "next/headers";
    import { redirect } from "next/navigation";

    async function getUserInfo(accessToken: string) {
        const response = await fetch(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/userinfo`, {
            headers: { Authorization: `Bearer ${accessToken}` },
            cache: "no-store",
        });
        if (!response.ok) return null;
        return response.json();
    }

    export default async function ProfilePage() {
        const cookieStore = await cookies();
        const accessToken = cookieStore.get("access_token")?.value;

        if (!accessToken) redirect("/login");

        const userInfo = await getUserInfo(accessToken);

        return (
            <div className="p-8 max-w-md">
                <h1 className="text-2xl font-semibold text-slate-800 mb-4">Your Profile</h1>
                <div className="rounded-lg border border-slate-200 p-4 space-y-2">
                    <p><span className="text-slate-500">Name:</span> {userInfo?.name}</p>
                    <p><span className="text-slate-500">Email:</span> {userInfo?.email}</p>
                    <p><span className="text-slate-500">Locale:</span> {userInfo?.locale ?? "Not set"}</p>
                </div>
            </div>
        );
    }

Add /profile to the Middleware matcher array from Step 3 so this page is protected too.

Step 5: Letting Users Update Their Own Profile

Older Okta tutorials handle this by calling the full Users Management API with an admin API token — that approach requires a privileged secret your app really shouldn't hold just to let a user edit their own name. Okta's current, correct answer for this exact case is the MyAccount API: end-user-scoped endpoints that work directly off the user's own access token, no admin credentials involved.

To enable it: in the Admin Console, go to your default Authorization Server → API Scopes tab, and grant the okta.myAccount.profile.manage scope (alongside the standard okta.myAccount.profile.read for reading). Then request that scope in your login route's scope parameter alongside the others.

A minimal update handler, src/app/api/profile/update/route.ts:


    import { NextRequest, NextResponse } from "next/server";

    export async function PATCH(request: NextRequest) {
        const accessToken = request.cookies.get("access_token")?.value;
        if (!accessToken) {
            return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
        }

        const body = await request.json(); // e.g. { firstName: "Aditi" }

        const response = await fetch(`${process.env.OKTA_ORG_URL}/idp/myaccount/profile`, {
            method: "PATCH",
            headers: {
                Authorization: `Bearer ${accessToken}`,
                "Content-Type": "application/json",
                Accept: "application/json; okta-version=1.0.0",
            },
            body: JSON.stringify({ profile: body }),
        });

        if (!response.ok) {
            return NextResponse.json({ error: "Update failed" }, { status: response.status });
        }

        return NextResponse.json(await response.json());
    }

Notice the Accept header includes okta-version=1.0.0 — the MyAccount API requires an explicit API version in that header, which is a detail specific to this API and easy to miss.

A Note on Custom Attributes

Everything above works for Okta's built-in profile fields (firstName, lastName, email, and so on). If TaskFlow needs its own fields — say, a jobTitle or teamSize — you add them as custom attributes on the User profile schema, under Directory → Profile Editor → User (default) in the Admin Console. Once added there, they behave exactly like built-in fields for both /userinfo (if you map them to a claim — covered properly in Phase 5) and the MyAccount API.


Where TaskFlow Stands Now

Sessions survive past the one-hour token expiry through silent, automatic renewal. Users can see their live profile data and update it themselves through Okta's current, properly-scoped end-user API — no admin secrets involved anywhere in this flow.

That completes Phase 3.

No comments:

Post a Comment