feat: Add Single User authentication to Selfhosted (#870)

* Single user/password for selfhosted

* fix revision id latest migration
This commit is contained in:
Juan Diego García
2026-02-23 11:10:27 -05:00
committed by GitHub
parent 2ba0d965e8
commit c8db37362b
31 changed files with 1333 additions and 163 deletions

View File

@@ -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=

View File

@@ -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";

View File

@@ -23,7 +23,7 @@ export default function UserInfo() {
className="font-light px-2"
onClick={(e) => {
e.preventDefault();
auth.signIn("authentik");
auth.signIn();
}}
>
Log in

View File

@@ -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,

View File

@@ -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
View 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>
);
}