diff --git a/.env.example b/.env.example index b16835f2..a1ef40c1 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,9 @@ ARTIFACT_SERVER_S3_FORCE_PATH_STYLE=false # ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=1000 # ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS=10000 +# Browser sessions last this many hours, from 1 through 720. +# ARTIFACT_SERVER_SESSION_LIFETIME_HOURS=12 + # Optional OpenTelemetry export. Artifact Server writes sampled structured JSON # request logs in deployed processes. Errors and requests taking at least one # second are always logged. Set 0 to disable normal access logs, or 1 to log all. diff --git a/apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx b/apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx index 104e70e8..cd17185a 100644 --- a/apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx +++ b/apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx @@ -75,6 +75,8 @@ the value or paste it into an issue. Do not include `workosClientId`, `workosIssuer`, or `workosApiKeySecretRef` in the same deployment. One installation uses one browser-login provider. +An Artifact Server browser session lasts 12 hours by default. To sign people in less often, set `sessionLifetimeHours` to an integer from 1 through 720. When the session ends, Artifact Server sends the browser back through Cloudflare Access. Access skips the login prompt while its own session is still valid, so set the Access application's session duration to the same length or longer. + Run the normal Cloudflare lifecycle commands: ```sh diff --git a/apps/site/src/content/docs/docs/deploy/compose.mdx b/apps/site/src/content/docs/docs/deploy/compose.mdx index 6a4f5f9a..8c651e55 100644 --- a/apps/site/src/content/docs/docs/deploy/compose.mdx +++ b/apps/site/src/content/docs/docs/deploy/compose.mdx @@ -67,6 +67,7 @@ ARTIFACT_SERVER_CONTENT_DOMAIN=content.example.net ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=1000 ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS=10000 ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE=0.01 +ARTIFACT_SERVER_SESSION_LIFETIME_HOURS=12 ``` The application origin and content domain must have different registrable domains. This rule keeps untrusted artifact content outside the trusted application origin. diff --git a/apps/site/src/content/docs/docs/deploy/google-cloud.mdx b/apps/site/src/content/docs/docs/deploy/google-cloud.mdx index 38c05e25..6ea5c6d2 100644 --- a/apps/site/src/content/docs/docs/deploy/google-cloud.mdx +++ b/apps/site/src/content/docs/docs/deploy/google-cloud.mdx @@ -149,7 +149,7 @@ Production configuration requires these values: - `deletionProtection` is `true`. - `imageReference` contains one immutable SHA-256 digest. -Optional configuration includes `otlpEndpoint`, `requestLogSampleRate`, and `resourceTags`. The request-log sample rate defaults to `0.01`. +Optional configuration includes `otlpEndpoint`, `requestLogSampleRate`, `resourceTags`, and `sessionLifetimeHours`. The request-log sample rate defaults to `0.01`. Browser sessions last 12 hours unless `sessionLifetimeHours` sets 1 through 720. The GCP package accepts 0.25, 0.5, 1, 2, 4, or 8 CPUs. Memory must be 512 MiB through 32,768 MiB. diff --git a/deploy/cloudflare/src/deployment-manifest.ts b/deploy/cloudflare/src/deployment-manifest.ts index ca75c7a5..7aaedf5b 100644 --- a/deploy/cloudflare/src/deployment-manifest.ts +++ b/deploy/cloudflare/src/deployment-manifest.ts @@ -43,6 +43,7 @@ export interface CloudflareDeploymentManifest { readonly ARTIFACT_SERVER_HOST: "0.0.0.0"; readonly ARTIFACT_SERVER_PORT: 8787; readonly ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE: number; + readonly ARTIFACT_SERVER_SESSION_LIFETIME_HOURS: number; readonly ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS: 1000; readonly ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS: 10000; readonly ARTIFACT_SERVER_OBJECT_STORAGE_PROVIDER: "r2"; @@ -97,6 +98,7 @@ export const buildCloudflareDeploymentManifest = ( ARTIFACT_SERVER_PORT: 8787, ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE: input.requestLogSampleRate ?? 0.01, + ARTIFACT_SERVER_SESSION_LIFETIME_HOURS: input.sessionLifetimeHours, ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS: 1000, ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS: 10000, ARTIFACT_SERVER_OBJECT_STORAGE_PROVIDER: "r2", diff --git a/deploy/cloudflare/src/worker.ts b/deploy/cloudflare/src/worker.ts index 1db4a9e9..96e06216 100644 --- a/deploy/cloudflare/src/worker.ts +++ b/deploy/cloudflare/src/worker.ts @@ -3,6 +3,7 @@ import {Effect, Layer, ManagedRuntime, Redacted} from "effect"; import type {ApplicationRuntime} from "../../../src/application/application-runtime.js"; import {ExpiredStagingCleanupService} from "../../../src/application/expired-staging-cleanup.js"; +import {maximumSessionLifetimeHours} from "../../../src/core/session-lifetime.js"; import {SystemClock, SystemIdGenerator} from "../../../src/core/system.js"; import { browserLoginKinds, @@ -65,6 +66,7 @@ export interface WorkerEnvironment { readonly ARTIFACT_SERVER_QUALIFICATION_MODE?: "enabled"; readonly ARTIFACT_SERVER_R2_BUCKET: R2Bucket; readonly ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE?: string | number; + readonly ARTIFACT_SERVER_SESSION_LIFETIME_HOURS?: string | number; readonly ARTIFACT_SERVER_WORKOS_API_KEY?: string; readonly ARTIFACT_SERVER_WORKOS_CLIENT_ID?: string; readonly ARTIFACT_SERVER_WORKOS_ISSUER?: string; @@ -187,6 +189,7 @@ async function createCloudflareRuntime( localBootstrapCredential: null, protectBootstrapAdministrator: false, repository, + sessionLifetimeMilliseconds: sessionLifetimeMilliseconds(environment), staging, }; if (gitHistory.provider !== null) { @@ -523,6 +526,16 @@ function requestLogSampleRate(environment: WorkerEnvironment): number { : defaultCompletedRequestLogSampleRate; } +function sessionLifetimeMilliseconds( + environment: WorkerEnvironment, +): number | undefined { + const hours = Number(environment.ARTIFACT_SERVER_SESSION_LIFETIME_HOURS); + return Number.isSafeInteger(hours) && + hours >= 1 && hours <= maximumSessionLifetimeHours + ? hours * 60 * 60 * 1_000 + : undefined; +} + const readyComponent = { latencyMilliseconds: 0, status: "ready" as const, diff --git a/deploy/cloudflare/tests/deployment-manifest.test.ts b/deploy/cloudflare/tests/deployment-manifest.test.ts index 58323597..2834797e 100644 --- a/deploy/cloudflare/tests/deployment-manifest.test.ts +++ b/deploy/cloudflare/tests/deployment-manifest.test.ts @@ -26,6 +26,18 @@ describe("Cloudflare deployment manifest", () => { ARTIFACT_SERVER_CONTENT_DOMAIN: "artifact-content.example.net", ARTIFACT_SERVER_OBJECT_STORAGE_PROVIDER: "r2", ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE: 0.01, + ARTIFACT_SERVER_SESSION_LIFETIME_HOURS: 12, + }); + }); + + it("passes the configured session lifetime to the worker", () => { + const manifest = buildCloudflareDeploymentManifest({ + ...validDeploymentInput, + sessionLifetimeHours: 336, + }); + + expect(manifest.runtimeConfiguration).toMatchObject({ + ARTIFACT_SERVER_SESSION_LIFETIME_HOURS: 336, }); }); diff --git a/deploy/cloudflare/tests/fixtures.ts b/deploy/cloudflare/tests/fixtures.ts index 17657503..9dcd24d7 100644 --- a/deploy/cloudflare/tests/fixtures.ts +++ b/deploy/cloudflare/tests/fixtures.ts @@ -32,6 +32,7 @@ export const validDeploymentInput = { resourceTags: { owner: "artifact-server-review", }, + sessionLifetimeHours: 12, stage: "review-foundation", cloudflareAccountId: "abcdef0123456789abcdef0123456789", compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE, diff --git a/deploy/pulumi/aws/configuration.ts b/deploy/pulumi/aws/configuration.ts index 0f18fa3f..ecb140bb 100644 --- a/deploy/pulumi/aws/configuration.ts +++ b/deploy/pulumi/aws/configuration.ts @@ -52,6 +52,11 @@ export function readAwsPulumiConfiguration(): ParsedAwsPulumiConfiguration { config.getNumber("requestLogSampleRate"), ); document = withOptional(document, "resourceTags", config.getObject("resourceTags")); + document = withOptional( + document, + "sessionLifetimeHours", + config.getNumber("sessionLifetimeHours"), + ); document = withOptional( document, "tlsCertificateArn", diff --git a/deploy/pulumi/aws/stack.ts b/deploy/pulumi/aws/stack.ts index ef18a52f..8279bb98 100644 --- a/deploy/pulumi/aws/stack.ts +++ b/deploy/pulumi/aws/stack.ts @@ -765,6 +765,7 @@ function runtimeEnvironment( {name: "ARTIFACT_SERVER_ORIGIN", value: `https://${input.applicationDomain}`}, {name: "ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS", value: "1000"}, {name: "ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE", value: String(input.requestLogSampleRate)}, + {name: "ARTIFACT_SERVER_SESSION_LIFETIME_HOURS", value: String(input.sessionLifetimeHours)}, {name: "ARTIFACT_SERVER_STAGING_CLEANUP_BATCH_SIZE", value: "100"}, {name: "ARTIFACT_SERVER_STAGING_CLEANUP_CONCURRENCY", value: "4"}, {name: "ARTIFACT_SERVER_STAGING_CLEANUP_SCHEDULE", value: "external"}, diff --git a/deploy/pulumi/gcp/configuration.ts b/deploy/pulumi/gcp/configuration.ts index b6e80a32..0feb1a11 100644 --- a/deploy/pulumi/gcp/configuration.ts +++ b/deploy/pulumi/gcp/configuration.ts @@ -51,6 +51,11 @@ export function readGcpPulumiConfiguration( config.getNumber("requestLogSampleRate"), ); document = withOptional(document, "resourceTags", config.getObject("resourceTags")); + document = withOptional( + document, + "sessionLifetimeHours", + config.getNumber("sessionLifetimeHours"), + ); document = withOptional( document, "workosApiKeySecretRef", diff --git a/deploy/pulumi/gcp/stack.ts b/deploy/pulumi/gcp/stack.ts index 0a982c71..c2878cb8 100644 --- a/deploy/pulumi/gcp/stack.ts +++ b/deploy/pulumi/gcp/stack.ts @@ -703,6 +703,7 @@ function gcpRuntimeEnvironment( {name: "ARTIFACT_SERVER_ORIGIN", value: `https://${input.applicationDomain}`}, {name: "ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS", value: "1000"}, {name: "ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE", value: String(input.requestLogSampleRate)}, + {name: "ARTIFACT_SERVER_SESSION_LIFETIME_HOURS", value: String(input.sessionLifetimeHours)}, {name: "ARTIFACT_SERVER_STAGING_CLEANUP_BATCH_SIZE", value: "100"}, {name: "ARTIFACT_SERVER_STAGING_CLEANUP_CONCURRENCY", value: "4"}, {name: "ARTIFACT_SERVER_STAGING_CLEANUP_SCHEDULE", value: "external"}, diff --git a/packaging/compose/.env.example b/packaging/compose/.env.example index 6b5bd8aa..e1e205f5 100644 --- a/packaging/compose/.env.example +++ b/packaging/compose/.env.example @@ -17,6 +17,9 @@ ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=1000 ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS=10000 ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE=0.01 +# Browser sessions last this many hours, from 1 through 720. +ARTIFACT_SERVER_SESSION_LIFETIME_HOURS=12 + # Configure exactly one browser-login provider. For WorkOS, use a dedicated # Artifact Server environment and uncomment all three values below. # A file-backed API key also requires a small Compose override that mounts the diff --git a/packaging/compose/compose.external-storage.yaml b/packaging/compose/compose.external-storage.yaml index 9b9644d6..601b5f6b 100644 --- a/packaging/compose/compose.external-storage.yaml +++ b/packaging/compose/compose.external-storage.yaml @@ -22,6 +22,7 @@ services: - ARTIFACT_SERVER_POSTGRES_MAX_CONNECTIONS=${ARTIFACT_SERVER_POSTGRES_MAX_CONNECTIONS:-10} - ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=${ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS:-1000} - ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE=${ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE:-0.01} + - ARTIFACT_SERVER_SESSION_LIFETIME_HOURS=${ARTIFACT_SERVER_SESSION_LIFETIME_HOURS:-12} - ARTIFACT_SERVER_S3_BUCKET=${ARTIFACT_SERVER_S3_BUCKET:?Set ARTIFACT_SERVER_S3_BUCKET} - ARTIFACT_SERVER_S3_ENDPOINT - ARTIFACT_SERVER_S3_FORCE_PATH_STYLE=${ARTIFACT_SERVER_S3_FORCE_PATH_STYLE:-false} diff --git a/packaging/compose/compose.yaml b/packaging/compose/compose.yaml index 76f20945..862b1c45 100644 --- a/packaging/compose/compose.yaml +++ b/packaging/compose/compose.yaml @@ -20,6 +20,7 @@ services: - ARTIFACT_SERVER_ORIGIN=${ARTIFACT_SERVER_ORIGIN:?Set the HTTPS application origin} - ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=${ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS:-1000} - ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE=${ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE:-0.01} + - ARTIFACT_SERVER_SESSION_LIFETIME_HOURS=${ARTIFACT_SERVER_SESSION_LIFETIME_HOURS:-12} - ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS=${ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS:-10000} - ARTIFACT_SERVER_WORKOS_API_KEY - ARTIFACT_SERVER_WORKOS_API_KEY_FILE diff --git a/packaging/helm/artifact-server/templates/_helpers.tpl b/packaging/helm/artifact-server/templates/_helpers.tpl index bb50b641..64e626d7 100644 --- a/packaging/helm/artifact-server/templates/_helpers.tpl +++ b/packaging/helm/artifact-server/templates/_helpers.tpl @@ -159,6 +159,8 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} value: {{ .Values.configuration.readinessWithdrawalMilliseconds | quote }} - name: ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE value: {{ .Values.configuration.requestLogSampleRate | quote }} +- name: ARTIFACT_SERVER_SESSION_LIFETIME_HOURS + value: {{ .Values.configuration.sessionLifetimeHours | quote }} - name: ARTIFACT_SERVER_S3_BUCKET value: {{ .Values.configuration.s3.bucket | quote }} - name: ARTIFACT_SERVER_S3_FORCE_PATH_STYLE diff --git a/packaging/helm/artifact-server/values.schema.json b/packaging/helm/artifact-server/values.schema.json index 7f459499..21c9b0a1 100644 --- a/packaging/helm/artifact-server/values.schema.json +++ b/packaging/helm/artifact-server/values.schema.json @@ -65,6 +65,7 @@ "postgresPoolSize", "requestLogSampleRate", "readinessWithdrawalMilliseconds", + "sessionLifetimeHours", "shutdownDeadlineMilliseconds", "s3" ], @@ -77,6 +78,7 @@ "postgresPoolSize": {"type": "integer", "minimum": 1, "maximum": 100}, "requestLogSampleRate": {"type": "number", "minimum": 0, "maximum": 1}, "readinessWithdrawalMilliseconds": {"type": "integer", "minimum": 0, "maximum": 60000}, + "sessionLifetimeHours": {"type": "integer", "minimum": 1, "maximum": 720}, "shutdownDeadlineMilliseconds": {"type": "integer", "minimum": 1, "maximum": 300000}, "s3": { "type": "object", diff --git a/packaging/helm/artifact-server/values.yaml b/packaging/helm/artifact-server/values.yaml index 258c6f86..358ed0c6 100644 --- a/packaging/helm/artifact-server/values.yaml +++ b/packaging/helm/artifact-server/values.yaml @@ -20,6 +20,7 @@ configuration: postgresPoolSize: 10 requestLogSampleRate: 0.01 readinessWithdrawalMilliseconds: 1000 + sessionLifetimeHours: 12 shutdownDeadlineMilliseconds: 10000 s3: bucket: "" diff --git a/project/spec/cloud-deployment-contract.md b/project/spec/cloud-deployment-contract.md index f3b9f216..15a00001 100644 --- a/project/spec/cloud-deployment-contract.md +++ b/project/spec/cloud-deployment-contract.md @@ -76,6 +76,7 @@ but every package must expose these names and meanings. | `otlpEndpoint` | no | HTTPS OTLP collector address | | `requestLogSampleRate` | no | Number from 0 through 1; default `0.01` | | `resourceTags` | no | String map merged with required Artifact Server management tags | +| `sessionLifetimeHours` | no | Integer from 1 through 720; default `12` | The package rejects an unpinned image, equal application and content domains, an invalid capacity range, a public deployment without a DNS zone, and a @@ -201,6 +202,7 @@ the application fail at startup. | `ARTIFACT_SERVER_HOST` | `0.0.0.0` inside the managed runtime | | `ARTIFACT_SERVER_PORT` | `8787` | | `ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE` | Shared input or `0.01` | +| `ARTIFACT_SERVER_SESSION_LIFETIME_HOURS` | Shared input or `12` | | `ARTIFACT_SERVER_WORKOS_CLIENT_ID` | Hosted login only; shared WorkOS client input | | `ARTIFACT_SERVER_WORKOS_ISSUER` | Hosted login only; exact AuthKit issuer | | `ARTIFACT_SERVER_WORKOS_API_KEY_FILE` or `ARTIFACT_SERVER_WORKOS_API_KEY` | Hosted login only; provider secret reference | diff --git a/src/cli/lifecycle-commands.ts b/src/cli/lifecycle-commands.ts index 8f6e8cd9..b29ca1eb 100644 --- a/src/cli/lifecycle-commands.ts +++ b/src/cli/lifecycle-commands.ts @@ -337,6 +337,7 @@ function configureExternalStorageStart( postgresMaxConnections: parsed.postgresMaxConnections, readinessWithdrawalMilliseconds: parsed.readinessWithdrawalMilliseconds, serviceVersion: productVersion, + sessionLifetimeMilliseconds: parsed.sessionLifetimeMilliseconds, shutdownDeadlineMilliseconds: parsed.shutdownDeadlineMilliseconds, stagingCleanupPolicy: parsed.stagingCleanupPolicy, }; @@ -409,6 +410,7 @@ async function startCompactServer( readinessWithdrawalMilliseconds: configuration.readinessWithdrawalMilliseconds, serviceVersion: productVersion, + sessionLifetimeMilliseconds: configuration.sessionLifetimeMilliseconds, shutdownDeadlineMilliseconds: configuration.shutdownDeadlineMilliseconds, stagingCleanupPolicy: configuration.stagingCleanupPolicy, }; diff --git a/src/core/session-lifetime.ts b/src/core/session-lifetime.ts new file mode 100644 index 00000000..fb5ac366 --- /dev/null +++ b/src/core/session-lifetime.ts @@ -0,0 +1,5 @@ +/** Browser session lifetime when an operator does not configure one. */ +export const defaultSessionLifetimeHours = 12; + +/** Longest browser session lifetime an operator may configure. */ +export const maximumSessionLifetimeHours = 720; diff --git a/src/deployment/cloud-deployment-contract.ts b/src/deployment/cloud-deployment-contract.ts index 94528bb1..d8b9baca 100644 --- a/src/deployment/cloud-deployment-contract.ts +++ b/src/deployment/cloud-deployment-contract.ts @@ -1,6 +1,10 @@ import {Effect, Schema, type Redacted} from "effect"; import {getDomain} from "tldts"; +import { + defaultSessionLifetimeHours, + maximumSessionLifetimeHours, +} from "../core/session-lifetime.ts"; import {findUnsafeCloudDeploymentValue} from "./cloud-deployment-secret-safety.ts"; @@ -129,6 +133,11 @@ const sharedInputFields = { resourceTags: resourceTags.pipe( Schema.withDecodingDefaultKey(Effect.succeed({})), ), + sessionLifetimeHours: Schema.Int.check( + Schema.isBetween({minimum: 1, maximum: maximumSessionLifetimeHours}), + ).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(defaultSessionLifetimeHours)), + ), workosApiKeySecretRef: Schema.optionalKey(providerResourceIdentifier), workosClientId: Schema.optionalKey(identifier), workosIssuer: Schema.optionalKey(httpsUrl), diff --git a/src/external-storage/create-external-storage-runtime.ts b/src/external-storage/create-external-storage-runtime.ts index 9b993342..e12a3073 100644 --- a/src/external-storage/create-external-storage-runtime.ts +++ b/src/external-storage/create-external-storage-runtime.ts @@ -91,6 +91,7 @@ export interface ExternalStorageRuntimeConfig { readonly postgresMaxConnections?: number; readonly runtimeLifecycle?: RuntimeLifecycle; readonly serviceVersion?: string; + readonly sessionLifetimeMilliseconds?: number; readonly stagingCleanupPolicy?: StagingCleanupPolicy; } @@ -175,6 +176,7 @@ export async function createExternalStorageRuntime( localBootstrapCredential: config.localBootstrapCredential ?? null, protectBootstrapAdministrator: false, repository, + sessionLifetimeMilliseconds: config.sessionLifetimeMilliseconds, staging, stagingCleanupPolicy, }; diff --git a/src/lifecycle/runtime-configuration.ts b/src/lifecycle/runtime-configuration.ts index 7cb319a4..331da8d7 100644 --- a/src/lifecycle/runtime-configuration.ts +++ b/src/lifecycle/runtime-configuration.ts @@ -5,6 +5,10 @@ import path from "node:path"; import {Effect, Option, Redacted, Schema} from "effect"; import {getDomain} from "tldts"; +import { + defaultSessionLifetimeHours, + maximumSessionLifetimeHours, +} from "../core/session-lifetime.js"; import {managedApiKeyCredentialPattern} from "../core/installation-identity.js"; import { @@ -96,6 +100,7 @@ export interface CompactRuntimeConfiguration { readonly linkRoots: readonly string[]; readonly port: number; readonly readinessWithdrawalMilliseconds: number; + readonly sessionLifetimeMilliseconds: number; readonly shutdownDeadlineMilliseconds: number; readonly stagingCleanupPolicy: StagingCleanupPolicy; } @@ -120,6 +125,7 @@ export interface ExternalStorageRuntimeConfiguration { readonly port: number; readonly postgresMaxConnections: number; readonly readinessWithdrawalMilliseconds: number; + readonly sessionLifetimeMilliseconds: number; readonly shutdownDeadlineMilliseconds: number; readonly stagingCleanupPolicy: StagingCleanupPolicy; } @@ -253,6 +259,7 @@ export const parseCompactRuntimeConfiguration = Effect.fn( "ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS", 1_000, ), + sessionLifetimeMilliseconds: yield* parseSessionLifetime(input.environment), shutdownDeadlineMilliseconds: yield* parseMilliseconds( input.environment, "ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS", @@ -331,6 +338,7 @@ export const parseExternalStorageRuntimeConfiguration = Effect.fn( "ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS", 1_000, ), + sessionLifetimeMilliseconds: yield* parseSessionLifetime(environment), shutdownDeadlineMilliseconds: yield* parseMilliseconds( environment, "ARTIFACT_SERVER_SHUTDOWN_DEADLINE_MS", @@ -690,6 +698,25 @@ function parseMilliseconds( return Effect.succeed(milliseconds); } +function parseSessionLifetime( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + const field = "ARTIFACT_SERVER_SESSION_LIFETIME_HOURS"; + const hours = Number( + environment[field] ?? String(defaultSessionLifetimeHours), + ); + if ( + !Number.isSafeInteger(hours) || + hours < 1 || hours > maximumSessionLifetimeHours + ) { + return invalidValue( + field, + `${field} must be an integer from 1 through ${maximumSessionLifetimeHours}.`, + ); + } + return Effect.succeed(hours * 60 * 60 * 1_000); +} + function parsePostgresMaxConnections( environment: NodeJS.ProcessEnv, ): Effect.Effect { diff --git a/src/local/create-local-application-layer.ts b/src/local/create-local-application-layer.ts index 3e390758..d90789e9 100644 --- a/src/local/create-local-application-layer.ts +++ b/src/local/create-local-application-layer.ts @@ -11,6 +11,7 @@ import { digestIdentitySecret, InstallationAccessService, } from "../application/installation-access.js"; +import {defaultSessionLifetimeHours} from "../core/session-lifetime.js"; import { type InteractiveIdentityProvider, InteractiveLoginService, @@ -179,6 +180,8 @@ export interface ApplicationAdapters { GitHistoryMirrorStore & PublicLinkInventoryStore & StagedUploadRepository; + /** Absolute lifetime of a browser application session. */ + readonly sessionLifetimeMilliseconds?: number | undefined; readonly staging: StagingStore; readonly stagingCleanupPolicy?: { readonly concurrency: number; @@ -881,7 +884,8 @@ export function createApplicationLayer( digest: digestIdentitySecret, issue: () => randomBase64Url(32), }, - sessionLifetimeMilliseconds: 12 * 60 * 60 * 1_000, + sessionLifetimeMilliseconds: adapters.sessionLifetimeMilliseconds ?? + defaultSessionLifetimeHours * 60 * 60 * 1_000, }); const authenticationLayer = Layer.effect( AuthenticationService, diff --git a/src/local/create-local-runtime.ts b/src/local/create-local-runtime.ts index 42fe1188..985aa1cc 100644 --- a/src/local/create-local-runtime.ts +++ b/src/local/create-local-runtime.ts @@ -109,6 +109,7 @@ export interface LocalRuntimeConfig { readonly runtimeLifecycle?: RuntimeLifecycle; readonly installationId?: string; readonly serviceVersion?: string; + readonly sessionLifetimeMilliseconds?: number; readonly stagingCleanupPolicy?: StagingCleanupPolicy; /** Overrides the compiled web bundle directory; used by tests. */ readonly webAssetsRoot?: string; @@ -236,6 +237,7 @@ export async function createLocalRuntime( protectBootstrapAdministrator: config.browserAccess.mode === browserAccessModes.localOwner, repository, + sessionLifetimeMilliseconds: config.sessionLifetimeMilliseconds, staging, stagingCleanupPolicy, }; diff --git a/tests/conformance/installation-identity.test.ts b/tests/conformance/installation-identity.test.ts index f6298d86..3a1c7375 100644 --- a/tests/conformance/installation-identity.test.ts +++ b/tests/conformance/installation-identity.test.ts @@ -656,6 +656,35 @@ describe("installation identity and access", () => { expect(await sessionStatus(expiring.header)).toBe(401); }); + test("a configured session lifetime sets the browser session and cookie expiry", async () => { + await server.stop(); + const lifetime = 14 * 24 * 60 * 60 * 1_000; + server = await startTestServer(installation, { + clock, + sessionLifetimeMilliseconds: lifetime, + }); + const token = await issueLocalBrowserLogin(server, installation); + const login = await fetch( + `${server.baseUrl}/auth/local?token=${token}`, + {redirect: "manual"}, + ); + expect(login.status).toBe(303); + const cookies = applicationCookies(login.headers.getSetCookie()); + const expires = /Expires=([^;]+)/u.exec(cookies.sessionAttributes)?.[1]; + expect(expires).toBe(new Date(clock.now().getTime() + lifetime).toUTCString()); + + const sessionStatus = async (): Promise => { + const response = await fetch(`${server.baseUrl}/api/v1/session`, { + headers: {Cookie: cookies.header}, + }); + return response.status; + }; + clock.advance(lifetime - 10_000); + expect(await sessionStatus()).toBe(200); + clock.advance(15_000); + expect(await sessionStatus()).toBe(401); + }); + test("AUTH-001-B AUTH-001-F AUTH-028-B: external login admits only the configured first administrator and consumes state once", async () => { await server.stop(); const provider = new TestIdentityProvider({ diff --git a/tests/deployment/cloud-deployment-contract.test.ts b/tests/deployment/cloud-deployment-contract.test.ts index 2f61aec1..d35f0deb 100644 --- a/tests/deployment/cloud-deployment-contract.test.ts +++ b/tests/deployment/cloud-deployment-contract.test.ts @@ -32,6 +32,7 @@ describe("shared cloud deployment contract", () => { "cloudflare", ]); expect(parsed.every((input) => input.requestLogSampleRate === 0.01)).toBe(true); + expect(parsed.every((input) => input.sessionLifetimeHours === 12)).toBe(true); expect(parsed.every((input) => Object.keys(input.resourceTags).length === 0)) .toBe(true); }); @@ -52,6 +53,14 @@ describe("shared cloud deployment contract", () => { }); }); + test("accepts a session lifetime up to thirty days", async () => { + const parsed = await Effect.runPromise(parseCloudDeploymentInput( + cloudflareInput({sessionLifetimeHours: 720}), + )); + + expect(parsed.sessionLifetimeHours).toBe(720); + }); + test("accepts complete Cloudflare OIDC configuration", async () => { const [confidentialClient, publicClient] = await Promise.all([ Effect.runPromise(parseCloudDeploymentInput(cloudflareInput({ @@ -153,6 +162,11 @@ describe("shared cloud deployment contract", () => { compatibilityDate: "2026-02-31", })], ["non-global Cloudflare region", cloudflareInput({region: "us-west-2"})], + ["zero-hour session lifetime", cloudflareInput({sessionLifetimeHours: 0})], + ["session lifetime beyond thirty days", cloudflareInput({ + sessionLifetimeHours: 721, + })], + ["fractional session lifetime", cloudflareInput({sessionLifetimeHours: 1.5})], ])("rejects %s before a provider write", async (_name, input) => { const failure = await Effect.runPromise( parseCloudDeploymentInput(input).pipe(Effect.flip), diff --git a/tests/support/runtime-harness.ts b/tests/support/runtime-harness.ts index 328c4acc..16fb895e 100644 --- a/tests/support/runtime-harness.ts +++ b/tests/support/runtime-harness.ts @@ -103,6 +103,7 @@ export async function startTestServer( readonly mcpOAuthResource?: McpOAuthResourceConfiguration; readonly observability?: boolean; readonly port?: number; + readonly sessionLifetimeMilliseconds?: number; readonly webAssetsRoot?: string; } = {}, ): Promise { @@ -163,6 +164,12 @@ export async function startTestServer( if (options.gitHistory !== undefined) { config = {...config, gitHistory: options.gitHistory}; } + if (options.sessionLifetimeMilliseconds !== undefined) { + config = { + ...config, + sessionLifetimeMilliseconds: options.sessionLifetimeMilliseconds, + }; + } if (options.gitHistoryHealthProbe !== undefined) { config = { ...config,