123 lines
3.4 KiB
TypeScript
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;
|
|
}
|