mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 20:59:05 +00:00
Merge branch 'main' into mathieu/calendar-integration-rebased
This commit is contained in:
@@ -9,6 +9,7 @@ import { Session } from "next-auth";
|
||||
import { SessionAutoRefresh } from "./SessionAutoRefresh";
|
||||
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
|
||||
import { assertExists } from "./utils";
|
||||
import { featureEnabled } from "./features";
|
||||
|
||||
type AuthContextType = (
|
||||
| { status: "loading" }
|
||||
@@ -27,65 +28,83 @@ type AuthContextType = (
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const isAuthEnabled = featureEnabled("requireLogin");
|
||||
|
||||
const noopAuthContext: AuthContextType = {
|
||||
status: "unauthenticated",
|
||||
update: async () => {
|
||||
return null;
|
||||
},
|
||||
signIn: async () => {
|
||||
throw new Error("signIn not supposed to be called");
|
||||
},
|
||||
signOut: async () => {
|
||||
throw new Error("signOut not supposed to be called");
|
||||
},
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status, update } = useNextAuthSession();
|
||||
const customSession = session ? assertCustomSession(session) : null;
|
||||
|
||||
const contextValue: AuthContextType = {
|
||||
...(() => {
|
||||
switch (status) {
|
||||
case "loading": {
|
||||
const sessionIsHere = !!customSession;
|
||||
switch (sessionIsHere) {
|
||||
case false: {
|
||||
return { status };
|
||||
const contextValue: AuthContextType = isAuthEnabled
|
||||
? {
|
||||
...(() => {
|
||||
switch (status) {
|
||||
case "loading": {
|
||||
const sessionIsHere = !!session;
|
||||
// actually exists sometimes; nextAuth types are something else
|
||||
switch (sessionIsHere as boolean) {
|
||||
case false: {
|
||||
return { status };
|
||||
}
|
||||
case true: {
|
||||
return {
|
||||
status: "refreshing" as const,
|
||||
user: assertCustomSession(
|
||||
assertExists(session as unknown as Session),
|
||||
).user,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
case true: {
|
||||
return {
|
||||
status: "refreshing" as const,
|
||||
user: assertExists(customSession).user,
|
||||
};
|
||||
case "authenticated": {
|
||||
const customSession = assertCustomSession(session);
|
||||
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
|
||||
// token had expired but next auth still returns "authenticated" so show user unauthenticated state
|
||||
return {
|
||||
status: "unauthenticated" as const,
|
||||
};
|
||||
} else if (customSession?.accessToken) {
|
||||
return {
|
||||
status,
|
||||
accessToken: customSession.accessToken,
|
||||
accessTokenExpires: customSession.accessTokenExpires,
|
||||
user: customSession.user,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
"illegal state: authenticated but have no session/or access token. ignoring",
|
||||
);
|
||||
return { status: "unauthenticated" as const };
|
||||
}
|
||||
}
|
||||
case "unauthenticated": {
|
||||
return { status: "unauthenticated" as const };
|
||||
}
|
||||
default: {
|
||||
const _: never = sessionIsHere;
|
||||
const _: never = status;
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
case "authenticated": {
|
||||
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
|
||||
// token had expired but next auth still returns "authenticated" so show user unauthenticated state
|
||||
return {
|
||||
status: "unauthenticated" as const,
|
||||
};
|
||||
} else if (customSession?.accessToken) {
|
||||
return {
|
||||
status,
|
||||
accessToken: customSession.accessToken,
|
||||
accessTokenExpires: customSession.accessTokenExpires,
|
||||
user: customSession.user,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
"illegal state: authenticated but have no session/or access token. ignoring",
|
||||
);
|
||||
return { status: "unauthenticated" as const };
|
||||
}
|
||||
}
|
||||
case "unauthenticated": {
|
||||
return { status: "unauthenticated" as const };
|
||||
}
|
||||
default: {
|
||||
const _: never = status;
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
})(),
|
||||
update,
|
||||
signIn,
|
||||
signOut,
|
||||
}
|
||||
})(),
|
||||
update,
|
||||
signIn,
|
||||
signOut,
|
||||
};
|
||||
: noopAuthContext;
|
||||
|
||||
// not useEffect, we need it ASAP
|
||||
// apparently, still no guarantee this code runs before mutations are fired
|
||||
|
||||
@@ -6,10 +6,17 @@ import createFetchClient from "openapi-react-query";
|
||||
import { assertExistsAndNonEmptyString } from "./utils";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
const API_URL = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
|
||||
export const API_URL = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(
|
||||
process.env.NEXT_PUBLIC_API_URL,
|
||||
"NEXT_PUBLIC_API_URL required",
|
||||
)
|
||||
: "http://localhost";
|
||||
|
||||
// TODO decide strict validation or not
|
||||
export const WEBSOCKET_URL =
|
||||
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
|
||||
|
||||
export const client = createClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
});
|
||||
|
||||
12
www/app/lib/array.ts
Normal file
12
www/app/lib/array.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type NonEmptyArray<T> = [T, ...T[]];
|
||||
export const isNonEmptyArray = <T>(arr: T[]): arr is NonEmptyArray<T> =>
|
||||
arr.length > 0;
|
||||
export const assertNonEmptyArray = <T>(
|
||||
arr: T[],
|
||||
err?: string,
|
||||
): NonEmptyArray<T> => {
|
||||
if (isNonEmptyArray(arr)) {
|
||||
return arr;
|
||||
}
|
||||
throw new Error(err ?? "Expected non-empty array");
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertExistsAndNonEmptyString } from "./utils";
|
||||
|
||||
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
|
||||
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
|
||||
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
|
||||
|
||||
@@ -19,102 +19,126 @@ import {
|
||||
} from "./redisTokenCache";
|
||||
import { tokenCacheRedis, redlock } from "./redisClient";
|
||||
import { isBuildPhase } from "./next";
|
||||
import { sequenceThrows } from "./errorUtils";
|
||||
import { featureEnabled } from "./features";
|
||||
|
||||
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
const CLIENT_ID = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID)
|
||||
: "noop";
|
||||
const CLIENT_SECRET = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET)
|
||||
: "noop";
|
||||
const getAuthentikClientId = () =>
|
||||
assertExistsAndNonEmptyString(
|
||||
process.env.AUTHENTIK_CLIENT_ID,
|
||||
"AUTHENTIK_CLIENT_ID required",
|
||||
);
|
||||
const getAuthentikClientSecret = () =>
|
||||
assertExistsAndNonEmptyString(
|
||||
process.env.AUTHENTIK_CLIENT_SECRET,
|
||||
"AUTHENTIK_CLIENT_SECRET required",
|
||||
);
|
||||
const getAuthentikRefreshTokenUrl = () =>
|
||||
assertExistsAndNonEmptyString(
|
||||
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
|
||||
"AUTHENTIK_REFRESH_TOKEN_URL required",
|
||||
);
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
providers: [
|
||||
AuthentikProvider({
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
issuer: process.env.AUTHENTIK_ISSUER,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid email profile offline_access",
|
||||
export const authOptions = (): AuthOptions =>
|
||||
featureEnabled("requireLogin")
|
||||
? {
|
||||
providers: [
|
||||
AuthentikProvider({
|
||||
...(() => {
|
||||
const [clientId, clientSecret] = sequenceThrows(
|
||||
getAuthentikClientId,
|
||||
getAuthentikClientSecret,
|
||||
);
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
};
|
||||
})(),
|
||||
issuer: process.env.AUTHENTIK_ISSUER,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid email profile offline_access",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account, user }) {
|
||||
if (account && !account.access_token) {
|
||||
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
}
|
||||
callbacks: {
|
||||
async jwt({ token, account, user }) {
|
||||
if (account && !account.access_token) {
|
||||
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
}
|
||||
|
||||
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
|
||||
if (account.access_token) {
|
||||
const expiresAtS = assertExists(account.expires_at);
|
||||
const expiresAtMs = expiresAtS * 1000;
|
||||
const jwtToken: JWTWithAccessToken = {
|
||||
...token,
|
||||
accessToken: account.access_token,
|
||||
accessTokenExpires: expiresAtMs,
|
||||
refreshToken: account.refresh_token,
|
||||
};
|
||||
if (jwtToken.error) {
|
||||
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
} else {
|
||||
assertNotExists(
|
||||
jwtToken.error,
|
||||
`panic! trying to cache token with error in jwt: ${jwtToken.error}`,
|
||||
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
|
||||
if (account.access_token) {
|
||||
const expiresAtS = assertExists(account.expires_at);
|
||||
const expiresAtMs = expiresAtS * 1000;
|
||||
const jwtToken: JWTWithAccessToken = {
|
||||
...token,
|
||||
accessToken: account.access_token,
|
||||
accessTokenExpires: expiresAtMs,
|
||||
refreshToken: account.refresh_token,
|
||||
};
|
||||
if (jwtToken.error) {
|
||||
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
} else {
|
||||
assertNotExists(
|
||||
jwtToken.error,
|
||||
`panic! trying to cache token with error in jwt: ${jwtToken.error}`,
|
||||
);
|
||||
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
|
||||
token: jwtToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jwtToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentToken = await getTokenCache(
|
||||
tokenCacheRedis,
|
||||
`token:${token.sub}`,
|
||||
);
|
||||
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
|
||||
token: jwtToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jwtToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.debug(
|
||||
"currentToken from cache",
|
||||
JSON.stringify(currentToken, null, 2),
|
||||
"will be returned?",
|
||||
currentToken &&
|
||||
!shouldRefreshToken(currentToken.token.accessTokenExpires),
|
||||
);
|
||||
if (
|
||||
currentToken &&
|
||||
!shouldRefreshToken(currentToken.token.accessTokenExpires)
|
||||
) {
|
||||
return currentToken.token;
|
||||
}
|
||||
|
||||
const currentToken = await getTokenCache(
|
||||
tokenCacheRedis,
|
||||
`token:${token.sub}`,
|
||||
);
|
||||
console.debug(
|
||||
"currentToken from cache",
|
||||
JSON.stringify(currentToken, null, 2),
|
||||
"will be returned?",
|
||||
currentToken &&
|
||||
!shouldRefreshToken(currentToken.token.accessTokenExpires),
|
||||
);
|
||||
if (
|
||||
currentToken &&
|
||||
!shouldRefreshToken(currentToken.token.accessTokenExpires)
|
||||
) {
|
||||
return currentToken.token;
|
||||
}
|
||||
|
||||
// access token has expired, try to update it
|
||||
return await lockedRefreshAccessToken(token);
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const extendedToken = token as JWTWithAccessToken;
|
||||
return {
|
||||
...session,
|
||||
accessToken: extendedToken.accessToken,
|
||||
accessTokenExpires: extendedToken.accessTokenExpires,
|
||||
error: extendedToken.error,
|
||||
user: {
|
||||
id: assertExists(extendedToken.sub),
|
||||
name: extendedToken.name,
|
||||
email: extendedToken.email,
|
||||
// access token has expired, try to update it
|
||||
return await lockedRefreshAccessToken(token);
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const extendedToken = token as JWTWithAccessToken;
|
||||
return {
|
||||
...session,
|
||||
accessToken: extendedToken.accessToken,
|
||||
accessTokenExpires: extendedToken.accessTokenExpires,
|
||||
error: extendedToken.error,
|
||||
user: {
|
||||
id: assertExists(extendedToken.sub),
|
||||
name: extendedToken.name,
|
||||
email: extendedToken.email,
|
||||
},
|
||||
} satisfies CustomSession;
|
||||
},
|
||||
},
|
||||
} satisfies CustomSession;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
: {
|
||||
providers: [],
|
||||
};
|
||||
|
||||
async function lockedRefreshAccessToken(
|
||||
token: JWT,
|
||||
@@ -174,16 +198,19 @@ async function lockedRefreshAccessToken(
|
||||
}
|
||||
|
||||
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
|
||||
const [url, clientId, clientSecret] = sequenceThrows(
|
||||
getAuthentikRefreshTokenUrl,
|
||||
getAuthentikClientId,
|
||||
getAuthentikClientSecret,
|
||||
);
|
||||
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,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: token.refreshToken as string,
|
||||
}).toString(),
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
type EdgeConfig = {
|
||||
[domainWithDash: string]: {
|
||||
features: {
|
||||
[featureName in
|
||||
| "requireLogin"
|
||||
| "privacy"
|
||||
| "browse"
|
||||
| "sendToZulip"]: boolean;
|
||||
};
|
||||
auth_callback_url: string;
|
||||
websocket_url: string;
|
||||
api_url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DomainConfig = EdgeConfig["domainWithDash"];
|
||||
|
||||
// Edge config main keys can only be alphanumeric and _ or -
|
||||
export function edgeKeyToDomain(key: string) {
|
||||
return key.replaceAll("_", ".");
|
||||
}
|
||||
|
||||
export function edgeDomainToKey(domain: string) {
|
||||
return domain.replaceAll(".", "_");
|
||||
}
|
||||
|
||||
// get edge config server-side (prefer DomainContext when available), domain is the hostname
|
||||
export async function getConfig() {
|
||||
if (process.env.NEXT_PUBLIC_ENV === "development") {
|
||||
try {
|
||||
return require("../../config").localConfig;
|
||||
} catch (e) {
|
||||
// next build() WILL try to execute the require above even if conditionally protected
|
||||
// but thank god it at least runs catch{} block properly
|
||||
if (!isBuildPhase) throw new Error(e);
|
||||
return require("../../config-template").localConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
|
||||
let config = await get(edgeDomainToKey(domain));
|
||||
|
||||
if (typeof config !== "object") {
|
||||
console.warn("No config for this domain, falling back to default");
|
||||
config = await get(edgeDomainToKey("default"));
|
||||
}
|
||||
|
||||
if (typeof config !== "object") throw Error("Error fetching config");
|
||||
|
||||
return config as DomainConfig;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
function shouldShowError(error: Error | null | undefined) {
|
||||
import { isNonEmptyArray, NonEmptyArray } from "./array";
|
||||
|
||||
export function shouldShowError(error: Error | null | undefined) {
|
||||
if (
|
||||
error?.name == "ResponseError" &&
|
||||
(error["response"].status == 404 || error["response"].status == 403)
|
||||
@@ -8,4 +10,40 @@ function shouldShowError(error: Error | null | undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export { shouldShowError };
|
||||
const defaultMergeErrors = (ex: NonEmptyArray<unknown>): unknown => {
|
||||
try {
|
||||
return new Error(
|
||||
ex
|
||||
.map((e) =>
|
||||
e ? (e.toString ? e.toString() : JSON.stringify(e)) : `${e}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error merging errors:", e);
|
||||
return ex[0];
|
||||
}
|
||||
};
|
||||
|
||||
type ReturnTypes<T extends readonly (() => any)[]> = {
|
||||
[K in keyof T]: T[K] extends () => infer R ? R : never;
|
||||
};
|
||||
|
||||
// sequence semantic for "throws"
|
||||
// calls functions passed and collects its thrown values
|
||||
export function sequenceThrows<Fns extends readonly (() => any)[]>(
|
||||
...fs: Fns
|
||||
): ReturnTypes<Fns> {
|
||||
const results: unknown[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
for (const f of fs) {
|
||||
try {
|
||||
results.push(f());
|
||||
} catch (e) {
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
if (errors.length) throw defaultMergeErrors(errors as NonEmptyArray<unknown>);
|
||||
return results as ReturnTypes<Fns>;
|
||||
}
|
||||
|
||||
55
www/app/lib/features.ts
Normal file
55
www/app/lib/features.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const FEATURES = [
|
||||
"requireLogin",
|
||||
"privacy",
|
||||
"browse",
|
||||
"sendToZulip",
|
||||
"rooms",
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof FEATURES)[number];
|
||||
|
||||
export type Features = Readonly<Record<FeatureName, boolean>>;
|
||||
|
||||
export const DEFAULT_FEATURES: Features = {
|
||||
requireLogin: true,
|
||||
privacy: true,
|
||||
browse: true,
|
||||
sendToZulip: true,
|
||||
rooms: true,
|
||||
} as const;
|
||||
|
||||
function parseBooleanEnv(
|
||||
value: string | undefined,
|
||||
defaultValue: boolean = false,
|
||||
): boolean {
|
||||
if (!value) return defaultValue;
|
||||
return value.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
|
||||
const features: Features = {
|
||||
requireLogin: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
|
||||
DEFAULT_FEATURES.requireLogin,
|
||||
),
|
||||
privacy: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
|
||||
DEFAULT_FEATURES.privacy,
|
||||
),
|
||||
browse: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
|
||||
DEFAULT_FEATURES.browse,
|
||||
),
|
||||
sendToZulip: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
|
||||
DEFAULT_FEATURES.sendToZulip,
|
||||
),
|
||||
rooms: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
|
||||
DEFAULT_FEATURES.rooms,
|
||||
),
|
||||
};
|
||||
|
||||
export const featureEnabled = (featureName: FeatureName): boolean => {
|
||||
return features[featureName];
|
||||
};
|
||||
@@ -72,3 +72,7 @@ export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
|
||||
// no other checks for now
|
||||
return r as CustomSession;
|
||||
};
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
@@ -171,5 +171,6 @@ export const assertNotExists = <T>(
|
||||
|
||||
export const assertExistsAndNonEmptyString = (
|
||||
value: string | null | undefined,
|
||||
err?: string,
|
||||
): NonEmptyString =>
|
||||
parseNonEmptyString(assertExists(value, "Expected non-empty string"));
|
||||
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
|
||||
|
||||
Reference in New Issue
Block a user