Files
windmill/backend/windmill-api/src/mcp/oauth_server.rs
c2deea13b7 fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr) (#10124)
* fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr)

Privilege escalation: an app/flow/schedule/trigger execution policy's `on_behalf_of`
(which a `wm_deployers` member can set) could point at a superadmin email. The
resulting job `WM_TOKEN` then passed the email-based superadmin checks, granting
instance superadmin. `forbid_superadmin_job_token` only guarded ~15 of ~75 routes.

Fix at the token layer: a WM_TOKEN must never satisfy a superadmin gate,
regardless of whose email it runs as (sentinel OR a real superadmin).

- `ApiAuthed` gains a `job_id` field, stamped once in `AuthCache::get_opt_job_authed`
  from the resolved token's job_id (correct even on cache hits).
- `require_super_admin(db, email)` -> `require_super_admin(db, &ApiAuthed)`, rejects
  `authed.job_id.is_some()`. `require_super_admin_email` kept for the few internal
  callers without an ApiAuthed.
- `is_super_admin_authed(db, &ApiAuthed)` for the boolean `is_super_admin_email`
  authorization branches on request handlers (workspace deletion, fork drops,
  dev-workspace attach/archive, object-storage SSRF exemption, custom dbname, EE GHES
  + connected repositories, ...). Migrate ~75 sites (OSS + EE).
- CUSTOM_INSTANCE_DB reads the *authenticated* job_id, not the caller-supplied
  `?job_id` query param. Worker-tag check takes a precomputed job-aware `is_super_admin`
  on the request path.

Execution-time on-behalf checks (scheduled/flow worker-tag, Cloud enqueue quota,
is_devops_email) are hardened in a follow-up — see
docs/followup-onbehalf-execution-privilege-hardening.md.

Regression tests: a superadmin-email WM_TOKEN is rejected on `require_super_admin`
routes, on `DELETE /workspaces/delete/{w}` (403, workspace preserved), and on the
CUSTOM_INSTANCE_DB lookup with no `?job_id` (401); real superadmin tokens still succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: cap devops role at workspace admin and reject reserved on_behalf_of identities

Extends the job-token cap with three pieces:

- `require_devops_role` takes `&ApiAuthed` and rejects job tokens.
  `is_devops_email` is true for superadmin emails, so every worker-management,
  instance-config and service-log route was reachable by the same superadmin
  `WM_TOKEN` that `require_super_admin` already rejects.
- A `job_id` claim that does not parse as a uuid rejects the token rather than
  resolving to `None`, which would clear the job provenance and uncap it. Applies
  to the internal JWT and the external `jwt_ext_` path.
- Defense in depth at store time: `validate_on_behalf_of` refuses the reserved
  internal sentinels as an `on_behalf_of` on apps/flows/scripts/schedules/triggers,
  and app execution refuses a policy carrying one — covering already-persisted and
  forked-app rows that predate the cap. Deploying on behalf of a real user,
  including a real superadmin, stays allowed; the cap handles that at execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): preserve job-token provenance when minting the proxy JWT

The MCP endpoint-tool proxy re-mints a JWT from the caller's ApiAuthed to
forward the proxied request, but passed job_id: None. A job's WM_TOKEN is
capped at workspace admin (GHSA-hfh4-cx4h-3fcr); dropping the job_id here
re-minted an uncapped token that satisfies require_super_admin /
require_devops_role on the proxied route (e.g. listWorkers exposing worker
IPs, job/workspace IDs, and sensitive tags).

Carry api_authed.job_id into create_jwt_token. Adds an in-module regression
that decodes the forwarded JWT and asserts the job_id is preserved for a job
caller and absent for a non-job caller.

Reported by Codex CI review (P1) on #10124.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: cap the admin-or-devops gate at workspace admin for job tokens

require_admin_or_devops (the EE critical-alerts endpoints) grants when the
caller is a workspace admin OR an instance devops. is_devops_email is true
for superadmins, so a WM_TOKEN running on-behalf of a superadmin who is not a
member of the target workspace could clear the devops branch and read/ack that
workspace's critical alerts (GHSA-hfh4-cx4h-3fcr). This gate takes a bare
email, not an ApiAuthed, so the token-layer cap could not see it.

Thread the caller's job-token provenance and reject the devops branch for job
tokens, matching require_devops_role. The workspace-admin branch stays allowed
— that is the cap ceiling. Adds an enterprise-gated regression proving the
bypass is closed and a real superadmin token still clears the gate.

Found while auditing the PR for bare-email gates the choke-point cap misses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: cap instance-global is_admin gates at workspace admin for job tokens

Three instance-global routes gate on the caller's own `is_admin` claim, which
`ApiAuthed.is_admin` carries into a WM_TOKEN (it is a workspace-admin claim,
true for superadmins too). A job token is capped at workspace admin
(GHSA-hfh4-cx4h-3fcr), so its is_admin claim must not authorize instance
actions on a route with no workspace binding:

