diff --git a/res/login.html b/res/login.html
new file mode 100644
index 0000000..15c8347
--- /dev/null
+++ b/res/login.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Login - SPFN
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/json_api/oauth/authorize.rs b/src/json_api/oauth/authorize.rs
new file mode 100644
index 0000000..07be2e9
--- /dev/null
+++ b/src/json_api/oauth/authorize.rs
@@ -0,0 +1,90 @@
+use rocket::FromForm;
+use rocket::{get, post, response::Redirect, http::{Status, ContentType}, State, form::Form};
+use std::fs;
+use rand::prelude::*;
+use sqlx::Row;
+use chrono::{Utc, Duration};
+use crate::json_api::oauth::generate_token::verify_nintendo_password;
+use crate::Pool;
+
+#[derive(FromForm)]
+pub struct AuthorizeQueryParams<'r> {
+ pub response_type: &'r str,
+ pub client_id: &'r str,
+ pub redirect_uri: &'r str,
+ pub state: Option<&'r str>,
+}
+
+#[derive(FromForm)]
+pub struct AuthorizeFormSubmit<'r> {
+ pub username: &'r str,
+ pub password: &'r str,
+ pub client_id: &'r str,
+ pub redirect_uri: &'r str,
+ pub state: Option<&'r str>,
+}
+
+#[get("/api/v2/oauth2/authorize?")]
+pub async fn authorize_page(params: AuthorizeQueryParams<'_>) -> Result<(ContentType, String), Status> {
+ if params.response_type != "code" {
+ return Err(Status::BadRequest);
+ }
+
+ let html_content = fs::read_to_string("res/login.html")
+ .map_err(|_| Status::InternalServerError)?;
+
+ let rendered = html_content
+ .replace("{{client_id}}", params.client_id)
+ .replace("{{redirect_uri}}", params.redirect_uri)
+ .replace("{{state}}", params.state.unwrap_or(""));
+
+ Ok((ContentType::HTML, rendered))
+}
+
+#[post("/api/v2/oauth2/authorize/submit", data = "")]
+pub async fn authorize_submit(
+ pool: &State,
+ form_data: Form>
+) -> Result {
+
+ let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1")
+ .bind(form_data.username)
+ .fetch_optional(pool.inner())
+ .await
+ {
+ Ok(Some(row)) => row,
+ _ => return Err((Status::Unauthorized, "invalid_credentials")),
+ };
+
+ let pid: i32 = user_row.get("pid");
+ let db_bcrypt_hash: String = user_row.get("password");
+
+ let mut random_bytes = [0u8; 32];
+ rand::rng().fill_bytes(&mut random_bytes);
+ let secure_auth_code = hex::encode(random_bytes);
+ let expires_at = Utc::now().naive_utc() + Duration::minutes(5);
+
+ if let Err(db_err) = sqlx::query(
+ "INSERT INTO oauth_auth_codes (code, pid, redirect_uri, expires_at) VALUES ($1, $2, $3, $4)"
+ )
+ .bind(&secure_auth_code)
+ .bind(pid)
+ .bind(form_data.redirect_uri)
+ .bind(expires_at)
+ .execute(pool.inner())
+ .await {
+ eprintln!("failed to save auth code: {:?}", db_err);
+ return Err((Status::InternalServerError, "server error saving session"));
+ }
+
+ if !verify_nintendo_password(pid, form_data.password, &db_bcrypt_hash) {
+ return Err((Status::Unauthorized, "invalid_credentials"));
+ }
+
+ let mut target_redirect = format!("{}?code={}", form_data.redirect_uri, secure_auth_code);
+ if let Some(state) = form_data.state {
+ target_redirect.push_str(&format!("&state={}", state));
+ }
+
+ Ok(Redirect::to(target_redirect))
+}
\ No newline at end of file
diff --git a/src/json_api/oauth/generate_token.rs b/src/json_api/oauth/generate_token.rs
index fb14912..ebd8873 100644
--- a/src/json_api/oauth/generate_token.rs
+++ b/src/json_api/oauth/generate_token.rs
@@ -1,27 +1,166 @@
-use chrono::{Duration, NaiveDateTime, Utc};
-use rocket::{get, State};
-use rocket::serde::json::Json;
+use crate::Pool;
use serde::Serialize;
-use crate::account::account::Auth;
+use rocket::FromForm;
+use sha2::{Sha256, Digest};
+use bytemuck::bytes_of;
+use rocket::{post, State, form::Form, http::Status, serde::json::Json, error};
+use sqlx::Row;
+use std::env;
+use once_cell::sync::Lazy;
use crate::nnid::oauth::generate_token::create_token;
use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN;
-use crate::Pool;
+use chrono::Utc;
+
+pub static CLIENT_SECRET: Lazy = Lazy::new(|| {
+ env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set")
+});
#[derive(Serialize)]
-pub struct TokenData{
- token: String,
- expiry: NaiveDateTime
+pub struct OAuthTokenResponse {
+ pub access_token: String,
+ pub token_type: String,
+ pub expires_in: i64,
}
-#[get("/api/v2/oauth2/generate_token")]
-pub async fn generate_token(pool: &State, auth: Auth) -> Json{
- let pool = pool.inner();
+#[derive(FromForm)]
+pub struct TokenRequest<'r> {
+ pub grant_type: &'r str,
+ pub username: Option<&'r str>,
+ pub password: Option<&'r str>,
+ pub client_id: Option<&'r str>,
+ pub client_secret: Option<&'r str>,
+ pub code: Option<&'r str>,
+ pub redirect_uri: Option<&'r str>,
+}
- Json(
- TokenData{
- expiry: Utc::now().naive_utc() + Duration::hours(1),
- token: create_token(pool, auth.pid, AUTH_TOKEN, None).await,
+pub fn verify_nintendo_password(pid: i32, text_password: &str, db_bcrypt_hash: &str) -> bool {
+ // maple: binder, there's already a function for this, why duplicate code?
+ // binder: dear maple, with this function i can just hand it a pid, the password from the user and the db_bcrypt_hash and it gives me a true/false value.
+ let mut sha = Sha256::new();
+ sha.update(bytes_of(&pid));
+ sha.update(&[0x02, 0x65, 0x43, 0x46]);
+ sha.update(text_password.as_bytes());
+ let hashed_password_hex = hex::encode(sha.finalize());
+ match bcrypt::verify(hashed_password_hex, db_bcrypt_hash) {
+ Ok(valid) => valid,
+ Err(_) => false,
+ }
+}
- }
- )
+// dummy error responses
+#[derive(Serialize)]
+pub struct OAuthErrorResponse {
+ pub error: String,
+}
+
+#[post("/api/v2/oauth2/generate_token", data = "")]
+pub async fn generate_token(
+ pool: &State,
+ form_data: Form>
+) -> Result, (Status, Json)> {
+
+ if form_data.client_id != Some("account") {
+ return Err((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "invalid_client".to_string() })
+ ));
+ }
+
+ let cl_secret: String = CLIENT_SECRET.clone();
+ if form_data.client_secret != Some(&cl_secret) {
+ return Err((
+ Status::Unauthorized,
+ Json(OAuthErrorResponse { error: "invalid_client".to_string() })
+ ));
+ }
+
+ // i'm only supporting the password grant incase someone feels lazy.
+ let pid: i32 = match form_data.grant_type {
+ "password" => {
+ let username = form_data.username.ok_or((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "invalid_request".to_string() })
+ ))?;
+ let password = form_data.password.ok_or((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "invalid_request".to_string() })
+ ))?;
+
+ let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1")
+ .bind(username)
+ .fetch_optional(pool.inner())
+ .await
+ {
+ Ok(Some(row)) => row,
+ _ => return Err((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
+ )),
+ };
+
+ let db_pid: i32 = user_row.get("pid");
+ let db_bcrypt_hash: String = user_row.get("password");
+
+ if !verify_nintendo_password(db_pid, password, &db_bcrypt_hash) {
+ return Err((
+ Status::Unauthorized,
+ Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
+ ));
+ }
+
+ db_pid
+ },
+
+ "authorization_code" => {
+ let incoming_code = form_data.code.ok_or((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "invalid_request".to_string() })
+ ))?;
+
+ let code_row = match sqlx::query(
+ "SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1"
+ )
+ .bind(incoming_code)
+ .fetch_optional(pool.inner())
+ .await
+ {
+ Ok(Some(row)) => row,
+ _ => return Err((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
+ )),
+ };
+
+ let is_used: bool = code_row.get("used");
+ let expires_at: chrono::NaiveDateTime = code_row.get("expires_at");
+ let target_pid: i32 = code_row.get("pid");
+
+ if is_used || expires_at < Utc::now().naive_utc() {
+ return Err((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
+ ));
+ }
+
+ let _ = sqlx::query("UPDATE oauth_auth_codes SET used = TRUE WHERE code = $1")
+ .bind(incoming_code)
+ .execute(pool.inner())
+ .await;
+
+ target_pid
+ },
+
+ _ => return Err((
+ Status::BadRequest,
+ Json(OAuthErrorResponse { error: "unsupported_grant_type".to_string() })
+ ))
+ };
+
+ let token = create_token(pool.inner(), pid, AUTH_TOKEN, None).await;
+
+ Ok(Json(OAuthTokenResponse {
+ access_token: token,
+ token_type: "Bearer".to_string(),
+ expires_in: 3600,
+ }))
}
\ No newline at end of file
diff --git a/src/json_api/oauth/mod.rs b/src/json_api/oauth/mod.rs
index 0f3eae2..fdd4baa 100644
--- a/src/json_api/oauth/mod.rs
+++ b/src/json_api/oauth/mod.rs
@@ -1,2 +1,2 @@
pub mod generate_token;
-
+pub mod authorize;
\ No newline at end of file
diff --git a/src/json_api/users/profile.rs b/src/json_api/users/profile.rs
index 81f11fb..b0c8966 100644
--- a/src/json_api/users/profile.rs
+++ b/src/json_api/users/profile.rs
@@ -1,10 +1,10 @@
use rocket::serde::json::Json;
use rocket::{get, State};
use crate::account::account::Auth;
-use crate::nnid::people::{build_profile, GetOwnProfileData};
+use crate::nnid::people::{build_oauth_profile, GetOwnOAuthProfileData};
use crate::Pool;
#[get("/api/v2/users/@me/profile")]
-pub async fn get_own_profile(_pool: &State, auth: Auth) -> Json {
- Json(build_profile(auth.into()))
+pub async fn get_own_profile(_pool: &State, auth: Auth) -> Json {
+ Json(build_oauth_profile(auth.into()))
}
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index 7257e32..c7ba707 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -116,6 +116,8 @@ async fn launch() -> _ {
json_api::users::profile::get_own_profile,
json_api::users::mii::get_mii_data_by_pid,
json_api::users::delete::delete_account,
+ json_api::oauth::authorize::authorize_page,
+ json_api::oauth::authorize::authorize_submit,
nnid::people::thing,
// graphql::graphiql,
// graphql::playground,
diff --git a/src/nnid/people.rs b/src/nnid/people.rs
index bacb86c..0399c57 100644
--- a/src/nnid/people.rs
+++ b/src/nnid/people.rs
@@ -180,6 +180,20 @@ struct EmailInfoOwnProfileData{
validated_date: Option
}
+#[derive(Serialize)]
+struct EmailInfoOwnOAuthProfileData{
+ address: String,
+ id: u32,
+ parent: bool,
+ primary: bool,
+ reachable: bool,
+ #[serde(rename = "type")]
+ email_type: String,
+ updated_by: String,
+ validated: bool,
+ validated_date: Option
+}
+
#[derive(Serialize)]
struct MiiImage{
cached_url: String,
@@ -230,6 +244,31 @@ pub struct GetOwnProfileData{
account_level: i32,
}
+#[derive(Serialize)]
+#[serde(rename(serialize = "person"))]
+pub struct GetOwnOAuthProfileData{
+ active_flag: bool,
+ birth_date: NaiveDate,
+ country: String,
+ create_date: NaiveDateTime,
+ gender: String,
+ language: String,
+ updated: NaiveDateTime,
+ marketing_flag: bool,
+ off_device_flag: bool,
+ pid: i32,
+ id: i32,
+ uid: i32,
+ sub: i32,
+ email: EmailInfoOwnOAuthProfileData,
+ mii: MiiDataOwnProfileData,
+ region: i32,
+ tz_name: String,
+ user_id: String,
+ utc_offset: String,
+ account_level: i32,
+}
+
#[get("/v1/api/people/@me/profile")]
pub fn get_own_profile(user: Auth) -> Ds>{
Ds(Xml(build_profile(user.into())))
@@ -330,6 +369,99 @@ pub fn build_profile(user: User) -> GetOwnProfileData {
}
}
+pub fn build_oauth_profile(user: User) -> GetOwnOAuthProfileData {
+ let User {
+ username,
+ pid,
+ account_level,
+ mii_data,
+ gender,
+ birthdate,
+ country,
+ creation_date,
+ timezone,
+ language,
+ email,
+ email_verified_since,
+ updated,
+ marketing_allowed,
+ off_device_allowed,
+ region,
+ // verification_code,
+ ..
+ } = user.into();
+
+ let timezone_offset = (&*OFFSET_FROM_TIMEZONE).get(&timezone).unwrap().to_owned();
+
+ // very bruteforce method, but i don't care.
+ let id = pid;
+ let sub = pid;
+ let uid = pid;
+
+ let mii_data = mii_data
+ .replace("\n", "")
+ .replace("\t", "")
+ .replace("\r", "")
+ .replace(" ", "");
+
+
+ GetOwnOAuthProfileData {
+ id,
+ sub,
+ uid,
+ active_flag: true,
+ pid,
+ user_id: username,
+ gender,
+ birth_date: birthdate,
+ country,
+ create_date: creation_date,
+ tz_name: timezone,
+ language,
+ updated,
+ marketing_flag: marketing_allowed,
+ email: EmailInfoOwnOAuthProfileData {
+ id: gxhash32(email.as_bytes(), 0),
+ address: email,
+ validated: email_verified_since.is_some(),
+ validated_date: email_verified_since,
+ email_type: "DEFAULT".to_string(),
+ updated_by: "USER".to_string(),
+ reachable: true,
+ primary: true,
+ parent: false,
+ },
+ mii: MiiDataOwnProfileData {
+ id: gxhash32(mii_data.as_bytes(), 0),
+ mii_hash: hex::encode(bytemuck::bytes_of(
+ &(gxhash64(mii_data.as_bytes(), 1) & !(0x1000000000000000))
+ )),
+ name: crate::mii_util::MiiData::read(&mii_data)
+ .map(|v| v.name)
+ .unwrap_or_else(|| "INVALID".to_string()),
+ primary: YesNoVal(true),
+ data: mii_data,
+ status: "COMPLETED".to_string(),
+ mii_images: MiiImages {
+ mii_image: {
+ let image_url = get_mii_img_url(pid, "png");
+ let url_hash = gxhash32(image_url.as_bytes(), 0);
+ MiiImage {
+ image_type: "standard".to_string(),
+ id: url_hash,
+ url: image_url.clone(),
+ cached_url: image_url,
+ }
+ }
+ }
+ },
+ off_device_flag: off_device_allowed,
+ region,
+ utc_offset: timezone_offset,
+ account_level,
+ }
+}
+
#[put("/v1/api/people/@me/miis/@primary", data = "")]
pub async fn change_mii(
database: &State,
@@ -360,6 +492,7 @@ pub async fn change_mii(
Ok(())
}
+
#[post("/v1/api/people/@me/agreements")]
pub async fn thing(){