Corpland ID

Next.js client

A copy paste client for Next.js App Router apps. The seed of the future @corpland/id-react package.

Three files give you sign in, the callback, refresh on 401 and sign out. They hold tokens in localStorage, the same place Corpland apps keep their access token today. Move to an httpOnly cookie set by your own backend when the app has outside users at scale.

Environment

NEXT_PUBLIC_CORPLAND_ID_URL=http://localhost:3300
NEXT_PUBLIC_CORPLAND_ID_API_URL=http://localhost:5008/api/v1
NEXT_PUBLIC_CORPLAND_ID_CLIENT_ID=corpland-content

lib/corpland-id.ts

const ID_URL = process.env.NEXT_PUBLIC_CORPLAND_ID_URL!;
const API = process.env.NEXT_PUBLIC_CORPLAND_ID_API_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_CORPLAND_ID_CLIENT_ID!;
const KEY = "corpland_id_tokens";

export type CorplandTokens = {
  access_token: string;
  refresh_token: string;
  expires_at: number;
  venture_id: string | null;
  user: { _id: string; name: string; email: string; image: string | null };
};

const b64 = (bytes: ArrayBuffer) =>
  btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

const callbackUrl = () => `${window.location.origin}/auth/callback`;

export const readTokens = (): CorplandTokens | null => {
  try {
    return JSON.parse(localStorage.getItem(KEY) || "null");
  } catch {
    return null;
  }
};

const saveTokens = (raw: any): CorplandTokens => {
  const tokens = {
    access_token: raw.access_token,
    refresh_token: raw.refresh_token,
    expires_at: Date.now() + raw.expires_in * 1000,
    venture_id: raw.venture_id,
    user: raw.user,
  };
  localStorage.setItem(KEY, JSON.stringify(tokens));
  return tokens;
};

export const signIn = async (options: { returnTo?: string; selectVenture?: boolean; prompt?: "login" } = {}) => {
  const verifier = b64(crypto.getRandomValues(new Uint8Array(32)).buffer);
  const challenge = b64(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
  const state = b64(crypto.getRandomValues(new Uint8Array(16)).buffer);
  sessionStorage.setItem("corpland_id_pkce", JSON.stringify({ verifier, state, returnTo: options.returnTo || "/" }));

  const url = new URL(`${ID_URL}/sign-in`);
  url.search = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: callbackUrl(),
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
    ...(options.selectVenture ? { prompt: "select_venture" } : {}),
    ...(options.prompt ? { prompt: options.prompt } : {}),
  }).toString();
  window.location.assign(url.toString());
};

export const finishSignIn = async (search: URLSearchParams) => {
  const saved = JSON.parse(sessionStorage.getItem("corpland_id_pkce") || "{}");
  sessionStorage.removeItem("corpland_id_pkce");
  if (!search.get("code") || search.get("state") !== saved.state) throw new Error("Sign in was interrupted. Try again.");

  const res = await fetch(`${API}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code: search.get("code"),
      client_id: CLIENT_ID,
      redirect_uri: callbackUrl(),
      code_verifier: saved.verifier,
    }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.message || "Sign in failed.");
  return { tokens: saveTokens(body), returnTo: saved.returnTo as string };
};

let refreshing: Promise<CorplandTokens | null> | null = null;

export const refresh = () => {
  if (refreshing) return refreshing;
  const current = readTokens();
  if (!current) return Promise.resolve(null);
  refreshing = fetch(`${API}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "refresh_token", refresh_token: current.refresh_token, client_id: CLIENT_ID }),
  })
    .then(async (res) => {
      if (res.ok) return saveTokens(await res.json());
      if (res.status >= 400 && res.status < 500) localStorage.removeItem(KEY);
      return null;
    })
    .catch(() => null)
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
};

export const accessToken = async () => {
  const tokens = readTokens();
  if (!tokens) return null;
  if (tokens.expires_at - Date.now() > 60_000) return tokens.access_token;
  return (await refresh())?.access_token ?? null;
};

export const signOut = async (returnTo = "/") => {
  const tokens = readTokens();
  localStorage.removeItem(KEY);
  if (tokens) {
    await fetch(`${API}/oauth/revoke`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token: tokens.refresh_token, client_id: CLIENT_ID }),
    }).catch(() => {});
  }
  const url = new URL(`${ID_URL}/sign-out`);
  url.search = new URLSearchParams({
    client_id: CLIENT_ID,
    post_logout_redirect_uri: `${window.location.origin}${returnTo}`,
  }).toString();
  window.location.assign(url.toString());
};

app/auth/callback/page.tsx

"use client";

import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { finishSignIn } from "@/lib/corpland-id";

export default function Callback() {
  const params = useSearchParams();
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    finishSignIn(new URLSearchParams(params.toString()))
      .then(({ returnTo }) => router.replace(returnTo))
      .catch((e) => setError(e.message));
  }, [params, router]);

  return <p>{error ?? "Signing you in"}</p>;
}

Wrap the page in <Suspense> from your layout, since it reads search params.

Calling your backend

import axios from "axios";
import { accessToken, refresh, signIn } from "@/lib/corpland-id";

export const api = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL });

api.interceptors.request.use(async (config) => {
  const token = await accessToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

api.interceptors.response.use(undefined, async (error) => {
  const original = error.config;
  if (error.response?.status === 401 && !original._retried) {
    original._retried = true;
    const next = await refresh();
    if (next) {
      original.headers.Authorization = `Bearer ${next.access_token}`;
      return api(original);
    }
    signIn({ returnTo: window.location.pathname });
  }
  return Promise.reject(error);
});

Switching venture

Send people back through sign in with selectVenture: true. They are already signed in, so they see the venture picker and one button, and you get new tokens with the new vid.

On this page