- `unarchive_workspace` — unarchive an arbitrary workspace by id
- `prune_concurrency_group` — delete a global concurrency group
- `list_worker_groups` — return unobfuscated `env_vars_static` (may hold secrets)

Add job-token-aware `is_instance_admin` / `require_instance_admin` helpers (the
same shape as `require_super_admin` / `require_devops_role`) and use them at
these three sites. Workspace-scoped `require_admin(authed.is_admin, ...)` gates
are intentionally left unchanged — a workspace-admin job token is within the
cap there. Regression added covering all three; verified it lets a WM_TOKEN
unarchive/leak without the fix and is blocked with it.

Reported by Codex CI review (P1) on #10124.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): drop orphaned path_field_renames from EndpointTool test helper

The merge with main adopted main's mcp path-substitution refactor (#10162),
which removed the `path_field_renames` field from `EndpointTool` and its
consumer (`substitute_path_params` no longer takes per-field path renames).
main's `runner.rs` `ep` test helper still constructed the struct with
`path_field_renames: None`, so the workspace test build (cargo test --all,
which compiles windmill-mcp's own #[cfg(test)] module under the `server`
feature) failed with E0560. A plain `cargo check` does not compile that test
module, so it only surfaced in CI's cargo_test.

Remove the orphaned field to match the struct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: describe the sentinel-rejection policy the forged-identity test asserts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: complete ApiAuthed initializers in feature-gated tests after merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: stop job tokens minting credentials that shed their provenance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cap the MCP OAuth approval mint at the same elevated-job-token gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cap the self-service password reset at the elevated-job-token gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cap app embed/SDK mints and scope widening at the elevated-job-token gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep job tokens from destroying the account they run on behalf of

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: deny job tokens a foreign-workspace admin claim and workspace ejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: keep the follow-up inventory in the PR instead of the repo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make the session workspace status gate job-token aware

session_workspace_status derived its superadmin branch from a bare email
check, so a job token carrying a superadmin identity resolved the existence
of workspaces it has no relationship with rather than seeing them as
deleted. Switch to is_super_admin_authed, matching every other instance
gate reached from a request ApiAuthed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* revert: leave the global concurrency-group listing on the plain admin gate

The listing exposes concurrency keys across workspaces, which is metadata
rather than a capability, and it 401s rather than degrading. Keep the guard
on the prune route next to it, which is the destructive one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the instance-admin gate on the global concurrency listing

The listing spans every workspace's concurrency keys, and the gate rejects
only job tokens: the !is_admin branch is the pre-existing check, so
workspaced tokens and interactive admins are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to d30af67d38954f9012f7bad08da23e347344b4c6

This commit updates the EE repository reference after PR #664 was merged in windmill-ee-private.

Previous ee-repo-ref: 7870573dbc3360f99bada143f094c67dce0d9e9c

New ee-repo-ref: d30af67d38954f9012f7bad08da23e347344b4c6

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-19 22:33:46 +02:00

1115 lines
37 KiB
Rust

