diff --git a/.env-example b/.env-example index 6bcce3a..ff402a1 100644 --- a/.env-example +++ b/.env-example @@ -50,3 +50,11 @@ GP_PASSWORD= # how often to check for new/updated queries in min QUERY_TIME_TYPE=m #valid options are m, h QUERY_CHECK=1 + + +# Oauth setup +PROVIDER="" +CLIENT_ID="" +CLIENT_SECRET="" +CLIENT_SCOPES="openid profile email groups" +DISCOVERY_URL="" diff --git a/backend/utils/auth.utils.ts b/backend/utils/auth.utils.ts index d943503..f9e7278 100644 --- a/backend/utils/auth.utils.ts +++ b/backend/utils/auth.utils.ts @@ -2,6 +2,7 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin as adminPlugin, + genericOAuth, // apiKey, // createAuthMiddleware, //customSession, @@ -16,6 +17,46 @@ import { ac, admin, systemAdmin, user } from "./auth.permissions.js"; import { allowedOrigins } from "./cors.utils.js"; import { sendEmail } from "./sendEmail.utils.js"; +function decodeJwtPayload>(jwt: string): T { + const parts = jwt.split("."); + if (parts.length < 2) { + throw new Error("Invalid JWT"); + } + + const payload = parts[1]?.replace(/-/g, "+").replace(/_/g, "/"); + + const padded = payload?.padEnd( + payload.length + ((4 - (payload.length % 4)) % 4), + "=", + ); + + const json = Buffer.from(padded ?? "", "base64").toString("utf8"); + return JSON.parse(json) as T; +} + +function normalizeGroups(groups?: unknown): string[] { + if (!Array.isArray(groups)) return []; + + return groups + .filter((g): g is string => typeof g === "string") + .map((g) => g.trim().toLowerCase()) + .filter((g) => g.length > 0); +} + +type VoidAuthClaims = { + sub: string; + name?: string; + preferred_username?: string; + email?: string; + email_verified?: boolean; + groups?: string[]; + picture?: string; + iss?: string; + aud?: string; + exp?: number; + iat?: number; +}; + export const schema = { user: rawSchema.user, session: rawSchema.session, @@ -25,9 +66,73 @@ export const schema = { apiKey: rawSchema.apikey, // 🔑 rename to apiKey }; +const hasOAuth = + Boolean(process.env.PROVIDER) && + Boolean(process.env.CLIENT_ID) && + Boolean(process.env.CLIENT_SECRET) && + Boolean(process.env.DISCOVERY_URL); + +if (!hasOAuth) { + console.warn("Missing oauth data."); +} + +const oauthPlugins = hasOAuth + ? [ + genericOAuth({ + config: [ + { + providerId: process.env.PROVIDER!, + clientId: process.env.CLIENT_ID!, + clientSecret: process.env.CLIENT_SECRET!, + discoveryUrl: process.env.DISCOVERY_URL!, + scopes: (process.env.CLIENT_SCOPES ?? "") + .split(/[,\s]+/) + .filter(Boolean), + pkce: true, + requireIssuerValidation: true, + redirectURI: `${process.env.URL}/lst/api/auth/oauth2/callback/${process.env.PROVIDER!}`, + getUserInfo: async (tokens) => { + if (!tokens.idToken) { + throw new Error("VoidAuth did not return an idToken"); + } + + const claims = decodeJwtPayload(tokens.idToken); + const groups = normalizeGroups(claims.groups); + + return { + id: claims.sub, + email: claims.email ?? "", + name: + claims.name ?? + claims.preferred_username ?? + claims.email ?? + "Unknown User", + image: claims.picture ?? null, + emailVerified: Boolean(claims.email_verified), + groups, + username: claims.preferred_username ?? null, + } as any; + }, + + mapProfileToUser: async (profile) => { + return { + name: profile.name, + role: profile.groups?.includes("lst_admins") + ? "systemAdmin" + : profile.groups?.includes("admins") + ? "admin" + : "user", + }; + }, + }, + ], + }), + ] + : []; + export const auth = betterAuth({ appName: "lst", - baseURL: process.env.URL, + baseURL: `${process.env.URL}/lst/api/auth`, database: drizzleAdapter(db, { provider: "pg", schema, @@ -42,6 +147,14 @@ export const auth = betterAuth({ }, }, }, + account: { + encryptOAuthTokens: true, + updateAccountOnSignIn: true, + accountLinking: { + enabled: true, + trustedProviders: ["voidauth"], + }, + }, plugins: [ jwt({ jwt: { expirationTime: "1h" } }), //apiKey(), @@ -63,6 +176,7 @@ export const auth = betterAuth({ return true; }, }), + ...oauthPlugins, // customSession(async ({ user, session }) => { // const roles = await db @@ -121,7 +235,7 @@ export const auth = betterAuth({ }, }, cookie: { - path: "/lst/app", + path: "/lst", sameSite: "lax", secure: false, httpOnly: true, diff --git a/frontend/src/lib/auth-client.ts b/frontend/src/lib/auth-client.ts index d881795..60ae7a5 100644 --- a/frontend/src/lib/auth-client.ts +++ b/frontend/src/lib/auth-client.ts @@ -1,4 +1,4 @@ -import { adminClient } from "better-auth/client/plugins"; +import { adminClient, genericOAuthClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { ac, admin, systemAdmin, user } from "./auth-permissions"; @@ -13,6 +13,7 @@ export const authClient = createAuthClient({ systemAdmin, }, }), + genericOAuthClient(), ], }); diff --git a/frontend/src/routes/(auth)/-components/LoginForm.tsx b/frontend/src/routes/(auth)/-components/LoginForm.tsx index 8cef350..c9de044 100644 --- a/frontend/src/routes/(auth)/-components/LoginForm.tsx +++ b/frontend/src/routes/(auth)/-components/LoginForm.tsx @@ -1,4 +1,5 @@ import { Link, useNavigate } from "@tanstack/react-router"; +import { Cat, LogIn } from "lucide-react"; import { toast } from "sonner"; import { Card, @@ -9,13 +10,23 @@ import { } from "@/components/ui/card"; import { authClient } from "@/lib/auth-client"; import { useAppForm } from "@/lib/formSutff"; +import { Button } from "../../../components/ui/button"; import socket from "../../../lib/socket.io"; export default function LoginForm({ redirectPath }: { redirectPath: string }) { const loginEmail = localStorage.getItem("loginEmail") || ""; const rememberMe = localStorage.getItem("rememberMe") === "true"; + const navigate = useNavigate(); + const oauthLogin = async () => { + await authClient.signIn.oauth2({ + providerId: "voidauth", + callbackURL: "/lst/app", + errorCallbackURL: "/lst/app/login", + }); + }; + const form = useAppForm({ defaultValues: { email: loginEmail, @@ -26,7 +37,7 @@ export default function LoginForm({ redirectPath }: { redirectPath: string }) { // set remember me incase we want it later if (value.rememberMe) { localStorage.setItem("rememberMe", value.rememberMe.toString()); - localStorage.setItem("loginEmail", value.email); + localStorage.setItem("loginEmail", value.email.toLocaleLowerCase()); } else { localStorage.removeItem("rememberMe"); localStorage.removeItem("loginEmail"); @@ -62,7 +73,17 @@ export default function LoginForm({ redirectPath }: { redirectPath: string }) {
- Login to your account + +
+ + Login to your account{" "} + +
+
Enter your username and password below @@ -76,12 +97,19 @@ export default function LoginForm({ redirectPath }: { redirectPath: string }) { > {(field) => ( - + )} {(field) => ( - + )} @@ -98,7 +126,7 @@ export default function LoginForm({ redirectPath }: { redirectPath: string }) {
-
+
Login diff --git a/scripts/updateServer.ps1 b/scripts/updateServer.ps1 index 7698ba5..cdeeb3e 100644 --- a/scripts/updateServer.ps1 +++ b/scripts/updateServer.ps1 @@ -23,6 +23,8 @@ $password = $ADM_PASSWORD $securePass = ConvertTo-SecureString $password -AsPlainText -Force $credentials = New-Object System.Management.Automation.PSCredential($username, $securePass) + + function Update-Server { param ( [string]$Destination, @@ -84,6 +86,75 @@ function Update-Server { $AppUpdate = { param ($Server, $Token, $Destination, $BuildFile) + function Fix-Env { + $envFile = ".env" + + if (-not (Test-Path $envFile)) { + Write-Host ".env not found, creating..." + New-Item -ItemType File -Path $envFile | Out-Null + } + + $envContent = Get-Content $envFile -Raw + + if ([string]::IsNullOrWhiteSpace($envContent)) { + $envContent = "" + } + + $envVarsToAdd = @{ + "PROVIDER" = "voidauth" + "CLIENT_ID" = "crIVcUilFWIS6ME3" + "CLIENT_SECRET" = "zsJeyjMN2yDDqfyzSsh96OtlA2714F5d" + "CLIENT_SCOPES" = "openid profile email groups" + "DISCOVERY_URL" = "https://auth.tuffraid.net/oidc/.well-known/openid-configuration" + } + + $linesToAppend = @() + + foreach ($key in $envVarsToAdd.Keys) { + $escapedKey = [regex]::Escape($key) + + if ($envContent -notmatch "(?m)^$escapedKey=") { + $linesToAppend += "$key=$($envVarsToAdd[$key])" + Write-Host "Adding missing env: $key" + } + else { + Write-Host "Env exists, skipping add: $key" + } + } + + ###### to replace the values of mistakens or something fun where we need to fix across all 17 servers put it here. + $envVarsToReplace = @{ + # "PORT" = "3000" + #"URL" = "https://$($Token)prod.alpla.net/lst" + } + + foreach ($key in $envVarsToReplace.Keys) { + $value = $envVarsToReplace[$key] + $escapedKey = [regex]::Escape($key) + + if ($envContent -match "(?m)^$escapedKey=") { + Write-Host "Replacing env: $key -> $value" + $envContent = $envContent -replace "(?m)^$escapedKey=.*", "$key=$value" + } + else { + Write-Host "Env not found for replace, skipping: $key" + } + } + + if ($linesToAppend.Count -gt 0) { + if ($envContent.Length -gt 0 -and -not $envContent.EndsWith("`n")) { + $envContent += "`r`n" + } + + $envContent += "`r`n# ---- VoidAuth Config ----`r`n" + $envContent += ($linesToAppend -join "`r`n") + Write-Host "Appending new env vars." + } + + Set-Content -Path $envFile -Value $envContent + Write-Host "Env update completed." + } + #convert everything to the server fun $LocalPath = $Destination -replace '\$', ':' $BuildFileLoc = "$LocalPath\$BuildFile" @@ -121,8 +192,12 @@ function Update-Server { Write-Host "Running install/update in: $LocalPath" npm install --omit=dev Start-Sleep -Seconds 3 - Write-Host "Install/update completed." - # do the migrations + Write-Host "Install/update completed." + + # update the env to include the new and missing things silly people and wanting things fixed :( + Fix-Env #-Path $LocalPath + + # do the migrations # Push-Location $LocalPath Write-Host "Running migrations" npm run dev:db:migrate