feat: Phase 2 — layout · auth · shell are real
@gsc/web-kit v0.2.0. Three modules turn from stubs into the working
surface apps need to render a chrome-wrapped Next.js page with one
import per concern.
auth/server:
- createAuth({ keycloak: { clientId, clientSecret, issuer } }) factory
returns { handlers, signIn, signOut, auth, requireAuth, signInPath }.
Canonical SessionUser shape (id, keycloakId, tenantId, email,
displayName, givenName, familyName, roles, accessToken, idToken)
baked into the session callback. Apps drop their hand-rolled
src/auth.ts (~80 lines) for a 6-line factory call.
- requireAuth() — server-only. await it at the top of an RSC layout
or page; redirects to signInPath if no session.
auth/middleware:
- createAuthMiddleware({ publicRoutes? }) returns a Next.js middleware
that redirects unauth'd requests to /api/auth/signin/keycloak with
?callbackUrl=<original>. Bypasses /api/auth/*, /_next/*, /images/*,
favicon, robots.txt always.
auth (client):
- signInRedirect(callbackUrl?) — hard-nav from any client component.
shell/server:
- fetchShellConfig({ appKey, accessToken, apiUrl?, timeoutMs? }).
Server-only fetcher. 3s default timeout. Graceful fallback config
on any error — shell-api outages can't blank-screen a host app.
shell (client):
- <ShellProvider> + useShell() — read the resolved config from any
descendant of <AppLayout>.
layout:
- <AppLayout config currentPath translate onSignOut navbarExtras>.
Renders the chronos-style Bootstrap-Layout-3 chrome (navbar-static,
sidebar-light sidebar-main with collapse + persistence in
localStorage, navbar-footer). Wraps children with the kit's
ShellProvider so useShell() works.
devDep: @types/node for the server-side process.env read.
All 14 sub-exports still resolve under dist/. Phase 3 (data + forms)
and the gscCRM pilot cutover come next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,24 @@
|
||||
// @gsc/web-kit/auth — Phase 1 stub. Real surface lands in later phases.
|
||||
export {};
|
||||
/**
|
||||
* @gsc/web-kit/auth — client-side auth helpers.
|
||||
*
|
||||
* Server-side surface (createAuth, requireAuth) lives in
|
||||
* `@gsc/web-kit/auth/server`. The split keeps client bundles from
|
||||
* pulling in next-auth's server runtime.
|
||||
*/
|
||||
|
||||
export type { SessionUser } from "./types";
|
||||
|
||||
/**
|
||||
* Client-side hard navigation to the sign-in endpoint. Use when a
|
||||
* component detects an auth-required action without a session — e.g.
|
||||
* a 401 from an API call.
|
||||
*
|
||||
* Default target matches createAuth's default (`/api/auth/signin/keycloak`).
|
||||
*/
|
||||
export function signInRedirect(callbackUrl?: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const target = "/api/auth/signin/keycloak";
|
||||
const url = new URL(target, window.location.origin);
|
||||
url.searchParams.set("callbackUrl", callbackUrl ?? window.location.pathname);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
@@ -1,2 +1,72 @@
|
||||
// @gsc/web-kit/auth/middleware — Phase 1 stub. Real createAuthMiddleware() lands in Phase 2.
|
||||
export {};
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export interface AuthMiddlewareOptions {
|
||||
/**
|
||||
* Path prefixes that should bypass the session check. Use sparingly —
|
||||
* pages without auth shouldn't be the default. The kit always allows
|
||||
* `/api/auth/*`, `/_next/*`, `/images/*`, and `/favicon.ico` regardless.
|
||||
*/
|
||||
publicRoutes?: string[];
|
||||
|
||||
/**
|
||||
* Where to send unauth'd users. Defaults to NextAuth's auto-redirect
|
||||
* endpoint (`/api/auth/signin/keycloak`); the original path is
|
||||
* preserved as `?callbackUrl=` so the user lands where they started.
|
||||
*/
|
||||
signInPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Next.js middleware function that redirects unauthenticated
|
||||
* requests to the sign-in endpoint. Wire it from `middleware.ts`:
|
||||
*
|
||||
* @example
|
||||
* // middleware.ts
|
||||
* import { createAuthMiddleware } from "@gsc/web-kit/auth/middleware";
|
||||
* export default createAuthMiddleware({
|
||||
* publicRoutes: ["/api/health", "/llms.txt"],
|
||||
* });
|
||||
* export const config = {
|
||||
* matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
* };
|
||||
*
|
||||
* The cookie check is intentionally cheap (presence-only): full token
|
||||
* validation still happens in NextAuth on every server render. This
|
||||
* keeps the middleware fast and edge-friendly.
|
||||
*/
|
||||
export function createAuthMiddleware(opts: AuthMiddlewareOptions = {}) {
|
||||
const publicRoutes = opts.publicRoutes ?? [];
|
||||
const signInPath = opts.signInPath ?? "/api/auth/signin/keycloak";
|
||||
|
||||
return function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (isAlwaysAllowed(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
if (publicRoutes.some((p) => pathname === p || pathname.startsWith(p + "/"))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const sessionCookie =
|
||||
req.cookies.get("authjs.session-token") ??
|
||||
req.cookies.get("__Secure-authjs.session-token");
|
||||
if (sessionCookie) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const url = new URL(signInPath, req.nextUrl);
|
||||
url.searchParams.set("callbackUrl", req.nextUrl.pathname + req.nextUrl.search);
|
||||
return NextResponse.redirect(url);
|
||||
};
|
||||
}
|
||||
|
||||
function isAlwaysAllowed(pathname: string): boolean {
|
||||
return (
|
||||
pathname.startsWith("/api/auth/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/images/") ||
|
||||
pathname === "/favicon.ico" ||
|
||||
pathname === "/robots.txt"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,124 @@
|
||||
// @gsc/web-kit/auth/server — Phase 1 stub. Real createAuth() / requireAuth() lands in Phase 2.
|
||||
export {};
|
||||
import NextAuth, { type NextAuthResult } from "next-auth";
|
||||
import Keycloak from "next-auth/providers/keycloak";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type { CreateAuthOptions, SessionUser } from "./types";
|
||||
|
||||
export type { SessionUser, CreateAuthOptions };
|
||||
|
||||
/**
|
||||
* Result of `createAuth()`. Mirrors NextAuth's return value plus
|
||||
* `requireAuth` and the resolved `signInPath` so consumers don't have to
|
||||
* thread these around.
|
||||
*/
|
||||
export interface AuthBundle {
|
||||
handlers: NextAuthResult["handlers"];
|
||||
signIn: NextAuthResult["signIn"];
|
||||
signOut: NextAuthResult["signOut"];
|
||||
auth: NextAuthResult["auth"];
|
||||
|
||||
/**
|
||||
* Server-only. Returns the current session user or redirects to the
|
||||
* sign-in path. Use as the first line of an RSC layout/page when the
|
||||
* route should be authenticated.
|
||||
*/
|
||||
requireAuth: () => Promise<SessionUser>;
|
||||
|
||||
/** Resolved sign-in URL; useful for client-side redirect helpers. */
|
||||
signInPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line factory that wires NextAuth v5 to Keycloak with the canonical
|
||||
* GSC session shape.
|
||||
*
|
||||
* @example
|
||||
* // src/auth.ts
|
||||
* import { createAuth } from "@gsc/web-kit/auth/server";
|
||||
* export const { handlers, signIn, signOut, auth, requireAuth } =
|
||||
* createAuth({
|
||||
* keycloak: {
|
||||
* clientId: process.env.AUTH_KEYCLOAK_ID!,
|
||||
* clientSecret: process.env.AUTH_KEYCLOAK_SECRET!,
|
||||
* issuer: process.env.AUTH_KEYCLOAK_ISSUER!,
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function createAuth(opts: CreateAuthOptions): AuthBundle {
|
||||
const signInPath = opts.signInPath ?? "/api/auth/signin/keycloak";
|
||||
const defaultTenantId =
|
||||
opts.defaultTenantId ?? "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const na = NextAuth({
|
||||
providers: [
|
||||
Keycloak({
|
||||
clientId: opts.keycloak.clientId,
|
||||
clientSecret: opts.keycloak.clientSecret,
|
||||
issuer: opts.keycloak.issuer,
|
||||
}),
|
||||
],
|
||||
trustHost: true,
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
// `account` + `profile` are only set on the initial sign-in.
|
||||
// On subsequent calls the token is read from the encrypted JWT
|
||||
// cookie, so the fields we set here persist for the session.
|
||||
if (account && profile) {
|
||||
token.keycloakId = profile.sub;
|
||||
token.tenantId =
|
||||
(profile as { tenant_id?: string }).tenant_id ?? defaultTenantId;
|
||||
token.displayName =
|
||||
profile.name ??
|
||||
(profile as { preferred_username?: string }).preferred_username ??
|
||||
"";
|
||||
token.givenName = profile.given_name ?? "";
|
||||
token.familyName = profile.family_name ?? "";
|
||||
token.roles =
|
||||
(profile as { realm_access?: { roles?: string[] } }).realm_access
|
||||
?.roles ?? [];
|
||||
token.accessToken = account.access_token;
|
||||
token.idToken = account.id_token;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (!token) return session;
|
||||
const user: SessionUser = {
|
||||
id: token.sub as string,
|
||||
keycloakId: (token.keycloakId as string) ?? (token.sub as string),
|
||||
tenantId: (token.tenantId as string) ?? defaultTenantId,
|
||||
email: session.user?.email ?? "",
|
||||
displayName: (token.displayName as string) ?? "",
|
||||
givenName: (token.givenName as string) ?? "",
|
||||
familyName: (token.familyName as string) ?? "",
|
||||
roles: (token.roles as string[]) ?? [],
|
||||
accessToken: (token.accessToken as string) ?? "",
|
||||
idToken: token.idToken as string | undefined,
|
||||
};
|
||||
// NextAuth's default `session.user` type is narrow; we replace
|
||||
// it wholesale with our canonical shape. The cast keeps TS quiet
|
||||
// while preserving the runtime payload.
|
||||
(session as unknown as { user: SessionUser }).user = user;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function requireAuth(): Promise<SessionUser> {
|
||||
const session = await na.auth();
|
||||
const user = (session as unknown as { user?: SessionUser } | null)?.user;
|
||||
if (!user || !user.accessToken) {
|
||||
redirect(signInPath);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
return {
|
||||
handlers: na.handlers,
|
||||
signIn: na.signIn,
|
||||
signOut: na.signOut,
|
||||
auth: na.auth,
|
||||
requireAuth,
|
||||
signInPath,
|
||||
};
|
||||
}
|
||||
|
||||
41
src/auth/types.ts
Normal file
41
src/auth/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The session shape every GSC app gets from `createAuth()`. Apps that
|
||||
* need to add their own fields should extend rather than fork.
|
||||
*/
|
||||
export interface SessionUser {
|
||||
/** NextAuth's `sub` — typically the Keycloak user UUID. */
|
||||
id: string;
|
||||
/** Same as `id`. Exposed separately so callers can name-distinguish. */
|
||||
keycloakId: string;
|
||||
/** Tenant the user is operating in. May fall back to defaultTenantId. */
|
||||
tenantId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
roles: string[];
|
||||
|
||||
/** Keycloak access token. Server-side use only — never log, never leak. */
|
||||
accessToken: string;
|
||||
/** Keycloak ID token. Optional; only present if the IdP returns it. */
|
||||
idToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to `createAuth()`.
|
||||
*/
|
||||
export interface CreateAuthOptions {
|
||||
keycloak: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer: string;
|
||||
};
|
||||
/** Used when Keycloak claims don't carry a `tenant_id`. */
|
||||
defaultTenantId?: string;
|
||||
/**
|
||||
* Where `requireAuth()` / the middleware redirects unauthenticated
|
||||
* users. Default: `/api/auth/signin/keycloak` (NextAuth's auto-redirect
|
||||
* endpoint). Override if the app has a custom landing page.
|
||||
*/
|
||||
signInPath?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user