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