Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/guide/platform/administration/connected-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Local development permits exact HTTP callbacks on localhost when the platform's

The app sends users to Void's authorization page, where Void displays its registered name, operator, callback origin, and requested permission. Sign-in permits reading the user's ID and username. It does not provide an account token or project management access.

Users can disconnect applications at `<platform API URL>/oauth/connections`. Grants also expire after an hour and become invalid when their platform login session is revoked or their account is suspended. Applications must request a new sign-in when access expires.
Users can disconnect applications at `<platform API URL>/oauth/connections`. Access tokens expire after an hour, and apps renew them with a refresh token. Refreshing does not extend access past the Void sign-in the user approved the app from: access ends 30 days after that sign-in, or earlier if the sign-in is revoked or the account is suspended. Applications must then ask the user to sign in again.

## Private previews

Expand Down Expand Up @@ -48,6 +48,8 @@ Disabling the app or disconnecting its grant stops authorization for subsequent

Discover endpoints at `/.well-known/oauth-authorization-server` on the platform API origin. Use authorization code flow with `scope=profile`, a random `state`, and S256 PKCE. Authenticate token exchanges using HTTP Basic client authentication. Send access tokens as Bearer credentials to `/oauth/userinfo`; its response contains `sub`, `preferred_username`, and the consented `scope`.

The code exchange also returns a `refresh_token`. To renew access, POST `grant_type=refresh_token` and `refresh_token` to `/oauth/token` with client Basic authentication. The response has a new access token and a new `refresh_token`; store the new one and discard the old. Each refresh token works once: sending a used one again revokes the connection and the user must reconnect, so send one refresh at a time per connection. Store refresh tokens as securely as access tokens. An optional space-separated `scope` requests fewer permissions than the user approved; the connection keeps the narrower set, and later refreshes cannot widen it. `deploy:approve` publish tokens never come with a refresh token, so each publish still needs its own approval.

For publishing, package the built app with [`prepareDeployArtifact`](/reference/api/deploy). POST to `/oauth/deployment-requests` using the profile token:

