Commit dc9470f7 authored by Claude's avatar Claude

feat: implement OAuth state management with CSRF protection and cleanup functionality

parent fb01b49e
...@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; ...@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import useNavigateTo from "./hooks/useNavigateTo"; import useNavigateTo from "./hooks/useNavigateTo";
import { userStore, workspaceStore } from "./store"; import { userStore, workspaceStore } from "./store";
import { cleanupExpiredOAuthState } from "./utils/oauth";
import { loadTheme } from "./utils/theme"; import { loadTheme } from "./utils/theme";
const App = observer(() => { const App = observer(() => {
...@@ -13,6 +14,11 @@ const App = observer(() => { ...@@ -13,6 +14,11 @@ const App = observer(() => {
const userGeneralSetting = userStore.state.userGeneralSetting; const userGeneralSetting = userStore.state.userGeneralSetting;
const workspaceGeneralSetting = workspaceStore.state.generalSetting; const workspaceGeneralSetting = workspaceStore.state.generalSetting;
// Clean up expired OAuth states on app initialization
useEffect(() => {
cleanupExpiredOAuthState();
}, []);
// Redirect to sign up page if no instance owner. // Redirect to sign up page if no instance owner.
useEffect(() => { useEffect(() => {
if (!workspaceProfile.owner) { if (!workspaceProfile.owner) {
......
import { last } from "lodash-es";
import { LoaderIcon } from "lucide-react"; import { LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ClientError } from "nice-grpc-web"; import { ClientError } from "nice-grpc-web";
...@@ -8,6 +7,7 @@ import { authServiceClient } from "@/grpcweb"; ...@@ -8,6 +7,7 @@ import { authServiceClient } from "@/grpcweb";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { initialUserStore } from "@/store/user"; import { initialUserStore } from "@/store/user";
import { validateOAuthState } from "@/utils/oauth";
interface State { interface State {
loading: boolean; loading: boolean;
...@@ -29,21 +29,24 @@ const AuthCallback = observer(() => { ...@@ -29,21 +29,24 @@ const AuthCallback = observer(() => {
if (!code || !state) { if (!code || !state) {
setState({ setState({
loading: false, loading: false,
errorMessage: "Failed to authorize. Invalid state passed to the auth callback.", errorMessage: "Failed to authorize. Missing authorization code or state parameter.",
}); });
return; return;
} }
const identityProviderId = Number(last(state.split("-"))); // Validate OAuth state (CSRF protection)
if (!identityProviderId) { const validatedState = validateOAuthState(state);
if (!validatedState) {
setState({ setState({
loading: false, loading: false,
errorMessage: "No identity provider ID found in the state parameter.", errorMessage: "Failed to authorize. Invalid or expired state parameter. This may indicate a CSRF attack attempt.",
}); });
return; return;
} }
const { identityProviderId, returnUrl } = validatedState;
const redirectUri = absolutifyLink("/auth/callback"); const redirectUri = absolutifyLink("/auth/callback");
(async () => { (async () => {
try { try {
await authServiceClient.createSession({ await authServiceClient.createSession({
...@@ -58,7 +61,8 @@ const AuthCallback = observer(() => { ...@@ -58,7 +61,8 @@ const AuthCallback = observer(() => {
errorMessage: "", errorMessage: "",
}); });
await initialUserStore(); await initialUserStore();
navigateTo("/"); // Redirect to return URL if specified, otherwise home
navigateTo(returnUrl || "/");
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
setState({ setState({
......
...@@ -14,6 +14,7 @@ import { workspaceStore } from "@/store"; ...@@ -14,6 +14,7 @@ import { workspaceStore } from "@/store";
import { extractIdentityProviderIdFromName } from "@/store/common"; import { extractIdentityProviderIdFromName } from "@/store/common";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { storeOAuthState } from "@/utils/oauth";
const SignIn = observer(() => { const SignIn = observer(() => {
const t = useTranslate(); const t = useTranslate();
...@@ -38,7 +39,6 @@ const SignIn = observer(() => { ...@@ -38,7 +39,6 @@ const SignIn = observer(() => {
}, []); }, []);
const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => { const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => {
const stateQueryParameter = `auth.signin.${identityProvider.title}-${extractIdentityProviderIdFromName(identityProvider.name)}`;
if (identityProvider.type === IdentityProvider_Type.OAUTH2) { if (identityProvider.type === IdentityProvider_Type.OAUTH2) {
const redirectUri = absolutifyLink("/auth/callback"); const redirectUri = absolutifyLink("/auth/callback");
const oauth2Config = identityProvider.config?.oauth2Config; const oauth2Config = identityProvider.config?.oauth2Config;
...@@ -46,12 +46,24 @@ const SignIn = observer(() => { ...@@ -46,12 +46,24 @@ const SignIn = observer(() => {
toast.error("Identity provider configuration is invalid."); toast.error("Identity provider configuration is invalid.");
return; return;
} }
try {
// Generate and store secure state parameter with CSRF protection
const identityProviderId = extractIdentityProviderIdFromName(identityProvider.name);
const state = storeOAuthState(identityProviderId);
// Build OAuth authorization URL with secure state
const authUrl = `${oauth2Config.authUrl}?client_id=${ const authUrl = `${oauth2Config.authUrl}?client_id=${
oauth2Config.clientId oauth2Config.clientId
}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${stateQueryParameter}&response_type=code&scope=${encodeURIComponent( }&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&response_type=code&scope=${encodeURIComponent(
oauth2Config.scopes.join(" "), oauth2Config.scopes.join(" "),
)}`; )}`;
window.location.href = authUrl; window.location.href = authUrl;
} catch (error) {
console.error("Failed to initiate OAuth flow:", error);
toast.error("Failed to initiate sign-in. Please try again.");
}
} }
}; };
......
/**
* OAuth state management utilities
* Implements secure state parameter handling following Auth0 best practices
* @see https://auth0.com/docs/secure/attack-protection/state-parameters
*/
const STATE_STORAGE_KEY = "oauth_state";
const STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
interface OAuthState {
state: string;
identityProviderId: number;
timestamp: number;
returnUrl?: string;
}
/**
* Generate a cryptographically secure random state value
* Uses Web Crypto API for strong randomness
*/
function generateSecureState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
/**
* Store OAuth state in sessionStorage with metadata
* State is stored temporarily and will be validated on callback
*/
export function storeOAuthState(identityProviderId: number, returnUrl?: string): string {
const state = generateSecureState();
const stateData: OAuthState = {
state,
identityProviderId,
timestamp: Date.now(),
returnUrl,
};
try {
sessionStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(stateData));
} catch (error) {
console.error("Failed to store OAuth state:", error);
throw new Error("Failed to initialize OAuth flow");
}
return state;
}
/**
* Validate and retrieve OAuth state from storage
* Implements CSRF protection by verifying state matches
* Cleans up expired or used states
*/
export function validateOAuthState(stateParam: string): { identityProviderId: number; returnUrl?: string } | null {
try {
const storedData = sessionStorage.getItem(STATE_STORAGE_KEY);
if (!storedData) {
console.error("No OAuth state found in storage");
return null;
}
const stateData: OAuthState = JSON.parse(storedData);
// Check if state has expired
if (Date.now() - stateData.timestamp > STATE_EXPIRY_MS) {
console.error("OAuth state has expired");
sessionStorage.removeItem(STATE_STORAGE_KEY);
return null;
}
// Validate state matches (CSRF protection)
if (stateData.state !== stateParam) {
console.error("OAuth state mismatch - possible CSRF attack");
sessionStorage.removeItem(STATE_STORAGE_KEY);
return null;
}
// State is valid, clean up and return data
sessionStorage.removeItem(STATE_STORAGE_KEY);
return {
identityProviderId: stateData.identityProviderId,
returnUrl: stateData.returnUrl,
};
} catch (error) {
console.error("Failed to validate OAuth state:", error);
sessionStorage.removeItem(STATE_STORAGE_KEY);
return null;
}
}
/**
* Clean up expired OAuth states
* Should be called on app initialization
*/
export function cleanupExpiredOAuthState(): void {
try {
const storedData = sessionStorage.getItem(STATE_STORAGE_KEY);
if (!storedData) {
return;
}
const stateData: OAuthState = JSON.parse(storedData);
if (Date.now() - stateData.timestamp > STATE_EXPIRY_MS) {
sessionStorage.removeItem(STATE_STORAGE_KEY);
}
} catch {
// If parsing fails, remove the corrupted data
sessionStorage.removeItem(STATE_STORAGE_KEY);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment