mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-21 22:56:47 +00:00
feat: Add Single User authentication to Selfhosted (#870)
* Single user/password for selfhosted * fix revision id latest migration
This commit is contained in:
committed by
GitHub
parent
2ba0d965e8
commit
c8db37362b
@@ -19,9 +19,13 @@ SERVER_API_URL=http://server:1250
|
||||
KV_URL=redis://redis:6379
|
||||
|
||||
# Authentication
|
||||
# Set to true when Authentik is configured
|
||||
# Set to true when Authentik or password auth is configured
|
||||
FEATURE_REQUIRE_LOGIN=false
|
||||
|
||||
# Auth provider: "authentik" or "credentials"
|
||||
# Set to "credentials" when using password auth backend
|
||||
# AUTH_PROVIDER=credentials
|
||||
|
||||
# Nullify auth vars when not using Authentik
|
||||
AUTHENTIK_ISSUER=
|
||||
AUTHENTIK_REFRESH_TOKEN_URL=
|
||||
|
||||
@@ -78,7 +78,10 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
|
||||
// Audio is not deleted, proceed to load it
|
||||
audioElement = document.createElement("audio");
|
||||
audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||
const audioUrl = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||
audioElement.src = accessTokenInfo
|
||||
? `${audioUrl}?token=${encodeURIComponent(accessTokenInfo)}`
|
||||
: audioUrl;
|
||||
audioElement.crossOrigin = "anonymous";
|
||||
audioElement.preload = "auto";
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function UserInfo() {
|
||||
className="font-light px-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
auth.signIn("authentik");
|
||||
auth.signIn();
|
||||
}}
|
||||
>
|
||||
Log in
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AuthOptions } from "next-auth";
|
||||
import AuthentikProvider from "next-auth/providers/authentik";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import { JWTWithAccessToken, CustomSession } from "./types";
|
||||
import {
|
||||
@@ -52,7 +53,7 @@ const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID");
|
||||
const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET");
|
||||
const getAuthentikRefreshTokenUrl = () =>
|
||||
getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL");
|
||||
getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL").replace(/\/+$/, "");
|
||||
|
||||
const getAuthentikIssuer = () => {
|
||||
const stringUrl = getNextEnvVar("AUTHENTIK_ISSUER");
|
||||
@@ -61,113 +62,194 @@ const getAuthentikIssuer = () => {
|
||||
} catch (e) {
|
||||
throw new Error("AUTHENTIK_ISSUER is not a valid URL: " + stringUrl);
|
||||
}
|
||||
return stringUrl;
|
||||
return stringUrl.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
export const authOptions = (): AuthOptions =>
|
||||
featureEnabled("requireLogin")
|
||||
? {
|
||||
providers: [
|
||||
AuthentikProvider({
|
||||
...(() => {
|
||||
const [clientId, clientSecret, issuer] = sequenceThrows(
|
||||
getAuthentikClientId,
|
||||
getAuthentikClientSecret,
|
||||
getAuthentikIssuer,
|
||||
);
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
issuer,
|
||||
};
|
||||
})(),
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid email profile offline_access",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
export const authOptions = (): AuthOptions => {
|
||||
if (!featureEnabled("requireLogin")) {
|
||||
return { providers: [] };
|
||||
}
|
||||
|
||||
const authProvider = process.env.AUTH_PROVIDER;
|
||||
|
||||
if (authProvider === "credentials") {
|
||||
return credentialsAuthOptions();
|
||||
}
|
||||
|
||||
return authentikAuthOptions();
|
||||
};
|
||||
|
||||
function credentialsAuthOptions(): AuthOptions {
|
||||
return {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Password",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account, user }) {
|
||||
if (account && !account.access_token) {
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
const apiUrl = getNextEnvVar("SERVER_API_URL");
|
||||
const response = await fetch(`${apiUrl}/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return {
|
||||
id: "pending",
|
||||
email: credentials.email,
|
||||
accessToken: data.access_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: { strategy: "jwt" },
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
// First login - user comes from authorize()
|
||||
const typedUser = user as any;
|
||||
token.accessToken = typedUser.accessToken;
|
||||
token.accessTokenExpires = Date.now() + typedUser.expiresIn * 1000;
|
||||
|
||||
// Resolve actual user ID from backend
|
||||
const userId = await getUserId(typedUser.accessToken);
|
||||
if (userId) {
|
||||
token.sub = userId;
|
||||
}
|
||||
token.email = typedUser.email;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const extendedToken = token as JWTWithAccessToken;
|
||||
return {
|
||||
...session,
|
||||
accessToken: extendedToken.accessToken,
|
||||
accessTokenExpires: extendedToken.accessTokenExpires,
|
||||
error: extendedToken.error,
|
||||
user: {
|
||||
id: assertExistsAndNonEmptyString(token.sub, "User ID required"),
|
||||
name: extendedToken.name,
|
||||
email: extendedToken.email,
|
||||
},
|
||||
} satisfies CustomSession;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authentikAuthOptions(): AuthOptions {
|
||||
return {
|
||||
providers: [
|
||||
AuthentikProvider({
|
||||
...(() => {
|
||||
const [clientId, clientSecret, issuer] = sequenceThrows(
|
||||
getAuthentikClientId,
|
||||
getAuthentikClientSecret,
|
||||
getAuthentikIssuer,
|
||||
);
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
issuer,
|
||||
};
|
||||
})(),
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid email profile offline_access",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
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}`,
|
||||
);
|
||||
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
|
||||
token: jwtToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jwtToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
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;
|
||||
console.log("extendedToken", extendedToken);
|
||||
const userId = await getUserId(extendedToken.accessToken);
|
||||
|
||||
// access token has expired, try to update it
|
||||
return await lockedRefreshAccessToken(token);
|
||||
return {
|
||||
...session,
|
||||
accessToken: extendedToken.accessToken,
|
||||
accessTokenExpires: extendedToken.accessTokenExpires,
|
||||
error: extendedToken.error,
|
||||
user: {
|
||||
id: assertExistsAndNonEmptyString(userId, "User ID required"),
|
||||
name: extendedToken.name,
|
||||
email: extendedToken.email,
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const extendedToken = token as JWTWithAccessToken;
|
||||
console.log("extendedToken", extendedToken);
|
||||
const userId = await getUserId(extendedToken.accessToken);
|
||||
|
||||
return {
|
||||
...session,
|
||||
accessToken: extendedToken.accessToken,
|
||||
accessTokenExpires: extendedToken.accessTokenExpires,
|
||||
error: extendedToken.error,
|
||||
user: {
|
||||
id: assertExistsAndNonEmptyString(userId, "User ID required"),
|
||||
name: extendedToken.name,
|
||||
email: extendedToken.email,
|
||||
},
|
||||
} satisfies CustomSession;
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
providers: [],
|
||||
};
|
||||
} satisfies CustomSession;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function lockedRefreshAccessToken(
|
||||
token: JWT,
|
||||
|
||||
@@ -28,10 +28,13 @@ export type EnvFeaturePartial = {
|
||||
[key in FeatureEnvName]: boolean | null;
|
||||
};
|
||||
|
||||
export type AuthProviderType = "authentik" | "credentials" | null;
|
||||
|
||||
// CONTRACT: isomorphic with JSON.stringify
|
||||
export type ClientEnvCommon = EnvFeaturePartial & {
|
||||
API_URL: NonEmptyString;
|
||||
WEBSOCKET_URL: NonEmptyString | null;
|
||||
AUTH_PROVIDER: AuthProviderType;
|
||||
};
|
||||
|
||||
let clientEnv: ClientEnvCommon | null = null;
|
||||
@@ -59,6 +62,12 @@ const parseBooleanString = (str: string | undefined): boolean | null => {
|
||||
return str === "true";
|
||||
};
|
||||
|
||||
const parseAuthProvider = (): AuthProviderType => {
|
||||
const val = process.env.AUTH_PROVIDER;
|
||||
if (val === "authentik" || val === "credentials") return val;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getClientEnvServer = (): ClientEnvCommon => {
|
||||
if (typeof window !== "undefined") {
|
||||
throw new Error(
|
||||
@@ -76,6 +85,7 @@ export const getClientEnvServer = (): ClientEnvCommon => {
|
||||
return {
|
||||
API_URL: getNextEnvVar("API_URL"),
|
||||
WEBSOCKET_URL: parseMaybeNonEmptyString(process.env.WEBSOCKET_URL ?? ""),
|
||||
AUTH_PROVIDER: parseAuthProvider(),
|
||||
...features,
|
||||
};
|
||||
}
|
||||
@@ -83,6 +93,7 @@ export const getClientEnvServer = (): ClientEnvCommon => {
|
||||
clientEnv = {
|
||||
API_URL: getNextEnvVar("API_URL"),
|
||||
WEBSOCKET_URL: parseMaybeNonEmptyString(process.env.WEBSOCKET_URL ?? ""),
|
||||
AUTH_PROVIDER: parseAuthProvider(),
|
||||
...features,
|
||||
};
|
||||
return clientEnv;
|
||||
|
||||
76
www/app/login/page.tsx
Normal file
76
www/app/login/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
VStack,
|
||||
Text,
|
||||
Heading,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (result?.error) {
|
||||
console.log(result?.error);
|
||||
setError("Invalid email or password");
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box maxW="400px" mx="auto" mt="100px" p={6}>
|
||||
<VStack gap={6} as="form" onSubmit={handleSubmit}>
|
||||
<Heading size="lg">Log in</Heading>
|
||||
{error && <Text color="red.500">{error}</Text>}
|
||||
<Field.Root required>
|
||||
<Field.Label>Email</Field.Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root required>
|
||||
<Field.Label>Password</Field.Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Button
|
||||
type="submit"
|
||||
colorPalette="blue"
|
||||
width="full"
|
||||
loading={loading}
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user