185 lines
5.3 KiB
TypeScript
185 lines
5.3 KiB
TypeScript
import { NextAuthOptions } from "next-auth";
|
|
import { JWT } from "next-auth/jwt";
|
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
|
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
|
|
|
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
|
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
|
|
|
|
type KeycloakTokenResponse = {
|
|
access_token: string;
|
|
expires_in: number;
|
|
refresh_token?: string;
|
|
};
|
|
|
|
const getKeycloakTokenEndpoint = () => {
|
|
const issuer = process.env.KEYCLOAK_ISSUER;
|
|
return issuer
|
|
? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
|
|
: undefined;
|
|
};
|
|
|
|
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
|
if (!token.refreshToken) {
|
|
return { ...token, error: "RefreshAccessTokenError" };
|
|
}
|
|
|
|
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID;
|
|
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
|
const keycloakTokenEndpoint = getKeycloakTokenEndpoint();
|
|
|
|
if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) {
|
|
return { ...token, error: "RefreshAccessTokenError" };
|
|
}
|
|
|
|
const body = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: keycloakClientId,
|
|
client_secret: keycloakClientSecret,
|
|
refresh_token: token.refreshToken,
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(keycloakTokenEndpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
});
|
|
const refreshed = (await response.json()) as Partial<KeycloakTokenResponse> | null;
|
|
|
|
if (
|
|
!response.ok ||
|
|
!refreshed ||
|
|
typeof refreshed.access_token !== "string" ||
|
|
!refreshed.access_token.trim() ||
|
|
typeof refreshed.expires_in !== "number" ||
|
|
!Number.isFinite(refreshed.expires_in) ||
|
|
refreshed.expires_in <= 0
|
|
) {
|
|
return { ...token, error: "RefreshAccessTokenError" };
|
|
}
|
|
|
|
const rotatedRefreshToken =
|
|
typeof refreshed.refresh_token === "string" &&
|
|
refreshed.refresh_token.trim()
|
|
? refreshed.refresh_token.trim()
|
|
: token.refreshToken;
|
|
|
|
return {
|
|
...token,
|
|
accessToken: refreshed.access_token.trim(),
|
|
accessTokenIssuedAt: Date.now(),
|
|
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
|
refreshToken: rotatedRefreshToken,
|
|
error: undefined,
|
|
};
|
|
} catch {
|
|
return { ...token, error: "RefreshAccessTokenError" };
|
|
}
|
|
};
|
|
|
|
const authOptions: NextAuthOptions = {
|
|
// Configure one or more authentication providers
|
|
providers: [
|
|
KeycloakProvider({
|
|
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
|
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
|
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
|
profile(profile) {
|
|
return {
|
|
id: profile.sub,
|
|
username: profile.preferred_username,
|
|
name: profile.name ?? profile.preferred_username,
|
|
email: profile.email,
|
|
image: Avatar.src,
|
|
};
|
|
},
|
|
}),
|
|
],
|
|
secret: process.env.NEXTAUTH_SECRET,
|
|
callbacks: {
|
|
jwt: async ({ token, profile, account, trigger, session }) => {
|
|
if (profile?.sub) {
|
|
token.sub = profile.sub;
|
|
}
|
|
const preferredUsername = (
|
|
profile as { preferred_username?: unknown } | undefined
|
|
)?.preferred_username;
|
|
if (typeof preferredUsername === "string") {
|
|
token.username = preferredUsername;
|
|
}
|
|
|
|
if (account) {
|
|
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
|
|
if (account.access_token) {
|
|
token.accessToken = account.access_token;
|
|
token.accessTokenIssuedAt = Date.now();
|
|
}
|
|
if (account.refresh_token) {
|
|
token.refreshToken = account.refresh_token;
|
|
}
|
|
if (account.id_token) {
|
|
token.idToken = account.id_token;
|
|
}
|
|
if (typeof account.expires_at === "number") {
|
|
token.accessTokenExpires = account.expires_at * 1000;
|
|
}
|
|
token.error = undefined;
|
|
return token;
|
|
}
|
|
|
|
if (
|
|
typeof token.sessionExpiresAt === "number" &&
|
|
Date.now() >= token.sessionExpiresAt
|
|
) {
|
|
return { ...token, error: "SessionExpired" };
|
|
}
|
|
|
|
if (
|
|
trigger === "update" &&
|
|
(session as { forceRefresh?: unknown } | undefined)?.forceRefresh === true
|
|
) {
|
|
return refreshAccessToken(token);
|
|
}
|
|
|
|
const accessTokenIsFresh =
|
|
typeof token.accessTokenExpires === "number" &&
|
|
typeof token.accessTokenIssuedAt === "number" &&
|
|
Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
|
|
if (accessTokenIsFresh) {
|
|
return token;
|
|
}
|
|
|
|
return refreshAccessToken(token);
|
|
},
|
|
session: async ({ session, token }) => {
|
|
if (session.user && token.sub) {
|
|
session.user.id = token.sub;
|
|
}
|
|
if (session.user && token.username) {
|
|
session.user.username = token.username;
|
|
}
|
|
if (token.accessToken) {
|
|
session.accessToken = token.accessToken;
|
|
}
|
|
if (token.error) {
|
|
session.error = token.error;
|
|
}
|
|
if (typeof token.sessionExpiresAt === "number") {
|
|
session.sessionExpiresAt = token.sessionExpiresAt;
|
|
}
|
|
return session;
|
|
},
|
|
},
|
|
session: {
|
|
strategy: "jwt",
|
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
|
},
|
|
jwt: {
|
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
|
},
|
|
};
|
|
|
|
export default authOptions;
|