Skip to content

Instantly share code, notes, and snippets.

@JamesBliss
Last active March 7, 2025 14:27
Show Gist options
  • Save JamesBliss/4e47094518054cdcb11b3b150c63448c to your computer and use it in GitHub Desktop.
Save JamesBliss/4e47094518054cdcb11b3b150c63448c to your computer and use it in GitHub Desktop.
The "I Really Don't Want to Pull My Hair Out" Guide to OIDC with Stytch + next-auth

Because I've done the hair pulling already (This is not bulletproof)

What Are We Doing Here?

Welcome to the frustrating world of authentication! This guide will walk you through implementing OpenID Connect (OIDC) authentication with Stytch and next-auth in your Next.js application.

Prerequisites:

  • "next-auth": "^5.0.0-beta.25" (Because living on the beta edge is how we roll)
  • "next": "15.2.0" (Might work earlier but I'm not testing that 🤷‍♂️)
  • "jsonwebtoken": "^9.0.2" (I had an augment with jose)
  • Using the App router (Because it's 2023+, and we've moved on)

1. Setting Up auth.ts - The Foundation of Your Auth

This file is where the authentication magic begins. It's like the bouncer at your application's VIP section - deciding who gets in and what wristband they get.

// app/libs/auth/index.ts
import NextAuth, { NextAuthConfig } from 'next-auth';
import getRoles from './getRoles';

export const authConfig: NextAuthConfig = {
  providers: [
    {
      id: 'oidc',
      name: 'oidc',
      type: 'oidc',
      clientId: process.env.OIDC_CLIENT_ID,
      clientSecret: process.env.OIDC_CLIENT_SECRET,
      issuer: process.env.OIDC_ISSUER_URL,
      token: process.env.OIDC_TOKEN_URL,
      userinfo: process.env.OIDC_USERINFO_URL,
      authorization: {
        url: process.env.OIDC_AUTHORIZATION_URL,
        params: { scope: 'openid email profile' },
      },
      checks: ['pkce', 'state'],
      profile(profile) {
        return {
          id: profile.sub,
          email: profile.email,
          name: profile.name,
          image: profile.picture,
          roles: profile.roles || [],
        };
      },
    },
  ],
  callbacks: {
    jwt({ token, user, account }) {
      if (account) {
        // Forward the access token to your JWT
        token.accessToken = account.access_token;
        // Also store the ID token, used for signing out
        token.idToken = account.id_token;
      }
      return token;
    },
    session({ session, token }) {
      // Ensure all properties required are added to the session
      session.accessToken = token.accessToken as string;
      session.idToken = token.idToken as string;
      const roles = getRoles({ session });
      session.user = {
        ...session.user,
        roles: roles as string[],
      };
      return session;
    },
    authorized({ auth }) {
      // Simple auth check
      return !!auth;
    },
  },
};

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);

What's Actually Happening Here:

  1. We're configuring NextAuth with OIDC as our provider. OIDC is like OAuth's but it handles both authentication and identity.
  2. The profile function transforms the raw OIDC profile into the shape we want to use in our app.
  3. The callbacks handle important stuff:
    • jwt: Stores tokens so we can use them later
    • session: Makes sure our session has all the user data we need, including roles
    • authorized: The simplest possible check

Pro tip: You can find most of these values in your OIDC provider's discovery document at /.well-known/openid-configuration

2. The getRoles Function - Because Not All Users Are Created Equal

This little helper extracts roles from your JWT. It's like the X-ray machine that sees what powers your users actually have.

// app/libs/auth/getRoles.ts
import jwt from 'jsonwebtoken';

const getRoles = ({ session }): string[] => {
  const userSession = session;
  let roles: string[] = [];

  if (userSession?.user) {
    const accessToken = jwt.decode(userSession.accessToken);
    roles = accessToken?.roles || [];
  }

  return roles;
};

export default getRoles;

Why This Matters: Stytch (and many OIDC providers) store user roles in the access token. This function decodes that token to extract those roles, allowing you to implement role-based access control without making additional API calls.

