Compare commits
6
Commits
1fe5acc871
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36745cc9c4 | ||
|
|
ca3abb19e1 | ||
|
|
7a910b3d8f | ||
|
|
bad3b5539c | ||
|
|
1f7164ed2b | ||
|
|
b006b07e63 |
@@ -40,3 +40,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
src/app/api/auth/debug
|
||||||
|
|||||||
@@ -145,3 +145,5 @@ git tag -d v1.0.0; git push origin :refs/tags/v1.0.0; git tag -a v1.0.0 -m "Rele
|
|||||||
- No error when streaming with too large model
|
- No error when streaming with too large model
|
||||||
- Translations
|
- Translations
|
||||||
- When user stop generation, forward it to server
|
- When user stop generation, forward it to server
|
||||||
|
- Message UI
|
||||||
|
- UX (eg keep focus when press enter)
|
||||||
|
|||||||
+54
-231
@@ -1,243 +1,66 @@
|
|||||||
// 'use client'
|
"use client";
|
||||||
|
|
||||||
// import { useEffect, useState } from 'react'
|
import { useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
// export default function AboutPage() {
|
export default function GenerateKeyButton() {
|
||||||
// const [data, setData] = useState<any>(null)
|
const [loading, setLoading] = useState(false);
|
||||||
// const [loading, setLoading] = useState(true)
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
|
||||||
// useEffect(() => {
|
const { data: session, status } = useSession();
|
||||||
// fetch((process.env.NEXT_PUBLIC_API_URL as string) + "/docs.json")
|
|
||||||
// .then((res) => res.json())
|
|
||||||
// .then((result) => {
|
|
||||||
// setData(result)
|
|
||||||
// setLoading(false)
|
|
||||||
// })
|
|
||||||
// .catch((err) => {
|
|
||||||
// console.error(err)
|
|
||||||
// setLoading(false)
|
|
||||||
// })
|
|
||||||
// }, [])
|
|
||||||
|
|
||||||
// return (
|
async function handleGenerate() {
|
||||||
// <div className="p-6">
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
// {loading && <p>Loading...</p>}
|
try {
|
||||||
|
const res = await fetch(process.env.NEXT_PUBLIC_API_URL! + '/keys/generate', {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${session?.accessToken}`,
|
||||||
|
// "x-api-key": "Q67lQp7NZY2f2Zy6DXYbFoauDMjw79FBD8LPn9ee6OM"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "my-api-key",
|
||||||
|
scopes: ["read", "write"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// {!loading && data && (
|
if (!res.ok) {
|
||||||
// <pre>{JSON.stringify(data, null, 2)}</pre>
|
const errText = await res.text();
|
||||||
// )}
|
throw new Error(errText);
|
||||||
// </div>
|
}
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 'use client';
|
const data = await res.json();
|
||||||
|
setResult(JSON.stringify(data, null, 2));
|
||||||
// import { useEffect, useState } from 'react';
|
} catch (err: any) {
|
||||||
// import Spinner from '@/components/spinner';
|
setResult(err.message);
|
||||||
|
} finally {
|
||||||
// export default function AboutPage() {
|
setLoading(false);
|
||||||
// const [data, setData] = useState<any>(null);
|
}
|
||||||
// const [loading, setLoading] = useState(true);
|
}
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// fetch((process.env.NEXT_PUBLIC_API_URL as string) + '/docs.json')
|
|
||||||
// .then((res) => res.json())
|
|
||||||
// .then((result) => {
|
|
||||||
// setData(result);
|
|
||||||
// setLoading(false);
|
|
||||||
// })
|
|
||||||
// .catch(() => {
|
|
||||||
// setLoading(false);
|
|
||||||
// });
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
// if (loading) {
|
|
||||||
// return (
|
|
||||||
// <div className="flex items-center justify-center min-h-screen">
|
|
||||||
// <div className="h-12 w-12 rounded-full border-4 border-gray-300 border-t-blue-600 animate-spin" />
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className="flex items-center justify-center min-h-screen bg-primary text-primary transition-theme">
|
|
||||||
// <Spinner />
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Sidebar from '@/components/Sidebar';
|
|
||||||
import { SidebarPanel } from '@/components/Sidebar';
|
|
||||||
import {
|
|
||||||
Slider,
|
|
||||||
NumberInput,
|
|
||||||
TextInput,
|
|
||||||
Toggle,
|
|
||||||
} from '../../../components/Inputs';
|
|
||||||
|
|
||||||
type PanelProps = {
|
|
||||||
settings: Settings;
|
|
||||||
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Settings = {
|
|
||||||
temp: number;
|
|
||||||
topP: number;
|
|
||||||
topK: number | undefined;
|
|
||||||
maxTokens: number | undefined;
|
|
||||||
numCtx: number | undefined;
|
|
||||||
stop: string[] | undefined;
|
|
||||||
repeatPenalty: number;
|
|
||||||
seed: number | undefined;
|
|
||||||
stream: boolean;
|
|
||||||
keepAlive: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SettingsPanel({ settings, setSettings }: PanelProps) {
|
|
||||||
const set =
|
|
||||||
<K extends keyof Settings>(key: K) =>
|
|
||||||
(val: Settings[K]) =>
|
|
||||||
setSettings((prev) => ({ ...prev, [key]: val }));
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{
|
|
||||||
title: 'Sampling',
|
|
||||||
fields: (s: any, set: any) => (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
label="Temperature"
|
|
||||||
value={s.temp}
|
|
||||||
setValue={set('temp')}
|
|
||||||
min={0}
|
|
||||||
max={2}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label="Top P"
|
|
||||||
value={s.topP}
|
|
||||||
setValue={set('topP')}
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
<NumberInput label="Top K" value={s.topK} setValue={set('topK')} />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Generation',
|
|
||||||
fields: (s: any, set: any) => (
|
|
||||||
<>
|
|
||||||
<NumberInput
|
|
||||||
label="Max Tokens"
|
|
||||||
value={s.maxTokens}
|
|
||||||
setValue={set('maxTokens')}
|
|
||||||
/>
|
|
||||||
<NumberInput
|
|
||||||
label="Context Size"
|
|
||||||
value={s.numCtx}
|
|
||||||
setValue={set('numCtx')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Stop Sequences"
|
|
||||||
value={s.stop?.join(', ')}
|
|
||||||
setValue={(val) =>
|
|
||||||
set('stop')(val ? val.split(',').map((s) => s.trim()) : undefined)
|
|
||||||
}
|
|
||||||
placeholder="Comma separated"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Control',
|
|
||||||
fields: (s: any, set: any) => (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
label="Repeat Penalty"
|
|
||||||
value={s.repeatPenalty}
|
|
||||||
setValue={set('repeatPenalty')}
|
|
||||||
min={0}
|
|
||||||
max={2}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
<NumberInput label="Seed" value={s.seed} setValue={set('seed')} />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'System',
|
|
||||||
fields: (s: any, set: any) => (
|
|
||||||
<>
|
|
||||||
<Toggle label="Streaming" value={s.stream} setValue={set('stream')} />
|
|
||||||
<TextInput
|
|
||||||
label="Keep Alive"
|
|
||||||
value={s.keepAlive}
|
|
||||||
setValue={set('keepAlive')}
|
|
||||||
placeholder="e.g. 5m"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 px-1">
|
<div style={{ padding: 20 }}>
|
||||||
{sections.map((section) => (
|
<button
|
||||||
<div key={section.title} className="space-y-4">
|
onClick={handleGenerate}
|
||||||
<h3 className="text-xs uppercase text-accent tracking-widest">
|
disabled={loading}
|
||||||
{section.title}
|
style={{
|
||||||
</h3>
|
padding: "10px 16px",
|
||||||
|
background: "black",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Generating..." : "Generate API Key"}
|
||||||
|
</button>
|
||||||
|
|
||||||
{section.fields(settings, set)}
|
{result && (
|
||||||
</div>
|
<pre style={{ marginTop: 20, background: "#eee", padding: 10 }}>
|
||||||
))}
|
{result}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SIDEBAR_PANELS = ({
|
|
||||||
settings,
|
|
||||||
setSettings,
|
|
||||||
}: PanelProps): SidebarPanel[] => [
|
|
||||||
{
|
|
||||||
id: 'settings',
|
|
||||||
label: 'Settings',
|
|
||||||
icon: (
|
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M7.84 1.804A1 1 0 018.82 1h2.36a1 1 0 01.98.804l.331 1.652a6.993 6.993 0 011.929 1.115l1.598-.54a1 1 0 011.186.447l1.18 2.044a1 1 0 01-.205 1.251l-1.267 1.113a7.047 7.047 0 010 2.228l1.267 1.113a1 1 0 01.205 1.251l-1.18 2.044a1 1 0 01-1.186.447l-1.598-.54a6.993 6.993 0 01-1.929 1.115l-.33 1.652a1 1 0 01-.98.804H8.82a1 1 0 01-.98-.804l-.331-1.652a6.993 6.993 0 01-1.929-1.115l-1.598.54a1 1 0 01-1.186-.447l-1.18-2.044a1 1 0 01.205-1.251l1.267-1.114a7.05 7.05 0 010-2.227L1.821 7.773a1 1 0 01-.206-1.25l1.18-2.045a1 1 0 011.187-.447l1.598.54A6.993 6.993 0 017.51 3.456l.33-1.652zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
component: <SettingsPanel settings={settings} setSettings={setSettings} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export default function DocPage() {
|
|
||||||
const [settings, setSettings] = useState<Settings>({
|
|
||||||
temp: 0.7,
|
|
||||||
topP: 0.9,
|
|
||||||
topK: 40,
|
|
||||||
maxTokens: 1000,
|
|
||||||
numCtx: 128000,
|
|
||||||
stop: [''],
|
|
||||||
repeatPenalty: 1.1,
|
|
||||||
seed: 0,
|
|
||||||
stream: true,
|
|
||||||
keepAlive: '5m',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-1 overflow-hidden min-h-0">
|
|
||||||
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,9 @@ import { routing } from '@/i18n/routing';
|
|||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { Providers } from '../providers';
|
import { Providers } from '../providers';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from '@/lib/auth';
|
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import SessionGuard from '@/components/SessionGuard';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Iceberg template',
|
title: 'Iceberg template',
|
||||||
@@ -42,13 +43,15 @@ export default async function RootLayout(props: {
|
|||||||
<html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}>
|
<html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}>
|
||||||
<body className="h-screen flex flex-col bg-primary text-primary transition-theme">
|
<body className="h-screen flex flex-col bg-primary text-primary transition-theme">
|
||||||
<Providers locale={locale} messages={messages} session={session}>
|
<Providers locale={locale} messages={messages} session={session}>
|
||||||
<Navbar />
|
<SessionGuard>
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
<main className="flex-1 pt-[var(--header-height)] flex overflow-hidden">
|
<main className="flex-1 pt-[var(--header-height)] flex overflow-hidden">
|
||||||
{props.children}
|
{props.children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</SessionGuard>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { env } from "process";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const res = await fetch(env.NEXT_PUBLIC_API_URL! + "/docs.json");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
return Response.json(data);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export function Providers({
|
|||||||
messages: AbstractIntlMessages;
|
messages: AbstractIntlMessages;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider session={session}>
|
<SessionProvider session={session} refetchInterval={4 * 60} refetchOnWindowFocus={true}>
|
||||||
<NextIntlClientProvider
|
<NextIntlClientProvider
|
||||||
locale={locale}
|
locale={locale}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession, signIn } from "next-auth/react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
|
||||||
|
export default function SessionGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const triedSilent = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated" && !triedSilent.current) {
|
||||||
|
triedSilent.current = true;
|
||||||
|
signIn("keycloak", undefined, { prompt: "none" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session?.error === "RefreshTokenExpired" ||
|
||||||
|
session?.error === "RefreshAccessTokenError"
|
||||||
|
) {
|
||||||
|
signIn("keycloak", undefined, { prompt: "none" });
|
||||||
|
}
|
||||||
|
}, [status, session?.error]);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return <Spinner />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
+157
-70
@@ -1,30 +1,34 @@
|
|||||||
import { NextAuthOptions } from 'next-auth';
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import KeycloakProvider from 'next-auth/providers/keycloak';
|
import { NextAuthOptions } from "next-auth";
|
||||||
import { JWT } from 'next-auth/jwt';
|
import { JWT } from "next-auth/jwt";
|
||||||
|
|
||||||
declare module 'next-auth' {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
accessToken?: string;
|
accessToken: string;
|
||||||
roles?: string[];
|
idToken: string;
|
||||||
|
roles: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'next-auth/jwt' {
|
declare module "next-auth/jwt" {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
accessToken?: string;
|
accessToken: string;
|
||||||
refreshToken?: string;
|
idToken: string;
|
||||||
accessTokenExpires?: number;
|
refreshToken: string;
|
||||||
|
accessTokenExpiresAt: number;
|
||||||
|
refreshTokenExpiresAt: number;
|
||||||
|
roles: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
roles?: string[];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decode(token: string) {
|
const NEVER_EXPIRES = 9999999999;
|
||||||
try {
|
|
||||||
const base64 = token.split('.')[1];
|
|
||||||
|
|
||||||
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
|
export function decode(token: string) {
|
||||||
|
try {
|
||||||
|
const base64 = token.split(".")[1];
|
||||||
|
return JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -33,46 +37,62 @@ function decode(token: string) {
|
|||||||
async function refreshAccessToken(token: JWT): Promise<JWT> {
|
async function refreshAccessToken(token: JWT): Promise<JWT> {
|
||||||
try {
|
try {
|
||||||
if (!token.refreshToken) {
|
if (!token.refreshToken) {
|
||||||
throw new Error('No refresh token');
|
throw new Error("Missing refresh token");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
|
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
grant_type: 'refresh_token',
|
grant_type: "refresh_token",
|
||||||
refresh_token: token.refreshToken,
|
refresh_token: token.refreshToken,
|
||||||
}),
|
}),
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Refresh failed');
|
throw new Error(`Refresh failed: ${response.status} ${JSON.stringify(data)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await response.json();
|
const access = decode(data.access_token);
|
||||||
|
const refresh = decode(data.refresh_token);
|
||||||
const decoded = decode(refreshed.access_token);
|
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
accessToken: refreshed.access_token,
|
|
||||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
accessToken: data.access_token!,
|
||||||
accessTokenExpires: decoded?.exp
|
refreshToken: data.refresh_token!,
|
||||||
? decoded.exp * 1000
|
idToken: data.id_token!,
|
||||||
: Date.now() + 5 * 60 * 1000,
|
|
||||||
|
accessTokenExpiresAt: access.exp!,
|
||||||
|
refreshTokenExpiresAt: refresh.exp ?? NEVER_EXPIRES,
|
||||||
|
|
||||||
|
roles: [
|
||||||
|
...new Set([
|
||||||
|
...(access?.realm_access?.roles ?? []),
|
||||||
|
...(access?.resource_access?.[clientId]?.roles ?? []),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
} catch {
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[refreshAccessToken] failed:", err);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
error: 'RefreshAccessTokenError',
|
error: "RefreshAccessTokenError",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,67 +103,134 @@ export const authOptions: NextAuthOptions = {
|
|||||||
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
issuer: process.env.KEYCLOAK_ISSUER!,
|
issuer: process.env.KEYCLOAK_ISSUER!,
|
||||||
}),
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: "openid offline_access",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
],
|
],
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account }) {
|
async jwt({ token, account }) {
|
||||||
if (token.error === 'RefreshAccessTokenError') return token;
|
const now = Math.floor(Date.now() / 1000);
|
||||||
// initial login
|
|
||||||
if (account) {
|
|
||||||
const decoded = decode(account.access_token!);
|
|
||||||
|
|
||||||
|
// 1. LOGIN
|
||||||
|
if (account) {
|
||||||
|
console.log("[jwt] LOGIN - building initial token state");
|
||||||
|
|
||||||
|
const decodedRefreshToken = decode(account.refresh_token!);
|
||||||
|
const decodedAccessToken = decode(account.access_token!);
|
||||||
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||||
|
|
||||||
const realmRoles = decoded?.realm_access?.roles ?? [];
|
token.accessToken = account.access_token!;
|
||||||
const clientRoles = decoded?.resource_access?.[clientId]?.roles ?? [];
|
token.refreshToken = account.refresh_token!;
|
||||||
|
token.idToken = account.id_token!;
|
||||||
token.roles = [...new Set([...realmRoles, ...clientRoles])];
|
token.accessTokenExpiresAt = account.expires_at!;
|
||||||
|
token.sub = decodedAccessToken.sub!
|
||||||
token.accessToken = account.access_token;
|
token.refreshTokenExpiresAt = decodedRefreshToken.exp ?? NEVER_EXPIRES;
|
||||||
token.refreshToken = account.refresh_token;
|
token.roles = [
|
||||||
token.accessTokenExpires = decoded?.exp
|
...new Set([
|
||||||
? decoded.exp * 1000
|
...(decodedAccessToken?.realm_access?.roles ?? []),
|
||||||
: Date.now() + 5 * 60 * 1000;
|
...(decodedAccessToken?.resource_access?.[clientId]?.roles ?? []),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
token.error = undefined;
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loop if refresh already failed
|
// 2. SESSION EXPIRED → FORCE LOGIN
|
||||||
if (
|
if (now >= token.refreshTokenExpiresAt) {
|
||||||
token.accessTokenExpires &&
|
return { ...token, error: "RefreshTokenExpired" };
|
||||||
Date.now() > token.accessTokenExpires - 60_000
|
|
||||||
) {
|
|
||||||
return { ...token, error: 'RefreshAccessTokenError' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// still valid
|
// 3. ACCESS TOKEN STILL VALID
|
||||||
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
|
if (now < token.accessTokenExpiresAt - 60) {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// refresh expired token
|
|
||||||
return await refreshAccessToken(token);
|
return await refreshAccessToken(token);
|
||||||
},
|
},
|
||||||
|
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
session.accessToken = token.accessToken;
|
session.accessToken = token.accessToken;
|
||||||
|
session.idToken = token.idToken;
|
||||||
|
session.roles = token.roles ?? [];
|
||||||
session.error = token.error;
|
session.error = token.error;
|
||||||
|
|
||||||
if (!token.accessToken) {
|
|
||||||
session.roles = [];
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = decode(token.accessToken);
|
|
||||||
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
|
||||||
|
|
||||||
const realmRoles = payload?.realm_access?.roles ?? [];
|
|
||||||
const clientRoles = payload?.resource_access?.[clientId]?.roles ?? [];
|
|
||||||
|
|
||||||
session.roles = [...new Set([...realmRoles, ...clientRoles])];
|
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// {
|
||||||
|
// provider: 'keycloak',
|
||||||
|
// type: 'oauth',
|
||||||
|
// providerAccountId: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
|
||||||
|
// access_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTY5MDIsImlhdCI6MTc3NzkxNjc4MiwiYXV0aF90aW1lIjoxNzc3OTEzNDc1LCJqdGkiOiJiZmRmMjc1ZC1jNWQ3LTRlZWQtYWJlYS04NDQ5YWRjY2E2NGMiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6WyJnaXRlYSIsImplbGx5ZmluIiwiYWNjb3VudCJdLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLWljZWJlcmciXX0sInJlc291cmNlX2FjY2VzcyI6eyJnaXRlYSI6eyJyb2xlcyI6WyJ0ZW1wbGF0ZSIsImljZWJlcmciLCJhZG1pbiIsImRlbGV0YW5nIiwidXNlciJdfSwiY2hhdC13ZWJzaXRlIjp7InJvbGVzIjpbImFkbWluIiwidXNlciJdfSwiamVsbHlmaW4iOnsicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIiwic2lkIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.X5PMv6-H21hdWvP768X4NDtRc_f5E1NALM1mSxM44-S8B8pQkP3ROSKZR2jmf828LOlLVfC-moODHTBgPkrwGGfkDpDXOtBzCo9-w_hu2S63OSSSg-ej3EPkT_7OocykSeq0V9VWllrdll7T6PiVuNX4dVstizVCweGJ7gtZXjDdi15VExfSjiddWk6mF-ZFX9B3TJFqjLfoXEaaEUrZJoqErKWbr8JlIYFPeaekYWxdScB_yoDJUPkruDShHbk5tGATr6OfkX_lsVQ5oEDDPQ_EHQU5P_r4ILEbXoAk96yJMTJo2yQLEgaeAX3RxBGXRlNYs3rV6cHYCw67mg0Now',
|
||||||
|
// expires_at: 1777916902,
|
||||||
|
// refresh_expires_in: 1800,
|
||||||
|
// refresh_token: 'eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2Nzc0ZGFiYi05NjAxLTQyOTYtYTkwYi0yOWFjYjVlM2U3N2UifQ.eyJleHAiOjE3Nzc5MTg1ODIsImlhdCI6MTc3NzkxNjc4MiwianRpIjoiODU3ZTZlMmEtZjI1MS00NTI3LWJjMDktYmY3MTM4ODE1NzQyIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmljZWJlcmcuYmxhY2svcmVhbG1zL2ljZWJlcmciLCJhdWQiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2Iiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSIsInNpZCI6IjJlMjRjYmY3LTE0MWItNDZlMC05NDdmLTlhZmNiZTFmNzViNiJ9.ydQzc6YWDdem_ubf4s9dw4Ys6hRnQSWBp8ZeweWZg7NqVCl7SvdxJ2XUgXNxMZ88cx5u_pE1bL6Kahsjvmq-wA',
|
||||||
|
// token_type: 'Bearer',
|
||||||
|
// id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTY5MDIsImlhdCI6MTc3NzkxNjc4MiwiYXV0aF90aW1lIjoxNzc3OTEzNDc1LCJqdGkiOiJhYjllYTliYi1lODkyLTQzNTctYmQ4NS0xYjk4NDg5ODliM2MiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6ImNoYXQtd2Vic2l0ZSIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IklEIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjJlMjRjYmY3LTE0MWItNDZlMC05NDdmLTlhZmNiZTFmNzViNiIsImF0X2hhc2giOiJFV1JkS0JYNmd0czZoNHFId0ViWG5nIiwic2lkIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.e2Jn0nbJ1qzNwnuSCAJMjHn6J3JOpJJ84ttne-XiZqtUln9KD3OaPikXH0Rzl-bKy6WxW7ZOLNdEiRUZgliUXxnc3j8zT5Fincm6cEZ3FOXIozlzdoo5i59gjZ9QqvbQ0794RvQJ-24J4C8xMdn5g5baOehf3gJL4pTRYfCECgijbQ-oAnu04YLT3OZN9GpCreU4VoIfgdebX2eiDuyTyECnfpfJuDBeAyn19fNp5TzY2GQHUUbhW2CxiTvHSBhYSw5Zld_csgTmTisWVJ7wTbAM_A7xjn9VjxCs5UnexzufGWL_O02Pi2XdTbloSSbHcB7rMAtSNE-o0fBHjdaH2g',
|
||||||
|
// 'not-before-policy': 0,
|
||||||
|
// session_state: '2e24cbf7-141b-46e0-947f-9afcbe1f75b6',
|
||||||
|
// scope: 'openid profile'
|
||||||
|
// }
|
||||||
|
|
||||||
|
// REFRESH
|
||||||
|
// {
|
||||||
|
// exp: 1777969679,
|
||||||
|
// iat: 1777967879,
|
||||||
|
// jti: 'cfb68e49-9383-46b7-af35-ad8cd357cd55',
|
||||||
|
// iss: 'https://auth.iceberg.black/realms/iceberg',
|
||||||
|
// aud: 'https://auth.iceberg.black/realms/iceberg',
|
||||||
|
// sub: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
|
||||||
|
// typ: 'Refresh',
|
||||||
|
// azp: 'chat-website',
|
||||||
|
// session_state: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
|
||||||
|
// scope: 'openid profile',
|
||||||
|
// sid: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1'
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ACCESS
|
||||||
|
// {
|
||||||
|
// exp: 1777968408,
|
||||||
|
// iat: 1777968288,
|
||||||
|
// auth_time: 1777967879,
|
||||||
|
// jti: 'ef46047d-c911-427d-bd41-872c92b0ffd3',
|
||||||
|
// iss: 'https://auth.iceberg.black/realms/iceberg',
|
||||||
|
// aud: [ 'gitea', 'jellyfin', 'account' ],
|
||||||
|
// sub: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
|
||||||
|
// typ: 'Bearer',
|
||||||
|
// azp: 'chat-website',
|
||||||
|
// session_state: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
|
||||||
|
// realm_access: {
|
||||||
|
// roles: [ 'offline_access', 'uma_authorization', 'default-roles-iceberg' ]
|
||||||
|
// },
|
||||||
|
// resource_access: {
|
||||||
|
// gitea: { roles: [Array] },
|
||||||
|
// 'chat-website': { roles: [Array] },
|
||||||
|
// jellyfin: { roles: [Array] },
|
||||||
|
// account: { roles: [Array] }
|
||||||
|
// },
|
||||||
|
// scope: 'openid profile',
|
||||||
|
// sid: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
|
||||||
|
// name: ' ',
|
||||||
|
// preferred_username: '',
|
||||||
|
// given_name: '',
|
||||||
|
// family_name: ''
|
||||||
|
// }
|
||||||
|
|
||||||
|
// TOKEN OF REFRESH
|
||||||
|
// {
|
||||||
|
// access_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTE1OTEsImlhdCI6MTc3NzkxMTQ3MSwiYXV0aF90aW1lIjoxNzc3OTA5MjAwLCJqdGkiOiJmNTI4ZWEzOC0zOTM0LTQ5ZTEtOGI0ZC1lNjU4OWY5ZTJjZDkiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6WyJnaXRlYSIsImplbGx5ZmluIiwiYWNjb3VudCJdLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLWljZWJlcmciXX0sInJlc291cmNlX2FjY2VzcyI6eyJnaXRlYSI6eyJyb2xlcyI6WyJ0ZW1wbGF0ZSIsImljZWJlcmciLCJhZG1pbiIsImRlbGV0YW5nIiwidXNlciJdfSwiY2hhdC13ZWJzaXRlIjp7InJvbGVzIjpbImFkbWluIiwidXNlciJdfSwiamVsbHlmaW4iOnsicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIG9mZmxpbmVfYWNjZXNzIiwic2lkIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.NOol8mmBqxsvKwUFkWDzJE2GxmjU5DsIc-WX9BbNycFJtYcpcKLDeZfNMKlKid-JYnOd8pBt3phl_2OJtkYZPD_GVdq1QFfHkDCf7zEJPEf11lJd7pjT2S-adNzppentz2EJyW-Eb3m2Bqoeq0gaJbMi3gaEzfwgQWFTXxoNi5N7RHGVLoa9xtS4tU0p0IXKUOD4XV2aScSedv0qtTLog2x54ZyXw_mcH6UQNrNYU27Gf1fby_i2fgU-ZyD3CKPMD-kPlq2dwePVxh-z6tDGAOfwNw2gsHgScZMyQ4pO7UIkEs0IuxW-O-96FgRMhttKWE4F3sqcMbeQagw4SKs_Bg',
|
||||||
|
// expires_in: 120,
|
||||||
|
// refresh_expires_in: 0,
|
||||||
|
// refresh_token: 'eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2Nzc0ZGFiYi05NjAxLTQyOTYtYTkwYi0yOWFjYjVlM2U3N2UifQ.eyJpYXQiOjE3Nzc5MTE0NzEsImp0aSI6ImE5MzQ2ZWJhLTk0NTMtNDZmZS1iMzk2LTIxMDFkYjM3YjA3MyIsImlzcyI6Imh0dHBzOi8vYXV0aC5pY2ViZXJnLmJsYWNrL3JlYWxtcy9pY2ViZXJnIiwiYXVkIjoiaHR0cHM6Ly9hdXRoLmljZWJlcmcuYmxhY2svcmVhbG1zL2ljZWJlcmciLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJPZmZsaW5lIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjY4YWE4ZDEyLTRlMGUtNDdjNi04OTVmLTMzODcwNzExNTJmNCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgb2ZmbGluZV9hY2Nlc3MiLCJzaWQiOiI2OGFhOGQxMi00ZTBlLTQ3YzYtODk1Zi0zMzg3MDcxMTUyZjQifQ.zd83fs3rwAdO7KodcGRZoW-COaLISWmSfh3vK6NNQp3zB4mypI09lGieEoTO_XOUWzNgmtt9YmbEgGgXk-uW7w',
|
||||||
|
// token_type: 'Bearer',
|
||||||
|
// id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTE1OTEsImlhdCI6MTc3NzkxMTQ3MSwiYXV0aF90aW1lIjoxNzc3OTA5MjAwLCJqdGkiOiI3MGEzOTc1MS04NzJjLTRmMzUtODJjNC01OTI5ODFkN2Q2NzgiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6ImNoYXQtd2Vic2l0ZSIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IklEIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjY4YWE4ZDEyLTRlMGUtNDdjNi04OTVmLTMzODcwNzExNTJmNCIsImF0X2hhc2giOiI3OGcyYUFsNEdqVXFxS0loRmhDTDNRIiwic2lkIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.PdsFWknqvdNKv68SWg9hau9ekFWHdcyA1HRJvwS55H0-ijUlMR2iAhjQYqb44aboko90wtIF1LLIsd2JQMY3gTgcU0qAsf5LdPMX_zRrXoACMYR26rTWX64OmgBX9oWv2rSduGIngR8k6xuugXNBXSXiMwYbHeMSJ7H79a2tyhxqjmnuCxtnWfyQg8yJGIC9e92dDimM5gq9hvNbVQXesjWS3pGCnq0EhVw_90sTMOwk_4CUuPy_0ZgVUNJklyJzxYqMibYto_X2Mb2g2JT2gUZAym78URYhLiVg1A99vh9JTx4NC1vHktlIsEGZLySz52FvtVo7P-C3EqdLKMrSKw',
|
||||||
|
// 'not-before-policy': 0,
|
||||||
|
// session_state: '68aa8d12-4e0e-47c6-895f-3387071152f4',
|
||||||
|
// scope: 'openid profile offline_access'
|
||||||
|
// }
|
||||||
Reference in New Issue
Block a user