Skip to content

Instantly share code, notes, and snippets.

@samcofer
Last active April 24, 2026 21:08
Show Gist options
  • Select an option

  • Save samcofer/be795f3799e127ace37e7038f8fef94c to your computer and use it in GitHub Desktop.

Select an option

Save samcofer/be795f3799e127ace37e7038f8fef94c to your computer and use it in GitHub Desktop.
Posit OIDC & SCIM configuration for Microsoft Entra ID (PowerShell 7)
#Requires -Version 7.0
$ErrorActionPreference = 'Stop'
# --- Helper functions ---
function Prompt-Value {
param(
[string]$Name,
[string]$Label,
[string]$Default = '',
[switch]$Secret
)
$envVal = [Environment]::GetEnvironmentVariable($Name)
if ($envVal) { return $envVal }
$prompt = if ($Default) { "$Label [$Default]" } else { $Label }
while ($true) {
if ($Secret) {
$secure = Read-Host -Prompt $prompt -AsSecureString
$value = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
} else {
$value = Read-Host -Prompt $prompt
}
if (-not $value) { $value = $Default }
if ($value) { return $value }
Write-Host 'A value is required.'
}
}
function Prompt-YesNo {
param(
[string]$Name,
[string]$Label,
[string]$Default = 'No'
)
$envVal = [Environment]::GetEnvironmentVariable($Name)
if ($envVal) {
switch ($envVal.ToLower()) {
{ $_ -in 'y','yes' } { return 'Yes' }
{ $_ -in 'n','no' } { return 'No' }
default { throw "Invalid value for ${Name}: $envVal. Use Yes or No." }
}
}
while ($true) {
$value = Read-Host -Prompt "$Label [Yes/No, default $Default]"
if (-not $value) { $value = $Default }
switch ($value.ToLower()) {
{ $_ -in 'y','yes' } { return 'Yes' }
{ $_ -in 'n','no' } { return 'No' }
default { Write-Host 'Please enter Yes or No.' }
}
}
}
function Validate-Url {
param(
[string]$Url,
[string]$Suffix = ''
)
if ($Url -notmatch '^https://') {
Write-Host 'URL must start with https://'
return $false
}
if ($Suffix -and -not $Url.EndsWith($Suffix)) {
Write-Host "URL must end with $Suffix"
return $false
}
return $true
}
function Prompt-Url {
param(
[string]$Name,
[string]$Label,
[string]$Default = '',
[string]$Suffix = ''
)
$envVal = [Environment]::GetEnvironmentVariable($Name)
if ($envVal) {
if (-not (Validate-Url -Url $envVal -Suffix $Suffix)) { throw "Invalid URL for ${Name}: $envVal" }
return $envVal
}
$prompt = if ($Default) { "$Label [$Default]" } else { $Label }
while ($true) {
$value = Read-Host -Prompt $prompt
if (-not $value) { $value = $Default }
if (-not $value) { Write-Host 'A value is required.'; continue }
if (Validate-Url -Url $value -Suffix $Suffix) { return $value }
}
}
function Truncate-Name {
param([string]$Base, [string]$Suffix, [int]$Max = 120)
$allowed = $Max - $Suffix.Length
if ($allowed -lt 1) { return $Suffix.Substring(0, $Max) }
return $Base.Substring(0, [Math]::Min($Base.Length, $allowed)) + $Suffix
}
function Invoke-Az {
param([Parameter(ValueFromRemainingArguments)]$AzArgs)
$stderr = $null
$output = & az @AzArgs 2>&1 | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { $stderr += $_.ToString() }
else { $_ }
}
if ($LASTEXITCODE -ne 0) { throw "az command failed: $stderr $output" }
return $output
}
function Invoke-AzJson {
param([Parameter(ValueFromRemainingArguments)]$AzArgs)
$raw = Invoke-Az @AzArgs --output json
return ($raw -join "`n") | ConvertFrom-Json
}
function Invoke-AzRestJson {
param(
[string]$Method,
[string]$Url,
[string]$Body
)
$tmpFile = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($tmpFile, $Body)
$raw = Invoke-Az rest --method $Method --url $Url --headers 'Content-Type=application/json' --body "@$tmpFile" --output json
return ($raw -join "`n") | ConvertFrom-Json
} finally {
Remove-Item -Path $tmpFile -ErrorAction SilentlyContinue
}
}
function Invoke-AzRestVoid {
param(
[string]$Method,
[string]$Url,
[string]$Body
)
$tmpFile = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($tmpFile, $Body)
Invoke-Az rest --method $Method --url $Url --headers 'Content-Type=application/json' --body "@$tmpFile" | Out-Null
} finally {
Remove-Item -Path $tmpFile -ErrorAction SilentlyContinue
}
}
$PositLogoPngB64 = "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"
function Set-AppLogo {
param([string]$AppObjectId)
Write-Host 'Setting application logo...'
$logoBytes = [Convert]::FromBase64String($PositLogoPngB64)
$token = (Invoke-AzJson account get-access-token --resource https://graph.microsoft.com).accessToken
$headers = @{
'Authorization' = "Bearer $token"
'Content-Type' = 'image/png'
}
try {
Invoke-RestMethod -Method Put `
-Uri "https://graph.microsoft.com/v1.0/applications/$AppObjectId/logo" `
-Headers $headers `
-Body $logoBytes | Out-Null
Write-Host 'Application logo set.'
} catch {
Write-Host 'WARNING: Failed to set application logo (non-fatal).'
}
}
# --- Collected state for error reporting ---
$script:State = @{}
trap {
Write-Host "`nScript failed."
Write-Host "`nCollected information so far:"
Write-Host '============================'
$script:State.GetEnumerator() | Sort-Object Name | ForEach-Object {
Write-Host " $($_.Name): $($_.Value)"
}
}
# --- Pre-flight checks ---
Write-Host 'Checking Azure login...'
$account = Invoke-AzJson account show
$TenantId = $account.tenantId
$script:State['TenantId'] = $TenantId
$SignedInUser = (Invoke-AzJson ad signed-in-user show).id
$GraphAppId = '00000003-0000-0000-c000-000000000000'
$GraphSpId = (Invoke-AzJson ad sp show --id $GraphAppId).id
$ScimTemplateId = '8adf8e6e-67b2-4cf2-a259-e3dc5476c621'
$ownerBody = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$SignedInUser" } | ConvertTo-Json -Compress
# --- Product selection ---
$Product = [Environment]::GetEnvironmentVariable('PRODUCT')
if ($Product) {
$Product = switch ($Product.ToLower()) {
{ $_ -in '1','workbench' } { 'workbench' }
{ $_ -in '2','connect' } { 'connect' }
{ $_ -in '3','packagemanager','ppm' } { 'packagemanager' }
default { throw "Invalid PRODUCT value: $Product" }
}
} else {
Write-Host ''
Write-Host 'Select Posit product to configure:'
Write-Host ' 1) Posit Workbench'
Write-Host ' 2) Posit Connect'
Write-Host ' 3) Posit Package Manager'
Write-Host ''
while ($true) {
$choice = Read-Host -Prompt 'Product [1/2/3]'
$Product = switch ($choice) {
'1' { 'workbench' }
'2' { 'connect' }
'3' { 'packagemanager' }
default { $null }
}
if ($Product) { break }
Write-Host 'Please enter 1, 2, or 3.'
}
}
$script:State['Product'] = $Product
$ProductConfig = switch ($Product) {
'workbench' { @{ DefaultAppName = 'posit-workbench-oidc'; Label = 'Posit Workbench'; UrlExample = 'https://workbench.example.com' } }
'connect' { @{ DefaultAppName = 'posit-connect-oidc'; Label = 'Posit Connect'; UrlExample = 'https://connect.example.com' } }
'packagemanager' { @{ DefaultAppName = 'posit-package-manager-oidc'; Label = 'Posit Package Manager'; UrlExample = 'https://packagemanager.example.com' } }
}
Write-Host ''
Write-Host "Configuring Entra ID for $($ProductConfig.Label)"
Write-Host '========================================'
if ($Product -eq 'workbench') {
$WbMode = [Environment]::GetEnvironmentVariable('WB_MODE')
if ($WbMode) {
switch ($WbMode.ToLower()) {
{ $_ -in '1','oidc-scim','oidc+scim' } { $SkipOidc = 'No'; $CreateScim = 'Yes' }
{ $_ -in '2','oidc' } { $SkipOidc = 'No'; $CreateScim = 'No' }
{ $_ -in '3','scim' } { $SkipOidc = 'Yes'; $CreateScim = 'Yes' }
default { throw "Invalid WB_MODE value: $WbMode. Use oidc+scim, oidc, or scim." }
}
} else {
Write-Host ''
Write-Host 'Select Workbench configuration mode:'
Write-Host ' 1) OIDC + SCIM provisioning'
Write-Host ' 2) OIDC only'
Write-Host ' 3) SCIM provisioning only'
Write-Host ''
while ($true) {
$wbChoice = Read-Host -Prompt 'Mode [1/2/3]'
switch ($wbChoice) {
'1' { $SkipOidc = 'No'; $CreateScim = 'Yes'; break }
'2' { $SkipOidc = 'No'; $CreateScim = 'No'; break }
'3' { $SkipOidc = 'Yes'; $CreateScim = 'Yes'; break }
default { Write-Host 'Please enter 1, 2, or 3.'; continue }
}
break
}
}
} else {
$SkipOidc = 'No'
$CreateScim = 'No'
}
if ($SkipOidc -ne 'Yes') {
$AppName = Prompt-Value -Name 'APP_NAME' -Label 'App registration name' -Default $ProductConfig.DefaultAppName
$BaseUrl = Prompt-Url -Name 'BASE_URL' -Label "$($ProductConfig.Label) base URL" -Default $ProductConfig.UrlExample
$script:State['AppName'] = $AppName
$script:State['BaseUrl'] = $BaseUrl
$RedirectSuffix = switch ($Product) {
'workbench' { '/openid/callback' }
default { '/__login__/callback' }
}
$DefaultRedirect = "$($BaseUrl.TrimEnd('/'))$RedirectSuffix"
$RedirectUri = Prompt-Url -Name 'REDIRECT_URI' -Label 'OIDC redirect URI' -Default $DefaultRedirect -Suffix $RedirectSuffix
$ClientSecretName = Prompt-Value -Name 'CLIENT_SECRET_NAME' -Label 'Client secret display name' -Default "$AppName-secret"
$SigninAudience = Prompt-Value -Name 'SIGNIN_AUDIENCE' -Label 'Sign-in audience: AzureADMyOrg, AzureADMultipleOrgs' -Default 'AzureADMyOrg'
$IncludeGroups = Prompt-YesNo -Name 'INCLUDE_GROUP_CLAIMS' -Label 'Include group claims in ID/access tokens?' -Default 'Yes'
$GroupClaims = Prompt-Value -Name 'GROUP_CLAIMS' -Label 'Group claim mode: SecurityGroup, All, DirectoryRole, ApplicationGroup, None' -Default 'SecurityGroup'
$GroupMembership = if ($IncludeGroups -eq 'Yes') { $GroupClaims } else { 'None' }
# --- Collect SCIM prompts early for unified mode ---
if ($CreateScim -eq 'Yes') {
$DefaultScimUrl = "$($BaseUrl.TrimEnd('/'))/scim/v2"
$ScimUrl = Prompt-Url -Name 'SCIM_URL' -Label 'Workbench SCIM base URL' -Default $DefaultScimUrl -Suffix '/scim/v2'
Write-Host 'Testing SCIM endpoint reachability...'
$scimReachable = $false
try {
$null = Invoke-WebRequest -Uri $ScimUrl -Method Head -TimeoutSec 10 -SkipCertificateCheck -ErrorAction Stop
$scimReachable = $true
} catch [System.Net.Http.HttpRequestException] {
$scimReachable = $false
} catch {
$scimReachable = $true
}
if ($scimReachable) {
Write-Host 'SCIM endpoint is reachable.'
} else {
Write-Host "WARNING: SCIM endpoint at $ScimUrl is not reachable from this environment."
$scimConfirmed = Prompt-YesNo -Name 'SCIM_CONNECTIVITY_CONFIRMED' -Label 'Do you have connectivity between Azure and your Workbench instance handled via another avenue (e.g., VPN, private endpoint)?' -Default 'No'
if ($scimConfirmed -ne 'Yes') {
Write-Host 'Skipping SCIM provisioning. SCIM requires network connectivity from Azure to your Workbench instance.'
$CreateScim = 'No'
}
}
if ($CreateScim -eq 'Yes') {
$ScimToken = Prompt-Value -Name 'SCIM_TOKEN' -Label 'Workbench SCIM bearer token' -Secret
$StartScim = Prompt-YesNo -Name 'START_SCIM' -Label 'Start SCIM provisioning job now?' -Default 'No'
}
}
# --- Create app registration ---
if ($CreateScim -eq 'Yes') {
Write-Host 'Creating unified OIDC+SCIM app from Microsoft template...'
$instantiateBody = @{ displayName = $AppName } | ConvertTo-Json -Compress
$templateJson = Invoke-AzRestJson -Method POST -Url "https://graph.microsoft.com/v1.0/applicationTemplates/$ScimTemplateId/instantiate" -Body $instantiateBody
$SpObjectId = $templateJson.servicePrincipal.id
$ClientId = $templateJson.application.appId
if (-not $SpObjectId) {
Write-Host 'Template instantiation did not return a service principal ID.'
Write-Host ($templateJson | ConvertTo-Json -Depth 5)
exit 1
}
Write-Host 'Waiting for service principal to become available...'
for ($i = 1; $i -le 12; $i++) {
try {
Invoke-Az ad sp show --id $SpObjectId --output none | Out-Null
break
} catch {
if ($i -eq 12) { throw "Timed out waiting for service principal $SpObjectId to become available." }
Start-Sleep -Seconds 5
}
}
$AppObjectId = (Invoke-AzJson ad app show --id $ClientId).id
Write-Host 'Configuring app registration with OIDC settings...'
$patchBody = @{
signInAudience = $SigninAudience
groupMembershipClaims = $GroupMembership
web = @{
redirectUris = @($RedirectUri)
implicitGrantSettings = @{
enableIdTokenIssuance = $true
enableAccessTokenIssuance = $false
}
}
optionalClaims = @{
idToken = @(
@{ name = 'email'; essential = $false }
@{ name = 'preferred_username'; essential = $false }
)
}
} | ConvertTo-Json -Depth 5 -Compress
Invoke-AzRestVoid -Method PATCH -Url "https://graph.microsoft.com/v1.0/applications/$AppObjectId" -Body $patchBody
} else {
Write-Host 'Creating OIDC app registration...'
$appBody = @{
displayName = $AppName
signInAudience = $SigninAudience
groupMembershipClaims = $GroupMembership
web = @{
redirectUris = @($RedirectUri)
implicitGrantSettings = @{
enableIdTokenIssuance = $true
enableAccessTokenIssuance = $false
}
}
optionalClaims = @{
idToken = @(
@{ name = 'email'; essential = $false }
@{ name = 'preferred_username'; essential = $false }
)
}
} | ConvertTo-Json -Depth 5 -Compress
$appJson = Invoke-AzRestJson -Method POST -Url 'https://graph.microsoft.com/v1.0/applications' -Body $appBody
$AppObjectId = $appJson.id
$ClientId = $appJson.appId
}
$script:State['ClientId'] = $ClientId
$script:State['AppObjectId'] = $AppObjectId
Set-AppLogo -AppObjectId $AppObjectId
Write-Host 'Adding OpenID delegated permissions...'
try {
Invoke-Az ad app permission add --id $ClientId --api $GraphAppId --api-permissions `
'37f7f235-527c-4136-accd-4a02d197296e=Scope' `
'64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0=Scope' `
'14dad69e-099b-42c9-810b-d002981feec1=Scope' `
'7427e0e9-2fba-42fe-b0c0-848c9e6a818b=Scope' `
'e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope' | Out-Null
} catch {
if ($_.Exception.Message -notmatch 'already exist') { throw }
}
Write-Host 'Creating client secret...'
$secretBody = @{ passwordCredential = @{ displayName = $ClientSecretName } } | ConvertTo-Json -Compress
$secretJson = Invoke-AzRestJson -Method POST -Url "https://graph.microsoft.com/v1.0/applications/$AppObjectId/addPassword" -Body $secretBody
$ClientSecret = $secretJson.secretText
$script:State['ClientSecret'] = '***'
Write-Host 'Adding signed-in user as app owner...'
try {
Invoke-AzRestVoid -Method POST -Url "https://graph.microsoft.com/v1.0/applications/$AppObjectId/owners/`$ref" -Body $ownerBody
} catch { }
if (-not $SpObjectId) {
Write-Host 'Creating/ensuring enterprise service principal...'
try { Invoke-Az ad sp create --id $ClientId | Out-Null } catch { }
for ($i = 1; $i -le 6; $i++) {
try {
$SpObjectId = (Invoke-AzJson ad sp show --id $ClientId).id
break
} catch {
if ($i -eq 6) { throw "Timed out waiting for service principal for $ClientId to become available." }
Start-Sleep -Seconds 5
}
}
}
$script:State['SpObjectId'] = $SpObjectId
try {
Invoke-AzRestVoid -Method POST -Url "https://graph.microsoft.com/v1.0/servicePrincipals/$SpObjectId/owners/`$ref" -Body $ownerBody
} catch { }
Write-Host 'Granting admin consent for delegated permissions...'
$consentBody = @{
clientId = $SpObjectId
consentType = 'AllPrincipals'
resourceId = $GraphSpId
scope = 'email offline_access openid profile User.Read'
} | ConvertTo-Json -Compress
Invoke-AzRestJson -Method POST -Url 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $consentBody | Out-Null
Write-Host 'Requiring user assignment on enterprise app...'
$assignReqBody = @{ appRoleAssignmentRequired = $true } | ConvertTo-Json -Compress
Invoke-AzRestVoid -Method PATCH -Url "https://graph.microsoft.com/v1.0/servicePrincipals/$SpObjectId" -Body $assignReqBody
Write-Host 'Assigning signed-in user to enterprise app...'
$spInfo = Invoke-AzJson rest --method GET --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SpObjectId"
$appRoleId = ($spInfo.appRoles | Where-Object { $_.isEnabled } | Select-Object -First 1).id
if (-not $appRoleId) { $appRoleId = '00000000-0000-0000-0000-000000000000' }
$assignBody = @{
principalId = $SignedInUser
resourceId = $SpObjectId
appRoleId = $appRoleId
} | ConvertTo-Json -Compress
Invoke-AzRestVoid -Method POST -Url "https://graph.microsoft.com/v1.0/servicePrincipals/$SpObjectId/appRoleAssignedTo" -Body $assignBody
} else {
$BaseUrl = Prompt-Url -Name 'BASE_URL' -Label "$($ProductConfig.Label) base URL" -Default $ProductConfig.UrlExample
$AppName = if ([Environment]::GetEnvironmentVariable('APP_NAME')) { [Environment]::GetEnvironmentVariable('APP_NAME') } else { $ProductConfig.DefaultAppName }
}
# --- SCIM provisioning (Workbench only) ---
$ScimOutput = ''
if ($CreateScim -eq 'Yes') {
if ($SkipOidc -eq 'Yes') {
# Mode 3 (SCIM only): standalone SCIM app
$DefaultScimAppName = Truncate-Name -Base $AppName -Suffix '-scim-provisioning' -Max 120
$DefaultScimUrl = "$($BaseUrl.TrimEnd('/'))/scim/v2"
$ScimAppName = Prompt-Value -Name 'SCIM_APP_NAME' -Label 'SCIM enterprise app name' -Default $DefaultScimAppName
$ScimUrl = Prompt-Url -Name 'SCIM_URL' -Label 'Workbench SCIM base URL' -Default $DefaultScimUrl -Suffix '/scim/v2'
Write-Host 'Testing SCIM endpoint reachability...'
$scimReachable = $false
try {
$null = Invoke-WebRequest -Uri $ScimUrl -Method Head -TimeoutSec 10 -SkipCertificateCheck -ErrorAction Stop
$scimReachable = $true
} catch [System.Net.Http.HttpRequestException] {
$scimReachable = $false
} catch {
$scimReachable = $true
}
if ($scimReachable) {
Write-Host 'SCIM endpoint is reachable.'
} else {
Write-Host "WARNING: SCIM endpoint at $ScimUrl is not reachable from this environment."
$scimConfirmed = Prompt-YesNo -Name 'SCIM_CONNECTIVITY_CONFIRMED' -Label 'Do you have connectivity between Azure and your Workbench instance handled via another avenue (e.g., VPN, private endpoint)?' -Default 'No'
if ($scimConfirmed -ne 'Yes') {
Write-Host 'Skipping SCIM provisioning. SCIM requires network connectivity from Azure to your Workbench instance.'
$CreateScim = 'No'
}
}
}
}
if ($CreateScim -eq 'Yes') {
if ($SkipOidc -eq 'Yes') {
# Mode 3: collect remaining prompts and create standalone SCIM app
$ScimToken = Prompt-Value -Name 'SCIM_TOKEN' -Label 'Workbench SCIM bearer token' -Secret
$StartScim = Prompt-YesNo -Name 'START_SCIM' -Label 'Start SCIM provisioning job now?' -Default 'No'
Write-Host 'Creating SCIM enterprise application from Microsoft template...'
$instantiateBody = @{ displayName = $ScimAppName } | ConvertTo-Json -Compress
$scimAppJson = Invoke-AzRestJson -Method POST -Url "https://graph.microsoft.com/v1.0/applicationTemplates/$ScimTemplateId/instantiate" -Body $instantiateBody
$ScimSpId = $scimAppJson.servicePrincipal.id
$ScimAppId = $scimAppJson.application.appId
$script:State['ScimSpId'] = $ScimSpId
$script:State['ScimAppId'] = $ScimAppId
if (-not $ScimSpId) {
Write-Host 'SCIM application creation did not return a service principal ID.'
Write-Host ($scimAppJson | ConvertTo-Json -Depth 5)
exit 1
}
Write-Host 'Waiting for SCIM service principal to become available...'
for ($i = 1; $i -le 12; $i++) {
try {
Invoke-Az ad sp show --id $ScimSpId --output none | Out-Null
break
} catch {
if ($i -eq 12) { throw "Timed out waiting for service principal $ScimSpId to become available." }
Start-Sleep -Seconds 5
}
}
Write-Host 'Adding signed-in user as SCIM app owner...'
$ScimAppObjectId = (Invoke-AzJson ad app show --id $ScimAppId).id
Set-AppLogo -AppObjectId $ScimAppObjectId
try {
Invoke-AzRestVoid -Method POST -Url "https://graph.microsoft.com/v1.0/applications/$ScimAppObjectId/owners/`$ref" -Body $ownerBody
} catch { }
try {
Invoke-AzRestVoid -Method POST -Url "https://graph.microsoft.com/v1.0/servicePrincipals/$ScimSpId/owners/`$ref" -Body $ownerBody
} catch { }
} else {
# Mode 1 (OIDC+SCIM unified): reuse the already-created SP
$ScimSpId = $SpObjectId
}
Write-Host 'Waiting for provisioning readiness...'
Start-Sleep -Seconds 10
Write-Host 'Creating SCIM provisioning job...'
$jobJson = Invoke-AzRestJson -Method POST -Url "https://graph.microsoft.com/v1.0/servicePrincipals/$ScimSpId/synchronization/jobs" -Body '{"templateId":"scim"}'
$ScimJobId = $jobJson.id
$script:State['ScimJobId'] = $ScimJobId
if (-not $ScimJobId) {
Write-Host 'SCIM provisioning job creation did not return a job ID.'
Write-Host ($jobJson | ConvertTo-Json -Depth 5)
exit 1
}
Write-Host 'Saving SCIM endpoint and token...'
$secretsBody = @{
value = @(
@{ key = 'BaseAddress'; value = $ScimUrl }
@{ key = 'SecretToken'; value = $ScimToken }
)
} | ConvertTo-Json -Depth 3 -Compress
Invoke-AzRestVoid -Method PUT -Url "https://graph.microsoft.com/v1.0/servicePrincipals/$ScimSpId/synchronization/secrets" -Body $secretsBody
if ($StartScim -eq 'Yes') {
Write-Host 'Starting SCIM provisioning job...'
Invoke-Az rest --method POST --url "https://graph.microsoft.com/v1.0/servicePrincipals/$ScimSpId/synchronization/jobs/$ScimJobId/start" | Out-Null
}
if ($SkipOidc -ne 'Yes') {
$ScimOutput = @"
# SCIM Provisioning (same app):
# Provisioning job ID: $ScimJobId
# SCIM URL: $ScimUrl
"@
} else {
$ScimOutput = @"
# SCIM Enterprise App:
# Display name: $ScimAppName
# App/client ID: $ScimAppId
# Service principal: $ScimSpId
# Provisioning job ID: $ScimJobId
# SCIM URL: $ScimUrl
# Enterprise App: https://portal.azure.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$ScimSpId/appId/$ScimAppId
"@
}
}
# --- Output emit functions ---
function Emit-WorkbenchCommands {
Write-Host @"
# Append OIDC settings to rserver.conf
cat >> /etc/rstudio/rserver.conf <<'RSERVER'
# --- Entra ID OpenID Connect ---
auth-openid=1
auth-openid-issuer=$Issuer
auth-openid-username-claim=preferred_username
RSERVER
# Create client credentials file
cat > /etc/rstudio/openid-client-secret <<'SECRET'
client-id=$ClientId
client-secret=$ClientSecret
SECRET
chmod 0600 /etc/rstudio/openid-client-secret
# Restart Workbench
sudo rstudio-server restart
"@
}
function Emit-ConnectCommands {
$groupsLines = if ($IncludeGroups -eq 'Yes') { "`nGroupsAutoProvision = true`nGroupsClaim = `"groups`"" } else { '' }
Write-Host @"
# Change auth provider from password to oauth2
sudo sed -i 's/^Provider = "password"/Provider = "oauth2"/' /etc/rstudio-connect/rstudio-connect.gcfg
# Append OAuth2 settings
cat >> /etc/rstudio-connect/rstudio-connect.gcfg <<'GCFG'
[OAuth2]
ClientId = "$ClientId"
ClientSecret = "$ClientSecret"
OpenIDConnectIssuer = "$Issuer"
RequireUsernameClaim = true
UsernameClaim = "preferred_username"${groupsLines}
GCFG
# Restart Connect
sudo systemctl restart rstudio-connect
"@
}
function Emit-PackageManagerCommands {
Write-Host @"
# Set the server address for OIDC callback support
sudo sed -i 's|^; Address = "http://posit-connect.example.com"|Address = "$BaseUrl"|' /etc/rstudio-pm/rstudio-pm.gcfg
# Append OpenID Connect settings
cat >> /etc/rstudio-pm/rstudio-pm.gcfg <<'GCFG'
[OpenIDConnect]
Issuer = "$Issuer"
ClientId = "$ClientId"
ClientSecret = "$ClientSecret"
GCFG
# Restart Package Manager
sudo systemctl restart rstudio-pm
"@
}
# --- Output configuration commands ---
$Issuer = "https://login.microsoftonline.com/$TenantId/v2.0"
if ($SkipOidc -ne 'Yes') {
Write-Host @"
=== Entra ID registration complete for $($ProductConfig.Label) ===
Tenant ID: $TenantId
Client ID: $ClientId
Client secret: $ClientSecret
Redirect URI: $RedirectUri
Issuer: $Issuer
Enterprise App SP ID: $SpObjectId
App Registration: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/$ClientId
Enterprise App: https://portal.azure.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$SpObjectId/appId/$ClientId
$ScimOutput
Run the following commands on your $($ProductConfig.Label) server to configure OIDC:
==========================================================================
"@
switch ($Product) {
'workbench' { Emit-WorkbenchCommands }
'connect' { Emit-ConnectCommands }
'packagemanager' { Emit-PackageManagerCommands }
}
} else {
Write-Host @"
=== SCIM-only configuration complete for $($ProductConfig.Label) ===
Tenant ID: $TenantId
$ScimOutput
"@
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment