1
//! Slice B: per-user JWT signing keys, end to end against real Postgres.
2
//!
3
//! Verifies the security-critical properties of the cutover: a user's token
4
//! signed with their own private key verifies against their public key, and a
5
//! token is REJECTED when checked against a different user's key (forgery /
6
//! cross-user isolation). Mirrors the web layer's mint → verify path using the
7
//! same `server::auth_keys` lookups and PEM wire form.
8
//!
9
//! Run via:
10
//!   DATABASE_URL=postgres://… cargo test -p tests-integration --features db
11

            
12
#![cfg(feature = "db")]
13

            
14
use base64::{Engine as _, engine::general_purpose::STANDARD};
15
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
16
use serde::{Deserialize, Serialize};
17
use sqlx::postgres::PgPoolOptions;
18

            
19
#[derive(Serialize, Deserialize)]
20
struct Claims {
21
    sub: String,
22
    exp: i64,
23
}
24

            
25
5
fn decode_b64_pem(b64: &str) -> Vec<u8> {
26
5
    STANDARD.decode(b64).expect("base64 pem")
27
5
}
28

            
29
2
fn sign(private_pem_b64: &str, sub: &str) -> String {
30
2
    let pem = decode_b64_pem(private_pem_b64);
31
2
    let claims = Claims {
32
2
        sub: sub.to_string(),
33
2
        exp: 9_999_999_999,
34
2
    };
35
2
    encode(
36
2
        &Header::new(Algorithm::RS256),
37
2
        &claims,
38
2
        &EncodingKey::from_rsa_pem(&pem).expect("encoding key"),
39
    )
40
2
    .expect("sign")
41
2
}
42

            
43
3
fn verifies_against(public_pem_b64: &str, token: &str) -> bool {
44
3
    let pem = decode_b64_pem(public_pem_b64);
45
3
    let key = match DecodingKey::from_rsa_pem(&pem) {
46
3
        Ok(k) => k,
47
        Err(_) => return false,
48
    };
49
3
    decode::<Claims>(token, &key, &Validation::new(Algorithm::RS256)).is_ok()
50
3
}
51

            
52
// One `#[tokio::test]` so the process-static admin pool keeps a stable lifetime
53
// across scenarios (see the provisioning test for the rationale).
54
#[tokio::test]
55
1
async fn per_user_signing_keys_end_to_end() -> anyhow::Result<()> {
56
1
    sign_verify_and_reject_cross_user().await?;
57
1
    stored_key_round_trips_through_lookup().await?;
58
2
    Ok(())
59
1
}
60

            
61
/// A token verifies against its own signer's public key and is REJECTED against
62
/// a different user's key (cross-user isolation / forgery guarantee).
63
1
async fn sign_verify_and_reject_cross_user() -> anyhow::Result<()> {
64
1
    let user_a = server::auth_keys::generate().await?;
65
1
    let user_b = server::auth_keys::generate().await?;
66

            
67
1
    let token_a = sign(&user_a.private_pem_b64, "user-a");
68

            
69
1
    assert!(
70
1
        verifies_against(&user_a.public_pem_b64, &token_a),
71
        "token must verify against its own signer's public key"
72
    );
73
1
    assert!(
74
1
        !verifies_against(&user_b.public_pem_b64, &token_a),
75
        "token must NOT verify against a different user's public key"
76
    );
77
1
    Ok(())
78
1
}
79

            
80
/// Provision a real per-user DB, store a generated private key, read it back via
81
/// the same lookup the mint path uses, and prove a token it signs verifies
82
/// against the public half.
83
1
async fn stored_key_round_trips_through_lookup() -> anyhow::Result<()> {
84
1
    let user_id = uuid::Uuid::new_v4();
85
1
    let db_name = format!("nomi_user_{}", user_id.simple());
86
1
    let keypair = server::auth_keys::generate().await?;
87

            
88
1
    let dsn = server::provision::create_user_database(user_id).await?;
89
1
    server::provision::store_user_private_key(&dsn, &keypair.private_pem_b64).await?;
90

            
91
    // private_key_for routes through userpool, which resolves the per-user DB
92
    // from the global users.db_name — so the user must exist in the directory.
93
1
    let admin_url = std::env::var("DATABASE_URL")?;
94
1
    let directory = PgPoolOptions::new()
95
1
        .max_connections(1)
96
1
        .connect(&admin_url)
97
1
        .await?;
98
1
    sqlx::query(
99
1
        "INSERT INTO users (id, user_name, email, user_password, db_name, jwt_public_key) \
100
1
         VALUES ($1, 'authkey-test', $2, 'x', $3, $4)",
101
1
    )
102
1
    .bind(user_id)
103
1
    .bind(format!("authkey-{user_id}@example.test"))
104
1
    .bind(&dsn)
105
1
    .bind(&keypair.public_pem_b64)
106
1
    .execute(&directory)
107
1
    .await?;
108

            
109
1
    let fetched_private = server::auth_keys::private_key_for(user_id).await?;
110
1
    assert_eq!(fetched_private, keypair.private_pem_b64);
111

            
112
1
    let fetched_public = server::auth_keys::public_key_for(user_id).await?;
113
1
    assert_eq!(
114
1
        fetched_public.as_deref(),
115
1
        Some(keypair.public_pem_b64.as_str())
116
    );
117

            
118
1
    let token = sign(&fetched_private, &user_id.to_string());
119
1
    assert!(verifies_against(&keypair.public_pem_b64, &token));
120

            
121
1
    sqlx::query("DELETE FROM users WHERE id = $1")
122
1
        .bind(user_id)
123
1
        .execute(&directory)
124
1
        .await?;
125
1
    sqlx::query(&format!(
126
1
        "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \
127
1
         WHERE datname = '{db_name}' AND pid <> pg_backend_pid()"
128
1
    ))
129
1
    .execute(&directory)
130
1
    .await
131
1
    .ok();
132
1
    sqlx::query(&format!("DROP DATABASE IF EXISTS \"{db_name}\""))
133
1
        .execute(&directory)
134
1
        .await?;
135
1
    directory.close().await;
136
1
    Ok(())
137
1
}