fix: better scope handling

This commit is contained in:
Matthieu Bessat 2024-11-11 23:16:50 +01:00
parent a7f6c28e0d
commit 81b249d341
10 changed files with 61 additions and 30 deletions

26
TODO.md
View file

@ -2,19 +2,33 @@
- [x] Login form - [x] Login form
- [x] Register form - [x] Register form
- [x] Generate JWT - [x] Redirect to login form if unauthenticated
- [ ] Redirect to login form if unauthenticated - [x] Upload picture
- OAuth2
- [x] Authorize form - [x] Authorize form
- [x] Verify authorize - [x] Verify authorize
- [x] Upload picture
- [x] Get access token - [x] Get access token
- [x] Support OpenID to use with demo client [oauth2c](https://github.com/cloudentity/oauth2c)
- .well-known/openid-configuration
- [ ] i18n strings in the http website.
- [ ] App config
- Add app logo (URI?)
- [ ] Public endpoint to get user avatar by id
- [ ] Rework avatar upload to limit size and process the image?
- [ ] Authorize form
- Show details about permissions
- Show app logo
- [ ] Support error responses by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 - [ ] Support error responses by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
- [ ] Redirect to login when JWT expire - [ ] Redirect to login when JWT expire
- [ ] Add admin panel via API - [ ] Add admin panel via API
- [ ] Add admin CLI - [ ] Add admin CLI
- [ ] Support OpenID to use with demo client [oauth2c](https://github.com/cloudentity/oauth2c)
- .well-known/openid-configuration

View file

@ -7,7 +7,7 @@ curl -v http://localhost:8085/authorize \
-d client_id="a1785786-8be1-443c-9a6f-35feed703609" \ -d client_id="a1785786-8be1-443c-9a6f-35feed703609" \
-d response_type="code" \ -d response_type="code" \
-d redirect_uri="http://localhost:9090/authorize" \ -d redirect_uri="http://localhost:9090/authorize" \
-d scope="read_basics" \ -d scope="user_read_basic" \
-d state="qxYAfk4kf6pbZkms78jM" -d state="qxYAfk4kf6pbZkms78jM"
code="$(cat tmp/headers.txt | grep -i "location" | awk -F ": " '{print $2}' | trurl -f - -g "{query:code}")" code="$(cat tmp/headers.txt | grep -i "location" | awk -F ": " '{print $2}' | trurl -f - -g "{query:code}")"

View file

@ -7,4 +7,4 @@ oauth2c http://localhost:8085 \
--response-mode query \ --response-mode query \
--grant-type authorization_code \ --grant-type authorization_code \
--auth-method client_secret_basic \ --auth-method client_secret_basic \
--scopes "read_user_basic" --scopes "user_read_basic"

View file

@ -76,9 +76,10 @@ pub async fn get_access_token(
// 3. Generate JWT for oauth2 client user session // 3. Generate JWT for oauth2 client user session
let jwt = create_token( let jwt = create_token(
&app_state.secrets, &app_state.secrets,
AppUserTokenClaims::from_client_user_id( AppUserTokenClaims::new(
&app_client_session.client_id, &app_client_session.client_id,
&authorization.user_id &authorization.user_id,
authorization.scopes.to_vec()
) )
); );
// 4. return JWT // 4. return JWT

View file

@ -1,8 +1,9 @@
use axum::{extract::State, response::IntoResponse, Json}; use axum::{extract::State, response::IntoResponse, Json};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use serde::Serialize; use serde::Serialize;
use strum::IntoEnumIterator;
use crate::server::AppState; use crate::{models::authorization::AuthorizationScope, server::AppState};
#[derive(Serialize)] #[derive(Serialize)]
#[fully_pub] #[fully_pub]
@ -25,7 +26,7 @@ pub async fn get_well_known_openid_configuration(
authorization_endpoint: format!("{}/authorize", base_url), authorization_endpoint: format!("{}/authorize", base_url),
token_endpoint: format!("{}/api/token", base_url), token_endpoint: format!("{}/api/token", base_url),
userinfo_endpoint: format!("{}/api/user", base_url), userinfo_endpoint: format!("{}/api/user", base_url),
scopes_supported: vec!["read_user_basic".into()], scopes_supported: AuthorizationScope::iter().map(|v| v.to_string()).collect(),
response_types_supported: vec!["code".into()], response_types_supported: vec!["code".into()],
token_endpoint_auth_methods_supported: vec!["client_secret_basic".into()], token_endpoint_auth_methods_supported: vec!["client_secret_basic".into()],
}) })

View file

