diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json
index a039ec681c..0127dc144c 100644
--- a/backend/Config/openapi.json
+++ b/backend/Config/openapi.json
@@ -18600,6 +18600,9 @@
}
}
},
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
"401": {
"description": "Unauthorized - invalid or missing bearer token"
},
@@ -30170,6 +30173,53 @@
"x-cipp-role": "CIPP.SuperAdmin.ReadWrite"
}
},
+ "/api/ExecSamSecretStatus": {
+ "post": {
+ "summary": "Reports whether the stored SAM application secret is usable yet.",
+ "operationId": "ExecSamSecretStatus",
+ "tags": [
+ "CIPP > Setup"
+ ],
+ "description": "The setup wizard creates a client secret on one step and uses it on the next, but Entra\ncan take several minutes to replicate a newly created secret. Until it has, every token\nrequest fails with AADSTS7000215 even though the value CIPP holds is correct. This lets\nthe wizard wait on that instead of failing the user after they have already signed in.",
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "CIPP.AppSettings.ReadWrite",
+ "x-cipp-any-tenant": true
+ }
+ },
"/api/ExecScheduleForwardingVacation": {
"post": {
"summary": "ExecScheduleForwardingVacation",
diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1
index cf8db7a27a..747c355edb 100644
--- a/backend/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1
+++ b/backend/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1
@@ -11,29 +11,39 @@ function New-DeviceLogin {
[string]$device_code,
[string]$TenantId
)
+
+ # The device code request and the token poll have to agree on both authority and scope.
+ # The authority previously diverged - the poll was hard-coded to /organizations while the
+ # device code request honoured -TenantId - which silently breaks any tenant-scoped login.
+ $Authority = if ($TenantId) { $TenantId } else { 'organizations' }
+
+ # Callers vary in whether they already include the OIDC scopes, so union them in rather
+ # than appending unconditionally, which sent them twice on the wire.
+ $ScopeList = [System.Collections.Generic.List[string]]@($scope -split '\s+' | Where-Object { $_ })
+ foreach ($RequiredScope in @('offline_access', 'profile', 'openid')) {
+ if (-not $ScopeList.Contains($RequiredScope)) { $ScopeList.Add($RequiredScope) }
+ }
+ $RequestScope = $ScopeList -join ' '
+
if ($FirstLogon) {
$Body = @{
client_id = $Clientid
- scope = "$scope offline_access profile openid"
- }
- if ($TenantID) {
- $ReturnCode = Invoke-CIPPRestMethod -Uri "https://login.microsoftonline.com/$($TenantID)/oauth2/v2.0/devicecode" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded'
- } else {
- $ReturnCode = Invoke-CIPPRestMethod -Uri 'https://login.microsoftonline.com/organizations/oauth2/v2.0/devicecode' -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded'
+ scope = $RequestScope
}
+ $ReturnCode = Invoke-CIPPRestMethod -Uri "https://login.microsoftonline.com/$Authority/oauth2/v2.0/devicecode" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded'
} else {
$Body = @{
client_id = $Clientid
- scope = "$scope offline_access profile openid"
+ scope = $RequestScope
grant_type = 'device_code'
device_code = $device_code
}
- $Checking = Invoke-CIPPRestMethod -SkipHttpErrorCheck -Uri 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token' -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded'
- if ($checking.refresh_token) {
- $ReturnCode = $Checking
- } else {
- $returncode = $Checking.error
- }
+ # Return the whole response, success or not. Collapsing failures to $Checking.error
+ # threw away error_description, which is where the AADSTS code lives - so a device
+ # code sign-in blocked by security defaults or a Conditional Access authentication
+ # flows policy surfaced as an unexplained failure. Callers distinguish the two cases
+ # by testing for refresh_token.
+ $ReturnCode = Invoke-CIPPRestMethod -SkipHttpErrorCheck -Uri "https://login.microsoftonline.com/$Authority/oauth2/v2.0/token" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded'
}
return $ReturnCode
}
diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1
index 30e9eccbd0..6fa6250206 100644
--- a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1
+++ b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1
@@ -88,6 +88,32 @@ function Test-CIPPAccessPermissions {
}
) | Out-Null
}
+
+ # Entra records the flow a refresh token was originally obtained through, and
+ # Conditional Access re-evaluates it on every redemption - including the per-tenant
+ # redemptions CIPP makes for GDAP. So a token family that began as a device code
+ # login keeps tripping device code flow blocks (security defaults now enforces one)
+ # in every customer tenant that has one, surfacing as a Conditional Access error on
+ # ordinary Graph calls. The weekly token rotation cannot clear it: rotation reuses
+ # the original authentication context rather than re-authenticating. Only a fresh
+ # authorization code sign-in mints a clean family.
+ # No access token claim records this, so read it from the partner tenant's
+ # non-interactive sign-ins, which are the redemptions themselves.
+ try {
+ $SignInFilter = "appId eq '$($env:ApplicationID)' and signInEventTypes/any(t: t eq 'nonInteractiveUser')"
+ $SamSignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?api-version=beta&`$filter=$SignInFilter&`$top=10&`$select=createdDateTime,originalTransferMethod,authenticationProtocol" -tenantid $env:TenantID -NoAuthCheck $true -ErrorAction Stop
+ $DeviceCodeSignIn = $SamSignIns | Where-Object { $_.originalTransferMethod -eq 'deviceCodeFlow' -or $_.authenticationProtocol -eq 'deviceCode' } | Select-Object -First 1
+ if ($DeviceCodeSignIn) {
+ $ErrorMessages.Add('Your refresh token originated from a device code login. Security defaults and Conditional Access authentication flow policies block that flow when the token is redeemed, which fails Graph calls in affected tenants with a Conditional Access error. Refresh your SAM tokens to sign in again - the weekly token update will not replace it.') | Out-Null
+ $Success = $false
+ } else {
+ $Messages.Add('Your refresh token did not originate from a device code login.') | Out-Null
+ }
+ } catch {
+ # Reading sign-in logs needs AuditLog.Read.All and an Entra ID P1 licence. Not
+ # having either is not an access check failure, it just leaves this unknown.
+ $Messages.Add('Could not determine whether your refresh token originated from a device code login. Reading sign-in logs requires AuditLog.Read.All and an Entra ID P1 license.') | Out-Null
+ }
}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1
index 69b1659238..ad3b10fbff 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1
@@ -16,40 +16,100 @@ function Invoke-ExecDeviceCodeLogon {
if (!$scope) {
$scope = 'https://graph.microsoft.com/.default'
}
+
+ # clientId and scope arrive straight off the query string and this endpoint hands back
+ # a usable token, so without these guards it brokers a device code login for any
+ # application in the operator's tenant. Restrict it to the clients the setup wizard
+ # actually drives: Azure PowerShell (used to create the SAM app before one exists),
+ # Graph Explorer (the component's fallback when no app id is known yet), and CIPP's
+ # own SAM app.
+ $AllowedClientIds = [System.Collections.Generic.List[string]]@(
+ '1950a258-227b-4e31-a9cf-717495945fc2'
+ '1b730954-1685-4b74-9bfd-dac224a7b894'
+ )
+ if ($env:ApplicationID) { $AllowedClientIds.Add($env:ApplicationID) }
+
+ if ($clientId -notin $AllowedClientIds) {
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::BadRequest
+ Body = @{
+ error = 'unsupported_client'
+ error_description = "The client id '$clientId' is not permitted for device code logon."
+ } | ConvertTo-Json
+ Headers = @{'Content-Type' = 'application/json' }
+ })
+ }
+
+ $InvalidScopes = @($scope -split '\s+' | Where-Object {
+ $_ -and $_ -notin @('openid', 'profile', 'email', 'offline_access') -and $_ -notlike 'https://graph.microsoft.com/*'
+ })
+ if ($InvalidScopes.Count -gt 0) {
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::BadRequest
+ Body = @{
+ error = 'invalid_scope'
+ error_description = "Unsupported scope(s) for device code logon: $($InvalidScopes -join ', ')"
+ } | ConvertTo-Json
+ Headers = @{'Content-Type' = 'application/json' }
+ })
+ }
+
if ($Request.Query.operation -eq 'getDeviceCode') {
$deviceCodeInfo = New-DeviceLogin -clientid $clientId -scope $scope -FirstLogon -TenantId $tenantId
- $Results = @{
- user_code = $deviceCodeInfo.user_code
- device_code = $deviceCodeInfo.device_code
- verification_uri = $deviceCodeInfo.verification_uri
- expires_in = $deviceCodeInfo.expires_in
- interval = $deviceCodeInfo.interval
- message = $deviceCodeInfo.message
+ if ($deviceCodeInfo.user_code) {
+ $Results = @{
+ user_code = $deviceCodeInfo.user_code
+ device_code = $deviceCodeInfo.device_code
+ verification_uri = $deviceCodeInfo.verification_uri
+ expires_in = $deviceCodeInfo.expires_in
+ interval = $deviceCodeInfo.interval
+ message = $deviceCodeInfo.message
+ }
+ } else {
+ $Results = @{
+ error = $deviceCodeInfo.error ?? 'device_code_error'
+ error_description = $deviceCodeInfo.error_description ?? 'Failed to request a device code.'
+ }
}
} elseif ($Request.Query.operation -eq 'checkToken') {
- $tokenInfo = New-DeviceLogin -clientid $clientId -scope $scope -device_code $deviceCode
+ $tokenInfo = New-DeviceLogin -clientid $clientId -scope $scope -device_code $deviceCode -TenantId $tenantId
if ($tokenInfo.refresh_token) {
+ # The refresh token is deliberately not returned. Nothing downstream consumes it -
+ # the wizard only forwards the access token to ExecCreateSamApp - and returning it
+ # would put a long-lived credential into the browser for no reason. The token CIPP
+ # runs on is minted by the authorization code step instead.
$Results = @{
status = 'success'
access_token = $tokenInfo.access_token
- refresh_token = $tokenInfo.refresh_token
id_token = $tokenInfo.id_token
expires_in = $tokenInfo.expires_in
ext_expires_in = $tokenInfo.ext_expires_in
}
} else {
+ # Only authorization_pending and slow_down mean "keep polling". Reporting every
+ # failure as pending made terminal errors - an expired code, a declined consent,
+ # or a device code flow block from security defaults or a Conditional Access
+ # authentication flows policy - look like the user simply had not finished
+ # signing in, so the wizard span until the code expired with nothing to show.
+ $Status = switch ($tokenInfo.error) {
+ 'authorization_pending' { 'pending' }
+ 'slow_down' { 'slow_down' }
+ default { 'error' }
+ }
$Results = @{
- status = 'pending'
+ status = $Status
error = $tokenInfo.error
error_description = $tokenInfo.error_description
}
}
}
} catch {
+ # ErrorDetails carries the response body from the token endpoint, which is where the
+ # AADSTS code lives; the exception message on its own is just the status line.
$Results = @{
error = 'server_error'
- error_description = "An error occurred: $($_.Exception.Message)"
+ error_description = "An error occurred: $($_.ErrorDetails.Message ?? $_.Exception.Message)"
}
}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1
new file mode 100644
index 0000000000..06cdc3c424
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1
@@ -0,0 +1,76 @@
+function Invoke-ExecSamSecretStatus {
+ <#
+ .FUNCTIONALITY
+ Entrypoint,AnyTenant
+ .ROLE
+ CIPP.AppSettings.ReadWrite
+ .SYNOPSIS
+ Reports whether the stored SAM application secret is usable yet.
+ .DESCRIPTION
+ The setup wizard creates a client secret on one step and uses it on the next, but Entra
+ can take several minutes to replicate a newly created secret. Until it has, every token
+ request fails with AADSTS7000215 even though the value CIPP holds is correct. This lets
+ the wizard wait on that instead of failing the user after they have already signed in.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ try {
+ $null = Get-CIPPAuthentication
+
+ # Same placeholder set the deployment template seeds and Get-CIPPAuthentication skips.
+ $PlaceholderPattern = '^(LongApplicationId|AppSecret|RefreshToken|tenantId)$'
+ $Configured = $env:ApplicationID -and $env:ApplicationID -notmatch $PlaceholderPattern -and
+ $env:ApplicationSecret -and $env:ApplicationSecret -notmatch $PlaceholderPattern -and
+ $env:TenantID -and $env:TenantID -notmatch $PlaceholderPattern
+
+ if (-not $Configured) {
+ $Results = @{
+ ready = $false
+ reason = 'notConfigured'
+ message = 'The application registration has not been created yet. Complete the application step first.'
+ }
+ } else {
+ # A client credentials request is the cheapest way to ask Entra whether the secret is
+ # live, and going direct deliberately bypasses CIPP's token cache - a token cached
+ # before the secret was replaced would report ready when it is not. The authority has
+ # to be tenant scoped; /common and /organizations do not accept this grant.
+ $Body = @{
+ client_id = $env:ApplicationID
+ client_secret = $env:ApplicationSecret
+ scope = 'https://graph.microsoft.com/.default'
+ grant_type = 'client_credentials'
+ }
+ $Response = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$($env:TenantID)/oauth2/v2.0/token" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck
+
+ if ($Response.access_token) {
+ $Results = @{ ready = $true; reason = 'ready' }
+ } elseif ($Response.error_description -match 'AADSTS7000215') {
+ $Results = @{
+ ready = $false
+ reason = 'propagating'
+ message = 'The application secret has been created but Microsoft has not finished activating it. This usually takes a few minutes and needs nothing recreating.'
+ }
+ } else {
+ # Anything else is a real problem - a deleted secret, a disabled application -
+ # and must not be presented as something waiting will fix.
+ $Results = @{
+ ready = $false
+ reason = 'error'
+ message = $Response.error_description ?? 'The application secret could not be validated.'
+ }
+ }
+ }
+ } catch {
+ $Results = @{
+ ready = $false
+ reason = 'error'
+ message = (Get-CippException -Exception $_).NormalizedError
+ }
+ }
+
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ Body = $Results
+ })
+}
diff --git a/frontend/src/components/CippComponents/CIPPDeviceCodeButton.js b/frontend/src/components/CippComponents/CIPPDeviceCodeButton.js
deleted file mode 100644
index 16711f8d13..0000000000
--- a/frontend/src/components/CippComponents/CIPPDeviceCodeButton.js
+++ /dev/null
@@ -1,252 +0,0 @@
-import { useState, useEffect } from "react";
-import {
- Alert,
- Button,
- Typography,
- CircularProgress,
- Box,
-} from "@mui/material";
-import { ApiGetCall } from "../../api/ApiCall";
-
-/**
- * CIPPDeviceCodeButton - A button component for Microsoft 365 OAuth authentication using device code flow
- *
- * @param {Object} props - Component props
- * @param {Function} props.onAuthSuccess - Callback function called when authentication is successful with token data
- * @param {Function} props.onAuthError - Callback function called when authentication fails with error data
- * @param {string} props.buttonText - Text to display on the button (default: "Login with Device Code")
- * @param {boolean} props.showResults - Whether to show authentication results in the component (default: true)
- * @returns {JSX.Element} The CIPPDeviceCodeButton component
- */
-export const CIPPDeviceCodeButton = ({
- onAuthSuccess,
- onAuthError,
- buttonText = "Login with Device Code",
- showResults = true,
-}) => {
- const [authInProgress, setAuthInProgress] = useState(false);
- const [authError, setAuthError] = useState(null);
- const [deviceCodeInfo, setDeviceCodeInfo] = useState(null);
- const [currentStep, setCurrentStep] = useState(0);
- const [pollInterval, setPollInterval] = useState(null);
- const [tokens, setTokens] = useState({
- accessToken: null,
- refreshToken: null,
- accessTokenExpiresOn: null,
- refreshTokenExpiresOn: null,
- username: null,
- tenantId: null,
- onmicrosoftDomain: null,
- });
-
- // Get application ID information from API
- const appIdInfo = ApiGetCall({
- url: `/api/ExecListAppId`,
- queryKey: `ExecListAppId`,
- waiting: true,
- });
-
- // Handle closing the error
- const handleCloseError = () => {
- setAuthError(null);
- };
-
- // Clear polling interval when component unmounts
- useEffect(() => {
- return () => {
- if (pollInterval) {
- clearInterval(pollInterval);
- }
- };
- }, [pollInterval]);
-
- // Start device code authentication
- const startDeviceCodeAuth = async () => {
- try {
- setAuthInProgress(true);
- setAuthError(null);
- setDeviceCodeInfo(null);
- setCurrentStep(1);
-
- // Call the API to start device code flow
- const response = await fetch(`/api/ExecSAMSetup?CreateSAM=true`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- const data = await response.json();
-
- if (response.ok && data.code) {
- // Store device code info
- setDeviceCodeInfo({
- user_code: data.code,
- verification_uri: data.url,
- expires_in: 900, // Default to 15 minutes if not provided
- });
-
- // Start polling for token
- const interval = setInterval(checkAuthStatus, 5000);
- setPollInterval(interval);
- } else {
- // Error getting device code
- setAuthError({
- errorCode: "device_code_error",
- errorMessage: data.message || "Failed to get device code",
- timestamp: new Date().toISOString(),
- });
- setAuthInProgress(false);
- if (onAuthError) onAuthError(error);
- }
- } catch (error) {
- console.error("Error starting device code authentication:", error);
- setAuthError({
- errorCode: "device_code_error",
- errorMessage: error.message || "An error occurred during device code authentication",
- timestamp: new Date().toISOString(),
- });
- setAuthInProgress(false);
- if (onAuthError) onAuthError(error);
- }
- };
-
- // Check authentication status
- const checkAuthStatus = async () => {
- try {
- // Call the API to check auth status
- const response = await fetch(`/api/ExecSAMSetup?CheckSetupProcess=true&step=1`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- const data = await response.json();
-
- if (response.ok) {
- if (data.step === 2) {
- // Authentication successful
- clearInterval(pollInterval);
- setPollInterval(null);
-
- // Process token data
- const tokenData = {
- accessToken: "Successfully authenticated",
- refreshToken: "Token stored on server",
- accessTokenExpiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour from now
- refreshTokenExpiresOn: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now
- username: "authenticated user",
- tenantId: data.tenantId || "unknown",
- onmicrosoftDomain: null,
- };
-
- // Store tokens in component state
- setTokens(tokenData);
- setDeviceCodeInfo(null);
- setCurrentStep(2);
-
- // Call the onAuthSuccess callback if provided
- if (onAuthSuccess) onAuthSuccess(tokenData);
-
- // Update UI state
- setAuthInProgress(false);
- }
- } else {
- // Error checking auth status
- clearInterval(pollInterval);
- setPollInterval(null);
-
- setAuthError({
- errorCode: "auth_status_error",
- errorMessage: data.message || "Failed to check authentication status",
- timestamp: new Date().toISOString(),
- });
- setAuthInProgress(false);
- if (onAuthError) onAuthError({
- errorCode: "auth_status_error",
- errorMessage: data.message || "Failed to check authentication status",
- timestamp: new Date().toISOString(),
- });
- }
- } catch (error) {
- console.error("Error checking auth status:", error);
- // Don't stop polling on transient errors
- }
- };
-
- return (
-
-
-
- {!appIdInfo.isLoading &&
- !appIdInfo?.data?.applicationId && (
-
- The Application ID is not valid. Please check your configuration.
-
- )
- }
-
- {showResults && (
-
- {deviceCodeInfo && authInProgress ? (
-
- Device Code Authentication
-
- To sign in, use a web browser to open the page {deviceCodeInfo.verification_uri} and enter the code {deviceCodeInfo.user_code} to authenticate.
-
-
- Code expires in {Math.round(deviceCodeInfo.expires_in / 60)} minutes
-
-
- ) : tokens.accessToken ? (
-
- Authentication Successful
-
- You've successfully refreshed your token using device code flow.
-
- {tokens.tenantId && (
-
- Tenant ID: {tokens.tenantId}
-
- )}
-
- ) : authError ? (
-
- Authentication Error: {authError.errorCode}
- {authError.errorMessage}
-
- Time: {authError.timestamp}
-
-
-
-
-
- ) : null}
-
- )}
-
- );
-};
-
-export default CIPPDeviceCodeButton;
\ No newline at end of file
diff --git a/frontend/src/components/CippComponents/CIPPM365OAuthButton.jsx b/frontend/src/components/CippComponents/CIPPM365OAuthButton.jsx
index fbc742fe8e..504aa24042 100644
--- a/frontend/src/components/CippComponents/CIPPM365OAuthButton.jsx
+++ b/frontend/src/components/CippComponents/CIPPM365OAuthButton.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import { Alert, Button, Typography, CircularProgress, Box } from '@mui/material'
import { Microsoft, Login, Refresh } from '@mui/icons-material'
import { ApiGetCall } from '../../api/ApiCall'
@@ -17,6 +17,7 @@ export const CIPPM365OAuthButton = ({
autoStartDeviceLogon = false,
validateServiceAccount = true,
promptBeforeAuth = false,
+ disabled = false,
}) => {
const [authInProgress, setAuthInProgress] = useState(false)
const [authError, setAuthError] = useState(null)
@@ -40,6 +41,86 @@ export const CIPPM365OAuthButton = ({
waiting: true,
})
+ // Closing the device login window does not cancel anything - the device code stays
+ // valid server side until it expires and can be completed in any browser. So the
+ // watcher below never stops the poll; it only tracks whether the window is gone so
+ // the UI can offer a way back in instead of sitting on a disabled "Authenticating..."
+ // button for the full 15 minutes.
+ const devicePopupRef = useRef(null)
+ const devicePopupWatcherRef = useRef(null)
+ const devicePollIdRef = useRef(0)
+ const [devicePopupClosed, setDevicePopupClosed] = useState(false)
+
+ const stopDevicePopupWatcher = () => {
+ if (devicePopupWatcherRef.current) {
+ clearInterval(devicePopupWatcherRef.current)
+ devicePopupWatcherRef.current = null
+ }
+ }
+
+ const openDeviceLoginPopup = () => {
+ const width = 500
+ const height = 600
+ const left = window.screen.width / 2 - width / 2
+ const top = window.screen.height / 2 - height / 2
+
+ const popup = window.open(
+ 'https://microsoft.com/devicelogin',
+ 'deviceLoginPopup',
+ `width=${width},height=${height},left=${left},top=${top}`
+ )
+
+ stopDevicePopupWatcher()
+ devicePopupRef.current = popup
+
+ // A blocked popup is indistinguishable from a closed one as far as the user is
+ // concerned - both leave them with no window to sign in through.
+ if (!popup) {
+ setDevicePopupClosed(true)
+ return null
+ }
+
+ setDevicePopupClosed(false)
+ devicePopupWatcherRef.current = setInterval(() => {
+ if (popup.closed) {
+ stopDevicePopupWatcher()
+ setDevicePopupClosed(true)
+ }
+ }, 1000)
+
+ return popup
+ }
+
+ const closeDeviceLoginPopup = () => {
+ stopDevicePopupWatcher()
+ const popup = devicePopupRef.current
+ if (popup && !popup.closed) {
+ popup.close()
+ }
+ devicePopupRef.current = null
+ setDevicePopupClosed(false)
+ }
+
+ useEffect(() => stopDevicePopupWatcher, [])
+
+ // Reopening the window is not offered: a user code is consumed the moment it is entered,
+ // so once someone has typed it in, re-entering the same code fails. Closing the window
+ // part way through a sign-in is therefore unrecoverable except with a fresh code. The
+ // poll is left running anyway, because the sign-in may still be getting finished at
+ // microsoft.com/devicelogin in another browser.
+ const canRestartDeviceLogin = useDeviceCode && authInProgress && devicePopupClosed
+
+ const restartDeviceLogin = async () => {
+ // Supersede the in-flight poll before requesting a new code, or it would keep
+ // polling the old device_code alongside the new one.
+ devicePollIdRef.current += 1
+ closeDeviceLoginPopup()
+ setAuthInProgress(false)
+ setAuthError(null)
+ setDeviceCodeInfo(null)
+ await retrieveDeviceCode()
+ }
+
const handleCloseError = () => {
setAuthError(null)
}
@@ -125,29 +206,24 @@ export const CIPPM365OAuthButton = ({
const appId =
applicationId || appIdInfo?.data?.applicationId || '1b730954-1685-4b74-9bfd-dac224a7b894' // Default to MS Graph Explorer app ID
- // Open popup to device login page
- const width = 500
- const height = 600
- const left = window.screen.width / 2 - width / 2
- const top = window.screen.height / 2 - height / 2
-
- const popup = window.open(
- 'https://microsoft.com/devicelogin',
- 'deviceLoginPopup',
- `width=${width},height=${height},left=${left},top=${top}`
- )
+ // Open popup to device login page. If it is closed or blocked the poll below keeps
+ // running - the button turns into "Reopen sign-in window" rather than locking up.
+ openDeviceLoginPopup()
// Start polling for token
const pollInterval = deviceCodeInfo.interval || 5
const expiresIn = deviceCodeInfo.expires_in || 900
const startTime = Date.now()
+ // Identifies this attempt. Starting over bumps the ref, which retires this poll
+ // rather than leaving it chasing a device code the user has abandoned.
+ const pollId = ++devicePollIdRef.current
const pollForToken = async () => {
+ if (devicePollIdRef.current !== pollId) return
+
// Check if we've exceeded the expiration time
if (Date.now() - startTime >= expiresIn * 1000) {
- if (popup && !popup.closed) {
- popup.close()
- }
+ closeDeviceLoginPopup()
setAuthError({
errorCode: 'timeout',
errorMessage: 'Device code authentication timed out',
@@ -158,17 +234,19 @@ export const CIPPM365OAuthButton = ({
}
try {
- // Poll for token using our API endpoint
+ // Poll for token using our API endpoint. The scope has to match the one the device
+ // code was issued for - omitting it here left the poll falling back to the API's
+ // default instead.
const tokenResponse = await fetch(
- `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}`
+ `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${
+ deviceCodeInfo.device_code
+ }&scope=${encodeURIComponent(scope)}`
)
const tokenData = await tokenResponse.json()
if (tokenResponse.ok && tokenData.status === 'success') {
// Successfully got token
- if (popup && !popup.closed) {
- popup.close()
- }
+ closeDeviceLoginPopup()
handleTokenResponse(tokenData)
} else if (
tokenData.error === 'authorization_pending' ||
@@ -181,9 +259,7 @@ export const CIPPM365OAuthButton = ({
setTimeout(pollForToken, (pollInterval + 5) * 1000)
} else {
// Other error
- if (popup && !popup.closed) {
- popup.close()
- }
+ closeDeviceLoginPopup()
setAuthError({
errorCode: tokenData.error || 'token_error',
errorMessage: tokenData.error_description || 'Failed to get token',
@@ -296,7 +372,7 @@ export const CIPPM365OAuthButton = ({
const msalConfig = {
auth: {
clientId: appId,
- authority: `https://login.microsoftonline.com/common`,
+ authority: `https://login.microsoftonline.com/organizations`,
redirectUri: `${window.location.origin}/authredirect`,
},
}
@@ -306,34 +382,44 @@ export const CIPPM365OAuthButton = ({
scopes: [scope],
}
- // Generate PKCE code verifier and challenge
- const generateCodeVerifier = () => {
- const array = new Uint8Array(32)
- window.crypto.getRandomValues(array)
- return Array.from(array, (byte) => ('0' + (byte & 0xff).toString(16)).slice(-2)).join('')
+ // crypto.subtle is only exposed in a secure context. Without this guard an instance
+ // served over plain HTTP fails on the digest below with an opaque TypeError.
+ if (!window.crypto?.subtle) {
+ const error = {
+ errorCode: 'insecure_context',
+ errorMessage:
+ 'Authentication requires a secure context. Serve CIPP over HTTPS (or localhost) and try again.',
+ timestamp: new Date().toISOString(),
+ }
+ setAuthError(error)
+ if (onAuthError) onAuthError(error)
+ setAuthInProgress(false)
+ return
}
- const codeVerifier = generateCodeVerifier()
- const codeChallenge = codeVerifier
- const state = Math.random().toString(36).substring(2, 15)
- const authUrl =
- `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` +
- `client_id=${appId}` +
- `&response_type=code` +
- `&redirect_uri=${encodeURIComponent(window.location.origin)}/authredirect` +
- `&scope=${encodeURIComponent(scope)}` +
- `&code_challenge=${codeChallenge}` +
- `&code_challenge_method=plain` +
- `&state=${state}` +
- `&prompt=select_account`
+ const base64UrlEncode = (bytes) =>
+ btoa(String.fromCharCode(...new Uint8Array(bytes)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '')
+
+ const randomUrlSafeString = (byteLength) => {
+ const array = new Uint8Array(byteLength)
+ window.crypto.getRandomValues(array)
+ return base64UrlEncode(array)
+ }
const width = 500
const height = 600
const left = window.screen.width / 2 - width / 2
const top = window.screen.height / 2 - height / 2
+ // Open the window before computing the challenge below. window.open only succeeds
+ // while the click's user activation is still live, and awaiting the SHA-256 digest
+ // first spends it - browsers then treat the call as an unsolicited popup and block
+ // it. Open a blank window synchronously and navigate it once the URL is ready.
const popup = window.open(
- authUrl,
+ '',
'msalAuthPopup',
`width=${width},height=${height},left=${left},top=${top}`
)
@@ -353,6 +439,36 @@ export const CIPPM365OAuthButton = ({
return
}
+ // Generate PKCE code verifier and S256 challenge
+ const codeVerifier = randomUrlSafeString(32)
+ const codeChallenge = base64UrlEncode(
+ await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
+ )
+ const state = randomUrlSafeString(16)
+ // prompt=login, not select_account: this flow mints the refresh token CIPP runs on, and
+ // Entra stamps that token with the authentication context of the sign-in that created it
+ // (including the protocol flow, which Conditional Access re-evaluates on every redemption).
+ // select_account can complete via SSO from an existing session - including the one the
+ // device code step establishes at microsoft.com/devicelogin in this same browser - which
+ // would carry a device-code-flow marker forward instead of clearing it.
+ // /organizations, not /common: CIPP-SAM is signInAudience AzureADMultipleOrgs, so it
+ // supports work and school accounts only. /common additionally advertises personal
+ // Microsoft accounts, letting someone pick one and fail later with a confusing error
+ // instead of being told up front that the account cannot be used. It also matches the
+ // authority the device code flow uses.
+ const authUrl =
+ `https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?` +
+ `client_id=${appId}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(window.location.origin)}/authredirect` +
+ `&scope=${encodeURIComponent(scope)}` +
+ `&code_challenge=${codeChallenge}` +
+ `&code_challenge_method=S256` +
+ `&state=${state}` +
+ `&prompt=login`
+
+ popup.location = authUrl
+
// Function to actually exchange the authorization code for tokens
const handleAuthorizationCode = async (code, receivedState) => {
// Verify the state parameter matches what we sent (security check)
@@ -393,7 +509,7 @@ export const CIPPM365OAuthButton = ({
},
body: JSON.stringify({
tokenRequest,
- tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
+ tokenUrl: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token',
tenantId: appId, // Pass the tenant ID to retrieve the correct client secret
}),
})
@@ -401,10 +517,15 @@ export const CIPPM365OAuthButton = ({
// Parse the token response
tokenData = await tokenResponse.json()
- // Check if it's the AADSTS650051 error (service principal already exists)
+ // AADSTS650051: service principal already exists.
+ // AADSTS7000215: the client secret is not valid *yet*. The wizard mints a new
+ // secret on the previous step and arrives here seconds later, but Entra can take
+ // minutes to replicate it. Retrying covers the fast case; the message below covers
+ // the rest, since waiting it out would outlive the authorization code.
if (
tokenData.error === 'invalid_client' &&
- tokenData.error_description?.includes('AADSTS650051')
+ (tokenData.error_description?.includes('AADSTS650051') ||
+ tokenData.error_description?.includes('AADSTS7000215'))
) {
retryCount++
if (retryCount <= maxRetries) {
@@ -419,10 +540,12 @@ export const CIPPM365OAuthButton = ({
// Check if the response contains an error
if (tokenData.error) {
+ const secretNotReady = tokenData.error_description?.includes('AADSTS7000215')
const error = {
errorCode: tokenData.error || 'token_error',
- errorMessage:
- tokenData.error_description || 'Failed to exchange authorization code for tokens',
+ errorMessage: secretNotReady
+ ? 'The application secret created for CIPP is not active yet. Microsoft can take several minutes to replicate a new secret across Entra ID. Wait a few minutes and run this step again - nothing needs to be recreated.'
+ : tokenData.error_description || 'Failed to exchange authorization code for tokens',
timestamp: new Date().toISOString(),
}
setAuthError(error)
@@ -551,10 +674,11 @@ export const CIPPM365OAuthButton = ({
// a short grace period before treating it as a cancellation. Without this,
// closing the sign-in window left the button stuck on "Authenticating..."
// until the 10-minute timeout.
+ let closeGraceTimer = null
const popupWatcher = setInterval(() => {
if (popup.closed) {
clearInterval(popupWatcher)
- setTimeout(() => {
+ closeGraceTimer = setTimeout(() => {
if (!resultReceived) {
cleanup()
const error = {
@@ -575,6 +699,12 @@ export const CIPPM365OAuthButton = ({
channel.close()
clearTimeout(authTimeout)
clearInterval(popupWatcher)
+ // The grace timer was previously left running. On the happy path - where the
+ // callback posts its result and then closes the popup - it would still be pending
+ // after cleanup, and if the user started another attempt inside that window it
+ // fired against the new one, clearing its progress state and reporting a
+ // cancellation for a sign-in that was still going.
+ clearTimeout(closeGraceTimer)
}
}
@@ -633,7 +763,14 @@ export const CIPPM365OAuthButton = ({
- {authInProgress ? (
+ {authInProgress && devicePopupClosed ? (
+ <>
+ The sign-in window was closed. If you are still finishing at{' '}
+ microsoft.com/devicelogin in another browser, CIPP is still
+ waiting. If you had already entered the code, it cannot be used again - start
+ over below to get a new one.
+ >
+ ) : authInProgress ? (
<>
If the popup was blocked or you closed it, you can also go to{' '}
microsoft.com/devicelogin manually and enter the code shown
@@ -733,15 +870,21 @@ export const CIPPM365OAuthButton = ({
)
diff --git a/frontend/src/components/CippWizard/CippSAMDeploy.jsx b/frontend/src/components/CippWizard/CippSAMDeploy.jsx
index d38d0f66dd..8507fac488 100644
--- a/frontend/src/components/CippWizard/CippSAMDeploy.jsx
+++ b/frontend/src/components/CippWizard/CippSAMDeploy.jsx
@@ -100,6 +100,15 @@ export const CippSAMDeploy = (props) => {
Multi-factor authentication enabled for the CIPP Service Account, with no trusted
locations or other exclusions.
+
+ Device code sign-in permitted in your partner tenant. Security defaults and Conditional
+ Access authentication flow policies can block it, which will stop this step from
+ completing.
+
+
+
+ This step only creates the CIPP-SAM application registration. The token CIPP runs on is
+ created by the sign-in on the next step.
{authStatus.error && (
diff --git a/frontend/src/components/CippWizard/CippTenantModeDeploy.jsx b/frontend/src/components/CippWizard/CippTenantModeDeploy.jsx
index d0736b8c5c..b31df79683 100644
--- a/frontend/src/components/CippWizard/CippTenantModeDeploy.jsx
+++ b/frontend/src/components/CippWizard/CippTenantModeDeploy.jsx
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import {
+ Alert,
Stack,
Box,
Typography,
@@ -35,6 +36,33 @@ export const CippTenantModeDeploy = (props) => {
waiting: true,
});
+ // The application step mints a client secret and this step uses it moments later, but Entra
+ // can take minutes to activate a new secret. Poll until it is usable so the wait happens
+ // here, rather than the sign-in appearing to work and then failing on the token exchange
+ // with an "invalid client secret" that looks like the app was created wrong.
+ const samSecret = ApiGetCall({
+ url: `/api/ExecSamSecretStatus`,
+ queryKey: "samSecretStatus",
+ waiting: true,
+ staleTime: 0,
+ });
+ const samSecretReady = samSecret.data?.ready === true;
+ const samSecretPropagating = samSecret.data?.reason === "propagating";
+ const {
+ isSuccess: samSecretLoaded,
+ dataUpdatedAt: samSecretUpdatedAt,
+ refetch: refetchSamSecret,
+ } = samSecret;
+
+ // Re-check on a timer rather than a fixed refetchInterval so polling stops once the secret
+ // is usable - there is nothing left to wait for at that point.
+ useEffect(() => {
+ if (samSecretLoaded && !samSecretReady) {
+ const timer = setTimeout(() => refetchSamSecret(), 15000);
+ return () => clearTimeout(timer);
+ }
+ }, [samSecretLoaded, samSecretUpdatedAt, samSecretReady, refetchSamSecret]);
+
useEffect(() => {
if (updateRefreshToken.isSuccess) {
formControl.setValue("GDAPAuth", true);
@@ -201,8 +229,24 @@ export const CippTenantModeDeploy = (props) => {
)}
+ {samSecretLoaded && !samSecretReady && (
+
+ {samSecretPropagating ? (
+ <>
+ Waiting for Microsoft to activate the application secret created in the previous
+ step. Signing in before it is active fails with an invalid client secret error, so
+ this step unlocks on its own once it is ready - usually within a few minutes.
+ Nothing needs to be recreated.
+ >
+ ) : (
+ samSecret.data?.message
+ )}
+
+ )}
+
{
const updatedTokenData = {
...tokenData,
diff --git a/frontend/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx b/frontend/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx
index 5072ce5c18..86fa667f86 100644
--- a/frontend/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx
+++ b/frontend/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx
@@ -42,8 +42,16 @@ describe('CIPPM365OAuthButton popup flow', () => {
MockBroadcastChannel.instances.length = 0
api.get = getResult({ data: { applicationId: APP_ID } })
openSpy = vi.spyOn(window, 'open')
+ // The PKCE S256 challenge awaits a real digest, which settles on the event loop
+ // rather than the microtask queue and so cannot be flushed under fake timers.
+ // A resolved stub keeps the popup setup that follows it deterministic.
+ vi.spyOn(globalThis.crypto.subtle, 'digest').mockResolvedValue(new Uint8Array(32).buffer)
})
+ // Everything after the digest - the BroadcastChannel and the popup watcher - is set up
+ // in a microtask, so tests touching those have to let the click settle first.
+ const settleAuthStart = () => act(async () => {})
+
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
@@ -63,7 +71,7 @@ describe('CIPPM365OAuthButton popup flow', () => {
expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled()
})
- it('re-enables the button shortly after the sign-in window is closed without a result', () => {
+ it('re-enables the button shortly after the sign-in window is closed without a result', async () => {
const popup = { closed: false, close: vi.fn() }
openSpy.mockReturnValue(popup)
const onAuthError = vi.fn()
@@ -71,6 +79,7 @@ describe('CIPPM365OAuthButton popup flow', () => {
fireEvent.click(authButton())
expect(screen.getByRole('button', { name: /Authenticating/ })).toBeDisabled()
+ await settleAuthStart()
popup.closed = true
// 1s watcher tick spots the closed window, then the 2s grace period elapses
@@ -86,12 +95,13 @@ describe('CIPPM365OAuthButton popup flow', () => {
expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled()
})
- it('does not report a cancellation when a result arrived before the popup closed', () => {
+ it('does not report a cancellation when a result arrived before the popup closed', async () => {
const popup = { closed: false, close: vi.fn() }
openSpy.mockReturnValue(popup)
renderWithTheme()
fireEvent.click(authButton())
+ await settleAuthStart()
// the /authredirect callback posts its result, then the popup closes itself
act(() => {
@@ -114,12 +124,13 @@ describe('CIPPM365OAuthButton popup flow', () => {
expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled()
})
- it('cleans up the popup watcher when a result arrives', () => {
+ it('cleans up the popup watcher when a result arrives', async () => {
const popup = { closed: false, close: vi.fn() }
openSpy.mockReturnValue(popup)
renderWithTheme()
fireEvent.click(authButton())
+ await settleAuthStart()
act(() => {
lastChannel().onmessage({
data: { type: 'auth_error', error: 'access_denied', errorDescription: 'cancelled' },
@@ -130,4 +141,119 @@ describe('CIPPM365OAuthButton popup flow', () => {
// with the watcher cleared, no timers remain to fire popup_closed later
expect(vi.getTimerCount()).toBe(0)
})
+
+ it('cancels the pending close check when a result lands during the grace period', async () => {
+ const popup = { closed: false, close: vi.fn() }
+ openSpy.mockReturnValue(popup)
+ renderWithTheme()
+
+ fireEvent.click(authButton())
+ await settleAuthStart()
+
+ // the callback closes the popup first, so the watcher schedules its grace check...
+ popup.closed = true
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+ // ...and the result lands inside that window
+ act(() => {
+ lastChannel().onmessage({
+ data: { type: 'auth_error', error: 'access_denied', errorDescription: 'cancelled' },
+ })
+ })
+
+ // nothing left pending that could fire against a subsequent attempt
+ expect(vi.getTimerCount()).toBe(0)
+
+ act(() => {
+ vi.advanceTimersByTime(5000)
+ })
+ expect(screen.getByText(/Authentication Error: access_denied/)).toBeInTheDocument()
+ expect(screen.queryByText(/sign-in window was closed/)).not.toBeInTheDocument()
+ })
+})
+
+describe('CIPPM365OAuthButton device code flow', () => {
+ let openSpy
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ api.get = getResult({ data: { applicationId: APP_ID } })
+ openSpy = vi.spyOn(window, 'open')
+ // keep the poll pending so the flow stays mid-authentication
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ status: 'pending', error: 'authorization_pending' }),
+ })
+ )
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ const codeResponse = (userCode, deviceCode) => ({
+ ok: true,
+ json: async () => ({
+ user_code: userCode,
+ device_code: deviceCode,
+ expires_in: 900,
+ interval: 5,
+ }),
+ })
+
+ const pendingResponse = {
+ ok: true,
+ json: async () => ({ status: 'pending', error: 'authorization_pending' }),
+ }
+
+ it('offers a fresh code instead of locking up when the sign-in window is closed', async () => {
+ const popup = { closed: false, close: vi.fn() }
+ openSpy.mockReturnValue(popup)
+ global.fetch = vi.fn().mockResolvedValue(codeResponse('FHA953X4X', 'dev-code-1'))
+
+ renderWithTheme()
+
+ // first click retrieves the device code
+ fireEvent.click(screen.getByRole('button', { name: /Login with Microsoft/ }))
+ await act(async () => {})
+
+ // second click opens the popup and starts polling
+ global.fetch = vi.fn().mockResolvedValue(pendingResponse)
+ fireEvent.click(screen.getByRole('button', { name: /Authenticate with Code/ }))
+ await act(async () => {})
+ expect(screen.getByRole('button', { name: /Authenticating/ })).toBeDisabled()
+
+ // the user closes the sign-in window
+ popup.closed = true
+ await act(async () => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ const restart = screen.getByRole('button', { name: /Start over with a new code/ })
+ expect(restart).toBeEnabled()
+ // the copy must not promise that the old code can be reused - it is consumed once entered
+ expect(screen.getByText(/cannot be used again/)).toBeInTheDocument()
+
+ // starting over requests a new code and retires the old poll
+ global.fetch = vi.fn().mockResolvedValue(codeResponse('NEWCODE99', 'dev-code-2'))
+ fireEvent.click(restart)
+ await act(async () => {})
+
+ expect(screen.getByText('NEWCODE99')).toBeInTheDocument()
+
+ // the superseded poll must not keep hitting the old device code
+ global.fetch.mockClear()
+ await act(async () => {
+ vi.advanceTimersByTime(30000)
+ })
+ const polledOldCode = global.fetch.mock.calls.some(([url]) =>
+ String(url).includes('dev-code-1')
+ )
+ expect(polledOldCode).toBe(false)
+ })
})
diff --git a/frontend/vitest.setup.js b/frontend/vitest.setup.js
index c8bfb4160d..75e16735e4 100644
--- a/frontend/vitest.setup.js
+++ b/frontend/vitest.setup.js
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom/vitest'
import './tests/mocks/require-context'
import { cleanup, configure } from '@testing-library/react'
import { afterEach } from 'vitest'
+import { webcrypto } from 'node:crypto'
// coverage instrumentation slows lazy chunks and fetches past the 1s default
configure({ asyncUtilTimeout: 10000 })
@@ -16,6 +17,16 @@ global.ResizeObserver = class ResizeObserver {
disconnect() {}
}
+// jsdom ships crypto.getRandomValues but not crypto.subtle, which the PKCE S256
+// challenge in CIPPM365OAuthButton needs. Node's webcrypto is the same API browsers
+// expose in a secure context.
+if (globalThis.crypto && !globalThis.crypto.subtle) {
+ Object.defineProperty(globalThis.crypto, 'subtle', {
+ value: webcrypto.subtle,
+ configurable: true,
+ })
+}
+
// Suppress jsdom "Not implemented" warnings for getComputedStyle with pseudo-elements
const originalConsoleError = console.error
console.error = (...args) => {