feat: Monadical SSO as replacement of Fief (#393)

* sso: first pass for integrating SSO

still have issue on refreshing
maybe customize the login page, or completely avoid it
make 100% to understand how session server/client are working
need to test with different configuration option (features flags and
requireLogin)

* sso: correctly handle refresh token, with pro-active refresh

Going on interceptors make extra calls to reflector when 401.
We need then to circle back with NextJS backend to update the jwt,
session, then retry the failed request.

I prefered to go pro-active, and ensure the session AND jwt are always
up to date.

A minute before the expiration, we'll try to refresh it. useEffect() of
NextJS cannot be asynchronous, so we cannot wait for the token to be
refreshed.

Every 20s, a minute before the expiration (so 3x in total max) we'll try
to renew. When the accessToken is renewed, the session is updated, and
dispatching up to the client, which updates the useApi().

Therefore, no component will left without a incorrect token.

* fixes: issue with missing key on react-select-search because the default value is undefined

* sso: fixes login/logout button, and avoid seeing the login with authentik page when clicking

* sso: ensure /transcripts/new is not behind protected page, and feature flags page are honored

* sso: fixes user sub->id

* fixes: remove old layout not used

* fixes: set default NEXT_PUBLIC_SITE_URL as localhost

* fixes: removing fief again due to merge with main

* sso: ensure session is always ready before doing any action

* sso: add migration from fief to jwt in server, only from transcripts list

* fixes: user tests

* fixes: compilation issues
This commit is contained in:
2024-09-03 19:27:15 +02:00
committed by GitHub
parent 28fe6c11f7
commit 03561453c5
30 changed files with 707 additions and 280 deletions

View File

@@ -0,0 +1,36 @@
/**
* This is a custom hook that automatically refreshes the session when the access token is about to expire.
* When communicating with the reflector API, we need to ensure that the access token is always valid.
*
* We could have implemented that as an interceptor on the API client, but not everything is using the
* API client, or have access to NextJS directly (serviceWorker).
*/
"use client";
import { useSession } from "next-auth/react";
import { useEffect } from "react";
import { CustomSession } from "./types";
export function SessionAutoRefresh({
children,
refreshInterval = 20 /* seconds */,
}) {
const { data: session, update } = useSession();
const customSession = session as CustomSession;
const accessTokenExpires = customSession?.accessTokenExpires;
useEffect(() => {
const interval = setInterval(() => {
if (accessTokenExpires) {
const timeLeft = accessTokenExpires - Date.now();
if (timeLeft < refreshInterval * 1000) {
update();
}
}
}, refreshInterval * 1000);
return () => clearInterval(interval);
}, [accessTokenExpires, refreshInterval, update]);
return children;
}

View File

@@ -0,0 +1,11 @@
"use client";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
import { SessionAutoRefresh } from "./SessionAutoRefresh";
export default function SessionProvider({ children }) {
return (
<SessionProviderNextAuth refetchInterval={60} refetchOnWindowFocus={true}>
<SessionAutoRefresh>{children}</SessionAutoRefresh>
</SessionProviderNextAuth>
);
}

101
www/app/lib/auth.ts Normal file
View File

@@ -0,0 +1,101 @@
import { AuthOptions } from "next-auth";
import AuthentikProvider from "next-auth/providers/authentik";
import { JWT } from "next-auth/jwt";
import { JWTWithAccessToken, CustomSession } from "./types";
const PRETIMEOUT = 60; // seconds before token expires to refresh it
export const authOptions: AuthOptions = {
providers: [
AuthentikProvider({
clientId: process.env.AUTHENTIK_CLIENT_ID as string,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET as string,
issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",
},
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, account, user }) {
const extendedToken = token as JWTWithAccessToken;
if (account && user) {
// called only on first login
// XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
const expiresAt = (account.expires_at as number) - PRETIMEOUT;
return {
...extendedToken,
accessToken: account.access_token,
accessTokenExpires: expiresAt * 1000,
refreshToken: account.refresh_token,
};
}
if (Date.now() < extendedToken.accessTokenExpires) {
return token;
}
// access token has expired, try to update it
return await refreshAccessToken(token);
},
async session({ session, token }) {
const extendedToken = token as JWTWithAccessToken;
const customSession = session as CustomSession;
customSession.accessToken = extendedToken.accessToken;
customSession.accessTokenExpires = extendedToken.accessTokenExpires;
customSession.error = extendedToken.error;
customSession.user = {
id: extendedToken.sub,
name: extendedToken.name,
email: extendedToken.email,
};
return customSession;
},
},
};
async function refreshAccessToken(token: JWT) {
try {
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
const options = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID as string,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}).toString(),
method: "POST",
};
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to refresh access token: ${response.statusText}`);
}
const refreshedTokens = await response.json();
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires:
Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000,
refreshToken: refreshedTokens.refresh_token,
};
} catch (error) {
console.error("Error refreshing access token", error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
}

View File

@@ -1,79 +0,0 @@
import { Fief, FiefUserInfo } from "@fief/fief";
import { FiefAuth, IUserInfoCache } from "@fief/fief/nextjs";
import { getConfig } from "./edgeConfig";
export const SESSION_COOKIE_NAME = "reflector-auth";
export const fiefClient = new Fief({
baseURL: process.env.FIEF_URL ?? "",
clientId: process.env.FIEF_CLIENT_ID ?? "",
clientSecret: process.env.FIEF_CLIENT_SECRET ?? "",
});
class MemoryUserInfoCache implements IUserInfoCache {
private storage: Record<string, any>;
constructor() {
this.storage = {};
}
async get(id: string): Promise<FiefUserInfo | null> {
const userinfo = this.storage[id];
if (userinfo) {
return userinfo;
}
return null;
}
async set(id: string, userinfo: FiefUserInfo): Promise<void> {
this.storage[id] = userinfo;
}
async remove(id: string): Promise<void> {
this.storage[id] = undefined;
}
async clear(): Promise<void> {
this.storage = {};
}
}
const FIEF_AUTHS = {} as { [domain: string]: FiefAuth };
export const getFiefAuth = async (url: URL) => {
if (FIEF_AUTHS[url.hostname]) {
return FIEF_AUTHS[url.hostname];
} else {
const config = url && (await getConfig());
if (config) {
FIEF_AUTHS[url.hostname] = new FiefAuth({
client: fiefClient,
sessionCookieName: SESSION_COOKIE_NAME,
redirectURI: config.auth_callback_url,
logoutRedirectURI: url.origin,
userInfoCache: new MemoryUserInfoCache(),
});
return FIEF_AUTHS[url.hostname];
} else {
throw new Error("Fief intanciation failed");
}
}
};
export const getFiefAuthMiddleware = async (url) => {
const protectedPaths = [
{
matcher: "/transcripts",
parameters: {},
},
{
matcher: "/browse",
parameters: {},
},
{
matcher: "/rooms",
parameters: {},
},
];
return (await getFiefAuth(url))?.middleware(protectedPaths);
};

20
www/app/lib/types.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Session } from "next-auth";
import { JWT } from "next-auth/jwt";
export interface JWTWithAccessToken extends JWT {
accessToken: string;
accessTokenExpires: number;
refreshToken: string;
error?: string;
}
export interface CustomSession extends Session {
accessToken: string;
accessTokenExpires: number;
error?: string;
user: {
id?: string;
name?: string | null;
email?: string | null;
};
}

View File

@@ -1,30 +1,40 @@
import { useFiefAccessTokenInfo } from "@fief/fief/nextjs/react";
import { useSession, signOut } from "next-auth/react";
import { useContext, useEffect, useState } from "react";
import { DomainContext, featureEnabled } from "../domainContext";
import { CookieContext } from "../(auth)/fiefWrapper";
import { OpenApi, DefaultService } from "../api";
import { CustomSession } from "./types";
export default function useApi(): DefaultService | null {
const accessTokenInfo = useFiefAccessTokenInfo();
const api_url = useContext(DomainContext).api_url;
const requireLogin = featureEnabled("requireLogin");
const [api, setApi] = useState<OpenApi | null>(null);
const { hasAuthCookie } = useContext(CookieContext);
const { data: session, status } = useSession();
const customSession = session as CustomSession;
const accessToken = customSession?.accessToken;
if (!api_url) throw new Error("no API URL");
useEffect(() => {
if (hasAuthCookie && requireLogin && !accessTokenInfo) {
if (customSession?.error === "RefreshAccessTokenError") {
signOut();
}
}, [session]);
useEffect(() => {
if (status === "loading") {
return;
}
if (status === "authenticated" && !accessToken) {
return;
}
const openApi = new OpenApi({
BASE: api_url,
TOKEN: accessTokenInfo ? accessTokenInfo?.access_token : undefined,
TOKEN: accessToken,
});
setApi(openApi);
}, [!accessTokenInfo, hasAuthCookie]);
}, [accessToken, status]);
return api?.default ?? null;
}

View File

@@ -1,21 +0,0 @@
export async function getCurrentUser(): Promise<any> {
try {
const response = await fetch("/api/current-user");
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
// Ensure the data structure is as expected
if (data.userinfo && data.access_token_info) {
return data;
} else {
throw new Error("Unexpected data structure");
}
} catch (error) {
console.error("Error fetching the user data:", error);
throw error; // or you can return an appropriate fallback or error indicator
}
}