Files
qbit-printing/dev/Invoke-Authentik.ps1
james.maesandCodex 1a0964ef23 chore: enforce owned source copyright notices (#25)
* chore(standards): preserve owned-source copyright notices

Add accurate repository copyright and license comments to 50 owned source/build/configuration files. Preserve existing holders, upstream wrappers, legal payloads and schema/data exemptions. Exact inverse-byte reconstruction and applicable parser/token/AST checks prove comment-only changes; existing relevant regressions pass.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

* chore: enforce owned source copyright notices

Check real leading comments against explicit repository provenance and retain existing native style gates.

Co-Authored-By: Codex GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-11-01 12:41:00 -06:00

398 lines
18 KiB
PowerShell

#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
<#
.SYNOPSIS
Prepares or checks the optional, owned Authentik development fixture.
.DESCRIPTION
Initialize only writes private state. Test configures a fresh synthetic provider
and checks its real HTTP discovery and rejection boundary; it never automates a
user login or changes QQQ authentication. The common development entrypoint owns
all Docker lifecycle operations. Provider response bodies and secrets are never
printed, including on errors.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('Initialize', 'Test')][string] $Action,
[Parameter(Mandatory)][string] $StateDirectory,
[Parameter(Mandatory)][ValidatePattern('^qbit-printing-dev-[a-z0-9-]{8,48}$')][string] $ProjectName,
[ValidateRange(1024, 65535)][int] $Port = 19010,
[ValidateRange(10, 300)][int] $ReadyTimeoutSeconds = 180
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
<# .SYNOPSIS Generates a private random value without placing it on a command line. #>
function Get-FixtureSecret
{
[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
<# .SYNOPSIS Limits the helper to an existing state directory outside the checkout. #>
function Get-FixtureState
{
param([string] $Directory)
$state = [IO.Path]::GetFullPath($Directory)
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$comparison = if($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
if($state.Equals($repository, $comparison) -or $state.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, $comparison))
{
throw 'Development secrets must be outside the source checkout.'
}
if(-not (Test-Path -LiteralPath $state -PathType Container) -or -not (Test-Path -LiteralPath (Join-Path $state 'compose.env') -PathType Leaf))
{
throw 'The common development entrypoint must create protected state and compose.env first.'
}
$ancestor = [IO.DirectoryInfo]::new($state)
while($null -ne $ancestor)
{
if(($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
{
throw 'Development state cannot use a symbolic link or reparse point.'
}
$ancestor = $ancestor.Parent
}
return $state
}
<# .SYNOPSIS Reads private configuration without echoing malformed JSON or credential values. #>
function Get-FixtureConfiguration
{
param([string] $Path)
try
{
$value = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable
foreach($field in @('project', 'port', 'adminPassword', 'bootstrapToken', 'clientId', 'clientSecret', 'username', 'userPassword'))
{
if(-not $value.ContainsKey($field) -or $null -eq $value[$field]) { throw 'Missing field.' }
}
return $value
}
catch { throw 'The private Authentik configuration is unreadable or incomplete; its contents are withheld.' }
}
<# .SYNOPSIS Performs bounded same-origin HTTP without redirects or response-body diagnostics. #>
function Invoke-FixtureHttp
{
param(
[Parameter(Mandatory)][Net.Http.HttpClient] $Client,
[Parameter(Mandatory)][string] $Path,
[ValidateSet('GET', 'POST')][string] $Method = 'GET',
[object] $Body,
[string] $Token,
[int[]] $ExpectedStatus = @(200)
)
if(-not $Path.StartsWith('/', [StringComparison]::Ordinal) -or $Path.StartsWith('//', [StringComparison]::Ordinal))
{
throw 'Only an absolute path on the fixture origin is permitted.'
}
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $Path)
$timeout = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(15))
$response = $null
$status = 0
try
{
if($Token)
{
$request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token)
}
if($null -ne $Body)
{
$request.Content = [Net.Http.StringContent]::new(($Body | ConvertTo-Json -Depth 12 -Compress), [Text.Encoding]::UTF8, 'application/json')
}
$response = $Client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $timeout.Token).GetAwaiter().GetResult()
$status = [int]$response.StatusCode
if($status -notin $ExpectedStatus)
{
throw "Fixture HTTP status $status was not expected. Response content is withheld."
}
$stream = $response.Content.ReadAsStreamAsync($timeout.Token).GetAwaiter().GetResult()
$bytes = [IO.MemoryStream]::new()
try
{
$buffer = [byte[]]::new(8192)
while(($read = $stream.ReadAsync($buffer, 0, $buffer.Length, $timeout.Token).GetAwaiter().GetResult()) -gt 0)
{
if($bytes.Length + $read -gt 524288)
{
throw 'Fixture response exceeded the 512 KiB limit.'
}
$bytes.Write($buffer, 0, $read)
}
$text = [Text.Encoding]::UTF8.GetString($bytes.ToArray())
$data = if($text.Length -gt 0 -and $response.Content.Headers.ContentType.MediaType -eq 'application/json')
{
ConvertFrom-Json -InputObject $text -AsHashtable
}
else { $null }
return @{ Status = $status; Data = $data; Location = $response.Headers.Location }
}
finally
{
$bytes.Dispose()
$stream.Dispose()
}
}
catch
{
# The failing API may contain passwords/tokens in its error response.
$operation = [uri]::new($Client.BaseAddress, $Path).AbsolutePath
throw "Fixture $Method $operation failed (HTTP $status; 0 means no response). Response content and authentication values are withheld."
}
finally
{
if($null -ne $response) { $response.Dispose() }
$request.Dispose()
$timeout.Dispose()
}
}
<# .SYNOPSIS Requires one exact default object rather than choosing an arbitrary provider resource. #>
function Get-FixtureObject
{
param([Net.Http.HttpClient] $Client, [string] $Path, [string] $Token)
$page = (Invoke-FixtureHttp -Client $Client -Path $Path -Token $Token).Data
if($null -eq $page -or $page.results.Count -ne 1)
{
throw 'A required Authentik default object is missing or ambiguous.'
}
return $page.results[0]
}
$state = Get-FixtureState -Directory $StateDirectory
$envPath = Join-Path $state 'compose.env'
$secretPath = Join-Path $state 'auth-secrets.json'
foreach($path in @($envPath, $secretPath))
{
if((Test-Path -LiteralPath $path) -and ((Get-Item -LiteralPath $path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
{
throw 'Development configuration cannot use a symbolic link or reparse point.'
}
}
if($Action -eq 'Initialize')
{
if(Test-Path -LiteralPath $secretPath)
{
$existing = Get-FixtureConfiguration -Path $secretPath
if($existing.project -ne $ProjectName -or $existing.port -ne $Port)
{
throw 'Existing Authentik state belongs to a different project or port.'
}
$existingEnvironment = Get-Content -LiteralPath $envPath -Raw
foreach($key in @('AUTHENTIK_HTTP_PORT', 'AUTHENTIK_POSTGRES_PASSWORD', 'AUTHENTIK_SECRET_KEY', 'AUTHENTIK_BOOTSTRAP_PASSWORD_HASH', 'AUTHENTIK_BOOTSTRAP_TOKEN'))
{
if([regex]::Matches($existingEnvironment, '(?m)^' + $key + '=').Count -ne 1)
{
throw 'The private Authentik environment is incomplete or ambiguous; use a fresh owned state directory.'
}
}
return
}
$contents = Get-Content -LiteralPath $envPath -Raw
if($contents -match '(?m)^AUTHENTIK_')
{
throw 'Partial Authentik initialization exists. Use a fresh owned state directory.'
}
$adminPassword = Get-FixtureSecret
$salt = Get-FixtureSecret
$passwordHash = [Convert]::ToBase64String([Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2($adminPassword, [Text.Encoding]::UTF8.GetBytes($salt), 1000000, [Security.Cryptography.HashAlgorithmName]::SHA256, 32))
$secrets = [ordered]@{
project = $ProjectName
port = $Port
adminPassword = $adminPassword
bootstrapToken = Get-FixtureSecret
clientId = $ProjectName
clientSecret = Get-FixtureSecret
username = 'printing-developer'
userPassword = Get-FixtureSecret
}
$secrets | ConvertTo-Json | Set-Content -LiteralPath $secretPath -Encoding utf8
$values = [ordered]@{
AUTHENTIK_HTTP_PORT = $Port
AUTHENTIK_POSTGRES_PASSWORD = Get-FixtureSecret
AUTHENTIK_SECRET_KEY = Get-FixtureSecret
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH = 'pbkdf2_sha256$1000000$' + $salt + '$' + $passwordHash
AUTHENTIK_BOOTSTRAP_TOKEN = $secrets.bootstrapToken
}
# Single quotes preserve the dollar signs in Django's password hash.
$lines = @('') + @($values.GetEnumerator() | ForEach-Object { $_.Key + "='" + $_.Value + "'" })
Add-Content -LiteralPath $envPath -Value $lines -Encoding utf8
if(-not $IsWindows)
{
[IO.File]::SetUnixFileMode($secretPath, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
}
return
}
$secrets = Get-FixtureConfiguration -Path $secretPath
if($secrets.project -cne $ProjectName -or $secrets.port -ne $Port -or $secrets.clientId -cne $ProjectName -or $secrets.username -cne 'printing-developer')
{
throw 'Authentik state does not match the owned development project.'
}
$handler = [Net.Http.HttpClientHandler]::new()
$handler.AllowAutoRedirect = $false
$handler.UseProxy = $false
$handler.UseCookies = $false
$client = [Net.Http.HttpClient]::new($handler)
$client.BaseAddress = [uri]"http://127.0.0.1:$Port"
$client.Timeout = [Threading.Timeout]::InfiniteTimeSpan
try
{
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($ReadyTimeoutSeconds)
$ready = $false
do
{
try
{
$authorization = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent' -Token $secrets.bootstrapToken
$invalidation = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-invalidation-flow' -Token $secrets.bootstrapToken
$ready = $true
}
catch
{
if([DateTimeOffset]::UtcNow -ge $deadline) { throw 'Authentik did not become ready before the bounded startup deadline.' }
Start-Sleep -Seconds 1
}
} while(-not $ready)
$mappingIds = @(foreach($scope in @('openid', 'profile', 'email', 'offline_access'))
{
(Get-FixtureObject -Client $client -Path "/api/v3/propertymappings/provider/scope/?scope_name=$scope" -Token $secrets.bootstrapToken).pk
})
$providerPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/providers/oauth2/?client_id=' + $secrets.clientId) -Token $secrets.bootstrapToken).Data
$callback = "http://127.0.0.1:$Port/reference-callback/"
if($providerPage.results.Count -eq 0)
{
$provider = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/providers/oauth2/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{
name = $ProjectName
authorization_flow = $authorization.pk
invalidation_flow = $invalidation.pk
client_type = 'confidential'
grant_types = @('authorization_code', 'refresh_token')
client_id = $secrets.clientId
client_secret = $secrets.clientSecret
access_code_validity = 'minutes=1'
access_token_validity = 'minutes=2'
refresh_token_validity = 'minutes=10'
redirect_uris = @(@{ matching_mode = 'strict'; url = $callback })
property_mappings = $mappingIds
issuer_mode = 'per_provider'
}).Data
}
elseif($providerPage.results.Count -eq 1)
{
$provider = $providerPage.results[0]
if($provider.name -ne $ProjectName) { throw 'The fixture provider identity changed.' }
}
else { throw 'The fixture provider identity is ambiguous.' }
if($provider.redirect_uris.Count -ne 1 -or $provider.redirect_uris[0].matching_mode -cne 'strict' -or
$provider.redirect_uris[0].url -cne $callback -or $provider.client_type -cne 'confidential' -or
$provider.access_token_validity -cne 'minutes=2' -or $provider.refresh_token_validity -cne 'minutes=10' -or
(@($provider.grant_types | Sort-Object) -join ',') -cne 'authorization_code,refresh_token' -or
@($mappingIds | Where-Object { $_ -notin $provider.property_mappings }).Count -ne 0)
{
throw 'The existing provider configuration differs from the bounded reference settings.'
}
$applicationPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/applications/?slug=' + $ProjectName) -Token $secrets.bootstrapToken).Data
if($applicationPage.results.Count -eq 0)
{
$null = Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/applications/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ name = $ProjectName; slug = $ProjectName; provider = $provider.pk; policy_engine_mode = 'all' }
}
elseif($applicationPage.results.Count -ne 1 -or $applicationPage.results[0].provider -ne $provider.pk)
{
throw 'The fixture application does not match its provider.'
}
$userPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/users/?username=' + $secrets.username) -Token $secrets.bootstrapToken).Data
if($userPage.results.Count -eq 0)
{
$user = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/users/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ username = $secrets.username; name = 'Printing development user'; email = 'printing-developer@example.invalid'; is_active = $true; type = 'internal' }).Data
}
elseif($userPage.results.Count -eq 1)
{
$user = $userPage.results[0]
if($user.name -cne 'Printing development user') { throw 'The synthetic user identity changed.' }
}
else { throw 'The synthetic user identity is ambiguous.' }
# Reusing the same generated value also recovers an interrupted first setup.
$null = Invoke-FixtureHttp -Client $client -Method POST -Path ("/api/v3/core/users/$($user.pk)/set_password/") -Token $secrets.bootstrapToken -ExpectedStatus @(204) -Body @{ password = $secrets.userPassword }
$issuer = "http://127.0.0.1:$Port/application/o/$ProjectName/"
$discovery = (Invoke-FixtureHttp -Client $client -Path "/application/o/$ProjectName/.well-known/openid-configuration").Data
if($discovery.issuer -cne $issuer -or 'S256' -notin $discovery.code_challenge_methods_supported)
{
throw 'The real provider discovery does not match the fixture issuer and S256 capability.'
}
foreach($endpoint in @('authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'))
{
$uri = [uri]$discovery[$endpoint]
if($uri.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port")
{
throw 'Provider discovery returned an endpoint outside the fixture origin.'
}
}
$verifier = Get-FixtureSecret
$challenge = [Convert]::ToBase64String([Security.Cryptography.SHA256]::HashData([Text.Encoding]::ASCII.GetBytes($verifier))).TrimEnd('=').Replace('+', '-').Replace('/', '_')
$stateValue = Get-FixtureSecret
$authorize = "/application/o/authorize/?client_id=$($secrets.clientId)&response_type=code&scope=openid%20profile%20email%20offline_access&state=$stateValue&code_challenge=$challenge&code_challenge_method=S256&prompt=login&redirect_uri="
$accepted = Invoke-FixtureHttp -Client $client -Path ($authorize + [uri]::EscapeDataString($callback)) -ExpectedStatus @(302, 303)
if($null -eq $accepted.Location)
{
throw 'The registered redirect did not continue to the real provider login flow.'
}
$login = [uri]::new($client.BaseAddress, $accepted.Location)
if($login.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port" -or -not $login.AbsolutePath.StartsWith('/if/flow/', [StringComparison]::Ordinal))
{
throw 'The registered redirect did not continue to the same-origin provider login flow.'
}
# Change only the callback: a generic malformed authorization request is not proof.
$invalidRedirect = [uri]::EscapeDataString("http://127.0.0.1:$Port/not-registered/")
$null = Invoke-FixtureHttp -Client $client -Path ($authorize + $invalidRedirect) -ExpectedStatus @(400)
$qqqPortMatch = [regex]::Match((Get-Content -LiteralPath $envPath -Raw), '(?m)^QQQ_HTTP_PORT=[''"]?(\d+)[''"]?\s*$')
if(-not $qqqPortMatch.Success) { throw 'The common environment has no QQQ HTTP port.' }
$qqqHandler = [Net.Http.HttpClientHandler]::new()
$qqqHandler.AllowAutoRedirect = $false
$qqqHandler.UseProxy = $false
$qqqClient = [Net.Http.HttpClient]::new($qqqHandler)
try
{
$qqqClient.BaseAddress = [uri]("http://127.0.0.1:" + $qqqPortMatch.Groups[1].Value)
$qqqClient.Timeout = [Threading.Timeout]::InfiniteTimeSpan
$qqqClient.DefaultRequestHeaders.Add('X-QQQ-Client', 'api/printing-dev-reference')
$version = (Invoke-FixtureHttp -Client $qqqClient -Path '/qqq/v1/version').Data
}
finally { $qqqClient.Dispose() }
if($version.apiVersion -ne '1.8.0' -or 'nativeSessionLogin' -in $version.features)
{
throw 'The core capability baseline changed; review the Authentik qualification scope before proceeding.'
}
[ordered]@{
status = 'PASS'
observedUtc = [DateTimeOffset]::UtcNow.ToString('O')
project = $ProjectName
providerOrigin = "http://127.0.0.1:$Port"
providerVersion = '2026.8.5'
discovery = 'PASS'
registeredRedirect = 'LOGIN_REQUIRED'
unregisteredRedirect = 'REJECTED'
qqqApiVersion = $version.apiVersion
nativeSessionLogin = 'UNAVAILABLE_EXPECTED'
browserLogin = 'NOT_RUN_OPERATOR_REQUIRED'
renewal = 'NOT_RUN'
windowsCallback = 'NOT_RUN_REQUIRES_QUALIFIED_CORE_AND_HTTPS'
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8
Write-Output 'Authentik reference discovery and redirect rejection passed; QQQ native browser login remains unavailable on RC.20.'
}
catch
{
@{ status = 'FAILED'; observedUtc = [DateTimeOffset]::UtcNow.ToString('O'); project = $ProjectName; message = 'Reference checks failed; no browser or native-login success is claimed.' } |
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8
throw
}
finally
{
$client.Dispose()
}