Skip to main content

sign_in_with_apple/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(clippy::pedantic)]
3#![deny(clippy::unwrap_used)]
4#![deny(clippy::panic)]
5#![deny(clippy::perf)]
6#![deny(clippy::nursery)]
7#![deny(clippy::match_like_matches_macro)]
8#![allow(clippy::module_name_repetitions)]
9#![allow(clippy::missing_errors_doc)]
10
11mod data;
12mod error;
13
14pub use data::{Claims, ClaimsServer2Server};
15pub use error::Error;
16
17use data::{KeyComponents, APPLE_ISSUER, APPLE_PUB_KEYS};
18use error::Result;
19use hyper::{body, Body, Client, Request};
20use hyper_tls::HttpsConnector;
21use jsonwebtoken::{
22	self, decode, decode_header, DecodingKey, TokenData, Validation,
23};
24use serde::de::DeserializeOwned;
25use std::collections::HashMap;
26
27//TODO: put verification into a struct and only fetch apple keys once in the beginning
28async fn fetch_apple_keys() -> Result<HashMap<String, KeyComponents>>
29{
30	let https = HttpsConnector::new();
31	let client = Client::builder().build::<_, hyper::Body>(https);
32
33	let req = Request::builder()
34		.method("GET")
35		.uri(APPLE_PUB_KEYS)
36		.body(Body::from(""))?;
37
38	let resp = client.request(req).await?;
39	let buf = body::to_bytes(resp).await?;
40
41	let mut resp: HashMap<String, Vec<KeyComponents>> =
42		serde_json::from_slice(&buf)?;
43
44	resp.remove("keys").map_or(Err(Error::AppleKeys), |res| {
45		Ok(res
46			.into_iter()
47			.map(|val| (val.kid.clone(), val))
48			.collect::<HashMap<String, KeyComponents>>())
49	})
50}
51
52/// decoe token with optional expiry validation
53pub async fn decode_token<T: DeserializeOwned>(
54	token: String,
55	ignore_expire: bool,
56) -> Result<TokenData<T>> {
57	let header = decode_header(token.as_str())?;
58
59	let kid = match header.kid {
60		Some(k) => k,
61		None => return Err(Error::KidNotFound),
62	};
63
64	let pubkeys = fetch_apple_keys().await?;
65
66	let pubkey = match pubkeys.get(&kid) {
67		Some(key) => key,
68		None => return Err(Error::KeyNotFound),
69	};
70
71	let mut val = Validation::new(header.alg);
72	val.validate_exp = !ignore_expire;
73	let token_data = decode::<T>(
74		token.as_str(),
75		&DecodingKey::from_rsa_components(&pubkey.n, &pubkey.e)
76			.unwrap(),
77		&val,
78	)?;
79
80	Ok(token_data)
81}
82
83pub async fn validate(
84	client_id: String,
85	token: String,
86	ignore_expire: bool,
87) -> Result<TokenData<Claims>> {
88	let token_data =
89		decode_token::<Claims>(token, ignore_expire).await?;
90
91	//TODO: can this be validated alread in `decode_token`?
92	if token_data.claims.iss != APPLE_ISSUER {
93		return Err(Error::IssClaimMismatch);
94	}
95
96	if token_data.claims.sub != client_id {
97		return Err(Error::ClientIdMismatch);
98	}
99	Ok(token_data)
100}
101
102/// allows to check whether the `validate` result was errored because of an expired signature
103#[must_use]
104pub fn is_expired(
105	validate_result: &Result<TokenData<Claims>>,
106) -> bool {
107	if let Err(Error::Jwt(error)) = validate_result {
108		return matches!(
109			error.kind(),
110			jsonwebtoken::errors::ErrorKind::ExpiredSignature
111		);
112	}
113
114	false
115}
116
117#[cfg(test)]
118mod tests {
119	use crate::{
120		decode_token, is_expired, validate, ClaimsServer2Server,
121		Error,
122	};
123
124	#[tokio::test]
125	async fn validate_test() -> std::result::Result<(), Error> {
126		let user_token =
127			"001026.16112b36378440d995af22b268f00984.1744";
128		let token = "eyJraWQiOiJZdXlYb1kiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmdhbWVyb2FzdGVycy5zdGFjazQiLCJleHAiOjE2MTQ1MTc1OTQsImlhdCI6MTYxNDQzMTE5NCwic3ViIjoiMDAxMDI2LjE2MTEyYjM2Mzc4NDQwZDk5NWFmMjJiMjY4ZjAwOTg0LjE3NDQiLCJjX2hhc2giOiJNNVVDdW5GdTFKNjdhdVE2LXEta093IiwiZW1haWwiOiJ6ZGZ1N2p0dXVzQHByaXZhdGVyZWxheS5hcHBsZWlkLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjoidHJ1ZSIsImlzX3ByaXZhdGVfZW1haWwiOiJ0cnVlIiwiYXV0aF90aW1lIjoxNjE0NDMxMTk0LCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.GuMJfVbnEvqppwwHFZjn3GDJtB4c4rl7C4PZzyDsdyiuXcFcXq52Ti0WSJBsqtfyT2dXvYxVxebHtONSQha_9DiM5qfYTZbpDDlIXrOMy1fkfStocold_wHWavofIpoJQVUMj45HLHtjixiNE903Pho6eY2UjEUjB3aFe8txuFIMv2JsaMCYzG4-e632FKBn63SroCkLc-8b4EVV4iYqnC5AfZArXhVjUevhhlaBH0E8Az2OGEe74U2WgBvMXEilmd62Ek-uInnrpJRgYQfYXvehQ1yT3aMiIgJICTQFMDdL1KAvs6mc081lNJLFYvViWlMH-Y7E5ajtUiMApiNYsg";
129
130		let result =
131			validate(user_token.to_string(), token.to_string(), true)
132				.await?;
133
134		assert_eq!(result.claims.sub, user_token);
135		assert_eq!(result.claims.aud, "com.gameroasters.stack4");
136
137		Ok(())
138	}
139
140	#[tokio::test]
141	async fn validate_no_email() {
142		let token = "eyJraWQiOiJlWGF1bm1MIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmdhbWVyb2FzdGVycy5zdGFjazQiLCJleHAiOjE2MzA4Mjc4MzAsImlhdCI6MTYzMDc0MTQzMCwic3ViIjoiMDAxMDI2LjE2MTEyYjM2Mzc4NDQwZDk5NWFmMjJiMjY4ZjAwOTg0LjE3NDQiLCJjX2hhc2giOiI0QjZKWTU4TmstVUJsY3dMa2VLc2lnIiwiYXV0aF90aW1lIjoxNjMwNzQxNDMwLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.iW0xk__fPD0mlh9UU-vh9VnR8yekWq64sl5re5d7UmDJxb1Fzk1Kca-hkA_Ka1LhSmKADdFW0DYEZhckqh49DgFtFdx6hM9t7guK3yrvBglhF5LAyb8NR028npxioLTTIgP_aR6Bpy5AyLQrU-yYEx2WTPYV5ln9n8vW154gZKRyl2KBlj9fS11BL_X1UFbFrL21GG_iPbB4qt5ywwTPoJ-diGN5JQzP5fk4yU4e4YmHhxJrT0NTTux2mB3lGJLa6YN-JYe_BuVV9J-sg_2r_ugTOUp3xQpfntu8xgQrY5W0oPxAPM4sibNLsye2kgPYYxfRYowc0JIjOcOd_JHDbQ";
143
144		validate(
145			"001026.16112b36378440d995af22b268f00984.1744".into(),
146			token.to_string(),
147			true,
148		)
149		.await
150		.unwrap();
151	}
152
153	#[tokio::test]
154	async fn validate_expired() {
155		let token = "eyJraWQiOiJlWGF1bm1MIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmdhbWVyb2FzdGVycy5zdGFjazQiLCJleHAiOjE2MzA4Mjc4MzAsImlhdCI6MTYzMDc0MTQzMCwic3ViIjoiMDAxMDI2LjE2MTEyYjM2Mzc4NDQwZDk5NWFmMjJiMjY4ZjAwOTg0LjE3NDQiLCJjX2hhc2giOiI0QjZKWTU4TmstVUJsY3dMa2VLc2lnIiwiYXV0aF90aW1lIjoxNjMwNzQxNDMwLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.iW0xk__fPD0mlh9UU-vh9VnR8yekWq64sl5re5d7UmDJxb1Fzk1Kca-hkA_Ka1LhSmKADdFW0DYEZhckqh49DgFtFdx6hM9t7guK3yrvBglhF5LAyb8NR028npxioLTTIgP_aR6Bpy5AyLQrU-yYEx2WTPYV5ln9n8vW154gZKRyl2KBlj9fS11BL_X1UFbFrL21GG_iPbB4qt5ywwTPoJ-diGN5JQzP5fk4yU4e4YmHhxJrT0NTTux2mB3lGJLa6YN-JYe_BuVV9J-sg_2r_ugTOUp3xQpfntu8xgQrY5W0oPxAPM4sibNLsye2kgPYYxfRYowc0JIjOcOd_JHDbQ";
156
157		let res = validate(
158			"001026.16112b36378440d995af22b268f00984.1744".into(),
159			token.to_string(),
160			false,
161		)
162		.await;
163
164		assert!(is_expired(&res));
165	}
166
167	#[tokio::test]
168	async fn test_server_to_server_payload() {
169		let token = "eyJraWQiOiJlWGF1bm1MIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmdhbWVyb2FzdGVycy5zdGFjazQiLCJleHAiOjE2MzAxNzE4MTIsImlhdCI6MTYzMDA4NTQxMiwianRpIjoiQjk0T2REMDNwRnNhWWFOLUZ0djdtQSIsImV2ZW50cyI6IntcInR5cGVcIjpcImVtYWlsLWRpc2FibGVkXCIsXCJzdWJcIjpcIjAwMTAyNi4xNjExMmIzNjM3ODQ0MGQ5OTVhZjIyYjI2OGYwMDk4NC4xNzQ0XCIsXCJldmVudF90aW1lXCI6MTYzMDA4NTQwMzY0OCxcImVtYWlsXCI6XCJ6ZGZ1N2p0dXVzQHByaXZhdGVyZWxheS5hcHBsZWlkLmNvbVwiLFwiaXNfcHJpdmF0ZV9lbWFpbFwiOlwidHJ1ZVwifSJ9.SSdUM88GHqrS0QXHtaehbPxLQkAB3s1-qzcy3i2iRoSCzDhA1Q3o_FhiCbqOsbiPDOQ9aA1Z8-oAz1p3-TMfHy6QdIs1vLxBmNTe5IazNJw_7wwDZG2nr-bsKPUQldE--tK1EUFXQqQxQbfjJJE0JFEwPib2rmnb-t0mRopKMx2wg3CUlI64BHI2O8giGCbWB7UbJs2BpcUuapVShCIR7Eqxy0_ud81CUDjKzZK2CcmSRGDIk8g9pRqOHmPUFMOrDjj6_hUR9mf-xCrCedoC9f05z_yKD026A4gWGFn4pxTP8-uDTRPxcONax_vnQHBUDigYi8HXuzWorTx2ORPjaw";
170
171		let result = decode_token::<ClaimsServer2Server>(
172			token.to_string(),
173			true,
174		)
175		.await
176		.unwrap();
177
178		assert_eq!(result.claims.aud, "com.gameroasters.stack4");
179		assert_eq!(
180			result.claims.events.sub,
181			"001026.16112b36378440d995af22b268f00984.1744"
182		);
183
184		println!("{:?}", result);
185	}
186}