Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
dc9470f7
Commit
dc9470f7
authored
Nov 02, 2025
by
Claude
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement OAuth state management with CSRF protection and cleanup functionality
parent
fb01b49e
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
146 additions
and
13 deletions
+146
-13
App.tsx
web/src/App.tsx
+6
-0
AuthCallback.tsx
web/src/pages/AuthCallback.tsx
+10
-6
SignIn.tsx
web/src/pages/SignIn.tsx
+19
-7
oauth.ts
web/src/utils/oauth.ts
+111
-0
No files found.
web/src/App.tsx
View file @
dc9470f7
...
@@ -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
)
{
...
...
web/src/pages/AuthCallback.tsx
View file @
dc9470f7
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
({
...
...
web/src/pages/SignIn.tsx
View file @
dc9470f7
...
@@ -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
;
}
}
const
authUrl
=
`
${
oauth2Config
.
authUrl
}
?client_id=
${
oauth2Config
.
clientId
try
{
}
&redirect_uri=
${
encodeURIComponent
(
redirectUri
)}
&state=
${
stateQueryParameter
}
&response_type=code&scope=
${
encodeURIComponent
(
// Generate and store secure state parameter with CSRF protection
oauth2Config
.
scopes
.
join
(
" "
),
const
identityProviderId
=
extractIdentityProviderIdFromName
(
identityProvider
.
name
);
)}
`
;
const
state
=
storeOAuthState
(
identityProviderId
);
window
.
location
.
href
=
authUrl
;
// Build OAuth authorization URL with secure state
const
authUrl
=
`
${
oauth2Config
.
authUrl
}
?client_id=
${
oauth2Config
.
clientId
}
&redirect_uri=
${
encodeURIComponent
(
redirectUri
)}
&state=
${
state
}
&response_type=code&scope=
${
encodeURIComponent
(
oauth2Config
.
scopes
.
join
(
" "
),
)}
`
;
window
.
location
.
href
=
authUrl
;
}
catch
(
error
)
{
console
.
error
(
"Failed to initiate OAuth flow:"
,
error
);
toast
.
error
(
"Failed to initiate sign-in. Please try again."
);
}
}
}
};
};
...
...
web/src/utils/oauth.ts
0 → 100644
View file @
dc9470f7
/**
* 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
);
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment