import KeycloakProvider from "next-auth/providers/keycloak"; import { NextAuthOptions } from "next-auth"; import { JWT } from "next-auth/jwt"; declare module "next-auth" { interface Session { accessToken: string; idToken: string; roles: string[]; error?: string; } } declare module "next-auth/jwt" { interface JWT { accessToken: string; idToken: string; refreshToken: string; accessTokenExpiresAt: number; refreshTokenExpiresAt: number; roles: string[]; error?: string; } } const NEVER_EXPIRES = 9999999999; export function decode(token: string) { try { const base64 = token.split(".")[1]; return JSON.parse(Buffer.from(base64, "base64").toString("utf-8")); } catch { return null; } } async function refreshAccessToken(token: JWT): Promise { try { if (!token.refreshToken) { throw new Error("Missing refresh token"); } const response = await fetch( `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { method: "POST", cache: 'no-store', headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ client_id: process.env.KEYCLOAK_CLIENT_ID!, client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, grant_type: "refresh_token", refresh_token: token.refreshToken, }), } ); const data = await response.json(); if (!response.ok) { throw new Error(`Refresh failed: ${response.status} ${JSON.stringify(data)}`); } const access = decode(data.access_token); const refresh = decode(data.refresh_token); const clientId = process.env.KEYCLOAK_CLIENT_ID!; return { ...token, accessToken: data.access_token!, refreshToken: data.refresh_token!, idToken: data.id_token!, accessTokenExpiresAt: access.exp!, refreshTokenExpiresAt: refresh.exp ?? NEVER_EXPIRES, roles: [ ...new Set([ ...(access?.realm_access?.roles ?? []), ...(access?.resource_access?.[clientId]?.roles ?? []), ]), ], error: undefined, }; } catch (err) { console.error("[refreshAccessToken] failed:", err); return { ...token, error: "RefreshAccessTokenError", }; } } export const authOptions: NextAuthOptions = { providers: [ KeycloakProvider({ clientId: process.env.KEYCLOAK_CLIENT_ID!, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, issuer: process.env.KEYCLOAK_ISSUER!, authorization: { params: { scope: "openid offline_access", }, }, }) ], callbacks: { async jwt({ token, account }) { const now = Math.floor(Date.now() / 1000); // 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!; token.accessToken = account.access_token!; token.refreshToken = account.refresh_token!; token.idToken = account.id_token!; token.accessTokenExpiresAt = account.expires_at!; token.sub = decodedAccessToken.sub! token.refreshTokenExpiresAt = decodedRefreshToken.exp ?? NEVER_EXPIRES; token.roles = [ ...new Set([ ...(decodedAccessToken?.realm_access?.roles ?? []), ...(decodedAccessToken?.resource_access?.[clientId]?.roles ?? []), ]), ]; token.error = undefined; return token; } // 2. SESSION EXPIRED → FORCE LOGIN if (now >= token.refreshTokenExpiresAt) { return { ...token, error: "RefreshTokenExpired" }; } // 3. ACCESS TOKEN STILL VALID if (now < token.accessTokenExpiresAt - 60) { return token; } return await refreshAccessToken(token); }, async session({ session, token }) { session.accessToken = token.accessToken; session.idToken = token.idToken; session.roles = token.roles ?? []; session.error = token.error; 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' // }