#![cfg(feature = "axum")]
use std::net::SocketAddr;
use std::sync::Arc;
use oauth_as::client::{Client, ClientAuth, ClientId};
use oauth_as::grant::GrantType;
use oauth_as::http::{ApprovalDecision, ServiceBuilder};
use oauth_as::scope::ScopeSet;
use oauth_as::server::{AuthorizationServer, ServerConfig};
use oauth_as::store::MemoryStorage;
use serde_json::Value;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::{TcpListener, TcpStream};
const PUBLIC_ID: &str = "test-public";
const CONFIDENTIAL_ID: &str = "test-confidential";
const SECRET: &str = "test-secret-0123456789";
const REDIRECT_URI: &str = "http://127.0.0.1:9999/cb";
const PUBLIC_NAME: &str = "Acme <TV> & \"Friends\"";
const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
struct Resp {
status: u16,
headers: Vec<(String, String)>,
body: String,
}
impl Resp {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
fn json_is_absent(&self) -> bool {
serde_json::from_str::<Value>(&self.body).is_err()
}
fn json(&self) -> Value {
serde_json::from_str(&self.body)
.unwrap_or_else(|e| panic!("body is not JSON ({e}): {:?}", self.body))
}
}
async fn request(
addr: SocketAddr,
method: &str,
path: &str,
extra_headers: &[(&str, &str)],
body: Option<&str>,
) -> Resp {
let mut stream = TcpStream::connect(addr).await.expect("connect");
let mut req = format!(
"{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n",
addr = addr
);
for (k, v) in extra_headers {
req.push_str(&format!("{k}: {v}\r\n"));
}
match body {
Some(b) => {
let has_ct = extra_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
if !has_ct {
req.push_str("Content-Type: application/x-www-form-urlencoded\r\n");
}
req.push_str(&format!("Content-Length: {}\r\n\r\n", b.len()));
req.push_str(b);
}
None => req.push_str("\r\n"),
}
stream.write_all(req.as_bytes()).await.expect("write");
stream.flush().await.expect("flush");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.expect("read");
let text = String::from_utf8_lossy(&raw).into_owned();
let (head, body) = text
.split_once("\r\n\r\n")
.unwrap_or_else(|| panic!("malformed response: {text:?}"));
let mut lines = head.split("\r\n");
let status_line = lines.next().expect("status line");
let status: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| panic!("no status code in {status_line:?}"));
let headers = lines
.filter_map(|l| l.split_once(':'))
.map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string()))
.collect();
Resp {
status,
headers,
body: body.to_string(),
}
}
#[derive(Default, Clone, Copy)]
struct Wiring {
subject: bool,
consent: Consent,
csrf: bool,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum Consent {
#[default]
Unwired,
Approve,
Deny,
Screen,
}
impl Wiring {
fn full() -> Self {
Wiring {
subject: true,
consent: Consent::Approve,
csrf: true,
}
}
fn subject_only() -> Self {
Wiring {
subject: true,
..Wiring::default()
}
}
}
type CsrfSessions = Arc<std::sync::Mutex<std::collections::HashMap<String, String>>>;
fn session_id(headers: &http::HeaderMap) -> Option<String> {
headers
.get("cookie")?
.to_str()
.ok()?
.split(';')
.filter_map(|c| c.trim().strip_prefix("sid="))
.map(str::to_string)
.next()
}
const VICTIM_SESSION: &str = "sid=victim-session";
async fn start(seed_subject: bool) -> SocketAddr {
start_wired(match seed_subject {
true => Wiring::full(),
false => Wiring::default(),
})
.await
.0
}
async fn start_wired(wiring: Wiring) -> (SocketAddr, CsrfSessions) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
let issuer = format!("http://{addr}");
let mut config = ServerConfig::new(issuer.clone(), format!("{issuer}/device"));
config.introspection_endpoint = Some(format!("{issuer}/introspect"));
let server = Arc::new(AuthorizationServer::new(config, MemoryStorage::new()));
let scopes = ScopeSet::from_tokens(["read", "write"]).expect("scopes");
server
.register_client(Client {
client_id: ClientId::new(PUBLIC_ID),
auth: ClientAuth::Public,
grant_types: vec![
GrantType::AuthorizationCode,
GrantType::DeviceCode,
GrantType::RefreshToken,
],
redirect_uris: vec![REDIRECT_URI.to_string()],
allowed_scopes: scopes.clone(),
default_scopes: scopes.clone(),
name: Some(PUBLIC_NAME.to_string()),
registration: None,
})
.await
.expect("register public");
server
.register_client(Client {
client_id: ClientId::new(CONFIDENTIAL_ID),
auth: ClientAuth::ConfidentialSecret {
secret: SECRET.to_string(),
},
grant_types: vec![GrantType::ClientCredentials, GrantType::AuthorizationCode],
redirect_uris: vec![REDIRECT_URI.to_string()],
allowed_scopes: scopes.clone(),
default_scopes: scopes,
name: None,
registration: None,
})
.await
.expect("register confidential");
let sessions: CsrfSessions = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
let mut builder = ServiceBuilder::new(server);
if wiring.subject {
builder = builder.with_subject_resolver(|_headers| Some("test-user".to_string()));
}
builder = match wiring.consent {
Consent::Unwired => builder,
Consent::Approve => builder.with_approval_resolver(|_req| ApprovalDecision::Approve),
Consent::Deny => builder.with_approval_resolver(|_req| ApprovalDecision::Deny),
Consent::Screen => builder.with_approval_resolver(|req| {
let mut body = String::from("Allow ");
body.push_str(req.client_id.as_str());
body.push_str(" scope ");
body.push_str(&req.scope.to_string());
let mut screen = http::Response::new(oauth_as::http::Body::from(body));
*screen.status_mut() = http::StatusCode::OK;
ApprovalDecision::Respond(Box::new(screen))
}),
};
if wiring.csrf {
let issue = Arc::clone(&sessions);
let consume = Arc::clone(&sessions);
builder = builder.with_csrf_tokens(
move |headers| {
let sid = session_id(headers)?;
let token = format!("csrf-for-{sid}");
issue.lock().unwrap().insert(sid, token.clone());
Some(token)
},
move |headers| consume.lock().unwrap().remove(&session_id(headers)?),
);
}
let router = axum::Router::from(builder.build().expect("service"));
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
(addr, sessions)
}
#[tokio::test]
async fn advertised_endpoints_do_not_404() {
let addr = start(true).await;
let meta = request(
addr,
"GET",
"/.well-known/oauth-authorization-server",
&[],
None,
)
.await;
assert_eq!(meta.status, 200);
let doc = meta.json();
assert_eq!(doc["issuer"], format!("http://{addr}"));
for key in [
"authorization_endpoint",
"token_endpoint",
"device_authorization_endpoint",
"introspection_endpoint",
"revocation_endpoint",
] {
let url = doc[key].as_str().unwrap_or_else(|| panic!("{key} missing"));
let path = url
.strip_prefix(&format!("http://{addr}"))
.unwrap_or_else(|| panic!("{key} is not under the issuer: {url}"));
let method = if key == "authorization_endpoint" {
"GET"
} else {
"POST"
};
let body = if method == "POST" {
Some("junk=junk")
} else {
None
};
let resp = request(addr, method, path, &[], body).await;
assert_ne!(resp.status, 404, "advertised {key} 404s at {path}");
assert_ne!(resp.status, 405, "advertised {key} rejects its own method");
}
}
#[tokio::test]
async fn token_success_is_no_store() {
let addr = start(true).await;
let resp = request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=client_credentials&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await;
assert_eq!(resp.status, 200, "body: {}", resp.body);
assert!(
resp.header("cache-control")
.is_some_and(|v| v.to_ascii_lowercase().contains("no-store")),
"RFC 6749 s5.1 requires Cache-Control: no-store, got {:?}",
resp.header("cache-control")
);
assert!(
resp.header("pragma")
.is_some_and(|v| v.to_ascii_lowercase().contains("no-cache")),
"RFC 6749 s5.1 requires Pragma: no-cache"
);
assert_eq!(resp.json()["token_type"], "Bearer");
}
#[tokio::test]
async fn the_verification_page_refuses_framing_and_caching() {
let addr = start(true).await;
let page = request(addr, "GET", "/device", &[("Cookie", VICTIM_SESSION)], None).await;
assert_eq!(page.status, 200, "body: {}", page.body);
assert!(
page.header("content-type")
.is_some_and(|v| v.starts_with("text/html")),
"this test is only meaningful on the HTML page, got {:?}",
page.header("content-type")
);
let csp = page
.header("content-security-policy")
.expect("the verification page must carry a Content-Security-Policy");
assert!(
csp.contains("frame-ancestors 'none'"),
"without frame-ancestors the page can be clickjacked into approving an attacker's device \
grant, because a framed document satisfies the Sec-Fetch-Site check: {csp:?}"
);
assert!(
csp.contains("form-action 'self'"),
"the approval form must not be redirectable off-origin: {csp:?}"
);
assert_eq!(
page.header("x-frame-options").map(str::to_ascii_uppercase),
Some("DENY".to_string()),
"browsers that do not enforce frame-ancestors need X-Frame-Options"
);
assert!(
page.header("cache-control")
.is_some_and(|v| v.to_ascii_lowercase().contains("no-store")),
"the page carries a live CSRF token and a third party's pending grant, got {:?}",
page.header("cache-control")
);
assert_eq!(
page.header("x-content-type-options")
.map(str::to_ascii_lowercase),
Some("nosniff".to_string())
);
assert_eq!(page.header("referrer-policy"), Some("no-referrer"));
}
#[tokio::test]
async fn failed_basic_auth_is_401_with_challenge() {
let addr = start(true).await;
let basic = base64_standard(&format!("{CONFIDENTIAL_ID}:wrong-secret"));
let resp = request(
addr,
"POST",
"/token",
&[("Authorization", &format!("Basic {basic}"))],
Some("grant_type=client_credentials"),
)
.await;
assert_eq!(resp.status, 401, "body: {}", resp.body);
let challenge = resp
.header("www-authenticate")
.expect("RFC 6749 s5.2: a 401 for header auth MUST include WWW-Authenticate");
assert!(
challenge.starts_with("Basic realm="),
"challenge must name the Basic scheme and a realm, got {challenge:?}"
);
assert_eq!(resp.json()["error"], "invalid_client");
}
#[tokio::test]
async fn basic_credentials_are_form_urldecoded() {
let addr = start(true).await;
let basic = base64_standard(&format!("{CONFIDENTIAL_ID}:test%2Dsecret%2D0123456789"));
let resp = request(
addr,
"POST",
"/token",
&[("Authorization", &format!("Basic {basic}"))],
Some("grant_type=client_credentials"),
)
.await;
assert_eq!(
resp.status, 200,
"RFC 6749 s2.3.1 requires the credentials to be form-urldecoded; body: {}",
resp.body
);
}
#[tokio::test]
async fn failed_body_auth_is_400_not_401() {
let addr = start(true).await;
let resp = request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=client_credentials&client_id={CONFIDENTIAL_ID}&client_secret=wrong"
)),
)
.await;
assert_eq!(resp.status, 400, "body: {}", resp.body);
assert!(resp.header("www-authenticate").is_none());
assert_eq!(resp.json()["error"], "invalid_client");
}
#[tokio::test]
async fn two_authentication_methods_is_invalid_request() {
let addr = start(true).await;
let basic = base64_standard(&format!("{CONFIDENTIAL_ID}:{SECRET}"));
let resp = request(
addr,
"POST",
"/token",
&[("Authorization", &format!("Basic {basic}"))],
Some(&format!(
"grant_type=client_credentials&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await;
assert_eq!(resp.status, 400, "body: {}", resp.body);
assert_eq!(resp.json()["error"], "invalid_request");
}
#[tokio::test]
async fn grant_type_dispatch_refusals() {
let addr = start(true).await;
let unknown = request(
addr,
"POST",
"/token",
&[],
Some(&format!("grant_type=magic-beans&client_id={PUBLIC_ID}")),
)
.await;
assert_eq!(unknown.status, 400);
assert_eq!(unknown.json()["error"], "unsupported_grant_type");
let missing = request(
addr,
"POST",
"/token",
&[],
Some(&format!("client_id={PUBLIC_ID}")),
)
.await;
assert_eq!(missing.status, 400);
assert_eq!(missing.json()["error"], "invalid_request");
}
#[tokio::test]
async fn authorization_endpoint_does_not_redirect_before_validation() {
let addr = start(true).await;
let bare = request(addr, "GET", "/authorize", &[], None).await;
assert_eq!(bare.status, 400, "body: {}", bare.body);
assert!(bare.header("location").is_none());
assert_eq!(bare.json()["error"], "invalid_request");
let unknown = request(
addr,
"GET",
"/authorize?response_type=code&client_id=nobody&redirect_uri=http%3A%2F%2Fevil.example%2Fcb",
&[],
None,
)
.await;
assert_eq!(unknown.status, 400);
assert!(unknown.header("location").is_none());
let bad_redirect = request(
addr,
"GET",
&format!(
"/authorize?response_type=code&client_id={PUBLIC_ID}\
&redirect_uri=http%3A%2F%2Fevil.example%2Fcb"
),
&[],
None,
)
.await;
assert_eq!(bad_redirect.status, 400);
assert!(
bad_redirect.header("location").is_none(),
"an unregistered redirect_uri must never be redirected to"
);
}
#[tokio::test]
async fn authorization_errors_after_validation_do_redirect() {
let addr = start(true).await;
let resp = request(
addr,
"GET",
&format!(
"/authorize?response_type=token&client_id={PUBLIC_ID}\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A9999%2Fcb&state=xyz"
),
&[],
None,
)
.await;
assert_eq!(resp.status, 302);
let location = resp.header("location").expect("302 needs Location");
assert!(location.starts_with(REDIRECT_URI), "got {location}");
assert!(
location.contains("error=unsupported_response_type"),
"{location}"
);
assert!(location.contains("state=xyz"), "{location}");
}
#[tokio::test]
async fn authorization_issues_a_code_when_a_subject_is_resolved() {
let addr = start(true).await;
let resp = request(
addr,
"GET",
&format!(
"/authorize?response_type=code&client_id={PUBLIC_ID}\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A9999%2Fcb&state=s1\
&code_challenge={CHALLENGE}&code_challenge_method=S256"
),
&[],
None,
)
.await;
assert_eq!(resp.status, 302, "body: {}", resp.body);
let location = resp.header("location").expect("Location").to_string();
let code = location
.split(['?', '&'])
.find_map(|p| p.strip_prefix("code="))
.expect("no code in redirect");
let token = request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=authorization_code&code={code}&client_id={PUBLIC_ID}\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A9999%2Fcb&code_verifier={VERIFIER}"
)),
)
.await;
assert_eq!(token.status, 200, "body: {}", token.body);
assert!(token.json()["access_token"].is_string());
}
#[tokio::test]
async fn authorization_without_a_resolver_refuses_directly() {
let addr = start(false).await;
let resp = request(
addr,
"GET",
&format!(
"/authorize?response_type=code&client_id={PUBLIC_ID}\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A9999%2Fcb\
&code_challenge={CHALLENGE}&code_challenge_method=S256"
),
&[],
None,
)
.await;
assert_eq!(resp.status, 403, "body: {}", resp.body);
assert!(resp.header("location").is_none());
assert_eq!(resp.json()["error"], "access_denied");
assert!(
resp.json()["error_description"]
.as_str()
.is_some_and(|d| d.contains("subject resolver")),
"the refusal must name the seam that produced it: {}",
resp.body
);
}
#[tokio::test]
async fn device_flow_over_http() {
let addr = start(true).await;
let start_resp = request(
addr,
"POST",
"/device_authorization",
&[],
Some(&format!("client_id={PUBLIC_ID}")),
)
.await;
assert_eq!(start_resp.status, 200, "body: {}", start_resp.body);
let doc = start_resp.json();
let device_code = doc["device_code"]
.as_str()
.expect("device_code")
.to_string();
let user_code = doc["user_code"].as_str().expect("user_code").to_string();
assert_eq!(doc["verification_uri"], format!("http://{addr}/device"));
let pending = request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=urn:ietf:params:oauth:grant-type:device_code\
&device_code={device_code}&client_id={PUBLIC_ID}"
)),
)
.await;
assert_eq!(pending.status, 400);
assert_eq!(pending.json()["error"], "authorization_pending");
let (form, csrf) = verification_form(addr, "").await;
assert_eq!(form.status, 200);
assert!(
form.body.contains("name=\"user_code\""),
"the verification page must offer a user_code field: {}",
form.body
);
let approve = request(
addr,
"POST",
"/device",
&[
("Cookie", VICTIM_SESSION),
("Origin", &format!("http://{addr}")),
],
Some(&format!(
"user_code={}&csrf_token={csrf}&action=approve",
user_code.replace('-', "%2D")
)),
)
.await;
assert_eq!(approve.status, 200, "body: {}", approve.body);
let issued = request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=urn:ietf:params:oauth:grant-type:device_code\
&device_code={device_code}&client_id={PUBLIC_ID}"
)),
)
.await;
assert!(
issued.status == 200 || issued.json()["error"] == "slow_down",
"unexpected post-approval poll: {} {}",
issued.status,
issued.body
);
}
#[tokio::test]
async fn introspection_and_revocation() {
let addr = start(true).await;
let token: Value = request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=client_credentials&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await
.json();
let access = token["access_token"].as_str().expect("access_token");
let live = request(
addr,
"POST",
"/introspect",
&[],
Some(&format!(
"token={access}&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await;
assert_eq!(live.status, 200, "body: {}", live.body);
assert_eq!(live.json()["active"], true);
let revoked = request(
addr,
"POST",
"/revoke",
&[],
Some(&format!(
"token={access}&token_type_hint=access_token\
&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await;
assert_eq!(revoked.status, 200, "body: {}", revoked.body);
let after = request(
addr,
"POST",
"/introspect",
&[],
Some(&format!(
"token={access}&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await;
assert_eq!(after.json()["active"], false);
let unknown = request(
addr,
"POST",
"/revoke",
&[],
Some(&format!(
"token=never-existed&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await;
assert_eq!(unknown.status, 200);
}
async fn begin_device_grant(addr: SocketAddr) -> (String, String) {
let doc = request(
addr,
"POST",
"/device_authorization",
&[],
Some(&format!("client_id={PUBLIC_ID}")),
)
.await
.json();
(
doc["device_code"]
.as_str()
.expect("device_code")
.to_string(),
doc["user_code"].as_str().expect("user_code").to_string(),
)
}
async fn poll_device(addr: SocketAddr, device_code: &str) -> Resp {
request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=urn:ietf:params:oauth:grant-type:device_code\
&device_code={device_code}&client_id={PUBLIC_ID}"
)),
)
.await
}
fn csrf_token_in(body: &str) -> String {
let marker = "name=\"csrf_token\" value=\"";
let start = body
.find(marker)
.unwrap_or_else(|| panic!("no CSRF token in the rendered form: {body}"))
+ marker.len();
let rest = &body[start..];
rest[..rest.find('"').expect("unterminated value")].to_string()
}
async fn verification_form(addr: SocketAddr, query: &str) -> (Resp, String) {
let page = request(
addr,
"GET",
&format!("/device{query}"),
&[("Cookie", VICTIM_SESSION)],
None,
)
.await;
let token = csrf_token_in(&page.body);
(page, token)
}
#[tokio::test]
async fn c1_cross_origin_form_post_must_not_approve_a_device_grant() {
let (addr, _sessions) = start_wired(Wiring::full()).await;
let (device_code, user_code) = begin_device_grant(addr).await;
let (_page, csrf) = verification_form(addr, "").await;
let forced = request(
addr,
"POST",
"/device",
&[
("Cookie", VICTIM_SESSION),
("Origin", "https://attacker.example"),
("Sec-Fetch-Site", "cross-site"),
("Referer", "https://attacker.example/setup-your-tv"),
],
Some(&format!(
"user_code={}&csrf_token={csrf}&action=approve",
user_code.replace('-', "%2D")
)),
)
.await;
assert_eq!(
forced.status, 403,
"a cross-origin form POST must be refused, got {} {}",
forced.status, forced.body
);
assert!(
forced.body.contains("did not come from this site"),
"the refusal must be the ORIGIN check, not the token check the attacker just satisfied: {}",
forced.body
);
let poll = poll_device(addr, &device_code).await;
assert_eq!(
poll.json()["error"],
"authorization_pending",
"the attacker's grant was approved by a cross-site request: {} {}",
poll.status,
poll.body
);
}
#[tokio::test]
async fn c1_device_approval_without_a_csrf_token_is_refused() {
let (addr, _sessions) = start_wired(Wiring::full()).await;
let (device_code, user_code) = begin_device_grant(addr).await;
let forced = request(
addr,
"POST",
"/device",
&[
("Cookie", VICTIM_SESSION),
("Origin", &format!("http://{addr}")),
],
Some(&format!(
"user_code={}&action=approve",
user_code.replace('-', "%2D")
)),
)
.await;
assert!(
forced.status >= 400,
"an approval with no CSRF token must be refused, got {} {}",
forced.status,
forced.body
);
assert_eq!(
poll_device(addr, &device_code).await.json()["error"],
"authorization_pending",
"the grant was approved without a CSRF token"
);
}
#[tokio::test]
async fn c1_verification_form_is_not_rendered_without_a_csrf_seam() {
let (addr, _sessions) = start_wired(Wiring::subject_only()).await;
let page = request(addr, "GET", "/device", &[("Cookie", VICTIM_SESSION)], None).await;
assert!(
!page.body.contains("<form"),
"a host with no CSRF seam must not be served a submittable form: {}",
page.body
);
let (device_code, user_code) = begin_device_grant(addr).await;
let forced = request(
addr,
"POST",
"/device",
&[
("Cookie", VICTIM_SESSION),
("Origin", &format!("http://{addr}")),
],
Some(&format!(
"user_code={}&action=approve",
user_code.replace('-', "%2D")
)),
)
.await;
assert!(forced.status >= 400, "body: {}", forced.body);
assert_eq!(
poll_device(addr, &device_code).await.json()["error"],
"authorization_pending"
);
}
#[tokio::test]
async fn c1_device_approval_requires_form_urlencoded_content_type() {
let (addr, _sessions) = start_wired(Wiring::full()).await;
let (device_code, user_code) = begin_device_grant(addr).await;
let (_page, csrf) = verification_form(addr, "").await;
let forced = request(
addr,
"POST",
"/device",
&[
("Content-Type", "text/plain;charset=UTF-8"),
("Cookie", VICTIM_SESSION),
("Origin", &format!("http://{addr}")),
],
Some(&format!(
"user_code={}&csrf_token={csrf}&action=approve",
user_code.replace('-', "%2D")
)),
)
.await;
assert_eq!(
forced.status, 415,
"a non-form content type must be refused: {}",
forced.body
);
assert_eq!(
poll_device(addr, &device_code).await.json()["error"],
"authorization_pending"
);
}
#[tokio::test]
async fn c2_verification_page_names_the_client_and_the_scope() {
let (addr, _sessions) = start_wired(Wiring::full()).await;
let (_device_code, user_code) = begin_device_grant(addr).await;
let (page, _csrf) = verification_form(
addr,
&format!("?user_code={}", user_code.replace('-', "%2D")),
)
.await;
assert_eq!(page.status, 200, "body: {}", page.body);
assert!(
page.body.contains("Acme") && page.body.contains("Friends"),
"RFC 8628 s3.3: the page must name the client asking for access: {}",
page.body
);
assert!(
page.body.contains("read") && page.body.contains("write"),
"RFC 8628 s3.3: the page must state the scope being granted: {}",
page.body
);
assert!(
!page.body.contains("<TV>") && page.body.contains("<TV>"),
"the client name must be HTML-escaped: {}",
page.body
);
}
#[tokio::test]
async fn c2_approval_requires_an_affirmative_action() {
let (addr, _sessions) = start_wired(Wiring::full()).await;
let (device_code, user_code) = begin_device_grant(addr).await;
let (_page, csrf) = verification_form(addr, "").await;
let bare = request(
addr,
"POST",
"/device",
&[
("Cookie", VICTIM_SESSION),
("Origin", &format!("http://{addr}")),
],
Some(&format!(
"user_code={}&csrf_token={csrf}",
user_code.replace('-', "%2D")
)),
)
.await;
assert!(
bare.body.contains("Acme") && bare.body.contains("read write"),
"stage one must answer with the consent screen: {}",
bare.body
);
assert_eq!(
poll_device(addr, &device_code).await.json()["error"],
"authorization_pending",
"a POST with no affirmative action approved the grant: {} {}",
bare.status,
bare.body
);
}
#[tokio::test]
async fn c4_authorization_endpoint_must_not_issue_a_code_without_consent() {
let (addr, _sessions) = start_wired(Wiring::subject_only()).await;
let resp = request(
addr,
"GET",
&format!(
"/authorize?response_type=code&client_id={PUBLIC_ID}\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A9999%2Fcb&state=s1\
&code_challenge={CHALLENGE}&code_challenge_method=S256"
),
&[],
None,
)
.await;
let location = resp.header("location").unwrap_or_default().to_string();
assert!(
!location.contains("code="),
"a code was issued with no consent step: {} {location}",
resp.status
);
}
#[tokio::test]
async fn c12_well_known_document_lives_under_the_issuer_path() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
let issuer = format!("http://{addr}/tenant1");
let config = ServerConfig::new(issuer.clone(), format!("{issuer}/device"));
let server = Arc::new(AuthorizationServer::new(config, MemoryStorage::new()));
let router = axum::Router::from(ServiceBuilder::new(server).build().expect("service"));
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let doc = request(
addr,
"GET",
"/.well-known/oauth-authorization-server/tenant1",
&[],
None,
)
.await;
assert_eq!(
doc.status, 200,
"RFC 8414 s3.1 path for a tenant issuer: {}",
doc.body
);
assert_eq!(doc.json()["issuer"], issuer);
let token = request(addr, "POST", "/tenant1/token", &[], Some("junk=junk")).await;
assert_ne!(token.status, 404, "advertised token_endpoint 404s");
}
#[tokio::test]
async fn c1_a_same_origin_submission_with_the_issued_token_approves_exactly_once() {
let (addr, _sessions) = start_wired(Wiring::full()).await;
let (device_code, user_code) = begin_device_grant(addr).await;
let (_page, csrf) = verification_form(addr, "").await;
let body = format!(
"user_code={}&csrf_token={csrf}&action=approve",
user_code.replace('-', "%2D")
);
let headers = [
("Cookie", VICTIM_SESSION),
("Origin", &format!("http://{addr}")),
];
let approve = request(addr, "POST", "/device", &headers, Some(&body)).await;
assert_eq!(approve.status, 200, "body: {}", approve.body);
let (second_device_code, second_user_code) = begin_device_grant(addr).await;
let replay = request(
addr,
"POST",
"/device",
&headers,
Some(&format!(
"user_code={}&csrf_token={csrf}&action=approve",
second_user_code.replace('-', "%2D")
)),
)
.await;
assert_eq!(
replay.status, 403,
"a consumed CSRF token must not work twice: {}",
replay.body
);
assert_eq!(
poll_device(addr, &second_device_code).await.json()["error"],
"authorization_pending"
);
let issued = poll_device(addr, &device_code).await;
assert!(
issued.status == 200 || issued.json()["error"] == "slow_down",
"unexpected post-approval poll: {} {}",
issued.status,
issued.body
);
}
#[tokio::test]
async fn a_denied_device_grant_reports_access_denied() {
let (addr, _sessions) = start_wired(Wiring::full()).await;
let (device_code, user_code) = begin_device_grant(addr).await;
let (_page, csrf) = verification_form(addr, "").await;
let deny = request(
addr,
"POST",
"/device",
&[
("Cookie", VICTIM_SESSION),
("Origin", &format!("http://{addr}")),
],
Some(&format!(
"user_code={}&csrf_token={csrf}&action=deny",
user_code.replace('-', "%2D")
)),
)
.await;
assert_eq!(deny.status, 200, "body: {}", deny.body);
assert_eq!(
poll_device(addr, &device_code).await.json()["error"],
"access_denied"
);
}
#[tokio::test]
async fn c4_the_consent_seam_decides_what_the_authorization_endpoint_does() {
let query = format!(
"/authorize?response_type=code&client_id={PUBLIC_ID}\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A9999%2Fcb&state=s1\
&code_challenge={CHALLENGE}&code_challenge_method=S256"
);
let (approve_addr, _a) = start_wired(Wiring::full()).await;
let approved = request(approve_addr, "GET", &query, &[], None).await;
assert_eq!(approved.status, 302, "body: {}", approved.body);
assert!(approved
.header("location")
.is_some_and(|l| l.contains("code=")));
let (deny_addr, _d) = start_wired(Wiring {
subject: true,
consent: Consent::Deny,
csrf: false,
})
.await;
let denied = request(deny_addr, "GET", &query, &[], None).await;
assert_eq!(denied.status, 302, "body: {}", denied.body);
let location = denied.header("location").expect("Location").to_string();
assert!(location.starts_with(REDIRECT_URI), "{location}");
assert!(location.contains("error=access_denied"), "{location}");
assert!(
location.contains("state=s1"),
"the client must be able to correlate its own refusal: {location}"
);
assert!(!location.contains("code="), "{location}");
let (screen_addr, _s) = start_wired(Wiring {
subject: true,
consent: Consent::Screen,
csrf: false,
})
.await;
let screen = request(screen_addr, "GET", &query, &[], None).await;
assert_eq!(screen.status, 200, "body: {}", screen.body);
assert!(screen.header("location").is_none(), "nothing was issued");
assert!(
screen.body.contains(PUBLIC_ID) && screen.body.contains("read write"),
"the host's own screen is served unchanged: {}",
screen.body
);
}
fn base64_standard(s: &str) -> String {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
STANDARD.encode(s.as_bytes())
}
#[cfg(feature = "jwt-p256")]
async fn start_signing() -> (SocketAddr, String) {
use oauth_as::jwt::{AccessTokenFormat, EcdsaP256Key, JwtConfig};
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
let issuer = format!("http://{addr}");
let mut config = ServerConfig::new(issuer.clone(), format!("{issuer}/device"));
let kid = "wire-test-key-1".to_string();
config.access_token_format = AccessTokenFormat::Jwt(Box::new(
JwtConfig::new(EcdsaP256Key::generate(kid.clone()), "https://rs.example")
.with_jwks_uri(format!("{issuer}/jwks")),
));
let server = Arc::new(AuthorizationServer::new(config, MemoryStorage::new()));
let scopes = ScopeSet::from_tokens(["read", "write"]).expect("scopes");
server
.register_client(Client {
client_id: ClientId::new(CONFIDENTIAL_ID),
auth: ClientAuth::ConfidentialSecret {
secret: SECRET.to_string(),
},
grant_types: vec![GrantType::ClientCredentials],
redirect_uris: vec![REDIRECT_URI.to_string()],
allowed_scopes: scopes.clone(),
default_scopes: scopes,
name: None,
registration: None,
})
.await
.expect("register confidential");
let router = axum::Router::from(ServiceBuilder::new(server).build().expect("service"));
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
(addr, kid)
}
#[cfg(feature = "jwt-p256")]
#[tokio::test]
async fn a_signing_server_serves_the_key_set_it_advertises() {
let (addr, kid) = start_signing().await;
let meta = request(
addr,
"GET",
"/.well-known/oauth-authorization-server",
&[],
None,
)
.await
.json();
let uri = meta["jwks_uri"]
.as_str()
.expect("a signing AS must advertise jwks_uri");
assert_eq!(uri, format!("http://{addr}/jwks"));
let resp = request(addr, "GET", "/jwks", &[], None).await;
assert_eq!(resp.status, 200, "body: {}", resp.body);
assert_eq!(
resp.header("content-type"),
Some("application/jwk-set+json")
);
let jwks = resp.json();
let keys = jwks["keys"].as_array().expect("RFC 7517 s5 keys array");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0]["kid"], kid);
assert_eq!(keys[0]["kty"], "EC");
assert_eq!(keys[0]["crv"], "P-256");
assert_eq!(keys[0]["alg"], "ES256");
assert!(
keys[0].get("d").is_none() && !resp.body.contains("\"d\""),
"the key set must carry public parameters only: {}",
resp.body
);
let token = request(
addr,
"POST",
"/token",
&[],
Some(&format!(
"grant_type=client_credentials&client_id={CONFIDENTIAL_ID}&client_secret={SECRET}"
)),
)
.await
.json();
let access_token = token["access_token"].as_str().expect("access_token");
let parts: Vec<&str> = access_token.split('.').collect();
assert_eq!(parts.len(), 3, "RFC 7515 s3.1 compact form: {access_token}");
assert!(parts.iter().all(|p| !p.is_empty()));
let header = {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
let raw = URL_SAFE_NO_PAD.decode(parts[0]).expect("base64url header");
serde_json::from_slice::<Value>(&raw).expect("JOSE header JSON")
};
assert_eq!(header["typ"], "at+jwt");
assert_eq!(header["alg"], "ES256");
assert_eq!(header["kid"], kid);
}
#[tokio::test]
async fn an_opaque_server_advertises_no_key_set_and_routes_none() {
let addr = start(true).await;
let meta = request(
addr,
"GET",
"/.well-known/oauth-authorization-server",
&[],
None,
)
.await
.json();
assert!(
meta.get("jwks_uri").is_none(),
"opaque tokens: nothing to publish"
);
assert_eq!(request(addr, "GET", "/jwks", &[], None).await.status, 404);
}
#[tokio::test]
async fn head_answers_like_get_with_no_body() {
let addr = start(true).await;
let path = "/.well-known/oauth-authorization-server";
let get = request(addr, "GET", path, &[], None).await;
let head = request(addr, "HEAD", path, &[], None).await;
assert_eq!(head.status, 200);
assert_eq!(head.body, "", "HEAD must not carry a body");
assert_eq!(
head.header("content-type"),
get.header("content-type"),
"HEAD and GET must describe the same representation"
);
assert_eq!(
head.header("content-length").and_then(|v| v.parse().ok()),
Some(get.body.len()),
"HEAD must report the length GET would have sent"
);
}
#[tokio::test]
async fn the_wrong_method_is_405_with_an_allow_header() {
let addr = start(true).await;
let resp = request(addr, "GET", "/token", &[], None).await;
assert_eq!(resp.status, 405);
assert_eq!(resp.header("allow"), Some("POST"));
let resp = request(addr, "POST", "/authorize", &[], Some("x=1")).await;
assert_eq!(resp.status, 405);
assert_eq!(resp.header("allow"), Some("GET, HEAD"));
let resp = request(addr, "POST", "/not-an-endpoint", &[], Some("x=1")).await;
assert_eq!(resp.status, 404);
assert_eq!(resp.header("allow"), None);
}
#[tokio::test]
async fn a_body_over_the_cap_is_refused_at_the_token_endpoint() {
let addr = start(true).await;
let inside = format!(
"grant_type=client_credentials&client_id=x&junk={}",
"a".repeat(60_000)
);
let resp = request(addr, "POST", "/token", &[], Some(&inside)).await;
assert_ne!(resp.status, 413, "60 KB is inside the 64 KiB cap");
assert_eq!(resp.json()["error"], "invalid_client");
let over = format!("grant_type=client_credentials&junk={}", "a".repeat(70_000));
let resp = oversized(addr, &over).await;
assert_eq!(resp.status, 413, "an oversized body must be refused");
assert!(resp.json_is_absent(), "{:?}", resp.body);
}
async fn oversized(addr: SocketAddr, body: &str) -> Resp {
let mut stream = TcpStream::connect(addr).await.expect("connect");
let req = format!(
"POST /token HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\
Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(req.as_bytes()).await;
let mut raw = Vec::new();
let mut buf = [0u8; 4096];
loop {
match stream.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => raw.extend_from_slice(&buf[..n]),
}
}
let text = String::from_utf8_lossy(&raw).into_owned();
let (head, body) = text
.split_once("\r\n\r\n")
.unwrap_or_else(|| panic!("no response at all: {text:?}"));
let mut lines = head.split("\r\n");
let status_line = lines.next().expect("status line");
let status: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| panic!("no status code in {status_line:?}"));
Resp {
status,
headers: lines
.filter_map(|l| l.split_once(':'))
.map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string()))
.collect(),
body: body.to_string(),
}
}