#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() 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"