| 1 | //! OpenID Connect and OAuth 2.0 for single sign-on: the authorization code |
| 2 | //! flow, with PKCE (S256) and, for OpenID Connect, a nonce. |
| 3 | //! |
| 4 | //! OpenID Connect reads the issuer's discovery document for its endpoints |
| 5 | //! and keys, and the ID token from the token endpoint. The token's |
| 6 | //! signature is verified with the issuer's published keys (jwt.rs) before |
| 7 | //! anything in it is read, and then its claims are checked: issuer, |
| 8 | //! audience (and azp), expiry, issued-at and nonce. Plain OAuth 2.0 |
| 9 | //! has no ID token: the person's id and address are claims of the userinfo |
| 10 | //! endpoint's answer, named in the connection. |
| 11 | |
| 12 | use base64::Engine; |
| 13 | use base64::engine::general_purpose::STANDARD; |
| 14 | use serde_json::Value; |
| 15 | use worker::{Fetch, Headers, Method, Request, RequestInit, Url}; |
| 16 | |
| 17 | /// How far apart g1t's clock and the provider's may be. |
| 18 | const SKEW_SECONDS: u64 = 3 * 60; |
| 19 | const UNREACHABLE: &str = "g1t could not reach the identity provider. Try again in a minute."; |
| 20 | |
| 21 | /// Where a provider takes each step. |
| 22 | #[derive(Debug, PartialEq, Eq)] |
| 23 | pub struct Endpoints { |
| 24 | pub authorize: String, |
| 25 | pub token: String, |
| 26 | pub userinfo: Option<String>, |
| 27 | /// OpenID Connect: the issuer, as its discovery document says it. |
| 28 | pub issuer: Option<String>, |
| 29 | /// OpenID Connect: where the issuer publishes its signing keys. |
| 30 | pub jwks: Option<String>, |
| 31 | /// Whether the client authenticates with HTTP Basic (else in the form). |
| 32 | pub basic: bool, |
| 33 | } |
| 34 | |
| 35 | /// An address g1t will send people or requests to: https only. |
| 36 | pub fn https(url: &str) -> Option<Url> { |
| 37 | Url::parse(url.trim()).ok().filter(|url| url.scheme() == "https" && url.host_str().is_some()) |
| 38 | } |
| 39 | |
| 40 | /// The endpoints in a discovery document, for `issuer`. |
| 41 | pub fn endpoints_from(document: &Value, issuer: &str) -> Result<Endpoints, String> { |
| 42 | let said = document["issuer"].as_str().unwrap_or_default(); |
| 43 | if said.trim_end_matches('/') != issuer.trim_end_matches('/') { |
| 44 | return Err(format!("The provider's discovery document names the issuer {said:?}, not {issuer:?}. Use the issuer exactly as it says.")); |
| 45 | } |
| 46 | let endpoint = |field: &str| document[field].as_str().filter(|url| https(url).is_some()).map(str::to_owned); |
| 47 | let (Some(authorize), Some(token)) = (endpoint("authorization_endpoint"), endpoint("token_endpoint")) else { |
| 48 | return Err("The provider's discovery document has no https authorization and token endpoints.".to_owned()); |
| 49 | }; |
| 50 | let Some(jwks) = endpoint("jwks_uri") else { |
| 51 | return Err("The provider's discovery document publishes no signing keys (jwks_uri), so its ID tokens cannot be verified.".to_owned()); |
| 52 | }; |
| 53 | let methods: Vec<&str> = document["token_endpoint_auth_methods_supported"] |
| 54 | .as_array() |
| 55 | .map(|methods| methods.iter().filter_map(Value::as_str).collect()) |
| 56 | .unwrap_or_default(); |
| 57 | Ok(Endpoints { |
| 58 | authorize, |
| 59 | token, |
| 60 | userinfo: endpoint("userinfo_endpoint"), |
| 61 | issuer: Some(said.to_owned()), |
| 62 | jwks: Some(jwks), |
| 63 | // Basic is the default when the provider lists none. |
| 64 | basic: methods.is_empty() || methods.contains(&"client_secret_basic"), |
| 65 | }) |
| 66 | } |
| 67 | |
| 68 | /// Reads an issuer's discovery document. |
| 69 | pub async fn discover(issuer: &str) -> Result<Endpoints, String> { |
| 70 | let Some(base) = https(issuer) else { |
| 71 | return Err("The issuer must be an https address.".to_owned()); |
| 72 | }; |
| 73 | let url = format!("{}/.well-known/openid-configuration", base.as_str().trim_end_matches('/')); |
| 74 | let (status, document) = send(Method::Get, &url, Headers::new(), None).await.map_err(|_| UNREACHABLE.to_owned())?; |
| 75 | if status != 200 { |
| 76 | return Err(format!("The provider's discovery document ({url}) answered {status}. Check the issuer.")); |
| 77 | } |
| 78 | endpoints_from(&document, issuer) |
| 79 | } |
| 80 | |
| 81 | /// Reads the issuer's signing keys. |
| 82 | pub async fn keys(url: &str) -> Result<Value, String> { |
| 83 | let (status, keys) = send(Method::Get, url, Headers::new(), None).await.map_err(|_| UNREACHABLE.to_owned())?; |
| 84 | if status != 200 || !keys["keys"].is_array() { |
| 85 | return Err(format!("The provider's signing keys ({url}) answered {status}.")); |
| 86 | } |
| 87 | Ok(keys) |
| 88 | } |
| 89 | |
| 90 | /// The scopes to ask for: as set, always with `openid` for OpenID Connect. |
| 91 | pub fn scopes(set: Option<&str>, openid: bool) -> String { |
| 92 | let set = set.map(str::trim).filter(|s| !s.is_empty()); |
| 93 | let mut scopes: Vec<&str> = set.unwrap_or(if openid { "openid email profile" } else { "" }).split_whitespace().collect(); |
| 94 | if openid && !scopes.contains(&"openid") { |
| 95 | scopes.insert(0, "openid"); |
| 96 | } |
| 97 | scopes.join(" ") |
| 98 | } |
| 99 | |
| 100 | pub struct Authorize<'a> { |
| 101 | pub client_id: &'a str, |
| 102 | pub redirect_uri: &'a str, |
| 103 | pub scopes: &'a str, |
| 104 | pub state: &'a str, |
| 105 | pub nonce: Option<&'a str>, |
| 106 | pub challenge: &'a str, |
| 107 | } |
| 108 | |
| 109 | pub fn authorize_url(endpoint: &str, a: &Authorize) -> Option<String> { |
| 110 | let mut url = https(endpoint)?; |
| 111 | { |
| 112 | let mut query = url.query_pairs_mut(); |
| 113 | query |
| 114 | .append_pair("response_type", "code") |
| 115 | .append_pair("client_id", a.client_id) |
| 116 | .append_pair("redirect_uri", a.redirect_uri) |
| 117 | .append_pair("state", a.state) |
| 118 | .append_pair("code_challenge", a.challenge) |
| 119 | .append_pair("code_challenge_method", "S256"); |
| 120 | if !a.scopes.is_empty() { |
| 121 | query.append_pair("scope", a.scopes); |
| 122 | } |
| 123 | if let Some(nonce) = a.nonce { |
| 124 | query.append_pair("nonce", nonce); |
| 125 | } |
| 126 | } |
| 127 | Some(url.to_string()) |
| 128 | } |
| 129 | |
| 130 | async fn send(method: Method, url: &str, headers: Headers, body: Option<String>) -> worker::Result<(u16, Value)> { |
| 131 | headers.set("user-agent", "g1t (+https://g1t.sh)")?; |
| 132 | headers.set("accept", "application/json")?; |
| 133 | let mut init = RequestInit::new(); |
| 134 | if let Some(body) = body { |
| 135 | headers.set("content-type", "application/x-www-form-urlencoded")?; |
| 136 | init.with_body(Some(body.into())); |
| 137 | } |
| 138 | init.with_method(method).with_headers(headers); |
| 139 | let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?; |
| 140 | let text = response.text().await.unwrap_or_default(); |
| 141 | Ok((response.status_code(), serde_json::from_str(&text).unwrap_or(Value::Null))) |
| 142 | } |
| 143 | |
| 144 | pub struct Exchange<'a> { |
| 145 | pub token_url: &'a str, |
| 146 | pub basic: bool, |
| 147 | pub client_id: &'a str, |
| 148 | pub client_secret: &'a str, |
| 149 | pub code: &'a str, |
| 150 | pub redirect_uri: &'a str, |
| 151 | pub verifier: &'a str, |
| 152 | } |
| 153 | |
| 154 | /// Trades the code for tokens. |
| 155 | pub async fn exchange(a: &Exchange<'_>) -> Result<Value, String> { |
| 156 | let mut form = url_form(&[ |
| 157 | ("grant_type", "authorization_code"), |
| 158 | ("code", a.code), |
| 159 | ("redirect_uri", a.redirect_uri), |
| 160 | ("code_verifier", a.verifier), |
| 161 | ]); |
| 162 | let headers = Headers::new(); |
| 163 | if a.basic { |
| 164 | // RFC 6749 2.3.1: each form-encoded, then joined. |
| 165 | let user = url_form(&[("", a.client_id)]); |
| 166 | let pass = url_form(&[("", a.client_secret)]); |
| 167 | let credentials = format!("{}:{}", &user[1..], &pass[1..]); |
| 168 | headers |
| 169 | .set("authorization", &format!("Basic {}", STANDARD.encode(credentials))) |
| 170 | .map_err(|_| UNREACHABLE.to_owned())?; |
| 171 | } else { |
| 172 | form.push('&'); |
| 173 | form.push_str(&url_form(&[("client_id", a.client_id), ("client_secret", a.client_secret)])); |
| 174 | } |
| 175 | let (status, answer) = send(Method::Post, a.token_url, headers, Some(form)).await.map_err(|_| UNREACHABLE.to_owned())?; |
| 176 | if status != 200 || answer.get("error").is_some() { |
| 177 | let error = answer["error"].as_str().unwrap_or("no answer"); |
| 178 | worker::console_log!("sso token request refused: {status} {error}"); |
| 179 | return Err(match error { |
| 180 | "invalid_client" | "unauthorized_client" => { |
| 181 | "The identity provider did not accept g1t's client id and secret. Check them in the single sign-on settings.".to_owned() |
| 182 | } |
| 183 | "invalid_grant" => "The identity provider did not accept the sign-in: it may have taken too long, or the callback URL does not match. Start again.".to_owned(), |
| 184 | _ => format!("The identity provider did not complete the sign-in ({error})."), |
| 185 | }); |
| 186 | } |
| 187 | Ok(answer) |
| 188 | } |
| 189 | |
| 190 | fn url_form(pairs: &[(&str, &str)]) -> String { |
| 191 | let mut url = Url::parse("https://form.invalid/").expect("a fixed URL parses"); |
| 192 | url.query_pairs_mut().extend_pairs(pairs); |
| 193 | url.query().unwrap_or_default().to_owned() |
| 194 | } |
| 195 | |
| 196 | /// Reads the userinfo endpoint with the access token. |
| 197 | pub async fn userinfo(url: &str, access_token: &str) -> Result<Value, String> { |
| 198 | let headers = Headers::new(); |
| 199 | headers.set("authorization", &format!("Bearer {access_token}")).map_err(|_| UNREACHABLE.to_owned())?; |
| 200 | let (status, answer) = send(Method::Get, url, headers, None).await.map_err(|_| UNREACHABLE.to_owned())?; |
| 201 | if status != 200 || !answer.is_object() { |
| 202 | return Err(format!("The identity provider's userinfo endpoint answered {status}.")); |
| 203 | } |
| 204 | Ok(answer) |
| 205 | } |
| 206 | |
| 207 | /// The claims of an ID token: its signature verified with the issuer's |
| 208 | /// keys (jwt.rs), then what it says checked against this sign-in. |
| 209 | pub fn id_token_claims(id_token: &str, keys: &Value, issuer: &str, client_id: &str, nonce: &str, now_seconds: u64) -> Result<Value, String> { |
| 210 | let claims = super::jwt::verify(id_token, keys)?; |
| 211 | check_claims(claims, issuer, client_id, nonce, now_seconds) |
| 212 | } |
| 213 | |
| 214 | /// Whether a verified ID token's claims say it is for this sign-in. |
| 215 | pub fn check_claims(claims: Value, issuer: &str, client_id: &str, nonce: &str, now_seconds: u64) -> Result<Value, String> { |
| 216 | // Google issues ID tokens as either form of its issuer (its OpenID |
| 217 | // Connect docs say so); no other issuer is taken without its scheme. |
| 218 | let google = issuer == "https://accounts.google.com" && claims["iss"].as_str() == Some("accounts.google.com"); |
| 219 | if claims["iss"].as_str() != Some(issuer) && !google { |
| 220 | return Err(format!("The ID token was issued by {:?}, not {issuer:?}.", claims["iss"].as_str().unwrap_or_default())); |
| 221 | } |
| 222 | let audiences: Vec<&str> = match &claims["aud"] { |
| 223 | Value::String(one) => vec![one.as_str()], |
| 224 | Value::Array(many) => many.iter().filter_map(Value::as_str).collect(), |
| 225 | _ => vec![], |
| 226 | }; |
| 227 | if !audiences.contains(&client_id) || (audiences.len() > 1 && claims["azp"].as_str().is_some_and(|azp| azp != client_id)) { |
| 228 | return Err("The ID token is not for g1t's client id.".to_owned()); |
| 229 | } |
| 230 | if claims["exp"].as_u64().is_none_or(|exp| exp + SKEW_SECONDS <= now_seconds) { |
| 231 | return Err("The ID token has expired. Start again.".to_owned()); |
| 232 | } |
| 233 | if claims["iat"].as_u64().is_some_and(|iat| iat > now_seconds + SKEW_SECONDS) { |
| 234 | return Err("The ID token was issued in the future: check the identity provider's clock.".to_owned()); |
| 235 | } |
| 236 | if claims["nonce"].as_str() != Some(nonce) { |
| 237 | return Err("The ID token answers another sign-in. Start again.".to_owned()); |
| 238 | } |
| 239 | Ok(claims) |
| 240 | } |
| 241 | |
| 242 | /// A claim by name, or by a dotted path (`data.id`), as text: ids are |
| 243 | /// sometimes numbers. |
| 244 | pub fn claim(value: &Value, path: &str) -> Option<String> { |
| 245 | let mut at = value; |
| 246 | for part in path.split('.') { |
| 247 | at = at.get(part)?; |
| 248 | } |
| 249 | match at { |
| 250 | Value::String(text) => Some(text.trim().to_owned()).filter(|t| !t.is_empty()), |
| 251 | Value::Number(number) => Some(number.to_string()), |
| 252 | _ => None, |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | #[cfg(test)] |
| 257 | mod tests { |
| 258 | use super::*; |
| 259 | use serde_json::json; |
| 260 | |
| 261 | #[test] |
| 262 | fn discovery_must_name_the_issuer_and_https_endpoints() { |
| 263 | let document = json!({ |
| 264 | "issuer": "https://acme.okta.com", |
| 265 | "authorization_endpoint": "https://acme.okta.com/oauth2/v1/authorize", |
| 266 | "token_endpoint": "https://acme.okta.com/oauth2/v1/token", |
| 267 | "userinfo_endpoint": "https://acme.okta.com/oauth2/v1/userinfo", |
| 268 | "jwks_uri": "https://acme.okta.com/oauth2/v1/keys", |
| 269 | "token_endpoint_auth_methods_supported": ["client_secret_post"], |
| 270 | }); |
| 271 | let found = endpoints_from(&document, "https://acme.okta.com/").unwrap(); |
| 272 | assert_eq!(found.issuer.as_deref(), Some("https://acme.okta.com")); |
| 273 | assert!(!found.basic); |
| 274 | assert!(endpoints_from(&document, "https://globex.okta.com").unwrap_err().contains("names the issuer")); |
| 275 | let plain = json!({ "issuer": "https://a.test", "authorization_endpoint": "http://a.test/auth", "token_endpoint": "https://a.test/token" }); |
| 276 | assert!(endpoints_from(&plain, "https://a.test").is_err()); |
| 277 | let defaults = json!({ "issuer": "https://a.test", "authorization_endpoint": "https://a.test/auth", "token_endpoint": "https://a.test/token", "jwks_uri": "https://a.test/keys" }); |
| 278 | assert!(endpoints_from(&defaults, "https://a.test").unwrap().basic); |
| 279 | let keyless = json!({ "issuer": "https://a.test", "authorization_endpoint": "https://a.test/auth", "token_endpoint": "https://a.test/token" }); |
| 280 | assert!(endpoints_from(&keyless, "https://a.test").unwrap_err().contains("jwks_uri")); |
| 281 | } |
| 282 | |
| 283 | #[test] |
| 284 | fn openid_is_always_asked_for() { |
| 285 | assert_eq!(scopes(None, true), "openid email profile"); |
| 286 | assert_eq!(scopes(Some("email groups"), true), "openid email groups"); |
| 287 | assert_eq!(scopes(Some(" "), false), ""); |
| 288 | assert_eq!(scopes(Some("read:user user:email"), false), "read:user user:email"); |
| 289 | } |
| 290 | |
| 291 | #[test] |
| 292 | fn the_authorize_url_carries_pkce_and_the_nonce() { |
| 293 | let url = authorize_url( |
| 294 | "https://idp.test/authorize?tenant=1", |
| 295 | &Authorize { client_id: "c", redirect_uri: "https://g1t.sh/auth/sso/acme/callback", scopes: "openid", state: "s", nonce: Some("n"), challenge: "x" }, |
| 296 | ) |
| 297 | .unwrap(); |
| 298 | let pairs: std::collections::HashMap<String, String> = Url::parse(&url).unwrap().query_pairs().into_owned().collect(); |
| 299 | assert_eq!(pairs["tenant"], "1"); |
| 300 | assert_eq!(pairs["code_challenge_method"], "S256"); |
| 301 | assert_eq!(pairs["nonce"], "n"); |
| 302 | assert_eq!(pairs["redirect_uri"], "https://g1t.sh/auth/sso/acme/callback"); |
| 303 | assert!(authorize_url("http://idp.test/authorize", &Authorize { client_id: "c", redirect_uri: "r", scopes: "", state: "s", nonce: None, challenge: "x" }).is_none()); |
| 304 | } |
| 305 | |
| 306 | #[test] |
| 307 | fn an_id_token_must_be_for_this_sign_in() { |
| 308 | let now = 1_800_000_000; |
| 309 | let good = json!({ "iss": "https://idp.test", "aud": "client", "exp": now + 60, "nonce": "n", "sub": "u1", "email": "ada@acme.test" }); |
| 310 | let claims = check_claims(good.clone(), "https://idp.test", "client", "n", now).unwrap(); |
| 311 | assert_eq!(claim(&claims, "sub").as_deref(), Some("u1")); |
| 312 | let with = |field: &str, value: Value| { |
| 313 | let mut claims = good.clone(); |
| 314 | claims[field] = value; |
| 315 | check_claims(claims, "https://idp.test", "client", "n", now) |
| 316 | }; |
| 317 | assert!(with("iss", json!("https://evil.test")).unwrap_err().contains("issued by")); |
| 318 | assert!(with("aud", json!("other")).unwrap_err().contains("client id")); |
| 319 | assert!(with("aud", json!(["other", "client"])).is_ok()); |
| 320 | assert!(with("exp", json!(now - 600)).unwrap_err().contains("expired")); |
| 321 | assert!(with("nonce", json!("m")).unwrap_err().contains("another sign-in")); |
| 322 | assert!(with("iat", json!(now + 3600)).unwrap_err().contains("future")); |
| 323 | let google = json!({ "iss": "accounts.google.com", "aud": "client", "exp": now + 60, "nonce": "n", "sub": "1" }); |
| 324 | assert!(check_claims(google.clone(), "https://accounts.google.com", "client", "n", now).is_ok()); |
| 325 | assert!(check_claims(google, "https://idp.test", "client", "n", now).is_err()); |
| 326 | assert!(id_token_claims("not.a-token", &json!({ "keys": [] }), "https://idp.test", "client", "n", now).is_err()); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn a_signed_id_token_is_verified_then_checked() { |
| 331 | let fixtures: Value = serde_json::from_str(include_str!("jwt_fixtures.json")).unwrap(); |
| 332 | let token = fixtures["tokens"]["rs256"].as_str().unwrap(); |
| 333 | let claims = id_token_claims(token, &fixtures["jwks"], "https://idp.example.test", "client", "n", 1_800_000_100).unwrap(); |
| 334 | assert_eq!(claim(&claims, "email").as_deref(), Some("ada@acme.test")); |
| 335 | // Signed, but for another sign-in. |
| 336 | assert!(id_token_claims(token, &fixtures["jwks"], "https://idp.example.test", "client", "other", 1_800_000_100).is_err()); |
| 337 | // Not signed with the issuer's keys. |
| 338 | assert!(id_token_claims(token, &json!({ "keys": [fixtures["jwks"]["keys"][1].clone()] }), "https://idp.example.test", "client", "n", 1_800_000_100).is_err()); |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn claims_are_found_by_path_and_read_as_text() { |
| 343 | let info = json!({ "id": 42, "data": { "login": "ada" }, "blank": "" }); |
| 344 | assert_eq!(claim(&info, "id").as_deref(), Some("42")); |
| 345 | assert_eq!(claim(&info, "data.login").as_deref(), Some("ada")); |
| 346 | assert_eq!(claim(&info, "blank"), None); |
| 347 | assert_eq!(claim(&info, "missing"), None); |
| 348 | } |
| 349 | |
| 350 | #[test] |
| 351 | fn form_values_are_encoded() { |
| 352 | assert_eq!(url_form(&[("a", "b c"), ("d", "&=")]), "a=b+c&d=%26%3D"); |
| 353 | } |
| 354 | } |