From b7ff92abd3ca43ea16002bd5b61d0011306fc61b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 2 Sep 2026 09:09:58 +0200 Subject: [PATCH] fix: SSO-MFA-Umgehung, Datei-Magic-Bytes und oeffentliches Swagger geschlossen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weitere vom Sicherheitsaudit gemeldete Luecken behoben, die bisher als niedrige Prioritaet offen gelassen wurden: - SSO-Login (Entra ID) prüft jetzt lokale MFA genauso wie der Passwort-Login: ist mfaEnabled gesetzt, liefert /auth/sso/complete denselben mfaRequired-Codepfad wie /auth/login, bevor Tokens ausgestellt werden. Frontend (Login-Seite, AuthContext) zeigt in dem Fall den bestehenden Code-Eingabe-Schritt statt die Anmeldung stillschweigend abzuschliessen. - Avatar- und Dokument-Uploads pruefen jetzt die tatsaechlichen Datei-Bytes (Magic Numbers fuer PNG/JPEG/WEBP/PDF) statt nur dem client-angegebenen MIME-Typ/data:-URL-Praefix zu vertrauen. - Swagger-UI (/api/v1/docs) ist jetzt standardmaessig deaktiviert (ENABLE_SWAGGER=true erforderlich), da sie zuvor das komplette API-Schema ungeschuetzt oeffentlich preisgab. npm-audit-Warnungen (deepmerge-ts/mysql2 unter prisma) bewusst nicht gefixt: kein stabiles Prisma-Release loest sie bislang auf, nur eine Release-Candidate-Hauptversion - das Risiko eines Downgrades/RC-Upgrades der Datenbankschicht uebersteigt den Nutzen fuer eine Kette, die zur Laufzeit ohnehin nicht erreichbar ist (nur Prisma-CLI, nur MySQL-Treiber, dieses Projekt nutzt Postgres). Co-Authored-By: Claude Sonnet 5 --- ApiCore-Backend/.env.example | 5 ++ ApiCore-Backend/src/auth/auth.controller.ts | 2 +- ApiCore-Backend/src/auth/auth.service.spec.ts | 23 +++++++++ ApiCore-Backend/src/auth/auth.service.ts | 18 +++++-- .../src/auth/sso/sso.controller.ts | 13 +++-- .../src/common/file-signature.spec.ts | 49 +++++++++++++++++++ ApiCore-Backend/src/common/file-signature.ts | 35 +++++++++++++ .../src/documents/documents.service.spec.ts | 10 +++- .../src/documents/documents.service.ts | 6 +++ ApiCore-Backend/src/main.ts | 23 ++++++--- .../src/profile/profile.service.spec.ts | 11 ++++- .../src/profile/profile.service.ts | 6 +++ .../src/app/(auth)/login/page.spec.tsx | 20 ++++++-- .../src/app/(auth)/login/page.tsx | 17 ++++++- ApiCore-Frontend/src/lib/auth/api.ts | 7 ++- .../src/lib/auth/auth-context.tsx | 8 ++- 16 files changed, 225 insertions(+), 28 deletions(-) create mode 100644 ApiCore-Backend/src/common/file-signature.spec.ts create mode 100644 ApiCore-Backend/src/common/file-signature.ts diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index e944469..ddc4b12 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -28,3 +28,8 @@ OIDC_ISSUER_URL="https://login.microsoftonline.com//v2.0" OIDC_CLIENT_ID="change-me-entra-client-id" OIDC_CLIENT_SECRET="change-me-entra-client-secret" OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback" + +# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das +# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde. +# Nur fuer lokale Entwicklung/Debugging explizit aktivieren. +ENABLE_SWAGGER=false diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts index 8926486..efbab53 100644 --- a/ApiCore-Backend/src/auth/auth.controller.ts +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -25,7 +25,7 @@ import { setRefreshTokenCookie, } from './util/refresh-token-cookie'; -interface MfaRequiredResponse { +export interface MfaRequiredResponse { mfaRequired: true; mfaToken: string; } diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index a128d07..95f22b0 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -240,6 +240,9 @@ describe('AuthService', () => { const result = await authService.issueTokensForSsoUser(activeUser.id); + if (result.mfaRequired) { + throw new Error('expected mfaRequired to be false'); + } expect(result.accessToken).toBe('signed.jwt.token'); expect(prisma.refreshToken.create).toHaveBeenCalled(); expect(auditLog.record).toHaveBeenCalledWith( @@ -247,6 +250,26 @@ describe('AuthService', () => { ); }); + it('returns an MFA challenge instead of tokens when the SSO user has local MFA enabled', async () => { + usersService.findById.mockResolvedValue({ + ...activeUser, + mfaEnabled: true, + mfaSecret: 'encrypted-secret', + }); + + const result = await authService.issueTokensForSsoUser(activeUser.id); + + if (!result.mfaRequired) { + throw new Error('expected mfaRequired to be true'); + } + expect(result.mfaToken).toBe('mfa-challenge-token'); + expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id); + expect(prisma.refreshToken.create).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalledWith( + expect.objectContaining({ action: 'LOGIN' }), + ); + }); + it('throws when the user is deactivated', async () => { usersService.findById.mockResolvedValue({ ...activeUser, diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index 066a317..42edbe4 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -144,12 +144,24 @@ export class AuthService { return { mfaRequired: false, accessToken, refreshToken }; } - /** Issues our own access/refresh token pair for a user already authenticated via SSO. */ - async issueTokensForSsoUser(userId: string): Promise { + /** + * Issues our own access/refresh token pair for a user already + * authenticated via SSO. Local MFA is still enforced here, same as + * password login: SSO establishes identity, not the second factor, so a + * user who has explicitly enabled TOTP must complete it here too rather + * than getting it silently skipped by signing in via Microsoft instead. + */ + async issueTokensForSsoUser(userId: string): Promise { const user = await this.usersService.findById(userId); if (!user.isActive) { throw new UnauthorizedException('Invalid credentials'); } + if (user.mfaEnabled) { + return { + mfaRequired: true, + mfaToken: this.mfaService.signChallengeToken(user.id), + }; + } const accessToken = this.signAccessToken({ id: user.id, email: user.email, @@ -161,7 +173,7 @@ export class AuthService { action: 'LOGIN', userId: user.id, }); - return { accessToken, refreshToken }; + return { mfaRequired: false, accessToken, refreshToken }; } async verifyMfaAndLogin(mfaToken: string, code: string): Promise { diff --git a/ApiCore-Backend/src/auth/sso/sso.controller.ts b/ApiCore-Backend/src/auth/sso/sso.controller.ts index 88b344d..b879e0e 100644 --- a/ApiCore-Backend/src/auth/sso/sso.controller.ts +++ b/ApiCore-Backend/src/auth/sso/sso.controller.ts @@ -13,6 +13,7 @@ import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; import { Public } from '../decorators/public.decorator'; import { AuthService } from '../auth.service'; +import { MfaRequiredResponse } from '../auth.controller'; import { AccessTokenDto } from '../dto/access-token.dto'; import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle'; import { setRefreshTokenCookie } from '../util/refresh-token-cookie'; @@ -121,7 +122,7 @@ export class SsoController { async complete( @Req() req: Request, @Res({ passthrough: true }) res: Response, - ): Promise { + ): Promise { const ssoCode = (req.cookies as Record | undefined)?.[ SSO_CODE_COOKIE ]; @@ -132,10 +133,12 @@ export class SsoController { ); } const { userId } = this.ssoService.verifySsoCode(ssoCode); - const { accessToken, refreshToken } = - await this.authService.issueTokensForSsoUser(userId); - setRefreshTokenCookie(res, refreshToken); - return { accessToken }; + const result = await this.authService.issueTokensForSsoUser(userId); + if (result.mfaRequired) { + return { mfaRequired: true, mfaToken: result.mfaToken }; + } + setRefreshTokenCookie(res, result.refreshToken); + return { accessToken: result.accessToken }; } private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null { diff --git a/ApiCore-Backend/src/common/file-signature.spec.ts b/ApiCore-Backend/src/common/file-signature.spec.ts new file mode 100644 index 0000000..5c1c7f9 --- /dev/null +++ b/ApiCore-Backend/src/common/file-signature.spec.ts @@ -0,0 +1,49 @@ +import { matchesFileSignature } from './file-signature'; + +describe('matchesFileSignature', () => { + it('accepts a buffer starting with the PNG signature', () => { + const png = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, + ]); + expect(matchesFileSignature(png, 'image/png')).toBe(true); + }); + + it('accepts a buffer starting with the JPEG signature', () => { + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]); + expect(matchesFileSignature(jpeg, 'image/jpeg')).toBe(true); + }); + + it('accepts a RIFF/WEBP container', () => { + const webp = Buffer.concat([ + Buffer.from('RIFF', 'ascii'), + Buffer.from([0, 0, 0, 0]), + Buffer.from('WEBP', 'ascii'), + ]); + expect(matchesFileSignature(webp, 'image/webp')).toBe(true); + }); + + it('accepts a PDF header', () => { + const pdf = Buffer.from('%PDF-1.7\n', 'ascii'); + expect(matchesFileSignature(pdf, 'application/pdf')).toBe(true); + }); + + it('rejects a buffer whose bytes do not match the declared type', () => { + const plainText = Buffer.from('this is not an image', 'ascii'); + expect(matchesFileSignature(plainText, 'image/png')).toBe(false); + expect(matchesFileSignature(plainText, 'image/jpeg')).toBe(false); + expect(matchesFileSignature(plainText, 'image/webp')).toBe(false); + expect(matchesFileSignature(plainText, 'application/pdf')).toBe(false); + }); + + it('rejects a buffer shorter than the signature it claims to have', () => { + expect(matchesFileSignature(Buffer.from([0xff, 0xd8]), 'image/jpeg')).toBe( + false, + ); + }); + + it('allows any bytes through for a mime type with no known signature', () => { + expect(matchesFileSignature(Buffer.from('anything'), 'text/plain')).toBe( + true, + ); + }); +}); diff --git a/ApiCore-Backend/src/common/file-signature.ts b/ApiCore-Backend/src/common/file-signature.ts new file mode 100644 index 0000000..accf381 --- /dev/null +++ b/ApiCore-Backend/src/common/file-signature.ts @@ -0,0 +1,35 @@ +const SIGNATURE_CHECKS: Record boolean> = { + 'image/png': (buffer) => + buffer.length >= 8 && + buffer + .subarray(0, 8) + .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), + 'image/jpeg': (buffer) => + buffer.length >= 3 && + buffer[0] === 0xff && + buffer[1] === 0xd8 && + buffer[2] === 0xff, + 'image/webp': (buffer) => + buffer.length >= 12 && + buffer.subarray(0, 4).toString('ascii') === 'RIFF' && + buffer.subarray(8, 12).toString('ascii') === 'WEBP', + 'application/pdf': (buffer) => + buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-', +}; + +/** + * Verifies a file's actual bytes match its declared MIME type instead of + * trusting the client-supplied value alone (a multipart Content-Type header + * or a data: URL prefix are both attacker-controlled). Used at every upload + * path that persists the bytes to disk under a server-chosen name/extension, + * so a mislabeled file can't be dropped onto the filesystem as a type it + * isn't. Types with no signature defined here are not checked (returns + * true) - callers still gate on their own MIME allowlist first. + */ +export function matchesFileSignature( + buffer: Buffer, + declaredMimeType: string, +): boolean { + const check = SIGNATURE_CHECKS[declaredMimeType]; + return check ? check(buffer) : true; +} diff --git a/ApiCore-Backend/src/documents/documents.service.spec.ts b/ApiCore-Backend/src/documents/documents.service.spec.ts index a64ff0a..d6d88e0 100644 --- a/ApiCore-Backend/src/documents/documents.service.spec.ts +++ b/ApiCore-Backend/src/documents/documents.service.spec.ts @@ -79,7 +79,7 @@ describe('DocumentsService', () => { describe('create', () => { const file = { - buffer: Buffer.from('binary'), + buffer: Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]), originalname: 'bild.jpg', mimetype: 'image/jpeg', size: 1234, @@ -95,6 +95,14 @@ describe('DocumentsService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('rejects a file whose bytes do not match the declared mime type', async () => { + const mislabeled = { ...file, buffer: Buffer.from('not-a-jpeg') }; + await expect( + service.create(mislabeled, 'COLONY', 'colony-1', 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + it('throws NotFoundException when the referenced entity does not exist', async () => { prisma.colony.findUnique.mockResolvedValue(null); await expect( diff --git a/ApiCore-Backend/src/documents/documents.service.ts b/ApiCore-Backend/src/documents/documents.service.ts index bb71f55..7091606 100644 --- a/ApiCore-Backend/src/documents/documents.service.ts +++ b/ApiCore-Backend/src/documents/documents.service.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { matchesFileSignature } from '../common/file-signature'; import { Document, DocumentEntityType } from '../../generated/prisma'; import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types'; @@ -86,6 +87,11 @@ export class DocumentsService { if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) { throw new BadRequestException('Nicht unterstützter Dateityp'); } + if (!matchesFileSignature(file.buffer, file.mimetype)) { + throw new BadRequestException( + 'Datei entspricht nicht dem angegebenen Format', + ); + } await this.assertEntityExists(entityType, entityId); const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype]; diff --git a/ApiCore-Backend/src/main.ts b/ApiCore-Backend/src/main.ts index 3863eaf..c8116bd 100644 --- a/ApiCore-Backend/src/main.ts +++ b/ApiCore-Backend/src/main.ts @@ -23,14 +23,21 @@ async function bootstrap() { configureApp(app); - const swaggerConfig = new DocumentBuilder() - .setTitle('ApiCore API') - .setDescription('Digitale Imkereiverwaltung – API-Dokumentation') - .setVersion('1.0') - .addBearerAuth() - .build(); - const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup('api/v1/docs', app, swaggerDocument); + // Swagger is mounted as raw Express middleware outside Nest's routing, so + // it is never covered by the global JwtAuthGuard - anyone reaching the + // host could otherwise read the full API schema (every endpoint and DTO + // shape) with no authentication at all. Off by default; an operator who + // wants it in a given environment opts in explicitly. + if (process.env.ENABLE_SWAGGER === 'true') { + const swaggerConfig = new DocumentBuilder() + .setTitle('ApiCore API') + .setDescription('Digitale Imkereiverwaltung – API-Dokumentation') + .setVersion('1.0') + .addBearerAuth() + .build(); + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/v1/docs', app, swaggerDocument); + } await app.listen(process.env.PORT ?? 3001); } diff --git a/ApiCore-Backend/src/profile/profile.service.spec.ts b/ApiCore-Backend/src/profile/profile.service.spec.ts index feb1803..3f8fe41 100644 --- a/ApiCore-Backend/src/profile/profile.service.spec.ts +++ b/ApiCore-Backend/src/profile/profile.service.spec.ts @@ -178,7 +178,8 @@ describe('ProfileService', () => { }); describe('uploadAvatar', () => { - const dataUrl = `data:image/jpeg;base64,${Buffer.from('binary').toString('base64')}`; + const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]); + const dataUrl = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`; it('rejects a value that is not a data: URL', async () => { await expect( @@ -200,6 +201,14 @@ describe('ProfileService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('rejects bytes that do not match the declared mime type', async () => { + const mislabeled = `data:image/jpeg;base64,${Buffer.from('not-a-jpeg').toString('base64')}`; + await expect( + service.uploadAvatar('user-1', mislabeled), + ).rejects.toBeInstanceOf(BadRequestException); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + it('writes the file, updates avatarPath, and deletes the old file if present', async () => { prisma.user.findUnique.mockResolvedValue({ ...baseUser, diff --git a/ApiCore-Backend/src/profile/profile.service.ts b/ApiCore-Backend/src/profile/profile.service.ts index a5b04a6..08b327a 100644 --- a/ApiCore-Backend/src/profile/profile.service.ts +++ b/ApiCore-Backend/src/profile/profile.service.ts @@ -11,6 +11,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { matchesFileSignature } from '../common/file-signature'; import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { UsersService } from '../users/users.service'; @@ -155,6 +156,11 @@ export class ProfileService { if (buffer.length > MAX_AVATAR_SIZE_BYTES) { throw new BadRequestException('Datei zu groß (max. 2 MB)'); } + if (!matchesFileSignature(buffer, mimeType)) { + throw new BadRequestException( + 'Datei entspricht nicht dem angegebenen Format', + ); + } const user = await this.requireUser(userId); const extension = ALLOWED_AVATAR_MIME_TYPES[mimeType]; const directory = avatarStorageRoot(); diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx index 43ba4b6..c02a67d 100644 --- a/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx @@ -111,11 +111,25 @@ describe("LoginPage", () => { it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => { searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1")); - completeSsoMock.mockResolvedValueOnce(undefined); + completeSsoMock.mockResolvedValueOnce({ mfaRequired: false }); render(); - expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument(); + await vi.waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/dashboard")); expect(completeSsoMock).toHaveBeenCalled(); - expect(replaceMock).toHaveBeenCalledWith("/dashboard"); + }); + + it("shows the MFA code step when SSO completion still requires local MFA", async () => { + searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1")); + completeSsoMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "sso-mfa-token" }); + verifyMfaMock.mockResolvedValueOnce(undefined); + render(); + + const codeInput = await screen.findByLabelText("Code"); + expect(replaceMock).toHaveBeenCalledWith("/login"); + await userEvent.type(codeInput, "123456"); + await userEvent.click(screen.getByRole("button", { name: /bestätigen/i })); + + expect(verifyMfaMock).toHaveBeenCalledWith("sso-mfa-token", "123456"); + expect(pushMock).toHaveBeenCalledWith("/dashboard"); }); }); diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.tsx index 1c139d7..be7977f 100644 --- a/ApiCore-Frontend/src/app/(auth)/login/page.tsx +++ b/ApiCore-Frontend/src/app/(auth)/login/page.tsx @@ -51,7 +51,11 @@ function LoginPageContent() { const [isSubmitting, setIsSubmitting] = useState(false); const [ssoEnabled, setSsoEnabled] = useState(false); const [ssoLoginHref, setSsoLoginHref] = useState(null); - const isCompletingSso = ssoPending; + // Its own state rather than deriving straight from the ssoPending query + // param: once completeSso() resolves with an MFA challenge, the MFA form + // must show immediately (same render pass as setMfaToken), without + // waiting on a router.replace() round trip to first clear the param. + const [isCompletingSso, setIsCompletingSso] = useState(ssoPending); useEffect(() => { ssoStatusRequest().then(setSsoEnabled); @@ -66,8 +70,17 @@ function LoginPageContent() { useEffect(() => { if (ssoPending) { completeSso() - .then(() => router.replace("/dashboard")) + .then((outcome) => { + setIsCompletingSso(false); + if (outcome.mfaRequired) { + setMfaToken(outcome.mfaToken); + router.replace("/login"); + } else { + router.replace("/dashboard"); + } + }) .catch(() => { + setIsCompletingSso(false); setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen."); router.replace("/login"); }); diff --git a/ApiCore-Frontend/src/lib/auth/api.ts b/ApiCore-Frontend/src/lib/auth/api.ts index 10f9076..e998ef8 100644 --- a/ApiCore-Frontend/src/lib/auth/api.ts +++ b/ApiCore-Frontend/src/lib/auth/api.ts @@ -93,7 +93,10 @@ export async function ssoLoginUrl(): Promise { // The one-time completion code lives only in an httpOnly cookie the callback // set (never in the URL or a request body) - the browser sends it automatically. -export async function ssoCompleteRequest(): Promise { +// Returns the same union as loginRequest: SSO establishes identity, not the +// second factor, so a user with local MFA enabled still has to complete the +// same challenge as a password login before tokens are issued. +export async function ssoCompleteRequest(): Promise { const apiUrl = await getApiUrl(); const response = await fetch(`${apiUrl}/auth/sso/complete`, { method: 'POST', @@ -102,7 +105,7 @@ export async function ssoCompleteRequest(): Promise { if (!response.ok) { throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.'); } - return response.json() as Promise; + return response.json() as Promise; } export async function logoutRequest(): Promise { diff --git a/ApiCore-Frontend/src/lib/auth/auth-context.tsx b/ApiCore-Frontend/src/lib/auth/auth-context.tsx index 23ca0c5..3ffb2f2 100644 --- a/ApiCore-Frontend/src/lib/auth/auth-context.tsx +++ b/ApiCore-Frontend/src/lib/auth/auth-context.tsx @@ -30,7 +30,7 @@ interface AuthContextValue { isLoading: boolean; login: (email: string, password: string) => Promise; verifyMfa: (mfaToken: string, code: string) => Promise; - completeSso: () => Promise; + completeSso: () => Promise; logout: () => Promise; hasPermission: (permission: string) => boolean; refreshUser: () => Promise; @@ -78,9 +78,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(await applyAccessToken(result.accessToken)); }, []); - const completeSso = useCallback(async () => { + const completeSso = useCallback(async (): Promise => { const result = await ssoCompleteRequest(); + if (isMfaRequired(result)) { + return { mfaRequired: true, mfaToken: result.mfaToken }; + } setUser(await applyAccessToken(result.accessToken)); + return { mfaRequired: false }; }, []); const logout = useCallback(async () => {