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 database_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
        // `verified` defaults FALSE, and this row is routed through the real
100
1
        // tenant pool, which refuses a disabled account.
101
1
        "INSERT INTO users (id, user_name, email, user_password, database_name, jwt_public_key, verified) \
102
1
         VALUES ($1, 'authkey-test', $2, 'x', $3, $4, TRUE)",
103
1
    )
104
1
    .bind(user_id)
105
1
    .bind(format!("authkey-{user_id}@example.test"))
106
1
    .bind(&dsn)
107
1
    .bind(&keypair.public_pem_b64)
108
1
    .execute(&directory)
109
1
    .await?;
110

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

            
114
1
    let fetched_public = server::auth_keys::public_key_for(user_id)
115
1
        .await?
116
1
        .expect("identity");
117
1
    assert_eq!(fetched_public.public_key, keypair.public_pem_b64);
118
1
    assert!(fetched_public.verified);
119

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

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