ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
31 changed files with 145336 additions and 0 deletions
4
services/mail-keys/.gitignore
vendored
Normal file
4
services/mail-keys/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
13
services/mail-keys/index.html
Normal file
13
services/mail-keys/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>App passwords</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
services/mail-keys/k8s/delivery.yaml
Normal file
26
services/mail-keys/k8s/delivery.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
apiVersion: delivery.quorate.dev/v1alpha1
|
||||
kind: ProjectDelivery
|
||||
metadata:
|
||||
name: mail-keys
|
||||
namespace: project-delivery
|
||||
spec:
|
||||
source:
|
||||
repository: https://forge.quorate.internal/quorate/cluster
|
||||
continuous:
|
||||
ref: refs/heads/master
|
||||
paths:
|
||||
- services/mail-keys
|
||||
- services/project-delivery/actions/go-oci/cache.fsx
|
||||
- services/project-delivery/actions/go-oci/contract.fsx
|
||||
- services/project-delivery/actions/go-oci/image.fsx
|
||||
- services/project-delivery/actions/go-oci/initialize.fsx
|
||||
- services/project-delivery/actions/go-oci/kustomization.yaml
|
||||
- services/project-delivery/actions/go-oci/publish.fsx
|
||||
- services/project-delivery/actions/go-oci/source.fsx
|
||||
- services/project-delivery/k8s/workflows/go-oci/base
|
||||
- services/project-delivery/kustomization.yaml
|
||||
targets:
|
||||
- kind: QuayOCI
|
||||
repository: quorate/mail-keys
|
||||
workflowTemplateRef:
|
||||
name: mail-keys-build
|
||||
64
services/mail-keys/k8s/delivery/build.fsx
Normal file
64
services/mail-keys/k8s/delivery/build.fsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#load "projects.fsx"
|
||||
#load "image.fsx"
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open Contract
|
||||
open Image
|
||||
open Projects
|
||||
|
||||
let revision = Environment.GetEnvironmentVariable "SOURCE_REVISION"
|
||||
let project = Environment.GetEnvironmentVariable "TARGETS" |> decode<Target array> |> selectProject
|
||||
let source = "/source/repository/services/mail-keys"
|
||||
let working = directory "/output/working"
|
||||
|
||||
for path in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories) do
|
||||
let target = Path.Combine(working, Path.GetRelativePath(source, path))
|
||||
directory (Path.GetDirectoryName target) |> ignore
|
||||
File.Copy(path, target)
|
||||
|
||||
let environment =
|
||||
[ "HOME", "/tmp"; "PATH", "/toolchain/node/bin:/toolchain/usr/bin:/usr/bin:/bin"
|
||||
"LD_LIBRARY_PATH", "/toolchain/usr/lib"; "npm_config_cache", "/tmp/npm" ]
|
||||
|
||||
requireSuccess
|
||||
"/toolchain/usr/bin/busybox"
|
||||
[ "cp"; "-a"; "/toolchain/frontend/node_modules"; Path.Combine(working, "node_modules") ]
|
||||
None
|
||||
environment
|
||||
|> ignore
|
||||
|
||||
let checks =
|
||||
Directory.EnumerateFiles(Path.Combine(working, "src"), "*.test.ts")
|
||||
|> Seq.map (fun path -> Path.GetRelativePath(working, path))
|
||||
|> Seq.toList
|
||||
|
||||
for arguments in
|
||||
[ "--test" :: "--test-isolation=none" :: checks
|
||||
[ "node_modules/typescript/bin/tsc"; "--noEmit" ]
|
||||
[ "node_modules/vite/bin/vite.js"; "build" ] ] do
|
||||
requireSuccess
|
||||
"/toolchain/node/bin/node"
|
||||
arguments
|
||||
(Some working)
|
||||
environment
|
||||
|> ignore
|
||||
|
||||
let files _ =
|
||||
let dist = Path.Combine(working, "dist")
|
||||
|
||||
(Directory.EnumerateFiles(dist, "*", SearchOption.AllDirectories)
|
||||
|> Seq.map (fun path -> Path.Combine("usr/share/nginx/html/account", Path.GetRelativePath(dist, path)), path)
|
||||
|> Seq.toList)
|
||||
@ [ "etc/nginx/nginx.conf", "/orchestration/nginx.conf" ]
|
||||
|
||||
let digest =
|
||||
assemble
|
||||
"/toolchain/image-base"
|
||||
"/output"
|
||||
revision
|
||||
[ "nginx"; "-g"; "daemon off;" ]
|
||||
"/"
|
||||
files
|
||||
|
||||
printfn "build=image project=%s digest=%s" project.Name digest
|
||||
53
services/mail-keys/k8s/delivery/kustomization.yaml
Normal file
53
services/mail-keys/k8s/delivery/kustomization.yaml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namePrefix: mail-keys-
|
||||
resources:
|
||||
- ../../../project-delivery/k8s/workflows/go-oci/base
|
||||
- ../../../project-delivery/actions/go-oci
|
||||
- network-policy.yaml
|
||||
configMapGenerator:
|
||||
- name: orch
|
||||
namespace: project-delivery
|
||||
behavior: merge
|
||||
files:
|
||||
- build.fsx
|
||||
- go-oci.apko.yaml=runtime.apko.yaml
|
||||
- projects.fsx
|
||||
- toolchain.fsx
|
||||
- nginx.conf
|
||||
options:
|
||||
labels:
|
||||
app.kubernetes.io/instance: mail-keys
|
||||
patches:
|
||||
- target:
|
||||
group: argoproj.io
|
||||
version: v1alpha1
|
||||
kind: WorkflowTemplate
|
||||
name: build
|
||||
patch: |-
|
||||
- op: replace
|
||||
path: /metadata/labels/app.kubernetes.io~1instance
|
||||
value: mail-keys
|
||||
- op: replace
|
||||
path: /spec/podMetadata/labels/delivery.quorate.dev~1project
|
||||
value: mail-keys
|
||||
- op: remove
|
||||
path: /spec/templates/2/dag/tasks/1/dependencies
|
||||
- op: remove
|
||||
path: /spec/templates/2/dag/tasks/2/dependencies
|
||||
- op: replace
|
||||
path: /spec/templates/2/dag/tasks/3/dependencies
|
||||
value: [source-pull, cache-pull, toolchain-pull]
|
||||
replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: mail-keys-orch
|
||||
fieldPath: metadata.name
|
||||
targets:
|
||||
- select:
|
||||
group: argoproj.io
|
||||
version: v1alpha1
|
||||
kind: WorkflowTemplate
|
||||
name: mail-keys-build
|
||||
fieldPaths:
|
||||
- spec.templates.*.volumes.*.configMap.name
|
||||
31
services/mail-keys/k8s/delivery/network-policy.yaml
Normal file
31
services/mail-keys/k8s/delivery/network-policy.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: toolchain
|
||||
namespace: project-delivery
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
delivery.quorate.dev/stage: go-oci-toolchain
|
||||
delivery.quorate.dev/project: mail-keys
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- {port: "53", protocol: UDP}
|
||||
- {port: "53", protocol: TCP}
|
||||
rules:
|
||||
dns:
|
||||
- matchName: packages.wolfi.dev
|
||||
- matchName: nodejs.org
|
||||
- matchName: registry.npmjs.org
|
||||
- toFQDNs:
|
||||
- {matchName: packages.wolfi.dev}
|
||||
- {matchName: nodejs.org}
|
||||
- {matchName: registry.npmjs.org}
|
||||
toPorts:
|
||||
- ports:
|
||||
- {port: "443", protocol: TCP}
|
||||
66
services/mail-keys/k8s/delivery/nginx.conf
Normal file
66
services/mail-keys/k8s/delivery/nginx.conf
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr notice;
|
||||
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log off;
|
||||
client_body_temp_path /tmp/client_body;
|
||||
proxy_temp_path /tmp/proxy;
|
||||
fastcgi_temp_path /tmp/fastcgi;
|
||||
uwsgi_temp_path /tmp/uwsgi;
|
||||
scgi_temp_path /tmp/scgi;
|
||||
|
||||
map $server_port $client_addr {
|
||||
default $remote_addr;
|
||||
8082 $http_x_forwarded_for;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8082;
|
||||
listen 8443 ssl;
|
||||
server_name mail.quorate.dev;
|
||||
absolute_redirect off;
|
||||
client_max_body_size 80m;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
ssl_certificate /run/tls/tls.crt;
|
||||
ssl_certificate_key /run/tls/tls.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location = /healthz {
|
||||
return 200 'ok';
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 302 /account/;
|
||||
}
|
||||
|
||||
location = /login {
|
||||
return 302 /account/login;
|
||||
}
|
||||
|
||||
location = /account/config.json {
|
||||
alias /run/config/config.json;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /account/ {
|
||||
try_files $uri $uri/ /account/index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $client_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
}
|
||||
}
|
||||
36
services/mail-keys/k8s/delivery/projects.fsx
Normal file
36
services/mail-keys/k8s/delivery/projects.fsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#load "contract.fsx"
|
||||
|
||||
open System.IO
|
||||
open System.Runtime.InteropServices
|
||||
open Contract
|
||||
|
||||
type Project =
|
||||
{ Name: string
|
||||
Workflow: string
|
||||
Repository: string }
|
||||
|
||||
let project =
|
||||
{ Name = "mail-keys"
|
||||
Workflow = "mail-keys-build"
|
||||
Repository = "quorate/mail-keys" }
|
||||
|
||||
let selectProject (targets: Target array) =
|
||||
require
|
||||
(targets.Length = 1
|
||||
&& targets[0].Kind = "QuayOCI"
|
||||
&& targets[0].Repository = project.Repository)
|
||||
"invalid delivery target"
|
||||
|
||||
project
|
||||
|
||||
let inputPaths _ (_: Project) =
|
||||
[ "services/mail-keys/package.json"; "services/mail-keys/package-lock.json" ]
|
||||
|
||||
let validateProject sourceRoot value =
|
||||
inputPaths sourceRoot value
|
||||
|> List.iter (fun path -> require (File.Exists(Path.Combine(sourceRoot, path))) $"missing input: {path}")
|
||||
|
||||
let toolchainProfileName = $"mail-keys-{RuntimeInformation.ProcessArchitecture}"
|
||||
|
||||
let toolchainCachePaths (_: Project) =
|
||||
[ "node"; "frontend"; "usr"; "image-base" ]
|
||||
20
services/mail-keys/k8s/delivery/runtime.apko.yaml
Normal file
20
services/mail-keys/k8s/delivery/runtime.apko.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
contents:
|
||||
keyring:
|
||||
- https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
|
||||
repositories:
|
||||
- https://packages.wolfi.dev/os
|
||||
packages:
|
||||
- nginx-stable=1.30.2-r0
|
||||
- nginx-stable-mime-types=1.30.2-r0
|
||||
- wolfi-baselayout=20230201-r30
|
||||
accounts:
|
||||
groups:
|
||||
- groupname: application
|
||||
gid: 65532
|
||||
users:
|
||||
- username: application
|
||||
uid: 65532
|
||||
gid: 65532
|
||||
run-as: 65532
|
||||
environment:
|
||||
PATH: /usr/sbin:/usr/bin:/sbin:/bin
|
||||
120
services/mail-keys/k8s/delivery/toolchain.fsx
Normal file
120
services/mail-keys/k8s/delivery/toolchain.fsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
#load "projects.fsx"
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Net.Http
|
||||
open System.Runtime.InteropServices
|
||||
open Contract
|
||||
open Projects
|
||||
|
||||
let project =
|
||||
Environment.GetEnvironmentVariable "TARGETS"
|
||||
|> decode<Target array>
|
||||
|> selectProject
|
||||
|
||||
let architecture, nodeArch, digest =
|
||||
match RuntimeInformation.ProcessArchitecture with
|
||||
| Architecture.X64 -> "x86_64", "x64", "5848553868b402d3fabb5b09270649977780a390f832155721025a08b5104455"
|
||||
| Architecture.Arm64 -> "aarch64", "arm64", "439e9c438acdf6a6ffe47418da709153d66c4bed812e30a473faacb107dd2791"
|
||||
| _ -> fail "unsupported build architecture"
|
||||
|
||||
if not (Handoff.primary "/state") then
|
||||
printfn "toolchain=cache"
|
||||
else
|
||||
requireSuccess
|
||||
"/sbin/apk"
|
||||
[ "add"
|
||||
"--root"
|
||||
"/toolchain"
|
||||
"--arch"
|
||||
architecture
|
||||
"--initdb"
|
||||
"--no-scripts"
|
||||
"--keys-dir"
|
||||
"/etc/apk/keys"
|
||||
"--repository"
|
||||
"https://packages.wolfi.dev/os"
|
||||
"busybox=1.38.0-r3"
|
||||
"glibc-2.43=2.43-r18"
|
||||
"ld-linux-2.43=2.43-r18"
|
||||
"libgcc=16.2.0-r1"
|
||||
"libstdc++=16.2.0-r1" ]
|
||||
None
|
||||
[]
|
||||
|> ignore
|
||||
|
||||
let name = $"node-v26.13.0-linux-{nodeArch}"
|
||||
let archive = "/tmp/node.tar.gz"
|
||||
use client = new HttpClient(Timeout = TimeSpan.FromMinutes 5.0)
|
||||
|
||||
use input =
|
||||
client
|
||||
.GetStreamAsync($"https://nodejs.org/dist/v26.13.0/{name}.tar.gz", cancellationToken)
|
||||
.GetAwaiter()
|
||||
.GetResult()
|
||||
|
||||
do
|
||||
use output = File.Create archive
|
||||
input.CopyTo output
|
||||
|
||||
require (sha256 archive = digest) "Node.js checksum mismatch"
|
||||
extractGzip archive "/toolchain"
|
||||
Directory.Move($"/toolchain/{name}", "/toolchain/node")
|
||||
File.Delete archive
|
||||
|
||||
use npm =
|
||||
client
|
||||
.GetStreamAsync("https://registry.npmjs.org/npm/-/npm-12.2.1.tgz", cancellationToken)
|
||||
.GetAwaiter()
|
||||
.GetResult()
|
||||
|
||||
do
|
||||
use output = File.Create archive
|
||||
npm.CopyTo output
|
||||
|
||||
require
|
||||
(sha256 archive = "228bd64aaef23270dbdbd2cf3d5ef5c467602ef38c4066246cef1984adab9a67")
|
||||
"npm checksum mismatch"
|
||||
|
||||
extractGzip archive (directory "/toolchain/npm")
|
||||
Directory.Delete("/toolchain/node/lib/node_modules/npm", true)
|
||||
Directory.Move("/toolchain/npm/package", "/toolchain/node/lib/node_modules/npm")
|
||||
File.Delete archive
|
||||
directory "/toolchain/frontend" |> ignore
|
||||
|
||||
[ "package.json"; "package-lock.json" ]
|
||||
|> List.iter (fun name -> File.Copy($"/source/repository/services/mail-keys/{name}", $"/toolchain/frontend/{name}"))
|
||||
|
||||
let environment =
|
||||
[ "HOME", "/tmp"
|
||||
"PATH", "/toolchain/node/bin:/usr/bin:/bin"
|
||||
"LD_LIBRARY_PATH", "/toolchain/usr/lib"
|
||||
"npm_config_cache", "/tmp/npm" ]
|
||||
|
||||
requireSuccess
|
||||
"/toolchain/node/bin/node"
|
||||
[ "/toolchain/node/lib/node_modules/npm/bin/npm-cli.js"
|
||||
"ci"
|
||||
"--ignore-scripts"
|
||||
"--no-audit"
|
||||
"--no-fund" ]
|
||||
(Some "/toolchain/frontend")
|
||||
environment
|
||||
|> ignore
|
||||
|
||||
directory "/toolchain/image-base" |> ignore
|
||||
|
||||
requireSuccess
|
||||
"/usr/bin/apko"
|
||||
[ "build"
|
||||
"--arch"
|
||||
"x86_64,arm64"
|
||||
"--sbom=false"
|
||||
"/orchestration/go-oci.apko.yaml"
|
||||
"mail-keys:base"
|
||||
"/toolchain/image-base" ]
|
||||
None
|
||||
environment
|
||||
|> ignore
|
||||
|
||||
printfn "toolchain=primary project=%s" project.Name
|
||||
5
services/mail-keys/k8s/kustomization.yaml
Normal file
5
services/mail-keys/k8s/kustomization.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- delivery.yaml
|
||||
- delivery
|
||||
1314
services/mail-keys/package-lock.json
generated
Normal file
1314
services/mail-keys/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
services/mail-keys/package.json
Normal file
28
services/mail-keys/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "mail-keys",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "node --test --test-isolation=none src/*.test.ts",
|
||||
"test:e2e": "npm run build && tsc --project tests/tsconfig.json && node tests/run.ts",
|
||||
"build": "npm run check && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"preview:stack": "npm run build && tsc --project tests/tsconfig.json && node tests/run.ts --serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "3.5.0",
|
||||
"preact": "11.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1",
|
||||
"@types/node": "26.6.5",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.5"
|
||||
}
|
||||
}
|
||||
5
services/mail-keys/public/config.json
Normal file
5
services/mail-keys/public/config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"issuer": "https://identity.example.invalid",
|
||||
"clientId": "mail-keys",
|
||||
"scope": "openid profile email"
|
||||
}
|
||||
73
services/mail-keys/src/api.test.ts
Normal file
73
services/mail-keys/src/api.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { ApiError, KeysApi } from "./api.ts";
|
||||
|
||||
function server(methodResponses: unknown[], inspect?: (body: Record<string, unknown>) => void) {
|
||||
const request: typeof fetch = async (url, init) => {
|
||||
assert.equal(url, "/jmap/");
|
||||
assert.equal(init?.credentials, "omit");
|
||||
assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer synthetic-token");
|
||||
const body = JSON.parse(init?.body as string) as Record<string, unknown>;
|
||||
const calls = body.methodCalls as [string, Record<string, unknown>, string][];
|
||||
for (const [, args] of calls) assert.equal(Object.hasOwn(args, "accountId"), false);
|
||||
inspect?.(body);
|
||||
return Response.json({ methodResponses });
|
||||
};
|
||||
return new KeysApi("synthetic-token", request);
|
||||
}
|
||||
|
||||
test("listing uses the caller token without requesting existing secrets", async () => {
|
||||
const key = { id: "a", description: "Phone", createdAt: "2026-10-27T00:00:00Z", expiresAt: null };
|
||||
const api = server([
|
||||
["x:AppPassword/query", { ids: ["a"] }, "query"],
|
||||
["x:AppPassword/get", { list: [key] }, "get"],
|
||||
], (body) => {
|
||||
assert.equal(JSON.stringify(body).includes('"secret"'), false);
|
||||
assert.deepEqual(body.using, ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]);
|
||||
});
|
||||
assert.deepEqual(await api.list(), [key]);
|
||||
});
|
||||
|
||||
test("creation exposes only the secret generated by the server", async () => {
|
||||
const api = server([["x:AppPassword/set", {
|
||||
created: { key: { id: "a", secret: "app_synthetic" } },
|
||||
}, "set"]], (body) => {
|
||||
const [, args] = (body.methodCalls as [string, Record<string, unknown>, string][])[0];
|
||||
const create = args.create as Record<string, Record<string, unknown>>;
|
||||
assert.equal(Object.hasOwn(create.key, "secret"), false);
|
||||
const permissions = create.key.permissions as { "@type": string; permissions: Record<string, boolean> };
|
||||
assert.equal(permissions["@type"], "Replace");
|
||||
assert.equal(Object.keys(permissions.permissions).some((name) => name.startsWith("sys")), false);
|
||||
});
|
||||
assert.deepEqual(await api.create("Phone"), { id: "a", secret: "app_synthetic" });
|
||||
});
|
||||
|
||||
test("a per-object update failure is not reported as a successful update", async () => {
|
||||
const api = server([["x:AppPassword/set", {
|
||||
notUpdated: { a: { type: "forbidden", description: "Permission denied." } },
|
||||
}, "set"]]);
|
||||
await assert.rejects(api.update("a", "Laptop"), { message: "Permission denied." });
|
||||
});
|
||||
|
||||
test("a per-object deletion failure is not reported as successful deletion", async () => {
|
||||
const api = server([["x:AppPassword/set", { notDestroyed: { a: { type: "notFound" } } }, "set"]]);
|
||||
await assert.rejects(api.delete("a"), { message: "notFound" });
|
||||
});
|
||||
|
||||
test("HTTP expiry is distinguished from a JMAP method failure", async () => {
|
||||
const request: typeof fetch = async () => new Response(null, { status: 401 });
|
||||
await assert.rejects(new KeysApi("synthetic-token", request).list(), (error: unknown) => {
|
||||
assert.ok(error instanceof ApiError);
|
||||
assert.equal(error.status, 401);
|
||||
return true;
|
||||
});
|
||||
const api = server([["error", { type: "unknownMethod" }, "query"]]);
|
||||
await assert.rejects(api.list(), { message: "unknownMethod" });
|
||||
});
|
||||
|
||||
test("missing mutation confirmation is not presented as success", async () => {
|
||||
const api = server([["x:AppPassword/set", {}, "set"]]);
|
||||
await assert.rejects(api.update("a", "Laptop"), /did not update/);
|
||||
await assert.rejects(api.delete("a"), /did not delete/);
|
||||
await assert.rejects(api.create("Phone"), /did not return the new app password/);
|
||||
});
|
||||
129
services/mail-keys/src/api.ts
Normal file
129
services/mail-keys/src/api.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { mailPermissions } from "./permissions.ts";
|
||||
|
||||
export interface AppKey {
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface NewKey {
|
||||
id: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
interface Failure {
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SetResult {
|
||||
created?: Record<string, NewKey>;
|
||||
updated?: Record<string, unknown>;
|
||||
destroyed?: string[];
|
||||
notCreated?: Record<string, Failure>;
|
||||
notUpdated?: Record<string, Failure>;
|
||||
notDestroyed?: Record<string, Failure>;
|
||||
}
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
type Reply = [string, Record<string, unknown>, string];
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function failure(value: Failure): ApiError {
|
||||
return new ApiError(value.description || value.type);
|
||||
}
|
||||
|
||||
export class KeysApi {
|
||||
private token: string;
|
||||
private request: typeof fetch;
|
||||
|
||||
constructor(token: string, request: typeof fetch = (input, init) => fetch(input, init)) {
|
||||
this.token = token;
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
private async call(calls: Call[]): Promise<Reply[]> {
|
||||
const response = await this.request("/jmap/", {
|
||||
method: "POST",
|
||||
credentials: "omit",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
using: ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
|
||||
methodCalls: calls,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status === 401
|
||||
? "Your session has expired. Sign in again."
|
||||
: `Unable to complete the request (HTTP ${response.status}).`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const value = await response.json() as { methodResponses: Reply[] };
|
||||
for (const [method, result] of value.methodResponses) {
|
||||
if (method === "error") throw failure(result as unknown as Failure);
|
||||
}
|
||||
return value.methodResponses;
|
||||
}
|
||||
|
||||
async list(): Promise<AppKey[]> {
|
||||
const replies = await this.call([
|
||||
["x:AppPassword/query", {}, "query"],
|
||||
["x:AppPassword/get", {
|
||||
"#ids": { resultOf: "query", name: "x:AppPassword/query", path: "/ids" },
|
||||
properties: ["id", "description", "createdAt", "expiresAt"],
|
||||
}, "get"],
|
||||
]);
|
||||
const result = replies.find(([method, , id]) => method === "x:AppPassword/get" && id === "get");
|
||||
if (!result) throw new ApiError("App-password listing returned no result.");
|
||||
return result[1].list as AppKey[];
|
||||
}
|
||||
|
||||
private async change(args: Record<string, unknown>): Promise<SetResult> {
|
||||
const replies = await this.call([["x:AppPassword/set", args, "set"]]);
|
||||
const result = replies.find(([method, , id]) => method === "x:AppPassword/set" && id === "set");
|
||||
if (!result) throw new ApiError("App-password change returned no result.");
|
||||
return result[1] as SetResult;
|
||||
}
|
||||
|
||||
async create(description: string): Promise<NewKey> {
|
||||
const result = await this.change({
|
||||
create: {
|
||||
key: {
|
||||
description,
|
||||
permissions: { "@type": "Replace", permissions: mailPermissions },
|
||||
allowedIps: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (result.notCreated?.key) throw failure(result.notCreated.key);
|
||||
const created = result.created?.key;
|
||||
if (!created?.secret) throw new ApiError("The server did not return the new app password.");
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, description: string): Promise<void> {
|
||||
const result = await this.change({ update: { [id]: { description } } });
|
||||
if (result.notUpdated?.[id]) throw failure(result.notUpdated[id]);
|
||||
if (!Object.hasOwn(result.updated ?? {}, id)) throw new ApiError("The server did not update the app password.");
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const result = await this.change({ destroy: [id] });
|
||||
if (result.notDestroyed?.[id]) throw failure(result.notDestroyed[id]);
|
||||
if (!result.destroyed?.includes(id)) throw new ApiError("The server did not delete the app password.");
|
||||
}
|
||||
}
|
||||
228
services/mail-keys/src/app.tsx
Normal file
228
services/mail-keys/src/app.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { useEffect, useState } from "preact/hooks";
|
||||
import type { User, UserManager } from "oidc-client-ts";
|
||||
import { ApiError, KeysApi } from "./api.ts";
|
||||
import type { AppKey, NewKey } from "./api.ts";
|
||||
import type { Session } from "./auth.ts";
|
||||
|
||||
interface Props {
|
||||
auth: UserManager;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "The request could not be completed.";
|
||||
}
|
||||
|
||||
function date(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function App({ auth, session }: Props) {
|
||||
const [user, setUser] = useState<User | null>(session.user);
|
||||
const [keys, setKeys] = useState<AppKey[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [created, setCreated] = useState<NewKey | null>(null);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [logoutHint, setLogoutHint] = useState<string | null>(null);
|
||||
|
||||
function fail(error: unknown) {
|
||||
setError(message(error));
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
void auth.removeUser();
|
||||
setUser(null);
|
||||
setKeys(null);
|
||||
setCreated(null);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
let active = true;
|
||||
setBusy(true);
|
||||
new KeysApi(user.access_token).list()
|
||||
.then((value) => { if (active) setKeys(value); })
|
||||
.catch((error: unknown) => { if (active) fail(error); })
|
||||
.finally(() => { if (active) setBusy(false); });
|
||||
return () => { active = false; };
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const expired = () => {
|
||||
void auth.removeUser();
|
||||
setUser(null);
|
||||
setKeys(null);
|
||||
setCreated(null);
|
||||
setBusy(false);
|
||||
setError("Your session has expired. Sign in again.");
|
||||
};
|
||||
auth.events.addAccessTokenExpired(expired);
|
||||
return () => auth.events.removeAccessTokenExpired(expired);
|
||||
}, [auth]);
|
||||
|
||||
async function signIn() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await auth.signinRedirect({ nonce: crypto.randomUUID() });
|
||||
} catch (error) {
|
||||
setError(`Unable to sign in. ${message(error)}`);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const hint = user?.id_token ?? logoutHint;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus("");
|
||||
setCreated(null);
|
||||
setKeys(null);
|
||||
setEditId(null);
|
||||
setDeleteId(null);
|
||||
setUser(null);
|
||||
try {
|
||||
await auth.signoutRedirect({ id_token_hint: hint ?? undefined, state: { signedOut: true } });
|
||||
} catch (error) {
|
||||
setLogoutHint(hint ?? null);
|
||||
setError(`Unable to finish signing out of Zitadel. ${message(error)}`);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function change(action: (api: KeysApi) => Promise<void>, done: string) {
|
||||
if (!user) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus("");
|
||||
try {
|
||||
const api = new KeysApi(user.access_token);
|
||||
await action(api);
|
||||
setStatus(done);
|
||||
setKeys(await api.list());
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const account = user?.profile.email ?? user?.profile.preferred_username ?? user?.profile.name;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<h1>App passwords</h1>
|
||||
{user && <button disabled={busy} onClick={() => void signOut()}>Sign out</button>}
|
||||
</header>
|
||||
{error && <p role="alert" class="error">{error}</p>}
|
||||
<p role="status" class="status">{busy ? "Please wait…" : status}</p>
|
||||
{!user ? (
|
||||
<section aria-labelledby="sign-in">
|
||||
<h2 id="sign-in">{session.signedOut ? "Signed out" : "Sign in"}</h2>
|
||||
{session.signedOut && <p>You have signed out of this app and Zitadel.</p>}
|
||||
{logoutHint ? (
|
||||
<button disabled={busy} onClick={() => void signOut()}>Finish sign out</button>
|
||||
) : (
|
||||
<button disabled={busy} onClick={() => void signIn()}>Sign in with Zitadel</button>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{account && <p class="account">Signed in as {account}</p>}
|
||||
{created && (
|
||||
<section class="new-key" aria-labelledby="new-key">
|
||||
<h2 id="new-key">New app password</h2>
|
||||
<p>Save this password now. It cannot be shown again.</p>
|
||||
<label for="secret">New app password</label>
|
||||
<div class="line">
|
||||
<input id="secret" class="secret" value={created.secret} readOnly spellcheck={false} autoComplete="off" />
|
||||
<button onClick={() => setCreated(null)}>Done</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section aria-labelledby="create">
|
||||
<h2 id="create">Create app password</h2>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void change(async (api) => {
|
||||
setCreated(await api.create(description.trim()));
|
||||
setDescription("");
|
||||
}, "App password created.");
|
||||
}}>
|
||||
<label for="description">Description</label>
|
||||
<div class="line">
|
||||
<input id="description" value={description} required onInput={(event) => setDescription(event.currentTarget.value)} disabled={busy} autoComplete="off" />
|
||||
<button disabled={busy || !description.trim()}>Create app password</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section aria-labelledby="list">
|
||||
<h2 id="list">Your app passwords</h2>
|
||||
{keys?.length === 0 && <p>No app passwords.</p>}
|
||||
{keys && keys.length > 0 && (
|
||||
<ul class="keys">
|
||||
{keys.map((key) => (
|
||||
<li key={key.id}>
|
||||
{editId === key.id ? (
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void change(async (api) => {
|
||||
await api.update(key.id, editName.trim());
|
||||
setEditId(null);
|
||||
}, "App password updated.");
|
||||
}}>
|
||||
<label for="edit-name">Description</label>
|
||||
<div class="line">
|
||||
<input id="edit-name" value={editName} required onInput={(event) => setEditName(event.currentTarget.value)} disabled={busy} autoComplete="off" />
|
||||
<button disabled={busy || !editName.trim()}>Save changes</button>
|
||||
<button type="button" disabled={busy} onClick={() => setEditId(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div class="key-line">
|
||||
<h3>{key.description}</h3>
|
||||
{deleteId === key.id ? (
|
||||
<div class="actions">
|
||||
<span>Delete?</span>
|
||||
<button disabled={busy} onClick={() => void change(async (api) => {
|
||||
await api.delete(key.id);
|
||||
if (created?.id === key.id) setCreated(null);
|
||||
setDeleteId(null);
|
||||
}, "App password deleted.")}>Confirm delete</button>
|
||||
<button disabled={busy} onClick={() => setDeleteId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<div class="actions">
|
||||
<button disabled={busy} aria-label={`Edit ${key.description}`} onClick={() => {
|
||||
setEditId(key.id);
|
||||
setEditName(key.description);
|
||||
setDeleteId(null);
|
||||
}}>Edit</button>
|
||||
<button disabled={busy} aria-label={`Delete ${key.description}`} onClick={() => {
|
||||
setDeleteId(key.id);
|
||||
setEditId(null);
|
||||
}}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p class="meta">
|
||||
Created <time dateTime={key.createdAt}>{date(key.createdAt)}</time>
|
||||
{key.expiresAt && <> · Expires <time dateTime={key.expiresAt}>{date(key.expiresAt)}</time></>}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
78
services/mail-keys/src/auth.test.ts
Normal file
78
services/mail-keys/src/auth.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { User } from "oidc-client-ts";
|
||||
import { restore } from "./auth.ts";
|
||||
import type { AuthClient } from "./auth.ts";
|
||||
|
||||
function client(calls: string[], user: User | null = null): AuthClient {
|
||||
return {
|
||||
async signinRedirectCallback() { calls.push("signin"); return user!; },
|
||||
async signoutRedirectCallback() { calls.push("signout"); },
|
||||
async removeUser() { calls.push("remove"); },
|
||||
async getUser() { calls.push("get"); return user; },
|
||||
};
|
||||
}
|
||||
|
||||
test("signed-out callback finishes protocol validation before showing the landing page", async () => {
|
||||
const calls: string[] = [];
|
||||
const session = await restore(
|
||||
client(calls),
|
||||
new URL("https://mail.example.test/account/oauth/logout?state=synthetic"),
|
||||
() => calls.push("clean"),
|
||||
);
|
||||
assert.deepEqual(calls, ["signout", "remove", "clean"]);
|
||||
assert.deepEqual(session, { user: null, signedOut: true });
|
||||
});
|
||||
|
||||
test("an invalid logout state never produces a successful signed-out session", async () => {
|
||||
const calls: string[] = [];
|
||||
const auth = client(calls);
|
||||
auth.signoutRedirectCallback = async () => { throw new Error("Invalid state"); };
|
||||
await assert.rejects(restore(
|
||||
auth,
|
||||
new URL("https://mail.example.test/account/oauth/logout?state=bad"),
|
||||
() => calls.push("clean"),
|
||||
), /Invalid state/);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("opening the logout callback directly does not claim IdP logout or remove the active user", async () => {
|
||||
const calls: string[] = [];
|
||||
const auth = client(calls, { expired: false } as User);
|
||||
await assert.rejects(restore(
|
||||
auth,
|
||||
new URL("https://mail.example.test/account/oauth/logout"),
|
||||
() => calls.push("clean"),
|
||||
), /Sign-out response is missing state/);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("failed sign-in clears callback parameters without restoring a previous user", async () => {
|
||||
const calls: string[] = [];
|
||||
const auth = client(calls);
|
||||
auth.signinRedirectCallback = async () => { throw new Error("Invalid state"); };
|
||||
await assert.rejects(restore(
|
||||
auth,
|
||||
new URL("https://mail.example.test/account/oauth/callback?code=synthetic&state=bad"),
|
||||
() => calls.push("clean"),
|
||||
), /Invalid state/);
|
||||
assert.deepEqual(calls, ["clean"]);
|
||||
});
|
||||
|
||||
test("an expired local session is removed without starting another sign-in", async () => {
|
||||
const calls: string[] = [];
|
||||
const session = await restore(
|
||||
client(calls, { expired: true } as User),
|
||||
new URL("https://mail.example.test/account/"),
|
||||
() => calls.push("clean"),
|
||||
);
|
||||
assert.deepEqual(calls, ["get", "remove"]);
|
||||
assert.deepEqual(session, { user: null, signedOut: false });
|
||||
});
|
||||
|
||||
test("an unauthenticated landing page does not start an authorization request", async () => {
|
||||
const calls: string[] = [];
|
||||
const session = await restore(client(calls), new URL("https://mail.example.test/account/"), () => calls.push("clean"));
|
||||
assert.deepEqual(calls, ["get"]);
|
||||
assert.deepEqual(session, { user: null, signedOut: false });
|
||||
});
|
||||
72
services/mail-keys/src/auth.ts
Normal file
72
services/mail-keys/src/auth.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { User, UserManagerSettings } from "oidc-client-ts";
|
||||
import type { Config } from "./config.ts";
|
||||
|
||||
export const loginPath = "/account/oauth/callback";
|
||||
export const logoutPath = "/account/oauth/logout";
|
||||
|
||||
export function authSettings(
|
||||
config: Config,
|
||||
origin: string,
|
||||
storage: Storage,
|
||||
): UserManagerSettings {
|
||||
return {
|
||||
authority: config.issuer,
|
||||
client_id: config.clientId,
|
||||
scope: config.scope,
|
||||
redirect_uri: origin + loginPath,
|
||||
post_logout_redirect_uri: origin + logoutPath,
|
||||
response_type: "code",
|
||||
automaticSilentRenew: false,
|
||||
monitorSession: false,
|
||||
loadUserInfo: false,
|
||||
userStore: new WebStorageStateStore({ store: storage }),
|
||||
stateStore: new WebStorageStateStore({ store: storage }),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeAuth(config: Config): UserManager {
|
||||
return new UserManager(authSettings(config, location.origin, sessionStorage));
|
||||
}
|
||||
|
||||
export interface AuthClient {
|
||||
signinRedirectCallback(url: string): Promise<User>;
|
||||
signoutRedirectCallback(url: string): Promise<unknown>;
|
||||
removeUser(): Promise<void>;
|
||||
getUser(): Promise<User | null>;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user: User | null;
|
||||
signedOut: boolean;
|
||||
}
|
||||
|
||||
export async function restore(
|
||||
auth: AuthClient,
|
||||
url: URL,
|
||||
cleanUrl: () => void,
|
||||
): Promise<Session> {
|
||||
if (url.pathname === logoutPath) {
|
||||
if (!url.searchParams.get("state")) {
|
||||
throw new Error("Sign-out response is missing state. Start sign-out from the app.");
|
||||
}
|
||||
await auth.signoutRedirectCallback(url.href);
|
||||
await auth.removeUser();
|
||||
cleanUrl();
|
||||
return { user: null, signedOut: true };
|
||||
}
|
||||
if (url.pathname === loginPath) {
|
||||
try {
|
||||
const user = await auth.signinRedirectCallback(url.href);
|
||||
return { user, signedOut: false };
|
||||
} finally {
|
||||
cleanUrl();
|
||||
}
|
||||
}
|
||||
const user = await auth.getUser();
|
||||
if (user?.expired) {
|
||||
await auth.removeUser();
|
||||
return { user: null, signedOut: false };
|
||||
}
|
||||
return { user, signedOut: false };
|
||||
}
|
||||
18
services/mail-keys/src/config.ts
Normal file
18
services/mail-keys/src/config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface Config {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
const response = await fetch("/account/config.json", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Unable to load sign-in configuration.");
|
||||
const value: unknown = await response.json();
|
||||
if (
|
||||
typeof value !== "object" || value === null ||
|
||||
!("issuer" in value) || typeof value.issuer !== "string" || !value.issuer ||
|
||||
!("clientId" in value) || typeof value.clientId !== "string" || !value.clientId ||
|
||||
!("scope" in value) || typeof value.scope !== "string" || !value.scope
|
||||
) throw new Error("Sign-in configuration requires issuer, clientId and scope.");
|
||||
return { issuer: value.issuer, clientId: value.clientId, scope: value.scope };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue