Skip to content

Commit 95267bc

Browse files
committed
feat(oidc-auth): add RFC 6750 §2.3 query parameter token support
Add opt-in `allow_query_token` config field (default false) to extract Bearer tokens from the `access_token` URL query parameter as a fallback when no Authorization header is present. Header always takes precedence.
1 parent 3c64c53 commit 95267bc

3 files changed

Lines changed: 147 additions & 6 deletions

File tree

docs/rulesets/functions/barbacane-validate-middleware-config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ const schemas = {
166166
clock_skew_seconds: { type: "integer", minimum: 0 },
167167
jwks_refresh_seconds: { type: "integer", minimum: 10 },
168168
timeout: { type: "number", minimum: 0 },
169+
allow_query_token: { type: "boolean" },
169170
},
170171
additionalProperties: false,
171172
},

plugins/oidc-auth/config-schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
"description": "HTTP timeout for discovery and JWKS calls (seconds)",
4242
"default": 5,
4343
"minimum": 0
44+
},
45+
"allow_query_token": {
46+
"type": "boolean",
47+
"description": "Allow token extraction from the access_token query parameter (RFC 6750 §2.3). Disabled by default — tokens in URLs risk leaking via logs and referer headers.",
48+
"default": false
4449
}
4550
},
4651
"additionalProperties": false

