-- ============================================================================ -- Krow — OAuth 2.1 clients -- -- Phase 3, migration 1 of 3. This table holds the clients that may ask for a -- token: in practice, one row per Claude installation that has connected. -- -- Registered DYNAMICALLY (RFC 7591), not seeded. An MCP client discovers this -- server, registers itself, and gets a client_id back. There is deliberately no -- pre-provisioned row and no fixture: a seeded client is a credential in the -- repository, and the whole point of dynamic registration is that nobody has to -- put one there. -- -- PUBLIC CLIENTS ONLY, and that is why there is no client_secret column. -- Claude Desktop and Claude Web are public clients — they run on a machine the -- user controls, so any secret shipped to them is a secret the user has. OAuth -- 2.1 handles this with PKCE instead, which is why code_challenge is mandatory -- in 000013 rather than optional. A column for a secret that must never be -- trusted is a column somebody will eventually trust. -- -- Target schema: public. No system schema is read or written. -- ============================================================================ SET search_path = public; CREATE TABLE oauth_clients ( -- The client_id handed back at registration and presented on every -- authorization and token request. Opaque and server-generated: a client -- that could choose its own id could impersonate one already registered. client_id text PRIMARY KEY, -- What the client calls itself, for the consent screen. Untrusted display -- text — it is whatever the registering client sent, so it is shown as a -- name and never used for a decision. client_name text NOT NULL DEFAULT '', -- Exact-match redirect targets. An array because a client may legitimately -- register more than one (a desktop loopback port and a hosted callback), -- and the authorization endpoint matches the presented redirect_uri against -- these byte-for-byte. No prefix matching, no wildcards, no normalisation: -- every one of those has been an open-redirect CVE somewhere. redirect_uris text[] NOT NULL, -- Recorded for auditing which client asked for what. Constrained rather than -- free text so an unexpected value is a failed insert instead of a row -- nobody notices. grant_types text[] NOT NULL DEFAULT ARRAY['authorization_code', 'refresh_token'], -- The scopes this client may request. Held per-client so tightening the -- global policy later does not silently widen an existing registration. scopes text[] NOT NULL DEFAULT ARRAY['krow.read'], created_date timestamptz NOT NULL DEFAULT now(), last_used_at timestamptz, -- A client can be disabled without deleting it, so its tokens can be -- revoked and its history kept. disabled_at timestamptz, -- At least one redirect URI, or the client can never complete a flow. Caught -- here so a malformed registration fails at the point of registration rather -- than at the point a person is staring at a broken consent screen. CONSTRAINT oauth_clients_redirect_uris_present CHECK (array_length(redirect_uris, 1) >= 1), -- Bound so a registration cannot be used to store bulk data. CONSTRAINT oauth_clients_redirect_uris_bounded CHECK (array_length(redirect_uris, 1) <= 10), CONSTRAINT oauth_clients_name_bounded CHECK (length(client_name) <= 200) ); -- Listing a user's connected clients is not a query this table answers — that -- comes from oauth_tokens, which carries user_id. The only index here is the -- primary key, which is also the lookup path: every request arrives with a -- client_id and probes exactly that column. COMMENT ON TABLE oauth_clients IS 'OAuth 2.1 public clients, registered dynamically per RFC 7591. No secrets ' 'are stored: public clients authenticate with PKCE, not with a credential.'; COMMENT ON COLUMN oauth_clients.redirect_uris IS 'Exact-match redirect targets. Never prefix-matched or normalised.'; COMMENT ON COLUMN oauth_clients.disabled_at IS 'Set to disable a client without losing its registration or audit history.';