* 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>
398 lines
18 KiB
PowerShell
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()
|
|
}
|