Refactor app instance to Keycloak SSO

This commit is contained in:
2026-06-15 15:54:39 +08:00
parent fc9fd93c36
commit 461d1300ad
246 changed files with 1350 additions and 52721 deletions

View File

@ -119,13 +119,17 @@ npm install
```env
NEXT_PUBLIC_API_URL=http://127.0.0.1:10000
NEXT_PUBLIC_WS_URL=wss://127.0.0.1:10000
NEXT_PUBLIC_AUTH_PORTAL_URL=http://127.0.0.1:3081
NEXT_PUBLIC_KEYCLOAK_ISSUER=https://keycloak.bwgdi.com/realms/beaver
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=beaver-agnet
NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=http://172.19.0.245:18080/auth/callback
NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=http://172.19.0.245:18080/logout/callback
```
当前前端的地址策略是:
- 如果配置了 `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL`,优先使用显式配置
- 如果配置了 `NEXT_PUBLIC_AUTH_PORTAL_URL`,未登录跳转会优先去独立 auth portal
- 未登录会跳转到 `NEXT_PUBLIC_KEYCLOAK_ISSUER` 的 Authorization Code + PKCE 登录页
- 退出登录会跳转到 Keycloak logout endpoint并通过 `NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI` 回到前端
- 如果未配置,浏览器端会优先使用当前站点同源地址
### 启动开发环境
@ -241,7 +245,7 @@ docker build \
### 2. 技术标识
当前前端使用 Beaver 技术命名,本地 token、语言和 handoff 状态都使用 `beaver_*` key。
当前前端使用 Beaver 技术命名,本地 token、语言和 Keycloak 登录状态都使用 `beaver_*` key。
### 3. 动态内容可能仍包含英文

View File

@ -3,23 +3,45 @@
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal';
import { startKeycloakLogin } from '@/lib/keycloak-oidc';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function LoginRedirectPage() {
const { locale } = useAppI18n();
const searchParams = useSearchParams();
const loggedOut = searchParams?.get('logged_out') === '1';
useEffect(() => {
if (loggedOut) return;
const nextPath = searchParams?.get('next') || '/';
window.location.replace(buildAuthPortalUrl('/login', nextPath));
}, [searchParams]);
void startKeycloakLogin(nextPath);
}, [loggedOut, searchParams]);
if (loggedOut) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center">
<div className="text-sm text-muted-foreground">
{pickAppText(locale, '你已退出登录。', 'You have signed out.')}
</div>
<button
type="button"
className="rounded-full bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground"
onClick={() => {
const nextPath = searchParams?.get('next') || '/';
void startKeycloakLogin(nextPath);
}}
>
{pickAppText(locale, '重新登录', 'Sign in again')}
</button>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="text-sm text-muted-foreground">
{pickAppText(locale, '正在跳转到登录门户...', 'Redirecting to the sign-in portal...')}
{pickAppText(locale, '正在跳转到 Keycloak 登录...', 'Redirecting to Keycloak sign-in...')}
</div>
</div>
);

View File

@ -3,7 +3,7 @@
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal';
import { startKeycloakLogin } from '@/lib/keycloak-oidc';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
@ -13,13 +13,13 @@ export default function RegisterRedirectPage() {
useEffect(() => {
const nextPath = searchParams?.get('next') || '/mcp';
window.location.replace(buildAuthPortalUrl('/register', nextPath));
void startKeycloakLogin(nextPath);
}, [searchParams]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="text-sm text-muted-foreground">
{pickAppText(locale, '正在跳转到注册门户...', 'Redirecting to the sign-up portal...')}
{pickAppText(locale, '正在跳转到 Keycloak 登录...', 'Redirecting to Keycloak sign-in...')}
</div>
</div>
);

View File

@ -0,0 +1,68 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
clearLoginState,
exchangeKeycloakCallback,
loadLoginState,
parseAuthCallbackUrl,
} from '@/lib/keycloak-oidc';
import { getMe } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
export default function AuthCallbackPage() {
const { locale } = useAppI18n();
const router = useRouter();
const setUser = useChatStore((s) => s.setUser);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
const completeLogin = async () => {
const callback = parseAuthCallbackUrl();
if (callback.error) {
throw new Error(callback.errorDescription || callback.error);
}
if (!callback.code || !callback.state) {
throw new Error('Missing Keycloak callback code or state');
}
const loginState = loadLoginState();
if (!loginState || loginState.state !== callback.state) {
throw new Error('Invalid Keycloak login state');
}
const result = await exchangeKeycloakCallback({ code: callback.code, state: loginState });
clearLoginState();
const user = await getMe().catch(() => result.user);
if (cancelled) return;
setUser(user);
router.replace(loginState.nextPath || '/');
};
completeLogin().catch((exc) => {
clearLoginState();
if (!cancelled) {
setError(exc instanceof Error ? exc.message : String(exc));
}
});
return () => {
cancelled = true;
};
}, [router, setUser]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="max-w-lg text-center text-sm text-muted-foreground">
{error
? pickAppText(locale, `登录失败:${error}`, `Sign-in failed: ${error}`)
: pickAppText(locale, '正在完成登录...', 'Completing sign-in...')}
</div>
</div>
);
}

