ocre/oauth.rs
1//! "Sign in with GitHub / Google": the OAuth 2.0 authorization code flow with PKCE.
2//!
3//! `ocre g auth --oauth github` (or `google`) generates the routes that use
4//! this module (`src/oauth.rs` in the app): `GET /auth/github` redirects to
5//! the provider with a random `state` and a PKCE challenge kept in the
6//! session; `GET /auth/github/callback` checks the state, trades the code
7//! for an access token ([`exchange_code`]), reads the user's [`Profile`]
8//! ([`profile`]) and signs the matching user in.
9//!
10//! Each provider needs an OAuth app registered with it; its client id and
11//! secret are Worker secrets named after the provider (`GITHUB_CLIENT_ID`,
12//! `GITHUB_CLIENT_SECRET`, see [`Provider::client_id_secret`]), in
13//! `.dev.vars` for `ocre dev` and uploaded with `ocre secrets push NAME --file .prod.vars`.
14//!
15//! # Free plan
16//!
17//! A sign-in makes 2 subrequests (Google) or 3 (GitHub: user and emails) out
18//! of the 50 a free-plan request may make, and a few milliseconds of CPU; no
19//! D1, KV or queue operation here (the app then reads and writes its users).
20//!
21//! ```
22//! use ocre::oauth::{GITHUB, Pkce, authorize_url};
23//!
24//! let pkce = Pkce::new();
25//! let state = ocre::token::generate();
26//! let url = authorize_url(&GITHUB, "client-id", "https://app.example.com/auth/github/callback", &state, &pkce.challenge);
27//! assert!(url.starts_with("https://github.com/login/oauth/authorize?response_type=code&client_id=client-id"));
28//! ```
29
30use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
31use serde::Deserialize;
32use sha2::{Digest as _, Sha256};
33
34pub use crate::runtime::oauth::{exchange_code, profile};
35use crate::{Error, Result};
36
37/// An OAuth 2.0 provider: its endpoints and the scopes Ocre asks for.
38///
39/// Use [`GITHUB`] or [`GOOGLE`]; [`provider`] finds one by name.
40///
41/// # Examples
42///
43/// ```
44/// let github = ocre::oauth::provider("github").unwrap();
45/// assert_eq!(github.token_url, "https://github.com/login/oauth/access_token");
46/// assert_eq!(github.client_id_secret, "GITHUB_CLIENT_ID");
47/// ```
48#[derive(Debug, PartialEq, Eq)]
49pub struct Provider {
50 /// Lowercase name, used in routes (`/auth/github`) and stored with identities.
51 pub name: &'static str,
52 /// Where the browser is sent to sign in and approve the app.
53 pub authorize_url: &'static str,
54 /// Where the Worker trades the code for an access token.
55 pub token_url: &'static str,
56 /// Where the Worker reads the signed-in user.
57 pub userinfo_url: &'static str,
58 /// Space-separated scopes: the user's identity and email address only.
59 pub scopes: &'static str,
60 /// Name of the Worker secret holding the OAuth app's client id.
61 pub client_id_secret: &'static str,
62 /// Name of the Worker secret holding the OAuth app's client secret.
63 pub client_secret_secret: &'static str,
64}
65
66/// GitHub (OAuth app or GitHub App): scopes `read:user user:email`.
67///
68/// Register at <https://github.com/settings/developers> with the callback
69/// URL `https://<your host>/auth/github/callback` (and
70/// `http://localhost:8787/auth/github/callback` in a second app for
71/// `ocre dev`).
72///
73/// # Examples
74///
75/// ```
76/// assert_eq!(ocre::oauth::GITHUB.name, "github");
77/// ```
78pub const GITHUB: Provider = Provider {
79 name: "github",
80 authorize_url: "https://github.com/login/oauth/authorize",
81 token_url: "https://github.com/login/oauth/access_token",
82 userinfo_url: "https://api.github.com/user",
83 scopes: "read:user user:email",
84 client_id_secret: "GITHUB_CLIENT_ID",
85 client_secret_secret: "GITHUB_CLIENT_SECRET",
86};
87
88/// Google (OpenID Connect): scopes `openid email profile`.
89///
90/// Create an OAuth client ID ("Web application") at
91/// <https://console.cloud.google.com/apis/credentials> with the redirect URI
92/// `https://<your host>/auth/google/callback` (and the `localhost:8787` one
93/// for `ocre dev`).
94///
95/// # Examples
96///
97/// ```
98/// assert_eq!(ocre::oauth::GOOGLE.scopes, "openid email profile");
99/// ```
100pub const GOOGLE: Provider = Provider {
101 name: "google",
102 authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
103 token_url: "https://oauth2.googleapis.com/token",
104 userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
105 scopes: "openid email profile",
106 client_id_secret: "GOOGLE_CLIENT_ID",
107 client_secret_secret: "GOOGLE_CLIENT_SECRET",
108};
109
110/// Every provider Ocre knows: `ocre g auth --oauth` accepts these names.
111///
112/// # Examples
113///
114/// ```
115/// assert_eq!(ocre::oauth::PROVIDERS.len(), 2);
116/// ```
117pub const PROVIDERS: [&Provider; 2] = [&GITHUB, &GOOGLE];
118
119/// The provider called `name` (`"github"`, `"google"`), if Ocre knows it.
120///
121/// # Examples
122///
123/// ```
124/// assert_eq!(ocre::oauth::provider("google"), Some(&ocre::oauth::GOOGLE));
125/// assert_eq!(ocre::oauth::provider("myspace"), None);
126/// ```
127pub fn provider(name: &str) -> Option<&'static Provider> {
128 PROVIDERS.into_iter().find(|provider| provider.name == name)
129}
130
131/// A PKCE pair (RFC 7636, `S256`): keep the verifier in the session, send the challenge.
132///
133/// PKCE ties the code the provider returns to the browser session that
134/// started the sign-in, so a stolen code is useless.
135///
136/// # Examples
137///
138/// ```
139/// use ocre::oauth::Pkce;
140///
141/// let pkce = Pkce::new();
142/// assert_eq!(pkce.verifier.len(), 43);
143/// assert_eq!(Pkce::challenge_for(&pkce.verifier), pkce.challenge);
144/// ```
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Pkce {
147 /// The secret, sent with the token request ([`exchange_code`]).
148 pub verifier: String,
149 /// `BASE64URL(SHA-256(verifier))`, sent in the authorization URL.
150 pub challenge: String,
151}
152
153impl Pkce {
154 /// A new random verifier (32 bytes) and its challenge.
155 ///
156 /// # Examples
157 ///
158 /// ```
159 /// assert_ne!(ocre::oauth::Pkce::new(), ocre::oauth::Pkce::new());
160 /// ```
161 #[allow(clippy::new_without_default)]
162 pub fn new() -> Self {
163 let verifier = crate::token::generate();
164 let challenge = Self::challenge_for(&verifier);
165 Self { verifier, challenge }
166 }
167
168 /// The `S256` challenge of `verifier`.
169 ///
170 /// # Examples
171 ///
172 /// ```
173 /// // The example of RFC 7636, appendix B.
174 /// let challenge = ocre::oauth::Pkce::challenge_for("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
175 /// assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
176 /// ```
177 pub fn challenge_for(verifier: &str) -> String {
178 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
179 }
180}
181
182/// The URL to send the browser to: the provider's sign-in page for this app.
183///
184/// `redirect_uri` must be registered with the provider exactly;
185/// `state` is a random value ([`crate::token::generate`]) kept in the
186/// session and compared on the callback (CSRF protection);
187/// `code_challenge` is [`Pkce::challenge`].
188///
189/// # Examples
190///
191/// ```
192/// use ocre::oauth::{GOOGLE, authorize_url};
193///
194/// let url = authorize_url(&GOOGLE, "id", "http://localhost:8787/auth/google/callback", "st", "ch");
195/// assert_eq!(
196/// url,
197/// "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=id\
198/// &redirect_uri=http%3A%2F%2Flocalhost%3A8787%2Fauth%2Fgoogle%2Fcallback&scope=openid+email+profile\
199/// &state=st&code_challenge=ch&code_challenge_method=S256"
200/// );
201/// ```
202pub fn authorize_url(
203 provider: &Provider,
204 client_id: &str,
205 redirect_uri: &str,
206 state: &str,
207 code_challenge: &str,
208) -> String {
209 let query = serde_urlencoded::to_string([
210 ("response_type", "code"),
211 ("client_id", client_id),
212 ("redirect_uri", redirect_uri),
213 ("scope", provider.scopes),
214 ("state", state),
215 ("code_challenge", code_challenge),
216 ("code_challenge_method", "S256"),
217 ])
218 .expect("string pairs encode");
219 format!("{}?{query}", provider.authorize_url)
220}
221
222/// The form body of the token request.
223pub(crate) fn token_request_body(
224 client_id: &str,
225 client_secret: &str,
226 redirect_uri: &str,
227 code: &str,
228 verifier: &str,
229) -> String {
230 serde_urlencoded::to_string([
231 ("grant_type", "authorization_code"),
232 ("client_id", client_id),
233 ("client_secret", client_secret),
234 ("redirect_uri", redirect_uri),
235 ("code", code),
236 ("code_verifier", verifier),
237 ])
238 .expect("string pairs encode")
239}
240
241/// The access token of a token response, or 401 when the provider refused the code.
242pub(crate) fn parse_token_response(provider: &Provider, status: u16, body: &str) -> Result<String> {
243 #[derive(Deserialize)]
244 struct TokenResponse {
245 access_token: Option<String>,
246 error: Option<String>,
247 error_description: Option<String>,
248 }
249 let parsed: TokenResponse = serde_json::from_str(body).map_err(|err| {
250 Error::internal(format!("{} token endpoint answered {status} with unexpected JSON: {err}", provider.name))
251 })?;
252 match parsed.access_token {
253 Some(token) if (200..300).contains(&status) => Ok(token),
254 _ => {
255 let reason = parsed.error_description.or(parsed.error).unwrap_or_else(|| format!("status {status}"));
256 crate::error::log_internal(&format!("{} refused the OAuth code: {reason}", provider.name));
257 Err(Error::Unauthorized)
258 }
259 }
260}
261
262/// The user a provider signed in: its id there, and its email when verified.
263///
264/// # Examples
265///
266/// ```
267/// let profile = ocre::oauth::Profile {
268/// provider: "github",
269/// uid: "583231".into(),
270/// email: Some("octocat@github.com".into()),
271/// name: Some("The Octocat".into()),
272/// };
273/// assert_eq!(profile.email.as_deref(), Some("octocat@github.com"));
274/// ```
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct Profile {
277 /// The provider's [`Provider::name`].
278 pub provider: &'static str,
279 /// The user's stable id at the provider (GitHub's numeric id, Google's `sub`).
280 pub uid: String,
281 /// The user's email, only when the provider says it is verified; lowercased.
282 pub email: Option<String>,
283 /// The display name, when the user has one.
284 pub name: Option<String>,
285}
286
287/// A profile from the provider's user JSON (and GitHub's `/user/emails` JSON).
288pub(crate) fn parse_profile(provider: &'static Provider, user: &str, emails: Option<&str>) -> Result<Profile> {
289 let bad = |err: serde_json::Error| Error::internal(format!("unexpected {} user JSON: {err}", provider.name));
290 if provider == &GITHUB {
291 #[derive(Deserialize)]
292 struct User {
293 id: i64,
294 name: Option<String>,
295 login: String,
296 }
297 #[derive(Deserialize)]
298 struct Email {
299 email: String,
300 primary: bool,
301 verified: bool,
302 }
303 let user: User = serde_json::from_str(user).map_err(bad)?;
304 let emails: Vec<Email> = serde_json::from_str(emails.unwrap_or("[]")).map_err(bad)?;
305 let email = emails.into_iter().find(|email| email.primary && email.verified).map(|email| email.email);
306 Ok(Profile {
307 provider: provider.name,
308 uid: user.id.to_string(),
309 email: email.map(|email| email.to_lowercase()),
310 name: Some(user.name.unwrap_or(user.login)),
311 })
312 } else {
313 #[derive(Deserialize)]
314 struct User {
315 sub: String,
316 email: Option<String>,
317 #[serde(default)]
318 email_verified: bool,
319 name: Option<String>,
320 }
321 let user: User = serde_json::from_str(user).map_err(bad)?;
322 let email = user.email.filter(|_| user.email_verified).map(|email| email.to_lowercase());
323 Ok(Profile { provider: provider.name, uid: user.sub, email, name: user.name })
324 }
325}
326
327#[cfg(test)]
328#[path = "../tests/oauth.rs"]
329mod tests;