Compare commits
2 Commits
960dfeba7c
...
ec33b7bcb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec33b7bcb8 | ||
|
|
08a62d550c |
@@ -12,12 +12,12 @@ INSERT INTO nav.apps (key, name, description, url, icon_class, icon_url, icon_bg
|
|||||||
VALUES
|
VALUES
|
||||||
('gsc-crm', 'CRM', 'Customer relationship management', 'https://crm.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/crm.svg', NULL, 10, TRUE),
|
('gsc-crm', 'CRM', 'Customer relationship management', 'https://crm.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/crm.svg', NULL, 10, TRUE),
|
||||||
('gsc-chronos', 'Chronos', 'Time tracking and timesheets', 'https://chronos.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/chronos.svg', NULL, 20, TRUE),
|
('gsc-chronos', 'Chronos', 'Time tracking and timesheets', 'https://chronos.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/chronos.svg', NULL, 20, TRUE),
|
||||||
('gsc-meet', 'GSC Meet', 'Video conferencing with AI features', '/apps/gsc-meet', NULL, '/images/demo/logos/1.svg', NULL, 30, TRUE),
|
('gsc-meet', 'GSC Meet', 'Video conferencing with AI features', '/apps/gsc-meet', 'ph-video-camera', NULL, 'bg-success-lt', 30, TRUE),
|
||||||
('gsc-voice', 'Voice', 'PBX and telephony management', '/apps/gsc-voice', 'ph-phone', NULL, 'bg-primary-lt', 40, TRUE),
|
('gsc-voice', 'Voice', 'PBX and telephony management', '/apps/gsc-voice', 'ph-phone', NULL, 'bg-primary-lt', 40, TRUE),
|
||||||
('gsc-ai-hub', 'AI Hub', 'AI models and services management', '/apps/gsc-ai-hub', NULL, '/images/demo/logos/2.svg', NULL, 50, TRUE),
|
('gsc-ai-hub', 'AI Hub', 'AI models and services management', '/apps/gsc-ai-hub', 'ph-brain', NULL, 'bg-warning-lt', 50, TRUE),
|
||||||
('gsc-surveillance', 'Surveillance', 'Video surveillance and security', '/apps/surveillance', NULL, '/images/demo/logos/3.svg', NULL, 60, TRUE),
|
('gsc-surveillance', 'Surveillance', 'Video surveillance and security', '/apps/surveillance', 'ph-shield-check', NULL, 'bg-danger-lt', 60, TRUE),
|
||||||
('gsc-archive', 'Archive', 'Email archiving and eDiscovery', '/apps/gsc-archive', 'ph-archive-box', NULL, 'bg-info-lt', 70, TRUE),
|
('gsc-archive', 'Archive', 'Email archiving and eDiscovery', '/apps/gsc-archive', 'ph-archive-box', NULL, 'bg-info-lt', 70, TRUE),
|
||||||
('gsc-dam', 'Digital Asset Manager', 'Media and asset management platform', '/apps/gsc-dam', NULL, '/images/demo/logos/4.svg', NULL, 80, TRUE)
|
('gsc-dam', 'Digital Asset Manager', 'Media and asset management platform', '/apps/gsc-dam', 'ph-images-square', NULL, 'bg-secondary-lt', 80, TRUE)
|
||||||
ON CONFLICT (key) DO UPDATE
|
ON CONFLICT (key) DO UPDATE
|
||||||
SET name = EXCLUDED.name,
|
SET name = EXCLUDED.name,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
|
|||||||
@@ -1,9 +1,75 @@
|
|||||||
import NextAuth, { type NextAuthResult } from "next-auth";
|
import NextAuth, { type NextAuthResult } from "next-auth";
|
||||||
import Keycloak from "next-auth/providers/keycloak";
|
import Keycloak from "next-auth/providers/keycloak";
|
||||||
|
import type { JWT } from "next-auth/jwt";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import type { CreateAuthOptions, SessionUser } from "./types";
|
import type { CreateAuthOptions, SessionUser } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the Keycloak access token using the stored refresh_token. Returns
|
||||||
|
* an updated JWT with new accessToken/expiresAt/refreshToken/idToken, or
|
||||||
|
* the original JWT marked with `error: "RefreshAccessTokenError"` if the
|
||||||
|
* refresh failed (caller surfaces this through the session so the client
|
||||||
|
* can re-trigger sign-in).
|
||||||
|
*
|
||||||
|
* Pre-refresh skew is handled by the caller; this function unconditionally
|
||||||
|
* exchanges the refresh_token.
|
||||||
|
*/
|
||||||
|
async function refreshAccessToken(
|
||||||
|
token: JWT,
|
||||||
|
keycloak: CreateAuthOptions["keycloak"],
|
||||||
|
): Promise<JWT> {
|
||||||
|
const refreshToken = (token as { refreshToken?: string }).refreshToken;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return { ...token, error: "RefreshTokenMissing" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${keycloak.issuer}/protocol/openid-connect/token`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: keycloak.clientId,
|
||||||
|
client_secret: keycloak.clientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
};
|
||||||
|
if (!res.ok || !body.access_token) {
|
||||||
|
console.error(
|
||||||
|
"@gsc/web-kit: refresh_token exchange failed",
|
||||||
|
res.status,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: body.access_token,
|
||||||
|
expiresAt:
|
||||||
|
Math.floor(Date.now() / 1000) + (body.expires_in ?? 300),
|
||||||
|
// Keycloak rotates refresh tokens by default; fall back to the
|
||||||
|
// existing one when rotation is disabled at the realm.
|
||||||
|
refreshToken: body.refresh_token ?? refreshToken,
|
||||||
|
idToken: body.id_token ?? token.idToken,
|
||||||
|
error: undefined,
|
||||||
|
} as JWT;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("@gsc/web-kit: refresh_token exchange threw", err);
|
||||||
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type { SessionUser, CreateAuthOptions };
|
export type { SessionUser, CreateAuthOptions };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +128,35 @@ export interface AuthBundle {
|
|||||||
* },
|
* },
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Decode the Keycloak access_token (JWS) and return its `realm_access.roles`.
|
||||||
|
* Realm roles are only present in the access_token, never in the OIDC
|
||||||
|
* userinfo `profile` — so callbacks that need to gate on roles must reach
|
||||||
|
* into `account.access_token`, not `profile`. We don't verify the signature
|
||||||
|
* here: NextAuth has already verified the ID token via the OIDC flow, the
|
||||||
|
* access_token came back over the same TLS exchange, and the only use of
|
||||||
|
* the decoded roles is local sign-in policy.
|
||||||
|
*/
|
||||||
|
function rolesFromAccessToken(accessToken: string | undefined): string[] {
|
||||||
|
if (!accessToken) return [];
|
||||||
|
const parts = accessToken.split(".");
|
||||||
|
if (parts.length < 2) return [];
|
||||||
|
try {
|
||||||
|
const payload = parts[1];
|
||||||
|
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
||||||
|
const decoded = Buffer.from(
|
||||||
|
padded.replace(/-/g, "+").replace(/_/g, "/"),
|
||||||
|
"base64",
|
||||||
|
).toString("utf-8");
|
||||||
|
const claims = JSON.parse(decoded) as {
|
||||||
|
realm_access?: { roles?: string[] };
|
||||||
|
};
|
||||||
|
return claims.realm_access?.roles ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createAuth(opts: CreateAuthOptions): AuthBundle {
|
export function createAuth(opts: CreateAuthOptions): AuthBundle {
|
||||||
// NextAuth v5: provider-specific paths like /api/auth/signin/keycloak
|
// NextAuth v5: provider-specific paths like /api/auth/signin/keycloak
|
||||||
// are POST-only (CSRF-protected form submit). A GET redirect there
|
// are POST-only (CSRF-protected form submit). A GET redirect there
|
||||||
@@ -70,8 +165,6 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
|
|||||||
// lists configured providers. Apps wanting one-click Keycloak can
|
// lists configured providers. Apps wanting one-click Keycloak can
|
||||||
// override signInPath with a custom page that calls signIn('keycloak').
|
// override signInPath with a custom page that calls signIn('keycloak').
|
||||||
const signInPath = opts.signInPath ?? "/api/auth/signin";
|
const signInPath = opts.signInPath ?? "/api/auth/signin";
|
||||||
const defaultTenantId =
|
|
||||||
opts.defaultTenantId ?? "00000000-0000-0000-0000-000000000000";
|
|
||||||
|
|
||||||
const na = NextAuth({
|
const na = NextAuth({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -82,35 +175,76 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
|
pages: opts.pages,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
|
async signIn({ account }) {
|
||||||
|
// Per-client access gate. The kit's consumers pass a predicate
|
||||||
|
// (e.g. "must be in gsccrm_* group") via `requireClientRole`; we
|
||||||
|
// run it against the access_token's `realm_access.roles`. Realm
|
||||||
|
// roles are NOT present in the OIDC userinfo `profile`, only in
|
||||||
|
// the access_token — so we decode it. Return false → NextAuth
|
||||||
|
// refuses to mint a session and bounces the user to the configured
|
||||||
|
// error page with `?error=AccessDenied`.
|
||||||
|
if (!opts.requireClientRole) return true;
|
||||||
|
const roles = rolesFromAccessToken(account?.access_token);
|
||||||
|
return opts.requireClientRole(roles);
|
||||||
|
},
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile }) {
|
||||||
// `account` + `profile` are only set on the initial sign-in.
|
// Initial sign-in: capture identity claims + refresh material.
|
||||||
// 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) {
|
if (account && profile) {
|
||||||
|
// Realm convention: GSC identity ships as three claims sourced
|
||||||
|
// from FreeIPA user attributes via the gosecCloud realm's LDAP
|
||||||
|
// federation + per-client OIDC protocol mappers.
|
||||||
|
const p = profile as {
|
||||||
|
gscTenantId?: string;
|
||||||
|
gscCustomerId?: string;
|
||||||
|
gscSID?: string;
|
||||||
|
};
|
||||||
|
if (!p.gscTenantId || !p.gscCustomerId || !p.gscSID) {
|
||||||
|
throw new Error(
|
||||||
|
`Keycloak token missing required GSC identity claims (gscTenantId=${!!p.gscTenantId}, gscCustomerId=${!!p.gscCustomerId}, gscSID=${!!p.gscSID})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
token.keycloakId = profile.sub;
|
token.keycloakId = profile.sub;
|
||||||
token.tenantId =
|
token.tenantId = p.gscTenantId;
|
||||||
(profile as { tenant_id?: string }).tenant_id ?? defaultTenantId;
|
token.customerId = p.gscCustomerId;
|
||||||
|
token.gscSid = p.gscSID;
|
||||||
token.displayName =
|
token.displayName =
|
||||||
profile.name ??
|
profile.name ??
|
||||||
(profile as { preferred_username?: string }).preferred_username ??
|
(profile as { preferred_username?: string }).preferred_username ??
|
||||||
"";
|
"";
|
||||||
token.givenName = profile.given_name ?? "";
|
token.givenName = profile.given_name ?? "";
|
||||||
token.familyName = profile.family_name ?? "";
|
token.familyName = profile.family_name ?? "";
|
||||||
token.roles =
|
// Realm roles are in the access_token, not the OIDC profile.
|
||||||
(profile as { realm_access?: { roles?: string[] } }).realm_access
|
token.roles = rolesFromAccessToken(account.access_token);
|
||||||
?.roles ?? [];
|
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
token.idToken = account.id_token;
|
token.idToken = account.id_token;
|
||||||
}
|
token.refreshToken = account.refresh_token;
|
||||||
|
// `expires_at` is Unix seconds (NextAuth's normalized form);
|
||||||
|
// fall back to `expires_in` (relative) if the provider omitted it.
|
||||||
|
token.expiresAt =
|
||||||
|
(account.expires_at as number | undefined) ??
|
||||||
|
Math.floor(Date.now() / 1000) +
|
||||||
|
((account.expires_in as number | undefined) ?? 300);
|
||||||
return token;
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent calls: refresh if the access token is about to expire.
|
||||||
|
// Skew of 30s avoids handing out a token that will time out mid-flight.
|
||||||
|
const expiresAt = (token as { expiresAt?: number }).expiresAt ?? 0;
|
||||||
|
if (Math.floor(Date.now() / 1000) < expiresAt - 30) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
return refreshAccessToken(token, opts.keycloak);
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (!token) return session;
|
if (!token) return session;
|
||||||
const user: SessionUser = {
|
const user: SessionUser = {
|
||||||
id: token.sub as string,
|
id: token.sub as string,
|
||||||
keycloakId: (token.keycloakId as string) ?? (token.sub as string),
|
keycloakId: (token.keycloakId as string) ?? (token.sub as string),
|
||||||
tenantId: (token.tenantId as string) ?? defaultTenantId,
|
tenantId: token.tenantId as string,
|
||||||
|
customerId: token.customerId as string,
|
||||||
|
gscSid: token.gscSid as string,
|
||||||
email: session.user?.email ?? "",
|
email: session.user?.email ?? "",
|
||||||
displayName: (token.displayName as string) ?? "",
|
displayName: (token.displayName as string) ?? "",
|
||||||
givenName: (token.givenName as string) ?? "",
|
givenName: (token.givenName as string) ?? "",
|
||||||
@@ -118,6 +252,7 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
|
|||||||
roles: (token.roles as string[]) ?? [],
|
roles: (token.roles as string[]) ?? [],
|
||||||
accessToken: (token.accessToken as string) ?? "",
|
accessToken: (token.accessToken as string) ?? "",
|
||||||
idToken: token.idToken as string | undefined,
|
idToken: token.idToken as string | undefined,
|
||||||
|
error: (token as { error?: string }).error,
|
||||||
};
|
};
|
||||||
// NextAuth's default `session.user` type is narrow; we replace
|
// NextAuth's default `session.user` type is narrow; we replace
|
||||||
// it wholesale with our canonical shape. The cast keeps TS quiet
|
// it wholesale with our canonical shape. The cast keeps TS quiet
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ export interface SessionUser {
|
|||||||
id: string;
|
id: string;
|
||||||
/** Same as `id`. Exposed separately so callers can name-distinguish. */
|
/** Same as `id`. Exposed separately so callers can name-distinguish. */
|
||||||
keycloakId: string;
|
keycloakId: string;
|
||||||
/** Tenant the user is operating in. May fall back to defaultTenantId. */
|
/** Tenant the user is operating in. From the Keycloak `gscTenantId` claim. */
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
/** Customer the user belongs to. From the Keycloak `gscCustomerId` claim. */
|
||||||
|
customerId: string;
|
||||||
|
/**
|
||||||
|
* GSC composite security identifier (tenant + customer + user parts).
|
||||||
|
* From the Keycloak `gscSID` claim. Distinct from any Keycloak/OIDC
|
||||||
|
* session `sid`.
|
||||||
|
*/
|
||||||
|
gscSid: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
givenName: string;
|
givenName: string;
|
||||||
@@ -19,6 +27,13 @@ export interface SessionUser {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
/** Keycloak ID token. Optional; only present if the IdP returns it. */
|
/** Keycloak ID token. Optional; only present if the IdP returns it. */
|
||||||
idToken?: string;
|
idToken?: string;
|
||||||
|
/**
|
||||||
|
* Set when the kit's silent refresh failed (refresh_token expired or
|
||||||
|
* revoked). Clients should treat this as a forced re-auth signal —
|
||||||
|
* any value here means the accessToken is stale and won't be refreshed
|
||||||
|
* by the kit on its own.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,12 +45,34 @@ export interface CreateAuthOptions {
|
|||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
};
|
};
|
||||||
/** Used when Keycloak claims don't carry a `tenant_id`. */
|
|
||||||
defaultTenantId?: string;
|
|
||||||
/**
|
/**
|
||||||
* Where `requireAuth()` / the middleware redirects unauthenticated
|
* Where `requireAuth()` / the middleware redirects unauthenticated
|
||||||
* users. Default: `/api/auth/signin/keycloak` (NextAuth's auto-redirect
|
* users. Default: `/api/auth/signin/keycloak` (NextAuth's auto-redirect
|
||||||
* endpoint). Override if the app has a custom landing page.
|
* endpoint). Override if the app has a custom landing page.
|
||||||
*/
|
*/
|
||||||
signInPath?: string;
|
signInPath?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional gate: predicate that runs on initial sign-in against the
|
||||||
|
* Keycloak `realm_access.roles` array. Return `true` to allow the
|
||||||
|
* sign-in, `false` to deny — denial bounces the user to NextAuth's
|
||||||
|
* error route with `?error=AccessDenied` and no session is created.
|
||||||
|
*
|
||||||
|
* Use this to enforce "must be in <app>_* group" semantics without
|
||||||
|
* relying solely on per-endpoint role gates in downstream services.
|
||||||
|
* Example for gscCRM:
|
||||||
|
* requireClientRole: (roles) => roles.some((r) => r.startsWith("gsccrm_"))
|
||||||
|
*/
|
||||||
|
requireClientRole?: (roles: string[]) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NextAuth page overrides. Set `pages.error` to a custom branded
|
||||||
|
* full-page message route when `requireClientRole` may deny sign-in,
|
||||||
|
* so users see something meaningful instead of NextAuth's default
|
||||||
|
* error page. The route should be public (skipped by auth middleware).
|
||||||
|
*/
|
||||||
|
pages?: {
|
||||||
|
error?: string;
|
||||||
|
signIn?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user