View File

@ -1,146 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { clearTokens, consumeHandoffCode, getMe, setTokens } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
const HANDOFF_STATE_KEY = 'beaver_handoff_state';
type HandoffState = {
code?: string;
accessToken?: string;
refreshToken?: string;
nextPath?: string;
};
function parseHandoffStateFromLocation(): HandoffState {
if (typeof window === 'undefined') {
return {};
}
const query = new URLSearchParams(window.location.search);
const code = query.get('code') || '';
const nextFromQuery = query.get('next') || '';
if (code) {
return {
code,
nextPath: nextFromQuery || '/',
};
}
const rawHash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash;
const hash = new URLSearchParams(rawHash);
const accessToken = hash.get('access_token') || '';
if (accessToken) {
return {
accessToken,
refreshToken: hash.get('refresh_token') || '',
nextPath: hash.get('next') || '/',
};
}
return {};
}
function loadHandoffState(): HandoffState {
if (typeof window === 'undefined') {
return {};
}
const fromLocation = parseHandoffStateFromLocation();
if (fromLocation.code || fromLocation.accessToken) {
sessionStorage.setItem(HANDOFF_STATE_KEY, JSON.stringify(fromLocation));
return fromLocation;
}
const cached = sessionStorage.getItem(HANDOFF_STATE_KEY) || '';
if (!cached) {
return {};
}
try {
const parsed = JSON.parse(cached) as HandoffState;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function clearHandoffState(): void {
if (typeof window === 'undefined') {
return;
}
sessionStorage.removeItem(HANDOFF_STATE_KEY);
}
export default function HandoffPage() {
const { locale } = useAppI18n();
const router = useRouter();
const setUser = useChatStore((s) => s.setUser);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
const run = async () => {
const handoff = loadHandoffState();
const nextPath = handoff.nextPath || '/';
if (!handoff.code && !handoff.accessToken) {
clearHandoffState();
setError(pickAppText(locale, '缺少登录凭证,无法进入目标前端。', 'Missing login credentials. Unable to enter the target frontend.'));
return;
}
window.history.replaceState(null, '', '/handoff');
try {
const tokenPayload = handoff.accessToken
? {
access_token: handoff.accessToken,
refresh_token: handoff.refreshToken || '',
}
: await consumeHandoffCode(handoff.code || '');
setTokens(tokenPayload.access_token, tokenPayload.refresh_token || '');
const me = await getMe();
if (cancelled) return;
clearHandoffState();
setUser(me);
router.replace(nextPath.startsWith('/') ? nextPath : '/');
} catch (err) {
clearHandoffState();
clearTokens();
if (cancelled) return;
setError(err instanceof Error ? err.message : pickAppText(locale, '目标前端登录失败', 'Target frontend sign-in failed'));
}
};
void run();
return () => {
cancelled = true;
};
}, [router, setUser]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="text-center">
<h1 className="text-xl font-semibold">{pickAppText(locale, '正在进入目标前端...', 'Entering the target frontend...')}</h1>
{error ? (
<p className="mt-3 text-sm text-red-400">{error}</p>
) : (
<p className="mt-3 text-sm text-muted-foreground">
{pickAppText(locale, '正在同步登录态,请稍候。', 'Syncing your sign-in state. Please wait.')}
</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { clearTokens } from '@/lib/api';
import { clearKeycloakLogoutInProgress, clearLoginState } from '@/lib/keycloak-oidc';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
export default function LogoutCallbackPage() {
const { locale } = useAppI18n();
const router = useRouter();
const setUser = useChatStore((s) => s.setUser);
useEffect(() => {
clearTokens();
clearLoginState();
clearKeycloakLogoutInProgress();
setUser(null);
router.replace('/login?logged_out=1');
}, [router, setUser]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="text-sm text-muted-foreground">
{pickAppText(locale, '正在退出登录...', 'Signing out...')}
</div>
</div>
);
}

View File

@ -2,8 +2,8 @@
import { useEffect } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { buildAuthPortalUrl } from '@/lib/auth-portal';
import { clearTokens, getMe, isLoggedIn } from '@/lib/api';
import { isKeycloakLogoutInProgress, startKeycloakLogin } from '@/lib/keycloak-oidc';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
@ -71,13 +71,16 @@ export default function AuthGuard({
return;
}
const isPublicRoute = pathname === '/login' || pathname === '/register';
const isPublicRoute = pathname === '/login' || pathname === '/register' || pathname === '/auth/callback' || pathname === '/logout/callback';
const loggedIn = isLoggedIn();
if (!loggedIn && !isPublicRoute) {
if (isKeycloakLogoutInProgress()) {
return;
}
const search = searchParams?.toString();
const nextPath = search ? `${pathname}?${search}` : pathname;
window.location.replace(buildAuthPortalUrl('/login', nextPath));
void startKeycloakLogin(nextPath);
return;
}
@ -94,7 +97,7 @@ export default function AuthGuard({
);
}
const isPublicRoute = pathname === '/login' || pathname === '/register';
const isPublicRoute = pathname === '/login' || pathname === '/register' || pathname === '/auth/callback' || pathname === '/logout/callback';
if (!isPublicRoute && (!isLoggedIn() || !user)) {
return null;
}

View File

@ -2,9 +2,10 @@
import React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, Menu, MessageSquare, Puzzle, Settings, Store, Wrench, X } from 'lucide-react';
import { logout } from '@/lib/api';
import { clearTokens, getIdToken, logout } from '@/lib/api';
import { buildKeycloakLogoutUrl, markKeycloakLogoutInProgress } from '@/lib/keycloak-oidc';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@ -68,7 +69,6 @@ function ConnectionDot() {
const Header = () => {
const { locale } = useAppI18n();
const pathname = usePathname();
const router = useRouter();
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const user = useChatStore((s) => s.user);
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
@ -87,11 +87,13 @@ const Header = () => {
return pickAppText(locale, '配置', 'Settings');
}, [locale]);
const handleLogout = async () => {
await logout();
const handleLogout = () => {
const logoutUrl = buildKeycloakLogoutUrl(getIdToken());
markKeycloakLogoutInProgress();
void logout();
clearTokens();
setUser(null);
router.replace('/login');
router.refresh();
window.location.assign(logoutUrl);
};
React.useEffect(() => {

View File

@ -1,4 +1,7 @@
# 单用户后端地址Boardware Genius Web 后端)
NEXT_PUBLIC_API_URL=http://127.0.0.1:10000
NEXT_PUBLIC_WS_URL=wss://127.0.0.1:10000
NEXT_PUBLIC_AUTH_PORTAL_URL=http://127.0.0.1:3081
NEXT_PUBLIC_KEYCLOAK_ISSUER=https://keycloak.bwgdi.com/realms/beaver
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=beaver-agnet
NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=http://172.19.0.245:18080/auth/callback
NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=http://172.19.0.245:18080/logout/callback

View File

@ -58,6 +58,7 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
const DEFAULT_API_URL = 'http://127.0.0.1:18080';
const ACCESS_TOKEN_KEY = 'beaver_access_token';
const REFRESH_TOKEN_KEY = 'beaver_refresh_token';
const ID_TOKEN_KEY = 'beaver_id_token';
const REQUEST_TIMEOUT_MS = 8000;
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000;
@ -75,31 +76,6 @@ function isBrowser(): boolean {
return typeof window !== 'undefined';
}
function normalizeBaseUrl(value?: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
return trimmed.replace(/\/+$/, '');
}
export function buildAuthHandoffUrl(response: TokenResponse, nextPath: string): string | null {
const targetBaseUrl = normalizeBaseUrl(
response.backend_connection?.frontend_base_url ||
response.backend_connection?.public_base_url ||
response.backend_connection?.api_base_url ||
response.local_backend?.public_base_url
);
if (!targetBaseUrl) return null;
const handoffCode = response.handoff_code?.trim();
if (!handoffCode) return null;
const target = new URL('/handoff', targetBaseUrl);
target.searchParams.set('code', handoffCode);
if (nextPath) {
target.searchParams.set('next', nextPath);
}
return target.toString();
}
function getApiBaseUrl(): string {
if (API_URL) return API_URL;
if (isBrowser()) return window.location.origin;
@ -153,16 +129,31 @@ export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(access: string, refresh: string): void {
export function getIdToken(): string | null {
if (!isBrowser()) return null;
return localStorage.getItem(ID_TOKEN_KEY);
}
export function setTokens(access: string, refresh: string, idToken: string = ''): void {
if (!isBrowser()) return;
localStorage.setItem(ACCESS_TOKEN_KEY, access);
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
if (refresh) {
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
} else {
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
if (idToken) {
localStorage.setItem(ID_TOKEN_KEY, idToken);
} else {
localStorage.removeItem(ID_TOKEN_KEY);
}
}
export function clearTokens(): void {
if (!isBrowser()) return;
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(ID_TOKEN_KEY);
}
export function isLoggedIn(): boolean {
@ -233,27 +224,6 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
// Auth API
// ---------------------------------------------------------------------------
export async function register(username: string, email: string, password: string): Promise<TokenResponse> {
return fetchJSON('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, email, password }),
});
}
export async function login(username: string, password: string): Promise<TokenResponse> {
return fetchJSON('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
export async function consumeHandoffCode(code: string): Promise<TokenResponse> {
return fetchJSON('/api/auth/handoff/consume', {
method: 'POST',
body: JSON.stringify({ code }),
});
}
export async function logout(): Promise<void> {
try {
await fetchJSON('/api/auth/logout', { method: 'POST' });

View File

@ -1,31 +0,0 @@
'use client';
const AUTH_PORTAL_URL = process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim();
const AUTH_PORTAL_PORT = process.env.NEXT_PUBLIC_AUTH_PORTAL_PORT?.trim() || '3081';
function normalizeBaseUrl(value?: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
return trimmed.replace(/\/+$/, '');
}
function getPortalBaseUrl(): string {
const explicit = normalizeBaseUrl(AUTH_PORTAL_URL);
if (explicit) return explicit;
if (typeof window !== 'undefined') {
const url = new URL(window.location.origin);
url.port = AUTH_PORTAL_PORT;
return normalizeBaseUrl(url.toString()) || window.location.origin;
}
return `http://127.0.0.1:${AUTH_PORTAL_PORT}`;
}
export function buildAuthPortalUrl(path: '/login' | '/register', nextPath?: string | null): string {
const url = new URL(path, `${getPortalBaseUrl()}/`);
const nextValue = nextPath?.trim();
if (nextValue) {
url.searchParams.set('next', nextValue);
}
return url.toString();
}

View File

@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
KEYCLOAK_LOGIN_STATE_KEY,
KEYCLOAK_LOGOUT_IN_PROGRESS_KEY,
buildKeycloakAuthorizeUrl,
buildKeycloakLogoutUrl,
clearKeycloakLogoutInProgress,
createCodeChallenge,
isKeycloakLogoutInProgress,
markKeycloakLogoutInProgress,
parseAuthCallbackUrl,
} from './keycloak-oidc';
function createStorage() {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => data.set(key, value),
removeItem: (key: string) => data.delete(key),
clear: () => data.clear(),
};
}
const testSessionStorage = createStorage();
const testWindow = {
location: new URL('http://172.19.0.245:18080/login'),
sessionStorage: testSessionStorage,
crypto: globalThis.crypto,
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: testWindow,
});
Object.defineProperty(globalThis, 'sessionStorage', {
configurable: true,
value: testSessionStorage,
});
function setWindowLocation(url: string) {
testWindow.location = new URL(url);
}
describe('keycloak oidc helpers', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T08:00:00.000Z'));
testSessionStorage.clear();
setWindowLocation('http://172.19.0.245:18080/login');
});
afterEach(() => {
vi.useRealTimers();
testSessionStorage.clear();
});
test('builds a Keycloak authorization URL using code flow with PKCE and nonce', () => {
const url = new URL(buildKeycloakAuthorizeUrl({
state: 'state-1',
nonce: 'nonce-1',
codeChallenge: 'challenge-1',
nextPath: '/files',
}));
expect(url.origin + url.pathname).toBe('https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/auth');
expect(url.searchParams.get('client_id')).toBe('beaver-agnet');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('scope')).toBe('openid profile email');
expect(url.searchParams.get('redirect_uri')).toBe('http://172.19.0.245:18080/auth/callback');
expect(url.searchParams.get('state')).toBe('state-1');
expect(url.searchParams.get('nonce')).toBe('nonce-1');
expect(url.searchParams.get('code_challenge')).toBe('challenge-1');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
});
test('creates the RFC 7636 S256 challenge without requiring https WebCrypto', async () => {
const challenge = await createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk');
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
test('builds a logout URL with post_logout_redirect_uri and id_token_hint', () => {
const url = new URL(buildKeycloakLogoutUrl('id-token-1'));
expect(url.origin + url.pathname).toBe('https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/logout');
expect(url.searchParams.get('client_id')).toBe('beaver-agnet');
expect(url.searchParams.get('id_token_hint')).toBe('id-token-1');
expect(url.searchParams.get('post_logout_redirect_uri')).toBe('http://172.19.0.245:18080/logout/callback');
});
test('tracks logout progress briefly to prevent immediate login restart', () => {
expect(isKeycloakLogoutInProgress()).toBe(false);
markKeycloakLogoutInProgress();
expect(testSessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY)).toBe('1781510400000');
expect(isKeycloakLogoutInProgress()).toBe(true);
vi.advanceTimersByTime(121_000);
expect(isKeycloakLogoutInProgress()).toBe(false);
});
test('parses callback code and state from the current URL', () => {
setWindowLocation('http://172.19.0.245:18080/auth/callback?code=abc&state=xyz');
expect(parseAuthCallbackUrl()).toEqual({ code: 'abc', state: 'xyz', error: '', errorDescription: '' });
});
test('exports stable session storage keys', () => {
expect(KEYCLOAK_LOGIN_STATE_KEY).toBe('beaver_keycloak_login_state');
clearKeycloakLogoutInProgress();
expect(testSessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY)).toBeNull();
});
});

View File

@ -0,0 +1,324 @@
import type { AuthUser, TokenResponse } from '@/types';
import { setTokens } from '@/lib/api';
export const KEYCLOAK_LOGIN_STATE_KEY = 'beaver_keycloak_login_state';
export const KEYCLOAK_LOGOUT_IN_PROGRESS_KEY = 'beaver_keycloak_logout_in_progress';
const DEFAULT_KEYCLOAK_ISSUER = 'https://keycloak.bwgdi.com/realms/beaver';
const DEFAULT_KEYCLOAK_CLIENT_ID = 'beaver-agnet';
const DEFAULT_SCOPE = 'openid profile email';
const LOGOUT_PROGRESS_TTL_MS = 120_000;
export type KeycloakLoginState = {
state: string;
nonce: string;
codeVerifier: string;
redirectUri: string;
nextPath: string;
createdAt: number;
};
export type KeycloakCallbackParams = {
code: string;
state: string;
error: string;
errorDescription: string;
};
type AuthorizeUrlInput = {
state: string;
nonce: string;
codeChallenge: string;
nextPath?: string;
};
type CallbackExchangeInput = {
code: string;
state: KeycloakLoginState;
};
type CallbackExchangeResult = {
token: TokenResponse;
user: AuthUser;
};
function isBrowser(): boolean {
return typeof window !== 'undefined';
}
function cleanBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
function envValue(name: string): string {
const value = process.env[name]?.trim();
return value || '';
}
export function getKeycloakIssuer(): string {
return cleanBaseUrl(envValue('NEXT_PUBLIC_KEYCLOAK_ISSUER') || DEFAULT_KEYCLOAK_ISSUER);
}
export function getKeycloakClientId(): string {
return envValue('NEXT_PUBLIC_KEYCLOAK_CLIENT_ID') || DEFAULT_KEYCLOAK_CLIENT_ID;
}
export function getKeycloakRedirectUri(): string {
const configured = envValue('NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI');
if (configured) return configured;
if (isBrowser()) return `${window.location.origin}/auth/callback`;
return '/auth/callback';
}
export function getKeycloakPostLogoutRedirectUri(): string {
const configured = envValue('NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI');
if (configured) return configured;
if (isBrowser()) return `${window.location.origin}/logout/callback`;
return '/logout/callback';
}
export function buildKeycloakAuthorizeUrl(input: AuthorizeUrlInput): string {
const url = new URL(`${getKeycloakIssuer()}/protocol/openid-connect/auth`);
url.searchParams.set('client_id', getKeycloakClientId());
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', DEFAULT_SCOPE);
url.searchParams.set('redirect_uri', getKeycloakRedirectUri());
url.searchParams.set('state', input.state);
url.searchParams.set('nonce', input.nonce);
url.searchParams.set('code_challenge', input.codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
return url.toString();
}
export function buildKeycloakLogoutUrl(idToken?: string | null): string {
const url = new URL(`${getKeycloakIssuer()}/protocol/openid-connect/logout`);
url.searchParams.set('client_id', getKeycloakClientId());
url.searchParams.set('post_logout_redirect_uri', getKeycloakPostLogoutRedirectUri());
if (idToken) {
url.searchParams.set('id_token_hint', idToken);
}
return url.toString();
}
function base64Url(bytes: Uint8Array): string {
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function rotateRight(value: number, shift: number): number {
return (value >>> shift) | (value << (32 - shift));
}
function sha256Fallback(input: string): Uint8Array {
const bytes = new TextEncoder().encode(input);
const bitLength = bytes.length * 8;
const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6);
const padded = new Uint8Array(paddedLength);
padded.set(bytes);
padded[bytes.length] = 0x80;
const view = new DataView(padded.buffer);
view.setUint32(paddedLength - 4, bitLength, false);
const h = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
];
const k = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
const w = new Array<number>(64);
for (let offset = 0; offset < paddedLength; offset += 64) {
for (let i = 0; i < 16; i += 1) {
w[i] = view.getUint32(offset + i * 4, false);
}
for (let i = 16; i < 64; i += 1) {
const s0 = rotateRight(w[i - 15], 7) ^ rotateRight(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotateRight(w[i - 2], 17) ^ rotateRight(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, hh] = h;
for (let i = 0; i < 64; i += 1) {
const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (hh + s1 + ch + k[i] + w[i]) >>> 0;
const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (s0 + maj) >>> 0;
hh = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
h[0] = (h[0] + a) >>> 0;
h[1] = (h[1] + b) >>> 0;
h[2] = (h[2] + c) >>> 0;
h[3] = (h[3] + d) >>> 0;
h[4] = (h[4] + e) >>> 0;
h[5] = (h[5] + f) >>> 0;
h[6] = (h[6] + g) >>> 0;
h[7] = (h[7] + hh) >>> 0;
}
const digest = new Uint8Array(32);
const digestView = new DataView(digest.buffer);
h.forEach((value, index) => digestView.setUint32(index * 4, value, false));
return digest;
}
export async function createCodeChallenge(codeVerifier: string): Promise<string> {
const subtle = isBrowser() ? window.crypto?.subtle : globalThis.crypto?.subtle;
if (subtle) {
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
return base64Url(new Uint8Array(digest));
}
return base64Url(sha256Fallback(codeVerifier));
}
function randomUrlSafe(bytes = 32): string {
const random = new Uint8Array(bytes);
const cryptoApi = isBrowser() ? window.crypto : globalThis.crypto;
if (cryptoApi?.getRandomValues) {
cryptoApi.getRandomValues(random);
} else {
for (let i = 0; i < random.length; i += 1) {
random[i] = Math.floor(Math.random() * 256);
}
}
return base64Url(random);
}
export function saveLoginState(state: KeycloakLoginState): void {
if (!isBrowser()) return;
sessionStorage.setItem(KEYCLOAK_LOGIN_STATE_KEY, JSON.stringify(state));
}
export function loadLoginState(): KeycloakLoginState | null {
if (!isBrowser()) return null;
const raw = sessionStorage.getItem(KEYCLOAK_LOGIN_STATE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<KeycloakLoginState>;
if (!parsed.state || !parsed.nonce || !parsed.codeVerifier || !parsed.redirectUri) {
return null;
}
return {
state: parsed.state,
nonce: parsed.nonce,
codeVerifier: parsed.codeVerifier,
redirectUri: parsed.redirectUri,
nextPath: parsed.nextPath || '/',
createdAt: Number(parsed.createdAt || Date.now()),
};
} catch {
return null;
}
}
export function clearLoginState(): void {
if (!isBrowser()) return;
sessionStorage.removeItem(KEYCLOAK_LOGIN_STATE_KEY);
}
export function markKeycloakLogoutInProgress(): void {
if (!isBrowser()) return;
sessionStorage.setItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY, String(Date.now()));
}
export function clearKeycloakLogoutInProgress(): void {
if (!isBrowser()) return;
sessionStorage.removeItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY);
}
export function isKeycloakLogoutInProgress(): boolean {
if (!isBrowser()) return false;
const raw = sessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY);
if (!raw) return false;
const timestamp = Number(raw);
if (!Number.isFinite(timestamp) || Date.now() - timestamp > LOGOUT_PROGRESS_TTL_MS) {
clearKeycloakLogoutInProgress();
return false;
}
return true;
}
export async function startKeycloakLogin(nextPath = '/'): Promise<void> {
if (!isBrowser()) return;
clearKeycloakLogoutInProgress();
const codeVerifier = randomUrlSafe(64);
const state = randomUrlSafe(32);
const nonce = randomUrlSafe(32);
const codeChallenge = await createCodeChallenge(codeVerifier);
const loginState: KeycloakLoginState = {
state,
nonce,
codeVerifier,
redirectUri: getKeycloakRedirectUri(),
nextPath,
createdAt: Date.now(),
};
saveLoginState(loginState);
window.location.assign(buildKeycloakAuthorizeUrl({ state, nonce, codeChallenge, nextPath }));
}
export function parseAuthCallbackUrl(): KeycloakCallbackParams {
const params = isBrowser() ? new URLSearchParams(window.location.search) : new URLSearchParams();
return {
code: params.get('code') || '',
state: params.get('state') || '',
error: params.get('error') || '',
errorDescription: params.get('error_description') || '',
};
}
function getApiBaseUrl(): string {
const configured = envValue('NEXT_PUBLIC_API_URL');
if (configured) return configured.replace(/\/+$/, '');
return '';
}
export async function exchangeKeycloakCallback(input: CallbackExchangeInput): Promise<CallbackExchangeResult> {
const response = await fetch(`${getApiBaseUrl()}/api/auth/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: input.code,
state: input.state.state,
code_verifier: input.state.codeVerifier,
redirect_uri: input.state.redirectUri,
nonce: input.state.nonce,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Keycloak callback failed: ${response.status} ${text}`);
}
const token = await response.json() as TokenResponse;
setTokens(token.access_token, token.refresh_token || '', token.id_token || '');
return {
token,
user: {
id: token.user_id,
username: token.username,
email: token.email || '',
role: token.role || 'owner',
quota_tier: 'single-user',
},
};
}

View File

@ -22,12 +22,13 @@ export interface BackendConnectionInfo {
export interface TokenResponse {
access_token: string;
refresh_token: string;
id_token?: string;
expires_in?: number;
token_type: string;
user_id: string;
username: string;
email?: string;
role: string;
handoff_code?: string;
handoff_expires_at?: number;
backend_connection?: BackendConnectionInfo | null;
local_backend?: AuthzLocalBackendStatus | null;
}