feat: implement OAuth login functionality and update configuration
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user