3. Setting Up Auth Routes - The Doorways to Your App

These routes handle the actual login and logout flows. Think of them as the clearly marked entrance and exit doors to your application.

The Login Route - The Welcoming

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth, signIn } from '~/libs/auth';

export async function GET(request: NextRequest) {
  const session = await auth();

  // Get the redirectTo parameter from the URL if it exists
  const searchParams = request.nextUrl.searchParams;
  const redirectTo = searchParams.get('redirectTo') || '/';

  // If user is already signed in, redirect
  if (session) {
    return NextResponse.redirect(new URL(redirectTo || '/', request.url));
  }

  return signIn('oidc', { redirectTo });
}

What This Does: This route makes logging in easy. It checks if you're already logged in (no point in signing in twice), and initiates the OIDC flow if you're not. The redirectTo parameter ensures users end up where they wanted to go after logging in.

The Logout Route - The Goodbye

// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth, signOut } from '~/libs/auth';

function getLogoutUrl(idToken: string, redirectTo: string) {
  // Create a new URL object
  const logoutDestinationUrl = new URL(process.env.OIDC_AUTHORIZATION_URL);
  // This should be the same as the client_id as the one you logged in with
  logoutDestinationUrl.searchParams.set('client_id', process.env.OIDC_CLIENT_ID);
  // This should be where you want to redirect to after logging in
  logoutDestinationUrl.searchParams.set(
    'post_logout_redirect_uri',
    `${process.env.NEXT_PUBLIC_URL}?logoutReturnTo=${redirectTo}`
  );
  // Without this, you will be asked if you want to "sign out"
  logoutDestinationUrl.searchParams.set('id_token_hint', idToken);
  // return the logout URL
  return logoutDestinationUrl.toString();
}

/**
 * Logout API route that redirects to the OIDC provider to clear the session
 */
export async function GET(request: NextRequest) {
  const session = await auth();

  // Get the redirectTo parameter from the URL if it exists
  const searchParams = request.nextUrl.searchParams;
  const redirectTo = searchParams.get('redirectTo') || '/';

  // If user is already signed out, redirect
  if (!session) {
    return NextResponse.redirect(new URL(redirectTo || '/', request.url));
  }

  // Sign user out of `next-auth`
  await signOut({
    // prevent page from reloading so we can redirect to the logout
    redirect: false,
  });

  // Redirect to the OIDC provider to clear the session
  return NextResponse.redirect(getLogoutUrl(session.idToken, redirectTo));
}

Why This Extra Complexity: Standard signOut from next-auth doesn't "fully" log out the user from the OIDC provider. This route ensures we clean up both the local session AND the OIDC provider session. It's like not just hanging up the phone but also ending the call on the provider's side. Without this, users might experience the dreaded "I logged out but somehow I'm still logged in" phenomenon.

4. Adding Auth Buttons - Because Users Need Something to Click

The final piece of the UI puzzle - buttons that actually trigger these flows.

// app/components/auth-buttons.tsx

export function SignIn({
  redirectTo,
  ...props
}: { redirectTo?: string }) {
  const url = redirectTo ? `/api/auth/login?redirectTo=${encodeURIComponent(redirectTo)}` : '/api/auth/login';
  return (
    <a href={url} {...props}>Sign In</a>
  )
}

export function SignOut({
  redirectTo,
  ...props
}: { redirectTo?: string }) {
  const url = redirectTo ? `/api/auth/logout?redirectTo=${encodeURIComponent(redirectTo)}` : '/api/auth/logout';
  return (
    <a href={url} {...props}>
      Sign Out
    </a>
  )
}

Simple But Smart: These components are just fancy links that point to our auth routes. The beauty is in the simplicity - they handle the redirect parameter automatically, making them drop-in solutions for your UI. Now you can place login/logout buttons anywhere without repeating the logic.

5. Extending Type Definitions - TypeScript's Seal of Approval

Because TypeScript without proper types is just JavaScript with extra steps.

// types/next-auth.d.ts
import 'next-auth';

