diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1c01cf..b92fe06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,11 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
- Added `/api/v1/apps` endpoint.
+- Added OAuth authorization page.
- Documented `http_cors_allowlist` configuration parameter.
### Changed
- Allow `instance_uri` configuration value to contain URI scheme.
+- Changed `Content-Security-Policy` header value in nginx config examples.
## [1.13.1] - 2023-02-09
diff --git a/contrib/mitra-alt-fe.nginx b/contrib/mitra-alt-fe.nginx
index 0e9ec26..cef9587 100644
--- a/contrib/mitra-alt-fe.nginx
+++ b/contrib/mitra-alt-fe.nginx
@@ -32,7 +32,8 @@ server {
add_header Strict-Transport-Security "max-age=63072000" always;
# script-src unsafe-inline required by MetaMask
- add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
+ # style-src oauth-authorization required by OAuth authorization page
+ add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'nonce-oauth-authorization'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
add_header X-Content-Type-Options "nosniff";
client_max_body_size 10M;
diff --git a/contrib/mitra.nginx b/contrib/mitra.nginx
index d9b144f..c8d0a62 100644
--- a/contrib/mitra.nginx
+++ b/contrib/mitra.nginx
@@ -32,7 +32,8 @@ server {
add_header Strict-Transport-Security "max-age=63072000" always;
# script-src unsafe-inline required by MetaMask
- add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
+ # style-src oauth-authorization required by OAuth authorization page
+ add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'nonce-oauth-authorization'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
add_header X-Content-Type-Options "nosniff";
client_max_body_size 10M;
diff --git a/migrations/V0043__oauth_authorization.sql b/migrations/V0043__oauth_authorization.sql
new file mode 100644
index 0000000..c3906bf
--- /dev/null
+++ b/migrations/V0043__oauth_authorization.sql
@@ -0,0 +1,9 @@
+CREATE TABLE oauth_authorization (
+ id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ code VARCHAR(100) UNIQUE NOT NULL,
+ user_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE,
+ application_id INTEGER NOT NULL REFERENCES oauth_application (id) ON DELETE CASCADE,
+ scopes VARCHAR(200) NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL
+);
diff --git a/migrations/schema.sql b/migrations/schema.sql
index ffac55c..37a672e 100644
--- a/migrations/schema.sql
+++ b/migrations/schema.sql
@@ -62,6 +62,16 @@ CREATE TABLE oauth_application (
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+CREATE TABLE oauth_authorization (
+ id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ code VARCHAR(100) UNIQUE NOT NULL,
+ user_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE,
+ application_id INTEGER NOT NULL REFERENCES oauth_application (id) ON DELETE CASCADE,
+ scopes VARCHAR(200) NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL
+);
+
CREATE TABLE oauth_token (
id SERIAL PRIMARY KEY,
owner_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE,
diff --git a/src/mastodon_api/oauth/types.rs b/src/mastodon_api/oauth/types.rs
index 4f2482b..c7c236d 100644
--- a/src/mastodon_api/oauth/types.rs
+++ b/src/mastodon_api/oauth/types.rs
@@ -1,4 +1,19 @@
use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+#[derive(Deserialize)]
+pub struct AuthorizationRequest {
+ pub username: String,
+ pub password: String,
+}
+
+#[derive(Deserialize)]
+pub struct AuthorizationQueryParams {
+ pub response_type: String,
+ pub client_id: Uuid,
+ pub redirect_uri: String,
+ pub scope: String,
+}
#[derive(Deserialize)]
pub struct TokenRequest {
diff --git a/src/mastodon_api/oauth/utils.rs b/src/mastodon_api/oauth/utils.rs
index 2598bbc..affad95 100644
--- a/src/mastodon_api/oauth/utils.rs
+++ b/src/mastodon_api/oauth/utils.rs
@@ -2,6 +2,42 @@ use base64;
use rand;
use rand::prelude::*;
+pub fn render_authorization_page() -> String {
+ let page = r#"
+
+
+
+
+
+ Authorization
+
+
+
+
+
+
+"#.to_string();
+ page
+}
+
const ACCESS_TOKEN_SIZE: usize = 20;
pub fn generate_access_token() -> String {
diff --git a/src/mastodon_api/oauth/views.rs b/src/mastodon_api/oauth/views.rs
index 90319ba..5049e13 100644
--- a/src/mastodon_api/oauth/views.rs
+++ b/src/mastodon_api/oauth/views.rs
@@ -1,4 +1,11 @@
-use actix_web::{post, web, HttpResponse, Scope as ActixScope};
+use actix_web::{
+ get,
+ post,
+ web,
+ HttpResponse,
+ Scope as ActixScope,
+ http::header as http_header,
+};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use chrono::{Duration, Utc};
@@ -7,7 +14,9 @@ use crate::database::{get_database_client, DatabaseError, DbPool};
use crate::errors::{HttpError, ValidationError};
use crate::ethereum::eip4361::verify_eip4361_signature;
use crate::models::oauth::queries::{
+ create_oauth_authorization,
delete_oauth_token,
+ get_oauth_app_by_client_id,
save_oauth_token,
};
use crate::models::users::queries::{
@@ -17,8 +26,79 @@ use crate::models::users::queries::{
use crate::utils::currencies::{validate_wallet_address, Currency};
use crate::utils::passwords::verify_password;
use super::auth::get_current_user;
-use super::types::{RevocationRequest, TokenRequest, TokenResponse};
-use super::utils::generate_access_token;
+use super::types::{
+ AuthorizationRequest,
+ AuthorizationQueryParams,
+ RevocationRequest,
+ TokenRequest,
+ TokenResponse,
+};
+use super::utils::{
+ generate_access_token,
+ render_authorization_page,
+};
+
+#[get("/authorize")]
+async fn authorization_page_view() -> HttpResponse {
+ let page = render_authorization_page();
+ HttpResponse::Ok()
+ .content_type("text/html")
+ .body(page)
+}
+
+const AUTHORIZATION_CODE_EXPIRES_IN: i64 = 86400 * 30;
+
+#[post("/authorize")]
+async fn authorize_view(
+ db_pool: web::Data,
+ form_data: web::Form,
+ query_params: web::Query,
+) -> Result {
+ let db_client = &**get_database_client(&db_pool).await?;
+ let user = get_user_by_name(db_client, &form_data.username).await?;
+ let password_hash = user.password_hash.as_ref()
+ .ok_or(ValidationError("password auth is disabled"))?;
+ let password_correct = verify_password(
+ password_hash,
+ &form_data.password,
+ ).map_err(|_| HttpError::InternalError)?;
+ if !password_correct {
+ return Err(ValidationError("incorrect password").into());
+ };
+ if query_params.response_type != "code" {
+ return Err(ValidationError("invalid response type").into());
+ };
+ let oauth_app = get_oauth_app_by_client_id(
+ db_client,
+ &query_params.client_id,
+ ).await?;
+ if oauth_app.redirect_uri != query_params.redirect_uri {
+ return Err(ValidationError("invalid redirect_uri parameter").into());
+ };
+
+ let authorization_code = generate_access_token();
+ let created_at = Utc::now();
+ let expires_at = created_at + Duration::seconds(AUTHORIZATION_CODE_EXPIRES_IN);
+ create_oauth_authorization(
+ db_client,
+ &authorization_code,
+ &user.id,
+ oauth_app.id,
+ &query_params.scope.replace('+', " "),
+ &created_at,
+ &expires_at,
+ ).await?;
+
+ let redirect_uri = format!(
+ "{}?code={}",
+ oauth_app.redirect_uri,
+ authorization_code,
+ );
+ let response = HttpResponse::Found()
+ .append_header((http_header::LOCATION, redirect_uri))
+ .finish();
+ Ok(response)
+}
const ACCESS_TOKEN_EXPIRES_IN: i64 = 86400 * 7;
@@ -114,6 +194,8 @@ async fn revoke_token_view(
pub fn oauth_api_scope() -> ActixScope {
web::scope("/oauth")
+ .service(authorization_page_view)
+ .service(authorize_view)
.service(token_view)
.service(revoke_token_view)
}
diff --git a/src/models/oauth/queries.rs b/src/models/oauth/queries.rs
index 1d623bf..e7eca32 100644
--- a/src/models/oauth/queries.rs
+++ b/src/models/oauth/queries.rs
@@ -40,6 +40,56 @@ pub async fn create_oauth_app(
Ok(app)
}
+pub async fn get_oauth_app_by_client_id(
+ db_client: &impl DatabaseClient,
+ client_id: &Uuid,
+) -> Result {
+ let maybe_row = db_client.query_opt(
+ "
+ SELECT oauth_application
+ FROM oauth_application
+ WHERE client_id = $1
+ ",
+ &[&client_id],
+ ).await?;
+ let row = maybe_row.ok_or(DatabaseError::NotFound("oauth application"))?;
+ let app = row.try_get("oauth_application")?;
+ Ok(app)
+}
+
+pub async fn create_oauth_authorization(
+ db_client: &impl DatabaseClient,
+ authorization_code: &str,
+ user_id: &Uuid,
+ application_id: i32,
+ scopes: &str,
+ created_at: &DateTime,
+ expires_at: &DateTime,
+) -> Result<(), DatabaseError> {
+ db_client.execute(
+ "
+ INSERT INTO oauth_authorization (
+ code,
+ user_id,
+ application_id,
+ scopes,
+ created_at,
+ expires_at
+ )
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ",
+ &[
+ &authorization_code,
+ &user_id,
+ &application_id,
+ &scopes,
+ &created_at,
+ &expires_at,
+ ],
+ ).await?;
+ Ok(())
+}
+
pub async fn save_oauth_token(
db_client: &impl DatabaseClient,
owner_id: &Uuid,
@@ -141,6 +191,31 @@ mod tests {
assert_eq!(app.app_name, "My App");
}
+ #[tokio::test]
+ #[serial]
+ async fn test_create_oauth_authorization() {
+ let db_client = &mut create_test_database().await;
+ let user_data = UserCreateData {
+ username: "test".to_string(),
+ ..Default::default()
+ };
+ let user = create_user(db_client, user_data).await.unwrap();
+ let app_data = DbOauthAppData {
+ app_name: "My App".to_string(),
+ ..Default::default()
+ };
+ let app = create_oauth_app(db_client, app_data).await.unwrap();
+ create_oauth_authorization(
+ db_client,
+ "code",
+ &user.id,
+ app.id,
+ "read write",
+ &Utc::now(),
+ &Utc::now(),
+ ).await.unwrap();
+ }
+
#[tokio::test]
#[serial]
async fn test_delete_oauth_token() {