add oauth login

This commit is contained in:
Do Manh Phuong 2026-04-07 13:20:46 +07:00
parent c9960aea75
commit 53c27c4efc
8 changed files with 501 additions and 57 deletions

343
SSO-OAuth-OIDC.md Normal file
View 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.

View File

@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth"; import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query"; 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 { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router"; import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login"; import { Route } from "@/routes/(auth)/login";
@ -44,12 +44,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
} }
}); });
const handleSsoLogin = () => { const handleGoogleLogin = () => {
const returnUrl = new URL("/sso/callback", window.location.origin); const returnUrl = new URL("/oauth/callback", window.location.origin);
if (search.redirect) { if (search.redirect) {
returnUrl.searchParams.set("redirect", 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>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@ -112,14 +112,26 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
</Button> </Button>
)} )}
<div className="text-center text-sm text-muted-foreground">Hoặc</div> <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"> <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" /> <path
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" /> fill="#4285F4"
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" /> 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"
<rect x="13" y="13" width="10" height="10" fill="#FFB900" /> />
<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> </svg>
Đăng nhập với Microsoft Đăng nhập với Google
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -11,6 +11,9 @@ export const BASE_MESH_URL = isDev
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
AUTH: { AUTH: {
LOGIN: `${BASE_URL}/login`, 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_LOGIN: `${BASE_URL}/auth/sso/login`,
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`, SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
LOGOUT: `${BASE_URL}/logout`, LOGOUT: `${BASE_URL}/logout`,

View File

@ -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>({ 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();
}

View File

@ -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 AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/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 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 AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index' import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index' import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
@ -132,9 +132,9 @@ const AuthProfileUserNameIndexRoute =
path: '/profile/$userName/', path: '/profile/$userName/',
getParentRoute: () => AuthRoute, getParentRoute: () => AuthRoute,
} as any) } as any)
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({ const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
id: '/(auth)/sso/callback/', id: '/(auth)/oauth/callback/',
path: '/sso/callback/', path: '/oauth/callback/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({ const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
@ -186,7 +186,7 @@ export interface FileRoutesByFullPath {
'/role': typeof AuthRoleIndexRoute '/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute '/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute '/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute '/oauth/callback': typeof authOauthCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute '/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute '/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute '/role/create': typeof AuthRoleCreateIndexRoute
@ -213,7 +213,7 @@ export interface FileRoutesByTo {
'/role': typeof AuthRoleIndexRoute '/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute '/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute '/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute '/oauth/callback': typeof authOauthCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute '/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute '/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute '/role/create': typeof AuthRoleCreateIndexRoute
@ -242,7 +242,7 @@ export interface FileRoutesById {
'/_auth/role/': typeof AuthRoleIndexRoute '/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute '/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/user/': typeof AuthUserIndexRoute '/_auth/user/': typeof AuthUserIndexRoute
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute '/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute '/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute '/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute '/_auth/role/create/': typeof AuthRoleCreateIndexRoute
@ -271,7 +271,7 @@ export interface FileRouteTypes {
| '/role' | '/role'
| '/rooms' | '/rooms'
| '/user' | '/user'
| '/sso/callback' | '/oauth/callback'
| '/profile/$userName' | '/profile/$userName'
| '/profile/change-password' | '/profile/change-password'
| '/role/create' | '/role/create'
@ -298,7 +298,7 @@ export interface FileRouteTypes {
| '/role' | '/role'
| '/rooms' | '/rooms'
| '/user' | '/user'
| '/sso/callback' | '/oauth/callback'
| '/profile/$userName' | '/profile/$userName'
| '/profile/change-password' | '/profile/change-password'
| '/role/create' | '/role/create'
@ -326,7 +326,7 @@ export interface FileRouteTypes {
| '/_auth/role/' | '/_auth/role/'
| '/_auth/rooms/' | '/_auth/rooms/'
| '/_auth/user/' | '/_auth/user/'
| '/(auth)/sso/callback/' | '/(auth)/oauth/callback/'
| '/_auth/profile/$userName/' | '/_auth/profile/$userName/'
| '/_auth/profile/change-password/' | '/_auth/profile/change-password/'
| '/_auth/role/create/' | '/_auth/role/create/'
@ -344,7 +344,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute authLoginIndexRoute: typeof authLoginIndexRoute
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -482,11 +482,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
parentRoute: typeof AuthRoute parentRoute: typeof AuthRoute
} }
'/(auth)/sso/callback/': { '/(auth)/oauth/callback/': {
id: '/(auth)/sso/callback/' id: '/(auth)/oauth/callback/'
path: '/sso/callback' path: '/oauth/callback'
fullPath: '/sso/callback' fullPath: '/oauth/callback'
preLoaderRoute: typeof authSsoCallbackIndexRouteImport preLoaderRoute: typeof authOauthCallbackIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_auth/user/role/$roleId/': { '/_auth/user/role/$roleId/': {
@ -592,7 +592,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren, AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute, authLoginIndexRoute: authLoginIndexRoute,
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute, authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@ -1,36 +1,60 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useExchangeSsoCode } from "@/hooks/queries";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoaderCircle } from "lucide-react"; 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/")({ const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
component: SsoCallbackPage, const consumedCodes = new Set<string>();
export const Route = createFileRoute("/(auth)/oauth/callback/")({
component: OAuthCallbackPage,
}); });
function SsoCallbackPage() { function OAuthCallbackPage() {
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const exchangeMutation = useExchangeSsoCode();
const search = Route.useSearch() as { code?: string; redirect?: string }; const search = Route.useSearch() as { code?: string; redirect?: string };
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isExchanging, setIsExchanging] = useState(false);
useEffect(() => { useEffect(() => {
if (!search.code) { const code = search.code;
setErrorMessage("SSO code is missing."); 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; return;
} }
setErrorMessage(null); setErrorMessage(null);
exchangeMutation.mutate(search.code, { setIsExchanging(true);
onSuccess: async (data) => {
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) { if (!data.token) {
setErrorMessage("SSO response missing token."); setErrorMessage("OAuth response missing token.");
return; return;
} }
consumedCodes.add(code);
localStorage.setItem("token", data.token); localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || ""); localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || ""); localStorage.setItem("name", data.name || "");
@ -45,22 +69,38 @@ function SsoCallbackPage() {
auth.login(data.username || ""); auth.login(data.username || "");
await navigate({ to: search.redirect || "/dashboard" }); await navigate({ to: search.redirect || "/dashboard" });
}, })
onError: () => { .catch((error) => {
setErrorMessage("SSO exchange failed."); if (cancelled) return;
},
}); if (axios.isAxiosError(error) && error.response?.status === 401) {
}, [auth, exchangeMutation, navigate, search.code, search.redirect]); 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 ( return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20"> <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"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <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> <CardDescription>Vui lòng đi trong giây lát.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center gap-4"> <CardContent className="flex flex-col items-center gap-4">
{exchangeMutation.isPending && ( {isExchanging && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="w-4 h-4 animate-spin" /> <LoaderCircle className="w-4 h-4 animate-spin" />
Đang trao đi đăng nhập Đang trao đi đăng nhập

View File

@ -1,5 +1,6 @@
import axios from "@/config/axios"; import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api"; import { API_ENDPOINTS } from "@/config/api";
import rawAxios from "axios";
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth"; 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 * @param returnUrl - FE callback url
*/ */
export function buildSsoLoginUrl(returnUrl: string): string { export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.SSO_LOGIN; const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
const encoded = encodeURIComponent(returnUrl); const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`; 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 * @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> { export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
const response = await axios.post<LoginResponse>( return exchangeOAuthCode(code);
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return response.data;
} }
/** /**

View File

@ -4,6 +4,7 @@
"outDir": "./dist", "outDir": "./dist",
"composite": true, "composite": true,
"noEmit": false, "noEmit": false,
"ignoreDeprecations": "6.0",
"types": [] "types": []
}, },
"include": ["src/**/*.ts", "src/**/*.tsx"], "include": ["src/**/*.ts", "src/**/*.tsx"],