Files
next-tjwater-frontend/src/shared/auth/keycloak-auth.ts
T

123 lines
3.4 KiB
TypeScript

import Keycloak, {
type KeycloakConfig,
type KeycloakInitOptions,
type KeycloakLoginOptions,
type KeycloakLogoutOptions,
type KeycloakTokenParsed
} from "keycloak-js";
import type { RuntimeConfig } from "@/shared/config/env";
const TOKEN_MIN_VALIDITY_SECONDS = 30;
export type AuthenticatedUser = {
name: string;
role: string;
};
export type AccessTokenProvider = () => Promise<string | null>;
export type Authentication = {
enabled: boolean;
user: AuthenticatedUser | null;
getAccessToken: AccessTokenProvider;
logout?: () => Promise<void>;
};
type KeycloakClient = {
token?: string;
tokenParsed?: KeycloakTokenParsed;
idTokenParsed?: KeycloakTokenParsed;
onTokenExpired?: () => void;
init: (options: KeycloakInitOptions) => Promise<boolean>;
login: (options?: KeycloakLoginOptions) => Promise<void>;
logout: (options?: KeycloakLogoutOptions) => Promise<void>;
updateToken: (minValidity: number) => Promise<boolean>;
};
type KeycloakClientFactory = (config: KeycloakConfig) => KeycloakClient;
export async function initializeAuthentication(
config: RuntimeConfig,
createClient: KeycloakClientFactory = (keycloakConfig) => new Keycloak(keycloakConfig)
): Promise<Authentication> {
if (config.TJWATER_AUTH_MODE === "disabled") {
return {
enabled: false,
user: null,
getAccessToken: async () => null
};
}
const keycloak = createClient(
toKeycloakConfig(
config.TJWATER_KEYCLOAK_ISSUER,
config.TJWATER_KEYCLOAK_CLIENT_ID
)
);
const redirectUri = `${window.location.origin}/`;
const login = () => keycloak.login({ redirectUri });
const authenticated = await keycloak.init({
onLoad: "login-required",
flow: "standard",
pkceMethod: "S256"
});
if (!authenticated) {
await login();
throw new Error("Keycloak authentication was not completed");
}
const refreshAccessToken = async () => {
try {
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
} catch (error) {
await login();
throw error;
}
};
keycloak.onTokenExpired = () => {
void refreshAccessToken().catch(() => undefined);
};
return {
enabled: true,
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
getAccessToken: async () => {
await refreshAccessToken();
if (!keycloak.token) {
throw new Error("Keycloak access token is unavailable");
}
return keycloak.token;
},
logout: () => keycloak.logout({ redirectUri })
};
}
export function toKeycloakConfig(issuer: string, clientId: string): KeycloakConfig {
const url = new URL(issuer);
const realmMarker = "/realms/";
const markerIndex = url.pathname.lastIndexOf(realmMarker);
const realm = decodeURIComponent(url.pathname.slice(markerIndex + realmMarker.length));
const serverPath = url.pathname.slice(0, markerIndex).replace(/\/+$/, "");
return {
url: `${url.origin}${serverPath}`,
realm,
clientId
};
}
export function toAuthenticatedUser(token: KeycloakTokenParsed | undefined): AuthenticatedUser {
const name = readClaim(token, "name") ?? readClaim(token, "preferred_username") ?? readClaim(token, "sub") ?? "已认证用户";
return {
name,
role: readClaim(token, "email") ?? "统一认证用户"
};
}
function readClaim(token: KeycloakTokenParsed | undefined, key: string) {
const value = token?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}