| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- //! `test(grant_device)` sender-constraint matrix at redemption time.
- use std::time::Duration;
- use axum::http::StatusCode;
- use super::super::{DevicePollError, DevicePollInputs, PollState, poll_device};
- use super::flows::{authorize, decide, now_unix, poll_once, public_inputs, secret};
- use super::{TestError, TestResult, harness, unique};
- use super::{keys, setup};
- use crate::{
- db::TokenEndpointAuthMethod,
- error::OAuthErrorCode,
- protocol::{device::DeviceHttpInputs, token::TokenFormat},
- };
- #[tokio::test]
- async fn test_grant_device_public_without_dpop_rejected() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("public");
- setup::insert_client_bound(
- &h.pool,
- &h.config,
- &client_id,
- TokenEndpointAuthMethod::None,
- None,
- "orders:read",
- true,
- )
- .await?;
- let t0 = now_unix();
- let key = keys::fresh_key()?;
- let authorize_htu = keys::device_htu(&h.config);
- let authorize_proof = keys::mint_proof(&key, &authorize_htu, &unique("jti"), t0)?;
- let authorize_inputs = DeviceHttpInputs {
- client_id: client_id.as_str(),
- scope: None,
- basic_header: None,
- body_secret: None,
- assertion_type: None,
- assertion: None,
- dpop_proof: Some(authorize_proof.as_str()),
- resource: &[],
- peer_certs: None,
- };
- let issued = authorize(&mut h, &authorize_inputs, t0)
- .await
- .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
- match poll_once(
- &mut h,
- &public_inputs(&client_id, &issued.device_code, None),
- t0,
- )
- .await
- {
- Err(DevicePollError::Protocol(error)) if error.code() == OAuthErrorCode::InvalidDpopProof => {
- Ok(())
- }
- Err(other) => Err(TestError::Unexpected(format!(
- "proofless public poll must be invalid_dpop_proof, got {}",
- other.code()
- ))),
- Ok(_) => Err(TestError::Unexpected(
- "a public client must never receive an unbound token".to_owned(),
- )),
- }
- }
- /// An unbound public client completes the device flow with no DPoP proof:
- /// authorization, approval, and poll all succeed with a `Bearer` token.
- #[tokio::test]
- async fn public_unbound_device_authorization_and_poll_succeeds() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("public-unbound");
- setup::insert_client(
- &h.pool,
- &h.config,
- &client_id,
- TokenEndpointAuthMethod::None,
- None,
- "orders:read",
- )
- .await?;
- setup::enable_scopes(&h.pool, &["orders:read"]).await?;
- let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
- let t0 = now_unix();
- let authorize_inputs = DeviceHttpInputs {
- client_id: client_id.as_str(),
- scope: Some("orders:read"),
- basic_header: None,
- body_secret: None,
- assertion_type: None,
- assertion: None,
- dpop_proof: None,
- resource: &[],
- peer_certs: None,
- };
- let issued = authorize(&mut h, &authorize_inputs, t0)
- .await
- .map_err(|error| TestError::Unexpected(format!("unbound authorize rejected: {error}")))?;
- if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
- return Err(TestError::Unexpected("decide must apply".to_owned()));
- }
- let tokens = poll_once(
- &mut h,
- &public_inputs(&client_id, &issued.device_code, None),
- t0 + 6,
- )
- .await
- .map_err(|error| TestError::Unexpected(format!("unbound poll rejected: {}", error.code())))?;
- if tokens.token_type != "Bearer" || tokens.scope != "orders:read" {
- return Err(TestError::Unexpected(
- "unbound device redemption must be a Bearer token".to_owned(),
- ));
- }
- Ok(())
- }
- /// A DPoP-bound public client cannot start the device flow without a proof:
- /// authorization itself is `invalid_dpop_proof` before any state is written.
- #[tokio::test]
- async fn public_bound_device_poll_requires_dpop() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("public-bound");
- setup::insert_client_bound(
- &h.pool,
- &h.config,
- &client_id,
- TokenEndpointAuthMethod::None,
- None,
- "orders:read",
- true,
- )
- .await?;
- let authorize_inputs = DeviceHttpInputs {
- client_id: client_id.as_str(),
- scope: None,
- basic_header: None,
- body_secret: None,
- assertion_type: None,
- assertion: None,
- dpop_proof: None,
- resource: &[],
- peer_certs: None,
- };
- match authorize(&mut h, &authorize_inputs, now_unix()).await {
- Err(error) if error.code() == OAuthErrorCode::InvalidDpopProof => Ok(()),
- Err(other) => Err(TestError::Unexpected(format!(
- "bound authorize without proof must be invalid_dpop_proof, got {other}"
- ))),
- Ok(_) => Err(TestError::Unexpected(
- "a bound client must never start a proof-free device flow".to_owned(),
- )),
- }
- }
- #[tokio::test]
- async fn test_grant_device_confidential_secret_redeems_bearer() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("conf");
- setup::insert_client(
- &h.pool,
- &h.config,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("poll-secret"),
- "orders:read",
- )
- .await?;
- setup::enable_scopes(&h.pool, &["orders:read"]).await?;
- let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
- let t0 = now_unix();
- let authorize_inputs = DeviceHttpInputs {
- client_id: client_id.as_str(),
- scope: Some("orders:read"),
- basic_header: None,
- body_secret: Some(secret("poll-secret")),
- assertion_type: None,
- assertion: None,
- dpop_proof: None,
- resource: &[],
- peer_certs: None,
- };
- let issued = authorize(&mut h, &authorize_inputs, t0)
- .await
- .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
- if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
- return Err(TestError::Unexpected("decide must apply".to_owned()));
- }
- let poll_inputs = DevicePollInputs {
- client_id: client_id.as_str(),
- device_code: issued.device_code.as_str(),
- basic_header: None,
- body_secret: Some(secret("poll-secret")),
- assertion_type: None,
- assertion: None,
- dpop_proof: None,
- peer_certs: None,
- aud: &[],
- };
- let mut state = PollState {
- pool: &h.pool,
- conn: &mut h.conn,
- store: &h.store,
- keys: &h.config.keys,
- issuer: &h.config.issuer,
- device_ttl: h.config.ttl.device,
- interval: Duration::from_secs(crate::protocol::device::DEVICE_INTERVAL_SECS),
- ttl: &h.config.ttl,
- token_format: TokenFormat::Opaque,
- jwt_signer: None,
- cimd: None,
- resource_indicators_enabled: false,
- now_unix: t0 + 6,
- };
- let tokens = poll_device(&mut state, &poll_inputs)
- .await
- .map_err(|error| {
- TestError::Unexpected(format!("confidential poll rejected: {}", error.code()))
- })?;
- if tokens.token_type != "Bearer" || tokens.scope != "orders:read" {
- return Err(TestError::Unexpected(
- "unconstrained confidential redemption must be a Bearer token".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn test_grant_device_dpop_bound_confidential_requires_fresh_proof() -> TestResult {
- let mut h = harness().await?;
- let client_id = unique("bound-conf");
- setup::insert_client_bound(
- &h.pool,
- &h.config,
- &client_id,
- TokenEndpointAuthMethod::ClientSecretPost,
- Some("bound-secret"),
- "orders:read",
- true,
- )
- .await?;
- setup::enable_scopes(&h.pool, &["orders:read"]).await?;
- let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
- let t0 = now_unix();
- let authorize_inputs = DeviceHttpInputs {
- client_id: client_id.as_str(),
- scope: Some("orders:read"),
- basic_header: None,
- body_secret: Some(secret("bound-secret")),
- assertion_type: None,
- assertion: None,
- dpop_proof: None,
- resource: &[],
- peer_certs: None,
- };
- let issued = authorize(&mut h, &authorize_inputs, t0)
- .await
- .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
- if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
- return Err(TestError::Unexpected("decide must apply".to_owned()));
- }
- let poll_inputs = DevicePollInputs {
- client_id: client_id.as_str(),
- device_code: issued.device_code.as_str(),
- basic_header: None,
- body_secret: Some(secret("bound-secret")),
- assertion_type: None,
- assertion: None,
- dpop_proof: None,
- peer_certs: None,
- aud: &[],
- };
- let mut state = PollState {
- pool: &h.pool,
- conn: &mut h.conn,
- store: &h.store,
- keys: &h.config.keys,
- issuer: &h.config.issuer,
- device_ttl: h.config.ttl.device,
- interval: Duration::from_secs(crate::protocol::device::DEVICE_INTERVAL_SECS),
- ttl: &h.config.ttl,
- token_format: TokenFormat::Opaque,
- jwt_signer: None,
- cimd: None,
- resource_indicators_enabled: false,
- now_unix: t0 + 6,
- };
- match poll_device(&mut state, &poll_inputs).await {
- Err(DevicePollError::Protocol(error)) if error.code() == OAuthErrorCode::InvalidGrant => {}
- Err(other) => {
- return Err(TestError::Unexpected(format!(
- "proofless bound poll must be invalid_grant, got {}",
- other.code()
- )));
- }
- Ok(_) => {
- return Err(TestError::Unexpected(
- "a DPoP-bound client must never receive an unbound token".to_owned(),
- ));
- }
- }
- let key = keys::fresh_key()?;
- let token_htu = keys::token_htu(&h.config);
- let proof = keys::mint_proof(&key, &token_htu, &unique("jti"), t0 + 6)?;
- let proof_inputs = DevicePollInputs {
- dpop_proof: Some(proof.as_str()),
- ..poll_inputs
- };
- let tokens = poll_device(&mut state, &proof_inputs)
- .await
- .map_err(|error| {
- TestError::Unexpected(format!("bound fresh-proof poll rejected: {}", error.code()))
- })?;
- if tokens.token_type != "DPoP" || tokens.scope != "orders:read" {
- return Err(TestError::Unexpected(
- "a fresh proof must mint a DPoP token for the bound client".to_owned(),
- ));
- }
- Ok(())
- }
- /// RFC 6749 §5.2 and the error table in docs/OAUTH.md: a grant issued to
- /// another client is `invalid_grant`. The foreign poll must not consume the
- /// code, so the legitimate client still redeems it.
- #[tokio::test]
- async fn test_grant_device_cross_client_poll_is_invalid_grant() -> TestResult {
- let mut h = harness().await?;
- let owner_id = unique("owner");
- let intruder_id = unique("intruder");
- setup::insert_client(
- &h.pool,
- &h.config,
- &owner_id,
- TokenEndpointAuthMethod::None,
- None,
- "orders:read",
- )
- .await?;
- setup::insert_client(
- &h.pool,
- &h.config,
- &intruder_id,
- TokenEndpointAuthMethod::None,
- None,
- "orders:read",
- )
- .await?;
- setup::enable_scopes(&h.pool, &["orders:read"]).await?;
- let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
- let t0 = now_unix();
- let owner_key = keys::fresh_key()?;
- let authorize_htu = keys::device_htu(&h.config);
- let poll_htu = keys::token_htu(&h.config);
- let authorize_proof = keys::mint_proof(&owner_key, &authorize_htu, &unique("jti"), t0)?;
- let authorize_inputs = DeviceHttpInputs {
- client_id: owner_id.as_str(),
- scope: Some("orders:read"),
- basic_header: None,
- body_secret: None,
- assertion_type: None,
- assertion: None,
- dpop_proof: Some(authorize_proof.as_str()),
- resource: &[],
- peer_certs: None,
- };
- let issued = authorize(&mut h, &authorize_inputs, t0)
- .await
- .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
- if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
- return Err(TestError::Unexpected("decide must apply".to_owned()));
- }
- let t1 = t0 + 6;
- let intruder_key = keys::fresh_key()?;
- let intruder_proof = keys::mint_proof(&intruder_key, &poll_htu, &unique("jti"), t1)?;
- match poll_once(
- &mut h,
- &public_inputs(&intruder_id, &issued.device_code, Some(&intruder_proof)),
- t1,
- )
- .await
- {
- Err(DevicePollError::Protocol(error))
- if error.code() == OAuthErrorCode::InvalidGrant
- && error.status() == StatusCode::BAD_REQUEST => {}
- Err(other) => {
- return Err(TestError::Unexpected(format!(
- "cross-client poll must be invalid_grant 400, got {}",
- other.code()
- )));
- }
- Ok(_) => {
- return Err(TestError::Unexpected(
- "a foreign client must never mint a token".to_owned(),
- ));
- }
- }
- // The rejected poll left the grant untouched: the owner still redeems it.
- let owner_proof = keys::mint_proof(&owner_key, &poll_htu, &unique("jti"), t1)?;
- let tokens = poll_once(
- &mut h,
- &public_inputs(&owner_id, &issued.device_code, Some(&owner_proof)),
- t1,
- )
- .await
- .map_err(|error| TestError::Unexpected(format!("owner redemption rejected: {}", error.code())))?;
- if tokens.token_type != "DPoP"
- || tokens.access_token.is_empty()
- || tokens.refresh_token.is_empty()
- || tokens.scope != "orders:read"
- {
- return Err(TestError::Unexpected(
- "the owner must still redeem the untouched grant".to_owned(),
- ));
- }
- Ok(())
- }
|