mobile2: Casdoor OIDC auth deployment
- New branch for mobile2.falah-os.com with Casdoor OIDC - Auth page with 'Sign in with Casdoor' button - OIDC callback: exchanges code via server-side API route - env vars configured on Netlify - DNS auto-configured via Netlify (falah-os.com zone)
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"remote": {
|
||||||
|
"https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb",
|
||||||
|
"https://edge.netlify.com/bootstrap/config.ts": "6a2ce0e544e15e8f8883a5c18da5948e37fd0f2619f68cb31f3af53c51817025",
|
||||||
|
"https://edge.netlify.com/bootstrap/context.ts": "e97240232121e2f369f6546ce961490f34d961ea1ea54be3ff09633e3f08373f",
|
||||||
|
"https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05",
|
||||||
|
"https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39",
|
||||||
|
"https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"packageJson": {
|
||||||
|
"dependencies": [
|
||||||
|
"npm:@netlify/plugin-nextjs@^5.15.12",
|
||||||
|
"npm:@prisma/client@^5.22.0",
|
||||||
|
"npm:@tailwindcss/postcss@^4.3.1",
|
||||||
|
"npm:@types/leaflet@^1.9.21",
|
||||||
|
"npm:@types/node@20",
|
||||||
|
"npm:@types/react-dom@19",
|
||||||
|
"npm:@types/react@19.2.17",
|
||||||
|
"npm:bcryptjs@^3.0.3",
|
||||||
|
"npm:eslint-config-next@16.2.7",
|
||||||
|
"npm:eslint@9",
|
||||||
|
"npm:jose@^6.2.3",
|
||||||
|
"npm:leaflet@^1.9.4",
|
||||||
|
"npm:lucide-react@^1.18.0",
|
||||||
|
"npm:next@16.2.7",
|
||||||
|
"npm:pdfkit@~0.19.1",
|
||||||
|
"npm:prisma@^5.22.0",
|
||||||
|
"npm:qrcode@^1.5.4",
|
||||||
|
"npm:react-dom@19.2.4",
|
||||||
|
"npm:react-leaflet@5",
|
||||||
|
"npm:react@19.2.4",
|
||||||
|
"npm:stripe@^22.2.1",
|
||||||
|
"npm:tailwindcss@^4.3.1",
|
||||||
|
"npm:typescript@5.9.3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
basePath: "/mobile",
|
basePath: "/mobile",
|
||||||
@@ -8,6 +9,9 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["lucide-react"],
|
optimizePackageImports: ["lucide-react"],
|
||||||
},
|
},
|
||||||
|
turbopack: {
|
||||||
|
root: path.resolve(import.meta.dirname || __dirname),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+2
-2
@@ -27,12 +27,12 @@
|
|||||||
"@netlify/plugin-nextjs": "^5.15.12",
|
"@netlify/plugin-nextjs": "^5.15.12",
|
||||||
"@tailwindcss/postcss": "^4.3.1",
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "^5"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|||||||
+2
-2
@@ -28,11 +28,11 @@
|
|||||||
"@netlify/plugin-nextjs": "^5.15.12",
|
"@netlify/plugin-nextjs": "^5.15.12",
|
||||||
"@tailwindcss/postcss": "^4.3.1",
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "^5"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const CLIENT_ID = process.env.NEXT_PUBLIC_CASDOOR_CLIENT_ID || "272f91105da5cf984773";
|
||||||
|
const CLIENT_SECRET = process.env.CASDOOR_CLIENT_SECRET;
|
||||||
|
const CASDOOR_TOKEN_URL = "https://auth.falahos.my/api/login/oauth/access_token";
|
||||||
|
const CASDOOR_USERINFO_URL = "https://auth.falahos.my/api/userinfo";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const code = req.nextUrl.searchParams.get("code");
|
||||||
|
const state = req.nextUrl.searchParams.get("state");
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json({ error: "Missing authorization code" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CLIENT_SECRET) {
|
||||||
|
return NextResponse.json({ error: "Casdoor client secret not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokenRes = await fetch(CASDOOR_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
code,
|
||||||
|
// redirect_uri must match exactly what was used in authorize request
|
||||||
|
redirect_uri: "https://mobile2.falah-os.com/auth/callback",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
const errBody = await tokenRes.text();
|
||||||
|
console.error("[casdoor-oidc] Token exchange failed:", tokenRes.status, errBody);
|
||||||
|
return NextResponse.json({ error: "Token exchange failed" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await tokenRes.json();
|
||||||
|
const accessToken = tokenData.access_token;
|
||||||
|
const idToken = tokenData.id_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json({ error: "No access_token in response" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch userinfo from Casdoor
|
||||||
|
const userRes = await fetch(CASDOOR_USERINFO_URL, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let userInfo = null;
|
||||||
|
if (userRes.ok) {
|
||||||
|
userInfo = await userRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
access_token: accessToken,
|
||||||
|
id_token: idToken,
|
||||||
|
token_type: tokenData.token_type || "Bearer",
|
||||||
|
expires_in: tokenData.expires_in || 3600,
|
||||||
|
refresh_token: tokenData.refresh_token || null,
|
||||||
|
user: userInfo
|
||||||
|
? {
|
||||||
|
id: userInfo.sub || userInfo.id,
|
||||||
|
email: userInfo.email,
|
||||||
|
name: userInfo.name || userInfo.displayName || userInfo.preferred_username,
|
||||||
|
avatar: userInfo.avatar || userInfo.picture,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[casdoor-oidc] Callback error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ function AuthCallbackContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = searchParams.get("token");
|
const token = searchParams.get("token");
|
||||||
|
const code = searchParams.get("code");
|
||||||
const error = searchParams.get("error");
|
const error = searchParams.get("error");
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -21,17 +22,62 @@ function AuthCallbackContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Casdoor OIDC flow: exchange authorization code for tokens ---
|
||||||
|
if (code) {
|
||||||
|
fetch(`/api/auth/casdoor-callback?code=${encodeURIComponent(code)}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMsg(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the access token
|
||||||
|
localStorage.setItem("flh_token", data.access_token);
|
||||||
|
if (data.id_token) {
|
||||||
|
localStorage.setItem("flh_id_token", data.id_token);
|
||||||
|
}
|
||||||
|
if (data.refresh_token) {
|
||||||
|
localStorage.setItem("flh_refresh_token", data.refresh_token);
|
||||||
|
}
|
||||||
|
if (data.user) {
|
||||||
|
localStorage.setItem("flh_user", JSON.stringify(data.user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch storage event so AuthContext picks it up
|
||||||
|
window.dispatchEvent(
|
||||||
|
new StorageEvent("storage", {
|
||||||
|
key: "flh_token",
|
||||||
|
newValue: data.access_token,
|
||||||
|
oldValue: null,
|
||||||
|
storageArea: localStorage,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setStatus("success");
|
||||||
|
|
||||||
|
// Redirect to home
|
||||||
|
setTimeout(() => router.push("/"), 500);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[oidc-callback] Token exchange error:", err);
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMsg("Failed to complete authentication. Please try again.");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legacy UmmahID flow: token passed directly in URL ---
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setErrorMsg("No authentication token received.");
|
setErrorMsg("No authentication code received.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store token in localStorage
|
// Store token in localStorage
|
||||||
localStorage.setItem("flh_token", token);
|
localStorage.setItem("flh_token", token);
|
||||||
|
|
||||||
// Dispatch a custom event so the AuthContext can pick it up
|
|
||||||
// (used by popup-based OAuth where the opener listens)
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new StorageEvent("storage", {
|
new StorageEvent("storage", {
|
||||||
key: "flh_token",
|
key: "flh_token",
|
||||||
@@ -47,7 +93,6 @@ function AuthCallbackContent() {
|
|||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
setTimeout(() => window.close(), 500);
|
setTimeout(() => window.close(), 500);
|
||||||
} else {
|
} else {
|
||||||
// Redirect to home
|
|
||||||
setTimeout(() => router.push("/"), 500);
|
setTimeout(() => router.push("/"), 500);
|
||||||
}
|
}
|
||||||
}, [searchParams, router]);
|
}, [searchParams, router]);
|
||||||
|
|||||||
+26
-1
@@ -3,9 +3,18 @@
|
|||||||
import { Suspense, useState, FormEvent, useEffect } from "react";
|
import { Suspense, useState, FormEvent, useEffect } from "react";
|
||||||
import { useAuth } from "@/lib/AuthContext";
|
import { useAuth } from "@/lib/AuthContext";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Mail, ArrowLeft } from "lucide-react";
|
import { Mail, ArrowLeft, Shield } from "lucide-react";
|
||||||
import ErrorFeedback from "@/components/ErrorFeedback";
|
import ErrorFeedback from "@/components/ErrorFeedback";
|
||||||
|
|
||||||
|
const getCasdoorUrl = () => {
|
||||||
|
const state = Math.random().toString(36).slice(2, 10);
|
||||||
|
const base = process.env.NEXT_PUBLIC_CASDOOR_SERVER_URL || "https://auth.falahos.my";
|
||||||
|
const clientId = process.env.NEXT_PUBLIC_CASDOOR_CLIENT_ID || "272f91105da5cf984773";
|
||||||
|
const redirect = process.env.NEXT_PUBLIC_CASDOOR_REDIRECT_URI ||
|
||||||
|
"https://mobile2.falah-os.com/auth/callback";
|
||||||
|
return `${base}/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirect)}&response_type=code&scope=openid+profile+email&state=${state}`;
|
||||||
|
};
|
||||||
|
|
||||||
type AuthMode = "login" | "register";
|
type AuthMode = "login" | "register";
|
||||||
type PageState = "select" | "email-form";
|
type PageState = "select" | "email-form";
|
||||||
|
|
||||||
@@ -89,6 +98,22 @@ function AuthPageInner() {
|
|||||||
|
|
||||||
{/* Sign-in Methods */}
|
{/* Sign-in Methods */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* Casdoor OIDC */}
|
||||||
|
<a
|
||||||
|
href={getCasdoorUrl()}
|
||||||
|
className="w-full flex items-center gap-4 px-5 py-4 rounded-2xl bg-green-900/20 border border-green-600/30 active:bg-green-900/30 transition-all touch-manipulation"
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
|
||||||
|
<Shield size={22} className="text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<span className="text-sm font-medium text-green-400 block">
|
||||||
|
Sign in with Casdoor
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-600">FalahOS Identity Service</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<button
|
<button
|
||||||
onClick={handleEmailClick}
|
onClick={handleEmailClick}
|
||||||
|
|||||||
Reference in New Issue
Block a user