plugins/oidc-auth/src/lib.rs

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ pub struct OidcAuth {
4242
#[serde(default = "default_timeout")]
4343
timeout: f64,
4444

45+
/// Allow token extraction from the `access_token` query parameter
46+
/// (RFC 6750 §2.3). Disabled by default — tokens in URLs risk leaking
47+
/// via logs, referer headers, and browser history.
48+
#[serde(default)]
49+
allow_query_token: bool,
50+
4551
/// Cached OIDC discovery document.
4652
#[serde(skip)]
4753
discovery: Option<DiscoveryDoc>,
@@ -63,6 +69,28 @@ fn default_timeout() -> f64 {
6369
5.0
6470
}
6571

72+
/// Minimal percent-decoding for query parameter values (RFC 3986).
73+
fn percent_decode(input: &str) -> String {
74+
let mut out = Vec::with_capacity(input.len());
75+
let bytes = input.as_bytes();
76+
let mut i = 0;
77+
while i < bytes.len() {
78+
if bytes[i] == b'%' && i + 2 < bytes.len() {
79+
if let Ok(byte) = u8::from_str_radix(
80+
&input[i + 1..i + 3],
81+
16,
82+
) {
83+
out.push(byte);
84+
i += 3;
85+
continue;
86+
}
87+
}
88+
out.push(bytes[i]);
89+
i += 1;
90+
}
91+
String::from_utf8_lossy(&out).into_owned()
92+
}
93+
6694
// --- Internal types ---
6795

6896
/// Cached OIDC discovery document.
@@ -354,19 +382,36 @@ impl OidcAuth {
354382
Ok(parsed.claims)
355383
}
356384

357-
/// Extract Bearer token from Authorization header.
385+
/// Extract Bearer token from Authorization header, falling back to the
386+
/// `access_token` query parameter when `allow_query_token` is enabled
387+
/// (RFC 6750 §2.3).
358388
fn extract_token(&self, req: &Request) -> Result<String, OidcError> {
359-
let auth_header = req
389+
if let Some(auth_header) = req
360390
.headers
361391
.get("authorization")
362392
.or_else(|| req.headers.get("Authorization"))
363-
.ok_or(OidcError::MissingToken)?;
393+
{
394+
if !auth_header.starts_with("Bearer ") && !auth_header.starts_with("bearer ") {
395+
return Err(OidcError::InvalidAuthHeader);
396+
}
397+
return Ok(auth_header[7..].trim().to_string());
398+
}
364399

365-
if !auth_header.starts_with("Bearer ") && !auth_header.starts_with("bearer ") {
366-
return Err(OidcError::InvalidAuthHeader);
400+
// RFC 6750 §2.3 — query parameter fallback
401+
if self.allow_query_token {
402+
if let Some(query) = &req.query {
403+
for pair in query.split('&') {
404+
if let Some(value) = pair.strip_prefix("access_token=") {
405+
let token = percent_decode(value);
406+
if !token.is_empty() {
407+
return Ok(token);
408+
}
409+
}
410+
}
411+
}
367412
}
368413

369-
Ok(auth_header[7..].trim().to_string())
414+
Err(OidcError::MissingToken)
370415
}
371416

372417
/// Parse a JWT token into its components.
@@ -774,6 +819,7 @@ mod tests {
774819
jwks_refresh_seconds: 300,
775820
issuer_override: None,
776821
timeout: 5.0,
822+
allow_query_token: false,
777823
discovery: None,
778824
jwks_cache: None,
779825
}
@@ -892,6 +938,95 @@ mod tests {
892938
assert_eq!(token, "cap.token.here");
893939
}
894940

941+
// --- Query parameter token tests (RFC 6750 §2.3) ---
942+
943+
fn create_query_request(query: Option<&str>) -> Request {
944+
Request {
945+
method: "GET".to_string(),
946+
path: "/test".to_string(),
947+
headers: BTreeMap::new(),
948+
body: None,
949+
query: query.map(|q| q.to_string()),
950+
path_params: BTreeMap::new(),
951+
client_ip: "127.0.0.1".to_string(),
952+
}
953+
}
954+
955+
#[test]
956+
fn extract_token_query_param_when_enabled() {
957+
let mut config = create_test_config();
958+
config.allow_query_token = true;
959+
let req = create_query_request(Some("access_token=my.jwt.token&foo=bar"));
960+
let token = config.extract_token(&req).unwrap();
961+
assert_eq!(token, "my.jwt.token");
962+
}
963+
964+
#[test]
965+
fn extract_token_query_param_disabled_by_default() {
966+
let config = create_test_config();
967+
let req = create_query_request(Some("access_token=my.jwt.token"));
968+
let result = config.extract_token(&req);
969+
assert!(matches!(result, Err(OidcError::MissingToken)));
970+
}
971+
972+
#[test]
973+
fn extract_token_header_takes_precedence_over_query() {
974+
let mut config = create_test_config();
975+
config.allow_query_token = true;
976+
let mut headers = BTreeMap::new();
977+
headers.insert(
978+
"authorization".to_string(),
979+
"Bearer header.token.here".to_string(),
980+
);
981+
let req = Request {
982+
method: "GET".to_string(),
983+
path: "/test".to_string(),
984+
headers,
985+
body: None,
986+
query: Some("access_token=query.token.here".to_string()),
987+
path_params: BTreeMap::new(),
988+
client_ip: "127.0.0.1".to_string(),
989+
};
990+
let token = config.extract_token(&req).unwrap();
991+
assert_eq!(token, "header.token.here");
992+
}
993+
994+
#[test]
995+
fn extract_token_query_param_percent_encoded() {
996+
let mut config = create_test_config();
997+
config.allow_query_token = true;
998+
let req = create_query_request(Some("access_token=eyJ%2Btoken%3D"));
999+
let token = config.extract_token(&req).unwrap();
1000+
assert_eq!(token, "eyJ+token=");
1001+
}
1002+
1003+
#[test]
1004+
fn extract_token_query_param_empty_value() {
1005+
let mut config = create_test_config();
1006+
config.allow_query_token = true;
1007+
let req = create_query_request(Some("access_token="));
1008+
let result = config.extract_token(&req);
1009+
assert!(matches!(result, Err(OidcError::MissingToken)));
1010+
}
1011+
1012+
#[test]
1013+
fn extract_token_query_param_missing() {
1014+
let mut config = create_test_config();
1015+
config.allow_query_token = true;
1016+
let req = create_query_request(Some("foo=bar&baz=qux"));
1017+
let result = config.extract_token(&req);
1018+
assert!(matches!(result, Err(OidcError::MissingToken)));
1019+
}
1020+
1021+
#[test]
1022+
fn extract_token_query_param_no_query_string() {
1023+
let mut config = create_test_config();
1024+
config.allow_query_token = true;
1025+
let req = create_query_request(None);
1026+
let result = config.extract_token(&req);
1027+
assert!(matches!(result, Err(OidcError::MissingToken)));
1028+
}
1029+
8951030
// --- JWT parsing tests ---
8961031

8971032
#[test]

0 commit comments

Comments
 (0)