diff --git a/Infrastructure/CLAUDE.md b/Infrastructure/CLAUDE.md
index d224806..1668e4e 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,249 @@ 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 **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 via OpenID Connect, 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/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: Create Client Secret
+
+**CRITICAL**: Azure App Service Easy Auth requires a client secret to avoid using deprecated implicit grant flow.
+
+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
+
+**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
+
+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 4: 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 5: 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 6: 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='' \
+ entraIdClientSecret='' \
+ 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='' \
+ entraIdClientSecret='' \
+ 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 |
+| `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 |
+
+### Authentication Mode Examples
+
+#### Example 1: Dual Mode (Password + EntraID)
+```bash
+--parameters enableEntraIdAuth=true \
+ entraIdClientId='abc123...' \
+ entraIdClientSecret='secret123...' \
+ 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...' \
+ entraIdClientSecret='secret123...' \
+ 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...' \
+ entraIdClientSecret='secret123...' \
+ 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 "AADSTS700054: response_type 'id_token' is not enabled" Error
+
+**Problem**: No client secret configured - Easy Auth is falling back to deprecated implicit grant flow
+
+**Solution**:
+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
+
+**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..0b09a1d 100644
--- a/Infrastructure/main.bicep
+++ b/Infrastructure/main.bicep
@@ -8,6 +8,25 @@ param adoOrganizationUrl string = ''
param adoProjectName string = ''
param adoRepositoryName string = ''
+@description('Enable EntraID authentication')
+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
+
+@description('Comma-separated list of Azure AD Group Object IDs allowed to access (empty = all tenant users)')
+param entraIdAllowedGroups string = ''
+
+@description('Disable password authentication')
+param disablePasswordAuth bool = false
+
var location = resourceGroup().location
@description('Create an App Service Plan')
@@ -45,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'
@@ -61,6 +88,30 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = {
name: 'ADO_REPOSITORY_NAME'
value: adoRepositoryName
}
+ {
+ 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
+ }
+ {
+ name: 'DISABLE_PASSWORD_AUTH'
+ value: string(disablePasswordAuth)
+ }
]
}
}
diff --git a/README.md b/README.md
index 2630e15..a5d1c63 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,9 @@
+> [!NOTE]
+> The README is slowly being moved to [Git Wiki](https://github.com/delegateas/DataModelViewer/wiki). Newer features are documented in the wiki whilst older ones are documented in this Readme.
+
# 👋 Introduction
diff --git a/Setup/azure-pipelines-deploy-jobs.yml b/Setup/azure-pipelines-deploy-jobs.yml
index 58cce5e..9b2bbec 100644
--- a/Setup/azure-pipelines-deploy-jobs.yml
+++ b/Setup/azure-pipelines-deploy-jobs.yml
@@ -32,6 +32,24 @@ parameters:
- name: adoRepositoryName
type: string
default: ''
+ - name: azureTenantId
+ type: string
+ default: ''
+ - name: azureClientId
+ type: string
+ default: ''
+ - name: azureClientSecret
+ type: string
+ default: ''
+ - name: enableEntraIdAuth
+ type: string
+ default: 'false'
+ - name: disablePasswordAuth
+ type: string
+ default: 'false'
+ - name: entraIdAllowedGroups
+ type: string
+ default: ''
steps:
- task: AzureCLI@2
@@ -54,6 +72,12 @@ steps:
--parameters adoOrganizationUrl="${{ parameters.adoOrganizationUrl }}" `
--parameters adoProjectName="${{ parameters.adoProjectName }}" `
--parameters adoRepositoryName="${{ parameters.adoRepositoryName }}" `
+ --parameters entraIdTenantId="${{ parameters.azureTenantId }}" `
+ --parameters entraIdClientId="${{ parameters.azureClientId }}" `
+ --parameters entraIdClientSecret="${{ parameters.azureClientSecret }}" `
+ --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 3ecd784..cc9c024 100644
--- a/Setup/azure-pipelines-external.yml
+++ b/Setup/azure-pipelines-external.yml
@@ -16,13 +16,18 @@
# - WebsitePassword
# - WebsiteSessionSecret
# - WebsiteName
-# - AzureTenantId
-# - AzureClientId
-# - AzureClientSecret
+# - AzureTenantId (for Dataverse access during build)
+# - AzureClientId (for Dataverse access during build)
+# - AzureClientSecret (for Dataverse access during build)
# - DataverseUrl
# - AdoWikiName
# - AdoWikiPagePath
# - AdoRepositoryName
+#
+# Optional variables (for Entra ID authentication):
+# - EnableEntraIdAuth (string: 'true' or 'false', default: 'false')
+# - DisablePasswordAuth (string: 'true' or 'false', default: 'false')
+# - EntraIdAllowedGroups (comma-separated list of Entra ID group object IDs)
trigger: none
pr: none
@@ -94,4 +99,10 @@ stages:
websiteName: $(WebsiteName)
adoOrganizationUrl: $(System.CollectionUri)
adoProjectName: $(System.TeamProject)
- adoRepositoryName: $(AdoRepositoryName)
\ No newline at end of file
+ adoRepositoryName: ${{ coalesce(variables.AdoRepositoryName, '') }}
+ enableEntraIdAuth: ${{ coalesce(variables.EnableEntraIdAuth, false) }}
+ azureTenantId: ${{ coalesce(variables.AzureTenantId, '') }}
+ azureClientId: ${{ coalesce(variables.AzureClientId, '') }}
+ azureClientSecret: ${{ coalesce(variables.AzureClientSecret, '') }}
+ entraIdAllowedGroups: ${{ coalesce(variables.EntraIdAllowedGroups, '') }}
+ disablePasswordAuth: ${{ coalesce(variables.DisablePasswordAuth, false) }}
\ No newline at end of file
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/[...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/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/app/api/auth/logout/route.ts b/Website/app/api/auth/logout/route.ts
index cd99ace..65b213a 100644
--- a/Website/app/api/auth/logout/route.ts
+++ b/Website/app/api/auth/logout/route.ts
@@ -1,17 +1,30 @@
'use server';
import { NextResponse } from "next/server";
-import { deleteSession } from "@/lib/session";
+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();
- // Return success response instead of redirect since we handle navigation client-side
- return NextResponse.json({ success: true });
+
+ // If EntraID, also sign out from NextAuth
+ if (wasEntraId) {
+ await signOut({ redirect: false });
+ }
+
+ 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/auth.config.ts b/Website/auth.config.ts
new file mode 100644
index 0000000..30bbe00
--- /dev/null
+++ b/Website/auth.config.ts
@@ -0,0 +1,85 @@
+import type { NextAuthConfig } from 'next-auth';
+import MicrosoftEntraID from 'next-auth/providers/microsoft-entra-id';
+
+export const authConfig = {
+ providers: [
+ MicrosoftEntraID({
+ 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`
+ : 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 } }) {
+ 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..1f61b8b
--- /dev/null
+++ b/Website/auth.ts
@@ -0,0 +1,65 @@
+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 }) {
+ // 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: (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, user }) {
+ // On initial sign in, add custom claims
+ if (account && profile) {
+ token.tenantId = (profile as any).tid;
+ token.groups = (user as any)?.groups || (profile as any).groups || [];
+ token.userId = (profile as any).oid;
+ }
+ return token;
+ },
+ async session({ 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();
+ 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/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx
index 2d0c3a6..d844f40 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!',
@@ -121,7 +128,6 @@ export const HomeView = ({ }: IHomeViewProps) => {
}
- backgroundImage={carouselItems[currentCarouselIndex]?.image}
className='h-96'
>
{
const router = useRouter();
+ const searchParams = useSearchParams();
const { setAuthenticated } = useAuth();
- const {
- isAuthenticating,
- isRedirecting,
- startAuthentication,
- startRedirection,
- stopAuthentication,
- resetAuthState
+ const {
+ isAuthenticating,
+ isRedirecting,
+ startAuthentication,
+ startRedirection,
+ stopAuthentication,
+ resetAuthState
} = useLoading();
const [showPassword, setShowPassword] = useState(false);
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);
+ 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
+ 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')
.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'))
+ .finally(() => setIsLoadingConfig(false));
}, []);
const handleClickShowPassword = () => setShowPassword((show) => !show);
+ const handleEntraIdLogin = async () => {
+ 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) {
event.preventDefault();
-
+
startAuthentication();
setShowIncorrectPassword(false);
setAnimateError(false);
+ setIsLockedOut(false);
const formData = new FormData(event.currentTarget);
const password = formData.get("password")
@@ -64,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();
@@ -71,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();
}
@@ -78,9 +127,9 @@ const LoginView = ({ }: LoginViewProps) => {
return (
-
@@ -96,7 +145,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',
@@ -107,64 +156,118 @@ const LoginView = ({ }: LoginViewProps) => {
}) : '...'}
{showIncorrectPassword && (
- }
- 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'
- }`}
+ }
+ 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}
+
+ )}
+
+ {isLoadingConfig ? (
+ <>
+
+
+ OR
+
+ >
+ ) : (
+ entraIdEnabled && (
+ <>
+ : undefined}
+ >
+ {isEntraIdAuthenticating ? (
+ 'Signing in with Microsoft...'
+ ) : (
+ <>
+ Sign in with Microsoft
+ >
+ )}
+
+ {!passwordAuthDisabled && (
+
+ OR
+
+ )}
+ >
+ )
+ )}
+
+ {isLoadingConfig ? (
+
+
+
+
+ ) : (
+ !passwordAuthDisabled && (
+
+ )
+ )}
Version: {version ?? '...'}
diff --git a/Website/components/shared/elements/Carousel.tsx b/Website/components/shared/elements/Carousel.tsx
index ff5f3bd..dba1fbc 100644
--- a/Website/components/shared/elements/Carousel.tsx
+++ b/Website/components/shared/elements/Carousel.tsx
@@ -28,13 +28,13 @@ const Carousel = ({ items, currentIndex = 0, slideDirection = null, className }:
if (currentIndex !== prevIndex && slideDirection) {
setAnimationState('sliding');
setShowPrevious(true);
-
+
const timer = setTimeout(() => {
setPrevIndex(currentIndex);
setAnimationState('idle');
setShowPrevious(false);
}, 500);
-
+
return () => clearTimeout(timer);
} else if (currentIndex !== prevIndex) {
setPrevIndex(currentIndex);
@@ -46,6 +46,36 @@ const Carousel = ({ items, currentIndex = 0, slideDirection = null, className }:
return (
+ {/* Previous background image - animating out */}
+ {showPrevious && animationState === 'sliding' && previousItem.image && (
+
+ )}
+
+ {/* Current background image - animating in or static */}
+ {currentItem.image && (
+
+ )}
+
{/* Gradient overlay for text readability */}
diff --git a/Website/contexts/AuthContext.tsx b/Website/contexts/AuthContext.tsx
index 681fcd8..4642e79 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,31 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
}));
}, []);
- const logout = useCallback(() => {
- setState({
- isAuthenticated: false,
- isLoading: false
- });
+ const logout = useCallback(async () => {
+ try {
+ // Call logout API to clear both custom session and NextAuth session
+ await fetch('/api/auth/logout', { method: 'POST' });
+
+ setState({
+ isAuthenticated: false,
+ isLoading: false,
+ authType: undefined,
+ user: undefined
+ });
+
+ // 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';
+ }
}, []);
useEffect(() => {
diff --git a/Website/lib/auth/entraid.ts b/Website/lib/auth/entraid.ts
new file mode 100644
index 0000000..5f0ab18
--- /dev/null
+++ b/Website/lib/auth/entraid.ts
@@ -0,0 +1,19 @@
+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);
+}
\ 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,
+ };
+}
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..49f725c 100644
--- a/Website/middleware.ts
+++ b/Website/middleware.ts
@@ -1,43 +1,66 @@
-import { NextResponse, NextRequest } from 'next/server'
-import { getSession } from './lib/session'
+import { auth } from './auth';
+import { NextResponse } from 'next/server';
+import { isPasswordAuthDisabled } from './lib/auth/entraid';
-export async function middleware(request: NextRequest) {
- const session = await getSession();
- const isAuthenticated = session !== null;
+export default auth((req) => {
+ const { auth: session } = req;
+ const isLoggedIn = !!session?.user;
+ const { pathname } = req.nextUrl;
- // If the user is authenticated, continue as normal
- if (isAuthenticated) {
- return NextResponse.next()
- }
+ // Allow access to login page
+ if (pathname === '/login') {
+ return NextResponse.next();
+ }
+
+ // Allow access to auth API routes
+ if (pathname.startsWith('/api/auth')) {
+ return NextResponse.next();
+ }
- // Allow access to public API endpoints without authentication
- const publicApiEndpoints = ['/api/auth/login', '/api/version'];
- if (publicApiEndpoints.includes(request.nextUrl.pathname)) {
- return NextResponse.next()
+ // Allow access to version API
+ if (pathname === '/api/version') {
+ return NextResponse.next();
+ }
+
+ // 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();
}
+ }
- // For API routes, return 401 Unauthorized
- if (request.nextUrl.pathname.startsWith('/api')) {
- return NextResponse.json(
- { error: 'Unauthorized' },
- { status: 401 }
- )
+ // For API routes, return 401
+ if (pathname.startsWith('/api')) {
+ if (!isLoggedIn) {
+ 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
- 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/next.config.ts b/Website/next.config.ts
index 12c8b4e..4cda934 100644
--- a/Website/next.config.ts
+++ b/Website/next.config.ts
@@ -7,6 +7,47 @@ const nextConfig = {
turbopack: {
root: path.join(__dirname, '..'),
},
+ async headers() {
+ return [
+ {
+ source: '/:path*',
+ headers: [
+ {
+ key: 'X-DNS-Prefetch-Control',
+ value: 'on'
+ },
+ {
+ key: 'Strict-Transport-Security',
+ value: 'max-age=63072000; includeSubDomains; preload'
+ },
+ {
+ key: 'X-Frame-Options',
+ value: 'SAMEORIGIN'
+ },
+ {
+ key: 'X-Content-Type-Options',
+ value: 'nosniff'
+ },
+ {
+ key: 'X-XSS-Protection',
+ value: '1; mode=block'
+ },
+ {
+ key: 'Referrer-Policy',
+ value: 'strict-origin-when-cross-origin'
+ },
+ {
+ key: 'Permissions-Policy',
+ value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()'
+ },
+ {
+ key: 'Content-Security-Policy',
+ value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
+ }
+ ],
+ },
+ ]
+ },
}
module.exports = nextConfig
\ No newline at end of file
diff --git a/Website/package-lock.json b/Website/package-lock.json
index c4a68de..015eb7d 100644
--- a/Website/package-lock.json
+++ b/Website/package-lock.json
@@ -24,7 +24,8 @@
"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",
"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",
@@ -1495,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": {
@@ -1510,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"
],
@@ -1526,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"
],
@@ -1542,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"
],
@@ -1558,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"
],
@@ -1574,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"
],
@@ -1590,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"
],
@@ -1606,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"
],
@@ -1622,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"
],
@@ -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",
@@ -2680,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"
}
@@ -3115,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"
@@ -5621,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"
},
@@ -5732,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"
}
},
@@ -6220,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",
@@ -6843,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",
@@ -6861,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": {
@@ -6894,6 +6945,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 +6999,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 +7325,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..e6ee248 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",
@@ -27,7 +27,8 @@
"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",
"react-dom": "^19.0.0",
diff --git a/Website/public/MSAuthentication.jpg b/Website/public/MSAuthentication.jpg
new file mode 100644
index 0000000..7a74501
Binary files /dev/null and b/Website/public/MSAuthentication.jpg differ
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