Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions backend/Config/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -18600,6 +18600,9 @@
}
}
},
"400": {
"description": "Bad request - missing required field or invalid input"
},
"401": {
"description": "Unauthorized - invalid or missing bearer token"
},
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 23 additions & 13 deletions backend/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
26 changes: 26 additions & 0 deletions backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
})
}
Loading
Loading