declare module 'next-auth' {
  /**
   * Extend the built-in session types
   */
  interface Session {
    accessToken: string;
    idToken: string;
    user: {
      id: string;
      email: string;
      name: string;
      image?: string;
      roles: string[];
    };
  }

  /**
   * Extend the built-in user types
   */
  interface User {
    id: string;
    name?: string;
    email?: string;
    image?: string | null;
    roles: string[];
  }
}

Why This Matters: This type definition tells TypeScript about our custom session and user properties. Without it, TypeScript would complain that rolesaccessToken, etc. don't exist on the session object. Your autocomplete will thank you.

6. Middleware - The Security Guard That Never Sleeps

Middleware runs on every request, allowing you to implement auth checks before a route even renders.

// middleware.ts
import { auth } from '~/libs/auth';
import { NextResponse } from 'next/server';

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|images|favicon.ico|robots.txt|sitemap.xml|site.webmanifest).*)',
  ],
};

export default auth((req) => {
  const { nextUrl } = req;

  // If user is signed out and the path includes `/secret` redirect
  if (!req.auth && nextUrl.pathname.includes('/secret')) {
    const redirectUrl = new URL(`/api/auth/login`, nextUrl.origin);
    redirectUrl.searchParams.append('redirectTo', req.nextUrl.pathname);
    return NextResponse.redirect(redirectUrl);
  }

  // do other bits you might want to do...
  return NextResponse.next();
});

The Power of Middleware: This middleware checks auth status before rendering protected pages. It's like having a bouncer who checks IDs before people even get to the door. The matcher pattern ensures we only run auth checks on actual pages, not on static assets (that would be inefficient and unnecessary).

7. Using the Session - The Fruits of Your Labor

On the Server - Where the Real Security Happens

// app/secret/page.tsx
import { auth } from '~/libs/auth';
import { SignIn, SignOut } from '~/components/auth-buttons';
import { redirect } from 'next/navigation';

export default async function SecretPage() {
  const session = await auth();

  if (!session || !session.user) {
    return (
      <div>
        No session data, please{' '}
        <SignIn className="inline-flex w-auto" redirectTo="/staff" />
        first.
      </div>
    );
  }

  // Check if the member is a staff member
  if (!session.user.roles.includes('staff')) {
    redirect('/unauthorized');
  }

  return (
    <div>
      <pre>{JSON.stringify(session, null, 2)}</pre>
      <br />
      <SignOut redirectTo="/staff" />
    </div>
  );
}

Server-Side Auth Checks: Server components give you true server-side authentication checks. The page doesn't even render sensitive content for unauthorised users - they never even see it in the HTML.

On the Client - For Interactive Experiences

// app/secret-client/page.tsx
"use client"
import { useSession } from "next-auth/react"
import { SignIn } from '~/components/auth-buttons';

export default function SecretClientPage() {
  const { data: session, status } = useSession()

  if (status === "loading") {
    return (
      <div>
        <p>Loading... (Please wait while we verify your secret agent credentials)</p>
      </div>
    )
  }

  if (!session?.user) {
    return (
      <div>
        <p>Access denied! You need to <SignIn>sign in</SignIn> first.</p>
      </div>
    )
  }

  return (
    <div>
      <h1>Welcome, Agent {session.user.name}</h1>
      <p>Your clearance level: {session.user.roles.join(', ')}</p>
      <pre>{JSON.stringify(session?.user, null, 2)}</pre>
    </div>
  )
}

Client-Side Auth Awareness: The useSession hook gives client components access to the session state. This is perfect for interactive UIs that need to adapt based on the auth state. Remember, though, that client-side checks are for UX, not security. Never rely solely on client-side checks to protect sensitive data.

In Conclusion

Congratulations! You've just implemented a "robust" OIDC authentication system with Stytch and next-auth. Your users can now securely log in and out, and your application knows exactly who they are and what they're allowed to do.

Remember:

  • Server components for true security
  • Client components for interactive UX
  • Middleware for route protection
  • The rest is just plumbing!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment