summaryrefslogtreecommitdiff
path: root/src/api/auth/oauth.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/auth/oauth.rs')
-rw-r--r--src/api/auth/oauth.rs433
1 files changed, 433 insertions, 0 deletions
diff --git a/src/api/auth/oauth.rs b/src/api/auth/oauth.rs
new file mode 100644
index 0000000..b0ed8ee
--- /dev/null
+++ b/src/api/auth/oauth.rs
@@ -0,0 +1,433 @@
+use std::collections::HashMap;
+
+use chrono::{DateTime, Duration, Local, Utc};
+use rocket::serde::json::Json;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use sha2::Digest;
+use subtle::ConstantTimeEq;
+
+use crate::api::auth::WithVerifiedFxaLogin;
+use crate::db::DbConn;
+use crate::types::oauth::{Scope, ScopeSet};
+use crate::{
+ api::{auth, serialize_dt},
+ auth::Authenticated,
+ crypto::{SecretBytes, SessionCredentials},
+ types::{
+ HawkKey, OauthAccessToken, OauthAccessType, OauthAuthorization, OauthAuthorizationID,
+ OauthRefreshToken, OauthToken, OauthTokenID, SessionID, UserID,
+ },
+};
+
+// MISSING get /oauth/client/{client_id}
+
+pub(crate) struct OauthClient {
+ pub(crate) id: &'static str,
+ // NOTE not read so far, but good to have
+ #[allow(dead_code)]
+ pub(crate) name: &'static str,
+ pub(crate) scopes: &'static [Scope<'static>],
+}
+
+const SESSION_SCOPE: Scope = Scope::borrowed("https://identity.mozilla.com/tokens/session");
+
+// NOTE the telemetry scopes don't seem to be needed. since we'd have to give
+// out keys for them (fxa does) we'll exclude them entirely.
+// see fxa-auth-server/config/dev.json for lists of predefined clients and permissions.
+pub(crate) const OAUTH_CLIENTS: [OauthClient; 2] = [
+ OauthClient {
+ id: "5882386c6d801776",
+ name: "Firefox",
+ scopes: &[
+ Scope::borrowed("profile:write"),
+ Scope::borrowed("https://identity.mozilla.com/apps/oldsync"),
+ Scope::borrowed("https://identity.mozilla.com/tokens/session"),
+ // "https://identity.mozilla.com/ids/ecosystem_telemetry",
+ ],
+ },
+ OauthClient {
+ id: "a2270f727f45f648",
+ name: "Fenix",
+ scopes: &[
+ Scope::borrowed("profile"),
+ Scope::borrowed("https://identity.mozilla.com/apps/oldsync"),
+ Scope::borrowed("https://identity.mozilla.com/tokens/session"),
+ // "https://identity.mozilla.com/ids/ecosystem_telemetry",
+ ],
+ },
+];
+
+// NOTE fxa dev config allows scoped keys only for:
+// - https://identity.mozilla.com/apps/notes
+// - https://identity.mozilla.com/apps/oldsync
+// - https://identity.mozilla.com/ids/ecosystem_telemetry
+// - https://identity.mozilla.com/apps/send
+// we only implement sync because notes and send are dead and
+// telemetry is of no use to us
+const SCOPES_WITH_KEYS: [Scope; 1] = [Scope::borrowed("https://identity.mozilla.com/apps/oldsync")];
+
+fn check_client_and_scopes(
+ client_id: &str,
+ scope: &ScopeSet,
+) -> Result<&'static OauthClient, auth::Error> {
+ let desc = match OAUTH_CLIENTS.iter().find(|&s| s.id == client_id) {
+ Some(d) => d,
+ None => return Err(auth::Error::UnknownClientID),
+ };
+ if !scope.is_allowed_by(desc.scopes) {
+ return Err(auth::Error::ScopesNotAllowed);
+ }
+ Ok(desc)
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum PkceChallengeType {
+ S256,
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum AuthResponseType {
+ #[serde(rename = "code")]
+ Code,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct OauthAuthReq {
+ client_id: String,
+ state: String,
+ keys_jwe: Option<String>,
+ scope: ScopeSet,
+ access_type: OauthAccessType,
+ // NOTE we don't support confidential clients, so PKCE is mandatory
+ code_challenge: String,
+
+ // MISSING redirect_uri
+ // MISSING acr_value
+
+ // for validation during deserialization only
+ #[allow(dead_code)]
+ code_challenge_method: PkceChallengeType,
+ #[allow(dead_code)]
+ response_type: AuthResponseType,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct OauthAuthResp {
+ code: OauthAuthorizationID,
+ state: String,
+ // MISSING redirect
+}
+
+#[post("/oauth/authorization", data = "<req>")]
+pub(crate) async fn authorization(
+ db: &DbConn,
+ req: Authenticated<OauthAuthReq, WithVerifiedFxaLogin>,
+) -> auth::Result<OauthAuthResp> {
+ check_client_and_scopes(&req.body.client_id, &req.body.scope)?;
+ let id = OauthAuthorizationID::random();
+ db.add_oauth_authorization(
+ &id,
+ OauthAuthorization {
+ user_id: req.context.uid,
+ client_id: req.body.client_id,
+ scope: req.body.scope,
+ access_type: req.body.access_type,
+ code_challenge: req.body.code_challenge,
+ keys_jwe: req.body.keys_jwe,
+ auth_at: req.context.created_at,
+ },
+ )
+ .await?;
+ Ok(Json(OauthAuthResp { code: id, state: req.body.state }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct ScopedKeysReq {
+ client_id: String,
+ scope: ScopeSet,
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ScopedKey {
+ identifier: String,
+ keyRotationSecret: &'static str,
+ keyRotationTimestamp: u64,
+}
+
+#[post("/account/scoped-key-data", data = "<data>")]
+pub(crate) async fn scoped_key_data(
+ data: Authenticated<ScopedKeysReq, WithVerifiedFxaLogin>,
+) -> auth::Result<HashMap<String, ScopedKey>> {
+ check_client_and_scopes(&data.body.client_id, &data.body.scope)?;
+ // like fxa we'll stub out key rotation handling entirely and return the same constants.
+ Ok(Json(
+ data.body
+ .scope
+ .split()
+ .filter(|s| SCOPES_WITH_KEYS.contains(s))
+ .map(|scope| {
+ (
+ scope.to_string(),
+ ScopedKey {
+ identifier: scope.to_string(),
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 0,
+ },
+ )
+ })
+ .collect(),
+ ))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct OauthDestroy {
+ client_id: String,
+ token: OauthToken,
+}
+
+#[post("/oauth/destroy", data = "<data>")]
+pub(crate) async fn destroy(db: &DbConn, data: Json<OauthDestroy>) -> auth::Result<()> {
+ // MISSING api spec allows an optional basic auth header, but what for?
+ // TODO fxa also checks the authorization header if present, but firefox doesn't send it
+ let client_id = if let Ok(t) = db.get_refresh_token(&data.token.hash()).await {
+ t.client_id
+ } else if let Ok(t) = db.get_access_token(&data.token.hash()).await {
+ t.client_id
+ } else {
+ return Err(auth::Error::InvalidParameter);
+ };
+ // fxa does constant-time checks for client_id, do that here too.
+ if client_id.as_bytes().ct_eq(data.client_id.as_bytes()).into() {
+ db.delete_oauth_token(&data.token.hash()).await?;
+ Ok(Json(()))
+ } else {
+ Err(auth::Error::InvalidParameter)
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "grant_type")]
+enum TokenReqDetails {
+ // we can't use deny_unknown_fields when flatten is involved, and multiple
+ // flattens in the same struct cause problems if one of them is greedy (like map).
+ // flatten an extra map into every variant instead and check each of them.
+ #[serde(rename = "authorization_code")]
+ AuthCode {
+ code: OauthAuthorizationID,
+ code_verifier: String,
+ // NOTE only useful with redirect flows, which we kinda don't support at all
+ #[allow(dead_code)]
+ redirect_uri: Option<String>,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+ #[serde(rename = "refresh_token")]
+ RefreshToken {
+ refresh_token: OauthToken,
+ scope: ScopeSet,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+ #[serde(rename = "fxa-credentials")]
+ FxaCreds {
+ scope: ScopeSet,
+ access_type: Option<OauthAccessType>,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+}
+
+impl TokenReqDetails {
+ fn extra_is_empty(&self) -> bool {
+ match self {
+ TokenReqDetails::AuthCode { extra, .. } => extra.is_empty(),
+ TokenReqDetails::RefreshToken { extra, .. } => extra.is_empty(),
+ TokenReqDetails::FxaCreds { extra, .. } => extra.is_empty(),
+ }
+ }
+}
+
+// TODO log errors in all the places
+
+#[derive(Debug, Deserialize)]
+pub(crate) struct TokenReq {
+ client_id: String,
+ ttl: Option<u32>,
+ #[serde(flatten)]
+ details: TokenReqDetails,
+ // MISSING client_secret
+ // MISSING redirect_uri
+ // MISSING ttl
+ // MISSING ppid_seed
+ // MISSING resource
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) enum TokenType {
+ #[serde(rename = "bearer")]
+ Bearer,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct TokenResp {
+ access_token: OauthToken,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ refresh_token: Option<OauthToken>,
+ // MISSING id_token
+ #[serde(skip_serializing_if = "Option::is_none")]
+ session_token: Option<String>,
+ scope: ScopeSet,
+ token_type: TokenType,
+ expires_in: u32,
+ #[serde(serialize_with = "serialize_dt")]
+ auth_at: DateTime<Utc>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keys_jwe: Option<String>,
+}
+
+#[post("/oauth/token", data = "<req>", rank = 1)]
+pub(crate) async fn token_authenticated(
+ db: &DbConn,
+ req: Authenticated<TokenReq, WithVerifiedFxaLogin>,
+) -> auth::Result<TokenResp> {
+ match &req.body.details {
+ TokenReqDetails::FxaCreds { .. } => (),
+ _ => return Err(auth::Error::InvalidParameter),
+ }
+ token_impl(
+ db,
+ Some(req.context.uid),
+ Some(req.context.created_at),
+ req.body,
+ None,
+ Some(req.session.clone()),
+ )
+ .await
+}
+
+#[post("/oauth/token", data = "<req>", rank = 2)]
+pub(crate) async fn token_unauthenticated(
+ db: &DbConn,
+ req: Json<TokenReq>,
+) -> auth::Result<TokenResp> {
+ let (parent_refresh, auth_at) = match &req.details {
+ TokenReqDetails::RefreshToken { refresh_token, .. } => {
+ let session = db.use_session_from_refresh(&refresh_token.hash()).await?;
+ (Some(refresh_token.hash()), Some(session.1.created_at))
+ },
+ TokenReqDetails::AuthCode { .. } => (None, None),
+ _ => return Err(auth::Error::InvalidParameter),
+ };
+ token_impl(db, None, auth_at, req.into_inner(), parent_refresh, None).await
+}
+
+async fn token_impl(
+ db: &DbConn,
+ user_id: Option<UserID>,
+ auth_at: Option<DateTime<Utc>>,
+ req: TokenReq,
+ parent_refresh: Option<OauthTokenID>,
+ parent_session: Option<SessionID>,
+) -> auth::Result<TokenResp> {
+ if !req.details.extra_is_empty() {
+ return Err(auth::Error::InvalidParameter);
+ }
+ let ttl = req.ttl.unwrap_or(3600).clamp(0, 7 * 86400);
+
+ let (auth_at, scope, keys_jwe, user_id, access_type) = match req.details {
+ TokenReqDetails::AuthCode { code, code_verifier, .. } => {
+ let auth = match db.take_oauth_authorization(&code).await {
+ Ok(a) => a,
+ Err(_) => return Err(auth::Error::InvalidAuthToken),
+ };
+ if !bool::from(auth.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) {
+ return Err(auth::Error::UnknownClientID);
+ }
+ let mut sha = sha2::Sha256::new();
+ sha.update(code_verifier.as_bytes());
+ let challenge = base64::encode_config(&sha.finalize(), base64::URL_SAFE_NO_PAD);
+ if !bool::from(challenge.as_bytes().ct_eq(auth.code_challenge.as_bytes())) {
+ return Err(auth::Error::InvalidParameter);
+ }
+ (auth.auth_at, auth.scope, auth.keys_jwe, auth.user_id, Some(auth.access_type))
+ },
+ TokenReqDetails::RefreshToken { refresh_token, scope, .. } => {
+ let auth_at =
+ auth_at.expect("oauth token requests with refresh token must set auth_at");
+ let base = db.get_refresh_token(&refresh_token.hash()).await?;
+ if !bool::from(base.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) {
+ return Err(auth::Error::UnknownClientID);
+ }
+ check_client_and_scopes(&req.client_id, &scope)?;
+ if !base.scope.implies_all(&scope) {
+ return Err(auth::Error::ScopesNotAllowed);
+ }
+ (auth_at, scope, None, base.user_id, None)
+ },
+ TokenReqDetails::FxaCreds { scope, access_type, .. } => {
+ let user_id = user_id.expect("oauth token requests with fxa must set user_id");
+ let auth_at = auth_at.expect("oauth token requests with fxa must set auth_at");
+ check_client_and_scopes(&req.client_id, &scope)?;
+ (auth_at, scope, None, user_id, access_type)
+ },
+ };
+
+ let access_token = OauthToken::random();
+ db.add_access_token(
+ &access_token.hash(),
+ OauthAccessToken {
+ user_id: user_id.clone(),
+ client_id: req.client_id.clone(),
+ scope: scope.clone(),
+ parent_refresh,
+ parent_session,
+ expires_at: (Local::now() + Duration::seconds(ttl.into())).into(),
+ },
+ )
+ .await?;
+
+ let (refresh_token, session_token) = if access_type == Some(OauthAccessType::Offline) {
+ let (session_token, session_id) = if scope.implies(&SESSION_SCOPE) {
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let session_id = SessionID(session.token_id.0);
+ db.add_session(session_id.clone(), &user_id, HawkKey(session.req_hmac_key), true, None)
+ .await?;
+ (Some(session_token.0), Some(SessionID(session.token_id.0)))
+ } else {
+ (None, None)
+ };
+
+ let refresh_token = OauthToken::random();
+ db.add_refresh_token(
+ &refresh_token.hash(),
+ OauthRefreshToken {
+ user_id,
+ client_id: req.client_id,
+ scope: scope.remove(&SESSION_SCOPE),
+ session_id,
+ },
+ )
+ .await?;
+ (Some(refresh_token), session_token)
+ } else {
+ (None, None)
+ };
+
+ Ok(Json(TokenResp {
+ access_token,
+ refresh_token,
+ session_token: session_token.map(hex::encode),
+ scope: scope.remove(&SESSION_SCOPE),
+ token_type: TokenType::Bearer,
+ expires_in: ttl,
+ auth_at,
+ keys_jwe,
+ }))
+}