```ts
Expand All @@ -66,6 +68,6 @@ Redirect the browser to the returned `authorization_url`. Exchange its code with

Use the token to request URLs from `POST /projects/:id/deploy/upload-urls`, upload the declared assets, and send the artifact to `POST /projects/:id/deploy`. Set `x-void-deploy-idempotency-key` to `approval_id`. Deployment uses Void's managed deployment protocol and requires the platform deployment Queue. The credential cannot read secrets, mint project tokens, delete projects, or deploy different bytes.

Revoke identity grants with `POST /oauth/revoke`, authenticated as the client. No refresh token or OpenID Connect ID token is issued.
Revoke a connection with `POST /oauth/revoke`, authenticated as the client, using either its access token or its refresh token. Void does not issue an OpenID Connect ID token.

If the API is protected by Cloudflare Access, the application operator must also configure a service identity permitted by that Access application's policy. Server OAuth endpoints and approved deployment requests still validate their own client, grant, and project authority; browser consent requires the user's company identity.
8 changes: 4 additions & 4 deletions platform/meta/design-docs/0091-connected-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@

Replace the companion callback allowlist from b829c7f7f with registered confidential OAuth clients. A client's callback origin is not an authority to receive a full API session.

Clients have exact callbacks, a hashed secret, an administrator-selected maximum scope, and an enabled flag. Profile authorization codes require explicit Void consent, S256 PKCE, client authentication, and exact callback binding. Codes are hashed, expire in five minutes, and are consumed transactionally. Profile access tokens are opaque, hashed in PostgreSQL, expire within an hour, and expose only subject and username.
Clients have exact callbacks, a hashed secret, an administrator-selected maximum scope, and an enabled flag. Profile authorization codes require explicit Void consent, S256 PKCE, client authentication, and exact callback binding. Codes are hashed, expire in five minutes, and are consumed transactionally. Profile access tokens are opaque, hashed in PostgreSQL, expire within an hour, and expose only subject and username. The code exchange also issues an opaque, hashed refresh token. Each redemption rotates it and issues a new access token on the same grant; the new token keeps the family expiry, which is the expiry of the grant's login session. A rejected redemption rolls back and leaves the token usable. Redeeming a rotated-out token revokes the grant. Publish tokens never get a refresh token.

Void's existing human login flow can redirect only to its own OAuth login callback. That bridge exchanges the existing PKCE code on Void and retains the API token in a Void-only HttpOnly cookie. External applications never receive that token. State and browser proof bind consent; forms require the configured Void origin. OAuth responses are uncached and suppress referrers.

A publish request commits to canonical SHA-256 of the manifest, asset manifest, and every Worker module. PostgreSQL retains metadata and digest, not executable bytes. The consent page identifies the registered operator, project, client-reported checkpoint label, resources, manifest, and digest. Approving a new project calls the existing project creation service so admission, slug rules, plans, and installation locks stay intact. Approval for an existing project requires ownership.

The code exchange issues a fifteen-minute JWT with a new oauth-deploy audience, project, and approval UUID. Older platform runtimes reject that audience. Authentication default-denies endpoints; uploads must match declared hashes and sizes, deployment must match the digest, and its idempotency key must equal the approval UUID. Deployment Queue receipts enforce one execution with recoverable retries. Platforms without that Queue fail closed.

Grants retain the upstream human session and its Access identity attested during browser consent. Every use checks user suspension, session revocation and expiration, provider enabled/generation, current Access settings, and client/grant status. Queued execution rechecks these and project ownership before cutover; share locks fence client, grant, identity, and provider mutations during that transaction. Lock order follows configuration → client → grant → user → connection → session → approval. Client disablement takes its client lock before grants and requests. OAuth service endpoints accept an admitted Access service identity, then independently validate purpose-bound credentials. Browser consent never bypasses the human Access gate.
Grants retain the upstream human session and its Access identity attested during browser consent. Every use checks user suspension, session revocation and expiration, provider enabled/generation, current Access settings, and client/grant status. Queued execution rechecks these and project ownership before cutover; share locks fence client, grant, identity, and provider mutations during that transaction. Lock order follows configuration → client → grant → user → connection → session → approval. A refresh takes the grant FOR UPDATE in that chain, so refreshes of one grant run in turn, and locks its refresh-token row last: grant → token, the same order as the cascade when a grant is deleted. Client disablement takes its client lock before grants and requests. OAuth service endpoints accept an admitted Access service identity, then independently validate purpose-bound credentials. Browser consent never bypasses the human Access gate.

Vibevoid stores only an encrypted profile token in its session and revalidates it before workspace access. Old account-token session cookies are not recognized. Logout revokes the grant. A separate sandbox builds an immutable checkpoint without platform credentials, stores its prepared output privately in R2, and is destroyed before Void approval. After callback, the server sends only approved bytes with the one-publish token; no platform token enters project code, the editing sandbox, or the publishing sandbox.

Registration and disablement use the existing admin auth, CSRF and durable intent/outcome audit. User revocation is restricted to the current subject's grants. Hourly housekeeping deletes expired requests and grants in bounded batches after a one-day retention window. The SQL migration adds tables and indexes only; native Cloudflare deployments gain no resource bindings.
Registration and disablement use the existing admin auth, CSRF and durable intent/outcome audit. User revocation is restricted to the current subject's grants. Hourly housekeeping deletes expired requests and grants in bounded batches after a one-day retention window. `oauth_grants.expires_at` is only the access token expiry, so a grant that is not revoked is kept while its refresh token family is live, and the connections page lists it. The SQL migration adds tables and indexes only; native Cloudflare deployments gain no resource bindings.

Trust remains explicit: registering a client does not attest its generated code. Approved code has the target project's runtime secrets and resources. Approval cannot prove that an operator's checkpoint label describes the submitted artifact. Cancellation or revocation does not undo code already published. Neither refresh tokens nor OpenID Connect are implemented.
Trust remains explicit: registering a client does not attest its generated code. Approved code has the target project's runtime secrets and resources. Approval cannot prove that an operator's checkpoint label describes the submitted artifact. Cancellation or revocation does not undo code already published. OpenID Connect is not implemented.

OAuth asset PUTs sign If-None-Match: * and expire within the publish token lifetime. Existing objects produce 412; deployment streams every approved asset through BLAKE3 before provisioning or cutover. This prevents a client from substituting bytes with a matching MD5 or overwriting verified content through its upload URLs. Browser consent forms use same-origin referrers so Chrome retains a verifiable Origin; code redirects keep no-referrer. Wrangler’s localhost port rewrite is accepted only in explicit development bypass mode.
13 changes: 13 additions & 0 deletions platform/packages/api/drizzle/0063_oauth_refresh_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE "oauth_refresh_tokens" (
"id" text PRIMARY KEY NOT NULL,
"grant_id" text NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oauth_refresh_tokens" ADD CONSTRAINT "oauth_refresh_tokens_grant_id_oauth_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."oauth_grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oauth_refresh_tokens_token" ON "oauth_refresh_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "oauth_refresh_tokens_grant" ON "oauth_refresh_tokens" USING btree ("grant_id");--> statement-breakpoint
CREATE INDEX "oauth_refresh_tokens_expiry" ON "oauth_refresh_tokens" USING btree ("expires_at");
Loading
Loading