feat(auth): enterprise authentication and user management
This commit is contained in:
parent
4bc8b20a21
commit
86a32a942c
36 changed files with 2239 additions and 324 deletions
103
frontend/athena/lib/server/hermes.ts
Normal file
103
frontend/athena/lib/server/hermes.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const ACCESS_TOKEN_COOKIE = "access_token";
|
||||
const DEFAULT_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
||||
|
||||
export function getHermesUrl() {
|
||||
return process.env.HERMES_INTERNAL_URL?.replace(/\/$/, "") ?? null;
|
||||
}
|
||||
|
||||
export async function getAccessToken() {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
||||
}
|
||||
|
||||
function isSecureCookieEnabled() {
|
||||
if (process.env.AUTH_COOKIE_SECURE) {
|
||||
return process.env.AUTH_COOKIE_SECURE !== "false";
|
||||
}
|
||||
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
function normalizeMaxAge(maxAge?: number) {
|
||||
if (typeof maxAge !== "number" || !Number.isFinite(maxAge) || maxAge <= 0) {
|
||||
return DEFAULT_COOKIE_MAX_AGE_SECONDS;
|
||||
}
|
||||
|
||||
return Math.floor(maxAge);
|
||||
}
|
||||
|
||||
export function setAuthCookie(response: NextResponse, token: string, maxAge: number) {
|
||||
response.cookies.set({
|
||||
name: ACCESS_TOKEN_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: isSecureCookieEnabled(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: normalizeMaxAge(maxAge),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAuthCookie(response: NextResponse) {
|
||||
response.cookies.set({
|
||||
name: ACCESS_TOKEN_COOKIE,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
secure: isSecureCookieEnabled(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function unauthorizedResponse() {
|
||||
return NextResponse.json(
|
||||
{ detail: "Nicht authentifiziert" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
export function upstreamUnavailableResponse() {
|
||||
return NextResponse.json(
|
||||
{ detail: "Hermes ist nicht erreichbar" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
export function upstreamConfigurationErrorResponse() {
|
||||
return NextResponse.json(
|
||||
{ detail: "Hermes ist nicht konfiguriert" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function readJson(response: Response) {
|
||||
const text = await response.text();
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { detail: text };
|
||||
}
|
||||
}
|
||||
|
||||
export async function hermesJsonResponse(response: Response) {
|
||||
if (response.status === 204) {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await readJson(response);
|
||||
|
||||
return NextResponse.json(body, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
56
frontend/athena/lib/server/request-guards.ts
Normal file
56
frontend/athena/lib/server/request-guards.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
function normalizeOrigin(origin: string) {
|
||||
return origin.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function getConfiguredOrigins() {
|
||||
return (process.env.ATHENA_PUBLIC_ORIGIN ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizeOrigin);
|
||||
}
|
||||
|
||||
function getForwardedOrigin(request: NextRequest) {
|
||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||
|
||||
if (!forwardedHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https";
|
||||
const firstHost = forwardedHost.split(",")[0]?.trim();
|
||||
const firstProto = forwardedProto.split(",")[0]?.trim();
|
||||
|
||||
if (!firstHost || !firstProto) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${firstProto}://${firstHost}`;
|
||||
}
|
||||
|
||||
export function assertSameOrigin(request: NextRequest) {
|
||||
const origin = request.headers.get("origin");
|
||||
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestOrigins = [
|
||||
request.nextUrl.origin,
|
||||
getForwardedOrigin(request),
|
||||
...getConfiguredOrigins(),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map(normalizeOrigin);
|
||||
|
||||
if (!requestOrigins.includes(normalizeOrigin(origin))) {
|
||||
return NextResponse.json(
|
||||
{ detail: "Ungültiger Ursprung der Anfrage" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue