v0.14 is out: a mobile auth pack for Swift/iOS and Android, catching insecure token storage, cleartext traffic, and OAuth in embedded WebViews. Read more →
HIGH AI PREVALENCE: MEDIUM auth.rust.oauth.ropc-grant

OAuth token request uses the Resource Owner Password Credentials grant (grant_type=password).

Why AI tools produce this: AI coding tools produce this regularly, typically when prompted for a shortcut or a quick fix.

Why this matters

The app collects the user's password and replays it to the authorization server, exactly what OAuth was designed to avoid. It cannot support federation, MFA, or step-up auth, and any compromise of your service exposes raw user passwords.

The OAuth 2.0 Security BCP (RFC 9700 §2.4) forbids ROPC and OAuth 2.1 removes it entirely. Use the authorization-code flow with PKCE (grant_type=authorization_code) for user login, or client_credentials for machine-to-machine. With the oauth2 crate, use authorize_url / exchange_code instead of exchange_password.

VULNERABLE
vulnerable.rs
use oauth2::basic::BasicClient;
use oauth2::{ResourceOwnerPassword, ResourceOwnerUsername};
use reqwest::Client;

// oauth2 crate ROPC helper — the password grant.
async fn login_oauth2(client: &BasicClient, username: &str, password: &str) {
    let http = reqwest::Client::new();
    // ruleid: auth.rust.oauth.ropc-grant
    let _ = client
        .exchange_password(
            &ResourceOwnerUsername::new(username.to_string()),
            &ResourceOwnerPassword::new(password.to_string()),
        )
        .request_async(&http)
        .await;
}

// Hand-built reqwest form carrying the password grant.
async fn login_form(client: &Client, username: &str, password: &str) {
    // ruleid: auth.rust.oauth.ropc-grant
    let params = [
        ("grant_type", "password"),
        ("username", username),
        ("password", password),
    ];
    let _ = client
        .post("https://issuer.example.com/oauth/token")
        .form(&params)
        .send()
        .await;
}

// URL-encoded body string built by hand.
async fn login_raw_body(client: &Client, username: &str) {
    // ruleid: auth.rust.oauth.ropc-grant
    let body = format!("grant_type=password&username={}", username);
    let _ = client
        .post("https://issuer.example.com/oauth/token")
        .body(body)
        .send()
        .await;
}

fn main() {}
SAFE
safe.rs
use oauth2::basic::BasicClient;
use oauth2::AuthorizationCode;
use reqwest::Client;

// Safe: authorization-code exchange — the recommended user-login flow.
async fn login_auth_code(client: &BasicClient, code: String) {
    let http = reqwest::Client::new();
    // ok: auth.rust.oauth.ropc-grant
    let _ = client
        .exchange_code(AuthorizationCode::new(code))
        .request_async(&http)
        .await;
}

// Safe: client-credentials grant — a different grant_type value must not match.
async fn login_client_creds(client: &Client) {
    // ok: auth.rust.oauth.ropc-grant
    let params = [("grant_type", "client_credentials"), ("client_id", "svc")];
    let _ = client
        .post("https://issuer.example.com/oauth/token")
        .form(&params)
        .send()
        .await;
}

// Safe trap: a password-reset body whose grant_type prefix-matches
// "password" but is a distinct, bounded value.
async fn reset_password(client: &Client) {
    // ok: auth.rust.oauth.ropc-grant
    let body = "grant_type=password_reset&email=user@example.com";
    let _ = client
        .post("https://issuer.example.com/reset")
        .body(body)
        .send()
        .await;
}

fn main() {}

Suppressing this rule

If a finding is a genuine false positive, scope the suppression to the exact line and leave a reason, never disable the rule project-wide. Disable directives are line-scoped by design.

// oauthlint-disable-next-line auth.rust.oauth.ropc-grant -- <reason>

References

https://datatracker.ietf.org/doc/html/rfc9700#section-2.4 ↗https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#section-2.4 ↗https://cwe.mitre.org/data/definitions/522.html ↗