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,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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user