//! OAuth 2.0 Authorization Server for MCP (RFC 6749, 7591, 7636, 8414, 9728)
use axum::{
extract::{Extension, Path, Query},
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Json, Router,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use windmill_common::{
auth::{hash_token, TOKEN_PREFIX_LEN},
error::{Error, Result},
min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH,
utils::rd_string,
BASE_URL, DB,
};
use crate::db::ApiAuthed;
use windmill_api_auth::forbid_elevated_job_token;
use windmill_mcp::parse_mcp_scopes;
/// Token expiration for MCP OAuth tokens (1 week in seconds)
const MCP_OAUTH_TOKEN_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60;
/// Refresh token expiration for MCP OAuth (30 days in seconds)
const MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS: u64 = 30 * 24 * 60 * 60;
/// RFC 8414
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorizationMetadata {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_types_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grant_types_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_challenge_methods_supported: Option<Vec<String>>,
}
/// RFC 9728
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtectedResourceMetadata {
pub resource: String,
pub authorization_servers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bearer_methods_supported: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
pub struct OAuthJsonError {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_description: Option<String>,
}
impl OAuthJsonError {
fn new(error: &str, description: Option<&str>) -> Self {
Self { error: error.to_string(), error_description: description.map(|s| s.to_string()) }
}
}
impl IntoResponse for OAuthJsonError {
fn into_response(self) -> axum::response::Response {
(axum::http::StatusCode::BAD_REQUEST, Json(self)).into_response()
}
}
#[derive(Debug, Serialize)]
pub struct OAuthTokenError {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_description: Option<String>,
}
impl OAuthTokenError {
fn invalid_request(description: &str) -> Self {
Self {
error: "invalid_request".to_string(),
error_description: Some(description.to_string()),
}
}
fn invalid_grant(description: &str) -> Self {
Self {
error: "invalid_grant".to_string(),
error_description: Some(description.to_string()),
}
}
fn unsupported_grant_type(description: &str) -> Self {
Self {
error: "unsupported_grant_type".to_string(),
error_description: Some(description.to_string()),
}
}
fn server_error(description: &str) -> Self {
Self { error: "server_error".to_string(), error_description: Some(description.to_string()) }
}
}
impl IntoResponse for OAuthTokenError {
fn into_response(self) -> axum::response::Response {
let status = match self.error.as_str() {
"server_error" => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
_ => axum::http::StatusCode::BAD_REQUEST,
};
(status, Json(self)).into_response()
}
}
#[derive(Debug, Deserialize)]
pub struct ClientRegistrationRequest {
pub client_name: String,
pub redirect_uris: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ClientRegistrationResponse {
pub client_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
pub client_name: String,
pub redirect_uris: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeQuery {
pub response_type: String,
pub client_id: String,
pub redirect_uri: String,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub code_challenge: Option<String>,
#[serde(default)]
pub code_challenge_method: Option<String>,
#[serde(default)]
pub resource: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ApprovalForm {
pub client_id: String,
pub redirect_uri: String,
pub scope: String,
pub state: String,
pub code_challenge: String,
pub code_challenge_method: String,
}
#[derive(Debug, Deserialize)]
pub struct TokenRequest {
pub grant_type: String,
#[serde(default)]
pub code: String,
#[serde(default)]
pub redirect_uri: String,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub code_verifier: Option<String>,
#[serde(default)]
pub refresh_token: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, FromRow)]
struct OAuthClient {
client_id: String,
client_name: String,
redirect_uris: Vec<String>,
}
#[allow(dead_code)]
#[derive(Debug, FromRow)]
struct AuthorizationCode {
code: String,
client_id: String,
user_email: String,
workspace_id: String,
scopes: Vec<String>,
redirect_uri: String,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, FromRow)]
struct RefreshTokenRow {
id: i64,
refresh_token: String,
access_token_hash: String,
client_id: String,
user_email: String,
workspace_id: String,
scopes: Vec<String>,
token_family: sqlx::types::Uuid,
created_at: chrono::DateTime<chrono::Utc>,
expires_at: chrono::DateTime<chrono::Utc>,
used_at: Option<chrono::DateTime<chrono::Utc>>,
revoked: bool,
}
fn supported_scopes() -> Vec<String> {
vec![
"mcp:all".to_string(),
"mcp:favorites".to_string(),
"mcp:scripts:*".to_string(),
"mcp:flows:*".to_string(),
"mcp:endpoints:*".to_string(),
]
}
/// GET /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server
fn build_oauth_metadata(oauth_prefix: &str, base_url: &str) -> AuthorizationMetadata {
AuthorizationMetadata {
issuer: format!("{}{}", base_url, oauth_prefix),
authorization_endpoint: format!("{}{}/authorize", base_url, oauth_prefix),
token_endpoint: format!("{}{}/token", base_url, oauth_prefix),
registration_endpoint: Some(format!("{}/api/mcp/oauth/server/register", base_url)),
scopes_supported: Some(supported_scopes()),
response_types_supported: Some(vec!["code".to_string()]),
grant_types_supported: Some(vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
]),
code_challenge_methods_supported: Some(vec!["S256".to_string()]),
}
}
fn build_protected_resource_metadata(
resource_path: &str,
oauth_prefix: &str,
base_url: &str,
) -> ProtectedResourceMetadata {
ProtectedResourceMetadata {
resource: format!("{}{}", base_url, resource_path),
authorization_servers: vec![format!("{}{}", base_url, oauth_prefix)],
scopes_supported: Some(supported_scopes()),
bearer_methods_supported: Some(vec!["header".to_string()]),
}
}
pub async fn workspaced_oauth_metadata(
Path(workspace_id): Path<String>,
) -> Json<AuthorizationMetadata> {
let base_url = BASE_URL.load();
let oauth_prefix = format!("/api/w/{}/mcp/oauth/server", workspace_id);
Json(build_oauth_metadata(&oauth_prefix, &base_url))
}
/// GET /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp
pub async fn protected_resource_metadata_by_path(
Path(workspace_id): Path<String>,
) -> Json<ProtectedResourceMetadata> {
let base_url = BASE_URL.load();
let resource_path = format!("/api/mcp/w/{}/mcp", workspace_id);
let oauth_prefix = format!("/api/w/{}/mcp/oauth/server", workspace_id);
Json(build_protected_resource_metadata(
&resource_path,
&oauth_prefix,
&base_url,
))
}
/// Schemes that a browser executes as script or uses to read local content. The
/// consent page navigates the user's browser to the registered redirect_uri
/// (`window.location` / anchor `href`), so accepting any of these turns dynamic
/// client registration into a stored-XSS / token-exfiltration primitive.
const DISALLOWED_REDIRECT_URI_SCHEMES: &[&str] =
&["javascript", "data", "vbscript", "blob", "file"];
/// Validate a dynamically-registered redirect_uri.
///
/// RFC 7591 dynamic client registration is intentionally unauthenticated for MCP
/// interoperability, so the redirect_uri is the security boundary: it must be an
/// absolute URI (RFC 6749 §3.1.2) and must not use a browser-executable scheme.
fn validate_redirect_uri(uri: &str) -> Result<()> {
let parsed = url::Url::parse(uri).map_err(|_| {
Error::BadRequest(format!(
"Invalid redirect_uri (must be an absolute URI): {uri}"
))
})?;
// `url` normalizes the scheme to lowercase ASCII.
if DISALLOWED_REDIRECT_URI_SCHEMES.contains(&parsed.scheme()) {
return Err(Error::BadRequest(format!(
"redirect_uri scheme '{}' is not allowed",
parsed.scheme()
)));
}
Ok(())
}
/// POST /api/mcp/oauth/server/register - dynamic client registration
pub async fn oauth_register(
Extension(db): Extension<DB>,
Json(req): Json<ClientRegistrationRequest>,
) -> Result<(axum::http::StatusCode, Json<ClientRegistrationResponse>)> {
if req.redirect_uris.is_empty() {
return Err(Error::BadRequest(
"At least one redirect_uri is required".to_string(),
));
}
for uri in &req.redirect_uris {
validate_redirect_uri(uri)?;
}
let client_id = format!("mcp-client-{}", rd_string(16));
sqlx::query!(
"INSERT INTO mcp_oauth_server_client (client_id, client_name, redirect_uris)
VALUES ($1, $2, $3)",
client_id,
req.client_name,
&req.redirect_uris,
)
.execute(&db)
.await
.map_err(|e| Error::InternalErr(format!("Failed to register client: {}", e)))?;
Ok((
axum::http::StatusCode::CREATED,
Json(ClientRegistrationResponse {
client_id,
client_secret: None,
client_name: req.client_name,
redirect_uris: req.redirect_uris,
}),
))
}
#[derive(Debug, Serialize)]
pub struct ApprovalResponse {
pub code: String,
pub state: Option<String>,
}
/// POST /api/w/:workspace_id/mcp/oauth/server/token - exchange code for token or refresh
pub async fn oauth_token(
Extension(db): Extension<DB>,
Form(req): Form<TokenRequest>,
) -> std::result::Result<Json<TokenResponse>, OAuthTokenError> {
match req.grant_type.as_str() {
"authorization_code" => handle_authorization_code_grant(&db, &req).await,
"refresh_token" => handle_refresh_token_grant(&db, &req).await,
_ => Err(OAuthTokenError::unsupported_grant_type(
"Supported grant types: authorization_code, refresh_token",
)),
}
}
/// Handle authorization_code grant type
async fn handle_authorization_code_grant(
db: &DB,
req: &TokenRequest,
) -> std::result::Result<Json<TokenResponse>, OAuthTokenError> {
let auth_code = match sqlx::query_as!(
AuthorizationCode,
"DELETE FROM mcp_oauth_server_code
WHERE code = $1 AND expires_at > now()
RETURNING code, client_id, user_email, workspace_id, scopes, redirect_uri,
code_challenge, code_challenge_method",
req.code
)
.fetch_optional(db)
.await
{
Ok(Some(code)) => code,
Ok(None) => {
return Err(OAuthTokenError::invalid_grant(
"Invalid or expired authorization code",
));
}
Err(e) => {
tracing::error!("Database error consuming auth code: {}", e);
return Err(OAuthTokenError::server_error("Database error"));
}
};
if auth_code.client_id != req.client_id {
return Err(OAuthTokenError::invalid_grant("client_id mismatch"));
}
if auth_code.redirect_uri != req.redirect_uri {
return Err(OAuthTokenError::invalid_grant("redirect_uri mismatch"));
}
let challenge = auth_code.code_challenge.as_ref().ok_or_else(|| {
OAuthTokenError::invalid_grant("Authorization code missing PKCE challenge")
})?;
let verifier = req
.code_verifier
.as_ref()
.ok_or_else(|| OAuthTokenError::invalid_request("code_verifier is required"))?;
let method = auth_code.code_challenge_method.as_deref().unwrap_or("S256");
if method != "S256" {
return Err(OAuthTokenError::invalid_grant(
"Only S256 PKCE method is supported",
));
}
if !validate_pkce_s256(verifier, challenge) {
return Err(OAuthTokenError::invalid_grant("Invalid code_verifier"));
}
let access_token = rd_string(32);
let access_token_hash = hash_token(&access_token);
let access_token_prefix = access_token
.get(..TOKEN_PREFIX_LEN)
.unwrap_or(&access_token);
let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
None
} else {
Some(&access_token)
};
let refresh_token = rd_string(32);
let token_family = sqlx::types::Uuid::new_v4();
let scopes = auth_code.scopes;
// Create access token (rejects archived workspaces inline)
let rows = sqlx::query!(
"INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id)
SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar
WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)",
access_token_hash,
access_token_prefix,
plaintext as Option<&str>,
auth_code.user_email,
format!("mcp-oauth-{}", auth_code.client_id),
MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(),
&scopes,
auth_code.workspace_id,
)
.execute(db)
.await
.map_err(|e| {
tracing::error!("Failed to create access token: {}", e);
OAuthTokenError::server_error("Failed to create access token")
})?;
if rows.rows_affected() == 0 {
return Err(OAuthTokenError::invalid_grant(
"Cannot create a token for an archived workspace",
));
}
// Create refresh token — store the hash of the access token so we can delete it later
let refresh_token_result = sqlx::query!(
"INSERT INTO mcp_oauth_refresh_token
(refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)",
refresh_token,
access_token_hash,
auth_code.client_id,
auth_code.user_email,
auth_code.workspace_id,
&scopes,
token_family,
MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS.to_string(),
)
.execute(db)
.await;
let refresh_token_value = match refresh_token_result {
Ok(_) => Some(refresh_token),
Err(e) => {
tracing::error!("Failed to create refresh token: {}", e);
None // Don't include invalid refresh token
}
};
Ok(Json(TokenResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: MCP_OAUTH_TOKEN_EXPIRATION_SECS,
scope: Some(scopes.join(" ")),
refresh_token: refresh_token_value,
}))
}
/// Handle refresh_token grant type with token rotation and theft detection
async fn handle_refresh_token_grant(
db: &DB,
req: &TokenRequest,
) -> std::result::Result<Json<TokenResponse>, OAuthTokenError> {
let refresh_token_value = req
.refresh_token
.as_ref()
.ok_or_else(|| OAuthTokenError::invalid_request("refresh_token is required"))?;
if req.client_id.is_empty() {
return Err(OAuthTokenError::invalid_request("client_id is required"));
}
// Atomically claim the refresh token by setting used_at in a single UPDATE.
let token_row = match sqlx::query_as!(
RefreshTokenRow,
"UPDATE mcp_oauth_refresh_token
SET used_at = now()
WHERE refresh_token = $1
AND client_id = $2
AND used_at IS NULL
AND NOT revoked
AND expires_at > now()
RETURNING id, refresh_token, access_token_hash, client_id, user_email, workspace_id,
scopes, token_family, created_at, expires_at, used_at, revoked",
refresh_token_value,
req.client_id
)
.fetch_optional(db)
.await
{
Ok(Some(row)) => row,
Ok(None) => {
// Check for token reuse (theft detection) and revoke family if detected
if let Ok(Some(family)) = sqlx::query_scalar!(
"SELECT token_family FROM mcp_oauth_refresh_token
WHERE refresh_token = $1 AND used_at IS NOT NULL",
refresh_token_value
)
.fetch_optional(db)
.await
{
tracing::warn!("Refresh token reuse detected, revoking family {:?}", family);
let _ = sqlx::query!(
"UPDATE mcp_oauth_refresh_token SET revoked = TRUE WHERE token_family = $1",
family
)
.execute(db)
.await;
}
return Err(OAuthTokenError::invalid_grant("Invalid refresh token"));
}
Err(e) => {
tracing::error!("Database error claiming refresh token: {}", e);
return Err(OAuthTokenError::server_error("Database error"));
}
};
// Delete old access token using the stored hash
if let Err(e) = sqlx::query!(
"DELETE FROM token WHERE token_hash = $1",
token_row.access_token_hash
)
.execute(db)
.await
{
tracing::error!("Failed to delete old access token: {}", e);
// Non-fatal, continue with token creation
}
// Generate new tokens
let new_access_token = rd_string(32);
let new_access_token_hash = hash_token(&new_access_token);
let new_access_token_prefix = new_access_token
.get(..TOKEN_PREFIX_LEN)
.unwrap_or(&new_access_token);
let new_plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
None
} else {
Some(&new_access_token)
};
let new_refresh_token = rd_string(32);
// Re-issues the already-approved (hence already-contained) scopes verbatim;
// containment is enforced once at approval time, so no re-check here.
let scopes = token_row.scopes;
// Create new access token (rejects archived workspaces inline)
let rows = sqlx::query!(
"INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id)
SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar
WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)",
new_access_token_hash,
new_access_token_prefix,
new_plaintext as Option<&str>,
token_row.user_email,
format!("mcp-oauth-{}", token_row.client_id),
MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(),
&scopes,
token_row.workspace_id,
)
.execute(db)
.await
.map_err(|e| {
tracing::error!("Failed to create new access token: {}", e);
OAuthTokenError::server_error("Failed to create access token")
})?;
if rows.rows_affected() == 0 {
return Err(OAuthTokenError::invalid_grant(
"Cannot create a token for an archived workspace",
));
}
// Create new refresh token (same token family for tracking) — store hash of access token
if let Err(e) = sqlx::query!(
"INSERT INTO mcp_oauth_refresh_token
(refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)",
new_refresh_token,
new_access_token_hash,
token_row.client_id,
token_row.user_email,
token_row.workspace_id,
&scopes,
token_row.token_family,
MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS.to_string(),
)
.execute(db)
.await
{
tracing::error!("Failed to create new refresh token: {}", e);
// Access token was created, return success without refresh token
}
Ok(Json(TokenResponse {
access_token: new_access_token,
token_type: "Bearer".to_string(),
expires_in: MCP_OAUTH_TOKEN_EXPIRATION_SECS,
scope: Some(scopes.join(" ")),
refresh_token: Some(new_refresh_token),
}))
}
/// Shared authorize logic — validates params, looks up client, redirects to consent page.
/// When `workspace_id` is Some, passes it to frontend; when None, passes `gateway=true`.
async fn oauth_authorize_inner(
db: &DB,
params: AuthorizeQuery,
workspace_id: Option<String>,
) -> axum::response::Response {
let client = match sqlx::query_as!(
OAuthClient,
"SELECT client_id, client_name, redirect_uris FROM mcp_oauth_server_client WHERE client_id = $1",
params.client_id
)
.fetch_optional(db)
.await
{
Ok(Some(client)) => client,
Ok(None) => {
return OAuthJsonError::new("invalid_client", Some("Unknown client_id"))
.into_response();
}
Err(e) => {
tracing::error!("Database error looking up client: {}", e);
return OAuthJsonError::new("server_error", Some("Database error"))
.into_response();
}
};
if !client.redirect_uris.contains(&params.redirect_uri) {
return OAuthJsonError::new(
"invalid_request",
Some("redirect_uri does not match registered URIs"),
)
.into_response();
}
if params.response_type != "code" {
return OAuthErrorRedirect::new(
&params.redirect_uri,
"unsupported_response_type",
Some("Only 'code' response type is supported"),
params.state.as_deref(),
)
.into_response();
}
let code_challenge = match &params.code_challenge {
Some(challenge) if !challenge.is_empty() => challenge.as_str(),
_ => {
return OAuthErrorRedirect::new(
&params.redirect_uri,
"invalid_request",
Some("PKCE required: code_challenge parameter is mandatory"),
params.state.as_deref(),
)
.into_response();
}
};
let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("S256");
if code_challenge_method != "S256" {
return OAuthErrorRedirect::new(
&params.redirect_uri,
"invalid_request",
Some("Invalid code_challenge_method: only 'S256' is supported"),
params.state.as_deref(),
)
.into_response();
}
let resource = match &params.resource {
Some(r) => r,
None => {
return OAuthErrorRedirect::new(
&params.redirect_uri,
"invalid_request",
Some("Missing 'resource' parameter. Required for MCP audience binding."),
params.state.as_deref(),
)
.into_response();
}
};
// Build frontend redirect params — gateway mode vs workspace mode
let (mode_key, mode_value) = match &workspace_id {
Some(w_id) => ("workspace_id", w_id.as_str()),
None => ("gateway", "true"),
};
let base_url = BASE_URL.load();
let frontend_url = format!(
"{}/oauth/mcp_authorize?{}",
base_url,
serde_urlencoded::to_string(&[
(mode_key, mode_value),
("client_id", params.client_id.as_str()),
("client_name", client.client_name.as_str()),
("redirect_uri", params.redirect_uri.as_str()),
("scope", params.scope.as_deref().unwrap_or("mcp:all")),
("state", params.state.as_deref().unwrap_or("")),
("code_challenge", code_challenge),
("code_challenge_method", code_challenge_method),
("resource", resource),
])
.unwrap_or_default()
);
Redirect::temporary(&frontend_url).into_response()
}
/// Shared approve logic — validates form, checks membership, stores auth code.
async fn oauth_approve_inner(
db: &DB,
authed: &ApiAuthed,
workspace_id: &str,
form: ApprovalForm,
) -> Result<Json<ApprovalResponse>> {
// The code approved here is exchanged for a database token carrying only this
// email, so an elevated job token would launder its identity into a credential
// with no `job_id` — and the MCP gateway would then re-enter the API uncapped
// (GHSA-hfh4-cx4h-3fcr). Guarded at the shared inner fn: both the workspaced and
// the gateway approve route reach the exchange through here.
forbid_elevated_job_token(db, &authed.email, authed.job_id).await?;
// Verify user is a member of the workspace
let is_member = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)",
workspace_id,
authed.email
)
.fetch_one(db)
.await
.map_err(|e| Error::InternalErr(format!("Database error: {}", e)))?
.unwrap_or(false);
if !is_member {
return Err(Error::NotAuthorized(
"User is not a member of this workspace".to_string(),
));
}
// Verify client exists and redirect_uri is registered
let client = sqlx::query_as!(
OAuthClient,
"SELECT client_id, client_name, redirect_uris FROM mcp_oauth_server_client WHERE client_id = $1",
form.client_id
)
.fetch_optional(db)
.await
.map_err(|e| Error::InternalErr(format!("Database error: {}", e)))?
.ok_or_else(|| Error::BadRequest("Unknown client_id".to_string()))?;
if !client.redirect_uris.contains(&form.redirect_uri) {
return Err(Error::BadRequest(
"Invalid redirect_uri for this client".to_string(),
));
}
if form.code_challenge.is_empty() {
return Err(Error::BadRequest(
"PKCE required: code_challenge is mandatory".to_string(),
));
}
if form.code_challenge_method.is_empty() {
return Err(Error::BadRequest(
"PKCE required: code_challenge_method is mandatory".to_string(),
));
}
if form.code_challenge_method != "S256" {
return Err(Error::BadRequest(
"Invalid code_challenge_method: only 'S256' is supported".to_string(),
));
}
let code = format!("mcp-code-{}", rd_string(32));
let scopes: Vec<String> = form
.scope
.split_whitespace()
.map(|s| s.to_string())
.collect();
// The approver's own token bounds what it may grant: a scope-restricted MCP
// token must not approve a broader one (e.g. mcp:scripts:f/x -> mcp:all). An
// unrestricted approver (interactive session, scopes None) grants freely,
// which is the normal consent flow. This is the legitimate MCP-narrowing
// path, so it uses MCP-pattern containment rather than the byte-identical
// rule ensure_scopes_within_caller applies on the generic token endpoints.
let caller_restricted = authed
.scopes
.as_deref()
.is_some_and(|s| s.iter().any(|x| !x.starts_with("if_jobs:filter_tags:")));
if caller_restricted {
// An empty grant would mint a token the auth layer treats as unscoped
// (full privileges), so a restricted approver must not produce one.
if scopes.is_empty() {
return Err(Error::NotAuthorized(
"A scope-restricted token cannot approve an empty scope grant".to_string(),
));
}
if scopes.iter().any(|s| !s.starts_with("mcp:")) {
return Err(Error::NotAuthorized(
"A scope-restricted token can only approve MCP (mcp:*) scopes".to_string(),
));
}
let caller_config = parse_mcp_scopes(authed.scopes.as_deref().unwrap_or(&[]))
.map_err(|e| Error::InternalErr(format!("Failed to parse caller MCP scopes: {e}")))?;
let requested_config = parse_mcp_scopes(&scopes)
.map_err(|e| Error::BadRequest(format!("Failed to parse requested MCP scopes: {e}")))?;
if !caller_config.contains(&requested_config) {
return Err(Error::NotAuthorized(
"Requested scopes exceed the approving token's own MCP scopes".to_string(),
));
}
}
sqlx::query!(
"INSERT INTO mcp_oauth_server_code
(code, client_id, user_email, workspace_id, scopes, redirect_uri, code_challenge, code_challenge_method)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
code,
form.client_id,
authed.email,
workspace_id,
&scopes,
form.redirect_uri,
&form.code_challenge,
&form.code_challenge_method,
)
.execute(db)
.await
.map_err(|e| Error::InternalErr(format!("Failed to store authorization code: {}", e)))?;
Ok(Json(ApprovalResponse {
code,
state: if form.state.is_empty() {
None
} else {
Some(form.state)
},
}))
}
/// GET /api/w/:workspace_id/mcp/oauth/server/authorize
pub async fn workspaced_oauth_authorize(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
Query(params): Query<AuthorizeQuery>,
) -> impl IntoResponse {
oauth_authorize_inner(&db, params, Some(workspace_id)).await
}
/// POST /api/w/:workspace_id/mcp/oauth/server/approve
pub async fn workspaced_oauth_approve(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
authed: ApiAuthed,
Json(form): Json<ApprovalForm>,
) -> Result<Json<ApprovalResponse>> {
oauth_approve_inner(&db, &authed, &workspace_id, form).await
}
/// PKCE validation (S256 only)
fn validate_pkce_s256(verifier: &str, challenge: &str) -> bool {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let computed = base64_url_encode(&hasher.finalize());
constant_time_eq(computed.as_bytes(), challenge.as_bytes())
}
/// Base64 URL encoding (no padding)
fn base64_url_encode(data: &[u8]) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(data)
}
/// Constant-time comparison
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).fold(0, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// Helper for OAuth error redirects
struct OAuthErrorRedirect {
redirect_uri: String,
error: String,
error_description: Option<String>,
state: Option<String>,
}
impl OAuthErrorRedirect {
fn new(
redirect_uri: &str,
error: &str,
error_description: Option<&str>,
state: Option<&str>,
) -> Self {
Self {
redirect_uri: redirect_uri.to_string(),
error: error.to_string(),
error_description: error_description.map(|s| s.to_string()),
state: state.map(|s| s.to_string()),
}
}
}
impl IntoResponse for OAuthErrorRedirect {
fn into_response(self) -> axum::response::Response {
let mut url = format!("{}?error={}", self.redirect_uri, self.error);
if let Some(desc) = &self.error_description {
url.push_str(&format!("&error_description={}", urlencoding::encode(desc)));
}
if let Some(state) = &self.state {
url.push_str(&format!("&state={}", state));
}
Redirect::temporary(&url).into_response()
}
}
// ── Gateway (workspace-agnostic) OAuth handlers ──
// Thin wrappers that delegate to the shared inner functions above.
pub async fn gateway_oauth_metadata() -> Json<AuthorizationMetadata> {
let base_url = BASE_URL.load();
Json(build_oauth_metadata(
"/api/mcp/gateway/oauth/server",
&base_url,
))
}
pub async fn gateway_protected_resource_metadata() -> Json<ProtectedResourceMetadata> {
let base_url = BASE_URL.load();
Json(build_protected_resource_metadata(
"/api/mcp/gateway",
"/api/mcp/gateway/oauth/server",
&base_url,
))
}
/// GET /api/mcp/gateway/oauth/server/authorize — gateway mode (no workspace in path)
pub async fn gateway_oauth_authorize(
Extension(db): Extension<DB>,
Query(params): Query<AuthorizeQuery>,
) -> impl IntoResponse {
oauth_authorize_inner(&db, params, None).await
}
/// POST /api/mcp/gateway/oauth/server/approve — workspace_id comes from JSON body
pub async fn gateway_oauth_approve(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Json(form): Json<GatewayApprovalForm>,
) -> Result<Json<ApprovalResponse>> {
if form.workspace_id.is_empty() {
return Err(Error::BadRequest("workspace_id is required".to_string()));
}
oauth_approve_inner(
&db,
&authed,
&form.workspace_id,
ApprovalForm {
client_id: form.client_id,
redirect_uri: form.redirect_uri,
scope: form.scope,
state: form.state,
code_challenge: form.code_challenge,
code_challenge_method: form.code_challenge_method,
},
)
.await
}
/// Gateway approval form — same as ApprovalForm but includes workspace_id
#[derive(Debug, Deserialize)]
pub struct GatewayApprovalForm {
pub client_id: String,
pub redirect_uri: String,
pub scope: String,
pub state: String,
pub code_challenge: String,
pub code_challenge_method: String,
pub workspace_id: String,
}
// ── Router constructors ──
/// Mounted at /api/mcp/oauth/server
pub fn global_service() -> Router {
Router::new().route("/register", post(oauth_register))
}
/// Workspace-scoped OAuth endpoints that don't require authentication
/// Mounted at /api/w/:workspace_id/mcp/oauth/server (outside authenticated section)
pub fn workspaced_unauthed_service() -> Router {
Router::new()
.route("/authorize", get(workspaced_oauth_authorize))
.route("/token", post(oauth_token))
}
/// Workspace-scoped OAuth endpoints that require authentication
/// Mounted at /api/w/:workspace_id/mcp/oauth/server (inside authenticated section)
pub fn workspaced_authed_service() -> Router {
Router::new().route("/approve", post(workspaced_oauth_approve))
}
/// Gateway OAuth endpoints that don't require authentication
/// Mounted at /api/mcp/gateway/oauth/server (outside authenticated section)
pub fn gateway_unauthed_service() -> Router {
Router::new()
.route("/authorize", get(gateway_oauth_authorize))
.route("/token", post(oauth_token))
}
/// Gateway OAuth endpoints that require authentication
/// Mounted at /api/mcp/gateway/oauth/server (inside authenticated section)
pub fn gateway_authed_service() -> Router {
Router::new().route("/approve", post(gateway_oauth_approve))
}
#[cfg(test)]
mod tests {
use super::validate_redirect_uri;
#[test]
fn accepts_legitimate_redirect_uris() {
for uri in [
"https://app.example.com/oauth/callback",
"http://localhost:9876/callback",
"http://127.0.0.1:33418/oauth/callback",
// Native/editor MCP clients use private-use URI schemes (RFC 8252).
"vscode://anthropic.claude/oauth/callback",
"cursor://anysphere.cursor/callback",
] {
assert!(
validate_redirect_uri(uri).is_ok(),
"expected {uri} to be accepted"
);
}
}
#[test]
fn rejects_browser_executable_and_relative_redirect_uris() {
for uri in [
// GHSA-q9xg-f2v2-695g: stored-XSS / token exfiltration via consent page.
"javascript:fetch('/api/w/admins/tokens/create',{method:'POST'})//",
"JavaScript:alert(document.domain)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"blob:https://example.com/uuid",
"file:///etc/passwd",
// Not an absolute URI (RFC 6749 §3.1.2).
"/relative/callback",
"not a uri",
] {
assert!(
validate_redirect_uri(uri).is_err(),
"expected {uri} to be rejected"
);
}
}
}