add oauth login
This commit is contained in:
parent
c9960aea75
commit
53c27c4efc
343
SSO-OAuth-OIDC.md
Normal file
343
SSO-OAuth-OIDC.md
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
# Google OAuth (OIDC) + AzureAD Shared SSO Service
|
||||
|
||||
Tai lieu nay tong hop toan bo flow dang nhap OAuth/OIDC theo muc coding cho codebase hien tai.
|
||||
|
||||
Pham vi:
|
||||
- Giu nguyen login username/password cu.
|
||||
- Dung chung 1 service cho AzureAD SSO va Google OAuth OIDC.
|
||||
- Van tao/cap nhat user noi bo trong DB.
|
||||
- User moi vao role Pending (khong co permission), doi admin cap quyen.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 1) Trang thai implementation hien tai
|
||||
|
||||
Da hoan tat trong code:
|
||||
- Shared service theo provider key: SsoService.
|
||||
- Controller da provider: OAuthController.
|
||||
- Controller AzureAD cu (SsoController) da chay tren shared service.
|
||||
- Config da provider qua OAuthProviders trong appsettings.
|
||||
|
||||
File chinh:
|
||||
- TTMT.CompManageWeb/Program.cs
|
||||
- TTMT.CompManageWeb/Interfaces/ISsoService.cs
|
||||
- TTMT.CompManageWeb/Services/SsoSerivce.cs
|
||||
- TTMT.CompManageWeb/Controllers/OAuthController.cs
|
||||
- TTMT.CompManageWeb/Controllers/SsoController.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProviderOptions.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProvidersOptions.cs
|
||||
- TTMT.CompManageWeb/appsettings.json
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 2) Kien truc luong dang nhap
|
||||
|
||||
### 2.1 Luong OAuth Google
|
||||
|
||||
1. FE redirect user den:
|
||||
- GET /api/auth/oauth/google/login?returnUrl=<frontend-url>
|
||||
2. Backend build authorize URL va redirect qua Google.
|
||||
3. Google callback ve backend:
|
||||
- GET /api/auth/oauth/google/callback?code=...&state=...
|
||||
4. Backend:
|
||||
- Exchange code -> token endpoint.
|
||||
- Validate id_token.
|
||||
- Lay email/name.
|
||||
- Kiem tra domain.
|
||||
- Upsert user noi bo.
|
||||
- Issue JWT noi bo.
|
||||
- Tao one-time code (2 phut).
|
||||
- Redirect ve returnUrl?code=<one-time-code>.
|
||||
5. FE goi API exchange:
|
||||
- POST /api/auth/oauth/exchange
|
||||
- Body: { "code": "..." }
|
||||
6. Backend consume one-time code va tra payload login (token + role + permission).
|
||||
|
||||
### 2.2 Luong AzureAD cu
|
||||
|
||||
Van giu endpoint cu:
|
||||
- GET /api/auth/sso/login
|
||||
- GET /api/auth/sso/callback
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Nhung ben trong da dung chung service theo provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 3) Cau hinh bat buoc
|
||||
|
||||
### 3.1 appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"OAuthProviders": {
|
||||
"DefaultProvider": "azuread",
|
||||
"Providers": {
|
||||
"google": {
|
||||
"Authority": "https://accounts.google.com",
|
||||
"AuthorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"TokenEndpoint": "https://oauth2.googleapis.com/token",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"CallbackPath": "/api/auth/oauth/google/callback",
|
||||
"AllowedDomain": "",
|
||||
"PendingRoleName": "Pending",
|
||||
"Scopes": "openid profile email",
|
||||
"Issuer": "https://accounts.google.com",
|
||||
"EmailClaim": "email",
|
||||
"NameClaim": "name",
|
||||
"HostedDomainClaimName": "hd",
|
||||
"EnforceHostedDomainClaim": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Giai thich nhanh:
|
||||
- AllowedDomain:
|
||||
- Rong: cho phep moi domain.
|
||||
- Co gia tri (vd hust.edu.vn): chi cho email domain nay.
|
||||
- EnforceHostedDomainClaim=true:
|
||||
- Neu co claim hd trong token thi bat buoc phai khop AllowedDomain.
|
||||
- Neu token khong co hd, he thong fallback check theo email domain.
|
||||
- PendingRoleName: role duoc gan khi user moi dang nhap lan dau.
|
||||
|
||||
### 3.2 Google Cloud Console
|
||||
|
||||
1. Tao OAuth client (Web application).
|
||||
2. Authorized redirect URIs:
|
||||
- https://<your-domain>/api/auth/oauth/google/callback
|
||||
- (dev) https://localhost:<port>/api/auth/oauth/google/callback
|
||||
3. Lay ClientId, ClientSecret va dien vao appsettings (hoac secrets).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 4) Dang ky DI va options
|
||||
|
||||
Trong Program.cs can co:
|
||||
|
||||
```csharp
|
||||
builder.Services.Configure<AzureAdOptions>(
|
||||
builder.Configuration.GetSection(AzureAdOptions.SectionName));
|
||||
|
||||
builder.Services.Configure<OAuthProvidersOptions>(
|
||||
builder.Configuration.GetSection(OAuthProvidersOptions.SectionName));
|
||||
|
||||
builder.Services.AddScoped<ISsoService, SsoService>();
|
||||
```
|
||||
|
||||
OAuthProvidersOptions dung dictionary provider theo key (vd google, azuread, okta).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 5) Thiet ke interface va service chung
|
||||
|
||||
### 5.1 Interface ISsoService
|
||||
|
||||
Interface da duoc mo rong thanh provider-aware:
|
||||
- BuildAuthorizeUrl(provider, redirectUri, state)
|
||||
- ExchangeCodeAsync(provider, code, redirectUri, ct)
|
||||
- ValidateIdTokenAsync(provider, idToken, ct)
|
||||
- IsAllowedDomain(provider, email, principal)
|
||||
- UpsertUserAsync(provider, email, name, ct)
|
||||
|
||||
Va van giu overload cu de backward compatibility.
|
||||
|
||||
### 5.2 SsoService - logic tong
|
||||
|
||||
Core y tuong:
|
||||
- Resolve config theo provider key.
|
||||
- Provider nao khong co endpoint explicit thi suy ra tu metadata/authority.
|
||||
- AzureAD duoc fallback tu AzureAdOptions de khong vo luong cu.
|
||||
|
||||
Phan quan trong:
|
||||
|
||||
1) ResolveProvider(...)
|
||||
- Doc provider trong OAuthProviders.
|
||||
- Neu key = azuread ma khong co trong OAuthProviders, fallback AzureAdOptions.
|
||||
|
||||
2) BuildAuthorizeUrl(...)
|
||||
- Build URL authorize theo endpoint cua provider.
|
||||
- Them scope, state, redirect_uri.
|
||||
- Neu bat hosted-domain check thi them hd=<AllowedDomain>.
|
||||
|
||||
3) ExchangeCodeAsync(...)
|
||||
- Goi token endpoint voi client_id, client_secret, code, redirect_uri.
|
||||
- Parse OidcTokenResponse.
|
||||
- Bat buoc co id_token (flow hien tai dang OIDC-centric).
|
||||
|
||||
4) ValidateIdTokenAsync(...)
|
||||
- Lay metadata OIDC (.well-known/openid-configuration).
|
||||
- Validate issuer, audience, signing key, lifetime.
|
||||
|
||||
5) IsAllowedDomain(...)
|
||||
- Neu AllowedDomain rong -> cho phep.
|
||||
- Neu khong rong -> check duoi email.
|
||||
- Neu EnforceHostedDomainClaim bat va token co claim hd -> bat buoc hd trung AllowedDomain.
|
||||
|
||||
6) UpsertUserAsync(...)
|
||||
- Tim user theo email (UserName).
|
||||
- Neu ton tai: cap nhat ten + metadata update.
|
||||
- Neu chua ton tai: tao user moi, Password = null, gan role PendingRoleName.
|
||||
|
||||
7) One-time code
|
||||
- CreateOneTimeCodeAsync(...): tao code, luu bang SsoOneTimeCodes, het han sau 2 phut.
|
||||
- ExchangeOneTimeCodeForLoginAsync(...): consume code, tra payload login final.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 6) Controller va contract API
|
||||
|
||||
### 6.1 OAuthController (da provider)
|
||||
|
||||
Route base: api/auth/oauth
|
||||
|
||||
1) Login
|
||||
- GET /api/auth/oauth/{provider}/login?returnUrl=...
|
||||
- Redirect sang provider authorize URL.
|
||||
|
||||
2) Callback
|
||||
- GET /api/auth/oauth/{provider}/callback?code=...&state=...
|
||||
- Xu ly token + domain + upsert + issue token + one-time code.
|
||||
- Redirect ve FE voi query code.
|
||||
|
||||
3) Exchange
|
||||
- POST /api/auth/oauth/exchange
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"code": "one-time-code"
|
||||
}
|
||||
```
|
||||
|
||||
Response thanh cong:
|
||||
```json
|
||||
{
|
||||
"token": "<jwt-noi-bo>",
|
||||
"name": "...",
|
||||
"username": "email@domain",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 SsoController (AzureAD legacy)
|
||||
|
||||
Van giu route cu, nhung da goi service chung voi provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 7) Rule user va role Pending
|
||||
|
||||
Rule bat buoc:
|
||||
- User OAuth lan dau phai duoc tao trong bang UserAccounts.
|
||||
- User moi phai vao role Pending.
|
||||
- Role Pending khong co permission nao (PermissionRoles.IsChecked = 0 hoac khong co row).
|
||||
- Admin se cap role/permission sau.
|
||||
|
||||
Model lien quan:
|
||||
- UserAccounts
|
||||
- Roles
|
||||
- PermissionRoles
|
||||
- SsoOneTimeCodes
|
||||
|
||||
Kiem tra nhanh trong DB:
|
||||
|
||||
```sql
|
||||
-- 1) Kiem tra role Pending co ton tai
|
||||
SELECT Id, RoleName, Priority
|
||||
FROM "Roles"
|
||||
WHERE "RoleName" = 'Pending';
|
||||
|
||||
-- 2) Kiem tra Pending role khong co permission active
|
||||
SELECT pr.*
|
||||
FROM "PermissionRoles" pr
|
||||
JOIN "Roles" r ON r."Id" = pr."RoleId"
|
||||
WHERE r."RoleName" = 'Pending'
|
||||
AND pr."IsChecked" = 1;
|
||||
|
||||
-- 3) Kiem tra user tao boi OAuth
|
||||
SELECT "Id", "UserName", "Password", "RoleId", "CreatedBy", "UpdatedBy"
|
||||
FROM "UserAccounts"
|
||||
WHERE "UserName" = '<email-user>';
|
||||
```
|
||||
|
||||
Ky vong:
|
||||
- Query #2 tra ve 0 rows.
|
||||
- User moi co Password = null, CreatedBy = 'SSO'.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 8) Frontend integration (chi tiet)
|
||||
|
||||
1. Nguoi dung bam nut Google Login:
|
||||
- Window.location -> GET /api/auth/oauth/google/login?returnUrl=<FE_CALLBACK_URL>
|
||||
2. Sau callback backend, FE nhan code tu query string.
|
||||
3. FE goi:
|
||||
|
||||
```http
|
||||
POST /api/auth/oauth/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
{ "code": "<code-tu-query>" }
|
||||
```
|
||||
|
||||
4. FE luu token va xu ly permission/role giong login cu.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 9) Luu y bao mat va hardening
|
||||
|
||||
Nen lam tiep:
|
||||
- Validate returnUrl theo allowlist de tranh open redirect.
|
||||
- Luu va verify state/nonce server-side (cache/redis).
|
||||
- Dung IHttpClientFactory thay new HttpClient() de kiem soat timeout/retry.
|
||||
- Dua ClientSecret sang secret manager/env var.
|
||||
- Bat HTTPS va secure cookie policy day du.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 10) Troubleshooting
|
||||
|
||||
1) Loi IdToken not found in token response
|
||||
- Provider dang khong tra OIDC token.
|
||||
- Kiem tra scope co openid chua.
|
||||
|
||||
2) Loi Email not found in token
|
||||
- Kiem tra claim trong Google token (email, preferred_username).
|
||||
|
||||
3) Loi Email domain is not allowed
|
||||
- Check AllowedDomain va claim hd.
|
||||
|
||||
4) Loi Role 'Pending' not found
|
||||
- Tao role Pending trong bang Roles.
|
||||
|
||||
5) Loi exchange code het han
|
||||
- One-time code chi song 2 phut va chi dung 1 lan.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 11) Checklist test E2E truoc khi merge
|
||||
|
||||
- Login Google thanh cong voi account hop le.
|
||||
- User moi duoc tao trong DB va role = Pending.
|
||||
- Pending role khong co permission active.
|
||||
- User cu login lai thi khong tao duplicate user.
|
||||
- One-time code chi dung 1 lan.
|
||||
- AllowedDomain rong cho phep tat ca domain.
|
||||
- AllowedDomain co gia tri thi chan dung domain.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 12) Ghi chu pham vi hien tai
|
||||
|
||||
Hien tai flow moi dang OIDC-centric (bat buoc id_token).
|
||||
Neu can support OAuth thuan (khong co id_token), can bo sung nhanh:
|
||||
- UserInfo endpoint call.
|
||||
- Mapping email/name tu userinfo response.
|
||||
- Branch logic trong callback de fallback userinfo.
|
||||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import type { LoginResquest } from "@/types/auth";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { buildSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { buildGoogleOAuthLoginUrl, login } from "@/services/auth.service";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { Route } from "@/routes/(auth)/login";
|
||||
|
|
@ -44,12 +44,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
}
|
||||
});
|
||||
|
||||
const handleSsoLogin = () => {
|
||||
const returnUrl = new URL("/sso/callback", window.location.origin);
|
||||
const handleGoogleLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
|
||||
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
|
|
@ -112,14 +112,26 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
</Button>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleGoogleLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
|
||||
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
|
||||
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
|
||||
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.02 5.02 0 0 1-2.18 3.29v2.74h3.52c2.05-1.89 3.3-4.67 3.3-8.04Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.52-2.74c-.98.66-2.23 1.06-3.76 1.06-2.89 0-5.33-1.95-6.2-4.56H2.18v2.84A11 11 0 0 0 12 23Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.8 14.1A6.62 6.62 0 0 1 5.45 12c0-.73.13-1.44.35-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.77.42 3.44 1.18 4.94L5.8 14.1Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.34c1.61 0 3.05.56 4.18 1.64l3.14-3.14C17.45 2.09 14.97 1 12 1a11 11 0 0 0-9.82 6.06L5.8 9.9C6.67 7.29 9.11 5.34 12 5.34Z"
|
||||
/>
|
||||
</svg>
|
||||
Đăng nhập với Microsoft
|
||||
Đăng nhập với Google
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export const BASE_MESH_URL = isDev
|
|||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: `${BASE_URL}/login`,
|
||||
OAUTH_LOGIN: (provider: string) =>
|
||||
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/login`,
|
||||
OAUTH_EXCHANGE: `${BASE_URL}/auth/oauth/exchange`,
|
||||
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
|
||||
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
|
||||
LOGOUT: `${BASE_URL}/logout`,
|
||||
|
|
|
|||
|
|
@ -115,10 +115,17 @@ export function useCreateAccount() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Hook để đổi one-time code SSO lấy payload đăng nhập
|
||||
* Hook để đổi one-time OAuth code lấy payload đăng nhập
|
||||
*/
|
||||
export function useExchangeSsoCode() {
|
||||
export function useExchangeOAuthCode() {
|
||||
return useMutation<LoginResponse, any, string>({
|
||||
mutationFn: (code) => authService.exchangeSsoCode(code),
|
||||
mutationFn: (code) => authService.exchangeOAuthCode(code),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias for backward compatibility.
|
||||
*/
|
||||
export function useExchangeSsoCode() {
|
||||
return useExchangeOAuthCode();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
|
|||
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
||||
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
|
||||
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
||||
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
|
||||
import { Route as authOauthCallbackIndexRouteImport } from './routes/(auth)/oauth/callback/index'
|
||||
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
||||
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
|
||||
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
||||
|
|
@ -132,9 +132,9 @@ const AuthProfileUserNameIndexRoute =
|
|||
path: '/profile/$userName/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
|
||||
id: '/(auth)/sso/callback/',
|
||||
path: '/sso/callback/',
|
||||
const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
|
||||
id: '/(auth)/oauth/callback/',
|
||||
path: '/oauth/callback/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
||||
|
|
@ -186,7 +186,7 @@ export interface FileRoutesByFullPath {
|
|||
'/role': typeof AuthRoleIndexRoute
|
||||
'/rooms': typeof AuthRoomsIndexRoute
|
||||
'/user': typeof AuthUserIndexRoute
|
||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -213,7 +213,7 @@ export interface FileRoutesByTo {
|
|||
'/role': typeof AuthRoleIndexRoute
|
||||
'/rooms': typeof AuthRoomsIndexRoute
|
||||
'/user': typeof AuthUserIndexRoute
|
||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -242,7 +242,7 @@ export interface FileRoutesById {
|
|||
'/_auth/role/': typeof AuthRoleIndexRoute
|
||||
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
||||
'/_auth/user/': typeof AuthUserIndexRoute
|
||||
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
|
||||
'/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
|
||||
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
||||
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -271,7 +271,7 @@ export interface FileRouteTypes {
|
|||
| '/role'
|
||||
| '/rooms'
|
||||
| '/user'
|
||||
| '/sso/callback'
|
||||
| '/oauth/callback'
|
||||
| '/profile/$userName'
|
||||
| '/profile/change-password'
|
||||
| '/role/create'
|
||||
|
|
@ -298,7 +298,7 @@ export interface FileRouteTypes {
|
|||
| '/role'
|
||||
| '/rooms'
|
||||
| '/user'
|
||||
| '/sso/callback'
|
||||
| '/oauth/callback'
|
||||
| '/profile/$userName'
|
||||
| '/profile/change-password'
|
||||
| '/role/create'
|
||||
|
|
@ -326,7 +326,7 @@ export interface FileRouteTypes {
|
|||
| '/_auth/role/'
|
||||
| '/_auth/rooms/'
|
||||
| '/_auth/user/'
|
||||
| '/(auth)/sso/callback/'
|
||||
| '/(auth)/oauth/callback/'
|
||||
| '/_auth/profile/$userName/'
|
||||
| '/_auth/profile/change-password/'
|
||||
| '/_auth/role/create/'
|
||||
|
|
@ -344,7 +344,7 @@ export interface RootRouteChildren {
|
|||
IndexRoute: typeof IndexRoute
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
authLoginIndexRoute: typeof authLoginIndexRoute
|
||||
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
|
||||
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -482,11 +482,11 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/(auth)/sso/callback/': {
|
||||
id: '/(auth)/sso/callback/'
|
||||
path: '/sso/callback'
|
||||
fullPath: '/sso/callback'
|
||||
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
|
||||
'/(auth)/oauth/callback/': {
|
||||
id: '/(auth)/oauth/callback/'
|
||||
path: '/oauth/callback'
|
||||
fullPath: '/oauth/callback'
|
||||
preLoaderRoute: typeof authOauthCallbackIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/user/role/$roleId/': {
|
||||
|
|
@ -592,7 +592,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
IndexRoute: IndexRoute,
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
authLoginIndexRoute: authLoginIndexRoute,
|
||||
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
|
||||
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,60 @@
|
|||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useExchangeSsoCode } from "@/hooks/queries";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { exchangeOAuthCode } from "@/services/auth.service";
|
||||
import type { LoginResponse } from "@/types/auth";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/sso/callback/")({
|
||||
component: SsoCallbackPage,
|
||||
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||
const consumedCodes = new Set<string>();
|
||||
|
||||
export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
||||
component: OAuthCallbackPage,
|
||||
});
|
||||
|
||||
function SsoCallbackPage() {
|
||||
function OAuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const exchangeMutation = useExchangeSsoCode();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isExchanging, setIsExchanging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.code) {
|
||||
setErrorMessage("SSO code is missing.");
|
||||
const code = search.code;
|
||||
if (!code) {
|
||||
setErrorMessage("OAuth code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumedCodes.has(code)) {
|
||||
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
exchangeMutation.mutate(search.code, {
|
||||
onSuccess: async (data) => {
|
||||
setIsExchanging(true);
|
||||
|
||||
let cancelled = false;
|
||||
let exchangePromise = inFlightExchanges.get(code);
|
||||
if (!exchangePromise) {
|
||||
exchangePromise = exchangeOAuthCode(code);
|
||||
inFlightExchanges.set(code, exchangePromise);
|
||||
}
|
||||
|
||||
exchangePromise
|
||||
.then(async (data) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (!data.token) {
|
||||
setErrorMessage("SSO response missing token.");
|
||||
setErrorMessage("OAuth response missing token.");
|
||||
return;
|
||||
}
|
||||
|
||||
consumedCodes.add(code);
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("username", data.username || "");
|
||||
localStorage.setItem("name", data.name || "");
|
||||
|
|
@ -45,22 +69,38 @@ function SsoCallbackPage() {
|
|||
auth.login(data.username || "");
|
||||
|
||||
await navigate({ to: search.redirect || "/dashboard" });
|
||||
},
|
||||
onError: () => {
|
||||
setErrorMessage("SSO exchange failed.");
|
||||
},
|
||||
});
|
||||
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
consumedCodes.add(code);
|
||||
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("OAuth exchange failed.");
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightExchanges.delete(code);
|
||||
if (!cancelled) {
|
||||
setIsExchanging(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [auth, navigate, search.code, search.redirect]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
|
||||
<CardTitle className="text-xl">Đang xác thực OAuth</CardTitle>
|
||||
<CardDescription>Vui lòng đợi trong giây lát.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
{exchangeMutation.isPending && (
|
||||
{isExchanging && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="w-4 h-4 animate-spin" />
|
||||
Đang trao đổi mã đăng nhập
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import axios from "@/config/axios";
|
||||
import { API_ENDPOINTS } from "@/config/api";
|
||||
import rawAxios from "axios";
|
||||
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
||||
|
||||
/**
|
||||
|
|
@ -16,25 +17,62 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
|
|||
}
|
||||
|
||||
/**
|
||||
* Build SSO login URL
|
||||
* Build OAuth login URL by provider
|
||||
* @param provider - OAuth provider key (e.g. google, azuread)
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildSsoLoginUrl(returnUrl: string): string {
|
||||
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
|
||||
export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
|
||||
const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
|
||||
const encoded = encodeURIComponent(returnUrl);
|
||||
return `${base}?returnUrl=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time code for login payload
|
||||
* Build Google OAuth login URL
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
||||
return buildOAuthLoginUrl("google", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time OAuth code for login payload
|
||||
* @param code - one-time code
|
||||
*/
|
||||
export async function exchangeOAuthCode(code: string): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.OAUTH_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (rawAxios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
if (status === 401 || status === 404 || status === 405) {
|
||||
const fallbackResponse = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return fallbackResponse.data;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy AzureAD SSO URL builder kept for backward compatibility.
|
||||
*/
|
||||
export function buildSsoLoginUrl(returnUrl: string): string {
|
||||
return buildOAuthLoginUrl("azuread", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy SSO exchange alias kept for backward compatibility.
|
||||
*/
|
||||
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
||||
const response = await axios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
return exchangeOAuthCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"outDir": "./dist",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user