Skip to main content

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;