kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | README

commit c7b6b7afa1c5377076a00640c615a2d5c379bee5
parent 30faa21785e30fe14caacc572ec114967c0f1614
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Wed, 21 Jan 2026 10:35:08 +0100

Add configurable VC settings to kych.conf

Diffstat:
Mkych_oauth2_gateway/kych.conf.example | 7++++++-
Mkych_oauth2_gateway/src/config.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mkych_oauth2_gateway/src/handlers.rs | 52+++++++++++++++++++++++++++++++++++++++-------------
3 files changed, 96 insertions(+), 14 deletions(-)

diff --git a/kych_oauth2_gateway/kych.conf.example b/kych_oauth2_gateway/kych.conf.example @@ -1,7 +1,7 @@ [kych-oauth2-gateway] #HOST = #PORT = -UNIXPATH = +UNIXPATH = UNIXPATH_MODE = 666 DATABASE = NONCE_BYTES = 32 @@ -10,6 +10,11 @@ AUTH_CODE_BYTES = 32 AUTH_CODE_TTL_MINUTES = 10 #ALLOWED_SCOPES = {family_name, given_name, birth_date} +VC_TYPE = betaid-sdjwt +VC_FORMAT = vc+sd-jwt +VC_ALGORITHMS = {ES256} +VC_CLAIMS = {family_name, given_name, birth_date, sex, place_of_origin, birth_place, nationality, portrait, personal_administrative_number, age_over_16, age_over_18, age_over_65, age_birth_year, document_number, issuance_date, expiry_date, additional_person_info, reference_id_type, reference_id_expiry_date, verification_type, verification_organization, issuing_authority, issuing_country} + # ---- Clients (one section per client) ---- [client_example] diff --git a/kych_oauth2_gateway/src/config.rs b/kych_oauth2_gateway/src/config.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use ini::Ini; +use std::collections::HashSet; use std::path::Path; const MAIN_SECTION: &str = "kych-oauth2-gateway"; @@ -10,6 +11,7 @@ pub struct Config { pub server: ServerConfig, pub database: DatabaseConfig, pub crypto: CryptoConfig, + pub vc: VcConfig, pub allowed_scopes: Option<Vec<String>>, pub clients: Vec<ClientConfig>, } @@ -71,6 +73,14 @@ pub struct ClientConfig { pub accepted_issuer_dids: Option<String>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VcConfig { + pub vc_type: String, + pub vc_format: String, + pub vc_algorithms: Vec<String>, + pub vc_claims: HashSet<String>, +} + impl Config { pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> { let ini = Ini::load_from_file(path.as_ref()) @@ -144,6 +154,46 @@ impl Config { _ => None, }; + let vc_type = main_section + .get("VC_TYPE") + .filter(|s| !s.is_empty()) + .context("missing required config: VC_TYPE")? + .to_string(); + + let vc_format = main_section + .get("VC_FORMAT") + .filter(|s| !s.is_empty()) + .context("missing required config: VC_FORMAT")? + .to_string(); + + let vc_algorithms = parse_bracketed_list( + main_section + .get("VC_ALGORITHMS") + .context("missing required config: VC_ALGORITHMS")?, + "VC_ALGORITHMS", + )?; + if vc_algorithms.is_empty() { + anyhow::bail!("VC_ALGORITHMS must contain at least one algorithm"); + } + + let vc_claims_list = parse_bracketed_list( + main_section + .get("VC_CLAIMS") + .context("missing required config: VC_CLAIMS")?, + "VC_CLAIMS", + )?; + if vc_claims_list.is_empty() { + anyhow::bail!("VC_CLAIMS must contain at least one claim"); + } + let vc_claims: HashSet<String> = vc_claims_list.into_iter().collect(); + + let vc = VcConfig { + vc_type, + vc_format, + vc_algorithms, + vc_claims, + }; + let mut clients = Vec::new(); for (section_name, properties) in ini.iter() { let section_name = match section_name { @@ -186,6 +236,7 @@ impl Config { server, database, crypto, + vc, allowed_scopes, clients, }) diff --git a/kych_oauth2_gateway/src/handlers.rs b/kych_oauth2_gateway/src/handlers.rs @@ -7,6 +7,7 @@ use axum::{ }; use chrono::Utc; use serde_json::json; +use std::collections::HashSet; use crate::{crypto, db::sessions::SessionStatus, models::*, state::AppState}; @@ -51,6 +52,15 @@ fn parse_accepted_issuer_dids(raw: &str) -> Result<Vec<String>, &'static str> { Ok(dids) } +fn validate_scope_claims(scope: &str, valid_claims: &HashSet<String>) -> Result<(), String> { + for claim in scope.split_whitespace() { + if !valid_claims.contains(claim) { + return Err(format!("invalid claim in scope: {}", claim)); + } + } + Ok(()) +} + // Health check endpoint pub async fn health_check() -> impl IntoResponse { tracing::info!("Received Health Request"); @@ -364,8 +374,24 @@ pub async fn authorize( } } - // Build presentation definition from scope - let presentation_definition = build_presentation_definition(&data.scope); + if let Err(e) = validate_scope_claims(&data.scope, &state.config.vc.vc_claims) { + tracing::warn!( + "Rejected invalid scope claims for client {}: {}", + params.client_id, + e + ); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new(&e)), + )); + } + + let presentation_definition = build_presentation_definition( + &data.scope, + &state.config.vc.vc_type, + &state.config.vc.vc_format, + &state.config.vc.vc_algorithms, + ); // Call Swiyu Verifier let verifier_url = format!("{}{}", data.verifier_url, data.verifier_management_api_path); @@ -534,10 +560,12 @@ pub async fn authorize( }).into_response()) } -/// Build a presentation definition from a space-delimited scope string -/// -/// Example: "age_over_18" or "first_name last_name" -fn build_presentation_definition(scope: &str) -> PresentationDefinition { +fn build_presentation_definition( + scope: &str, + vc_type: &str, + vc_format: &str, + vc_algorithms: &[String], +) -> PresentationDefinition { use std::collections::HashMap; use uuid::Uuid; @@ -548,7 +576,6 @@ fn build_presentation_definition(scope: &str) -> PresentationDefinition { attributes ); - // First field: $.vct with filter for credential type let vct_field = Field { path: vec!["$.vct".to_string()], id: None, @@ -556,11 +583,10 @@ fn build_presentation_definition(scope: &str) -> PresentationDefinition { purpose: None, filter: Some(Filter { filter_type: "string".to_string(), - const_value: Some("betaid-sdjwt".to_string()), + const_value: Some(vc_type.to_string()), }), }; - // Attribute fields from scope let mut fields: Vec<Field> = vec![vct_field]; for attr in &attributes { fields.push(Field { @@ -574,10 +600,10 @@ fn build_presentation_definition(scope: &str) -> PresentationDefinition { let mut format = HashMap::new(); format.insert( - "vc+sd-jwt".to_string(), + vc_format.to_string(), FormatAlgorithm { - sd_jwt_alg_values: vec!["ES256".to_string()], - kb_jwt_alg_values: vec!["ES256".to_string()], + sd_jwt_alg_values: vc_algorithms.to_vec(), + kb_jwt_alg_values: vc_algorithms.to_vec(), }, ); @@ -593,7 +619,7 @@ fn build_presentation_definition(scope: &str) -> PresentationDefinition { id: Uuid::new_v4().to_string(), name: Some("Over 18 Verification".to_string()), purpose: Some("Verify age is over 18".to_string()), - format: None, // No format at top level + format: None, input_descriptors: vec![input_descriptor], } }