feat: implement OAuth login functionality and update configuration

This commit is contained in:
sai80082
2026-02-09 15:08:54 +05:30
parent a8e4f77f3a
commit 222d9a5982
6 changed files with 281 additions and 20 deletions
+51 -20
View File
@@ -10,13 +10,12 @@ import {
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useState } from "react"
import { useEffect, useState } from "react"
import { loginUser } from "@/handlers/api/user.handler"
import { useConfig } from "@/contexts/ConfigContext"
import { IUser } from "@/types/user"
import Head from "next/head"
import { Tooltip } from "../ui/tooltip"
import { set } from "date-fns"
import { useRouter } from "next/router"
export const description =
"A login form with email and password. There's an option to login with Google and a link to sign up if you don't have an account."
@@ -27,7 +26,8 @@ interface ILoginFormProps {
export function LoginForm(
{ onLogin }: ILoginFormProps
) {
const { exImmichUrl } = useConfig()
const { exImmichUrl, oauthEnabled, oauthButtonText } = useConfig()
const router = useRouter()
const [formData, setFormData] = useState({
email: "",
password: "",
@@ -35,28 +35,43 @@ export function LoginForm(
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Check for error in query parameters
useEffect(() => {
if (router.query.error) {
setErrorMessage(router.query.error as string);
// Clear the error from the URL without refreshing
const { error, ...rest } = router.query;
router.replace({ pathname: router.pathname, query: rest }, undefined, { shallow: true });
}
}, [router.query.error]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.id]: e.target.value })
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
setErrorMessage(null)
loginUser(formData.email, formData.password)
.then(onLogin)
.catch((error) => {
setErrorMessage(error.message)
.then(onLogin)
.catch((error) => {
setErrorMessage(error.message)
}).finally(() => {
setLoading(false)
})
setLoading(false)
})
}
const handleOAuthLogin = () => {
window.location.assign("/api/auth/oauth/authorize")
}
return (
<>
<Head>
<title>Immich Power Tools - Login</title>
</Head>
<Head>
<title>Immich Power Tools - Login</title>
</Head>
<div className="relative flex min-h-screen justify-center flex-col bg-background">
<Card className="mx-auto max-w-sm">
<CardHeader>
@@ -98,12 +113,28 @@ export function LoginForm(
<Button type="submit" className="w-full" disabled={loading}>
Login
</Button>
<Button variant="outline" className="w-full" disabled={true}>
Login with OAuth (Comming Soon)
</Button>
<p className="text-xs text-gray-400 text-center">
Or Use <code>IMMICH_API_KEY</code> in your environment to use API Key instead of password.
</p>
{oauthEnabled && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleOAuthLogin}
>
{oauthButtonText}
</Button>
</>
)}
</form>
</CardContent>
</Card>
+3
View File
@@ -15,6 +15,9 @@ export const ENV = {
GEMINI_API_KEY: process.env.GEMINI_API_KEY as string,
IMMICH_SHARE_LINK_KEY: process.env.IMMICH_SHARE_LINK_KEY as string,
POWER_TOOLS_ENDPOINT_URL: process.env.POWER_TOOLS_ENDPOINT_URL as string,
// OAuth configuration
OAUTH_ENABLED: process.env.OAUTH_ENABLED === 'true',
OAUTH_BUTTON_TEXT: (process.env.OAUTH_BUTTON_TEXT || 'Login with OAuth') as string,
};
+4
View File
@@ -5,6 +5,8 @@ export interface ConfigContextType {
exImmichUrl: string;
version?: string;
geminiEnabled: boolean;
oauthEnabled: boolean;
oauthButtonText: string;
}
const ConfigContext = createContext<ConfigContextType>({
@@ -12,6 +14,8 @@ const ConfigContext = createContext<ConfigContextType>({
exImmichUrl: "",
version: "",
geminiEnabled: false,
oauthEnabled: false,
oauthButtonText: "",
});
export default ConfigContext;
+2
View File
@@ -34,6 +34,8 @@ App.getInitialProps = async () => {
immichURL: ENV.IMMICH_URL,
version: ENV.VERSION,
geminiEnabled: !!ENV.GEMINI_API_KEY?.length,
oauthEnabled: ENV.OAUTH_ENABLED,
oauthButtonText: ENV.OAUTH_BUTTON_TEXT,
},
};
};
+87
View File
@@ -0,0 +1,87 @@
import { NextApiRequest, NextApiResponse } from "next";
import { ENV } from "@/config/environment";
import crypto from "crypto";
import axios from "axios";
import { serializeCookie } from "@/lib/cookie";
/**
* OAuth Authorization Endpoint
* Redirects user to OAuth provider's authorization page with PKCE
*/
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const immichBaseUrl = ENV.EXTERNAL_IMMICH_URL || ENV.IMMICH_URL;
// Check if OAuth is enabled
if (!ENV.OAUTH_ENABLED || !immichBaseUrl) {
return res.status(400).json({ error: "OAuth is not configured" });
}
try {
// Generate PKCE code verifier and challenge
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
// Generate state for CSRF protection
const state = crypto.randomBytes(32).toString("base64url");
// Store state and code verifier in cookies
res.setHeader("Set-Cookie", [
serializeCookie("oauth_state", state, {
httpOnly: true,
secure: ENV.SECURE_COOKIE,
sameSite: "lax",
maxAge: 600, // 10 minutes
path: "/",
}),
serializeCookie("oauth_verifier", codeVerifier, {
httpOnly: true,
secure: ENV.SECURE_COOKIE,
sameSite: "lax",
maxAge: 600, // 10 minutes
path: "/",
}),
]);
// Build the authorization URL
const forwardedProto = req.headers["x-forwarded-proto"];
const protocol = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto || "http";
const host = req.headers.host || "localhost";
const baseUrl = ENV.POWER_TOOLS_ENDPOINT_URL || `${protocol}://${host}`;
const redirectUri = `${baseUrl}/api/auth/oauth/callback`;
// Ask Immich to start OAuth and return the provider URL
const immichAuthorize = await axios.post(
`${immichBaseUrl}/api/oauth/authorize`,
{
redirectUri,
state,
codeChallenge,
},
{
headers: { "Content-Type": "application/json" },
}
);
const { url } = immichAuthorize.data || {};
if (!url) {
throw new Error("No authorization URL returned from Immich");
}
// Redirect to OAuth provider
res.redirect(302, url);
} catch (error: any) {
console.error("OAuth authorization error:", error);
const errorMessage = error.response?.data?.message || error.message || "Failed to initiate OAuth flow";
res.redirect(`/?error=${encodeURIComponent(errorMessage)}`);
}
}
+134
View File
@@ -0,0 +1,134 @@
import { NextApiRequest, NextApiResponse } from "next";
import { ENV } from "@/config/environment";
import { parse } from "cookie";
import axios from "axios";
import { serializeCookie } from "@/lib/cookie";
import { appConfig } from "@/config/app.config";
/**
* OAuth Callback Endpoint
* Handles the callback from OAuth provider and exchanges code for tokens
*/
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const immichBaseUrl = ENV.EXTERNAL_IMMICH_URL || ENV.IMMICH_URL;
if (!ENV.OAUTH_ENABLED || !immichBaseUrl) {
return res.status(400).json({ error: "OAuth is not configured" });
}
try {
const { code, state, error, error_description } = req.query;
const getSingle = (value?: string | string[]) =>
Array.isArray(value) ? value[0] : value;
// Check for OAuth provider errors
if (error) {
const errorText = getSingle(error_description) || getSingle(error) || "OAuth error";
console.error("OAuth provider error:", errorText);
return res.redirect(`/?error=${encodeURIComponent(errorText)}`);
}
// Validate required parameters
const codeValue = getSingle(code);
const stateValue = getSingle(state);
if (!codeValue || !stateValue) {
return res.redirect("/?error=Missing code or state parameter");
}
// Get stored state and verifier from cookies
const cookies = parse(req.headers.cookie || "");
const storedState = cookies.oauth_state;
const codeVerifier = cookies.oauth_verifier;
if (!storedState || !codeVerifier) {
return res.redirect("/?error=OAuth session expired");
}
// Verify state to prevent CSRF attacks
if (stateValue !== storedState) {
return res.redirect("/?error=Invalid state parameter");
}
// Build the callback URL for Immich with ALL query parameters
const forwardedProto = req.headers["x-forwarded-proto"];
const protocol = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto || "http";
const host = req.headers.host || "localhost";
const baseUrl = ENV.POWER_TOOLS_ENDPOINT_URL || `${protocol}://${host}`;
const redirectUri = `${baseUrl}/api/auth/oauth/callback`;
const queryParams = new URLSearchParams();
Object.entries(req.query).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value)) {
value.forEach((entry) => queryParams.append(key, entry));
return;
}
queryParams.append(key, value);
});
const callbackUrl = `${redirectUri}?${queryParams.toString()}`;
// Finish OAuth with Immich
const immichResponse = await axios.post(
`${immichBaseUrl}/api/oauth/callback`,
{
url: callbackUrl,
state: stateValue,
codeVerifier: codeVerifier,
},
{
headers: {
"Content-Type": "application/json",
},
}
);
const accessToken = immichResponse.data.accessToken;
if (!accessToken) {
throw new Error("No access token received from Immich");
}
// Clear OAuth temporary cookies and set session cookie
res.setHeader("Set-Cookie", [
// Clear OAuth temporary cookies
serializeCookie("oauth_state", "", {
httpOnly: true,
secure: ENV.SECURE_COOKIE,
sameSite: "lax",
maxAge: 0,
path: "/",
}),
serializeCookie("oauth_verifier", "", {
httpOnly: true,
secure: ENV.SECURE_COOKIE,
sameSite: "lax",
maxAge: 0,
path: "/",
}),
// Set session cookie with Immich access token
serializeCookie(appConfig.sessionCookieName, accessToken, {
httpOnly: true,
secure: ENV.SECURE_COOKIE,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
}),
]);
// Redirect to home page
return res.redirect("/");
} catch (error: any) {
console.error("OAuth callback error:", error);
const errorMessage = error.response?.data?.message || error.message || "Authentication failed";
return res.redirect(`/?error=${encodeURIComponent(errorMessage)}`);
}
}