quorate/services/mirrored-image/k8s/bootstrap/quay-tests.fsx

188 lines
8.6 KiB
FSharp

#load "gateway.fsx"
open System
open System.Net
open System.Net.Http
open System.Net.Http.Headers
open System.Security.Cryptography
open System.Text
open System.Text.Json
open System.Threading
open System.Threading.Tasks
open Bootstrap
open Gateway
let require condition message =
if not condition then
invalidOp message
let base64Bytes (value: string) =
let value = value.Replace('-', '+').Replace('_', '/')
let padded = value.PadRight(value.Length + ((4 - value.Length % 4) % 4), '=')
Convert.FromBase64String padded
type QuayHandler(rsa: RSA, wrongScopes: bool) =
inherit HttpMessageHandler()
let mutable assigned = false
let seen = ResizeArray<string>()
member _.Seen = seen
member private _.Respond(request: HttpRequestMessage) =
let path = request.RequestUri.AbsolutePath
seen.Add($"{request.Method} {path}")
if request.RequestUri.Host = "zitadel-api" then
require (request.Headers.Host = "zitadel") "canonical ZITADEL host changed"
require
(request.Headers.GetValues("X-Zitadel-Public-Host") |> Seq.exactlyOne = "zitadel")
"public ZITADEL host changed"
if path.StartsWith "/v2/" then
require (request.Headers.Authorization.Scheme = "Bearer") "ZITADEL system JWT was omitted"
let segments = request.Headers.Authorization.Parameter.Split('.')
require (segments.Length = 3) "JWT segment count changed"
use header = JsonDocument.Parse(base64Bytes segments[0])
use claims = JsonDocument.Parse(base64Bytes segments[1])
require (header.RootElement.GetProperty("alg").GetString() = "RS256") "JWT algorithm changed"
require (claims.RootElement.GetProperty("iss").GetString() = "login-client") "JWT issuer changed"
require (claims.RootElement.GetProperty("sub").GetString() = "login-client") "JWT subject changed"
require (claims.RootElement.GetProperty("aud").GetString() = "http://zitadel") "JWT audience changed"
let issued = claims.RootElement.GetProperty("iat").GetInt64()
require (claims.RootElement.GetProperty("exp").GetInt64() = issued + 300L) "JWT lifetime changed"
require
(rsa.VerifyData(
Encoding.UTF8.GetBytes($"{segments[0]}.{segments[1]}"),
base64Bytes segments[2],
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1
))
"JWT signature is invalid"
let status, body, location =
match request.Method.Method, path with
| "GET", "/csrf_token" -> HttpStatusCode.OK, """{"csrf_token":"csrf"}""", None
| "POST", "/api/v1/externallogin/zitadel" ->
require (request.Headers.GetValues("X-CSRF-Token") |> Seq.exactlyOne = "csrf") "Quay CSRF token changed"
require (isNull request.Content.Headers.ContentType.CharSet) "Quay JSON media type included a charset"
require (request.Content.Headers.ContentLength.HasValue) "Quay JSON request omitted its content length"
HttpStatusCode.OK, """{"auth_url":"http://zitadel/oidc/start"}""", None
| "GET", "/oidc/start" -> HttpStatusCode.Found, "", Some "/ui/v2/login/login?authRequest=request-id"
| "POST", "/v2/sessions" -> HttpStatusCode.Created, """{"sessionId":"session-id"}""", None
| "PATCH", "/v2/sessions/session-id" -> HttpStatusCode.OK, """{"sessionToken":"session-token"}""", None
| "POST", "/v2/oidc/auth_requests/request-id" ->
HttpStatusCode.OK, """{"callbackUrl":"http://quay/callback"}""", None
| "GET", "/callback" -> HttpStatusCode.Found, "", Some "http://quay/"
| "PUT", "/api/v1/organization/candidates/team/mirrored-image" -> HttpStatusCode.OK, "", None
| "PUT", "/api/v1/organization/candidates/team/mirrored-image/members/candidates-bot" ->
HttpStatusCode.OK, "", None
| "GET", "/api/v1/organization/candidates/prototypes" -> HttpStatusCode.OK, """{"prototypes":[]}""", None
| "POST", "/api/v1/organization/candidates/prototypes" -> HttpStatusCode.OK, "", None
| "GET", "/api/v1/organization/candidates/applications" ->
HttpStatusCode.OK, """{"applications":[]}""", None
| "POST", "/api/v1/organization/candidates/applications" ->
HttpStatusCode.OK, """{"client_id":"client-id"}""", None
| "GET", "/api/v1/user/assignedauthorization" when not assigned ->
HttpStatusCode.OK, """{"authorizations":[]}""", None
| "POST", "/oauth/authorize/assignuser" ->
require (request.Headers.Authorization.Scheme = "Basic") "admin token did not use Quay Basic auth"
let query = Uri.UnescapeDataString request.RequestUri.Query
require (query.Contains "scope=repo:create+repo:write") "writer scopes changed"
require (not (query.Contains "repo:read")) "writer token redundantly requested read"
assigned <- true
HttpStatusCode.OK, "", None
| "GET", "/api/v1/user/assignedauthorization" ->
let scopes =
if wrongScopes then
"""[{"scope":"repo:read"}]"""
else
"""[{"scope":"repo:create"},{"scope":"repo:write"}]"""
HttpStatusCode.OK,
sprintf
"""{"authorizations":[{"application":{"clientId":"client-id"},"uuid":"assignment-id","redirectUri":"http://quay/oauth/localapp","responseType":"token","scopes":%s}]}"""
scopes,
None
| "GET", "/oauth/authorize" -> HttpStatusCode.OK, """{"csrf_token_val":"approval-csrf"}""", None
| "POST", "/oauth/authorizeapp" ->
require
(request.Headers.GetValues("X-CSRF-Token") |> Seq.exactlyOne = "approval-csrf")
"approval CSRF token changed"
HttpStatusCode.Found, "", Some "http://quay/oauth/localapp#access_token=issued-token"
| _ -> invalidOp $"unexpected request {request.Method} {request.RequestUri}"
let response = new HttpResponseMessage(status)
response.Content <- new StringContent(body, Encoding.UTF8, "application/json")
location
|> Option.iter (fun value -> response.Headers.Location <- Uri(value, UriKind.RelativeOrAbsolute))
response
override this.Send(request: HttpRequestMessage, _: CancellationToken) = this.Respond request
override this.SendAsync(request: HttpRequestMessage, _: CancellationToken) = Task.FromResult(this.Respond request)
let rsa = RSA.Create(2048)
let handler = new QuayHandler(rsa, false)
let adminClient = new HttpClient(handler, false)
let sessionClient () = new HttpClient(handler, false)
let quay =
makeQuayWith
adminClient
sessionClient
{ URL = "http://quay"
ZitadelAPI = "http://zitadel-api"
ZitadelIssuer = "http://zitadel"
CaPath = ""
LoginKey = rsa.ExportRSAPrivateKeyPem() }
let session = quay.Login "candidates-bot" "password" (quay.LoginAuth())
let definition = apps |> List.find (fun app -> app.Name = "candidates-writer")
quay.Access "admin-token" definition
let clientID = quay.Application "admin-token" definition
let app = { definition with Client = clientID }
quay.Assign "admin-token" session app
let token = quay.Issue session app
require (token = "issued-token") "issued Quay token changed"
require (handler.Seen.Count = 19) "bootstrap request sequence changed"
adminClient.Dispose()
let wrongHandler = new QuayHandler(rsa, true)
let wrongAdmin = new HttpClient(wrongHandler, false)
let wrongQuay =
makeQuayWith
wrongAdmin
(fun () -> new HttpClient(wrongHandler, false))
{ URL = "http://quay"
ZitadelAPI = "http://zitadel-api"
ZitadelIssuer = "http://zitadel"
CaPath = ""
LoginKey = rsa.ExportRSAPrivateKeyPem() }
let wrongSession =
wrongQuay.Login "candidates-bot" "password" (wrongQuay.LoginAuth())
wrongQuay.Access "admin-token" definition
let wrongApp =
{ definition with
Client = wrongQuay.Application "admin-token" definition }
let scopeRejected =
try
wrongQuay.Assign "admin-token" wrongSession wrongApp
false
with :? InvalidOperationException as error ->
error.Message = "assigned authorization has different scopes"
require scopeRejected "wrong assigned scopes were accepted"
wrongAdmin.Dispose()
rsa.Dispose()
printfn "Quay bootstrap tests passed"