From 4c9bee963ca7bf388a35e6a3beeeb53a5d305e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 28 Oct 2026 08:18:31 +0000 Subject: [PATCH] fix(configurator): renew sign-in safely and propose template permissions --- .../api/db/075_stackit_session_renewal.sql | 81 +++ .../app/apps/api/src/app.ts | 1 + .../assistant/knowledge-topics.generated.ts | 4 +- .../apps/api/src/assistant/model-serving.ts | 269 +++++++++- .../app/apps/api/src/auth/routes.ts | 8 + .../app/apps/api/src/auth/stackit-device.ts | 42 ++ .../app/apps/api/src/auth/stackit-login.ts | 24 +- .../app/apps/api/src/auth/stackit-sessions.ts | 309 +++++++++++ .../app/apps/api/src/main.ts | 10 + .../app/apps/web/src/components/Account.tsx | 166 +++++- .../apps/web/src/components/Organisation.tsx | 13 +- .../app/apps/web/src/locales/en.ts | 9 + .../app/apps/web/src/login-draft.ts | 137 ++++- .../app/apps/web/src/main.tsx | 180 ++++++- .../app/e2e/configurator.spec.ts | 133 ++--- .../app/e2e/session-lifecycle.spec.ts | 499 ++++++++++++++++++ .../app/integration/stackit-sessions.test.ts | 311 +++++++++++ .../app/scripts/assistant-scenarios.ts | 91 +++- .../app/tests/assistant-editor.test.ts | 305 ++++++++++- .../app/tests/login-draft.test.ts | 90 ++++ .../app/tests/stackit-session-routes.test.ts | 147 ++++++ .../app/tests/stackit-sessions.test.ts | 135 +++++ .../docs/assistant-test-catalogue.json | 58 +- .../docs/development.de.md | 11 + landing-zone-configurator/docs/development.md | 8 + .../docs/github-login.de.md | 67 ++- .../docs/github-login.md | 57 +- .../docs/project-template-drafts.de.md | 2 + .../docs/project-template-drafts.md | 2 + 29 files changed, 3003 insertions(+), 166 deletions(-) create mode 100644 landing-zone-configurator/app/apps/api/db/075_stackit_session_renewal.sql create mode 100644 landing-zone-configurator/app/apps/api/src/auth/stackit-sessions.ts create mode 100644 landing-zone-configurator/app/e2e/session-lifecycle.spec.ts create mode 100644 landing-zone-configurator/app/integration/stackit-sessions.test.ts create mode 100644 landing-zone-configurator/app/tests/login-draft.test.ts create mode 100644 landing-zone-configurator/app/tests/stackit-session-routes.test.ts create mode 100644 landing-zone-configurator/app/tests/stackit-sessions.test.ts diff --git a/landing-zone-configurator/app/apps/api/db/075_stackit_session_renewal.sql b/landing-zone-configurator/app/apps/api/db/075_stackit_session_renewal.sql new file mode 100644 index 00000000..48e0b81f --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/075_stackit_session_renewal.sql @@ -0,0 +1,81 @@ +CREATE OR REPLACE FUNCTION lzc_auth.complete_stackit_login(p_issuer text,p_subject text,p_email text,p_method text,p_session uuid,p_hash text,p_csrf text,p_expires timestamptz,p_proof_expires timestamptz,p_existing_session uuid DEFAULT NULL) +RETURNS TABLE(user_id uuid,tenant_id uuid,github_id text) +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE account uuid; workspace uuid; existing_account uuid; active_workspace uuid; +BEGIN + IF p_issuer IS DISTINCT FROM 'https://accounts.stackit.cloud' OR p_subject IS NULL OR length(p_subject) NOT BETWEEN 1 AND 255 + OR p_email IS NULL OR length(p_email) NOT BETWEEN 3 AND 254 OR p_email !~ '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$' OR p_email ~* '@sa\.stackit\.cloud$' + OR p_method IS NULL OR p_method NOT IN ('signed-id-token-and-userinfo','device-grant-userinfo') + OR p_expires IS NULL OR p_proof_expires IS NULL OR p_expires<=now() OR p_expires>now()+interval '8 hours' OR p_proof_expires<=now() OR p_proof_expires>now()+interval '24 hours' THEN + RAISE EXCEPTION 'invalid_stackit_identity' USING ERRCODE='23514'; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended(p_issuer || ':' || p_subject,0)); + SELECT i.user_id INTO account FROM lzc_auth.external_identities i WHERE i.provider='oidc' AND i.issuer=p_issuer AND i.subject=p_subject; + IF p_existing_session IS NOT NULL THEN + SELECT s.user_id,coalesce(s.active_tenant_id,s.tenant_id) INTO STRICT existing_account,active_workspace FROM lzc_auth.sessions s JOIN lzc.memberships m ON m.user_id=s.user_id AND m.tenant_id=coalesce(s.active_tenant_id,s.tenant_id) + JOIN lzc.tenants t ON t.id=m.tenant_id AND t.archived_at IS NULL + WHERE s.id=p_existing_session AND s.expires_at>now() FOR UPDATE OF s; + IF account IS NOT NULL AND account<>existing_account THEN RAISE EXCEPTION 'identity_already_bound' USING ERRCODE='23505'; END IF; + END IF; + IF account IS NULL THEN + SELECT i.user_id INTO account FROM lzc.stackit_identities i WHERE i.issuer=p_issuer AND i.subject=p_subject; + IF existing_account IS NOT NULL THEN + IF account IS NOT NULL AND account<>existing_account THEN RAISE EXCEPTION 'identity_already_bound' USING ERRCODE='23505'; END IF; + account:=existing_account; + END IF; + IF account IS NULL THEN + INSERT INTO lzc_auth.users(display_name) VALUES(p_email) RETURNING id INTO account; + END IF; + INSERT INTO lzc_auth.external_identities(provider,issuer,subject,user_id) VALUES('oidc',p_issuer,p_subject,account); + END IF; + UPDATE lzc_auth.users SET display_name=p_email WHERE id=account; + INSERT INTO lzc.tenants(owner_user_id,name) VALUES(account,'Persönlicher Arbeitsbereich') + ON CONFLICT(owner_user_id) DO UPDATE SET owner_user_id=excluded.owner_user_id RETURNING id INTO workspace; + INSERT INTO lzc.memberships(tenant_id,user_id,role) VALUES(workspace,account,'admin') ON CONFLICT DO NOTHING; + INSERT INTO lzc.stackit_identities(user_id,issuer,subject,email,verification_method,valid_until) VALUES(account,p_issuer,p_subject,p_email,p_method,p_proof_expires) + ON CONFLICT ON CONSTRAINT stackit_identities_pkey DO UPDATE SET email=excluded.email,verification_method=excluded.verification_method,verified_at=now(),valid_until=excluded.valid_until,revoked_at=NULL + WHERE stackit_identities.issuer=excluded.issuer AND stackit_identities.subject=excluded.subject; + IF NOT FOUND THEN RAISE EXCEPTION 'identity_binding_conflict' USING ERRCODE='23505'; END IF; + INSERT INTO lzc_auth.sessions(id,token_hash,user_id,tenant_id,csrf_token,expires_at,active_tenant_id) VALUES(p_session,p_hash,account,workspace,p_csrf,p_expires,active_workspace); + RETURN QUERY SELECT account,workspace,u.github_id::text FROM lzc_auth.users u WHERE u.id=account; +END $$; + + +-- Only authenticated server code can read the encrypted, session-bound renewal grant. +ALTER TABLE lzc_auth.sessions ADD COLUMN stackit_refresh_cipher bytea + CHECK (stackit_refresh_cipher IS NULL OR octet_length(stackit_refresh_cipher) BETWEEN 28 AND 131072); +CREATE FUNCTION lzc_auth.lock_stackit_refresh(p_hash text) +RETURNS TABLE(session_id uuid,user_id uuid,token_tenant_id uuid,refresh_cipher bytea,expires_at timestamptz, + issuer text,subject text,email text,proof_expires_at timestamptz) +LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$ + SELECT s.id,s.user_id,s.tenant_id,s.stackit_refresh_cipher,s.expires_at,i.issuer,i.subject,i.email,i.valid_until + FROM lzc_auth.resolve_session(p_hash) r JOIN lzc_auth.sessions s ON s.id=r.session_id + JOIN lzc.stackit_identities i ON i.user_id=s.user_id AND i.revoked_at IS NULL + FOR UPDATE OF s +$$; +CREATE FUNCTION lzc_auth.write_stackit_refresh(p_hash text,p_cipher bytea) RETURNS boolean +LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$ + WITH updated AS ( + UPDATE lzc_auth.sessions s SET stackit_refresh_cipher=p_cipher + WHERE s.id IN (SELECT session_id FROM lzc_auth.resolve_session(p_hash)) + RETURNING s.id) SELECT EXISTS(SELECT 1 FROM updated) +$$; +CREATE FUNCTION lzc_auth.complete_stackit_refresh(p_hash text,p_cipher bytea,p_issuer text,p_subject text,p_email text,p_proof_expires timestamptz) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE account uuid; +BEGIN + IF p_issuer IS DISTINCT FROM 'https://accounts.stackit.cloud' OR p_proof_expires IS NULL + OR p_proof_expires<=now() OR p_proof_expires>now()+interval '24 hours' THEN + RAISE EXCEPTION 'invalid_stackit_refresh' USING ERRCODE='23514'; + END IF; + SELECT user_id INTO STRICT account FROM lzc_auth.resolve_session(p_hash); + -- Renewal must never revive revocation, change identity or renew organization-owner proof. + UPDATE lzc.stackit_identities SET valid_until=greatest(valid_until,p_proof_expires) + WHERE user_id=account AND issuer=p_issuer AND subject=p_subject AND email=p_email AND revoked_at IS NULL; + IF NOT FOUND THEN RAISE EXCEPTION 'invalid_stackit_refresh_identity' USING ERRCODE='42501'; END IF; + IF NOT lzc_auth.write_stackit_refresh(p_hash,p_cipher) THEN + RAISE EXCEPTION 'invalid_stackit_refresh_session' USING ERRCODE='42501'; + END IF; +END $$; +REVOKE ALL ON FUNCTION lzc_auth.lock_stackit_refresh(text),lzc_auth.write_stackit_refresh(text,bytea),lzc_auth.complete_stackit_refresh(text,bytea,text,text,text,timestamptz) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.lock_stackit_refresh(text),lzc_auth.write_stackit_refresh(text,bytea),lzc_auth.complete_stackit_refresh(text,bytea,text,text,text,timestamptz) TO configurator_app; diff --git a/landing-zone-configurator/app/apps/api/src/app.ts b/landing-zone-configurator/app/apps/api/src/app.ts index bcde01e8..727e0805 100644 --- a/landing-zone-configurator/app/apps/api/src/app.ts +++ b/landing-zone-configurator/app/apps/api/src/app.ts @@ -151,6 +151,7 @@ export function buildApp( github: !!options.auth && options.auth.githubEnabled !== false, stackit: !!options.stackit, stackitFlow: options.stackit?.authFlow, + stackitRenewal: !!options.auth?.stackitSessions, primary: options.developmentLogin ? "development" : options.auth?.primaryStackit diff --git a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts index 45bea2cf..28f79ba3 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts @@ -407,7 +407,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "f625ec156516fdf3729758cb6c1ac118bb71f077b489194a2ddcc936fd94f48d", + "4f13675917d05b0e57de3ba3cc01a02aa50775608b0004ac8126e429debe7f3c", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1580,7 +1580,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "f625ec156516fdf3729758cb6c1ac118bb71f077b489194a2ddcc936fd94f48d", + "4f13675917d05b0e57de3ba3cc01a02aa50775608b0004ac8126e429debe7f3c", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts", diff --git a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts index 39d43610..60de519c 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts @@ -609,6 +609,105 @@ export class AssistantModelServing { const permissionServices = [ ...new Set(permissionNames.map((p) => p.split(".")[0] ?? p)), ].sort(); + const accessTemplates = objectValue( + current.projectTemplates as Parameters[0], + ); + const kindCandidates = (kind: string) => + Object.entries(accessTemplates).filter( + ([, value]) => objectValue(value).kind === kind, + ); + const kindQuestion = (kind: string, english: boolean) => { + const candidates = kindCandidates(kind); + return { + path: "projectTemplates", + text: + (english + ? `Which ${kind} template should be changed?` + : `Welche ${kind}-Vorlage soll geändert werden?`) + + ` (${candidates.length}: ${candidates + .slice(0, 6) + .map(([key]) => key) + .join(", ")})`, + options: candidates + .slice(0, 6) + .map( + ([key, value]) => + `${String(objectValue(value).name ?? key).slice(0, 100)} (${key})`, + ), + }; + }; + const permissionClarification = ( + kind: string, + services: string[], + target: "requester" | "automation" | "both", + english: boolean, + ) => ({ + explanation: + (english + ? "Select the existing Application template to receive the service permissions. " + : "Wähle die bestehende Application-Vorlage, deren Dienstberechtigungen ergänzt werden sollen. ") + + (english ? "Requested services: " : "Angefragte Dienste: ") + + services.join(", ") + + (english ? "; permissions for: " : "; Rechte für: ") + + (english + ? { + requester: "requester", + automation: "Application service account", + both: "requester and Application service account", + }[target] + : { + requester: "Besteller", + automation: "Application-Service-Account", + both: "Besteller und Application-Service-Account", + }[target]) + + (english + ? ". Nothing has been changed or saved." + : ". Noch nichts gespeichert."), + questions: [kindQuestion(kind, english)], + changes: [], + references: ["project-templates"], + }); + // Only the immediately open, server-shaped selection can authorize a source + // reference to its immediately preceding USER request. Plain historical + // mentions, completed proposals and new topics never open this continuation. + const pendingPermissionSelection = (() => { + const previous = history.at(-2), + reply = history.at(-1); + if (previous?.role !== "user" || reply?.role !== "assistant") return null; + for (const kind of ["public", "corporate"]) + for (const english of [false, true]) { + const question = kindQuestion(kind, english); + if ( + kindCandidates(kind).length < 2 || + !reply.content.endsWith(`\n${question.text}`) + ) + continue; + const selection = message.startsWith( + `${question.text}\nMeine Antwort: `, + ) + ? message.slice(`${question.text}\nMeine Antwort: `.length).trim() + : message.trim(); + const matches = kindCandidates(kind) + .slice(0, 6) + .filter(([key, value], index) => + [ + key, + String(objectValue(value).name ?? key), + question.options[index], + ].includes(selection), + ); + if (matches.length === 1) + return { + source: `history-${history.length - 2}`, + key: matches[0]![0], + kind, + english, + userMessage: previous.content, + assistantMessage: reply.content, + }; + } + return null; + })(); const intentSchema = z.strictObject({ applicationAccessLanguage: z .enum(["de", "en"]) @@ -618,11 +717,24 @@ export class AssistantModelServing { ), applicationAccess: z .strictObject({ - key: z.enum( - existingTemplateKeys.length - ? existingTemplateKeys - : ["no-existing-template"], - ), + key: z + .enum([...existingTemplateKeys, "__select_by_kind__"]) + .describe( + "Use the exact existing key only when the user identifies that template. For a kind-only Public/Corporate request use __select_by_kind__, never guess among matching templates.", + ), + templateKind: z + .enum(["public", "corporate", "unspecified"]) + .default("unspecified") + .describe( + "Public/Corporate only when the current user identifies the kind rather than a specific template; otherwise unspecified.", + ), + templateEvidence: z + .string() + .max(1000) + .default("") + .describe( + "Exact current USER quote identifying Public or Corporate. If only a kind is named, use key=__select_by_kind__ and this quote; the server resolves exactly one matching existing template or asks which. Never guess a key when several match.", + ), choice: z .enum([ "owner", @@ -645,14 +757,25 @@ export class AssistantModelServing { .enum(["requester", "automation", "both"]) .default("both") .describe( - "Only requester/bestellende Person, only Application-Service-Account, or both. If no distinction is requested, both receive the additions; their previous differing lists stay intact.", + "Who should receive the requested rights? requester = the person ordering the Landing Zone; automation = the created Application-Service-Account; both ONLY if both are intended or no recipient is mentioned. A request that someone gets rights when ordering (German: bei der Bestellung ... Rechte bekommt) explicitly means requester, NOT both. The word Application Landing Zone is not a request for automation permissions. Preserve the other recipient unchanged.", + ), + serviceEvidenceSource: z + .enum([ + "current", + ...(pendingPermissionSelection + ? [pendingPermissionSelection.source] + : []), + ]) + .default("current") + .describe( + "Use current normally. The supplied pendingPermissionSelection source may be used ONLY when the current user selects exactly that offered template, continuing the still-open original service-rights request; keep its exact services and permissionTarget. Never use arbitrary history.", ), serviceEvidence: z .string() .max(1000) .default("") .describe( - "Exact nonempty quote from the current USER message requesting the service permission addition, not from history or a catalogue.", + "Exact nonempty quote from the selected USER source requesting service permission addition. Current normally; only the supplied still-open pending selection may use its controlled history source. Never assistant statements or a catalogue.", ), initialOwnerEmail: z.string().max(254).default(""), permissions: z @@ -667,7 +790,7 @@ export class AssistantModelServing { .nullable() .default(null) .describe( - "Only an explicit current USER request to change one existing template's deployed project IAM policy. Copy its exact key, central owner and any explicit permission lists; no invented addresses/permissions. Missing central owner uses empty text so the server can ask. This is a draft, never an IAM grant, IDP role or membership change. Set topic=project-templates,mode=draft. Advice/new-template setup/other settings use null. No templateSetup is needed for this narrow update.", + "Required for an explicit current USER request to change one existing template's deployed project IAM policy, including accessProfile-only full project Owner requests (choice=owner), workload presets and adding service rights. Do not use templateSetup or generic editorInputs for an IAM-only change; they can accidentally reconstruct the template or retain the wrong profile. Copy its exact key, central owner and any explicit permission lists; no invented addresses/permissions. Missing central owner uses empty text so the server can ask. This is a draft, never an IAM grant, IDP role or membership change. Set topic=project-templates,mode=draft. Advice/new-template setup/other settings use null. No templateSetup is needed for this narrow update.", ), templateSetup: z .strictObject({ @@ -954,6 +1077,13 @@ export class AssistantModelServing { role, content, })), + pendingPermissionSelection: pendingPermissionSelection + ? { + source: pendingPermissionSelection.source, + key: pendingPermissionSelection.key, + userMessage: pendingPermissionSelection.userMessage, + } + : null, history: history.map(({ role, content }) => ({ role, content, @@ -1026,7 +1156,7 @@ export class AssistantModelServing { { role: "system", content: - "templateSetup.fields limits the next template schema to USER-requested settings. A Corporate/Public request with local networks uses fields=[network_enabled,network_area_key]. Do not include observability, secretsmanager_enabled, namespaceServices or parameterPolicy because the platform already has Audit/Git/services. Only include those fields if the USER requests them for the templates. Questions about Automatic or subnet size in a Corporate/Public TEMPLATE use topic=project-templates, including explanations that mention SNA defaults or Invalid IPv4 configuration. Advice stays mode=advice; editing that template uses editorContract=true, editorInputs=[landing_zones], templateSetup.fields=[network_prefix_length], collectionMode=update. Explicit subnet size overrides use network_prefix_length. Requested template namespace services use namespaceServices; requester-role parameter choices use parameterPolicy only for legacy templates. Project IAM profiles use accessProfile (full owner or granular workload permissions); mode=draft, topic=project-templates, editorContract=true, editorInputs=[landing_zones], templateSetup.fields=[accessProfile], collectionMode=update for existing templates; never put initial owner or profile choices into order parameters. This scope never grants publication, execution or IAM.", + "templateSetup.fields limits the next template schema to USER-requested settings. A Corporate/Public request with local networks uses fields=[network_enabled,network_area_key]. Do not include observability, secretsmanager_enabled, namespaceServices or parameterPolicy because the platform already has Audit/Git/services. Only include those fields if the USER requests them for the templates. Questions about Automatic or subnet size in a Corporate/Public TEMPLATE use topic=project-templates, including explanations that mention SNA defaults or Invalid IPv4 configuration. The corrected Accelerator module resolves the actual regional SNA default_prefix_length during Plan and supplies that exact numeric value while preserving DNS; it is not merely a suggestion or a provider-version change. Do not discuss hypothetical provider upgrades unless the user asks about them. Advice stays mode=advice; editing that template uses editorContract=true, editorInputs=[landing_zones], templateSetup.fields=[network_prefix_length], collectionMode=update. Explicit subnet size overrides use network_prefix_length. Requested template namespace services use namespaceServices; requester-role parameter choices use parameterPolicy only for legacy templates. Project IAM profiles use accessProfile (full owner or granular workload permissions). For an IAM-only change of an existing template, use mode=draft, topic=project-templates and the dedicated applicationAccess object, never templateSetup/editorInputs; new template setup may include accessProfile. Never put initial owner or profile choices into order parameters. This scope never grants publication, execution or IAM.", }, { role: "system", @@ -1038,6 +1168,11 @@ export class AssistantModelServing { content: "Governance removals, replacements, default resets and new folders use editorContract=true, editorInputs containing only the targeted native roots, and accessAssignment=null. The specialized accessAssignment flow ONLY adds requested subjects to existing roles/folders. Labels and stable platform namespace changes also use editorContract with their exact targeted root. 'Bitte nur das Ressourcenlabel cost_center auf synthetic-team setzen' => topic=identity-foundations, mode=draft, editorContract=true, editorInputs=[labels]. Label editing is NOT configuration-lifecycle. Audit/Observability/Kubernetes setup uses editorContract=true and the corresponding single native root. Application Landing Zone template setup is supported: on a platformDraft, requests to prepare, name or configure Public/Corporate templates and their local networks use topic=project-templates, mode=draft, editorContract=true, editorInputs=[landing_zones]. A current template setup command overrides prior Audit/DevOps history. 'No sandboxes for now' excludes sandbox creation; it is not a sandbox setup topic. Template preparation is not publication or creation of application instances. Advice about Application team/project IAM permissions, initial project owner or accessProfile uses topic=project-templates and mode=advice, not configuration-lifecycle or identity-foundations. IDP login and Configurator membership roles are distinct. For non-network topics requirements MUST be [].", }, + { + role: "system", + content: + "Application project IAM: distinguish information questions from desired changes. An accessProfile-only change on an existing template MUST use applicationAccess, not generic templateSetup/editorInputs. Full project Owner rights explicitly requested for requester and automation select choice=owner (schemaVersion 1, mode owner), with no granular fields; do not substitute the workload default. A wish that future orderers receive Kubernetes/other service rights is an explicit template-change request, even without the words add/proposal: use topic=project-templates, mode=draft, adviceTask=other, applicationAccess.choice=add-service-permissions. Granting a future requester the ability to order/use a Kubernetes cluster means adding SKE project IAM to the existing template, NOT creating a cluster, platform Kubernetes or namespace building block. If only Public/Corporate is specified, use key=__select_by_kind__, templateKind and exact templateEvidence from the current message. The server asks which when several match. If the request concerns what the orderer receives, permissionTarget=requester; do not also change Application-service-account permissions unless requested. Preserve the existing central owner and all other settings. Actual advice questions about what roles mean use mode=advice with applicationAccess=null. Never treat a desired permission change as a request for the general access overview. If pendingPermissionSelection is supplied and the current user chooses that template, continue its original USER service request using that controlled serviceEvidenceSource and key, preserving the original services and recipient. The current selection is not a new advice request. Otherwise never reactivate old permission tasks.", + }, { role: "system", content: @@ -1150,6 +1285,7 @@ export class AssistantModelServing { ) throw new AssistantInferenceError("intent_evidence"); if ( + !classifiedIntent.applicationAccess && classifiedIntent.mode === "advice" && classifiedIntent.topic === "project-templates" && classifiedIntent.adviceTask === "application-access" @@ -1177,7 +1313,118 @@ export class AssistantModelServing { ) throw new AssistantInferenceError("invalid_scope"); const selected = classifiedIntent.applicationAccess; - const { key } = selected; + let { key } = selected; + let serviceSource = message; + if (selected.serviceEvidenceSource !== "current") { + if ( + selected.choice !== "add-service-permissions" || + !pendingPermissionSelection || + selected.serviceEvidenceSource !== + pendingPermissionSelection.source || + key !== pendingPermissionSelection.key + ) + throw new AssistantInferenceError("intent_evidence"); + const expected = permissionClarification( + pendingPermissionSelection.kind, + selected.services, + selected.permissionTarget, + pendingPermissionSelection.english, + ); + if ( + pendingPermissionSelection.assistantMessage !== + expected.explanation + "\n" + expected.questions[0]!.text + ) + throw new AssistantInferenceError("intent_evidence"); + serviceSource = pendingPermissionSelection.userMessage; + } + if ( + selected.choice === "add-service-permissions" && + (!selected.services.length || + !selected.serviceEvidence.trim() || + !serviceSource.includes(selected.serviceEvidence) || + !selected.services.every((service) => + applicationPermissionServiceMentioned( + service, + selected.serviceEvidence, + ), + )) + ) + throw new AssistantInferenceError("intent_evidence"); + // A model-selected existing key is not evidence that the user selected it. + // Resolve kind-only requests in the server even if the classifier guessed a key. + let templateKind = selected.templateKind; + let templateEvidence = selected.templateEvidence; + if ( + selected.choice === "add-service-permissions" && + selected.serviceEvidenceSource === "current" && + key !== "__select_by_kind__" + ) { + const mentioned = Object.entries( + objectValue( + current.projectTemplates as Parameters[0], + ), + ).filter(([candidateKey, value]) => { + const candidate = objectValue(value); + return [candidateKey, String(candidate.name ?? "")].some((label) => { + if ( + !label || + /^(public|corporate)([- ](?:template|landing[- ]zone))?$/i.test( + label, + ) + ) + return false; + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + `(?:^|[^\\p{L}\\p{N}_-])${escaped}(?:$|[^\\p{L}\\p{N}_-])`, + "iu", + ).test(message); + }); + }); + if (mentioned.length === 1) { + if (mentioned[0]![0] !== key) + throw new AssistantInferenceError("intent_evidence"); + } else { + const kinds = ["public", "corporate"].filter((kind) => + new RegExp(`(?:^|[^a-z])${kind}(?:$|[^a-z])`, "i").test(message), + ); + if (mentioned.length || kinds.length !== 1) + throw new AssistantInferenceError("intent_evidence"); + key = "__select_by_kind__"; + templateKind = kinds[0] as "public" | "corporate"; + templateEvidence = message; + } + } + if (key === "__select_by_kind__") { + if ( + selected.choice !== "add-service-permissions" || + templateKind === "unspecified" || + !templateEvidence.trim() || + !message.includes(templateEvidence) || + !new RegExp(`(?:^|[^a-z])${templateKind}(?:$|[^a-z])`, "i").test( + templateEvidence, + ) + ) + throw new AssistantInferenceError("intent_evidence"); + const candidates = Object.entries( + objectValue( + current.projectTemplates as Parameters[0], + ), + ).filter(([, value]) => objectValue(value).kind === templateKind); + if (candidates.length !== 1) { + const dialogue = validateDialogue( + permissionClarification( + templateKind, + selected.services, + selected.permissionTarget, + classifiedIntent.applicationAccessLanguage === "en", + ), + context.rules.map((rule) => rule.id), + ); + stream?.onText(dialogue.explanation); + return dialogue; + } + key = candidates[0]![0]; + } const template = objectValue( objectValue( current.projectTemplates as Parameters[0], @@ -1222,7 +1469,7 @@ export class AssistantModelServing { previous.mode !== "granular" || !selected.services.length || !selected.serviceEvidence.trim() || - !message.includes(selected.serviceEvidence) || + !serviceSource.includes(selected.serviceEvidence) || !selected.services.every((service) => applicationPermissionServiceMentioned( service, diff --git a/landing-zone-configurator/app/apps/api/src/auth/routes.ts b/landing-zone-configurator/app/apps/api/src/auth/routes.ts index d74e329b..9c0ef724 100644 --- a/landing-zone-configurator/app/apps/api/src/auth/routes.ts +++ b/landing-zone-configurator/app/apps/api/src/auth/routes.ts @@ -9,6 +9,7 @@ import { validGitHubCallback, } from "./github-flow.js"; import type { UserTokenStore } from "./secrets.js"; +import type { StackitSessions } from "./stackit-sessions.js"; import type { AuthStore, Session } from "./store.js"; export type AuthServices = { @@ -17,6 +18,7 @@ export type AuthServices = { store: AuthStore; github: GitHubLoginClient; tokens: UserTokenStore; + stackitSessions?: Pick; primaryStackit?: boolean; githubEnabled?: boolean; allowLoopbackHttp?: boolean; @@ -255,6 +257,12 @@ export function registerAuth(app: FastifyInstance, services: AuthServices) { return reply.code(401).send({ error: "authentication_required" }); if (!validMutation(request, session, services.origin)) return reply.code(403).send({ error: "invalid_request_origin_or_csrf" }); + await services.stackitSessions?.revoke(session, token).catch(() => { + app.log.warn( + { event: "stackit_secret_cleanup_pending" }, + "STACKIT session secret cleanup pending", + ); + }); await services.store.deleteSession(token); reply.header("Set-Cookie", clearSession); try { diff --git a/landing-zone-configurator/app/apps/api/src/auth/stackit-device.ts b/landing-zone-configurator/app/apps/api/src/auth/stackit-device.ts index 3728aede..e074d871 100644 --- a/landing-zone-configurator/app/apps/api/src/auth/stackit-device.ts +++ b/landing-zone-configurator/app/apps/api/src/auth/stackit-device.ts @@ -14,6 +14,7 @@ const verificationUrl = z.url().refine((value) => { const url = new URL(value); return url.origin === issuer && !url.username && !url.password; }); +export const stackitIssuer = issuer; const identitySchema = z.object({ sub: z.string().min(1).max(255), email: z @@ -44,6 +45,16 @@ export type DeviceIdentity = { } | null; }; +export type StackitRefreshGrant = { + clientId: string; + refreshToken: string; + nonce: string; + issuer: string; + subject: string; + email: string; + expiresAt: string; +}; + export type DeviceState = | { status: "idle" | "expired" | "cancelled" } | { status: "waiting"; retryAfterMs: number } @@ -125,6 +136,7 @@ export function configuredStackitFlow( export class StackitDeviceFlow { private readonly clientId: string; + private refreshGrant: StackitRefreshGrant | null = null; private deviceCode: string | null = null; private expiresAt = 0; private nextPollAt = 0; @@ -266,7 +278,14 @@ export class StackitDeviceFlow { }; } + takeRefreshGrant(): StackitRefreshGrant | null { + const grant = this.refreshGrant; + this.refreshGrant = null; + return grant; + } + cancel(): DeviceState { + this.refreshGrant = null; this.deviceCode = null; this.codeVerifier = null; this.codeState = null; @@ -375,6 +394,7 @@ export class StackitDeviceFlow { const tokens = z .object({ access_token: z.string().min(1).max(32768), + refresh_token: z.string().min(1).max(32768).optional(), id_token: z.string().min(1).max(32768).optional(), expires_in: z.number().int().positive().max(86400), token_type: z @@ -382,6 +402,7 @@ export class StackitDeviceFlow { .refine((value) => value.toLowerCase() === "bearer"), }) .parse(data); + const authorizationNonce = this.nonce; let identity: { sub: string; email?: string | undefined } | undefined; let tokenExpiresAt = this.now() + tokens.expires_in * 1000; if (this.codeOptions && !tokens.id_token) @@ -398,6 +419,13 @@ export class StackitDeviceFlow { maxTokenAge: "10m", currentDate: new Date(this.now()), }); + if ( + (payload.azp !== undefined && payload.azp !== this.clientId) || + (Array.isArray(payload.aud) && + payload.aud.length > 1 && + payload.azp !== this.clientId) + ) + throw new DeviceFlowError("identity_authorized_party_mismatch"); if (this.codeOptions && payload.nonce !== this.nonce) throw new DeviceFlowError("identity_nonce_mismatch"); this.nonce = null; @@ -563,6 +591,20 @@ export class StackitDeviceFlow { } if (this.state().status !== "waiting") return this.state(); this.deviceCode = null; + if ( + this.codeOptions?.purpose === "login" && + tokens.refresh_token && + authorizationNonce + ) + this.refreshGrant = { + clientId: this.clientId, + refreshToken: tokens.refresh_token, + nonce: authorizationNonce, + issuer, + subject: user.sub, + email: user.email, + expiresAt: new Date(tokenExpiresAt).toISOString(), + }; this.current = { status: "verified", identity: { diff --git a/landing-zone-configurator/app/apps/api/src/auth/stackit-login.ts b/landing-zone-configurator/app/apps/api/src/auth/stackit-login.ts index 9b38d894..9e23cef9 100644 --- a/landing-zone-configurator/app/apps/api/src/auth/stackit-login.ts +++ b/landing-zone-configurator/app/apps/api/src/auth/stackit-login.ts @@ -133,7 +133,7 @@ export function registerStackitLogin( entry.existingSessionId ) throw new Error("Login binding changed"); - await auth.store.createStackitSession!({ + const created = await auth.store.createStackitSession!({ identity: state.identity, id: randomUUID(), hash: secret.hash, @@ -143,9 +143,19 @@ export function registerStackitLogin( ? { existingSessionId: entry.existingSessionId } : {}), }); + const grant = entry.flow.takeRefreshGrant(); + try { + if (grant && auth.stackitSessions) + await auth.stackitSessions.put(created, secret.hash, grant); + } catch (error) { + await auth.store.deleteSession(secret.token); + throw error; + } const old = cookie(request, "__Host-lzc-session"); if (old) { const previous = await authenticatedSession(request, auth); + if (previous) + await auth.stackitSessions?.revoke(previous, old).catch(() => {}); await auth.store.deleteSession(old); if (previous?.githubId) await auth.tokens.remove(previous).catch(() => {}); @@ -171,6 +181,18 @@ export function registerStackitLogin( } } }); + routes.post("/auth/stackit/refresh", async (request, reply) => { + const session = await authenticatedSession(request, auth); + const token = cookie(request, "__Host-lzc-session"); + if (!session || !token) + return reply.code(401).send({ error: "authentication_required" }); + if (!validMutation(request, session, auth.origin)) + return reply + .code(403) + .send({ error: "invalid_request_origin_or_csrf" }); + if (!auth.stackitSessions) return { status: "reauthentication-required" }; + return auth.stackitSessions.refresh(session, token); + }); routes.post("/auth/stackit/cancel", async (request, reply) => { const key = requestKey(request); const entry = key ? pending.get(key) : undefined; diff --git a/landing-zone-configurator/app/apps/api/src/auth/stackit-sessions.ts b/landing-zone-configurator/app/apps/api/src/auth/stackit-sessions.ts new file mode 100644 index 00000000..9ce95e72 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/auth/stackit-sessions.ts @@ -0,0 +1,309 @@ +import { createRemoteJWKSet, customFetch, jwtVerify } from "jose"; +import type pg from "pg"; +import { z } from "zod"; +import type { ArtifactCrypto } from "../plans/crypto.js"; +import { type StackitRefreshGrant, stackitIssuer } from "./stackit-device.js"; +import { type Session, tokenHash } from "./store.js"; + +const grantSchema = z.strictObject({ + clientId: z.string().min(1).max(255), + refreshToken: z.string().min(1).max(32768), + nonce: z.string().min(1).max(128), + issuer: z.literal(stackitIssuer), + subject: z.string().min(1).max(255), + email: z.email().max(254), + expiresAt: z.iso.datetime(), +}); +class ReauthenticationRequired extends Error {} + +/** Refreshes only this client grant; it cannot renew the STACKIT Portal browser cookie. */ +export class StackitRefreshClient { + private readonly keys; + constructor( + private readonly request: typeof fetch = fetch, + private readonly now = Date.now, + ) { + this.keys = createRemoteJWKSet( + new URL(`${stackitIssuer}/.well-known/jwks.json`), + { + [customFetch]: (input, init) => + request(input, { ...init, redirect: "error" }), + }, + ); + } + private async call(path: string, init: RequestInit) { + const response = await this.request(`${stackitIssuer}${path}`, { + ...init, + redirect: "error", + signal: AbortSignal.timeout(15000), + }); + if (Number(response.headers.get("content-length")) > 65536) + throw new Error("stackit_refresh_unavailable"); + const text = await response.text(); + if (Buffer.byteLength(text, "utf8") > 65536) + throw new Error("stackit_refresh_unavailable"); + if (response.status === 429 || response.status >= 500) + throw new Error("stackit_refresh_unavailable"); + if (!response.ok) throw new ReauthenticationRequired(); + return (text ? JSON.parse(text) : {}) as unknown; + } + async refresh(input: StackitRefreshGrant): Promise { + const grant = grantSchema.parse(input); + const tokens = z + .object({ + access_token: z.string().min(1).max(32768), + refresh_token: z.string().min(1).max(32768).optional(), + id_token: z.string().min(1).max(32768), + expires_in: z.number().int().positive().max(86400), + token_type: z.string().refine((v) => v.toLowerCase() === "bearer"), + }) + .safeParse( + await this.call("/oauth/v2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: grant.clientId, + grant_type: "refresh_token", + refresh_token: grant.refreshToken, + }), + }), + ); + if (!tokens.success) throw new ReauthenticationRequired(); + try { + const { payload } = await jwtVerify(tokens.data.id_token, this.keys, { + issuer: stackitIssuer, + audience: grant.clientId, + algorithms: ["RS256"], + requiredClaims: ["sub", "iat", "exp"], + maxTokenAge: "10m", + currentDate: new Date(this.now()), + }); + if ( + (payload.azp !== undefined && payload.azp !== grant.clientId) || + (Array.isArray(payload.aud) && + payload.aud.length > 1 && + payload.azp !== grant.clientId) || + payload.sub !== grant.subject || + (payload.nonce !== undefined && payload.nonce !== grant.nonce) || + (payload.email !== undefined && payload.email !== grant.email) || + payload.email_verified === false + ) + throw new ReauthenticationRequired(); + const user = z + .object({ + sub: z.literal(grant.subject), + email: z.literal(grant.email), + email_verified: z.literal(true), + }) + .safeParse( + await this.call("/oidc/v1/userinfo", { + headers: { Authorization: `Bearer ${tokens.data.access_token}` }, + }), + ); + if (!user.success) throw new ReauthenticationRequired(); + const expiresAt = Math.min( + this.now() + tokens.data.expires_in * 1000, + (payload.exp ?? 0) * 1000, + ); + if (expiresAt <= this.now() + 30000) throw new ReauthenticationRequired(); + return { + ...grant, + refreshToken: tokens.data.refresh_token ?? grant.refreshToken, + expiresAt: new Date(expiresAt).toISOString(), + }; + } catch (error) { + // Transport outages permit another bounded check, never an invented proof extension. + if ( + error instanceof TypeError || + (error instanceof Error && + (error.message === "stackit_refresh_unavailable" || + ["AbortError", "TimeoutError"].includes(error.name) || + ("code" in error && error.code === "ERR_JWKS_TIMEOUT"))) + ) + throw error; + throw new ReauthenticationRequired(); + } + } + async revoke(grant: StackitRefreshGrant) { + await this.call("/oauth/v2/revoke", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: grant.clientId, + token: grant.refreshToken, + token_type_hint: "refresh_token", + }), + }); + } +} + +type LockedSession = { + session_id: string; + user_id: string; + token_tenant_id: string; + refresh_cipher: Buffer | null; + expires_at: Date; + issuer: string; + subject: string; + email: string; + proof_expires_at: Date; +}; +export type StackitSessionState = { + status: "active" | "reauthentication-required"; + verifiedUntil?: string; +}; +export class StackitSessions { + constructor( + private readonly pool: pg.Pool, + private readonly crypto: ArtifactCrypto, + private readonly clientId: string, + private readonly provider = new StackitRefreshClient(), + private readonly now = Date.now, + ) {} + private encrypt( + session: Pick, + grant: StackitRefreshGrant, + ) { + return this.crypto.encrypt( + Buffer.from(JSON.stringify(grantSchema.parse(grant))), + session.tokenTenantId ?? session.tenantId, + session.userId, + `stackit-refresh:${session.id}`, + ); + } + async put(session: Session, hash: string, grant: StackitRefreshGrant) { + if (grant.clientId !== this.clientId) + throw new Error("stackit_refresh_binding_invalid"); + const result = await this.pool.query<{ written: boolean }>( + "SELECT lzc_auth.write_stackit_refresh($1,$2) AS written", + [hash, this.encrypt(session, grant)], + ); + if (!result.rows[0]?.written) + throw new Error("stackit_refresh_session_missing"); + } + private async locked( + session: Session, + token: string, + work: ( + client: pg.PoolClient, + row: LockedSession, + hash: string, + ) => Promise, + ): Promise { + const client = await this.pool.connect(); + let broken = false; + try { + await client.query("BEGIN"); + const hash = tokenHash(token); + const result = await client.query( + "SELECT * FROM lzc_auth.lock_stackit_refresh($1)", + [hash], + ); + const row = result.rows[0]; + if ( + !row || + row.session_id !== session.id || + row.user_id !== session.userId || + row.expires_at.getTime() <= this.now() + ) + throw new Error("stackit_refresh_session_missing"); + const value = await work(client, row, hash); + await client.query("COMMIT"); + return value; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + broken = true; + } + throw error; + } finally { + client.release(broken); + } + } + private decrypt(row: LockedSession) { + if (!row.refresh_cipher) return null; + const grant = grantSchema.parse( + JSON.parse( + this.crypto + .decrypt( + row.refresh_cipher, + row.token_tenant_id, + row.user_id, + `stackit-refresh:${row.session_id}`, + ) + .toString("utf8"), + ), + ); + if ( + grant.clientId !== this.clientId || + grant.issuer !== row.issuer || + grant.subject !== row.subject || + grant.email !== row.email + ) + throw new Error("stackit_refresh_binding_invalid"); + return grant; + } + async refresh(session: Session, token: string): Promise { + return this.locked( + session, + token, + async (client, row, hash) => { + const grant = this.decrypt(row); + const expires = grant + ? Date.parse(grant.expiresAt) + : row.proof_expires_at.getTime(); + if (expires > this.now() + 120000) + return { + status: "active", + verifiedUntil: new Date(expires).toISOString(), + }; + if (!grant) return { status: "reauthentication-required" }; + let next: StackitRefreshGrant; + try { + next = await this.provider.refresh(grant); + } catch (error) { + if (!(error instanceof ReauthenticationRequired)) throw error; + await client.query("SELECT lzc_auth.write_stackit_refresh($1,NULL)", [ + hash, + ]); + return { status: "reauthentication-required" }; + } + const identity = { + id: row.session_id, + userId: row.user_id, + tenantId: row.token_tenant_id, + }; + await client.query( + "SELECT lzc_auth.complete_stackit_refresh($1,$2,$3,$4,$5,$6)", + [ + hash, + this.encrypt(identity, next), + next.issuer, + next.subject, + next.email, + new Date(next.expiresAt), + ], + ); + return { status: "active", verifiedUntil: next.expiresAt }; + }, + ).catch((error: unknown) => { + if ( + error instanceof Error && + error.message === "stackit_refresh_session_missing" + ) + return { status: "reauthentication-required" as const }; + throw error; + }); + } + async revoke(session: Session, token: string) { + await this.locked(session, token, async (client, row, hash) => { + const grant = this.decrypt(row); + // Delete the local grant even if the provider cannot be reached on logout. + await client.query("SELECT lzc_auth.write_stackit_refresh($1,NULL)", [ + hash, + ]); + if (grant) await this.provider.revoke(grant).catch(() => {}); + }); + } +} diff --git a/landing-zone-configurator/app/apps/api/src/main.ts b/landing-zone-configurator/app/apps/api/src/main.ts index 8fa6218a..6288a487 100644 --- a/landing-zone-configurator/app/apps/api/src/main.ts +++ b/landing-zone-configurator/app/apps/api/src/main.ts @@ -12,6 +12,7 @@ import { SecretsManagerTokenStore } from "./auth/secrets.js"; import { configuredStackitFlow } from "./auth/stackit-device.js"; import { StackitIdentities } from "./auth/stackit-identities.js"; import type { StackitServices } from "./auth/stackit-routes.js"; +import { StackitSessions } from "./auth/stackit-sessions.js"; import { PostgresAuthStore } from "./auth/store.js"; import { Configurations } from "./configurations/service.js"; import { PostgresCloudCatalogues } from "./credentials/catalogues.js"; @@ -225,6 +226,15 @@ if (process.env.LZC_AUTH_ENABLED === "true") { callback: `${origin}/auth/github/callback`, }), tokens: new SecretsManagerTokenStore(secretConfig), + ...(primaryStackit && artifactCrypto && process.env.LZC_STACKIT_CLIENT_ID + ? { + stackitSessions: new StackitSessions( + pool, + artifactCrypto, + process.env.LZC_STACKIT_CLIENT_ID, + ), + } + : {}), }; if (process.env.LZC_STACKIT_DEVICE_ENABLED === "true") { if (!createStackitFlow) diff --git a/landing-zone-configurator/app/apps/web/src/components/Account.tsx b/landing-zone-configurator/app/apps/web/src/components/Account.tsx index 3ad81515..d744ab5e 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Account.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Account.tsx @@ -19,12 +19,22 @@ export function Account({ beforeLogin, onSessionChange, onLogin, + onNavigate, + beforeLogout, }: { beforeLogin: () => boolean; - onSessionChange: (session: Session | null) => void; + onSessionChange: ( + session: Session | null, + reason?: "expired" | "logout" | "sync", + ) => void; + onNavigate?: () => void; + beforeLogout?: () => boolean; onLogin?: () => void; }) { const [enabled, setEnabled] = useState(false); + const [renewalEnabled, setRenewalEnabled] = useState(false); + const sessionEnd = useRef<"expired" | "logout" | "sync">("sync"); + const channel = useRef(null); const [primary, setPrimary] = useState<"github" | "stackit" | "development">( "github", ); @@ -43,8 +53,15 @@ export function Account({ } | null>(null); const dialog = useRef(null); const [session, setSession] = useState(null); + const sessionRef = useRef(session); + sessionRef.current = session; + const lastSession = useRef(null); + if (session) lastSession.current = session; + const sessionIdentity = session + ? `${session.user.id}:${session.tenant?.id}:${session.csrfToken}` + : ""; useEffect(() => { - onSessionChange(session); + onSessionChange(session, session ? "sync" : sessionEnd.current); }, [session, onSessionChange]); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -78,6 +95,7 @@ export function Account({ : "github", ); setEnabled(true); + setRenewalEnabled(providers.stackitRenewal === true); if (returningFromStackit) return; const response = await fetch("/api/v1/session", { signal: controller.signal, @@ -130,6 +148,7 @@ export function Account({ }); if (!current.ok) throw new Error(); setSession(await current.json()); + channel.current?.postMessage("changed"); setAuthorization(null); setReturning(false); onLogin?.(); @@ -163,6 +182,7 @@ export function Account({ return; } if (primary === "github") { + onNavigate?.(); window.location.assign("/auth/github/start"); return; } @@ -188,7 +208,17 @@ export function Account({ if (!response.ok) throw new Error(); const result = await response.json(); const url = new URL(result.verificationUri); - if (url.origin !== "https://accounts.stackit.cloud") throw new Error(); + if ( + url.origin !== "https://accounts.stackit.cloud" || + url.username || + url.password + ) + throw new Error(); + if (!result.userCode) { + onNavigate?.(); + window.location.assign(url.toString()); + return; + } setAuthorization(result); } catch { setError("STACKIT-Anmeldung derzeit nicht erreichbar."); @@ -211,13 +241,119 @@ export function Account({ useEffect(() => { if (!session) return; const timer = setTimeout( - () => setSession(null), + () => { + sessionEnd.current = "expired"; + setSession(null); + }, Math.max(0, Date.parse(session.expiresAt) - Date.now()), ); return () => clearTimeout(timer); }, [session]); + useEffect(() => { + if (!enabled || returning || authorization) return; + const controller = new AbortController(); + let pending = false; + const check = async () => { + if (pending || document.visibilityState === "hidden") return; + pending = true; + try { + const response = await fetch("/api/v1/session", { + signal: controller.signal, + cache: "no-store", + }); + if (controller.signal.aborted) return; + if (response.status === 401) { + if (sessionRef.current) { + sessionEnd.current = "expired"; + setSession(null); + } + return; + } + if (!response.ok) throw new Error(); + let current: Session = await response.json(); + if (controller.signal.aborted) return; + setSession(current); + if (primary === "stackit" && renewalEnabled) { + const refreshed = await fetch("/auth/stackit/refresh", { + method: "POST", + headers: { "x-lzc-csrf": current.csrfToken }, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + if (refreshed.status === 401) { + sessionEnd.current = "expired"; + setSession(null); + return; + } + if (!refreshed.ok) throw new Error(); + const renewal = await refreshed.json(); + current = { + ...current, + stackitVerified: renewal.status === "active", + }; + } + if (controller.signal.aborted) return; + setSession(current); + setError((value) => + value === + "STACKIT konnte gerade nicht geprüft werden. Dein Entwurf bleibt erhalten; versuche es gleich erneut." + ? "" + : value, + ); + } catch { + if (!controller.signal.aborted && sessionRef.current) + setError( + "STACKIT konnte gerade nicht geprüft werden. Dein Entwurf bleibt erhalten; versuche es gleich erneut.", + ); + } finally { + pending = false; + } + }; + const timer = setInterval(() => void check(), 60000); + const resume = () => void check(); + document.addEventListener("visibilitychange", resume); + window.addEventListener("focus", resume); + const sync = + typeof BroadcastChannel === "undefined" + ? null + : new BroadcastChannel("lzc-auth-session"); + channel.current = sync; + if (sync) + sync.onmessage = (event) => { + const current = sessionRef.current ?? lastSession.current; + if ( + event.data?.type === "logout" && + current && + event.data.userId === current.user.id && + event.data.expiresAt === current.expiresAt + ) { + sessionEnd.current = "logout"; + lastSession.current = null; + if (!sessionRef.current) onSessionChange(null, "logout"); + setSession(null); + } else resume(); + }; + // Established sessions renew promptly; guest initialization remains a single request. + if (sessionIdentity && renewalEnabled) void check(); + return () => { + controller.abort(); + clearInterval(timer); + document.removeEventListener("visibilitychange", resume); + window.removeEventListener("focus", resume); + sync?.close(); + if (channel.current === sync) channel.current = null; + }; + }, [ + enabled, + primary, + renewalEnabled, + sessionIdentity, + returning, + authorization, + onSessionChange, + ]); async function logout() { - if (!session) return; + if (!session || beforeLogout?.() === false) return; setBusy(true); setError(""); try { @@ -226,7 +362,13 @@ export function Account({ headers: { "x-lzc-csrf": session.csrfToken }, }); if (!response.ok && response.status !== 401) throw new Error(); + sessionEnd.current = "logout"; setSession(null); + channel.current?.postMessage({ + type: "logout", + userId: session.user.id, + expiresAt: session.expiresAt, + }); } catch { setError("Abmelden fehlgeschlagen. Bitte erneut versuchen."); } finally { @@ -257,6 +399,7 @@ export function Account({ if (!current.ok) throw new Error("Development session unavailable"); setSession(await current.json()); setDevelopmentOpen(false); + channel.current?.postMessage("changed"); onLogin?.(); } catch { setError("Anmeldung derzeit nicht erreichbar."); @@ -305,14 +448,23 @@ export function Account({ type="button" className="button secondary" title={t( - "Du bist im Configurator angemeldet. Für geschützte STACKIT-Aktionen ist eine aktuelle Bestätigung erforderlich.", + "Die STACKIT-Anmeldung muss erneuert werden. Deinen Entwurf kannst du weiterhin speichern; Cloud-Aktionen benötigen die erneute Anmeldung.", )} disabled={busy || !!authorization} onClick={() => void start()} > - {t("STACKIT bestätigen")} + {t("STACKIT-Anmeldung erneuern")} )} + {session && + primary === "stackit" && + session.stackitVerified === false && ( +

+ {t( + "Die STACKIT-Anmeldung muss erneuert werden. Deinen Entwurf kannst du weiterhin speichern; Cloud-Aktionen benötigen die erneute Anmeldung.", + )} +

+ )} {authorization && ( item.id === remembered); if (!tenant) return; diff --git a/landing-zone-configurator/app/apps/web/src/locales/en.ts b/landing-zone-configurator/app/apps/web/src/locales/en.ts index 1b7c158b..a815e115 100644 --- a/landing-zone-configurator/app/apps/web/src/locales/en.ts +++ b/landing-zone-configurator/app/apps/web/src/locales/en.ts @@ -2747,4 +2747,13 @@ export const englishMessages: Record = { "Keine Sperre": "No lock", "Eine Recovery ist noch offen. Schließe zuerst den Abgleich dieser Bestellung ab.": "Recovery is still open. Complete reconciliation of this order first.", + "STACKIT-Anmeldung erneuern": "Renew STACKIT sign-in", + "Die STACKIT-Anmeldung muss erneuert werden. Deinen Entwurf kannst du weiterhin speichern; Cloud-Aktionen benötigen die erneute Anmeldung.": + "Your STACKIT sign-in needs renewal. You can still save your draft; cloud actions require signing in again.", + "STACKIT konnte gerade nicht geprüft werden. Dein Entwurf bleibt erhalten; versuche es gleich erneut.": + "STACKIT could not be checked right now. Your draft is preserved; try again shortly.", + "Deine Sitzung ist abgelaufen. Dein Entwurf bleibt hier erhalten und kann heruntergeladen werden. Melde dich erneut an, um ihn im selben Arbeitsbereich zu speichern.": + "Your session has expired. Your draft is preserved here and can be downloaded. Sign in again to save it in the same workspace.", + "Abmelden? Nicht gespeicherte Änderungen bitte zuerst speichern oder herunterladen.": + "Sign out? Save or download unsaved changes first.", }; diff --git a/landing-zone-configurator/app/apps/web/src/login-draft.ts b/landing-zone-configurator/app/apps/web/src/login-draft.ts index 65401fad..86f1541c 100644 --- a/landing-zone-configurator/app/apps/web/src/login-draft.ts +++ b/landing-zone-configurator/app/apps/web/src/login-draft.ts @@ -1,41 +1,146 @@ import { type EditorDraft, readEditorDraft } from "@lzc/domain"; +import type { Session } from "./components/Account"; +import type { ConfigurationBinding } from "./components/Configurations"; +import type { DeploymentStep, EditorStep, View } from "./navigation"; import { standaloneTemplate } from "./templates"; const key = "lzc-oauth-draft"; -export function preserveLoginDraft(draft: EditorDraft | null): boolean { +const ownerKey = (owner: { userId: string; tenantId: string }) => + `${key}:${encodeURIComponent(owner.userId)}:${encodeURIComponent(owner.tenantId)}`; +export type LoginDraftContext = { + owner?: { userId: string; tenantId: string }; + binding?: ConfigurationBinding | null; + savedDraft?: string | null; + location?: { view: View; step: EditorStep; deploymentStep: DeploymentStep }; +}; +export function preserveLoginDraft( + draft: EditorDraft | null, + context: LoginDraftContext = {}, +): boolean { try { - if (draft) - sessionStorage.setItem( - key, - JSON.stringify({ - draft, - sha256: standaloneTemplate.sha256, - expires: Date.now() + 10 * 60 * 1000, - }), - ); - else sessionStorage.removeItem(key); + if (draft) { + const value = JSON.stringify({ + draft, + ...context, + sha256: standaloneTemplate.sha256, + expires: Date.now() + 24 * 3600000, + }); + if (value.length > 3 * 1024 * 1024) return false; + if (context.owner) sessionStorage.setItem(ownerKey(context.owner), value); + sessionStorage.setItem(key, value); + } else sessionStorage.removeItem(key); return true; } catch { return !draft; } } -export function readLoginDraft(): EditorDraft | null { +export function readLoginRecovery( + session: Session | null = null, +): (LoginDraftContext & { draft: EditorDraft }) | null { try { - const raw = sessionStorage.getItem(key); - if (!raw || raw.length > 1024 * 1024) return null; + const raw = + (session?.user.id && session.tenant?.id + ? sessionStorage.getItem( + ownerKey({ userId: session.user.id, tenantId: session.tenant.id }), + ) + : null) ?? sessionStorage.getItem(key); + if (!raw || raw.length > 3 * 1024 * 1024) return null; const saved = JSON.parse(raw); if ( saved.sha256 !== standaloneTemplate.sha256 || !(saved.expires > Date.now()) ) return null; - return readEditorDraft(saved.draft); + if ( + saved.owner && + (!session || + saved.owner.userId !== session.user.id || + saved.owner.tenantId !== session.tenant?.id) + ) + return null; + if ( + saved.binding && + (!/^[0-9a-f-]{36}$/i.test(saved.binding.id) || + !Number.isSafeInteger(saved.binding.revision) || + saved.binding.revision < 1) + ) + return null; + if (saved.savedDraft != null) readEditorDraft(JSON.parse(saved.savedDraft)); + const location = saved.location; + if ( + location && + (![ + "workspaces", + "templates", + "preview", + "editor", + "repositories", + "credentials", + "deployments", + "history", + "organisation", + "applications", + ].includes(location.view) || + ![ + "basics", + "folders", + "network", + "platform", + "projects", + "operations", + "review", + ].includes(location.step) || + !["preparation", "plan", "apply", "history"].includes( + location.deploymentStep, + )) + ) + return null; + return { + draft: readEditorDraft(saved.draft), + owner: saved.owner, + binding: saved.binding ?? null, + savedDraft: saved.savedDraft ?? null, + location, + }; } catch { return null; } } -export function clearLoginDraft() { +export function readLoginDraft(): EditorDraft | null { + return readLoginRecovery()?.draft ?? null; +} +export function pendingLoginOwner( + session: Session, +): { userId: string; tenantId: string } | null { try { + const raw = sessionStorage.getItem(key); + if (!raw || raw.length > 3 * 1024 * 1024) return null; + const saved = JSON.parse(raw); + return saved.sha256 === standaloneTemplate.sha256 && + saved.expires > Date.now() && + saved.owner?.userId === session.user.id && + typeof saved.owner?.tenantId === "string" + ? saved.owner + : null; + } catch { + return null; + } +} +export function clearLoginDraft(session?: Session | null) { + try { + const raw = sessionStorage.getItem(key); + const owner = raw ? JSON.parse(raw).owner : null; + if (session?.user.id && session.tenant?.id) { + sessionStorage.removeItem( + ownerKey({ userId: session.user.id, tenantId: session.tenant.id }), + ); + if ( + owner && + (owner.userId !== session.user.id || + owner.tenantId !== session.tenant.id) + ) + return; + } else if (owner) sessionStorage.removeItem(ownerKey(owner)); sessionStorage.removeItem(key); } catch { /* Storage can be disabled. */ diff --git a/landing-zone-configurator/app/apps/web/src/main.tsx b/landing-zone-configurator/app/apps/web/src/main.tsx index 57267dd1..9bea690a 100644 --- a/landing-zone-configurator/app/apps/web/src/main.tsx +++ b/landing-zone-configurator/app/apps/web/src/main.tsx @@ -55,8 +55,10 @@ import "./style.css"; import { Account, type Session } from "./components/Account"; import { clearLoginDraft, + pendingLoginOwner, preserveLoginDraft, readLoginDraft, + readLoginRecovery, } from "./login-draft"; function TemplateDraftEditor({ @@ -201,20 +203,111 @@ function App() { ? "Die GitHub-Anmeldung ist fehlgeschlagen. Bitte erneut versuchen." : "", ); + const latestDraft = useRef({ + draft, + configurationBinding, + savedDraft, + view, + step, + deploymentStep, + }); + latestDraft.current = { + draft, + configurationBinding, + savedDraft, + view, + step, + deploymentStep, + }; const activeAccount = useRef(null); + const draftOwner = useRef(null); + const loginInProgress = useRef(window.location.hash === "#stackit-login"); + const [sessionExpired, setSessionExpired] = useState(false); const currentWorkspace = useRef(workspaceKey(session)); currentWorkspace.current = workspaceKey(session); - const changeSession = useCallback((next: Session | null) => { - const key = workspaceKey(next); - if (activeAccount.current && activeAccount.current !== key) { - setDraft(null); - setConfigurationBinding(null); - setSavedDraft(null); - setWorkspaceEpoch((value) => value + 1); - } - activeAccount.current = key; - setSession(next); - }, []); + const changeSession = useCallback( + (next: Session | null, reason: "expired" | "logout" | "sync" = "sync") => { + const key = workspaceKey(next); + if ( + reason !== "logout" && + activeAccount.current && + activeAccount.current !== key + ) { + const owner = draftOwner.current; + const snapshot = latestDraft.current; + if (snapshot.draft && owner?.user.id && owner.tenant?.id) + preserveLoginDraft(snapshot.draft, { + owner: { userId: owner.user.id, tenantId: owner.tenant.id }, + binding: snapshot.configurationBinding, + savedDraft: snapshot.savedDraft, + location: { + view: snapshot.view, + step: snapshot.step, + deploymentStep: snapshot.deploymentStep, + }, + }); + } + if (!next && reason === "expired") { + setSessionExpired(true); + setWorkspaceEpoch((value) => value + 1); + setSession(null); + return; + } + if (activeAccount.current && activeAccount.current !== key) { + setDraft(null); + setConfigurationBinding(null); + setSavedDraft(null); + setWorkspaceEpoch((value) => value + 1); + } + if (next) { + const liveOwner = workspaceKey(draftOwner.current); + const preferLive = + !!latestDraft.current.draft && (!liveOwner || liveOwner === key); + const recovered = + activeAccount.current === key || preferLive + ? null + : readLoginRecovery(next); + if (recovered) { + setDraft(recovered.draft); + setConfigurationBinding(recovered.binding ?? null); + setSavedDraft(recovered.savedDraft ?? null); + const location = recovered.location; + if ( + location && + (loginInProgress.current || + window.location.pathname === "/" || + (activeAccount.current !== null && activeAccount.current !== key)) + ) + setView( + location.view, + location.view === "editor" + ? location.step + : location.view === "deployments" + ? location.deploymentStep + : undefined, + true, + ); + clearLoginDraft(next); + } + const pending = loginInProgress.current + ? pendingLoginOwner(next) + : null; + if (pending && pending.tenantId !== next.tenant?.id) { + setReturningFromLogin(true); + setView("workspaces", undefined, true); + } else loginInProgress.current = false; + draftOwner.current = next; + setSessionExpired(false); + } else if (reason === "logout") { + clearLoginDraft(draftOwner.current); + draftOwner.current = null; + setSessionExpired(false); + } + activeAccount.current = key; + setSession(next); + }, + [setView], + ); useEffect(() => { const scope = workspaceKey(session); if (!scope) return; @@ -276,13 +369,39 @@ function App() { }); return () => controller.abort(); }, [session, platformAccess, view, draft, configurationBinding]); + useEffect(() => { + const owner = session ?? draftOwner.current; + if (!draft || !owner?.user.id || !owner.tenant?.id) return; + const timer = setTimeout(() => { + if ( + !preserveLoginDraft(draft, { + owner: { userId: owner.user.id!, tenantId: owner.tenant!.id }, + binding: configurationBinding, + savedDraft, + location: { view, step, deploymentStep }, + }) + ) + setLoginError( + "Dein Entwurf konnte nicht zwischengespeichert werden. Bitte lade ihn vor der Anmeldung herunter.", + ); + }, 500); + return () => clearTimeout(timer); + }, [ + draft, + session, + configurationBinding, + savedDraft, + view, + step, + deploymentStep, + ]); const leavingForLogin = useRef(false); const beforeWorkspaceNavigation = useCallback(() => { leavingForLogin.current = true; }, []); useEffect(() => { - clearLoginDraft(); - }, []); + if (restoredDraft) clearLoginDraft(); + }, [restoredDraft]); const [search, setSearch] = useState(""); const heading = useRef(null); // Focus the page title after client-side navigation. @@ -505,18 +624,49 @@ function App() { + !draftDirty || + window.confirm( + t( + "Abmelden? Nicht gespeicherte Änderungen bitte zuerst speichern oder herunterladen.", + ), + ) + } beforeLogin={() => { - if (!preserveLoginDraft(draft)) { + const owner = session ?? draftOwner.current; + if ( + !preserveLoginDraft(draft, { + ...(owner?.user.id && owner.tenant?.id + ? { + owner: { + userId: owner.user.id, + tenantId: owner.tenant.id, + }, + } + : {}), + binding: configurationBinding, + savedDraft, + location: { view, step, deploymentStep }, + }) + ) { setLoginError( "Dein Entwurf konnte nicht zwischengespeichert werden. Bitte lade ihn vor der Anmeldung herunter.", ); return false; } - leavingForLogin.current = true; + loginInProgress.current = true; return true; }} /> + {sessionExpired && ( +

+ {t( + "Deine Sitzung ist abgelaufen. Dein Entwurf bleibt hier erhalten und kann heruntergeladen werden. Melde dich erneut an, um ihn im selben Arbeitsbereich zu speichern.", + )} +

+ )}
diff --git a/landing-zone-configurator/app/e2e/configurator.spec.ts b/landing-zone-configurator/app/e2e/configurator.spec.ts index 11ba32dd..d580ef9e 100644 --- a/landing-zone-configurator/app/e2e/configurator.spec.ts +++ b/landing-zone-configurator/app/e2e/configurator.spec.ts @@ -2387,8 +2387,8 @@ for (const failedRequest of ["start", "poll"] as const) { }); return route.fulfill({ json: { - verificationUri: - "https://accounts.stackit.cloud/oauth/v2/authorize?response_type=code", + verificationUri: "https://accounts.stackit.cloud/device", + userCode: "RETRY-CODE", expiresAt: new Date(Date.now() + 900000).toISOString(), retryAfterMs: 10, }, @@ -2507,7 +2507,7 @@ for (const { codeFlow, returning, completedElsewhere, missingFlow } of [ missingFlow: true, }, ]) { - test(`STACKIT primary login, approval dialog and logout without GitHub ${codeFlow ? "PKCE" : "device"}${returning ? ` return${completedElsewhere ? " race" : missingFlow ? " missing" : ""}` : ""}`, async ({ + test(`STACKIT primary login, ${codeFlow ? "direct redirect" : "approval dialog"} and logout without GitHub ${codeFlow ? "PKCE" : "device"}${returning ? ` return${completedElsewhere ? " race" : missingFlow ? " missing" : ""}` : ""}`, async ({ page: initialPage, context, }, testInfo) => { @@ -2606,45 +2606,17 @@ for (const { codeFlow, returning, completedElsewhere, missingFlow } of [ }); } await installLoginRoutes(page); - await page.goto("/organisation"); - await page - .getByRole("button", { name: "Mit STACKIT anmelden", exact: true }) - .click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - if (codeFlow) await expect(dialog.getByLabel("Anmeldecode")).toHaveCount(0); - else await expect(dialog.getByLabel("Anmeldecode")).toHaveText("TEST-CODE"); - await expect( - dialog.getByRole("link", { name: "Bei STACKIT bestätigen" }), - ).toHaveAttribute( - "href", - codeFlow - ? /^https:\/\/accounts\.stackit\.cloud\/oauth\/v2\/authorize/ - : /^https:\/\/accounts\.stackit\.cloud\/device/, - ); - await expect( - dialog.getByRole("link", { name: "Bei STACKIT bestätigen" }), - ).toHaveAttribute("target", codeFlow ? "_self" : "_blank"); - expect( - await dialog.evaluate((element) => { - const box = element.getBoundingClientRect(); - return ( - box.left >= 0 && - box.right <= innerWidth && - box.top >= 0 && - box.bottom <= innerHeight - ); - }), - ).toBe(true); - await page.screenshot({ - path: testInfo.outputPath("stackit-login.png"), - fullPage: true, - }); - if (returning) { - const returnUrl = new URL("/#stackit-login", page.url()).toString(); + let providerRedirects = 0; + if (codeFlow) { + // PKCE navigates immediately: intercept the provider before pressing sign-in. + const returnUrl = new URL( + "/#stackit-login", + testInfo.project.use.baseURL, + ).toString(); await page.route( "https://accounts.stackit.cloud/oauth/v2/authorize*", (route) => { + providerRedirects++; approved = true; return route.fulfill({ status: 302, @@ -2652,12 +2624,43 @@ for (const { codeFlow, returning, completedElsewhere, missingFlow } of [ }); }, ); - await dialog - .getByRole("link", { name: "Bei STACKIT bestätigen" }) - .click(); + } + await page.goto("/organisation"); + await page + .getByRole("button", { name: "Mit STACKIT anmelden", exact: true }) + .click(); + const dialog = page.getByRole("dialog"); + if (codeFlow) { + await expect.poll(() => providerRedirects).toBe(1); + await expect(dialog).toHaveCount(0); await expect(page).not.toHaveURL(/#stackit-login/); expect(context.pages()).toEqual([page]); - } else approved = true; + } else { + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Anmeldecode")).toHaveText("TEST-CODE"); + await expect( + dialog.getByRole("link", { name: "Bei STACKIT bestätigen" }), + ).toHaveAttribute("href", /^https:\/\/accounts\.stackit\.cloud\/device/); + await expect( + dialog.getByRole("link", { name: "Bei STACKIT bestätigen" }), + ).toHaveAttribute("target", "_blank"); + expect( + await dialog.evaluate((element) => { + const box = element.getBoundingClientRect(); + return ( + box.left >= 0 && + box.right <= innerWidth && + box.top >= 0 && + box.bottom <= innerHeight + ); + }), + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath("stackit-login.png"), + fullPage: true, + }); + approved = true; + } if (missingFlow) { await expect(page.locator(".account")).toContainText( "STACKIT-Anmeldung fehlgeschlagen", @@ -2751,8 +2754,8 @@ test("session proof renewal is distinct from signing in and preserves authentica starts++; return route.fulfill({ json: { - verificationUri: - "https://accounts.stackit.cloud/oauth/v2/authorize?response_type=code&code_challenge_method=S256", + verificationUri: "https://accounts.stackit.cloud/device", + userCode: "RENEW-CODE", expiresAt: new Date(Date.now() + 300000).toISOString(), retryAfterMs: 50, }, @@ -2779,12 +2782,12 @@ test("session proof renewal is distinct from signing in and preserves authentica account.getByRole("button", { name: "Mit STACKIT anmelden", exact: true }), ).toHaveCount(0); const confirmation = account.getByRole("button", { - name: "STACKIT bestätigen", + name: "STACKIT-Anmeldung erneuern", exact: true, }); await expect(confirmation).toHaveAttribute( "title", - /im Configurator angemeldet/, + /Cloud-Aktionen benötigen die erneute Anmeldung/, ); await confirmation.click(); const dialog = page.getByRole("dialog"); @@ -2807,10 +2810,10 @@ test("session proof renewal is distinct from signing in and preserves authentica ).toBe(true); approved = true; await account - .getByRole("button", { name: "Confirm STACKIT", exact: true }) + .getByRole("button", { name: "Renew STACKIT sign-in", exact: true }) .click(); await expect( - account.getByRole("button", { name: "Confirm STACKIT", exact: true }), + account.getByRole("button", { name: "Renew STACKIT sign-in", exact: true }), ).toHaveCount(0); await expect( account.getByRole("button", { name: "Sign out", exact: true }), @@ -2819,7 +2822,7 @@ test("session proof renewal is distinct from signing in and preserves authentica expect(starts).toBe(2); await page.reload(); await expect( - account.getByRole("button", { name: "Confirm STACKIT", exact: true }), + account.getByRole("button", { name: "Renew STACKIT sign-in", exact: true }), ).toHaveCount(0); await expect( account.getByRole("button", { name: "Sign out", exact: true }), @@ -2827,7 +2830,7 @@ test("session proof renewal is distinct from signing in and preserves authentica verified = false; await page.reload(); await expect( - account.getByRole("button", { name: "Confirm STACKIT", exact: true }), + account.getByRole("button", { name: "Renew STACKIT sign-in", exact: true }), ).toBeVisible(); await expect( account.getByRole("button", { name: "Sign in with STACKIT", exact: true }), @@ -2841,7 +2844,7 @@ test("session proof renewal is distinct from signing in and preserves authentica account.getByRole("button", { name: "Sign out", exact: true }), ).toHaveCount(0); await expect( - account.getByRole("button", { name: "Confirm STACKIT", exact: true }), + account.getByRole("button", { name: "Renew STACKIT sign-in", exact: true }), ).toHaveCount(0); }); @@ -3141,9 +3144,6 @@ for (const provider of ["GitHub", "STACKIT"] as const) { await page .getByLabel("Name der Konfiguration") .fill("Entwurf vor Anmeldung"); - await page - .getByRole("button", { name: `Mit ${provider} anmelden` }) - .click(); if (provider === "STACKIT") { const returnUrl = new URL("/#stackit-login", page.url()).toString(); await page.route( @@ -3156,11 +3156,11 @@ for (const provider of ["GitHub", "STACKIT"] as const) { }); }, ); - await page - .getByRole("dialog") - .getByRole("link", { name: "Bei STACKIT bestätigen" }) - .click(); } + await page + .getByRole("button", { name: `Mit ${provider} anmelden` }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); await expect( page .locator(".account") @@ -3173,7 +3173,12 @@ for (const provider of ["GitHub", "STACKIT"] as const) { await page.evaluate(() => sessionStorage.getItem("lzc-oauth-draft")), ).toBeNull(); expect(context.pages()).toEqual([page]); + const logoutConfirmation = page.waitForEvent("dialog"); + page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: "Abmelden", exact: true }).click(); + expect((await logoutConfirmation).message()).toBe( + "Abmelden? Nicht gespeicherte Änderungen bitte zuerst speichern oder herunterladen.", + ); await expect( page.getByRole("button", { name: `Mit ${provider} anmelden` }), ).toBeVisible(); @@ -5505,7 +5510,11 @@ test.describe("explicit saved-plan approval", () => { page.getByRole("button", { name: "Freigegebenen Plan anwenden" }), ).toBeEnabled(); const initialReads = reads; - await page.clock.fastForward(61000); + // Session renewal deliberately refreshes session-bound data every 60s. + // Assert idle execution polling independently before that heartbeat. + await page.clock.fastForward(30000); + expect(reads).toBe(initialReads); + await page.clock.fastForward(31000); await expect( page.getByRole("button", { name: "Freigegebenen Plan anwenden" }), ).toHaveCount(0); @@ -5521,7 +5530,9 @@ test.describe("explicit saved-plan approval", () => { await expect( page.getByText(/Plan abgelaufen oder keine Gültigkeit bestätigt/), ).toBeVisible(); - expect(reads).toBe(initialReads); + const expiredReads = reads; + await page.clock.fastForward(30000); + expect(reads).toBe(expiredReads); expect(writes).toBe(0); }); diff --git a/landing-zone-configurator/app/e2e/session-lifecycle.spec.ts b/landing-zone-configurator/app/e2e/session-lifecycle.spec.ts new file mode 100644 index 00000000..fb0d8724 --- /dev/null +++ b/landing-zone-configurator/app/e2e/session-lifecycle.spec.ts @@ -0,0 +1,499 @@ +import { createEditorConfiguration } from "@lzc/domain"; +import type { Session } from "../apps/web/src/components/Account"; +import { expect, type Page, test } from "./fixtures"; + +const user = "11111111-1111-4111-8111-111111111111"; +const tenant = "22222222-2222-4222-8222-222222222222"; +const rowId = "33333333-3333-4333-8333-333333333333"; +const documentId = "44444444-4444-4444-8444-444444444444"; +const makeSession = (): Session => ({ + user: { id: user, login: "owner@example.test" }, + tenant: { + id: tenant, + name: "Owner workspace", + kind: "organisation", + roles: ["platform-engineer"], + }, + csrfToken: "csrf-session-lifecycle", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + stackitVerified: true, +}); + +async function setup(page: Page, renewalEnabled = true) { + let current: Session | null = makeSession(); + let refreshStatus = 200; + let refreshes = 0; + let renewalStatus = "active"; + let refreshGate: Promise | null = null; + let writes = 0; + const original = { + ...createEditorConfiguration("standalone", documentId), + name: "Saved private platform", + }; + await page.route("**/auth/status", (route) => + route.fulfill({ + json: { + github: false, + stackit: true, + primary: "stackit", + stackitRenewal: renewalEnabled, + }, + }), + ); + await page.route("**/api/v1/session", (route) => + route.fulfill( + current + ? { json: current } + : { status: 401, json: { error: "authentication_required" } }, + ), + ); + await page.route("**/auth/stackit/refresh", async (route) => { + expect(route.request().method()).toBe("POST"); + expect(route.request().headers()["x-lzc-csrf"]).toBe(current?.csrfToken); + refreshes++; + if (refreshGate) await refreshGate; + return route.fulfill( + refreshStatus === 200 + ? { json: { status: renewalStatus } } + : { status: refreshStatus, json: { error: "provider_unavailable" } }, + ); + }); + await page.route("**/api/v1/configurations", (route) => { + expect(route.request().method()).toBe("GET"); + return route.fulfill({ + json: { + configurations: + current?.tenant?.id === tenant + ? [ + { + id: rowId, + name: original.name, + templateId: "standalone", + revision: 1, + updatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + issueCount: 0, + }, + ] + : [], + }, + }); + }); + await page.route(`**/api/v1/configurations/${rowId}`, (route) => { + expect(current?.tenant?.id).toBe(tenant); + if (route.request().method() === "GET") + return route.fulfill({ + json: { + configuration: { + id: rowId, + name: original.name, + revision: 1, + draft: original, + }, + }, + }); + expect(route.request().method()).toBe("PUT"); + expect(route.request().postDataJSON().revision).toBe(1); + writes++; + return route.fulfill({ + json: { + configuration: { + id: rowId, + revision: 2, + draft: route.request().postDataJSON().draft, + }, + }, + }); + }); + for (const [name, body] of Object.entries({ + templates: { versions: [] }, + instances: { instances: [] }, + "platform-contracts": { contracts: [] }, + })) + await page.route(`**/api/v1/applications/${name}`, (route) => { + expect(route.request().method()).toBe("GET"); + return route.fulfill({ json: body }); + }); + await page.goto("/configurations"); + await page + .getByRole("button", { + name: `Konfiguration öffnen: ${original.name}`, + exact: true, + }) + .click(); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveValue(original.name); + if (renewalEnabled) await expect.poll(() => refreshes).toBeGreaterThan(0); + return { + session: () => current, + setSession: (next: Session | null) => { + current = next; + }, + holdRefresh: (status: string) => { + renewalStatus = status; + let release!: () => void; + refreshGate = new Promise((resolve) => { + release = resolve; + }); + return () => { + refreshGate = null; + release(); + }; + }, + setRefreshStatus: (status: number) => { + refreshStatus = status; + }, + refreshes: () => refreshes, + writes: () => writes, + }; +} + +async function focusHeartbeat(page: Page) { + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); +} + +test("STACKIT heartbeat preserves focused unsaved draft and persisted binding", async ({ + page, +}) => { + await page.clock.install(); + const state = await setup(page); + const name = page.getByLabel("Name der Konfiguration", { exact: true }); + await name.fill("Unsaved heartbeat draft"); + await name.focus(); + const input = await name.elementHandle(); + const initial = state.refreshes(); + const next = makeSession(); + next.expiresAt = new Date(Date.now() + 7200000).toISOString(); + state.setSession(next); + await page.clock.fastForward(60_001); + await expect.poll(state.refreshes).toBeGreaterThan(initial); + await expect(name).toBeFocused(); + await expect(name).toHaveValue("Unsaved heartbeat draft"); + expect( + await name.evaluate((element, original) => element === original, input), + ).toBe(true); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeDisabled(); + expect(state.writes()).toBe(0); + await page + .locator("#configuration-save") + .getByRole("button", { name: "Konfiguration speichern", exact: true }) + .click(); + await expect.poll(state.writes).toBe(1); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeEnabled(); +}); + +test("transient renewal errors and session expiry preserve the owned draft for the same account", async ({ + page, +}) => { + const state = await setup(page); + const name = page.getByLabel("Name der Konfiguration", { exact: true }); + await name.fill("Unsaved expired draft"); + state.setRefreshStatus(503); + const initial = state.refreshes(); + await focusHeartbeat(page); + await expect.poll(state.refreshes).toBeGreaterThan(initial); + await expect(page.locator(".account")).toContainText( + "Dein Entwurf bleibt erhalten", + ); + await expect(name).toHaveValue("Unsaved expired draft"); + state.setSession(null); + await focusHeartbeat(page); + await expect( + page.getByRole("button", { name: "Mit STACKIT anmelden", exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Deine Sitzung ist abgelaufen.", { exact: false }), + ).toBeVisible(); + await expect(name).toHaveValue("Unsaved expired draft"); + state.setRefreshStatus(200); + state.setSession(makeSession()); + // Another tab may have completed sign-in; the next focus must recover this account only. + await focusHeartbeat(page); + await expect( + page.getByRole("button", { name: "Abmelden", exact: true }), + ).toBeVisible(); + await expect(name).toHaveValue("Unsaved expired draft"); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeDisabled(); + expect(state.writes()).toBe(0); +}); + +test("session scope switches hide private drafts and restore only their original workspace", async ({ + page, +}) => { + const state = await setup(page); + await page + .getByLabel("Name der Konfiguration", { exact: true }) + .fill("Private unsaved workspace draft"); + const other = makeSession(); + other.user = { + id: "55555555-5555-4555-8555-555555555555", + login: "other@example.test", + }; + other.tenant = { + id: "66666666-6666-4666-8666-666666666666", + name: "Other workspace", + kind: "organisation", + roles: ["application-owner"], + }; + other.csrfToken = "other-csrf"; + state.setSession(other); + await focusHeartbeat(page); + await expect(page.locator(".account")).toContainText("other@example.test"); + await expect(page).toHaveURL(/\/applications$/); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText("Private unsaved workspace draft", { exact: true }), + ).toHaveCount(0); + await expect( + page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Konfigurationen", exact: true }), + ).toHaveCount(0); + state.setSession(makeSession()); + await focusHeartbeat(page); + await expect(page.locator(".account")).toContainText("owner@example.test"); + await expect(page).toHaveURL(/\/configurations\/edit\/basics$/); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveValue("Private unsaved workspace draft"); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeDisabled(); + expect(state.writes()).toBe(0); + await page + .locator("#configuration-save") + .getByRole("button", { name: "Konfiguration speichern", exact: true }) + .click(); + await expect.poll(state.writes).toBe(1); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeEnabled(); +}); + +test("legacy auth status never opts into STACKIT refresh implicitly", async ({ + page, +}) => { + const state = await setup(page, false); + await page + .getByLabel("Name der Konfiguration", { exact: true }) + .fill("Legacy session draft"); + await focusHeartbeat(page); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveValue("Legacy session draft"); + expect(state.refreshes()).toBe(0); + expect(state.writes()).toBe(0); +}); + +test("an explicit renewal-required response becomes visible without replacing the draft", async ({ + page, +}) => { + const state = await setup(page); + const initial = state.refreshes(); + const release = state.holdRefresh("reauthentication-required"); + await focusHeartbeat(page); + await expect.poll(state.refreshes).toBeGreaterThan(initial); + const name = page.getByLabel("Name der Konfiguration", { exact: true }); + await name.fill("Edited while renewal is pending"); + release(); + await expect( + page.getByRole("button", { + name: "STACKIT-Anmeldung erneuern", + exact: true, + }), + ).toBeVisible(); + await expect(name).toHaveValue("Edited while renewal is pending"); + expect(state.writes()).toBe(0); +}); + +async function broadcastLogout(page: Page, session: Session) { + await page.evaluate( + ({ userId, expiresAt }) => { + const sync = new BroadcastChannel("lzc-auth-session"); + sync.postMessage({ type: "logout", userId, expiresAt }); + sync.close(); + }, + { userId: session.user.id, expiresAt: session.expiresAt }, + ); +} + +test("a matching scoped logout clears a draft even after the local session expired", async ({ + page, +}) => { + const state = await setup(page); + const session = state.session()!; + await page + .getByLabel("Name der Konfiguration", { exact: true }) + .fill("Expired private logout draft"); + state.setSession(null); + await focusHeartbeat(page); + await expect( + page.getByRole("button", { name: "Mit STACKIT anmelden", exact: true }), + ).toBeVisible(); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveValue("Expired private logout draft"); + await broadcastLogout(page, session); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveCount(0); + expect( + await page.evaluate( + () => + Object.keys(sessionStorage).filter((key) => + key.startsWith("lzc-oauth-draft"), + ).length, + ), + ).toBe(0); + state.setSession(session); + await focusHeartbeat(page); + await expect( + page.getByRole("button", { name: "Abmelden", exact: true }), + ).toBeVisible(); + expect( + await page + .locator("input") + .evaluateAll((inputs) => + inputs.some( + (input) => + input instanceof HTMLInputElement && + input.value === "Expired private logout draft", + ), + ), + ).toBe(false); + expect(state.writes()).toBe(0); +}); + +test("logout from an older session generation cannot clear the renewed owned draft", async ({ + page, +}) => { + const state = await setup(page); + const previous = state.session()!; + await page + .getByLabel("Name der Konfiguration", { exact: true }) + .fill("New generation unsaved draft"); + const renewed = { + ...makeSession(), + csrfToken: "new-generation-csrf", + expiresAt: new Date(Date.now() + 7200000).toISOString(), + }; + state.setSession(renewed); + let initial = state.refreshes(); + await focusHeartbeat(page); + await expect.poll(state.refreshes).toBeGreaterThan(initial); + initial = state.refreshes(); + await broadcastLogout(page, previous); + await expect.poll(state.refreshes).toBeGreaterThan(initial); + await expect( + page.getByRole("button", { name: "Abmelden", exact: true }), + ).toBeVisible(); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveValue("New generation unsaved draft"); + await expect( + page.getByRole("button", { name: "Deployment vorbereiten", exact: true }), + ).toBeDisabled(); + expect(state.writes()).toBe(0); +}); + +test("authenticated STACKIT renewal redirects directly and preserves the dirty saved configuration", async ({ + page, + context, +}) => { + const state = await setup(page, false); + const name = page.getByLabel("Name der Konfiguration", { exact: true }); + await name.fill("Private draft across direct renewal"); + state.setSession({ ...makeSession(), stackitVerified: false }); + await focusHeartbeat(page); + const renewal = page.getByRole("button", { + name: "STACKIT-Anmeldung erneuern", + exact: true, + }); + await expect(renewal).toBeVisible(); + let starts = 0; + let providerVisits = 0; + let polls = 0; + const callback = new URL("/#stackit-login", page.url()).toString(); + await page.route("**/auth/stackit/start", (route) => { + starts++; + expect(route.request().method()).toBe("POST"); + expect(route.request().headers()["x-lzc-csrf"]).toBe( + makeSession().csrfToken, + ); + return route.fulfill({ + json: { + verificationUri: + "https://accounts.stackit.cloud/oauth/v2/authorize?response_type=code&code_challenge_method=S256", + expiresAt: new Date(Date.now() + 300000).toISOString(), + retryAfterMs: 50, + }, + }); + }); + await page.route( + "https://accounts.stackit.cloud/oauth/v2/authorize*", + (route) => { + providerVisits++; + state.setSession(makeSession()); + return route.fulfill({ status: 302, headers: { location: callback } }); + }, + ); + await page.route("**/auth/stackit/poll", (route) => { + polls++; + return route.fulfill({ json: { status: "verified" } }); + }); + await renewal.click(); + await expect(name).toHaveValue("Private draft across direct renewal"); + await expect(renewal).toHaveCount(0); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.locator(".account")).toContainText("owner@example.test"); + expect(starts).toBe(1); + expect(providerVisits).toBe(1); + expect(polls).toBe(1); + expect(context.pages()).toEqual([page]); + expect(state.writes()).toBe(0); + await page + .locator("#configuration-save") + .getByRole("button", { name: "Konfiguration speichern", exact: true }) + .click(); + await expect.poll(() => state.writes()).toBe(1); +}); + +test("an explicit URL wins over an older draft checkpoint location on immediate reload", async ({ + page, +}) => { + const clockStart = new Date(); + await page.clock.install({ time: clockStart }); + await page.clock.pauseAt(new Date(clockStart.getTime() + 1000)); + await setup(page, false); + await page + .getByLabel("Name der Konfiguration", { exact: true }) + .fill("Draft with older editor location"); + await page.clock.fastForward(1000); + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Application Landing Zones", exact: true }) + .click(); + await expect(page).toHaveURL(/\/applications$/); + // The clock is paused: the new location has not reached the 500ms checkpoint. + await page.reload(); + await expect(page).toHaveURL(/\/applications$/); + await expect( + page.getByRole("tab", { name: "Template-Entwürfe", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Konfigurationen", exact: true }) + .click(); + await expect( + page.getByLabel("Name der Konfiguration", { exact: true }), + ).toHaveValue("Draft with older editor location"); +}); diff --git a/landing-zone-configurator/app/integration/stackit-sessions.test.ts b/landing-zone-configurator/app/integration/stackit-sessions.test.ts new file mode 100644 index 00000000..aa712351 --- /dev/null +++ b/landing-zone-configurator/app/integration/stackit-sessions.test.ts @@ -0,0 +1,311 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; +import { newSessionToken } from "../apps/api/src/auth/github-flow.js"; +import { + type StackitRefreshGrant, + stackitIssuer, +} from "../apps/api/src/auth/stackit-device.js"; +import { StackitIdentities } from "../apps/api/src/auth/stackit-identities.js"; +import { + StackitRefreshClient, + StackitSessions, +} from "../apps/api/src/auth/stackit-sessions.js"; +import { PostgresAuthStore } from "../apps/api/src/auth/store.js"; +import { PostgresOrganisations } from "../apps/api/src/organisation/service.js"; +import { ArtifactCrypto } from "../apps/api/src/plans/crypto.js"; +import { migrate } from "../apps/api/src/storage/migrations.js"; + +const address = new URL(process.env.LZC_TEST_DATABASE_URL ?? ""); +if ( + !["127.0.0.1", "localhost"].includes(address.hostname) || + address.pathname !== "/configurator_test" +) + throw new Error("Refusing a non-local/non-test database"); +const admin = new pg.Client({ connectionString: address.href }); +const connection = { + host: address.hostname, + port: Number(address.port), + database: "configurator_test", + user: "configurator_migration", + password: "migration-test-only", +}; +const migration = new pg.Client(connection); +// Two runtime connections are intentional: serialization must come from the +// PostgreSQL session-row lock, not an accidentally single-connection client pool. +const pool = new pg.Pool({ + ...connection, + user: "configurator_app", + password: "runtime-test-only", + max: 3, +}); +const store = new PostgresAuthStore(pool); +const crypto = new ArtifactCrypto(randomBytes(32).toString("base64")); +const clientId = "synthetic-session-renewal-client"; +const provider = () => + new StackitRefreshClient(async () => { + throw new Error("Unexpected external request in isolated database test"); + }); + +beforeAll(async () => { + await admin.connect(); + await admin.query( + "DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='configurator_migration') THEN CREATE ROLE configurator_migration LOGIN PASSWORD 'migration-test-only'; END IF; IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='configurator_app') THEN CREATE ROLE configurator_app LOGIN PASSWORD 'runtime-test-only'; END IF; END $$; ALTER DATABASE configurator_test OWNER TO configurator_migration", + ); + await migration.connect(); + await migrate( + migration, + fileURLToPath(new URL("../apps/api/db/", import.meta.url)), + ); +}, 30000); +afterAll(async () => { + await Promise.all([pool.end(), migration.end(), admin.end()]); +}); + +async function login(subject = randomUUID(), existingSessionId?: string) { + const secret = newSessionToken(); + const expiresAt = new Date(Date.now() + 8 * 3600000 - 2000); + const identity = { + issuer: stackitIssuer, + subject, + email: `renewal-${subject}@example.test`, + emailVerified: true as const, + verificationMethod: "signed-id-token-and-userinfo" as const, + tokenExpiresAt: new Date(Date.now() + 60000).toISOString(), + organization: null, + }; + const session = await store.createStackitSession({ + identity, + id: randomUUID(), + hash: secret.hash, + csrfToken: randomBytes(32).toString("base64url"), + expiresAt, + ...(existingSessionId ? { existingSessionId } : {}), + }); + const grant: StackitRefreshGrant = { + clientId, + refreshToken: `synthetic-refresh-${randomUUID()}`, + nonce: randomUUID(), + issuer: stackitIssuer, + subject, + email: identity.email, + expiresAt: identity.tokenExpiresAt, + }; + return { session, secret, expiresAt, identity, grant }; +} +async function stored(id: string) { + const result = await migration.query<{ + stackit_refresh_cipher: Buffer | null; + expires_at: Date; + tenant_id: string; + active_tenant_id: string | null; + }>( + "SELECT stackit_refresh_cipher,expires_at,tenant_id,active_tenant_id FROM lzc_auth.sessions WHERE id=$1", + [id], + ); + const row = result.rows[0]; + if (!row) throw new Error("Missing synthetic session"); + return row; +} + +it("encrypts refresh grants with immutable personal-tenant/user/session AAD and rejects ciphertext transplanted to another session", async () => { + const first = await login(); + const fake = provider(); + const refresh = vi.spyOn(fake, "refresh"); + const sessions = new StackitSessions(pool, crypto, clientId, fake); + await sessions.put(first.session, first.secret.hash, first.grant); + const cipher = (await stored(first.session.id)).stackit_refresh_cipher; + if (!cipher) throw new Error("Expected encrypted grant"); + expect(cipher.toString("utf8")).not.toContain(first.grant.refreshToken); + const aad = [ + first.session.tenantId, + first.session.userId, + `stackit-refresh:${first.session.id}`, + ] as const; + expect(JSON.parse(crypto.decrypt(cipher, ...aad).toString("utf8"))).toEqual( + first.grant, + ); + for (const wrong of [ + [randomUUID(), aad[1], aad[2]], + [aad[0], randomUUID(), aad[2]], + [aad[0], aad[1], `stackit-refresh:${randomUUID()}`], + ] as const) + expect(() => + crypto.decrypt(cipher, wrong[0], wrong[1], wrong[2]), + ).toThrow(); + const second = await login(first.identity.subject); + await migration.query( + "UPDATE lzc_auth.sessions SET stackit_refresh_cipher=$2 WHERE id=$1", + [second.session.id, cipher], + ); + await expect( + sessions.refresh(second.session, second.secret.token), + ).rejects.toThrow(); + expect(refresh).not.toHaveBeenCalled(); +}); + +it("serializes concurrent rotation across real DB connections, preserves workspace and never extends app expiry or organization-owner proof", async () => { + const first = await login(); + const organisations = new PostgresOrganisations(pool); + const organizationId = randomUUID(); + const tenantId = await organisations.create( + first.session, + "Synthetic renewal workspace", + organizationId, + ); + await organisations.switch(first.session, tenantId); + const active = await store.resolveSession(first.secret.token); + if (!active) throw new Error("Missing scoped synthetic session"); + expect(active.tokenTenantId).toBe(first.session.tenantId); + expect(active.tenantId).toBe(tenantId); + await new StackitIdentities(pool).save(active, { + ...first.identity, + organization: { + id: organizationId, + name: "Synthetic organization", + permissions: ["organization.read", "organization.write"], + ownerPermissions: ["organization.write"], + }, + }); + const before = ( + await migration.query( + "SELECT i.verified_at,i.valid_until,a.verified_at AS organization_verified_at,a.valid_until AS organization_valid_until,a.owner_permissions FROM lzc.stackit_identities i JOIN lzc.stackit_organization_access a ON a.user_id=i.user_id WHERE i.user_id=$1 AND a.tenant_id=$2", + [active.userId, tenantId], + ) + ).rows[0]; + const fake = provider(); + let unblock = () => {}; + const gate = new Promise((resolve) => { + unblock = resolve; + }); + let inFlight = 0, + peak = 0; + const next = { + ...first.grant, + refreshToken: `synthetic-rotated-${randomUUID()}`, + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }; + const refresh = vi + .spyOn(fake, "refresh") + .mockImplementation(async (grant) => { + expect(grant).toEqual(first.grant); + inFlight++; + peak = Math.max(peak, inFlight); + await gate; + inFlight--; + return next; + }); + const sessions = new StackitSessions(pool, crypto, clientId, fake); + await sessions.put(active, first.secret.hash, first.grant); + const one = sessions.refresh(active, first.secret.token); + await vi.waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + const two = sessions.refresh(active, first.secret.token); + try { + await vi.waitFor(async () => { + const waiting = await admin.query( + "SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname=current_database() AND usename='configurator_app' AND wait_event_type='Lock' AND query LIKE '%lock_stackit_refresh%'", + ); + expect(waiting.rows[0].count).toBeGreaterThanOrEqual(1); + }); + expect(refresh).toHaveBeenCalledTimes(1); + } finally { + unblock(); + } + expect(await Promise.all([one, two])).toEqual([ + { status: "active", verifiedUntil: next.expiresAt }, + { status: "active", verifiedUntil: next.expiresAt }, + ]); + expect(peak).toBe(1); + expect(refresh).toHaveBeenCalledTimes(1); + const row = await stored(active.id); + expect(row.expires_at).toEqual(first.expiresAt); + expect(row.active_tenant_id).toBe(tenantId); + if (!row.stackit_refresh_cipher) throw new Error("Missing rotated cipher"); + expect( + JSON.parse( + crypto + .decrypt( + row.stackit_refresh_cipher, + first.session.tenantId, + active.userId, + `stackit-refresh:${active.id}`, + ) + .toString("utf8"), + ), + ).toEqual(next); + const after = ( + await migration.query( + "SELECT i.verified_at,i.valid_until,a.verified_at AS organization_verified_at,a.valid_until AS organization_valid_until,a.owner_permissions FROM lzc.stackit_identities i JOIN lzc.stackit_organization_access a ON a.user_id=i.user_id WHERE i.user_id=$1 AND a.tenant_id=$2", + [active.userId, tenantId], + ) + ).rows[0]; + expect(after).toEqual({ ...before, valid_until: new Date(next.expiresAt) }); + const repeatedLogin = await login(first.identity.subject, active.id); + const resolved = await store.resolveSession(repeatedLogin.secret.token); + expect(resolved?.tenantId).toBe(tenantId); + expect(resolved?.tokenTenantId).toBe(first.session.tenantId); +}); + +it("rejects missing or revoked identity and invalid identity bindings without renewing session or restoring proof", async () => { + const first = await login(); + const fake = provider(); + const refresh = vi.spyOn(fake, "refresh"); + const sessions = new StackitSessions(pool, crypto, clientId, fake); + await sessions.put(first.session, first.secret.hash, first.grant); + await expect( + pool.query("SELECT lzc_auth.complete_stackit_refresh($1,$2,$3,$4,$5,$6)", [ + first.secret.hash, + (await stored(first.session.id)).stackit_refresh_cipher, + stackitIssuer, + "wrong-subject", + first.identity.email, + new Date(Date.now() + 3600000), + ]), + ).rejects.toMatchObject({ code: "42501" }); + await expect( + pool.query("SELECT lzc_auth.complete_stackit_refresh($1,$2,$3,$4,$5,$6)", [ + first.secret.hash, + (await stored(first.session.id)).stackit_refresh_cipher, + stackitIssuer, + first.identity.subject, + first.identity.email, + new Date(Date.now() + 25 * 3600000), + ]), + ).rejects.toMatchObject({ code: "23514" }); + await new StackitIdentities(pool).revoke(first.session); + await expect( + pool.query("SELECT lzc_auth.complete_stackit_refresh($1,$2,$3,$4,$5,$6)", [ + first.secret.hash, + (await stored(first.session.id)).stackit_refresh_cipher, + stackitIssuer, + first.identity.subject, + first.identity.email, + new Date(Date.now() + 3600000), + ]), + ).rejects.toMatchObject({ code: "42501" }); + await expect( + sessions.refresh(first.session, first.secret.token), + ).resolves.toEqual({ status: "reauthentication-required" }); + expect((await stored(first.session.id)).expires_at).toEqual(first.expiresAt); + const revoked = ( + await migration.query( + "SELECT revoked_at FROM lzc.stackit_identities WHERE user_id=$1", + [first.session.userId], + ) + ).rows[0]; + expect(revoked.revoked_at).not.toBeNull(); + const secret = newSessionToken(); + const noIdentity = await store.createSession({ + githubId: 900000000 + Math.floor(Math.random() * 100000000), + login: "synthetic-without-stackit", + id: randomUUID(), + hash: secret.hash, + csrfToken: randomBytes(32).toString("base64url"), + expiresAt: new Date(Date.now() + 3600000), + }); + await expect(sessions.refresh(noIdentity, secret.token)).resolves.toEqual({ + status: "reauthentication-required", + }); + expect(refresh).not.toHaveBeenCalled(); +}); diff --git a/landing-zone-configurator/app/scripts/assistant-scenarios.ts b/landing-zone-configurator/app/scripts/assistant-scenarios.ts index 2f644af1..160edcbe 100644 --- a/landing-zone-configurator/app/scripts/assistant-scenarios.ts +++ b/landing-zone-configurator/app/scripts/assistant-scenarios.ts @@ -139,7 +139,10 @@ export type AssistantScenario = { | "vm-default" | "workload-default" | "kubernetes-add" - | "kubernetes-requester"; + | "kubernetes-requester" + | "public-kubernetes-requester" + | "public-kubernetes-clarify" + | "public-kubernetes-followup"; corporateIpv4?: "advice" | "automatic" | "explicit"; history?: readonly { role: "user" | "assistant"; content: string }[]; }; @@ -277,6 +280,36 @@ export const assistantScenarios: readonly AssistantScenario[] = [ message: "Ergänze bei der vorhandenen Vorlage central nur für den Besteller die Rechte zum Management von Kubernetes Clustern. Die Rechte des Application-Service-Accounts und der zentrale Owner bleiben unverändert, ebenso alle anderen Einstellungen. Nur einen überprüfbaren Vorschlag erstellen.", }, + ...(["requester", "clarify"] as const).map((mode) => ({ + id: `application-access-public-kubernetes-${mode}`, + topic: "project-templates", + fixture: "corporate-ipv4" as const, + applicationAccess: + mode === "requester" + ? ("public-kubernetes-requester" as const) + : ("public-kubernetes-clarify" as const), + message: + "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können", + })), + { + id: "application-access-public-kubernetes-followup", + topic: "project-templates", + fixture: "corporate-ipv4", + applicationAccess: "public-kubernetes-followup", + history: [ + { + role: "user", + content: + "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können", + }, + { + role: "assistant", + content: + "Wähle die bestehende Application-Vorlage, deren Dienstberechtigungen ergänzt werden sollen. Angefragte Dienste: ske; Rechte für: Besteller. Noch nichts gespeichert.\nWelche public-Vorlage soll geändert werden? (2: central, second-public)", + }, + ], + message: "central", + }, { id: "corporate-ipv4-automatic-advice", topic: "project-templates", @@ -876,6 +909,39 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { }, ]; } + if (scenario.applicationAccess?.startsWith("public-kubernetes-")) { + const publicTemplate = draft.projectTemplates![0]!; + publicTemplate.kind = "public"; + publicTemplate.name = "Public workload"; + publicTemplate.settings = { + env: "dev", + network_enabled: true, + network_prefix_length: 25, + secretsmanager_enabled: true, + }; + publicTemplate.accessProfile = { + ...defaultApplicationAccessProfile("platform-owner@example.test"), + permissions: ["secrets-manager.secret.get", "iaas.server.get"], + automationPermissions: ["iaas.server.get"], + }; + draft.projectTemplates!.push({ + ...structuredClone(publicTemplate), + id: "00000000-0000-4000-8000-000000000027", + key: "untouched-corporate", + name: "Other Corporate", + kind: "corporate", + }); + if ( + scenario.applicationAccess === "public-kubernetes-clarify" || + scenario.applicationAccess === "public-kubernetes-followup" + ) + draft.projectTemplates!.push({ + ...structuredClone(publicTemplate), + id: "00000000-0000-4000-8000-000000000028", + key: "second-public", + name: "Second Public", + }); + } const values = assistantPublicValues(draft); if (scenario.fixture === "governance-verified-email") values["assistant.user_email"] = "login@example.test"; @@ -960,6 +1026,15 @@ export function validateAssistantScenario( failures.push("application_access_invented_contract"); if (dialogue.changes.length) failures.push("application_access_advice_mutated"); + } else if (scenario.applicationAccess === "public-kubernetes-clarify") { + if ( + dialogue.changes.length || + dialogue.questions.length !== 1 || + !["central", "second-public"].every((key) => + dialogue.questions[0]?.options?.some((option) => option.includes(key)), + ) + ) + failures.push("application_access_ambiguous_public_not_clarified"); } else if (scenario.applicationAccess) { const expected = structuredClone(fixture.draft); const central = expected.projectTemplates?.find((x) => x.key === "central"); @@ -982,7 +1057,9 @@ export function validateAssistantScenario( }; if ( scenario.applicationAccess === "kubernetes-add" || - scenario.applicationAccess === "kubernetes-requester" + scenario.applicationAccess === "kubernetes-requester" || + scenario.applicationAccess === "public-kubernetes-requester" || + scenario.applicationAccess === "public-kubernetes-followup" ) { const before = fixture.draft.projectTemplates?.find( (t) => t.key === "central", @@ -994,9 +1071,15 @@ export function validateAssistantScenario( ); central.accessProfile = { ...before, - permissions: [...new Set([...before.permissions, ...additions])].sort(), + permissions: + scenario.applicationAccess === "public-kubernetes-requester" || + scenario.applicationAccess === "public-kubernetes-followup" + ? [...new Set([...before.permissions, ...additions])] + : [...new Set([...before.permissions, ...additions])].sort(), automationPermissions: - scenario.applicationAccess === "kubernetes-requester" + scenario.applicationAccess === "kubernetes-requester" || + scenario.applicationAccess === "public-kubernetes-requester" || + scenario.applicationAccess === "public-kubernetes-followup" ? before.automationPermissions : [ ...new Set([...before.automationPermissions, ...additions]), diff --git a/landing-zone-configurator/app/tests/assistant-editor.test.ts b/landing-zone-configurator/app/tests/assistant-editor.test.ts index b0cb3dc9..9f7094ee 100644 --- a/landing-zone-configurator/app/tests/assistant-editor.test.ts +++ b/landing-zone-configurator/app/tests/assistant-editor.test.ts @@ -2062,7 +2062,7 @@ it("does not offer an unchanged policy when all Kubernetes rights are already pr knowledgeSelection(), assistantPublicValues(original), [], - "Bitte Kubernetes-Rechte ergänzen.", + `Bitte im Template ${template.key} Kubernetes-Rechte ergänzen.`, ); expect(result.changes).toEqual([]); expect(result.questions).toEqual([]); @@ -2144,3 +2144,306 @@ it("uses bounded serving strings for permissions while retaining strict runtime profile.safeParse({ permissions: ["ske.cluster.create"] }).success, ).toBe(true); }); + +it.each([ + [1, false], + [2, false], + [2, true], +] as const)( + "resolves a natural-language Public request across %s templates even for a guessed key (%s)", + async (count, guessedKey) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "public", + )!; + template.accessProfile = { + ...defaultApplicationAccessProfile("central@example.test"), + permissions: ["secrets-manager.secret.get", "iaas.server.get"], + automationPermissions: ["iaas.server.get"], + }; + if (count === 2) + original.projectTemplates!.push({ + ...structuredClone(template), + id: crypto.randomUUID(), + key: "public-second", + name: "Second Public", + }); + const before = structuredClone(original); + const message = + "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können"; + const result = await collectionModel( + { + topic: "project-templates", + mode: "draft", + adviceTask: "application-access", + requirements: [], + applicationAccess: { + key: guessedKey ? template.key : "__select_by_kind__", + templateKind: guessedKey ? "unspecified" : "public", + templateEvidence: "Public Landing Zone", + choice: "add-service-permissions", + services: ["ske"], + permissionTarget: "requester", + serviceEvidence: "Kubernetes Cluster bestellen und nutzen", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + message, + ); + expect(original).toEqual(before); + if (count === 2) { + expect(result.changes).toEqual([]); + expect(result.questions).toHaveLength(1); + expect(result.questions[0]!.options).toEqual([ + `${template.name} (${template.key})`, + "Second Public (public-second)", + ]); + return; + } + expect(result.questions).toEqual([]); + const expected = structuredClone(original); + const expectedProfile = expected.projectTemplates!.find( + (t) => t.id === template.id, + )!.accessProfile!; + if (expectedProfile.mode !== "granular") + throw new Error("Expected granular"); + expectedProfile.permissions = [ + ...expectedProfile.permissions, + ...applicationProjectPermissionNames.filter((p) => p.startsWith("ske.")), + ]; + expect(applyAssistantChanges(original, result.changes)).toEqual(expected); + }, +); + +it.each(["", "Corporate Landing Zone", "Public not quoted"])( + "rejects ungrounded template-kind evidence %s", + async (templateEvidence) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + await expect( + collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: "__select_by_kind__", + templateKind: "public", + templateEvidence, + choice: "add-service-permissions", + services: ["ske"], + permissionTarget: "requester", + serviceEvidence: "Kubernetes", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + "Public Landing Zone soll Kubernetes Rechte erhalten.", + ), + ).rejects.toBeInstanceOf(AssistantInferenceError); + }, +); + +it.each(["key", "question-answer"])( + "continues only the immediately open Public selection using its USER source (%s)", + async (replyKind) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const first = original.projectTemplates!.find((t) => t.kind === "public")!; + first.accessProfile = defaultApplicationAccessProfile( + "central@example.test", + ); + original.projectTemplates!.push({ + ...structuredClone(first), + id: crypto.randomUUID(), + key: "other-public", + name: "Other Public", + }); + const request = + "Public Landing Zone Besteller sollen Kubernetes Cluster bestellen und nutzen können."; + const access = { + key: "__select_by_kind__", + templateKind: "public", + templateEvidence: "Public Landing Zone", + choice: "add-service-permissions", + services: ["ske"], + permissionTarget: "requester", + serviceEvidence: "Kubernetes Cluster bestellen und nutzen", + }; + const clarification = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: access, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + request, + ); + expect(clarification.changes).toEqual([]); + const question = clarification.questions[0]!; + const message = + replyKind === "key" + ? first.key + : `${question.text}\nMeine Antwort: ${question.options![0]}`; + const result = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + ...access, + key: first.key, + serviceEvidenceSource: "history-0", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [ + { role: "user", content: request }, + { + role: "assistant", + content: clarification.explanation + "\n" + question.text, + }, + ], + message, + ); + const expected = structuredClone(original); + const profile = expected.projectTemplates!.find((t) => t.id === first.id)! + .accessProfile!; + if (profile.mode !== "granular") throw new Error("Expected granular"); + profile.permissions = [ + ...new Set([ + ...profile.permissions, + ...applicationProjectPermissionNames.filter((p) => + p.startsWith("ske."), + ), + ]), + ]; + expect(applyAssistantChanges(original, result.changes)).toEqual(expected); + }, +); + +it.each([ + "negation", + "new-topic", + "completed", + "older-question", + "wrong-target", + "wrong-template", + "changed-options", +])( + "never reactivates a historical service request through %s", + async (reason) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const first = original.projectTemplates!.find((t) => t.kind === "public")!; + first.accessProfile = defaultApplicationAccessProfile( + "central@example.test", + ); + original.projectTemplates!.push({ + ...structuredClone(first), + id: crypto.randomUUID(), + key: "other-public", + name: "Other Public", + }); + const request = + "Public Landing Zone Besteller sollen Kubernetes Cluster bestellen und nutzen können."; + const access = { + key: "__select_by_kind__", + templateKind: "public", + templateEvidence: "Public Landing Zone", + choice: "add-service-permissions", + services: ["ske"], + permissionTarget: "requester", + serviceEvidence: "Kubernetes Cluster bestellen und nutzen", + }; + const clarification = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: access, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + request, + ); + const history: { role: "user" | "assistant"; content: string }[] = [ + { role: "user", content: request }, + { + role: "assistant", + content: + clarification.explanation + "\n" + clarification.questions[0]!.text, + }, + ]; + if (reason === "completed") + history[1]!.content = + "Vorschlag erstellt, Auftrag abgeschlossen. Noch nichts gespeichert."; + if (reason === "older-question") + history.push( + { role: "user", content: "Nun etwas anderes." }, + { role: "assistant", content: "Was möchtest du wissen?" }, + ); + if (reason === "changed-options") + original.projectTemplates!.push({ + ...structuredClone(first), + id: crypto.randomUUID(), + key: "new-public", + name: "New Public", + }); + const before = structuredClone(original); + await expect( + collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + ...access, + key: reason === "wrong-template" ? "other-public" : first.key, + serviceEvidenceSource: "history-0", + permissionTarget: + reason === "wrong-target" ? "automation" : "requester", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + history, + reason === "negation" + ? "Nein, doch keine Rechte ändern." + : reason === "new-topic" + ? "Erkläre mir Object Storage." + : first.key, + ), + ).rejects.toBeInstanceOf(AssistantInferenceError); + expect(original).toEqual(before); + }, +); diff --git a/landing-zone-configurator/app/tests/login-draft.test.ts b/landing-zone-configurator/app/tests/login-draft.test.ts new file mode 100644 index 00000000..014e6f09 --- /dev/null +++ b/landing-zone-configurator/app/tests/login-draft.test.ts @@ -0,0 +1,90 @@ +import { createEditorConfiguration } from "@lzc/domain"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + clearLoginDraft, + pendingLoginOwner, + preserveLoginDraft, + readLoginDraft, + readLoginRecovery, +} from "../apps/web/src/login-draft.js"; + +const user = "11111111-1111-4111-8111-111111111111"; +const tenant = "22222222-2222-4222-8222-222222222222"; +const session = { + user: { id: user, login: "human@example.test" }, + tenant: { id: tenant }, + csrfToken: "c".repeat(43), + expiresAt: new Date(Date.now() + 3600000).toISOString(), +}; +let storage: Map; +beforeEach(() => { + storage = new Map(); + vi.stubGlobal("sessionStorage", { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => storage.set(k, v), + removeItem: (k: string) => storage.delete(k), + }); +}); +afterEach(() => vi.unstubAllGlobals()); +it("restores the exact draft, database revision, baseline and page only to its original workspace", () => { + const draft = createEditorConfiguration("hub-and-spoke", crypto.randomUUID()); + const binding = { id: crypto.randomUUID(), revision: 11 }; + const context = { + owner: { userId: user, tenantId: tenant }, + binding, + savedDraft: JSON.stringify(draft), + location: { + view: "applications" as const, + step: "platform" as const, + deploymentStep: "plan" as const, + }, + }; + expect(preserveLoginDraft(draft, context)).toBe(true); + expect(readLoginDraft()).toBeNull(); + expect(readLoginRecovery(session)).toEqual({ ...context, draft }); + expect( + readLoginRecovery({ + ...session, + user: { ...session.user, id: "other-user" }, + }), + ).toBeNull(); + expect( + readLoginRecovery({ ...session, tenant: { id: "other-tenant" } }), + ).toBeNull(); + expect(pendingLoginOwner(session)).toEqual(context.owner); + expect( + pendingLoginOwner({ + ...session, + user: { ...session.user, id: "other-user" }, + }), + ).toBeNull(); +}); +it("retains separately scoped drafts when another account starts a local draft", () => { + const a = createEditorConfiguration("standalone", crypto.randomUUID()); + const b = createEditorConfiguration("standalone", crypto.randomUUID()); + const other = { ...session, user: { ...session.user, id: "other-user" } }; + preserveLoginDraft(a, { owner: { userId: user, tenantId: tenant } }); + preserveLoginDraft(b, { owner: { userId: "other-user", tenantId: tenant } }); + expect(readLoginRecovery(session)?.draft).toEqual(a); + clearLoginDraft(other); + expect(readLoginRecovery(other)).toBeNull(); + expect(readLoginRecovery(session)?.draft).toEqual(a); +}); +it("keeps guest drafts compatible without storing session credentials", () => { + const draft = createEditorConfiguration("standalone", crypto.randomUUID()); + preserveLoginDraft(draft); + expect(readLoginDraft()).toEqual(draft); + expect([...storage.values()].join()).not.toContain(session.csrfToken); +}); +it("blocks navigation when browser storage is unavailable", () => { + vi.stubGlobal("sessionStorage", { + setItem: () => { + throw new Error("quota"); + }, + }); + expect( + preserveLoginDraft( + createEditorConfiguration("standalone", crypto.randomUUID()), + ), + ).toBe(false); +}); diff --git a/landing-zone-configurator/app/tests/stackit-session-routes.test.ts b/landing-zone-configurator/app/tests/stackit-session-routes.test.ts new file mode 100644 index 00000000..8003f11e --- /dev/null +++ b/landing-zone-configurator/app/tests/stackit-session-routes.test.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import { afterEach, expect, it, vi } from "vitest"; +import { buildApp } from "../apps/api/src/app.js"; +import type { AuthServices } from "../apps/api/src/auth/routes.js"; +import type { Session } from "../apps/api/src/auth/store.js"; + +const apps: ReturnType[] = []; +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); +}); +function fixture(renewal = true) { + const token = "s".repeat(43); + const session: Session = { + id: randomUUID(), + userId: randomUUID(), + tenantId: randomUUID(), + githubId: "", + login: "human@example.test", + csrfToken: "c".repeat(43), + expiresAt: new Date(Date.now() + 28800000), + }; + const services: AuthServices = { + origin: "https://configurator.example", + clientId: "client", + primaryStackit: true, + store: { + beginLogin: vi.fn(), + consumeLogin: vi.fn(), + createSession: vi.fn(), + resolveSession: vi.fn(async (raw) => (raw === token ? session : null)), + deleteSession: vi.fn(), + }, + github: { authorize: vi.fn() }, + tokens: { put: vi.fn(), get: vi.fn(), remove: vi.fn() }, + ...(renewal + ? { + stackitSessions: { + put: vi.fn(), + refresh: vi.fn(async () => ({ + status: "active" as const, + verifiedUntil: new Date(Date.now() + 600000).toISOString(), + })), + revoke: vi.fn(), + }, + } + : {}), + }; + const app = buildApp({ + auth: services, + stackit: { + identities: { save: vi.fn(), status: vi.fn(), revoke: vi.fn() }, + organisations: { overview: vi.fn() }, + }, + }); + apps.push(app); + const headers = { + origin: services.origin, + cookie: `__Host-lzc-session=${token}`, + "x-lzc-csrf": session.csrfToken, + }; + return { app, services, session, token, headers }; +} +it("renews only a live authenticated session with exact origin, CSRF and empty input", async () => { + const { app, services, session, token, headers } = fixture(); + for (const [request, status] of [ + [{ headers: { origin: headers.origin } }, 401], + [{ headers: { ...headers, origin: "https://attacker.example" } }, 403], + [{ headers: { ...headers, "x-lzc-csrf": "wrong" } }, 403], + [{ headers, payload: { refreshToken: "browser-injected-secret" } }, 400], + [{ headers, url: "/auth/stackit/refresh?token=injected" }, 400], + ] as const) { + expect( + ( + await app.inject({ + method: "POST", + url: "/auth/stackit/refresh", + ...request, + }) + ).statusCode, + ).toBe(status); + } + expect(services.stackitSessions?.refresh).not.toHaveBeenCalled(); + const result = await app.inject({ + method: "POST", + url: "/auth/stackit/refresh", + headers, + }); + expect(result.statusCode).toBe(200); + expect(result.json()).toEqual({ + status: "active", + verifiedUntil: expect.any(String), + }); + expect(services.stackitSessions?.refresh).toHaveBeenCalledExactlyOnceWith( + session, + token, + ); + expect(result.headers["set-cookie"]).toBeUndefined(); + expect(result.body).not.toContain(token); + expect((await app.inject("/auth/status")).json().stackitRenewal).toBe(true); +}); +it("returns a reauthentication requirement for sessions without renewal support", async () => { + const { app, headers } = fixture(false); + expect( + ( + await app.inject({ + method: "POST", + url: "/auth/stackit/refresh", + headers, + }) + ).json(), + ).toEqual({ status: "reauthentication-required" }); + expect((await app.inject("/auth/status")).json().stackitRenewal).toBe(false); +}); +it("sanitizes provider outages without extending the app session", async () => { + const { app, services, headers, session } = fixture(); + vi.mocked(services.stackitSessions!.refresh).mockRejectedValueOnce( + new Error("private-provider-response"), + ); + const expires = session.expiresAt.getTime(); + const result = await app.inject({ + method: "POST", + url: "/auth/stackit/refresh", + headers, + }); + expect(result.statusCode).toBe(503); + expect(result.body).not.toContain("private-provider-response"); + expect(session.expiresAt.getTime()).toBe(expires); +}); +it("logs out even if refresh revocation fails, clearing the opaque cookie", async () => { + const { app, services, headers, session, token } = fixture(); + vi.mocked(services.stackitSessions!.revoke).mockRejectedValueOnce( + new Error("private-token"), + ); + const result = await app.inject({ + method: "POST", + url: "/auth/logout", + headers, + }); + expect(result.statusCode).toBe(204); + expect(services.stackitSessions?.revoke).toHaveBeenCalledExactlyOnceWith( + session, + token, + ); + expect(services.store.deleteSession).toHaveBeenCalledExactlyOnceWith(token); + expect(result.headers["set-cookie"]).toContain("Max-Age=0"); + expect(result.body).not.toContain("private-token"); +}); diff --git a/landing-zone-configurator/app/tests/stackit-sessions.test.ts b/landing-zone-configurator/app/tests/stackit-sessions.test.ts new file mode 100644 index 00000000..8dec40ae --- /dev/null +++ b/landing-zone-configurator/app/tests/stackit-sessions.test.ts @@ -0,0 +1,135 @@ +import { randomBytes } from "node:crypto"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { expect, it, vi } from "vitest"; +import { StackitRefreshClient } from "../apps/api/src/auth/stackit-sessions.js"; +import { ArtifactCrypto } from "../apps/api/src/plans/crypto.js"; + +const issuer = "https://accounts.stackit.cloud"; +const clientId = "registered-web-client"; +const now = Date.now(); +const pair = await generateKeyPair("RS256"); +const jwk = { + ...(await exportJWK(pair.publicKey)), + kid: "test-key", + alg: "RS256", + use: "sig", +}; +const grant = { + clientId, + issuer, + subject: "human-subject", + email: "human@example.test", + nonce: "original-login-nonce", + refreshToken: "private-refresh-original", + expiresAt: new Date(now + 60000).toISOString(), +}; +async function fixture( + overrides: Record = {}, + responseStatus = 200, +) { + const claims = { + sub: grant.subject, + email: grant.email, + email_verified: true, + nonce: grant.nonce, + aud: clientId, + iss: issuer, + iat: Math.floor(now / 1000), + exp: Math.floor(now / 1000) + 600, + ...overrides, + }; + const jwt = await new SignJWT(claims) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .sign(pair.privateKey); + const request = vi.fn(async (url, init) => { + if (String(url).endsWith("jwks.json")) + return Response.json({ keys: [jwk] }); + if (String(url).endsWith("/token")) { + expect(init?.redirect).toBe("error"); + expect( + new URLSearchParams(init?.body as URLSearchParams).get("grant_type"), + ).toBe("refresh_token"); + return Response.json( + responseStatus === 200 + ? { + access_token: "private-access", + refresh_token: "private-refresh-rotated", + id_token: jwt, + token_type: "Bearer", + expires_in: 600, + } + : { error: "invalid_grant" }, + { status: responseStatus }, + ); + } + if (String(url).endsWith("userinfo")) + return Response.json({ + sub: grant.subject, + email: grant.email, + email_verified: true, + }); + throw new Error("Unexpected endpoint"); + }); + return { client: new StackitRefreshClient(request, () => now), request }; +} +it("rotates a refresh grant only after signed identity and userinfo agree", async () => { + const { client, request } = await fixture(); + const rotated = await client.refresh(grant); + expect(rotated).toEqual({ + ...grant, + refreshToken: "private-refresh-rotated", + expiresAt: new Date(Math.floor(now / 1000) * 1000 + 600000).toISOString(), + }); + expect(request).toHaveBeenCalledTimes(3); + expect(JSON.stringify(rotated)).not.toContain("private-access"); +}); +it.each([ + { sub: "different-human" }, + { email: "other@example.test" }, + { iss: "https://other.example" }, + { aud: "other-client" }, + { nonce: "foreign-flow" }, + { email_verified: false }, + { azp: "other-client" }, + { aud: [clientId, "other-client"] }, + { exp: Math.floor(now / 1000) - 1 }, +])("rejects a refreshed identity with mismatched claims %j", async (claims) => { + const { client } = await fixture(claims); + await expect(client.refresh(grant)).rejects.toThrow(); +}); +it("permits a valid authorized-party claim for multiple audiences", async () => { + const { client } = await fixture({ + aud: [clientId, "extra-audience"], + azp: clientId, + }); + await expect(client.refresh(grant)).resolves.toMatchObject({ + subject: grant.subject, + }); +}); +it("requires reauthentication for revoked grants without returning provider details", async () => { + const { client } = await fixture({}, 400); + await expect(client.refresh(grant)).rejects.toThrow(); +}); +it("treats provider outages as temporary instead of inventing a new proof", async () => { + const { client } = await fixture({}, 503); + await expect(client.refresh(grant)).rejects.toThrow( + "stackit_refresh_unavailable", + ); +}); +it("cannot decrypt a stored grant in another user, tenant, session or artifact purpose", () => { + const crypto = new ArtifactCrypto(randomBytes(32).toString("base64")); + const bytes = crypto.encrypt( + Buffer.from(JSON.stringify(grant)), + "tenant", + "human", + "stackit-refresh:session", + ); + expect(bytes.toString()).not.toContain(grant.refreshToken); + for (const [tenant, user, purpose] of [ + ["other", "human", "stackit-refresh:session"], + ["tenant", "other", "stackit-refresh:session"], + ["tenant", "human", "stackit-refresh:other"], + ["tenant", "human", "plan-artifact"], + ]) + expect(() => crypto.decrypt(bytes, tenant!, user!, purpose!)).toThrow(); +}); diff --git a/landing-zone-configurator/docs/assistant-test-catalogue.json b/landing-zone-configurator/docs/assistant-test-catalogue.json index 21d40ec7..7ad821a5 100644 --- a/landing-zone-configurator/docs/assistant-test-catalogue.json +++ b/landing-zone-configurator/docs/assistant-test-catalogue.json @@ -2,12 +2,12 @@ "schemaVersion": 1, "status": "field-inventory-and-smoke-corpus-not-ai-qualification", "engineRevision": "a256f6896d11134fdc351786f1be5eba4e56b2e2", - "knowledgeSha256": "d58a93b40fb96fc191af7865283bca669ed7b1fa328b9590674550e8ad07384e", + "knowledgeSha256": "1014410747d59386c02dfc89e37b2e0b2d2655c4d1ac42a8468f658972286dde", "sourceDigests": { - "scripts/assistant-scenarios.ts": "1c8f359c0d93aa8df8e21ed41ec9291911aded82a07ff9bf857306728134fb0e", + "scripts/assistant-scenarios.ts": "71ea9733d4e93b976d16ffd75902e0ecb9472880e24d352a4a291c8165b433d2", "scripts/assistant-tests.ts": "5e19b1c74da1ffa996f2d76876cc8dc639da698d0b6f9b7e1fc6637863833a8d" }, - "suiteSha256": "574a5af4f62fac2f64a43ceddfd197d8a8b28f7186d78c6556a2cbf80ae4f78d", + "suiteSha256": "407f960fab9817a793f5c282d181aeb6a749215baf3ced2778cb1dadef42de10", "topics": [ { "id": "credential-foundations", @@ -5374,6 +5374,58 @@ "no_premature_saved_claim" ] }, + { + "id": "application-access-public-kubernetes-requester", + "topic": "project-templates", + "fixture": "corporate-ipv4", + "applicationAccess": "public-kubernetes-requester", + "message": "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, + { + "id": "application-access-public-kubernetes-clarify", + "topic": "project-templates", + "fixture": "corporate-ipv4", + "applicationAccess": "public-kubernetes-clarify", + "message": "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, + { + "id": "application-access-public-kubernetes-followup", + "topic": "project-templates", + "fixture": "corporate-ipv4", + "applicationAccess": "public-kubernetes-followup", + "history": [ + { + "role": "user", + "content": "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können" + }, + { + "role": "assistant", + "content": "Wähle die bestehende Application-Vorlage, deren Dienstberechtigungen ergänzt werden sollen. Angefragte Dienste: ske; Rechte für: Besteller. Noch nichts gespeichert.\nWelche public-Vorlage soll geändert werden? (2: central, second-public)" + } + ], + "message": "central", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, { "id": "corporate-ipv4-automatic-advice", "topic": "project-templates", diff --git a/landing-zone-configurator/docs/development.de.md b/landing-zone-configurator/docs/development.de.md index 92353b8d..bb1abcbb 100644 --- a/landing-zone-configurator/docs/development.de.md +++ b/landing-zone-configurator/docs/development.de.md @@ -40,6 +40,17 @@ Editor-DOM, damit ein fokussiertes Eingabefeld nicht entfernt wird. Tatsächlich Identitäts-/Arbeitsumgebungswechsel setzen weiterhin den Entwurf und den bereichsgebundenen Katalogprovider zurück; private Kataloge dürfen nie über diese Grenzen hinweg erhalten bleiben. +Heartbeat-Aktualisierungen dürfen keinen älteren Checkpoint über einen aktuellen +Entwurf zurückspielen oder fokussierte Eingaben entfernen. Bei Sitzungsablauf +bleiben Entwurf, Datenbankbindung und Revision sowie gespeicherte Vergleichsbasis +erhalten; Wiederherstellung verlangt denselben Benutzer und Arbeitsbereich. +Ausdrücklicher Logout löscht den Checkpoint auch in abgelaufenen Tabs; ein Logout +einer älteren Sitzung darf aber keine neuere Anmeldung löschen. Teste verzögerte +Refresh-Ergebnisse mit einem neuen unveränderlichen Session-Wert auf Desktop und +Mobilgeräten. Refresh-Zugänge bleiben serverseitig verschlüsselt; absolute +App-Laufzeit und Organisations-Owner-Nachweis dürfen nicht verlängert werden. +Siehe [Anmeldeverhalten](github-login.de.md) für Erneuerung und den Ausweichpfad +bei nicht verfügbarem Browserspeicher. Die Navigation „Konfigurationen“ setzt den aktuellen Entwurf direkt im Editor fort; der Brotkrumen-Link im Editor öffnet die gespeicherte Liste. Listenaktionen diff --git a/landing-zone-configurator/docs/development.md b/landing-zone-configurator/docs/development.md index 3f4f8b17..6f688899 100644 --- a/landing-zone-configurator/docs/development.md +++ b/landing-zone-configurator/docs/development.md @@ -36,6 +36,14 @@ The initial guest-to-authenticated session lookup preserves the editor DOM so it cannot detach a focused input. Actual identity/workspace changes still reset the draft and scoped catalogue provider; never retain private catalogues across those boundaries. +Session heartbeat updates must not restore an older checkpoint over a live draft +or detach focused inputs. Expiry preserves draft, database binding/revision and +saved baseline; restoration requires the same user and workspace. Explicit logout +clears the checkpoint, including in expired tabs, but a logout from an older session +must not clear a newer login. Test delayed refresh outcomes with a new immutable +Session value and both desktop/mobile layouts. Refresh grants remain encrypted +server-side; never extend the absolute app session or organization-owner proof. +See [login behavior](github-login.md) for renewal and unavailable-storage fallback. Application template drafts use the same controlled root draft, configuration binding and saved snapshot across configuration editing and the first Application Landing diff --git a/landing-zone-configurator/docs/github-login.de.md b/landing-zone-configurator/docs/github-login.de.md index f926a952..9f49730d 100644 --- a/landing-zone-configurator/docs/github-login.de.md +++ b/landing-zone-configurator/docs/github-login.de.md @@ -11,22 +11,46 @@ Restore-Pruefungen lokal aktiviert. Echter menschlicher Login, vollstaendiger Owner-Nachweis und ausdrueckliche Organisationsbindung sind in der bestehenden Kundenorganisation erfolgreich abgenommen. GitHub bleibt als Legacy-Modus erhalten. -Seit Migration 058 ist die achtstuendige Configurator-Sitzung unabhaengig von -der kuerzeren STACKIT-Tokenlaufzeit. Beim Login muss der verifizierte Nachweis -weiterhin aktuell sein. Identitaets- und Organisations-Owner-Nachweise behalten -ihre eigenen Ablaufzeiten; abgelaufene Nachweise erlauben weder eine neue -Organisationsbindung noch geschuetzte Cloud-Aktionen. Sie muessen bei Bedarf -erneut bestaetigt werden. Refresh-Tokens werden weiterhin nicht gespeichert. -Logout widerruft die App-Sitzung, und Rollen-/Mandantenpruefungen bleiben aktiv. -Bestehende Sitzungen werden nicht nachtraeglich verlaengert; die neue Laufzeit -gilt nach erneuter Anmeldung. - -Der Web-Login mit Authorization Code und PKCE verwendet denselben Browser-Tab. -Der vorhandene Callback setzt die Anmeldung im Configurator fort; unvollständige -Entwürfe bleiben dabei über den bestehenden tabgebundenen Zwischenspeicher erhalten. -Dies gilt auch für die erneute STACKIT-Bestätigung im Kontobereich. Nur der -kompatible Device-Flow mit Anmeldecode öffnet weiterhin einen separaten Tab, -weil der ursprüngliche Tab für das Polling geöffnet bleiben muss. +Die Configurator-Sitzung gilt absolut acht Stunden, unabhängig vom kürzeren +STACKIT-Identitätsnachweis. Mit Migration 075 und konfigurierter +Sitzungsverschlüsselung behält der PKCE-Login einen serverseitig verschlüsselten +Refresh-Zugang, gebunden an diese Sitzung, den Benutzer und den ursprünglichen +Arbeitsbereich. Der Browser erhält weder Provider-Access- noch Refresh-Tokens. +Solange der Tab sichtbar ist, erneuern regelmäßige Prüfungen und Prüfungen bei der +Rückkehr zum Tab den Identitätsnachweis kurz vor Ablauf. Die Token-Rotation wird +über Tabs und API-Instanzen hinweg serialisiert. Die Erneuerung prüft signiertes +ID-Token, Issuer, Audience, Authorized Party, Subject und bestätigte Userinfo; eine +geänderte Identität wird abgelehnt. Sie verlängert weder die achtstündige +App-Sitzung noch Organisations-Owner-Rechtenachweise und hebt keine Widerrufe auf. +Diese Rechtenachweise müssen weiterhin gesondert bestätigt werden. Logout entfernt +die App-Sitzung und versucht den zugehörigen Refresh-Zugang beim Provider zu +widerrufen. Alte Sitzungen ohne diesen Zugang verlangen nach Ablauf ihres +Nachweises eine erneute Anmeldung. + +Der Web-Login mit Authorization Code und PKCE leitet sofort im selben Tab weiter, +ohne zusätzlichen Bestätigungsdialog des Configurators. Eine notwendige Anmeldung, +MFA oder Einwilligung bei STACKIT bleibt unter Kontrolle von STACKIT. Die Erneuerung +des Client-Zugangs kann das getrennte Browsercookie des STACKIT-Portals nicht +aktiv halten. Der kompatible Device-Flow mit Anmeldecode behält seinen Dialog und +den separaten Provider-Tab für das Polling. + +Eine abgelehnte oder nicht verfügbare Erneuerung verwirft keine Bearbeitung und +leitet nicht stillschweigend weiter. Solange die App-Sitzung gilt, können +Konfigurationen weiterhin gespeichert werden; geschützte Cloud-Aktionen brauchen +einen aktuellen Nachweis. Läuft die App-Sitzung selbst ab, behält der Editor den +Entwurf und bietet lokalen Download und Anmeldung an. Ein tablokaler Checkpoint +für 24 Stunden bewahrt Entwurf, ursprüngliche Datenbank-Konfigurations-ID und +Revision, gespeicherte Vergleichsbasis und Editorposition. Er enthält keine +Login-Tokens oder CSRF-Geheimnisse. Er wird nur für denselben authentifizierten +Benutzer und Arbeitsbereich wiederhergestellt. Beginnt eine neue Anmeldung im +persönlichen Arbeitsbereich, wird die vorherige Organisation nur nach einer +frischen serverseitigen Mitgliedschaftsprüfung gewählt. Andere Konten und +Arbeitsbereiche erhalten den privaten Entwurf nicht; ausdrücklicher Logout löscht +den Checkpoint dieses Kontos. Ist Browserspeicher nicht verfügbar, wird die +Weiterleitung zur Anmeldung blockiert, bis der Entwurf gesichert oder +heruntergeladen werden kann. Eine erneute Anmeldung speichert, veröffentlicht, +plant oder appliziert keinen Entwurf automatisch. Die ursprünglichen +Revisionskonfliktprüfungen bleiben erhalten. Der Resource Manager liefert fuer die exakt angefragte Organisations-UUID einen IAM-Containerbezug. Die Pruefung verwendet ausschliesslich diesen Bezug fuer @@ -150,8 +174,10 @@ STACKIT-Identität an und wählen bei mehreren Mitgliedschaften ihren aktiven Te Der Application Owner benötigt weder GitHub noch einen Service Account. Der Platform Engineer hinterlegt den technischen STACKIT-Service-Account-Zugang; Produktkataloge verwenden danach denselben Zugang automatisch, ohne weitere -Profil- oder Projektwahl im Editor. Persönliche Login-Tokens werden nicht für -technische STACKIT-Automatisierungen gespeichert oder wiederverwendet. +Profil- oder Projektwahl im Editor. Persönliche Access-Tokens werden nach der +Identitätsprüfung verworfen. Verschlüsselte persönliche Refresh-Zugänge dienen +ausschließlich der Anmeldeerneuerung, niemals dem Runner oder technischer +STACKIT-Automatisierung. `POST /auth/stackit/start` beginnt den konfigurierten browsergebundenen Flow. Lokal ist dies CLI-PKCE; ohne explizite Flow-Factory bleibt es Device Flow. @@ -166,8 +192,9 @@ Issuer und Subject bestimmen den stabilen Benutzer, niemals allein die E-Mail-Ad Device-Cookies sind `Secure`, `HttpOnly`, `SameSite=Strict`; fuer die PKCE- Callback-Rueckkehr verwendet die Browserbindung `SameSite=Lax`. Sessions verwenden `SameSite=Lax`. Pre-Auth-POSTs verlangen die exakte Origin, authentifizierte -Änderungen zusätzlich CSRF. Sessions gelten höchstens acht Stunden und enden -spätestens 30 Sekunden vor dem Provider-Token. Kein Refresh-Token wird behalten. +Änderungen zusätzlich CSRF. Sessions gelten höchstens acht Stunden; für den +unabhängigen Identitätsnachweis und den verschlüsselten Refresh-Zugang gelten +die oben beschriebenen Erneuerungsregeln. Offene Device-Flows liegen nur im Speicher einer Instanz: Neustart verwirft sie; mehrere Instanzen benötigen vor Aktivierung eine gemeinsame Ablage oder Sticky Routing. diff --git a/landing-zone-configurator/docs/github-login.md b/landing-zone-configurator/docs/github-login.md index 4612ac0d..ce9dd382 100644 --- a/landing-zone-configurator/docs/github-login.md +++ b/landing-zone-configurator/docs/github-login.md @@ -10,22 +10,38 @@ restore checks. Real human login, complete owner proof and explicit organization binding have passed acceptance in the existing customer organization. GitHub remains as legacy mode. -Since migration 058, the eight-hour Configurator session is independent of -the shorter STACKIT token lifetime. At login, verified proof -must still be current. Identity and organization-owner proofs retain -their own expiry times; expired proofs permit neither a new -organization binding nor protected cloud actions. They must be -reconfirmed when needed. Refresh tokens are still not stored. -Logout revokes the app session, and role/tenant checks remain active. -Existing sessions are not extended retroactively; the new lifetime -applies after signing in again. - -Web login with Authorization Code and PKCE uses the same browser tab. -The existing callback resumes login in the Configurator; incomplete -drafts survive through the existing tab-bound intermediate storage. -This also applies to renewed STACKIT confirmation in the account area. Only the -compatible Device Flow with login code still opens a separate tab, -because the original tab must remain open for polling. +The Configurator session has an absolute eight-hour lifetime, independent of the +shorter STACKIT identity proof. With migration 075 and configured session encryption, +PKCE login retains a refresh grant encrypted on the server, bound to that session, +user and base workspace. The browser receives neither provider access nor refresh +tokens. While the tab is visible, a periodic check and checks on return to the tab +renew the identity proof shortly before expiry. Token rotation is serialized across +tabs and API instances. Renewal verifies the signed ID token, issuer, audience, +authorized party, subject and verified userinfo; a changed identity is rejected. +It never extends the eight-hour app session, revives revoked identities, or extends +organization-owner permission proofs. Those proofs still need separate confirmation. +Logout removes the app session and attempts to revoke its provider refresh grant. +Older sessions without a grant require signing in again when their proof expires. + +Web login with Authorization Code and PKCE redirects immediately in the same tab, +without an additional Configurator confirmation dialog. A required STACKIT login, +MFA or consent screen remains controlled by STACKIT. Renewal of the client grant +cannot keep the separate STACKIT Portal browser cookie alive. The compatible Device +Flow with login code retains its dialog and separate provider tab for polling. + +A rejected or unavailable renewal never silently discards an edit or redirects the +user. While the app session remains valid, configuration saves remain available; +protected cloud actions require current proof. If the app session itself expires, +the editor keeps its draft and offers local download and sign-in. A tab-local, +24-hour checkpoint preserves the draft, original database configuration ID/revision, +saved baseline and editor location. It contains no login tokens or CSRF secrets. +It is restored only for the same authenticated user and workspace. After a fresh +login starts in the personal workspace, the previous organization is selected only +if a fresh server membership check still permits it. Different accounts and +workspaces do not receive the private draft; explicit logout clears that account's +checkpoint. If browser storage is unavailable, sign-in navigation is blocked until +the draft can be secured or downloaded. Reauthentication never saves, publishes, +plans or applies a draft automatically. Original revision conflict checks remain. Resource Manager returns an IAM container reference for the exact requested organization UUID. Verification uses only this reference for @@ -145,8 +161,9 @@ STACKIT identity and select their active tenant when they have multiple membersh The Application Owner needs neither GitHub nor a service account. The Platform Engineer stores technical STACKIT service-account credentials; product catalogues then automatically use the same credentials without additional -profile or project selection in the editor. Personal login tokens are not stored -or reused for technical STACKIT automation. +profile or project selection in the editor. Personal access tokens are discarded +after identity verification. Encrypted personal refresh grants are used only for +authentication renewal, never for Runner or technical STACKIT automation. `POST /auth/stackit/start` begins the configured browser-bound flow. Locally this is CLI PKCE; without an explicit flow factory it remains Device Flow. @@ -161,8 +178,8 @@ Issuer and subject determine the stable user, never email alone. Device cookies are `Secure`, `HttpOnly`, `SameSite=Strict`; for the PKCE callback return, browser binding uses `SameSite=Lax`. Sessions use `SameSite=Lax`. Pre-auth POSTs require exact origin; authenticated -changes additionally CSRF. Sessions last at most eight hours and end -at least 30 seconds before the provider token. No refresh token is retained. +changes additionally CSRF. Sessions last at most eight hours; the independent +identity proof and encrypted refresh grant follow the renewal rules above. Open Device Flows reside only in one instance's memory: restart discards them; multiple instances need shared storage or sticky routing before activation. diff --git a/landing-zone-configurator/docs/project-template-drafts.de.md b/landing-zone-configurator/docs/project-template-drafts.de.md index c034a5c9..e772cae6 100644 --- a/landing-zone-configurator/docs/project-template-drafts.de.md +++ b/landing-zone-configurator/docs/project-template-drafts.de.md @@ -156,6 +156,8 @@ Live-Daten ergänzen den vollständigen mitgelieferten Katalog. Nicht mehr live Bitten Sie den Assistant beispielsweise: „Bitte füge der Vorlage central die Rechte zum Management von Kubernetes Clustern hinzu.“ Er ergänzt offizielle SKE-Rechte und erhält vorhandene Rechte, zentralen Owner und sonstige Einstellungen. Ohne Unterscheidung ergänzt er beide Listen; „nur für den Besteller“ lässt die Automation unverändert. Übernahme, Speichern und neue Veröffentlichung bleiben ausdrücklich getrennt. Bestehende Versionen und Bestellungen werden nicht verändert. +Der Wunsch, dass zukünftige Besteller Kubernetes-Cluster bestellen und nutzen können, ist ein Auftrag zur Anpassung der Template-Rechte, nicht nur eine Frage nach einer Übersicht. Wird nur Public oder Corporate genannt, verwendet der Assistant die einzige passende bestehende Vorlage oder fragt nach, welche der passenden Vorlagen gemeint ist. Die Auswahl einer angebotenen Vorlage setzt nur diesen noch offenen Dienstauftrag fort; ein neues Thema, eine Absage oder ein abgeschlossener Vorschlag aktiviert keinen älteren Rechteauftrag erneut. Bestellerspezifische Wünsche erhalten die Rechte des Application-Service-Accounts, den zentralen Owner und alle übrigen Entwurfseinstellungen. Der Vorschlag legt keinen Cluster an. + SKE-Projektrechte verwalten Cluster; Kubernetes-RBAC innerhalb eines Clusters ist ein separater Mechanismus. Der Verwaltungsumfang enthält `ske.cluster.kubeconfig.create`, das den Abruf von Admin-Kubeconfigs und damit Clusteradministrator-Zugriff erlaubt. Zusätzliche IAM-Rollen-/Mitgliederverwaltung kann Rechteerweiterungen ermöglichen und ist nicht vorausgewählt. Solche Rechte und volle Owner-Rechte bleiben bewusste Kundenwahl. Siehe [STACKIT Custom Roles](https://docs.stackit.cloud/platform/access-and-identity/roles-permissions/custom-roles/) und [SKE-Clusterzugriff](https://docs.stackit.cloud/products/runtime/kubernetes-engine/getting-started/access-cluster/). diff --git a/landing-zone-configurator/docs/project-template-drafts.md b/landing-zone-configurator/docs/project-template-drafts.md index 21820dc7..090391f9 100644 --- a/landing-zone-configurator/docs/project-template-drafts.md +++ b/landing-zone-configurator/docs/project-template-drafts.md @@ -76,6 +76,8 @@ Live data supplements the complete bundled catalogue. Identifiers no longer list For example, ask the Assistant: “Add Kubernetes cluster management permissions to template central.” It adds official SKE permissions while preserving existing permissions, the central owner and other settings. Without a distinction it adds to both lists; “requester only” leaves automation unchanged. Acceptance, saving and a new publication remain explicit separate actions. Existing versions and orders are unchanged. +A request that future orderers should be able to order and use Kubernetes clusters is a request to adjust template permissions, not merely for an overview. When only Public or Corporate is specified, the Assistant uses the sole matching existing template or asks which matching template you mean. Selecting one offered template continues only that still-open service request; a new topic, cancellation or completed proposal never revives an older permission request. Requester-specific wishes preserve the Application service account's permissions, the central owner and all other draft settings. No cluster is provisioned by this proposal. + SKE project permissions manage clusters; Kubernetes RBAC inside a cluster is a separate mechanism. The management set includes `ske.cluster.kubeconfig.create`, which permits obtaining admin kubeconfigs and therefore cluster administrator access. Additional IAM role/member administration can enable privilege escalation and is not selected by default. Such permissions and full Owner rights remain deliberate customer choices. See [STACKIT custom roles](https://docs.stackit.cloud/platform/access-and-identity/roles-permissions/custom-roles/) and [SKE cluster access](https://docs.stackit.cloud/products/runtime/kubernetes-engine/getting-started/access-cluster/).