$client_id, // Self-reported and untrusted — escaped wherever it is displayed. 'client_name' => $client_name, 'redirect_uris' => array_values( $redirect_uris ), 'created_at' => time(), // An EXPLICIT expiry, because sweep() only reclaims index entries // whose expiry is > 0. Without it a client row registered by an // unauthenticated caller was immortal: read-time expiry never fires // for a record nobody ever reads back, so garbage registrations // accumulated forever and made the index grow without bound. 'expires_at' => time() + self::CLIENT_TTL, ]; self::put( self::CLIENT_PREFIX . $client_id, $record ); return $record; } /** * @param string $client_id * @return array|null */ public static function get_client( string $client_id ): ?array { return self::get( self::CLIENT_PREFIX . $client_id, self::CLIENT_TTL ); } // -------------------------------------------------------------- grants /** * @param array $grant * @return string The one-time code (never stored in plaintext). */ public static function create_grant( array $grant ): string { $code = bin2hex( random_bytes( 32 ) ); $grant['created_at'] = time(); // Defaulted HERE, not left to the caller. `put()` indexes a record with no // `expires_at` as expiry 0, and `sweep()` only reclaims entries whose // expiry is > 0 — so a grant that is abandoned mid-flow (the user opens the // approval screen, approves, and the client never exchanges the code) would // be immortal. Read-time expiry never fires for a row nobody reads back. // That is the same trap `create_client()` was fixed for, and it also keeps // `has_any()` — which counts grant rows — reporting the site as connected // forever, holding the endpoint out of its inert state (FR-023). if ( empty( $grant['expires_at'] ) ) { $grant['expires_at'] = time() + self::GRANT_TTL; } self::put( self::GRANT_PREFIX . hash( 'sha256', $code ), $grant ); return $code; } /** * Fetch a grant AND DELETE IT, whether or not the caller then accepts it. * * Deleting before verification is deliberate (FR-033): it makes a failed * exchange unretryable, so a leaked code cannot be brute-forced against the * proof-of-origination check. * * @param string $code * @return array|null */ public static function consume_grant( string $code ): ?array { $key = self::GRANT_PREFIX . hash( 'sha256', $code ); $grant = self::get( $key, self::GRANT_TTL ); delete_option( $key ); self::deindex( $key ); return $grant; } // -------------------------------------------------------------- tokens /** * @param array $token * @param int $ttl * @return string The secret (never stored in plaintext). */ public static function create_token( array $token, int $ttl ): string { $secret = bin2hex( random_bytes( 32 ) ); $token['created_at'] = time(); $token['expires_at'] = time() + $ttl; self::put( self::TOKEN_PREFIX . hash( 'sha256', $secret ), $token ); return $secret; } /** * @param string $secret * @return array|null */ public static function find_access_token( string $secret ): ?array { $record = self::get( self::TOKEN_PREFIX . hash( 'sha256', $secret ), self::ACCESS_TTL ); if ( null === $record || 'access' !== ( $record['type'] ?? '' ) ) { return null; } // Audience check — RFC 9700 §2.3 / MCP: a resource server MUST refuse a // token that was not meant for it. Exactly one acceptable value; the // transitional arm that also accepted the issuer is gone, because the // issuer is now path-scoped and no token ever carried that string. if ( ! empty( $record['resource'] ) && $record['resource'] !== self::audience() ) { return null; } return $record; } /** * @param string $secret * @return array|null */ public static function find_refresh_token( string $secret ): ?array { $record = self::get( self::TOKEN_PREFIX . hash( 'sha256', $secret ), self::REFRESH_TTL ); if ( null === $record || 'refresh' !== ( $record['type'] ?? '' ) ) { return null; } // Same audience check as the access token. Not exploitable today (a // refresh token can never be presented as a bearer), but the asymmetry // becomes live the moment this store is shared across sites. if ( ! empty( $record['resource'] ) && $record['resource'] !== self::audience() ) { return null; } return $record; } // ------------------------------------------------- grant chains (reuse) /** * Remember that a refresh token was spent, WITHOUT keeping the credential. * * Rotation alone cannot detect replay: once the old row is deleted, a stolen * token presented afterwards is indistinguishable from a random string, so * the attacker's theft is invisible and the legitimate client simply falls * off. Keeping a tombstone — the hash and the chain it belonged to, no * secret — is what makes the second presentation recognisable as REUSE. * * OAuth 2.1 §4.3.1 describes revoking "the active refresh token as well as * the access authorization grant associated with it" on detection. (RFC 9700 * §4.14.2 revokes only the active token — the MUST is on having a detection * mechanism at all, not on the breadth of the response. This takes the * stronger of the two.) * * @param string $hash sha256 of the spent secret. * @param string $chain Grant chain the token belonged to. * @param int $ttl * @return void */ public static function tombstone_refresh( string $hash, string $chain, int $ttl ): void { self::put( self::SPENT_PREFIX . $hash, [ 'chain' => $chain, 'created_at' => time(), 'expires_at' => time() + $ttl, ] ); } /** * @param string $secret * @return string '' when this token was never spent. */ public static function spent_chain( string $secret ): string { $record = self::get( self::SPENT_PREFIX . hash( 'sha256', $secret ), self::REFRESH_TTL ); return is_array( $record ) ? (string) ( $record['chain'] ?? '' ) : ''; } /** * Revoke every credential descended from one authorization grant. * * @param string $chain * @return int Number of records removed. */ public static function revoke_chain( string $chain ): int { if ( '' === $chain ) { return 0; } $removed = 0; foreach ( array_merge( self::option_names( self::TOKEN_PREFIX ), self::option_names( self::SPENT_PREFIX ) ) as $key ) { $record = get_option( $key, null ); if ( ! is_array( $record ) || (string) ( $record['chain'] ?? '' ) !== $chain ) { continue; } delete_option( $key ); self::deindex( $key ); ++$removed; } return $removed; } /** * @return string */ public static function new_chain(): string { return bin2hex( random_bytes( 16 ) ); } /** * Take a refresh token AND spend it, letting the DELETE arbitrate the race. * * `delete_option()` returns false when the row was already gone, and its * underlying DELETE reports rows-affected — so of two concurrent refreshes * presenting the same token, exactly one sees `true` and may mint. The * previous find-then-delete let BOTH pass the lookup and both issue a token * pair, leaving an extra live credential per race. * * @param string $secret * @return array|null The record, or null if it was not ours to spend. */ public static function consume_refresh_token( string $secret ): ?array { $key = self::TOKEN_PREFIX . hash( 'sha256', $secret ); $record = self::get( $key, self::REFRESH_TTL ); if ( null === $record || 'refresh' !== ( $record['type'] ?? '' ) ) { return null; } if ( ! empty( $record['resource'] ) && $record['resource'] !== self::audience() ) { return null; } if ( ! delete_option( $key ) ) { return null; } self::deindex( $key ); return $record; } /** * @param string $secret * @return void */ public static function delete_token( string $secret ): void { self::delete_token_by_hash( hash( 'sha256', $secret ) ); } /** * For a token known only by its hash — the paired access token recorded on a * refresh token, which is stored hashed and never held in plaintext. * * @param string $hash * @return void */ public static function delete_token_by_hash( string $hash ): void { $key = self::TOKEN_PREFIX . $hash; delete_option( $key ); self::deindex( $key ); } /** * This authorization server's ISSUER identifier (RFC 8414 §2). * * PATH-SCOPED — `https://site.test/templately`, not the bare origin. * * RFC 8414 gives an origin exactly ONE authorization-server document, at * `/.well-known/oauth-authorization-server`. Claiming the bare origin as our * issuer therefore put us in a single global slot that any other OAuth- * serving plugin on the same site claims too — whoever registered its * rewrite first won, and a client could be handed the wrong server's * authorization endpoint. We won that race by accident, not by design. * * §3.1 resolves it: for an issuer with a path component, the metadata URL is * built by INSERTING the well-known segment between host and path — * `https://site.test/.well-known/oauth-authorization-server/templately`. That * is a slot only this plugin can occupy, so two MCP plugins can coexist. * * @return string */ public static function issuer(): string { return untrailingslashit( home_url( '/templately' ) ); } /** * @deprecated Use issuer() (identity) or audience() (what a token is for). * Kept because the two were conflated under this one name. * @return string */ public static function resource_id(): string { return self::issuer(); } /** * The AUDIENCE a token is minted for — the MCP endpoint's canonical URI. * * Distinct from the issuer above, which it used to be conflated with. Both * were `resource_id()`, so the "audience check" in find_access_token() was * really an issuer check that happened to be self-consistent. RFC 8707 binds * a token to a RESOURCE, and RFC 9700 §2.3 makes rejecting a token meant for * a different resource a MUST at the resource server — which is * unimplementable while the two identifiers are the same string. * * This is also the only value this server will accept in a client's * `resource` parameter. * * @return string */ public static function audience(): string { return rest_url( 'templately/v1/mcp' ); } /** * Whether a client-supplied `resource` indicator names THIS server. * * MCP requires clients to send `resource` on both the authorization and the * token request. Accepting one that names somebody else's server and issuing * a token anyway is how a client ends up believing it holds a credential for * a resource it does not — the token-passthrough shape the MCP security * guidance calls out. * * An ABSENT indicator is accepted: RFC 8707 §2.1 leaves that to local policy, * and this server has exactly one resource, so there is nothing to disambiguate. * * @param string $requested * @return bool */ public static function resource_matches( string $requested ): bool { if ( '' === $requested ) { return true; } $normalise = static function ( $value ) { $parts = wp_parse_url( untrailingslashit( $value ) ); if ( empty( $parts['host'] ) ) { return ''; } return strtolower( $parts['scheme'] ?? '' ) . '://' . strtolower( $parts['host'] ) . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) . ( $parts['path'] ?? '' ); }; return '' !== $normalise( $requested ) && $normalise( $requested ) === $normalise( self::audience() ); } // ----------------------------------------------------------- admin view /** Settings-list id prefix, so one revoke route can serve both systems. */ const PUBLIC_ID_PREFIX = 'oauth_'; /** * Delegated connections for the Settings → AI Agents list, grouped BY CLIENT. * * Grouping matters. A live connection is a rotating pair of rows — an access * token that expires hourly and a refresh token that replaces it — so * listing raw token rows would show a connection vanishing an hour after it * was made and reappearing under a new id. One row per application is what * an administrator actually means by "the ChatGPT connection". * * Before this, the settings list read only `Credentials` (pairing tokens), * so a site whose only connection was granted through OAuth showed an empty * list and no way to inspect or revoke it. * * Shaped like `Credentials::list_public()` so the same table renders both. * * @return array */ public static function list_connections(): array { $clients = []; foreach ( self::option_names( self::TOKEN_PREFIX ) as $key ) { $record = get_option( $key, null ); if ( ! is_array( $record ) || empty( $record['client_id'] ) ) { continue; } $expires = (int) ( $record['expires_at'] ?? 0 ); if ( $expires > 0 && $expires < time() ) { continue; } $client_id = (string) $record['client_id']; $created = (int) ( $record['created_at'] ?? 0 ); if ( ! isset( $clients[ $client_id ] ) ) { $clients[ $client_id ] = [ 'user_id' => (int) ( $record['user_id'] ?? 0 ), 'access_level' => (string) ( $record['access_level'] ?? '' ), 'created_at' => $created, 'expires_at' => $expires, ]; continue; } // Granted-at is the OLDEST surviving token; valid-until the newest. $clients[ $client_id ]['created_at'] = min( $clients[ $client_id ]['created_at'], $created ); $clients[ $client_id ]['expires_at'] = max( $clients[ $client_id ]['expires_at'], $expires ); } $rows = []; foreach ( $clients as $client_id => $data ) { $client = self::get_client( $client_id ); $user = $data['user_id'] ? get_userdata( $data['user_id'] ) : null; $rows[] = [ 'id' => self::PUBLIC_ID_PREFIX . $client_id, // Distinguishes the two systems in the UI: a delegated grant has // no secret an administrator could ever have copied. 'source' => 'oauth', 'name' => ( $client && '' !== $client['client_name'] ) ? $client['client_name'] : __( 'Unnamed application', 'templately' ), 'user_id' => $data['user_id'], 'user_login' => $user ? $user->user_login : '', // Never '' — the settings table feeds this straight into a // controlled