added mobile auth support

This commit is contained in:
Ultradesu
2026-06-05 03:37:51 +03:00
parent df16713aa2
commit 952d11e6f5
8 changed files with 2091 additions and 300 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.2.15"
version = "0.3.1"
dependencies = [
"anyhow",
"async-trait",
+3
View File
@@ -1324,6 +1324,9 @@ impl App for AdminApp {
all.extend(cot::db::migrations::wrap_migrations(
crate::scheduler::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::auth::db_migrations::MIGRATIONS,
));
all
}
}
+525 -11
View File
@@ -1,14 +1,27 @@
use std::marker::PhantomData;
use cot::aide::openapi::{
MediaType, Operation, ReferenceOr, RequestBody, Response as OpenApiResponse, SchemaObject,
StatusCode as OpenApiStatusCode,
};
use cot::auth::PasswordVerificationResult;
use cot::common_types::Password;
use cot::db::Database;
use cot::http::StatusCode;
use cot::http::header::CONTENT_TYPE;
use cot::json::Json;
use cot::openapi::{AsApiOperation, RouteContext};
use cot::response::IntoResponse;
use cot::router::method::openapi::api_get;
use cot::router::method::openapi::{api_get, api_post};
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body};
use schemars::JsonSchema;
use serde::Serialize;
use cot::{App, Body, RequestHandler};
use schemars::{JsonSchema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use crate::auth;
use crate::config::AppConfig;
use crate::user::User;
// ---------------------------------------------------------------------------
// JSON error helper
@@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
.expect("valid response")
}
#[derive(Clone, Copy)]
struct DocumentedJsonHandler<H, Req, Res> {
handler: H,
summary: &'static str,
_marker: PhantomData<fn(Req) -> Res>,
}
#[derive(Clone, Copy)]
struct DocumentedResponseHandler<H, Res> {
handler: H,
summary: &'static str,
_marker: PhantomData<fn() -> Res>,
}
fn documented_json_handler<Req, Res, H>(
handler: H,
summary: &'static str,
) -> DocumentedJsonHandler<H, Req, Res> {
DocumentedJsonHandler {
handler,
summary,
_marker: PhantomData,
}
}
fn documented_response_handler<Res, H>(
handler: H,
summary: &'static str,
) -> DocumentedResponseHandler<H, Res> {
DocumentedResponseHandler {
handler,
summary,
_marker: PhantomData,
}
}
impl<HandlerParams, H, Req, Res> RequestHandler<HandlerParams>
for DocumentedJsonHandler<H, Req, Res>
where
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
{
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
self.handler.handle(request).await
}
}
impl<HandlerParams, H, Res> RequestHandler<HandlerParams> for DocumentedResponseHandler<H, Res>
where
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
{
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
self.handler.handle(request).await
}
}
impl<H, Req, Res> AsApiOperation for DocumentedJsonHandler<H, Req, Res>
where
Req: JsonSchema,
Res: JsonSchema,
{
fn as_api_operation(
&self,
_route_context: &RouteContext<'_>,
schema_generator: &mut SchemaGenerator,
) -> Option<Operation> {
let mut operation = Operation {
summary: Some(self.summary.to_owned()),
..Default::default()
};
let mut request_body = RequestBody {
required: true,
..Default::default()
};
request_body.content.insert(
"application/json".to_owned(),
MediaType {
schema: Some(SchemaObject {
json_schema: Req::json_schema(schema_generator),
external_docs: None,
example: None,
}),
..Default::default()
},
);
operation.request_body = Some(ReferenceOr::Item(request_body));
let responses = operation.responses.get_or_insert_default();
let mut ok = OpenApiResponse {
description: "OK".to_owned(),
..Default::default()
};
ok.content.insert(
"application/json".to_owned(),
MediaType {
schema: Some(SchemaObject {
json_schema: Res::json_schema(schema_generator),
external_docs: None,
example: None,
}),
..Default::default()
},
);
responses
.responses
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
Some(operation)
}
}
impl<H, Res> AsApiOperation for DocumentedResponseHandler<H, Res>
where
Res: JsonSchema,
{
fn as_api_operation(
&self,
_route_context: &RouteContext<'_>,
schema_generator: &mut SchemaGenerator,
) -> Option<Operation> {
let mut operation = Operation {
summary: Some(self.summary.to_owned()),
..Default::default()
};
add_json_response::<Res>(&mut operation, schema_generator);
Some(operation)
}
}
fn add_json_response<Res: JsonSchema>(
operation: &mut Operation,
schema_generator: &mut SchemaGenerator,
) {
let responses = operation.responses.get_or_insert_default();
let mut ok = OpenApiResponse {
description: "OK".to_owned(),
..Default::default()
};
ok.content.insert(
"application/json".to_owned(),
MediaType {
schema: Some(SchemaObject {
json_schema: Res::json_schema(schema_generator),
external_docs: None,
example: None,
}),
..Default::default()
},
);
responses
.responses
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
}
fn is_json_content_type(value: &str) -> bool {
value
.split(';')
.next()
.map(str::trim)
.is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json"))
}
async fn parse_json_request<T>(
request: cot::request::Request,
) -> cot::Result<Result<T, cot::response::Response>>
where
T: for<'de> Deserialize<'de>,
{
let content_type = request
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if !is_json_content_type(content_type) {
return Ok(Err(json_error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"expected application/json",
)));
}
let bytes = request.into_body().into_bytes().await?;
let body = match serde_json::from_slice::<T>(&bytes) {
Ok(body) => body,
Err(_) => {
return Ok(Err(json_error(
StatusCode::BAD_REQUEST,
"invalid JSON body",
)));
}
};
Ok(Ok(body))
}
// ---------------------------------------------------------------------------
// GET /api/me
// ---------------------------------------------------------------------------
@@ -34,8 +240,85 @@ struct MeResponse {
role: String,
}
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_session_user(&session, &db).await else {
#[derive(Debug, Serialize, JsonSchema)]
struct AuthUserResponse {
id: i64,
name: String,
role: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AuthTokenResponse {
access_token: String,
refresh_token: String,
token_type: String,
expires_in_seconds: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AuthLoginResponse {
user: AuthUserResponse,
tokens: AuthTokenResponse,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PasswordLoginRequest {
username: String,
password: String,
device_name: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RefreshRequest {
refresh_token: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SsoExchangeRequest {
code: String,
device_name: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct LogoutRequest {
refresh_token: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LogoutResponse {
revoked: bool,
}
fn user_response(user: auth::AuthenticatedUser) -> AuthUserResponse {
AuthUserResponse {
id: user.id,
name: user.name,
role: user.role.code().to_owned(),
}
}
fn token_response(tokens: auth::ApiTokenPair) -> AuthTokenResponse {
AuthTokenResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type.to_owned(),
expires_in_seconds: tokens.expires_in_seconds,
}
}
fn login_response(user: auth::AuthenticatedUser, tokens: auth::ApiTokenPair) -> AuthLoginResponse {
AuthLoginResponse {
user: user_response(user),
tokens: token_response(tokens),
}
}
async fn me_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(
cot::http::StatusCode::UNAUTHORIZED,
"not authenticated",
@@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
.into_response()
}
async fn password_login_handler(
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<PasswordLoginRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
let (config, _) = AppConfig::load_with_db(&db).await;
if !config.auth_password_enabled {
crate::metrics::record_auth_attempt("api_password", "failure", "disabled");
return Ok(json_error(
StatusCode::FORBIDDEN,
"password login is disabled",
));
}
let user = match User::get_by_username(&db, request.username.trim()).await {
Ok(Some(user)) if user.is_active() => user,
_ => {
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid username or password",
));
}
};
let Some(hash) = user.password_ref() else {
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid username or password",
));
};
match hash.verify(&Password::new(&request.password)) {
PasswordVerificationResult::Ok | PasswordVerificationResult::OkObsolete(_) => {
let auth_user = auth::AuthenticatedUser {
id: user.id_val(),
name: {
let display = user.display_name_str();
if display.is_empty() {
user.username_str().to_owned()
} else {
display
}
},
role: user.role(),
};
let tokens =
auth::create_api_session(&db, user.id_val(), request.device_name.as_deref())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
crate::metrics::record_auth_attempt("api_password", "success", "ok");
crate::metrics::record_session_created("api_password");
Json(login_response(auth_user, tokens)).into_response()
}
PasswordVerificationResult::Invalid => {
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid username or password",
))
}
}
}
async fn refresh_handler(
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<RefreshRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
match auth::refresh_api_session(&db, request.refresh_token.trim()).await {
Ok(Some(tokens)) => Json(token_response(tokens)).into_response(),
Ok(None) => Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid refresh token",
)),
Err(err) => Err(cot::Error::internal(err.to_string())),
}
}
async fn sso_exchange_handler(
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<SsoExchangeRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
match auth::exchange_mobile_code_for_api_session(
&db,
request.code.trim(),
request.device_name.as_deref(),
)
.await
{
Ok(Some((user, tokens))) => {
crate::metrics::record_auth_attempt("api_sso_exchange", "success", "ok");
crate::metrics::record_session_created("api_sso_exchange");
Json(login_response(user, tokens)).into_response()
}
Ok(None) => {
crate::metrics::record_auth_attempt("api_sso_exchange", "failure", "bad_code");
Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid SSO exchange code",
))
}
Err(err) => Err(cot::Error::internal(err.to_string())),
}
}
async fn logout_handler(
auth_ctx: auth::AuthContext,
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<LogoutRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
let revoked = auth::revoke_api_session(
&db,
auth_ctx.bearer_token(),
request.refresh_token.as_deref().map(str::trim),
)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(LogoutResponse { revoked }).into_response()
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
@@ -62,10 +485,101 @@ impl App for ApiApp {
}
fn router(&self) -> Router {
Router::with_urls([Route::with_api_handler_and_name(
"/me",
api_get(me_handler),
"api_me",
)])
Router::with_urls([
Route::with_api_handler_and_name(
"/me",
api_get(documented_response_handler::<MeResponse, _>(
me_handler,
"Get the current authenticated user",
)),
"api_me",
),
Route::with_api_handler_and_name(
"/auth/password",
api_post(documented_json_handler::<
PasswordLoginRequest,
AuthLoginResponse,
_,
>(
password_login_handler,
"Log in with username and password",
)),
"api_auth_password",
),
Route::with_api_handler_and_name(
"/auth/refresh",
api_post(documented_json_handler::<
RefreshRequest,
AuthTokenResponse,
_,
>(
refresh_handler, "Refresh an API token pair"
)),
"api_auth_refresh",
),
Route::with_api_handler_and_name(
"/auth/sso/exchange",
api_post(documented_json_handler::<
SsoExchangeRequest,
AuthLoginResponse,
_,
>(
sso_exchange_handler,
"Exchange a mobile SSO code for API tokens",
)),
"api_auth_sso_exchange",
),
Route::with_api_handler_and_name(
"/auth/logout",
api_post(documented_json_handler::<LogoutRequest, LogoutResponse, _>(
logout_handler,
"Revoke an API session",
)),
"api_auth_logout",
),
])
}
}
#[cfg(test)]
mod tests {
use cot::aide::openapi::{PathItem, ReferenceOr};
use super::*;
fn assert_get_path(paths: &cot::aide::openapi::Paths, path: &str) {
assert!(matches!(
paths.paths.get(path),
Some(ReferenceOr::Item(PathItem { get: Some(_), .. }))
));
}
fn assert_post_path(paths: &cot::aide::openapi::Paths, path: &str) {
assert!(matches!(
paths.paths.get(path),
Some(ReferenceOr::Item(PathItem { post: Some(_), .. }))
));
}
#[test]
fn openapi_includes_auth_routes() {
let openapi = ApiApp.router().as_api();
let paths = openapi.paths.expect("OpenAPI paths");
assert_get_path(&paths, "/me");
assert_post_path(&paths, "/auth/password");
assert_post_path(&paths, "/auth/refresh");
assert_post_path(&paths, "/auth/sso/exchange");
assert_post_path(&paths, "/auth/logout");
let Some(ReferenceOr::Item(PathItem {
post: Some(operation),
..
})) = paths.paths.get("/auth/password")
else {
panic!("password auth path should be documented as POST");
};
assert!(operation.request_body.is_some());
assert!(operation.responses.is_some());
}
}
+550 -6
View File
@@ -1,7 +1,13 @@
use chrono::{Duration, Utc};
use cot::Body;
use cot::db::Database;
use cot::db::{Auto, Database, LimitedString, Model};
use cot::http::header::AUTHORIZATION;
use cot::request::RequestHead;
use cot::request::extractors::FromRequestHead;
use cot::response::IntoResponse;
use cot::session::Session;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::user::User;
@@ -46,11 +52,7 @@ pub struct AuthenticatedUser {
pub role: Role,
}
/// Read `user_id` from the session, fetch the `User` from DB, return
/// `AuthenticatedUser` if the user exists and is active.
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
let user = User::get_by_id(db, user_id).await.ok()??;
fn authenticated_user_from_user(user: User) -> Option<AuthenticatedUser> {
if !user.is_active() {
return None;
}
@@ -70,6 +72,362 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
})
}
/// Read `user_id` from the session, fetch the `User` from DB, return
/// `AuthenticatedUser` if the user exists and is active.
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
let user = User::get_by_id(db, user_id).await.ok()??;
authenticated_user_from_user(user)
}
// ---------------------------------------------------------------------------
// API bearer-token auth
// ---------------------------------------------------------------------------
const ACCESS_TOKEN_PREFIX: &str = "furu_at_";
const REFRESH_TOKEN_PREFIX: &str = "furu_rt_";
const MOBILE_EXCHANGE_CODE_PREFIX: &str = "furu_mx_";
const ACCESS_TOKEN_TTL_MINUTES: i64 = 15;
const REFRESH_TOKEN_TTL_DAYS: i64 = 60;
const MOBILE_EXCHANGE_CODE_TTL_MINUTES: i64 = 3;
#[derive(Debug, Clone, Default)]
pub struct AuthContext {
bearer_token: Option<String>,
}
impl AuthContext {
pub fn bearer_token(&self) -> Option<&str> {
self.bearer_token.as_deref()
}
}
impl FromRequestHead for AuthContext {
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
let bearer_token = head
.headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(parse_bearer_token)
.map(str::to_owned);
Ok(Self { bearer_token })
}
}
fn parse_bearer_token(header: &str) -> Option<&str> {
let header = header.trim();
let (scheme, token) = header.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("Bearer") {
return None;
}
let token = token.trim();
if token.is_empty() || token.len() > 512 {
return None;
}
Some(token)
}
#[derive(Debug, Serialize)]
pub struct ApiTokenPair {
pub access_token: String,
pub refresh_token: String,
pub token_type: &'static str,
pub expires_in_seconds: i64,
}
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct ApiSession {
#[model(primary_key)]
id: Auto<i64>,
user_id: i64,
device_name: Option<String>,
access_token_hash: LimitedString<128>,
refresh_token_hash: LimitedString<128>,
access_expires_at: String,
refresh_expires_at: String,
created_at: String,
last_used_at: Option<String>,
revoked_at: Option<String>,
}
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct MobileExchangeCode {
#[model(primary_key)]
id: Auto<i64>,
code_hash: LimitedString<128>,
user_id: i64,
created_at: String,
expires_at: String,
consumed_at: Option<String>,
}
impl ApiSession {
pub async fn create_for_user(
db: &Database,
user_id: i64,
device_name: Option<&str>,
) -> cot::db::Result<ApiTokenPair> {
let tokens = fresh_token_pair();
let now = now_iso();
let mut session = Self {
id: Auto::auto(),
user_id,
device_name: device_name.and_then(normalize_device_name),
access_token_hash: LimitedString::new(&token_hash(&tokens.access_token)).unwrap(),
refresh_token_hash: LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(),
access_expires_at: access_expires_at(),
refresh_expires_at: refresh_expires_at(),
created_at: now.clone(),
last_used_at: Some(now),
revoked_at: None,
};
session.insert(db).await?;
Ok(tokens)
}
async fn find_by_access_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
return Ok(None);
};
cot::db::query!(ApiSession, $access_token_hash == hash)
.get(db)
.await
}
async fn find_by_refresh_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
return Ok(None);
};
cot::db::query!(ApiSession, $refresh_token_hash == hash)
.get(db)
.await
}
fn is_revoked(&self) -> bool {
self.revoked_at.is_some()
}
fn access_token_valid(&self) -> bool {
!self.is_revoked() && self.access_expires_at > now_iso()
}
fn refresh_token_valid(&self) -> bool {
!self.is_revoked() && self.refresh_expires_at > now_iso()
}
async fn rotate(&mut self, db: &Database) -> cot::db::Result<ApiTokenPair> {
let tokens = fresh_token_pair();
self.access_token_hash = LimitedString::new(&token_hash(&tokens.access_token)).unwrap();
self.refresh_token_hash = LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap();
self.access_expires_at = access_expires_at();
self.refresh_expires_at = refresh_expires_at();
self.last_used_at = Some(now_iso());
self.save(db).await?;
Ok(tokens)
}
async fn revoke(&mut self, db: &Database) -> cot::db::Result<()> {
if self.revoked_at.is_none() {
self.revoked_at = Some(now_iso());
self.save(db).await?;
}
Ok(())
}
}
pub async fn create_api_session(
db: &Database,
user_id: i64,
device_name: Option<&str>,
) -> cot::db::Result<ApiTokenPair> {
ApiSession::create_for_user(db, user_id, device_name).await
}
pub async fn get_bearer_user(db: &Database, token: &str) -> Option<AuthenticatedUser> {
let session = ApiSession::find_by_access_token(db, token).await.ok()??;
if !session.access_token_valid() {
return None;
}
let user = User::get_by_id(db, session.user_id).await.ok()??;
authenticated_user_from_user(user)
}
pub async fn get_request_user(
auth: &AuthContext,
session: &Session,
db: &Database,
) -> Option<AuthenticatedUser> {
if let Some(token) = auth.bearer_token() {
return get_bearer_user(db, token).await;
}
get_session_user(session, db).await
}
pub async fn refresh_api_session(
db: &Database,
refresh_token: &str,
) -> cot::db::Result<Option<ApiTokenPair>> {
let Some(mut session) = ApiSession::find_by_refresh_token(db, refresh_token).await? else {
return Ok(None);
};
if !session.refresh_token_valid() {
session.revoke(db).await?;
return Ok(None);
}
let Some(user) = User::get_by_id(db, session.user_id).await? else {
session.revoke(db).await?;
return Ok(None);
};
if !user.is_active() {
session.revoke(db).await?;
return Ok(None);
}
Ok(Some(session.rotate(db).await?))
}
pub async fn revoke_api_session(
db: &Database,
access_token: Option<&str>,
refresh_token: Option<&str>,
) -> cot::db::Result<bool> {
let mut session = if let Some(token) = access_token {
ApiSession::find_by_access_token(db, token).await?
} else {
None
};
if session.is_none() {
if let Some(token) = refresh_token {
session = ApiSession::find_by_refresh_token(db, token).await?;
}
}
let Some(mut session) = session else {
return Ok(false);
};
session.revoke(db).await?;
Ok(true)
}
impl MobileExchangeCode {
pub async fn create_for_user(db: &Database, user_id: i64) -> cot::db::Result<String> {
let code = random_token(MOBILE_EXCHANGE_CODE_PREFIX);
let now = now_iso();
let mut row = Self {
id: Auto::auto(),
code_hash: LimitedString::new(&token_hash(&code)).unwrap(),
user_id,
created_at: now,
expires_at: mobile_exchange_code_expires_at(),
consumed_at: None,
};
row.insert(db).await?;
Ok(code)
}
async fn find_by_code(db: &Database, code: &str) -> cot::db::Result<Option<Self>> {
let Ok(hash) = LimitedString::<128>::new(&token_hash(code)) else {
return Ok(None);
};
cot::db::query!(MobileExchangeCode, $code_hash == hash)
.get(db)
.await
}
fn is_valid(&self) -> bool {
self.consumed_at.is_none() && self.expires_at > now_iso()
}
async fn consume(&mut self, db: &Database) -> cot::db::Result<()> {
self.consumed_at = Some(now_iso());
self.save(db).await
}
}
pub async fn create_mobile_exchange_code(db: &Database, user_id: i64) -> cot::db::Result<String> {
MobileExchangeCode::create_for_user(db, user_id).await
}
pub async fn exchange_mobile_code_for_api_session(
db: &Database,
code: &str,
device_name: Option<&str>,
) -> cot::db::Result<Option<(AuthenticatedUser, ApiTokenPair)>> {
let Some(mut exchange_code) = MobileExchangeCode::find_by_code(db, code).await? else {
return Ok(None);
};
if !exchange_code.is_valid() {
return Ok(None);
}
let Some(user) = User::get_by_id(db, exchange_code.user_id).await? else {
exchange_code.consume(db).await?;
return Ok(None);
};
let Some(auth_user) = authenticated_user_from_user(user) else {
exchange_code.consume(db).await?;
return Ok(None);
};
exchange_code.consume(db).await?;
let tokens = ApiSession::create_for_user(db, auth_user.id, device_name).await?;
Ok(Some((auth_user, tokens)))
}
fn fresh_token_pair() -> ApiTokenPair {
ApiTokenPair {
access_token: random_token(ACCESS_TOKEN_PREFIX),
refresh_token: random_token(REFRESH_TOKEN_PREFIX),
token_type: "Bearer",
expires_in_seconds: ACCESS_TOKEN_TTL_MINUTES * 60,
}
}
fn random_token(prefix: &str) -> String {
format!(
"{prefix}{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
fn token_hash(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn normalize_device_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.chars().take(255).collect())
}
fn now_iso() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn access_expires_at() -> String {
(Utc::now() + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}
fn refresh_expires_at() -> String {
(Utc::now() + Duration::days(REFRESH_TOKEN_TTL_DAYS))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}
fn mobile_exchange_code_expires_at() -> String {
(Utc::now() + Duration::minutes(MOBILE_EXCHANGE_CODE_TTL_MINUTES))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
/// `Err(response)` — a redirect to `/login` or a 403.
pub async fn require_admin_or_redirect(
@@ -159,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::Response {
.expect("valid response")
}
// ---------------------------------------------------------------------------
// Migrations
// ---------------------------------------------------------------------------
pub mod db_migrations {
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
use cot::db::{DatabaseField, Identifier, LimitedString};
#[derive(Debug, Copy, Clone)]
pub struct M0038CreateApiSession;
impl migrations::Migration for M0038CreateApiSession {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0038_create_api_session";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0003_create_user",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__api_session"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("device_name"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("access_token_hash"),
<LimitedString<128> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("refresh_token_hash"),
<LimitedString<128> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("access_expires_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("refresh_expires_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("created_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("last_used_at"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("revoked_at"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
])
.build()];
}
#[cot::db::migrations::migration_op]
async fn create_api_session_indexes(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_api_session_access_token_hash \
ON furumusic__api_session (access_token_hash)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_api_session_refresh_token_hash \
ON furumusic__api_session (refresh_token_hash)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX idx_api_session_user_id \
ON furumusic__api_session (user_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0039CreateApiSessionIndexes;
impl migrations::Migration for M0039CreateApiSessionIndexes {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0039_create_api_session_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0038_create_api_session",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_api_session_indexes).build()];
}
#[derive(Debug, Copy, Clone)]
pub struct M0040CreateMobileExchangeCode;
impl migrations::Migration for M0040CreateMobileExchangeCode {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0040_create_mobile_exchange_code";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0039_create_api_session_indexes",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__mobile_exchange_code"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("code_hash"),
<LimitedString<128> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("created_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("expires_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("consumed_at"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
])
.build()];
}
#[cot::db::migrations::migration_op]
async fn create_mobile_exchange_code_indexes(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_mobile_exchange_code_hash \
ON furumusic__mobile_exchange_code (code_hash)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX idx_mobile_exchange_code_user_id \
ON furumusic__mobile_exchange_code (user_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0041CreateMobileExchangeCodeIndexes;
impl migrations::Migration for M0041CreateMobileExchangeCodeIndexes {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0041_create_mobile_exchange_code_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0040_create_mobile_exchange_code",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_mobile_exchange_code_indexes).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0038CreateApiSession,
&M0039CreateApiSessionIndexes,
&M0040CreateMobileExchangeCode,
&M0041CreateMobileExchangeCodeIndexes,
];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
+42
View File
@@ -338,10 +338,52 @@ impl AppConfig {
pub fn load() -> Self {
let mut cfg = Self::default();
cfg.apply_env_overrides();
cfg.apply_startup_db_overrides();
cfg.apply_env_overrides();
cfg.normalize_host_paths();
cfg
}
fn apply_startup_db_overrides(&mut self) {
if self.database_url.is_empty() {
return;
}
if tokio::runtime::Handle::try_current().is_ok() {
tracing::warn!("skipping startup DB config load from inside an existing Tokio runtime");
return;
}
let database_url = self.database_url.clone();
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
tracing::warn!("failed to create runtime for startup DB config load");
return;
};
let result = runtime.block_on(async move {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await?;
sqlx::query_scalar::<_, String>(
"SELECT value FROM furumusic__config_entry WHERE key = 'swagger_enabled'",
)
.fetch_optional(&pool)
.await
});
match result {
Ok(Some(value)) => match value.parse::<bool>() {
Ok(value) => self.swagger_enabled = value,
Err(_) => tracing::warn!("ignoring invalid DB config value for swagger_enabled"),
},
Ok(None) => {}
Err(err) => tracing::warn!("failed to read startup DB config overrides: {err}"),
}
}
/// Build config with full 3-layer resolution (default → DB → env) and
/// track the source of each field.
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
+19
View File
@@ -378,6 +378,15 @@ impl App for FuruApp {
}
};
let (live_config, _) = AppConfig::load_with_db(&db).await;
if !live_config.auth_password_enabled {
metrics::record_auth_attempt("password", "failure", "disabled");
let msg = i18n.t.login_disabled.to_owned();
return login_page_handler(i18n, &config, db, msg)
.await?
.into_response();
}
// Try to authenticate
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
{
@@ -425,6 +434,16 @@ impl App for FuruApp {
get(oidc::oidc_callback_handler),
"oidc_callback",
),
Route::with_handler_and_name(
"/auth/mobile/oidc/start",
get(oidc::oidc_mobile_start_handler),
"mobile_oidc_start",
),
Route::with_handler_and_name(
"/auth/mobile/oidc/callback",
get(oidc::oidc_mobile_callback_handler),
"mobile_oidc_callback",
),
])
}
}
+409 -3
View File
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
use std::time::Instant;
use cot::db::Database;
use cot::request::extractors::UrlQuery;
use cot::session::Session;
use openidconnect::core::{CoreClient, CoreProviderMetadata};
use openidconnect::{
@@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce";
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
const SESSION_MOBILE_CSRF_STATE: &str = "mobile_oidc_csrf_state";
const SESSION_MOBILE_NONCE: &str = "mobile_oidc_nonce";
const SESSION_MOBILE_PKCE_VERIFIER: &str = "mobile_oidc_pkce_verifier";
const SESSION_MOBILE_PROVIDER_REDIRECT_URI: &str = "mobile_oidc_provider_redirect_uri";
const SESSION_MOBILE_APP_REDIRECT_URI: &str = "mobile_oidc_app_redirect_uri";
const DEFAULT_MOBILE_REDIRECT_URI: &str = "furumi://auth/callback";
// ---------------------------------------------------------------------------
// Provider cache
// ---------------------------------------------------------------------------
@@ -247,13 +255,23 @@ pub struct OidcCallbackQuery {
state: String,
}
#[derive(Deserialize)]
pub struct MobileOidcStartQuery {
redirect_uri: Option<String>,
}
#[derive(Deserialize)]
pub struct MobileOidcCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
pub async fn oidc_callback_handler(
i18n: I18n,
db: Database,
session: Session,
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
OidcCallbackQuery,
>,
UrlQuery(query): UrlQuery<OidcCallbackQuery>,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(&db).await;
@@ -461,6 +479,296 @@ pub async fn oidc_callback_handler(
Ok(auth::redirect(&redirect_to))
}
// ---------------------------------------------------------------------------
// Mobile OIDC flow
// ---------------------------------------------------------------------------
pub async fn oidc_mobile_start_handler(
origin: RequestOrigin,
db: Database,
session: Session,
UrlQuery(query): UrlQuery<MobileOidcStartQuery>,
) -> cot::Result<cot::response::Response> {
let Some(app_redirect_uri) = safe_mobile_redirect_uri(query.redirect_uri.as_deref()) else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "bad_redirect_uri");
return Ok(text_response(
cot::http::StatusCode::BAD_REQUEST,
"invalid mobile redirect_uri",
));
};
let (config, _) = AppConfig::load_with_db(&db).await;
if !config.auth_sso_enabled
|| config.oidc_issuer.is_empty()
|| config.oidc_client_id.is_empty()
|| config.oidc_client_secret.is_empty()
{
tracing::warn!("Mobile OIDC start requested but SSO is not configured");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
return Ok(mobile_redirect_error(
&app_redirect_uri,
"sso_not_configured",
));
}
let http = oidc_http_client();
let client = match get_or_refresh_provider(&config, &http).await {
Ok(c) => c,
Err(e) => {
tracing::error!("Mobile OIDC provider error: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
}
};
let provider_redirect_uri = format!("{}/auth/mobile/oidc/callback", origin.0);
let redirect_url = RedirectUrl::new(provider_redirect_uri.clone())
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI: {e}")))?;
let client = client.set_redirect_uri(redirect_url);
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = client
.authorize_url(
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
session
.insert(SESSION_MOBILE_CSRF_STATE, csrf_state.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_MOBILE_NONCE, nonce.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_MOBILE_PKCE_VERIFIER, pkce_verifier.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(
SESSION_MOBILE_PROVIDER_REDIRECT_URI,
provider_redirect_uri.clone(),
)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_MOBILE_APP_REDIRECT_URI, app_redirect_uri)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
tracing::info!(
auth_url = %auth_url,
provider_redirect_uri = %provider_redirect_uri,
"Mobile OIDC start: redirecting to provider",
);
Ok(auth::redirect(auth_url.as_str()))
}
pub async fn oidc_mobile_callback_handler(
db: Database,
session: Session,
UrlQuery(query): UrlQuery<MobileOidcCallbackQuery>,
) -> cot::Result<cot::response::Response> {
let app_redirect_uri = mobile_app_redirect_uri_from_session(&session).await?;
if query.error.is_some() {
tracing::warn!("Mobile OIDC callback returned provider error");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_denied");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_denied"));
}
let Some(code) = query.code else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_code");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_code"));
};
let Some(state) = query.state else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
};
let saved_csrf: Option<String> = session
.get(SESSION_MOBILE_CSRF_STATE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let saved_nonce: Option<String> = session
.get(SESSION_MOBILE_NONCE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let saved_pkce: Option<String> = session
.get(SESSION_MOBILE_PKCE_VERIFIER)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let provider_redirect_uri: Option<String> = session
.get(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let Some(saved_csrf) = saved_csrf else {
tracing::warn!("Mobile OIDC callback: no CSRF state in session");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
};
if state != saved_csrf {
tracing::warn!("Mobile OIDC callback: CSRF state mismatch");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "csrf");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "csrf"));
}
let Some(nonce_str) = saved_nonce else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_nonce");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_nonce"));
};
let Some(pkce_str) = saved_pkce else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_pkce");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_pkce"));
};
let Some(provider_redirect_uri) = provider_redirect_uri else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_redirect_uri");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(
&app_redirect_uri,
"missing_redirect_uri",
));
};
let (config, _) = AppConfig::load_with_db(&db).await;
if !config.auth_sso_enabled
|| config.oidc_issuer.is_empty()
|| config.oidc_client_id.is_empty()
|| config.oidc_client_secret.is_empty()
{
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(
&app_redirect_uri,
"sso_not_configured",
));
}
let http = oidc_http_client();
let client = match get_or_refresh_provider(&config, &http).await {
Ok(c) => c,
Err(e) => {
tracing::error!("Mobile OIDC provider error during callback: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
}
};
let redirect_url = RedirectUrl::new(provider_redirect_uri)
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI from session: {e}")))?;
let client = client.set_redirect_uri(redirect_url);
let token_request = match client.exchange_code(AuthorizationCode::new(code)) {
Ok(req) => req,
Err(e) => {
tracing::error!("Mobile OIDC token endpoint not configured: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_config");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let token_response = token_request
.set_pkce_verifier(PkceCodeVerifier::new(pkce_str))
.request_async(&http)
.await;
let token_response = match token_response {
Ok(t) => t,
Err(e) => {
tracing::error!("Mobile OIDC token exchange failed: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_exchange");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
use openidconnect::TokenResponse;
let id_token = match token_response.id_token() {
Some(t) => t,
None => {
tracing::error!("Mobile OIDC response missing ID token");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_id_token");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let nonce = Nonce::new(nonce_str);
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
Ok(c) => c,
Err(e) => {
tracing::error!("Mobile OIDC ID token verification failed: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "id_token_verify");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let sub = claims.subject().to_string();
let issuer = claims.issuer().to_string();
let email = claims.email().map(|e| e.to_string());
let name = claims
.name()
.and_then(|n| n.get(None))
.map(|n| n.to_string());
let groups = extract_groups_from_jwt(&id_token.to_string());
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
tracing::warn!(
"Mobile OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
config.oidc_user_groups,
config.oidc_admin_groups,
);
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_in_group");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "access_denied"));
}
let user = match provision_user(
&db,
&issuer,
&sub,
email.as_deref(),
name.as_deref(),
&groups,
&config.oidc_admin_groups,
)
.await
{
Ok(u) => u,
Err(e) => {
tracing::error!("Mobile OIDC user provisioning failed: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provisioning");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let exchange_code = auth::create_mobile_exchange_code(&db, user.id_val())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
clear_mobile_oidc_session(&session).await?;
crate::metrics::record_auth_attempt("mobile_oidc", "success", "ok");
crate::metrics::record_session_created("mobile_oidc");
Ok(mobile_redirect_success(&app_redirect_uri, &exchange_code))
}
// ---------------------------------------------------------------------------
// User provisioning
// ---------------------------------------------------------------------------
@@ -616,6 +924,104 @@ fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Respon
Ok(auth::redirect(&format!("/login?error={encoded}")))
}
fn text_response(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
cot::http::Response::builder()
.status(status)
.body(cot::Body::fixed(message.to_owned()))
.expect("valid response")
}
async fn mobile_app_redirect_uri_from_session(session: &Session) -> cot::Result<String> {
let saved: Option<String> = session
.get(SESSION_MOBILE_APP_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(safe_mobile_redirect_uri(saved.as_deref())
.unwrap_or_else(|| DEFAULT_MOBILE_REDIRECT_URI.to_owned()))
}
async fn clear_mobile_oidc_session(session: &Session) -> cot::Result<()> {
let _: Option<String> = session
.remove(SESSION_MOBILE_CSRF_STATE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_NONCE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_PKCE_VERIFIER)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_APP_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(())
}
fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
let value = raw
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_MOBILE_REDIRECT_URI);
if value.len() > 2048 || value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
return None;
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
return Some(value.to_owned());
}
None
}
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
auth::redirect(&append_query_param(app_redirect_uri, "code", code))
}
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
auth::redirect(&append_query_param(app_redirect_uri, "error", error))
}
fn append_query_param(uri: &str, key: &str, value: &str) -> String {
let (base, fragment) = uri.split_once('#').unwrap_or((uri, ""));
let separator = if base.contains('?') { '&' } else { '?' };
let mut out = format!("{base}{separator}{key}={}", urlencoded(value));
if !fragment.is_empty() {
out.push('#');
out.push_str(fragment);
}
out
}
fn extract_groups_from_jwt(token: &str) -> Vec<String> {
use base64::Engine;
let Some(payload_b64) = token.split('.').nth(1) else {
return Vec::new();
};
let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
else {
return Vec::new();
};
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
return Vec::new();
};
let Some(arr) = value.get("groups").and_then(|value| value.as_array()) else {
return Vec::new();
};
arr.iter()
.filter_map(|value| value.as_str().map(String::from))
.collect()
}
/// Minimal percent-encoding for query parameter values.
fn urlencoded(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
+542 -279
View File
File diff suppressed because it is too large Load Diff