Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

319 linesCodeBlameRaw
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
13use base64::Engine;
14use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
15use serde_json::Value;
16use worker::{Fetch, Headers, Method, Request, RequestInit, Url};
17
18/// How far apart g1t's clock and the provider's may be.
19const SKEW_SECONDS: u64 = 3 * 60;
20const 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)]
24pub 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.
35pub 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`.
40pub 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.
64pub 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.
77pub 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
86pub 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
95pub 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
116async 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
130pub 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.
141pub 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
176fn 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.
183pub 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.
194pub 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.
224pub 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)]
237mod 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}