@ -87,13 +87,23 @@ pub async fn authorize_form(
).into_response(); ).into_response();
} }
}; };
// 1.4. Parse and validate scopes
let scopes = match parse_scope(&authorization_params.scope) {
Ok(v) => v,
Err(_err) => {
return (
StatusCode::BAD_REQUEST,
Html("Invalid scopes. Scopes must be space-delimited and snake_case.")
).into_response();
}
};
// 2. Check if the app is already authorized // 2. Check if the app is already authorized
let authorizations_res = sqlx::query_as::<_, Authorization>( let authorizations_res = sqlx::query_as::<_, Authorization>(
"SELECT * FROM authorizations WHERE user_id = $1 AND client_id = $2" "SELECT * FROM authorizations WHERE user_id = $1 AND client_id = $2 AND scopes = $3"
) )
.bind(&token_claims.sub) .bind(&token_claims.sub)
.bind(&authorization_params.client_id) .bind(&authorization_params.client_id)
.bind(sqlx::types::Json(&scopes))
.fetch_one(&app_state.db) .fetch_one(&app_state.db)
.await; .await;
@ -160,17 +170,17 @@ pub async fn perform_authorize(
).into_response(); ).into_response();
} }
}; };
// parse scope again // 1.2. Parse and validate scope to use in DB
let scopes = match parse_scope(&authorize_form.scope) { let scopes = match parse_scope(&authorize_form.scope) {
Ok(v) => v, Ok(v) => v,
Err(err) => { Err(_err) => {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Html(format!("Invalid scope: {}", err)) Html("Invalid scopes.")
).into_response(); ).into_response();
} }
}; };
// 2. Create an authorizaton code // 2. Create an authorization code
let authorization_code = get_random_alphanumerical(32); let authorization_code = get_random_alphanumerical(32);
let authorization = Authorization { let authorization = Authorization {

View file

@ -1,6 +1,7 @@
use axum::{body::Bytes, extract::State, response::IntoResponse, Extension}; use axum::{body::Bytes, extract::State, response::IntoResponse, Extension};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use log::error;
use minijinja::context; use minijinja::context;
use crate::{ use crate::{
@ -99,11 +100,11 @@ pub async fn me_perform_update_details(
) )
}, },
Err(err) => { Err(err) => {
dbg!(&err); error!("Cannot update user details. {}", err);
renderer.render( renderer.render(
template_path, template_path,
context!( context!(
error => Some("Cannot update user details".to_string()), error => Some("Cannot update user details.".to_string()),
user => user_res user => user_res
) )
) )

View file

@ -2,17 +2,17 @@ use fully_pub::fully_pub;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::Json; use sqlx::types::Json;
use strum_macros::{Display, EnumString}; use strum_macros::{Display, EnumIter, EnumString};
#[derive( #[derive(
Clone, Debug, Serialize, Deserialize, PartialEq, Clone, Debug, Serialize, Deserialize, PartialEq,
sqlx::Type, sqlx::Type,
Display, EnumString Display, EnumString, EnumIter
)] )]
#[strum(serialize_all = "lowercase")] #[strum(serialize_all = "snake_case")]
#[fully_pub] pub enum AuthorizationScope {
enum AuthorizationScope { UserReadBasic,
ReadBasics UserReadRoles
} }
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)] #[derive(sqlx::FromRow, Deserialize, Serialize, Debug)]

View file

@ -2,6 +2,8 @@ use fully_pub::fully_pub;
use jsonwebtoken::get_current_timestamp; use jsonwebtoken::get_current_timestamp;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::authorization::AuthorizationScope;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub] #[fully_pub]
struct UserTokenClaims { struct UserTokenClaims {
@ -30,6 +32,7 @@ struct AppUserTokenClaims {
/// combined subject /// combined subject
client_id: String, client_id: String,
user_id: String, user_id: String,
scopes: Vec<AuthorizationScope>,
/// token expiration /// token expiration
exp: u64, exp: u64,
/// token issuer /// token issuer
@ -37,10 +40,11 @@ struct AppUserTokenClaims {
} }
impl AppUserTokenClaims { impl AppUserTokenClaims {
pub fn from_client_user_id(client_id: &str, user_id: &str) -> Self { pub fn new(client_id: &str, user_id: &str, scopes: Vec<AuthorizationScope>) -> Self {
AppUserTokenClaims { AppUserTokenClaims {
client_id: client_id.into(), client_id: client_id.into(),
user_id: user_id.into(), user_id: user_id.into(),
scopes,
exp: get_current_timestamp() + 86_000, exp: get_current_timestamp() + 86_000,
iss: "Minauth".into() iss: "Minauth".into()
} }

View file

@ -15,7 +15,7 @@
<ul> <ul>
<li>App name: {{ app.name }}</li> <li>App name: {{ app.name }}</li>
<li>App description: <i>{{ app.description }}</i></li> <li>App description: <i>{{ app.description }}</i></li>
<li>Permisions: {{ authorization_params.scope }}</li> <li>Permissions: {{ authorization_params.scope }}</li>
</ul> </ul>
<input type="hidden" name="client_id" value="{{ authorization_params.client_id }}" /> <input type="hidden" name="client_id" value="{{ authorization_params.client_id }}" />
<input type="hidden" name="scope" value="{{ authorization_params.scope }}" /> <input type="hidden" name="scope" value="{{ authorization_params.scope }}" />