ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
31 changed files with 145336 additions and 0 deletions

4
services/mail-keys/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
test-results/
playwright-report/

View 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>

View 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

View 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

View 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

View 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}

View 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 "";
}
}
}

View 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" ]

View 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

View 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

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -0,0 +1,5 @@
{
"issuer": "https://identity.example.invalid",
"clientId": "mail-keys",
"scope": "openid profile email"
}

View 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/);
});

View 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.");
}
}

View 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>
);
}

View 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 });
});

View 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 };
}

View 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