From c2994dbe642cda2942ec4bfbdf0d2f76c7f8842d Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 6 Dec 2025 19:11:05 +0100 Subject: [PATCH 01/20] feat: EntraID authentication --- Infrastructure/CLAUDE.md | 215 +++++++++++++++++++++ Infrastructure/main.bicep | 61 ++++++ Website/CLAUDE.md | 65 +++++++ Website/app/api/auth/logout/route.ts | 14 +- Website/app/api/auth/session/route.ts | 43 ++++- Website/app/api/version/route.ts | 7 +- Website/components/loginview/LoginView.tsx | 48 ++++- Website/contexts/AuthContext.tsx | 46 ++++- Website/lib/auth/entraid.ts | 64 ++++++ Website/lib/session.ts | 41 +++- Website/middleware.ts | 72 +++++-- 11 files changed, 634 insertions(+), 42 deletions(-) create mode 100644 Website/lib/auth/entraid.ts diff --git a/Infrastructure/CLAUDE.md b/Infrastructure/CLAUDE.md index d224806..070b7b7 100644 --- a/Infrastructure/CLAUDE.md +++ b/Infrastructure/CLAUDE.md @@ -64,6 +64,21 @@ param adoProjectName string = '' @description('Azure DevOps repository name for diagram storage') param adoRepositoryName string = '' + +@description('Enable EntraID authentication via Easy Auth') +param enableEntraIdAuth bool = false + +@description('Azure AD App Registration Client ID') +param entraIdClientId string = '' + +@description('Azure AD Tenant ID (defaults to subscription tenant)') +param entraIdTenantId string = subscription().tenantId + +@description('Comma-separated list of Azure AD Group Object IDs allowed to access (empty = all tenant users)') +param entraIdAllowedGroups string = '' + +@description('Disable password authentication (EntraID only)') +param disablePasswordAuth bool = false ``` ### Resource Naming Convention @@ -86,6 +101,9 @@ The template configures these environment variables for the Website: | `ADO_ORGANIZATION_URL` | Parameter | Azure DevOps org URL | | `ADO_PROJECT_NAME` | Parameter | ADO project name | | `ADO_REPOSITORY_NAME` | Parameter | Diagram storage repo | +| `ENABLE_ENTRAID_AUTH` | Parameter | Enable EntraID auth | +| `ENTRAID_ALLOWED_GROUPS` | Parameter | Group-based access control | +| `DISABLE_PASSWORD_AUTH` | Parameter | Disable password login | ## Deployment @@ -141,6 +159,203 @@ The Azure Pipeline (`azure-pipelines-deploy-jobs.yml`) deploys using: -adoRepositoryName $(AdoRepositoryName) ``` +## EntraID Authentication Setup + +The application supports **optional** Microsoft EntraID (Azure AD) authentication using App Service Easy Auth. This provides enterprise single sign-on (SSO) with your organization's Microsoft accounts. + +### Authentication Modes + +Three authentication modes are supported: + +1. **Password Only** (default): Traditional password-based login +2. **EntraID Only**: Microsoft SSO authentication, password login disabled +3. **Dual Mode**: Users can choose between password or Microsoft SSO + +### EntraID Prerequisites + +Before enabling EntraID authentication: + +1. **Azure AD App Registration** +2. **User access to Azure AD tenant** +3. **Optional**: Azure AD security groups for access control + +### Step 1: Create Azure AD App Registration + +1. Navigate to [Azure Portal](https://portal.azure.com) → **Azure Active Directory** → **App registrations** +2. Click **New registration** +3. Configure: + - **Name**: `Data Model Viewer - {environment}` (e.g., `Data Model Viewer - Production`) + - **Supported account types**: `Accounts in this organizational directory only (Single tenant)` + - **Redirect URI**: + - Platform: `Web` + - URI: `https://wa-{solutionId}.azurewebsites.net/.auth/login/aad/callback` + - Replace `{solutionId}` with your actual solution ID +4. Click **Register** +5. Note the **Application (client) ID** and **Directory (tenant) ID** from the Overview page + +### Step 2: Configure App Registration API Permissions + +1. In your App Registration, go to **API permissions** +2. Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** +3. Add these permissions: + - `User.Read` (required - basic user profile) + - `Group.Read.All` (optional - required for group-based access control) +4. Click **Add permissions** +5. **Grant admin consent** if required by your organization + +### Step 3: Configure Token Claims (Optional - for Group-Based Access) + +To enable group-based access control: + +1. Go to **Token configuration** in your App Registration +2. Click **Add groups claim** +3. Select **Security groups** +4. Check both **ID** and **Access tokens** +5. Click **Add** + +### Step 4: Get Security Group Object IDs (Optional) + +If restricting access to specific groups: + +1. Navigate to **Azure Active Directory** → **Groups** +2. Find the group(s) that should have access +3. Click on each group and copy the **Object ID** +4. Prepare comma-separated list: `abc123-...,def456-...,ghi789-...` + +### Step 5: Deploy with EntraID Enabled + +#### Option A: Enable on New Deployment + +```bash +az deployment group create \ + --resource-group rg-datamodelviewer \ + --template-file main.bicep \ + --parameters solutionId=myorg-dmv \ + websitePassword='SecurePassword123!' \ + sessionSecret='<32-byte-random-string>' \ + enableEntraIdAuth=true \ + entraIdClientId='' \ + entraIdTenantId='' \ + entraIdAllowedGroups=',' \ + disablePasswordAuth=false \ + adoOrganizationUrl='https://dev.azure.com/myorg' \ + adoProjectName='MyProject' \ + adoRepositoryName='DataModelViewer' +``` + +#### Option B: Update Existing Deployment + +```bash +az deployment group create \ + --resource-group rg-datamodelviewer \ + --template-file main.bicep \ + --parameters @previous-parameters.json \ + enableEntraIdAuth=true \ + entraIdClientId='' \ + entraIdTenantId='' +``` + +### EntraID Parameter Reference + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `enableEntraIdAuth` | No | `false` | Enables EntraID authentication via Easy Auth | +| `entraIdClientId` | Yes if enabled | `''` | Application (client) ID from App Registration | +| `entraIdTenantId` | No | Subscription tenant | Directory (tenant) ID for your Azure AD | +| `entraIdAllowedGroups` | No | `''` | Comma-separated group Object IDs. Empty = all tenant users | +| `disablePasswordAuth` | No | `false` | Set to `true` for EntraID-only mode | + +### Authentication Mode Examples + +#### Example 1: Dual Mode (Password + EntraID) +```bash +--parameters enableEntraIdAuth=true \ + entraIdClientId='abc123...' \ + disablePasswordAuth=false +``` +- Users see both "Sign in with Microsoft" and password form +- Existing password auth continues to work +- Ideal for gradual migration + +#### Example 2: EntraID Only +```bash +--parameters enableEntraIdAuth=true \ + entraIdClientId='abc123...' \ + disablePasswordAuth=true +``` +- Only "Sign in with Microsoft" button shown +- Password login disabled +- Full enterprise SSO + +#### Example 3: EntraID with Group Restrictions +```bash +--parameters enableEntraIdAuth=true \ + entraIdClientId='abc123...' \ + entraIdAllowedGroups='group-id-1,group-id-2' +``` +- Only users in specified security groups can access +- Returns 403 Forbidden for unauthorized users + +### How EntraID Authentication Works + +1. **User Access**: User navigates to `https://wa-{solutionId}.azurewebsites.net/` +2. **Easy Auth Intercepts**: App Service Easy Auth detects unauthenticated request +3. **Redirect to Microsoft**: User redirected to `login.microsoftonline.com` +4. **User Signs In**: User authenticates with Microsoft account +5. **Token Exchange**: Microsoft returns ID token to Easy Auth +6. **Header Injection**: Easy Auth validates token and injects `X-MS-CLIENT-PRINCIPAL` header +7. **Application Access**: Middleware parses header, creates session, grants access + +### Troubleshooting EntraID Authentication + +#### Users Get "Redirect URI Mismatch" Error + +**Problem**: App Registration redirect URI doesn't match deployed URL + +**Solution**: +1. Check App Registration → Authentication → Redirect URIs +2. Ensure it matches: `https://wa-{solutionId}.azurewebsites.net/.auth/login/aad/callback` +3. Verify HTTPS (not HTTP) +4. No trailing slash + +#### Users Get "AADSTS50020: User account does not exist" Error + +**Problem**: User's account is not in the specified tenant + +**Solution**: +1. Verify user belongs to correct Azure AD tenant +2. Check App Registration is "Single tenant" type +3. Ensure user account is not external/guest (or add multi-tenant support) + +#### Users Get 403 Forbidden After Login + +**Problem**: User not in allowed security groups + +**Solution**: +1. Check `entraIdAllowedGroups` parameter includes user's group +2. Verify group claim is configured in token configuration +3. Check API permission `Group.Read.All` is granted +4. Wait 5-10 minutes for group membership cache to refresh + +#### EntraID Login Doesn't Work Locally + +**Expected Behavior**: Easy Auth only works on Azure App Service + +**Solution**: +- Use password authentication for local development +- Set `ENABLE_ENTRAID_AUTH=false` in `.env.local` +- Test EntraID in deployed dev environment + +#### Can't Find App Service Managed Identity in Azure AD + +**Problem**: Looking for wrong object + +**Solution**: +- Managed Identity is for **backend services** (Dataverse, ADO) +- **EntraID/Easy Auth** is for **user authentication** +- These are separate authentication mechanisms +- Don't add users to Managed Identity + ## Post-Deployment Configuration ### 1. Configure Startup Command diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index 7e32d13..702ef79 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -8,6 +8,21 @@ param adoOrganizationUrl string = '' param adoProjectName string = '' param adoRepositoryName string = '' +@description('Enable EntraID authentication via Easy Auth') +param enableEntraIdAuth bool = false + +@description('Azure AD App Registration Client ID') +param entraIdClientId string = '' + +@description('Azure AD Tenant ID (defaults to subscription tenant)') +param entraIdTenantId string = subscription().tenantId + +@description('Comma-separated list of Azure AD Group Object IDs allowed to access (empty = all tenant users)') +param entraIdAllowedGroups string = '' + +@description('Disable password authentication (EntraID only)') +param disablePasswordAuth bool = false + var location = resourceGroup().location @description('Create an App Service Plan') @@ -61,11 +76,57 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'ADO_REPOSITORY_NAME' value: adoRepositoryName } + { + name: 'ENABLE_ENTRAID_AUTH' + value: string(enableEntraIdAuth) + } + { + name: 'ENTRAID_ALLOWED_GROUPS' + value: entraIdAllowedGroups + } + { + name: 'DISABLE_PASSWORD_AUTH' + value: string(disablePasswordAuth) + } ] } } } +@description('Configure Easy Auth for EntraID authentication') +resource authSettings 'Microsoft.Web/sites/config@2022-09-01' = if (enableEntraIdAuth) { + name: 'authsettingsV2' + parent: webApp + properties: { + globalValidation: { + requireAuthentication: true + unauthenticatedClientAction: 'RedirectToLoginPage' + redirectToProvider: 'azureactivedirectory' + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + openIdIssuer: 'https://sts.windows.net/${entraIdTenantId}/' + clientId: entraIdClientId + } + validation: { + allowedAudiences: [ + 'api://${entraIdClientId}' + entraIdClientId + ] + } + } + } + login: { + tokenStore: { + enabled: true + } + allowedExternalRedirectUrls: [] + } + } +} + @description('Output the web app name and managed identity info') output webAppName string = webApp.name output managedIdentityPrincipalId string = webApp.identity.principalId diff --git a/Website/CLAUDE.md b/Website/CLAUDE.md index ce11501..f3b7168 100644 --- a/Website/CLAUDE.md +++ b/Website/CLAUDE.md @@ -194,6 +194,71 @@ Authentication uses either: File operations always specify branch (default: 'main') and commit messages. +## Authentication System + +The application supports two authentication methods that can be used independently or together: + +### Password Authentication (Default) +- Traditional password-based login +- JWT session stored in HTTP-only cookie +- Configured via `WebsitePassword` environment variable +- Session managed by [lib/session.ts](lib/session.ts) + +### EntraID Authentication (Optional) +- Microsoft single sign-on (SSO) using Azure Active Directory +- Enabled via App Service Easy Auth +- Optional group-based access control +- User information extracted from `X-MS-CLIENT-PRINCIPAL` header + +### Authentication Architecture + +**Unified Session Model**: Both auth types use the same session cookie format with `authType` field: + +```typescript +type Session = { + authType: 'password' | 'entraid'; + expiresAt: Date; + password?: string; // Password auth + userPrincipalName?: string; // EntraID auth + userId?: string; // EntraID auth + name?: string; // EntraID auth + groups?: string[]; // EntraID auth (for access control) +} +``` + +**Authentication Flow**: +1. [middleware.ts](middleware.ts) checks authentication on every request +2. If EntraID enabled: Parse `X-MS-CLIENT-PRINCIPAL` header → validate groups → create session +3. If password enabled: Validate existing JWT session cookie +4. [AuthContext.tsx](contexts/AuthContext.tsx) provides auth state to React components +5. Login page conditionally shows password form and/or "Sign in with Microsoft" button + +### EntraID Configuration + +**Environment Variables** ([.env.local](.env.local) for local, App Service settings for production): + +```bash +# Enable EntraID authentication +ENABLE_ENTRAID_AUTH=false # Set to true to enable + +# Group-based access control (optional) +ENTRAID_ALLOWED_GROUPS= # Comma-separated Azure AD group Object IDs + # Empty = all tenant users allowed + +# Disable password authentication (optional) +DISABLE_PASSWORD_AUTH=false # Set to true for EntraID-only mode +``` + +**Key Files**: +- [lib/auth/entraid.ts](lib/auth/entraid.ts) - EntraID utilities (parse headers, validate groups) +- [middleware.ts](middleware.ts) - Authentication middleware (checks both auth types) +- [lib/session.ts](lib/session.ts) - Session management (unified for both auth types) +- [components/loginview/LoginView.tsx](components/loginview/LoginView.tsx) - Login UI + +**Setup Instructions**: See [Infrastructure/CLAUDE.md](../Infrastructure/CLAUDE.md) for complete Azure AD App Registration setup guide. + +**Local Development**: EntraID requires deployment to Azure App Service (Easy Auth doesn't work locally). Use password auth for local development. + ## Styling Guidelines **CRITICAL**: This project uses a specific MUI + Tailwind integration pattern. diff --git a/Website/app/api/auth/logout/route.ts b/Website/app/api/auth/logout/route.ts index cd99ace..73c7a0f 100644 --- a/Website/app/api/auth/logout/route.ts +++ b/Website/app/api/auth/logout/route.ts @@ -1,17 +1,23 @@ 'use server'; import { NextResponse } from "next/server"; -import { deleteSession } from "@/lib/session"; +import { deleteSession, getSession } from "@/lib/session"; export async function POST() { try { + const session = await getSession(); + const wasEntraId = session?.authType === 'entraid'; + await deleteSession(); - // Return success response instead of redirect since we handle navigation client-side - return NextResponse.json({ success: true }); + + return NextResponse.json({ + success: true, + redirectToEntraIdLogout: wasEntraId + }); } catch (error) { console.error('Logout error:', error); return NextResponse.json( - { error: 'Failed to logout' }, + { success: false, error: 'Logout failed' }, { status: 500 } ); } diff --git a/Website/app/api/auth/session/route.ts b/Website/app/api/auth/session/route.ts index 360f73b..d7037fc 100644 --- a/Website/app/api/auth/session/route.ts +++ b/Website/app/api/auth/session/route.ts @@ -4,16 +4,45 @@ import { getSession } from "@/lib/session"; export async function GET() { try { const session = await getSession(); - const isAuthenticated = session !== null; - - return NextResponse.json({ - isAuthenticated, - user: isAuthenticated ? { authenticated: true } : null - }); + + if (!session) { + return NextResponse.json({ + isAuthenticated: false, + authType: null, + user: null + }); + } + + const response: { + isAuthenticated: boolean; + authType: 'password' | 'entraid'; + user: { + userPrincipalName?: string; + name?: string; + authenticated?: boolean; + }; + } = { + isAuthenticated: true, + authType: session.authType, + user: {} + }; + + if (session.authType === 'entraid') { + response.user = { + userPrincipalName: session.userPrincipalName, + name: session.name + }; + } else { + response.user = { + authenticated: true + }; + } + + return NextResponse.json(response); } catch (error) { console.error('Session check error:', error); return NextResponse.json( - { isAuthenticated: false, user: null }, + { isAuthenticated: false, authType: null, user: null }, { status: 500 } ); } diff --git a/Website/app/api/version/route.ts b/Website/app/api/version/route.ts index d4c7665..75561ce 100644 --- a/Website/app/api/version/route.ts +++ b/Website/app/api/version/route.ts @@ -1,6 +1,11 @@ import { NextResponse } from 'next/server' import { version } from '../../../package.json' +import { isEntraIdEnabled, isPasswordAuthDisabled } from '@/lib/auth/entraid' export async function GET() { - return NextResponse.json({ version }) + return NextResponse.json({ + version, + entraIdEnabled: isEntraIdEnabled(), + passwordAuthDisabled: isPasswordAuthDisabled() + }) } diff --git a/Website/components/loginview/LoginView.tsx b/Website/components/loginview/LoginView.tsx index f52fed5..539cbed 100644 --- a/Website/components/loginview/LoginView.tsx +++ b/Website/components/loginview/LoginView.tsx @@ -2,7 +2,7 @@ import React, { FormEvent, useEffect, useState } from 'react' import LoadingOverlay from '../shared/LoadingOverlay' import { useLoading } from '@/hooks/useLoading' import { useAuth } from '@/contexts/AuthContext' -import { Alert, Box, Button, Container, FormControl, IconButton, InputAdornment, InputLabel, OutlinedInput, Typography, CircularProgress } from '@mui/material' +import { Alert, Box, Button, Container, Divider, FormControl, IconButton, InputAdornment, InputLabel, OutlinedInput, Typography, CircularProgress } from '@mui/material' import { Info, Visibility, VisibilityOff, Warning } from '@mui/icons-material' import { createSession } from '@/lib/session' import { LastSynched } from '@/stubs/Data' @@ -29,16 +29,26 @@ const LoginView = ({ }: LoginViewProps) => { const [version, setVersion] = useState(null); const [showIncorrectPassword, setShowIncorrectPassword] = useState(false); const [animateError, setAnimateError] = useState(false); + const [entraIdEnabled, setEntraIdEnabled] = useState(false); + const [passwordAuthDisabled, setPasswordAuthDisabled] = useState(false); useEffect(() => { fetch('/api/version') .then((res) => res.json()) - .then((data) => setVersion(data.version)) + .then((data) => { + setVersion(data.version); + setEntraIdEnabled(data.entraIdEnabled || false); + setPasswordAuthDisabled(data.passwordAuthDisabled || false); + }) .catch(() => setVersion('Unknown')) }, []); const handleClickShowPassword = () => setShowPassword((show) => !show); + const handleEntraIdLogin = () => { + window.location.href = '/.auth/login/aad?post_login_redirect_uri=/'; + }; + async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -107,19 +117,40 @@ const LoginView = ({ }: LoginViewProps) => { }) : '...'} {showIncorrectPassword && ( - } - severity="warning" + } + severity="warning" className={`w-full rounded-lg mt-4 transition-all duration-300 ease-out ${ - animateError - ? 'translate-x-0 opacity-100' + animateError + ? 'translate-x-0 opacity-100' : 'translate-x-4 opacity-0' }`} > The password is incorrect. )} -
+ + {entraIdEnabled && ( + <> + + {!passwordAuthDisabled && ( + + OR + + )} + + )} + + {!passwordAuthDisabled && ( + { {isAuthenticating ? 'Signing In...' : 'Sign In'} + )} Version: {version ?? '...'} diff --git a/Website/contexts/AuthContext.tsx b/Website/contexts/AuthContext.tsx index 681fcd8..71e286a 100644 --- a/Website/contexts/AuthContext.tsx +++ b/Website/contexts/AuthContext.tsx @@ -5,17 +5,24 @@ import { createContext, ReactNode, useContext, useState, useEffect, useCallback export interface AuthState { isAuthenticated: boolean | null; // null = loading isLoading: boolean; + authType?: 'password' | 'entraid'; + user?: { + userPrincipalName?: string; + name?: string; + }; } const initialState: AuthState = { isAuthenticated: null, - isLoading: true + isLoading: true, + authType: undefined, + user: undefined } interface AuthContextType extends AuthState { checkAuth: () => Promise; setAuthenticated: (authenticated: boolean) => void; - logout: () => void; + logout: () => Promise; } const AuthContext = createContext(undefined); @@ -34,13 +41,17 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const data = await response.json(); setState({ isAuthenticated: data.isAuthenticated, - isLoading: false + isLoading: false, + authType: data.authType, + user: data.user }); } catch (error) { console.error('Auth check failed:', error); setState({ isAuthenticated: false, - isLoading: false + isLoading: false, + authType: undefined, + user: undefined }); } }, []); @@ -53,11 +64,28 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { })); }, []); - const logout = useCallback(() => { - setState({ - isAuthenticated: false, - isLoading: false - }); + const logout = useCallback(async () => { + try { + // Call logout API to clear session + const response = await fetch('/api/auth/logout', { method: 'POST' }); + const data = await response.json(); + + setState({ + isAuthenticated: false, + isLoading: false, + authType: undefined, + user: undefined + }); + + // If EntraID, redirect to Easy Auth logout + if (data.redirectToEntraIdLogout) { + window.location.href = '/.auth/logout'; + } else { + window.location.href = '/login'; + } + } catch (error) { + console.error('Logout failed:', error); + } }, []); useEffect(() => { diff --git a/Website/lib/auth/entraid.ts b/Website/lib/auth/entraid.ts new file mode 100644 index 0000000..489e361 --- /dev/null +++ b/Website/lib/auth/entraid.ts @@ -0,0 +1,64 @@ +'use server'; + +interface EntraIdPrincipal { + auth_typ: string; // "aad" + claims: Array<{ typ: string; val: string }>; + name_typ: string; + role_typ: string; +} + +export interface ParsedEntraIdUser { + userPrincipalName: string; + userId: string; + name: string; + groups: string[]; +} + +export function isEntraIdEnabled(): boolean { + return process.env.ENABLE_ENTRAID_AUTH === 'true'; +} + +export function isPasswordAuthDisabled(): boolean { + return process.env.DISABLE_PASSWORD_AUTH === 'true'; +} + +export function getEntraIdAllowedGroups(): string[] { + const groups = process.env.ENTRAID_ALLOWED_GROUPS || ''; + return groups.split(',').filter(g => g.trim().length > 0); +} + +export function parseEntraIdPrincipal(principalHeader: string | null): ParsedEntraIdUser | null { + if (!principalHeader) return null; + + try { + const decoded = Buffer.from(principalHeader, 'base64').toString('utf-8'); + const principal: EntraIdPrincipal = JSON.parse(decoded); + + const getClaim = (type: string) => + principal.claims.find(c => c.typ === type)?.val || ''; + + return { + userPrincipalName: getClaim('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn') || + getClaim('preferred_username'), + userId: getClaim('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier') || + getClaim('oid'), + name: getClaim('name') || getClaim('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'), + groups: principal.claims + .filter(c => c.typ === 'groups') + .map(c => c.val) + }; + } catch (error) { + console.error('Failed to parse EntraID principal:', error); + return null; + } +} + +export function validateGroupAccess(userGroups: string[]): boolean { + const allowedGroups = getEntraIdAllowedGroups(); + + // If no groups configured, allow all authenticated users + if (allowedGroups.length === 0) return true; + + // Check if user is in at least one allowed group + return userGroups.some(group => allowedGroups.includes(group)); +} diff --git a/Website/lib/session.ts b/Website/lib/session.ts index 2be88aa..0fe3cbb 100644 --- a/Website/lib/session.ts +++ b/Website/lib/session.ts @@ -7,8 +7,17 @@ const secretKey = process.env.WebsiteSessionSecret; const encodedKey = new TextEncoder().encode(secretKey); export type Session = { - password: string; + authType: 'password' | 'entraid'; expiresAt: Date; + + // Password auth + password?: string; + + // EntraID auth + userPrincipalName?: string; + userId?: string; + name?: string; + groups?: string[]; } export async function encrypt(payload: Session) { @@ -34,7 +43,35 @@ export async function decrypt(session: string | undefined = '') { export async function createSession(password: string) { const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - const session = await encrypt({ password, expiresAt }); + const session = await encrypt({ + authType: 'password', + password, + expiresAt + }); + (await cookies()).set( + "session", + session, + { + httpOnly: true, + secure: true, + expires: expiresAt, + sameSite: "lax", + path: "/", + }); +} + +export async function createEntraIdSession(userInfo: { + userPrincipalName: string; + userId: string; + name?: string; + groups?: string[]; +}) { + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + const session = await encrypt({ + authType: 'entraid', + expiresAt, + ...userInfo + }); (await cookies()).set( "session", session, diff --git a/Website/middleware.ts b/Website/middleware.ts index 0bc6b8a..f8fef56 100644 --- a/Website/middleware.ts +++ b/Website/middleware.ts @@ -1,31 +1,81 @@ import { NextResponse, NextRequest } from 'next/server' -import { getSession } from './lib/session' +import { getSession, createEntraIdSession } from './lib/session' +import { + isEntraIdEnabled, + isPasswordAuthDisabled, + parseEntraIdPrincipal, + validateGroupAccess +} from './lib/auth/entraid' export async function middleware(request: NextRequest) { - const session = await getSession(); - const isAuthenticated = session !== null; + let isAuthenticated = false; - // If the user is authenticated, continue as normal + // 1. Check EntraID authentication first (if enabled) + if (isEntraIdEnabled()) { + const principalHeader = request.headers.get('X-MS-CLIENT-PRINCIPAL'); + + if (principalHeader) { + const userInfo = parseEntraIdPrincipal(principalHeader); + + if (userInfo) { + // Validate group access + if (!validateGroupAccess(userInfo.groups)) { + return NextResponse.json( + { error: 'Access denied. You are not in an allowed group.' }, + { status: 403 } + ); + } + + // Check if we need to create/update session + const session = await getSession(); + if (!session || session.authType !== 'entraid' || session.userId !== userInfo.userId) { + // Create new session for this EntraID user + await createEntraIdSession(userInfo); + } + + isAuthenticated = true; + } + } + } + + // 2. Check password session (if not authenticated via EntraID) + if (!isAuthenticated && !isPasswordAuthDisabled()) { + const session = await getSession(); + if (session && session.authType === 'password') { + isAuthenticated = true; + } + } + + // 3. Handle authentication result if (isAuthenticated) { - return NextResponse.next() + return NextResponse.next(); } - // Allow access to public API endpoints without authentication + // Allow access to public endpoints const publicApiEndpoints = ['/api/auth/login', '/api/version']; if (publicApiEndpoints.includes(request.nextUrl.pathname)) { - return NextResponse.next() + return NextResponse.next(); } - // For API routes, return 401 Unauthorized + // For API routes, return 401 if (request.nextUrl.pathname.startsWith('/api')) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } - ) + ); } - // For page routes, redirect to login page - return NextResponse.redirect(new URL('/login', request.url)) + // For page routes, redirect based on auth type + if (isEntraIdEnabled() && !isPasswordAuthDisabled()) { + // Dual mode: redirect to custom login page that offers both options + return NextResponse.redirect(new URL('/login', request.url)); + } else if (isEntraIdEnabled()) { + // EntraID only: redirect to Easy Auth login + return NextResponse.redirect(new URL('/.auth/login/aad', request.url)); + } else { + // Password only: redirect to login page + return NextResponse.redirect(new URL('/login', request.url)); + } } export const config = { From 759d4d5670fb7ee1b701dd3b8422c113c347c939 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 6 Dec 2025 20:09:40 +0100 Subject: [PATCH 02/20] chore: can remove server serialization. Not used clientside --- Website/lib/auth/entraid.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/Website/lib/auth/entraid.ts b/Website/lib/auth/entraid.ts index 489e361..31d558f 100644 --- a/Website/lib/auth/entraid.ts +++ b/Website/lib/auth/entraid.ts @@ -1,5 +1,3 @@ -'use server'; - interface EntraIdPrincipal { auth_typ: string; // "aad" claims: Array<{ typ: string; val: string }>; From 275c2d016df3b973f3b884d21e59d53076205951 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 6 Dec 2025 20:25:35 +0100 Subject: [PATCH 03/20] fix: always redirect to loign --- Website/middleware.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/Website/middleware.ts b/Website/middleware.ts index f8fef56..26f8ed2 100644 --- a/Website/middleware.ts +++ b/Website/middleware.ts @@ -65,17 +65,9 @@ export async function middleware(request: NextRequest) { ); } - // For page routes, redirect based on auth type - if (isEntraIdEnabled() && !isPasswordAuthDisabled()) { - // Dual mode: redirect to custom login page that offers both options - return NextResponse.redirect(new URL('/login', request.url)); - } else if (isEntraIdEnabled()) { - // EntraID only: redirect to Easy Auth login - return NextResponse.redirect(new URL('/.auth/login/aad', request.url)); - } else { - // Password only: redirect to login page - return NextResponse.redirect(new URL('/login', request.url)); - } + // For page routes, redirect to login page + // The login page will show appropriate options based on config + return NextResponse.redirect(new URL('/login', request.url)); } export const config = { From f6b7bbb13178f4e6ecd07c6ab25771a727218983 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 6 Dec 2025 20:58:07 +0100 Subject: [PATCH 04/20] chore: Microsoft icon for AD login --- Infrastructure/CLAUDE.md | 35 +++++- Website/components/loginview/LoginView.tsx | 126 ++++++++++----------- Website/public/MS_LOGO.svg | 1 + 3 files changed, 94 insertions(+), 68 deletions(-) create mode 100644 Website/public/MS_LOGO.svg diff --git a/Infrastructure/CLAUDE.md b/Infrastructure/CLAUDE.md index 070b7b7..6484ea4 100644 --- a/Infrastructure/CLAUDE.md +++ b/Infrastructure/CLAUDE.md @@ -193,7 +193,20 @@ Before enabling EntraID authentication: 4. Click **Register** 5. Note the **Application (client) ID** and **Directory (tenant) ID** from the Overview page -### Step 2: Configure App Registration API Permissions +### Step 2: Enable Implicit Grant Flow + +**CRITICAL**: Azure App Service Easy Auth requires ID tokens to be enabled. + +1. In your App Registration, go to **Authentication** +2. Scroll down to **Implicit grant and hybrid flows** section +3. Check these boxes: + - ✅ **ID tokens** (required for Easy Auth) + - ✅ **Access tokens** (recommended) +4. Click **Save** + +**Note**: Without this step, users will get error `AADSTS700054: response_type 'id_token' is not enabled for the application` + +### Step 3: Configure App Registration API Permissions 1. In your App Registration, go to **API permissions** 2. Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** @@ -203,7 +216,7 @@ Before enabling EntraID authentication: 4. Click **Add permissions** 5. **Grant admin consent** if required by your organization -### Step 3: Configure Token Claims (Optional - for Group-Based Access) +### Step 4: Configure Token Claims (Optional - for Group-Based Access) To enable group-based access control: @@ -213,7 +226,7 @@ To enable group-based access control: 4. Check both **ID** and **Access tokens** 5. Click **Add** -### Step 4: Get Security Group Object IDs (Optional) +### Step 5: Get Security Group Object IDs (Optional) If restricting access to specific groups: @@ -222,7 +235,7 @@ If restricting access to specific groups: 3. Click on each group and copy the **Object ID** 4. Prepare comma-separated list: `abc123-...,def456-...,ghi789-...` -### Step 5: Deploy with EntraID Enabled +### Step 6: Deploy with EntraID Enabled #### Option A: Enable on New Deployment @@ -308,6 +321,20 @@ az deployment group create \ ### Troubleshooting EntraID Authentication +#### Users Get "AADSTS700054: response_type 'id_token' is not enabled" Error + +**Problem**: Implicit grant flow not enabled in App Registration + +**Solution**: +1. Go to Azure Portal → Azure Active Directory → App registrations +2. Select your Data Model Viewer app registration +3. Go to **Authentication** → **Implicit grant and hybrid flows** +4. Check ✅ **ID tokens** (required) +5. Check ✅ **Access tokens** (recommended) +6. Click **Save** +7. Wait 1-2 minutes for changes to propagate +8. Try logging in again + #### Users Get "Redirect URI Mismatch" Error **Problem**: App Registration redirect URI doesn't match deployed URL diff --git a/Website/components/loginview/LoginView.tsx b/Website/components/loginview/LoginView.tsx index 539cbed..018f8e4 100644 --- a/Website/components/loginview/LoginView.tsx +++ b/Website/components/loginview/LoginView.tsx @@ -16,13 +16,13 @@ const LoginView = ({ }: LoginViewProps) => { const router = useRouter(); const { setAuthenticated } = useAuth(); - const { - isAuthenticating, - isRedirecting, - startAuthentication, - startRedirection, - stopAuthentication, - resetAuthState + const { + isAuthenticating, + isRedirecting, + startAuthentication, + startRedirection, + stopAuthentication, + resetAuthState } = useLoading(); const [showPassword, setShowPassword] = useState(false); @@ -51,7 +51,7 @@ const LoginView = ({ }: LoginViewProps) => { async function handleSubmit(event: FormEvent) { event.preventDefault(); - + startAuthentication(); setShowIncorrectPassword(false); setAnimateError(false); @@ -88,9 +88,9 @@ const LoginView = ({ }: LoginViewProps) => { return ( - @@ -106,7 +106,7 @@ const LoginView = ({ }: LoginViewProps) => { Sign in to your organization } severity="info" className='w-full rounded-lg'> - Last synchronization: {LastSynched ? LastSynched.toLocaleString('en-DK', { + Last synchronization: {LastSynched ? LastSynched.toLocaleString('en-DK', { timeZone: 'Europe/Copenhagen', timeZoneName: 'short', year: 'numeric', @@ -120,11 +120,10 @@ const LoginView = ({ }: LoginViewProps) => { } severity="warning" - className={`w-full rounded-lg mt-4 transition-all duration-300 ease-out ${ - animateError - ? 'translate-x-0 opacity-100' - : 'translate-x-4 opacity-0' - }`} + className={`w-full rounded-lg mt-4 transition-all duration-300 ease-out ${animateError + ? 'translate-x-0 opacity-100' + : 'translate-x-4 opacity-0' + }`} > The password is incorrect. @@ -134,12 +133,11 @@ const LoginView = ({ }: LoginViewProps) => { <> {!passwordAuthDisabled && ( @@ -151,51 +149,51 @@ const LoginView = ({ }: LoginViewProps) => { {!passwordAuthDisabled && (
- - Password - - + Password + + - {showPassword ? : } - - - } - label="Password" - /> - - -
+ {showPassword ? : } + + + } + label="Password" + /> +
+ + )} diff --git a/Website/public/MS_LOGO.svg b/Website/public/MS_LOGO.svg new file mode 100644 index 0000000..5334aa7 --- /dev/null +++ b/Website/public/MS_LOGO.svg @@ -0,0 +1 @@ + \ No newline at end of file From 226faae47037f15ebe76436df8070bb47ac325cc Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 15:13:37 +0100 Subject: [PATCH 05/20] feat: use normal grant flow instead of baked in, in the app service. --- Infrastructure/CLAUDE.md | 61 ++++++---- Infrastructure/main.bicep | 58 ++++----- Website/app/api/auth/[...nextauth]/route.ts | 3 + Website/auth.config.ts | 41 +++++++ Website/auth.ts | 43 +++++++ Website/components/loginview/LoginView.tsx | 6 +- Website/middleware.ts | 127 +++++++++----------- Website/package-lock.json | 103 ++++++++++++++++ Website/package.json | 5 +- 9 files changed, 315 insertions(+), 132 deletions(-) create mode 100644 Website/app/api/auth/[...nextauth]/route.ts create mode 100644 Website/auth.config.ts create mode 100644 Website/auth.ts diff --git a/Infrastructure/CLAUDE.md b/Infrastructure/CLAUDE.md index 6484ea4..1668e4e 100644 --- a/Infrastructure/CLAUDE.md +++ b/Infrastructure/CLAUDE.md @@ -161,14 +161,16 @@ The Azure Pipeline (`azure-pipelines-deploy-jobs.yml`) deploys using: ## EntraID Authentication Setup -The application supports **optional** Microsoft EntraID (Azure AD) authentication using App Service Easy Auth. This provides enterprise single sign-on (SSO) with your organization's Microsoft accounts. +The application supports **optional** Microsoft EntraID (Azure AD) authentication using **OpenID Connect** (via NextAuth.js). This provides enterprise single sign-on (SSO) with your organization's Microsoft accounts. + +**Important**: This implementation uses standard OpenID Connect flow, NOT Azure App Service Easy Auth. Users can always access the login page - authentication only occurs when they click "Sign in with Microsoft". ### Authentication Modes Three authentication modes are supported: 1. **Password Only** (default): Traditional password-based login -2. **EntraID Only**: Microsoft SSO authentication, password login disabled +2. **EntraID Only**: Microsoft SSO authentication via OpenID Connect, password login disabled 3. **Dual Mode**: Users can choose between password or Microsoft SSO ### EntraID Prerequisites @@ -188,23 +190,25 @@ Before enabling EntraID authentication: - **Supported account types**: `Accounts in this organizational directory only (Single tenant)` - **Redirect URI**: - Platform: `Web` - - URI: `https://wa-{solutionId}.azurewebsites.net/.auth/login/aad/callback` + - URI: `https://wa-{solutionId}.azurewebsites.net/api/auth/callback/microsoft-entra-id` - Replace `{solutionId}` with your actual solution ID 4. Click **Register** 5. Note the **Application (client) ID** and **Directory (tenant) ID** from the Overview page -### Step 2: Enable Implicit Grant Flow +### Step 2: Create Client Secret -**CRITICAL**: Azure App Service Easy Auth requires ID tokens to be enabled. +**CRITICAL**: Azure App Service Easy Auth requires a client secret to avoid using deprecated implicit grant flow. -1. In your App Registration, go to **Authentication** -2. Scroll down to **Implicit grant and hybrid flows** section -3. Check these boxes: - - ✅ **ID tokens** (required for Easy Auth) - - ✅ **Access tokens** (recommended) -4. Click **Save** +1. In your App Registration, go to **Certificates & secrets** +2. Click **Client secrets** → **New client secret** +3. Enter: + - **Description**: `App Service SSO` + - **Expires**: Choose appropriate duration (e.g., 24 months) +4. Click **Add** +5. **IMPORTANT**: Copy the **Value** (not the Secret ID) immediately - it won't be shown again +6. Save this value securely - you'll use it in the deployment -**Note**: Without this step, users will get error `AADSTS700054: response_type 'id_token' is not enabled for the application` +**Alternative (Preview)**: Azure supports using a managed identity with federated credentials instead of a client secret. This approach is currently in preview and requires additional setup. For production deployments, the client secret approach is recommended. See Microsoft's documentation on [using a managed identity instead of a secret](https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-provider-aad?tabs=workforce-configuration%2Cworkforce-tenant#use-a-managed-identity-instead-of-a-secret-preview) if interested. ### Step 3: Configure App Registration API Permissions @@ -248,6 +252,7 @@ az deployment group create \ sessionSecret='<32-byte-random-string>' \ enableEntraIdAuth=true \ entraIdClientId='' \ + entraIdClientSecret='' \ entraIdTenantId='' \ entraIdAllowedGroups=',' \ disablePasswordAuth=false \ @@ -265,6 +270,7 @@ az deployment group create \ --parameters @previous-parameters.json \ enableEntraIdAuth=true \ entraIdClientId='' \ + entraIdClientSecret='' \ entraIdTenantId='' ``` @@ -274,6 +280,7 @@ az deployment group create \ |-----------|----------|---------|-------------| | `enableEntraIdAuth` | No | `false` | Enables EntraID authentication via Easy Auth | | `entraIdClientId` | Yes if enabled | `''` | Application (client) ID from App Registration | +| `entraIdClientSecret` | Yes if enabled | `''` | Client secret value from App Registration | | `entraIdTenantId` | No | Subscription tenant | Directory (tenant) ID for your Azure AD | | `entraIdAllowedGroups` | No | `''` | Comma-separated group Object IDs. Empty = all tenant users | | `disablePasswordAuth` | No | `false` | Set to `true` for EntraID-only mode | @@ -284,6 +291,7 @@ az deployment group create \ ```bash --parameters enableEntraIdAuth=true \ entraIdClientId='abc123...' \ + entraIdClientSecret='secret123...' \ disablePasswordAuth=false ``` - Users see both "Sign in with Microsoft" and password form @@ -294,6 +302,7 @@ az deployment group create \ ```bash --parameters enableEntraIdAuth=true \ entraIdClientId='abc123...' \ + entraIdClientSecret='secret123...' \ disablePasswordAuth=true ``` - Only "Sign in with Microsoft" button shown @@ -304,6 +313,7 @@ az deployment group create \ ```bash --parameters enableEntraIdAuth=true \ entraIdClientId='abc123...' \ + entraIdClientSecret='secret123...' \ entraIdAllowedGroups='group-id-1,group-id-2' ``` - Only users in specified security groups can access @@ -323,17 +333,26 @@ az deployment group create \ #### Users Get "AADSTS700054: response_type 'id_token' is not enabled" Error -**Problem**: Implicit grant flow not enabled in App Registration +**Problem**: No client secret configured - Easy Auth is falling back to deprecated implicit grant flow **Solution**: -1. Go to Azure Portal → Azure Active Directory → App registrations -2. Select your Data Model Viewer app registration -3. Go to **Authentication** → **Implicit grant and hybrid flows** -4. Check ✅ **ID tokens** (required) -5. Check ✅ **Access tokens** (recommended) -6. Click **Save** -7. Wait 1-2 minutes for changes to propagate -8. Try logging in again +1. Create a client secret in your App Registration: + - Go to Azure Portal → Azure Active Directory → App registrations + - Select your Data Model Viewer app registration + - Go to **Certificates & secrets** → **Client secrets** → **New client secret** + - Copy the secret **Value** (not Secret ID) +2. Redeploy with the client secret parameter: + ```bash + az deployment group create \ + --resource-group rg-datamodelviewer \ + --template-file main.bicep \ + --parameters @previous-parameters.json \ + entraIdClientSecret='' + ``` +3. Wait 2-3 minutes for App Service to restart +4. Try logging in again + +**Note**: Easy Auth requires a client secret to avoid using the deprecated OAuth 2.0 implicit grant flow #### Users Get "Redirect URI Mismatch" Error diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index 702ef79..9479d00 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -14,6 +14,10 @@ param enableEntraIdAuth bool = false @description('Azure AD App Registration Client ID') param entraIdClientId string = '' +@description('Azure AD App Registration Client Secret') +@secure() +param entraIdClientSecret string = '' + @description('Azure AD Tenant ID (defaults to subscription tenant)') param entraIdTenantId string = subscription().tenantId @@ -60,6 +64,14 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'WebsiteSessionSecret' value: sessionSecret } + { + name: 'AUTH_SECRET' + value: sessionSecret + } + { + name: 'NEXTAUTH_URL' + value: 'https://wa-${solutionId}.azurewebsites.net' + } { name: 'WEBSITE_NODE_DEFAULT_VERSION' value: '~20' @@ -80,6 +92,18 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'ENABLE_ENTRAID_AUTH' value: string(enableEntraIdAuth) } + { + name: 'AZURE_AD_CLIENT_ID' + value: entraIdClientId + } + { + name: 'AZURE_AD_CLIENT_SECRET' + value: entraIdClientSecret + } + { + name: 'AZURE_AD_TENANT_ID' + value: entraIdTenantId + } { name: 'ENTRAID_ALLOWED_GROUPS' value: entraIdAllowedGroups @@ -93,40 +117,6 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { } } -@description('Configure Easy Auth for EntraID authentication') -resource authSettings 'Microsoft.Web/sites/config@2022-09-01' = if (enableEntraIdAuth) { - name: 'authsettingsV2' - parent: webApp - properties: { - globalValidation: { - requireAuthentication: true - unauthenticatedClientAction: 'RedirectToLoginPage' - redirectToProvider: 'azureactivedirectory' - } - identityProviders: { - azureActiveDirectory: { - enabled: true - registration: { - openIdIssuer: 'https://sts.windows.net/${entraIdTenantId}/' - clientId: entraIdClientId - } - validation: { - allowedAudiences: [ - 'api://${entraIdClientId}' - entraIdClientId - ] - } - } - } - login: { - tokenStore: { - enabled: true - } - allowedExternalRedirectUrls: [] - } - } -} - @description('Output the web app name and managed identity info') output webAppName string = webApp.name output managedIdentityPrincipalId string = webApp.identity.principalId diff --git a/Website/app/api/auth/[...nextauth]/route.ts b/Website/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0a98352 --- /dev/null +++ b/Website/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth'; + +export const { GET, POST } = handlers; diff --git a/Website/auth.config.ts b/Website/auth.config.ts new file mode 100644 index 0000000..14f12db --- /dev/null +++ b/Website/auth.config.ts @@ -0,0 +1,41 @@ +import type { NextAuthConfig } from 'next-auth'; +import MicrosoftEntraID from 'next-auth/providers/microsoft-entra-id'; + +export const authConfig = { + providers: [ + MicrosoftEntraID({ + clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, + clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + issuer: process.env.AZURE_TENANT_ID + ? `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0` + : process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER, + }), + ], + pages: { + signIn: '/login', + }, + callbacks: { + authorized({ auth, request: { nextUrl } }) { + const isLoggedIn = !!auth?.user; + const isOnLoginPage = nextUrl.pathname === '/login'; + + // Allow everyone to access the login page + if (isOnLoginPage) { + return true; + } + + // Public API endpoints + const publicApiEndpoints = ['/api/auth', '/api/version']; + if (publicApiEndpoints.some(path => nextUrl.pathname.startsWith(path))) { + return true; + } + + // All other pages require authentication + if (!isLoggedIn) { + return false; // Will redirect to /login + } + + return true; + }, + }, +} satisfies NextAuthConfig; diff --git a/Website/auth.ts b/Website/auth.ts new file mode 100644 index 0000000..9001601 --- /dev/null +++ b/Website/auth.ts @@ -0,0 +1,43 @@ +import NextAuth from 'next-auth'; +import { authConfig } from './auth.config'; +import { getEntraIdAllowedGroups } from './lib/auth/entraid'; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + ...authConfig, + callbacks: { + ...authConfig.callbacks, + async jwt({ token, account, profile }) { + console.log('JWT callback:', { token, account, profile }); + // On initial sign in, add custom claims + if (account && profile) { + token.tenantId = (profile as any).tid; + token.groups = (profile as any).groups || []; + token.userId = (profile as any).oid; + } + return token; + }, + async session({ session, token }) { + console.log('Session callback:', { session, token }); + // Add custom claims to session + if (session.user) { + (session.user as any).tenantId = token.tenantId; + (session.user as any).groups = token.groups; + (session.user as any).userId = token.userId; + } + + // Validate group access + const allowedGroups = getEntraIdAllowedGroups(); + console.log('Groups:', allowedGroups); + if (allowedGroups.length > 0) { + const userGroups = (token.groups as string[]) || []; + const hasAccess = userGroups.some(group => allowedGroups.includes(group)); + + if (!hasAccess) { + throw new Error('User not in allowed groups'); + } + } + + return session; + }, + }, +}); diff --git a/Website/components/loginview/LoginView.tsx b/Website/components/loginview/LoginView.tsx index 018f8e4..77dc6eb 100644 --- a/Website/components/loginview/LoginView.tsx +++ b/Website/components/loginview/LoginView.tsx @@ -45,8 +45,10 @@ const LoginView = ({ }: LoginViewProps) => { const handleClickShowPassword = () => setShowPassword((show) => !show); - const handleEntraIdLogin = () => { - window.location.href = '/.auth/login/aad?post_login_redirect_uri=/'; + const handleEntraIdLogin = async () => { + // Use NextAuth signIn + const { signIn } = await import('next-auth/react'); + await signIn('microsoft-entra-id', { callbackUrl: '/' }); }; async function handleSubmit(event: FormEvent) { diff --git a/Website/middleware.ts b/Website/middleware.ts index 26f8ed2..49f725c 100644 --- a/Website/middleware.ts +++ b/Website/middleware.ts @@ -1,85 +1,66 @@ -import { NextResponse, NextRequest } from 'next/server' -import { getSession, createEntraIdSession } from './lib/session' -import { - isEntraIdEnabled, - isPasswordAuthDisabled, - parseEntraIdPrincipal, - validateGroupAccess -} from './lib/auth/entraid' +import { auth } from './auth'; +import { NextResponse } from 'next/server'; +import { isPasswordAuthDisabled } from './lib/auth/entraid'; -export async function middleware(request: NextRequest) { - let isAuthenticated = false; +export default auth((req) => { + const { auth: session } = req; + const isLoggedIn = !!session?.user; + const { pathname } = req.nextUrl; - // 1. Check EntraID authentication first (if enabled) - if (isEntraIdEnabled()) { - const principalHeader = request.headers.get('X-MS-CLIENT-PRINCIPAL'); + // Allow access to login page + if (pathname === '/login') { + return NextResponse.next(); + } - if (principalHeader) { - const userInfo = parseEntraIdPrincipal(principalHeader); + // Allow access to auth API routes + if (pathname.startsWith('/api/auth')) { + return NextResponse.next(); + } - if (userInfo) { - // Validate group access - if (!validateGroupAccess(userInfo.groups)) { - return NextResponse.json( - { error: 'Access denied. You are not in an allowed group.' }, - { status: 403 } - ); - } + // Allow access to version API + if (pathname === '/api/version') { + return NextResponse.next(); + } - // Check if we need to create/update session - const session = await getSession(); - if (!session || session.authType !== 'entraid' || session.userId !== userInfo.userId) { - // Create new session for this EntraID user - await createEntraIdSession(userInfo); - } - - isAuthenticated = true; - } - } - } - - // 2. Check password session (if not authenticated via EntraID) - if (!isAuthenticated && !isPasswordAuthDisabled()) { - const session = await getSession(); - if (session && session.authType === 'password') { - isAuthenticated = true; - } + // Check password auth session if EntraID is not used or if dual mode + if (!isLoggedIn && !isPasswordAuthDisabled()) { + // Check for password session + const passwordSession = req.cookies.get('session'); + if (passwordSession) { + // User has password session, allow access + return NextResponse.next(); } + } - // 3. Handle authentication result - if (isAuthenticated) { - return NextResponse.next(); + // For API routes, return 401 + if (pathname.startsWith('/api')) { + if (!isLoggedIn) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); } + } - // Allow access to public endpoints - const publicApiEndpoints = ['/api/auth/login', '/api/version']; - if (publicApiEndpoints.includes(request.nextUrl.pathname)) { - return NextResponse.next(); - } - - // For API routes, return 401 - if (request.nextUrl.pathname.startsWith('/api')) { - return NextResponse.json( - { error: 'Unauthorized' }, - { status: 401 } - ); - } + // For page routes, redirect to login if not authenticated + if (!isLoggedIn) { + const loginUrl = new URL('/login', req.url); + return NextResponse.redirect(loginUrl); + } - // For page routes, redirect to login page - // The login page will show appropriate options based on config - return NextResponse.redirect(new URL('/login', request.url)); -} + // User is authenticated, allow access + return NextResponse.next(); +}); export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - login (login page) - * - public assets (images, SVGs, etc.) - */ - '/((?!_next/static|_next/image|favicon.ico|login|.*\\.).*)', - ] -} \ No newline at end of file + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public assets (images, SVGs, etc.) + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.).*)', + ], +}; diff --git a/Website/package-lock.json b/Website/package-lock.json index c4a68de..b8016ee 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -25,6 +25,7 @@ "jose": "^5.9.6", "libavoid-js": "^0.4.5", "next": "^15.5.3", + "next-auth": "^5.0.0-beta.30", "postcss": "^8.5.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -58,6 +59,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -1953,6 +1992,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -6894,6 +6942,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6921,6 +6996,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/oauth4webapi": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", + "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7238,6 +7322,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/Website/package.json b/Website/package.json index c000f0a..b967e91 100644 --- a/Website/package.json +++ b/Website/package.json @@ -18,8 +18,8 @@ "@mui/material-nextjs": "^7.3.2", "@nivo/bar": "^0.99.0", "@nivo/core": "^0.99.0", - "@nivo/pie": "^0.99.0", "@nivo/heatmap": "^0.99.0", + "@nivo/pie": "^0.99.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", @@ -28,6 +28,7 @@ "jose": "^5.9.6", "libavoid-js": "^0.4.5", "next": "^15.5.3", + "next-auth": "^5.0.0-beta.30", "postcss": "^8.5.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -48,4 +49,4 @@ "eslint-config-next": "15.0.3", "typescript": "^5" } -} +} \ No newline at end of file From 930a4c7ce0a533e9ad10eedf080589fdab195370 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 15:25:54 +0100 Subject: [PATCH 06/20] fix: save session when logged in with entra --- Website/auth.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Website/auth.ts b/Website/auth.ts index 9001601..2de88a2 100644 --- a/Website/auth.ts +++ b/Website/auth.ts @@ -1,13 +1,26 @@ import NextAuth from 'next-auth'; import { authConfig } from './auth.config'; import { getEntraIdAllowedGroups } from './lib/auth/entraid'; +import { createEntraIdSession } from './lib/session'; export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, callbacks: { ...authConfig.callbacks, + async signIn({ user, account, profile }) { + // Create custom session cookie for EntraID users + if (account?.provider === 'microsoft-entra-id' && profile) { + await createEntraIdSession({ + userPrincipalName: (profile as any).email || (profile as any).preferred_username || user.email || '', + userId: (profile as any).oid || user.id || '', + name: user.name || (profile as any).name || '', + groups: (profile as any).groups || [] + }); + } + + return true; + }, async jwt({ token, account, profile }) { - console.log('JWT callback:', { token, account, profile }); // On initial sign in, add custom claims if (account && profile) { token.tenantId = (profile as any).tid; @@ -17,7 +30,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ return token; }, async session({ session, token }) { - console.log('Session callback:', { session, token }); // Add custom claims to session if (session.user) { (session.user as any).tenantId = token.tenantId; @@ -27,7 +39,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // Validate group access const allowedGroups = getEntraIdAllowedGroups(); - console.log('Groups:', allowedGroups); if (allowedGroups.length > 0) { const userGroups = (token.groups as string[]) || []; const hasAccess = userGroups.some(group => allowedGroups.includes(group)); From 88d4035ebcbb634ef1fa1091156f309b72847596 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 15:30:32 +0100 Subject: [PATCH 07/20] fix: clear Entra cookies and session on logout, like with password authentication --- Website/app/api/auth/logout/route.ts | 7 +++++++ Website/contexts/AuthContext.tsx | 21 ++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Website/app/api/auth/logout/route.ts b/Website/app/api/auth/logout/route.ts index 73c7a0f..65b213a 100644 --- a/Website/app/api/auth/logout/route.ts +++ b/Website/app/api/auth/logout/route.ts @@ -2,14 +2,21 @@ import { NextResponse } from "next/server"; import { deleteSession, getSession } from "@/lib/session"; +import { signOut } from "@/auth"; export async function POST() { try { const session = await getSession(); const wasEntraId = session?.authType === 'entraid'; + // Delete custom session cookie await deleteSession(); + // If EntraID, also sign out from NextAuth + if (wasEntraId) { + await signOut({ redirect: false }); + } + return NextResponse.json({ success: true, redirectToEntraIdLogout: wasEntraId diff --git a/Website/contexts/AuthContext.tsx b/Website/contexts/AuthContext.tsx index 71e286a..4642e79 100644 --- a/Website/contexts/AuthContext.tsx +++ b/Website/contexts/AuthContext.tsx @@ -66,9 +66,8 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const logout = useCallback(async () => { try { - // Call logout API to clear session - const response = await fetch('/api/auth/logout', { method: 'POST' }); - const data = await response.json(); + // Call logout API to clear both custom session and NextAuth session + await fetch('/api/auth/logout', { method: 'POST' }); setState({ isAuthenticated: false, @@ -77,14 +76,18 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { user: undefined }); - // If EntraID, redirect to Easy Auth logout - if (data.redirectToEntraIdLogout) { - window.location.href = '/.auth/logout'; - } else { - window.location.href = '/login'; - } + // Redirect to login page + window.location.href = '/login'; } catch (error) { console.error('Logout failed:', error); + // Still redirect to login even if API call fails + setState({ + isAuthenticated: false, + isLoading: false, + authType: undefined, + user: undefined + }); + window.location.href = '/login'; } }, []); From e3721e365d7e2b65efa7de254172699e1025b2b5 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 15:36:32 +0100 Subject: [PATCH 08/20] chore: swapped to correct env variables in auth config and pipelines --- Setup/azure-pipelines-deploy-jobs.yml | 12 ++++++++++++ Setup/azure-pipelines-external.yml | 5 ++++- Website/auth.config.ts | 6 +++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Setup/azure-pipelines-deploy-jobs.yml b/Setup/azure-pipelines-deploy-jobs.yml index 58cce5e..9a0683b 100644 --- a/Setup/azure-pipelines-deploy-jobs.yml +++ b/Setup/azure-pipelines-deploy-jobs.yml @@ -32,6 +32,15 @@ parameters: - name: adoRepositoryName type: string default: '' + - name: entraIdTenantId + type: string + default: '' + - name: entraIdClientId + type: string + default: '' + - name: entraIdClientSecret + type: string + default: '' steps: - task: AzureCLI@2 @@ -54,6 +63,9 @@ steps: --parameters adoOrganizationUrl="${{ parameters.adoOrganizationUrl }}" ` --parameters adoProjectName="${{ parameters.adoProjectName }}" ` --parameters adoRepositoryName="${{ parameters.adoRepositoryName }}" ` + --parameters entraIdTenantId="${{ parameters.entraIdTenantId }}" ` + --parameters entraIdClientId="${{ parameters.entraIdClientId }}" ` + --parameters entraIdClientSecret="${{ parameters.entraIdClientSecret }}" ` | ConvertFrom-Json # Extract outputs diff --git a/Setup/azure-pipelines-external.yml b/Setup/azure-pipelines-external.yml index 3ecd784..8c017fd 100644 --- a/Setup/azure-pipelines-external.yml +++ b/Setup/azure-pipelines-external.yml @@ -94,4 +94,7 @@ stages: websiteName: $(WebsiteName) adoOrganizationUrl: $(System.CollectionUri) adoProjectName: $(System.TeamProject) - adoRepositoryName: $(AdoRepositoryName) \ No newline at end of file + adoRepositoryName: $(AdoRepositoryName) + entraIdTenantId: $(AzureTenantId) + entraIdClientId: $(AzureClientId) + entraIdClientSecret: $(AzureClientSecret) \ No newline at end of file diff --git a/Website/auth.config.ts b/Website/auth.config.ts index 14f12db..3e26961 100644 --- a/Website/auth.config.ts +++ b/Website/auth.config.ts @@ -4,11 +4,11 @@ import MicrosoftEntraID from 'next-auth/providers/microsoft-entra-id'; export const authConfig = { providers: [ MicrosoftEntraID({ - clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, - clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + clientId: process.env.AZURE_AD_CLIENT_ID, + clientSecret: process.env.AZURE_AD_CLIENT_SECRET, issuer: process.env.AZURE_TENANT_ID ? `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0` - : process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER, + : undefined, }), ], pages: { From 373a46d4c04c028f12a8a1d418763c09c75fe055 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 15:40:29 +0100 Subject: [PATCH 09/20] feat: UI indication for slower Entra login --- Website/components/loginview/LoginView.tsx | 161 +++++++++++++-------- 1 file changed, 97 insertions(+), 64 deletions(-) diff --git a/Website/components/loginview/LoginView.tsx b/Website/components/loginview/LoginView.tsx index 77dc6eb..c6ab030 100644 --- a/Website/components/loginview/LoginView.tsx +++ b/Website/components/loginview/LoginView.tsx @@ -2,7 +2,7 @@ import React, { FormEvent, useEffect, useState } from 'react' import LoadingOverlay from '../shared/LoadingOverlay' import { useLoading } from '@/hooks/useLoading' import { useAuth } from '@/contexts/AuthContext' -import { Alert, Box, Button, Container, Divider, FormControl, IconButton, InputAdornment, InputLabel, OutlinedInput, Typography, CircularProgress } from '@mui/material' +import { Alert, Box, Button, Container, Divider, FormControl, IconButton, InputAdornment, InputLabel, OutlinedInput, Typography, CircularProgress, Skeleton } from '@mui/material' import { Info, Visibility, VisibilityOff, Warning } from '@mui/icons-material' import { createSession } from '@/lib/session' import { LastSynched } from '@/stubs/Data' @@ -31,6 +31,8 @@ const LoginView = ({ }: LoginViewProps) => { const [animateError, setAnimateError] = useState(false); const [entraIdEnabled, setEntraIdEnabled] = useState(false); const [passwordAuthDisabled, setPasswordAuthDisabled] = useState(false); + const [isLoadingConfig, setIsLoadingConfig] = useState(true); + const [isEntraIdAuthenticating, setIsEntraIdAuthenticating] = useState(false); useEffect(() => { fetch('/api/version') @@ -41,14 +43,21 @@ const LoginView = ({ }: LoginViewProps) => { setPasswordAuthDisabled(data.passwordAuthDisabled || false); }) .catch(() => setVersion('Unknown')) + .finally(() => setIsLoadingConfig(false)); }, []); const handleClickShowPassword = () => setShowPassword((show) => !show); const handleEntraIdLogin = async () => { - // Use NextAuth signIn - const { signIn } = await import('next-auth/react'); - await signIn('microsoft-entra-id', { callbackUrl: '/' }); + setIsEntraIdAuthenticating(true); + try { + // Use NextAuth signIn + const { signIn } = await import('next-auth/react'); + await signIn('microsoft-entra-id', { callbackUrl: '/' }); + } catch (error) { + console.error('EntraID login error:', error); + setIsEntraIdAuthenticating(false); + } }; async function handleSubmit(event: FormEvent) { @@ -131,71 +140,95 @@ const LoginView = ({ }: LoginViewProps) => {
)} - {entraIdEnabled && ( + {isLoadingConfig ? ( <> - - {!passwordAuthDisabled && ( - - OR - - )} + + + OR + + ) : ( + entraIdEnabled && ( + <> + + {!passwordAuthDisabled && ( + + OR + + )} + + ) )} - {!passwordAuthDisabled && ( -
- - Password - + + + + ) : ( + !passwordAuthDisabled && ( + + + Password + + + {showPassword ? : } + + + } + label="Password" + /> + + - + className='rounded-lg' + startIcon={isAuthenticating ? : undefined} + > + {isAuthenticating ? 'Signing In...' : 'Sign In'} + + + ) )} From ec1f11bc87af466c6c808405626855d5918d2b4b Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 16:06:52 +0100 Subject: [PATCH 10/20] feat: Entra security group required authentication --- Setup/azure-pipelines-deploy-jobs.yml | 12 ++++++ Setup/azure-pipelines-external.yml | 6 ++- Website/auth.config.ts | 44 ++++++++++++++++++++++ Website/auth.ts | 25 ++++++++---- Website/components/loginview/LoginView.tsx | 23 ++++++++++- 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/Setup/azure-pipelines-deploy-jobs.yml b/Setup/azure-pipelines-deploy-jobs.yml index 9a0683b..db6d68d 100644 --- a/Setup/azure-pipelines-deploy-jobs.yml +++ b/Setup/azure-pipelines-deploy-jobs.yml @@ -41,6 +41,15 @@ parameters: - name: entraIdClientSecret type: string default: '' + - name: enableEntraIdAuth + type: boolean + default: false + - name: disablePasswordAuth + type: boolean + default: false + - name: entraIdAllowedGroups + type: string + default: '' steps: - task: AzureCLI@2 @@ -66,6 +75,9 @@ steps: --parameters entraIdTenantId="${{ parameters.entraIdTenantId }}" ` --parameters entraIdClientId="${{ parameters.entraIdClientId }}" ` --parameters entraIdClientSecret="${{ parameters.entraIdClientSecret }}" ` + --parameters enableEntraIdAuth=${{ parameters.enableEntraIdAuth }} ` + --parameters disablePasswordAuth=${{ parameters.disablePasswordAuth }} ` + --parameters entraIdAllowedGroups="${{ parameters.entraIdAllowedGroups }}" ` | ConvertFrom-Json # Extract outputs diff --git a/Setup/azure-pipelines-external.yml b/Setup/azure-pipelines-external.yml index 8c017fd..469995e 100644 --- a/Setup/azure-pipelines-external.yml +++ b/Setup/azure-pipelines-external.yml @@ -23,6 +23,7 @@ # - AdoWikiName # - AdoWikiPagePath # - AdoRepositoryName +# - EntraIdAllowedGroups trigger: none pr: none @@ -95,6 +96,9 @@ stages: adoOrganizationUrl: $(System.CollectionUri) adoProjectName: $(System.TeamProject) adoRepositoryName: $(AdoRepositoryName) + enableEntraIdAuth: $(EnableEntraIdAuth) entraIdTenantId: $(AzureTenantId) entraIdClientId: $(AzureClientId) - entraIdClientSecret: $(AzureClientSecret) \ No newline at end of file + entraIdClientSecret: $(AzureClientSecret) + entraIdAllowedGroups: $(EntraIdAllowedGroups) + disablePasswordAuth: $(DisablePasswordAuth) \ No newline at end of file diff --git a/Website/auth.config.ts b/Website/auth.config.ts index 3e26961..30bbe00 100644 --- a/Website/auth.config.ts +++ b/Website/auth.config.ts @@ -9,10 +9,54 @@ export const authConfig = { issuer: process.env.AZURE_TENANT_ID ? `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0` : undefined, + authorization: { + params: { + scope: 'openid profile email User.Read', + }, + }, + async profile(profile, tokens) { + // Only fetch groups if group-based access control is configured + let groups: string[] = []; + const allowedGroups = process.env.ENTRAID_ALLOWED_GROUPS || ''; + const hasGroupRestrictions = allowedGroups.trim().length > 0; + + if (hasGroupRestrictions && tokens.access_token) { + try { + const response = await fetch('https://graph.microsoft.com/v1.0/me/memberOf', { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + // Extract group Object IDs + groups = data.value + .filter((item: any) => item['@odata.type'] === '#microsoft.graph.group') + .map((group: any) => group.id); + } else { + console.error('Failed to fetch groups:', response.status, response.statusText); + } + } catch (error) { + console.error('Error fetching groups:', error); + } + } + + return { + id: profile.sub || profile.oid, + name: profile.name, + email: profile.email || profile.preferred_username, + groups, + oid: profile.oid, + tid: profile.tid, + preferred_username: profile.preferred_username, + }; + }, }), ], pages: { signIn: '/login', + error: '/login', }, callbacks: { authorized({ auth, request: { nextUrl } }) { diff --git a/Website/auth.ts b/Website/auth.ts index 2de88a2..1f61b8b 100644 --- a/Website/auth.ts +++ b/Website/auth.ts @@ -8,23 +8,34 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ callbacks: { ...authConfig.callbacks, async signIn({ user, account, profile }) { - // Create custom session cookie for EntraID users + // Validate EntraID users before creating session if (account?.provider === 'microsoft-entra-id' && profile) { + // Groups are now fetched via the profile callback and available in user object + const userGroups = (user as any).groups || (profile as any).groups || []; + const allowedGroups = getEntraIdAllowedGroups(); + + // If groups are configured, validate access + if (allowedGroups.length > 0) { + const hasAccess = userGroups.some((group: string) => allowedGroups.includes(group)); + if (!hasAccess) return false; + } + + // Create custom session cookie only after group validation passes await createEntraIdSession({ - userPrincipalName: (profile as any).email || (profile as any).preferred_username || user.email || '', - userId: (profile as any).oid || user.id || '', - name: user.name || (profile as any).name || '', - groups: (profile as any).groups || [] + userPrincipalName: (user as any).preferred_username || user.email || '', + userId: (user as any).oid || user.id || '', + name: user.name || '', + groups: userGroups }); } return true; }, - async jwt({ token, account, profile }) { + async jwt({ token, account, profile, user }) { // On initial sign in, add custom claims if (account && profile) { token.tenantId = (profile as any).tid; - token.groups = (profile as any).groups || []; + token.groups = (user as any)?.groups || (profile as any).groups || []; token.userId = (profile as any).oid; } return token; diff --git a/Website/components/loginview/LoginView.tsx b/Website/components/loginview/LoginView.tsx index c6ab030..9be3e01 100644 --- a/Website/components/loginview/LoginView.tsx +++ b/Website/components/loginview/LoginView.tsx @@ -6,7 +6,7 @@ import { Alert, Box, Button, Container, Divider, FormControl, IconButton, InputA import { Info, Visibility, VisibilityOff, Warning } from '@mui/icons-material' import { createSession } from '@/lib/session' import { LastSynched } from '@/stubs/Data' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' interface LoginViewProps { @@ -15,6 +15,7 @@ interface LoginViewProps { const LoginView = ({ }: LoginViewProps) => { const router = useRouter(); + const searchParams = useSearchParams(); const { setAuthenticated } = useAuth(); const { isAuthenticating, @@ -33,6 +34,16 @@ const LoginView = ({ }: LoginViewProps) => { const [passwordAuthDisabled, setPasswordAuthDisabled] = useState(false); const [isLoadingConfig, setIsLoadingConfig] = useState(true); const [isEntraIdAuthenticating, setIsEntraIdAuthenticating] = useState(false); + const [authError, setAuthError] = useState(null); + + useEffect(() => { + // Check for authentication errors in URL + const error = searchParams.get('error'); + if (error) { + setAuthError('Access denied. You are not a member of an authorized security group or not in a correct tenant.'); + setIsEntraIdAuthenticating(false); + } + }, [searchParams]); useEffect(() => { fetch('/api/version') @@ -139,6 +150,16 @@ const LoginView = ({ }: LoginViewProps) => { The password is incorrect. )} + {authError && ( + } + severity="error" + className="w-full rounded-lg mt-4" + onClose={() => setAuthError(null)} + > + {authError} + + )} {isLoadingConfig ? ( <> From 4a60b3ec8265e032bcc09cbceeab029d909eb897 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 16:11:24 +0100 Subject: [PATCH 11/20] feat: carousel item for home page. --- Website/components/homeview/HomeView.tsx | 7 +++++++ Website/public/MSAuthentication.jpg | Bin 0 -> 133338 bytes 2 files changed, 7 insertions(+) create mode 100644 Website/public/MSAuthentication.jpg diff --git a/Website/components/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx index 2d0c3a6..ad93f3a 100644 --- a/Website/components/homeview/HomeView.tsx +++ b/Website/components/homeview/HomeView.tsx @@ -23,6 +23,13 @@ export const HomeView = ({ }: IHomeViewProps) => { // Carousel data const carouselItems: CarouselItem[] = [ + { + image: '/MSAuthentication.jpg', + title: 'Microsoft Entra ID Authentication!', + text: 'Enhanced security with Microsoft Entra ID (Azure AD) authentication. Support for group-based access control, allowing administrators to restrict access to specific security groups. Seamlessly integrates with your organization\'s identity provider for secure single sign-on (SSO).', + type: '(v2.3.0) Security Feature', + actionlabel: 'Learn More' + }, { image: '/processes.jpg', title: 'Implicit data!', diff --git a/Website/public/MSAuthentication.jpg b/Website/public/MSAuthentication.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a7450178e50770c171854b746fd4565e1f733f5 GIT binary patch literal 133338 zcmYhCV{~T0wytA!Y}+WrIdkqi(1itM-8qD^O z1iU<-d@lhB3&`_M@<8zJ@Kpauv>o6X&<}X=zVl4g?f=TZ>J8%`{iOOP zdg#9lum#}s0Ra;4Oi#OefCGTVcl*1-C@{MG>R05N!;87iZ$fb-r^x)7B{LsfR@qGUn@@V7t8PBY7=`Q5|j~Ycl4ZkD@X3eke3! zeQh}@%p22$%P|nkx@90`?>tfZRNrUpg+RP@XK^prfTIg1-L7pGNbm(oystMZ+>Ypf z%RC7=td9t=^V?!HZ)+th4<*bb$4bXzm|uoYrq%pd zUF9G}Vb)xB57(hW-FQnv@}TB%7sxq+K2!(kX(NHgaWv>YAG;9uupHMT@gcSD@Fh6F zHnyJS+UXnJsxL{RKw6EjK2iVU56*b_k@0bFDTD7qeY)dB$gyG<`h_ZyftigUO0e0* zx!w2o_F1WI?{)Kqw?LfoA$c=LiO;#wWpBX3@}559@VZy+ibnD|bo=9|F=kN*lq|H9 zek0kP_sJtCO%vRkH7M5Mue*zza98u7%b?5gQIS0OWjioN$FeFu<4@D&O{6e-3O*!` z1i=T>VzoDVOqmS2V6Deq{6Roh{$AF$EBiaPlEX!EfsEaD3A3a(lPJ>4_dY?|_p6^E zc85sh;#utWUaGv-*Kg9{ttggx*hILKa9qJ**tk@hA?`2#bY65%G3@7;t)7m$)h}9O zEGorXJU#?Mj_OjV?;}$h3P9}lNh`&e)LrBY*3+Es7DE?zTT`AU4oe^p**Ww8ERh9S zjRsU(m3#P&Y1~vp$Uc`@h6U!x(2Q8E@zzkEx$y5Rg?F}3y;6R+7wH*9JFixpz4B4y z3s^82y=#j5ycs~wbS>V`Jjx~t8S`{5m+S|*wCTa&vkiOk11HZdRI=< z#2mW-s6+6)RO+P)7xJuJ)jxW}$acZ8dr;Y>dBYn&mEJWXDb5Vbh6zYZJA>^w45Ot|FWloSK_v0?1Sh& ziNi)&fyCYlhB+u&hY2P-FT^LhnB>ZqhXlgty*39x6uM_#B2QS^7iY+w>Cpu`f)+Oa z;u|yVyoS%1F>OXRgFg{Qaa&a4W2}Hd6u*!8NpJ^Oc->4d5#F?1b0FSqa{NLbLT$6& zm?h^IetYw)cq>$1Cc&MO>IyyUNGT6)O7iqf z(n|&Pz=o(C%g?lr(I5aK`61k*dZ{x!Wb3Tt2y4b6doK6)2W3ReEHjEE{JkgA%2_jV zSY*|$JGFE@SluJoUHPSiA(1EKp_hEiT7fSgFX^o^E3(2;yU2ZReiR;H(-4sTZ9kR47B`VE7a>^hcE!ptFW4RB|NL3e;Qtt}NKhLx!ltY{1lm6080^s$Jp+ zwyY&Aj{kmptU-Y(9Mk^W4Ix;_e|mQxfWRh7@W*f^Q5hH>d5D|=1l@K3T%zdGD0_x8 zBL)6bwSV$}yIG3+mJ3d80g9%(i4imLE)F{(0<;qSTvFcTfGN@QOba4ON924G&4myy zfarRnt|-Hadjy{DOB9Gx-}9?aTd%Vs)g`V;bV7XZnfxHjiKNRa)rhA|LH>SCC9Eot zjT|j>lre`ytt4eQM~Ex*S78ukI+m|5di+UbrMOWkJ&;GXsa(b2!zi@&GK~5H`reI^ zaEPV4q)&&MJ)_=>D4=so$8sE(Y+h*BTq#|Rw6zdV$kEsC7}RNsFDvFaY+p5qgo~kZ zB)agM{JyX!>jH!=(%F7Y1FO=pCJ<(w@w3z;y@|r+^O}_1OztgQ$vfD@qT1iO36*n7 zFXN%9B0K95omg%N(UiWm^fCuzUbRpy7ia$-GzRX$obtM!w0WptbcdP#rP5dMA1s@Lv{B*HK@j*2ckFgA zvQahCAlq|{>2}_AbV48j*uMjL^!`o!UrY#qNDn@}pC-A|678gifG~Rw{Qltjj=?ou z!tm(@euMMxLHYgixXd3GV2|}OsZ9Q0+O`F(5}OZ`F_O{GAjX zvWyb<68y&s5ECQ&hT_5k z4`3^Haclqh$xwmhZC1D8iq+b!sEvt1N&nI6sE_lGJj2P~^Ll-K!7IObAd-*=wm)VH zq1d4{?|GLos~ptQCkIaWe5P-_ZvJ)9uBNY0LPYLyC=r_7{FpH_G9_U5u0eqtKhT

bM&&DqA~$_D_;Y_tRei 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ė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 V69{EG8uBL-f`3IfU;qFB001Tor?vn9 literal 0 HcmV?d00001 From e77783a6842de12b83ff3a1526c06df0eb9788af Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 16:16:35 +0100 Subject: [PATCH 12/20] fix: npm package version bump to remove critial and high vulnerabilities. --- Website/package-lock.json | 115 +++++++++++++++++++------------------- Website/package.json | 4 +- 2 files changed, 61 insertions(+), 58 deletions(-) diff --git a/Website/package-lock.json b/Website/package-lock.json index b8016ee..015eb7d 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -24,7 +24,7 @@ "html2canvas": "^1.4.1", "jose": "^5.9.6", "libavoid-js": "^0.4.5", - "next": "^15.5.3", + "next": "^15.5.9", "next-auth": "^5.0.0-beta.30", "postcss": "^8.5.6", "react": "^19.0.0", @@ -1534,9 +1534,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", - "integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1549,9 +1549,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", - "integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -1565,9 +1565,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", - "integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -1581,9 +1581,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", - "integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -1597,9 +1597,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", - "integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -1613,9 +1613,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", - "integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -1629,9 +1629,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", - "integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -1645,9 +1645,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", - "integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -1661,9 +1661,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", - "integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -2728,10 +2728,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3163,10 +3164,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5669,10 +5671,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5780,12 +5783,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -6268,9 +6271,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -6891,12 +6894,12 @@ "dev": true }, "node_modules/next": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", - "integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.3", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -6909,14 +6912,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.3", - "@next/swc-darwin-x64": "15.5.3", - "@next/swc-linux-arm64-gnu": "15.5.3", - "@next/swc-linux-arm64-musl": "15.5.3", - "@next/swc-linux-x64-gnu": "15.5.3", - "@next/swc-linux-x64-musl": "15.5.3", - "@next/swc-win32-arm64-msvc": "15.5.3", - "@next/swc-win32-x64-msvc": "15.5.3", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { diff --git a/Website/package.json b/Website/package.json index b967e91..e6ee248 100644 --- a/Website/package.json +++ b/Website/package.json @@ -27,7 +27,7 @@ "html2canvas": "^1.4.1", "jose": "^5.9.6", "libavoid-js": "^0.4.5", - "next": "^15.5.3", + "next": "^15.5.9", "next-auth": "^5.0.0-beta.30", "postcss": "^8.5.6", "react": "^19.0.0", @@ -49,4 +49,4 @@ "eslint-config-next": "15.0.3", "typescript": "^5" } -} \ No newline at end of file +} From 6eef484447b422a05c88ec180db4c20fcb86cb0c Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 17:03:48 +0100 Subject: [PATCH 13/20] feat: rate limit and brute force password protection. --- Website/app/api/auth/login/route.ts | 44 +++- Website/components/loginview/LoginView.tsx | 27 ++- Website/lib/auth/entraid.ts | 45 +---- Website/lib/auth/rateLimit.ts | 225 +++++++++++++++++++++ 4 files changed, 291 insertions(+), 50 deletions(-) create mode 100644 Website/lib/auth/rateLimit.ts diff --git a/Website/app/api/auth/login/route.ts b/Website/app/api/auth/login/route.ts index 7e10007..8198594 100644 --- a/Website/app/api/auth/login/route.ts +++ b/Website/app/api/auth/login/route.ts @@ -1,14 +1,56 @@ 'use server'; import { NextResponse } from "next/server"; +import { + checkRateLimit, + recordFailedAttempt, + recordSuccessfulAttempt, + getClientIp +} from "@/lib/auth/rateLimit"; export async function POST(req: Request) { + const clientIp = getClientIp(req); + + // Check if the IP is currently rate limited + const rateLimitCheck = checkRateLimit(clientIp); + if (rateLimitCheck.isLocked) { + return NextResponse.json( + { + error: rateLimitCheck.message, + remainingTime: rateLimitCheck.remainingTime + }, + { status: 429 } // 429 Too Many Requests + ); + } + const body: { password: string } = await req.json(); const validPassword = process.env.WebsitePassword; + // Validate password if (body.password !== validPassword) { - return NextResponse.json({}, { status: 401 }); + const failureResult = recordFailedAttempt(clientIp); + + if (failureResult.isLocked) { + return NextResponse.json( + { + error: failureResult.message, + remainingTime: failureResult.remainingTime + }, + { status: 429 } + ); + } + + return NextResponse.json( + { + error: failureResult.message || "Invalid password", + attemptsRemaining: failureResult.attemptsRemaining + }, + { status: 401 } + ); } + // Successful login - reset attempt counter + recordSuccessfulAttempt(clientIp); + return NextResponse.json({}, { status: 200 }); } \ No newline at end of file diff --git a/Website/components/loginview/LoginView.tsx b/Website/components/loginview/LoginView.tsx index 9be3e01..0d67031 100644 --- a/Website/components/loginview/LoginView.tsx +++ b/Website/components/loginview/LoginView.tsx @@ -35,6 +35,8 @@ const LoginView = ({ }: LoginViewProps) => { const [isLoadingConfig, setIsLoadingConfig] = useState(true); const [isEntraIdAuthenticating, setIsEntraIdAuthenticating] = useState(false); const [authError, setAuthError] = useState(null); + const [errorMessage, setErrorMessage] = useState('The password is incorrect.'); + const [isLockedOut, setIsLockedOut] = useState(false); useEffect(() => { // Check for authentication errors in URL @@ -77,6 +79,7 @@ const LoginView = ({ }: LoginViewProps) => { startAuthentication(); setShowIncorrectPassword(false); setAnimateError(false); + setIsLockedOut(false); const formData = new FormData(event.currentTarget); const password = formData.get("password") @@ -96,6 +99,18 @@ const LoginView = ({ }: LoginViewProps) => { startRedirection(); router.push("/"); } else { + const data = await response.json(); + + if (response.status === 429) { + // Rate limited / locked out + setIsLockedOut(true); + setErrorMessage(data.error || 'Too many failed attempts. Please try again later.'); + } else if (response.status === 401) { + // Invalid password + setIsLockedOut(false); + setErrorMessage(data.error || 'The password is incorrect.'); + } + setShowIncorrectPassword(true); setTimeout(() => setAnimateError(true), 10); stopAuthentication(); @@ -103,6 +118,8 @@ const LoginView = ({ }: LoginViewProps) => { } catch (error) { console.error('Login error:', error); setShowIncorrectPassword(true); + setIsLockedOut(false); + setErrorMessage('An error occurred. Please try again.'); setTimeout(() => setAnimateError(true), 10); resetAuthState(); } @@ -141,13 +158,13 @@ const LoginView = ({ }: LoginViewProps) => { {showIncorrectPassword && ( } - severity="warning" + severity={isLockedOut ? "error" : "warning"} className={`w-full rounded-lg mt-4 transition-all duration-300 ease-out ${animateError ? 'translate-x-0 opacity-100' : 'translate-x-4 opacity-0' }`} > - The password is incorrect. + {errorMessage} )} {authError && ( @@ -219,7 +236,7 @@ const LoginView = ({ }: LoginViewProps) => { name="password" type={showPassword ? 'text' : 'password'} autoComplete='current-password' - disabled={isAuthenticating} + disabled={isAuthenticating || isLockedOut} endAdornment={ { variant="contained" color="primary" type="submit" - disabled={isAuthenticating} + disabled={isAuthenticating || isLockedOut} className='rounded-lg' startIcon={isAuthenticating ? : undefined} > - {isAuthenticating ? 'Signing In...' : 'Sign In'} + {isAuthenticating ? 'Signing In...' : isLockedOut ? 'Account Locked' : 'Sign In'} ) diff --git a/Website/lib/auth/entraid.ts b/Website/lib/auth/entraid.ts index 31d558f..5f0ab18 100644 --- a/Website/lib/auth/entraid.ts +++ b/Website/lib/auth/entraid.ts @@ -1,10 +1,3 @@ -interface EntraIdPrincipal { - auth_typ: string; // "aad" - claims: Array<{ typ: string; val: string }>; - name_typ: string; - role_typ: string; -} - export interface ParsedEntraIdUser { userPrincipalName: string; userId: string; @@ -23,40 +16,4 @@ export function isPasswordAuthDisabled(): boolean { export function getEntraIdAllowedGroups(): string[] { const groups = process.env.ENTRAID_ALLOWED_GROUPS || ''; return groups.split(',').filter(g => g.trim().length > 0); -} - -export function parseEntraIdPrincipal(principalHeader: string | null): ParsedEntraIdUser | null { - if (!principalHeader) return null; - - try { - const decoded = Buffer.from(principalHeader, 'base64').toString('utf-8'); - const principal: EntraIdPrincipal = JSON.parse(decoded); - - const getClaim = (type: string) => - principal.claims.find(c => c.typ === type)?.val || ''; - - return { - userPrincipalName: getClaim('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn') || - getClaim('preferred_username'), - userId: getClaim('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier') || - getClaim('oid'), - name: getClaim('name') || getClaim('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'), - groups: principal.claims - .filter(c => c.typ === 'groups') - .map(c => c.val) - }; - } catch (error) { - console.error('Failed to parse EntraID principal:', error); - return null; - } -} - -export function validateGroupAccess(userGroups: string[]): boolean { - const allowedGroups = getEntraIdAllowedGroups(); - - // If no groups configured, allow all authenticated users - if (allowedGroups.length === 0) return true; - - // Check if user is in at least one allowed group - return userGroups.some(group => allowedGroups.includes(group)); -} +} \ No newline at end of file diff --git a/Website/lib/auth/rateLimit.ts b/Website/lib/auth/rateLimit.ts new file mode 100644 index 0000000..61e666a --- /dev/null +++ b/Website/lib/auth/rateLimit.ts @@ -0,0 +1,225 @@ +/** + * Rate limiting and brute-force protection for login attempts + * Tracks failed login attempts by IP address with progressive lockout durations + */ + +type AttemptRecord = { + attempts: number; + lockoutUntil: Date | null; + lastAttempt: Date; + lockoutCount: number; // Track how many times they've been locked out +}; + +// In-memory store for tracking login attempts by IP address (could be replaced with a persistent store someday maybe?) +const attemptStore = new Map(); + +// Configuration constants +const MAX_ATTEMPTS = 5; +const CLEANUP_INTERVAL = 60 * 60 * 1000; + +const LOCKOUT_DURATIONS = [ + 15 * 60 * 1000, // 1st lockout: 15 minutes + 30 * 60 * 1000, // 2nd lockout: 30 minutes + 60 * 60 * 1000, // 3rd lockout: 1 hour + 2 * 60 * 60 * 1000, // 4th lockout: 2 hours + 4 * 60 * 60 * 1000, // 5th lockout: 4 hours + 8 * 60 * 60 * 1000, // 6th lockout: 8 hours + 24 * 60 * 60 * 1000, // 7th+ lockout: 24 hours +]; +function getLockoutDuration(lockoutCount: number): number { + const index = Math.min(lockoutCount, LOCKOUT_DURATIONS.length - 1); + return LOCKOUT_DURATIONS[index]; +} + +/** + * Clean up expired entries from the attempt store + */ +function cleanupExpiredEntries(): void { + const now = new Date(); + const expirationThreshold = 24 * 60 * 60 * 1000; // Remove entries older than 24 hours with no lockout + + for (const [ip, record] of attemptStore.entries()) { + // Remove if: no lockout and last attempt was more than 24 hours ago + if (!record.lockoutUntil && now.getTime() - record.lastAttempt.getTime() > expirationThreshold) { + attemptStore.delete(ip); + } + // Remove if: lockout expired more than 24 hours ago + else if (record.lockoutUntil && now.getTime() - record.lockoutUntil.getTime() > expirationThreshold) { + attemptStore.delete(ip); + } + } +} + +// Start cleanup interval +if (typeof setInterval !== 'undefined') { + setInterval(cleanupExpiredEntries, CLEANUP_INTERVAL); +} + +/** + * Get or create an attempt record for an IP address + */ +function getAttemptRecord(ip: string): AttemptRecord { + let record = attemptStore.get(ip); + + if (!record) { + record = { + attempts: 0, + lockoutUntil: null, + lastAttempt: new Date(), + lockoutCount: 0, + }; + attemptStore.set(ip, record); + } + + return record; +} + +/** + * Check if an IP address is currently locked out + * Returns the lockout info if locked, null otherwise + */ +export function checkRateLimit(ip: string): { + isLocked: boolean; + remainingTime?: number; + attemptsRemaining?: number; + message?: string; +} { + const record = getAttemptRecord(ip); + const now = new Date(); + + // Check if currently locked out + if (record.lockoutUntil && now < record.lockoutUntil) { + const remainingMs = record.lockoutUntil.getTime() - now.getTime(); + const remainingMinutes = Math.ceil(remainingMs / (60 * 1000)); + + return { + isLocked: true, + remainingTime: remainingMs, + message: `Too many failed attempts. Please try again in ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}.`, + }; + } + + // Lockout expired, reset attempts + if (record.lockoutUntil && now >= record.lockoutUntil) { + record.attempts = 0; + record.lockoutUntil = null; + } + + // Return remaining attempts + const attemptsRemaining = Math.max(0, MAX_ATTEMPTS - record.attempts); + + return { + isLocked: false, + attemptsRemaining, + }; +} + +/** + * Record a failed login attempt for an IP address + * Returns lockout info if the user is now locked out + */ +export function recordFailedAttempt(ip: string): { + isLocked: boolean; + remainingTime?: number; + attemptsRemaining?: number; + message?: string; +} { + const record = getAttemptRecord(ip); + const now = new Date(); + + // Increment attempt counter + record.attempts += 1; + record.lastAttempt = now; + + // Check if we've hit the max attempts + if (record.attempts >= MAX_ATTEMPTS) { + record.lockoutCount += 1; + const lockoutDuration = getLockoutDuration(record.lockoutCount - 1); + record.lockoutUntil = new Date(now.getTime() + lockoutDuration); + + const remainingMinutes = Math.ceil(lockoutDuration / (60 * 1000)); + + return { + isLocked: true, + remainingTime: lockoutDuration, + message: `Too many failed attempts. Account locked for ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}.`, + }; + } + + // Return remaining attempts + const attemptsRemaining = MAX_ATTEMPTS - record.attempts; + + return { + isLocked: false, + attemptsRemaining, + message: attemptsRemaining > 0 + ? `Invalid password. ${attemptsRemaining} attempt${attemptsRemaining !== 1 ? 's' : ''} remaining.` + : undefined, + }; +} + +export function recordSuccessfulAttempt(ip: string): void { + const record = getAttemptRecord(ip); + // reset on successful login + record.attempts = 0; + record.lockoutUntil = null; + record.lastAttempt = new Date(); +} + +/** + * Get client IP address from request headers + * Handles various proxy configurations + */ +export function getClientIp(request: Request): string { + const headers = request.headers; + + // Try common headers for proxied requests + const forwarded = headers.get('x-forwarded-for'); + if (forwarded) { + // x-forwarded-for can contain multiple IPs, take the first one + return forwarded.split(',')[0].trim(); + } + + const realIp = headers.get('x-real-ip'); + if (realIp) { + return realIp; + } + + const cfConnectingIp = headers.get('cf-connecting-ip'); // Cloudflare + if (cfConnectingIp) { + return cfConnectingIp; + } + + return 'unknown'; +} + +export function formatRemainingTime(milliseconds: number): string { + const minutes = Math.floor(milliseconds / (60 * 1000)); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainingHours = hours % 24; + return `${days} day${days !== 1 ? 's' : ''}${remainingHours > 0 ? ` and ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}` : ''}`; + } + + if (hours > 0) { + const remainingMinutes = minutes % 60; + return `${hours} hour${hours !== 1 ? 's' : ''}${remainingMinutes > 0 ? ` and ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}` : ''}`; + } + + return `${Math.max(1, minutes)} minute${minutes !== 1 ? 's' : ''}`; +} + +export function resetAttempts(ip: string): void { + attemptStore.delete(ip); +} + +export function getRateLimitStats() { + return { + totalTrackedIps: attemptStore.size, + lockedIps: Array.from(attemptStore.entries()) + .filter(([, record]) => record.lockoutUntil && new Date() < record.lockoutUntil) + .length, + }; +} From 68efb5a5056958b7ccd6c4549a1f1bd6a8dc20b3 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 15 Dec 2025 17:54:21 +0100 Subject: [PATCH 14/20] feat: security headers: 1. X-DNS-Prefetch-Control: on Purpose: Controls DNS prefetching for external resources Protection: Allows the browser to proactively resolve domain names in the background, improving performance while still being safe when set to "on" 2. Strict-Transport-Security: max-age=63072000; includeSubDomains; preload Purpose: Forces browsers to only connect via HTTPS Protection: Prevents man-in-the-middle attacks by ensuring all communication is encrypted Details: max-age=63072000 = 2 years includeSubDomains = applies to all subdomains too preload = allows inclusion in browser HSTS preload lists 3. X-Frame-Options: SAMEORIGIN Purpose: Controls whether your site can be embedded in iframes Protection: Prevents clickjacking attacks where attackers embed your site in a malicious iframe Details: SAMEORIGIN allows framing only from your own domain 4. X-Content-Type-Options: nosniff Purpose: Prevents browsers from MIME-type sniffing Protection: Stops browsers from interpreting files as a different MIME type than declared (e.g., executing a text file as JavaScript) Result: Reduces XSS attack surface 5. X-XSS-Protection: 1; mode=block Purpose: Enables browser's built-in XSS filter Protection: Blocks pages when cross-site scripting attacks are detected Note: Legacy header (modern browsers use CSP instead), but provides defense-in-depth for older browsers 6. Referrer-Policy: strict-origin-when-cross-origin Purpose: Controls what referrer information is sent with requests Protection: Prevents leaking sensitive information in URLs Details: Sends full URL for same-origin requests, only origin for cross-origin HTTPS requests, nothing for HTTP downgrades 7. Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=() Purpose: Controls which browser features and APIs can be used Protection: Disables unnecessary permissions that could be exploited Details: camera=() = no camera access microphone=() = no microphone access geolocation=() = no location tracking interest-cohort=() = disables FLoC tracking (privacy protection) 8. Content-Security-Policy (CSP) This is the most important and complex header. Let me break down each directive: default-src 'self' Default policy: only allow resources from your own domain script-src 'self' 'unsafe-eval' 'unsafe-inline' Scripts: Allow from your domain 'unsafe-eval': Allows eval() - needed for Next.js development/runtime 'unsafe-inline': Allows inline