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.

354 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 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
12use base64::Engine;
13use base64::engine::general_purpose::STANDARD;
14use serde_json::Value;
15use worker::{Fetch, Headers, Method, Request, RequestInit, Url};
16
17/// How far apart g1t's clock and the provider's may be.
18const SKEW_SECONDS: u64 = 3 * 60;
19const 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)]
23pub 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.
36pub 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`.
41pub 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.
69pub 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.
82pub 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.
91pub 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
100pub 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
109pub 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
130async 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
144pub 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.
155pub 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
190fn 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.
197pub 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.
209pub 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.
215pub 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.
244pub 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)]
257mod 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}