Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Lint

on:
push:
branches: [main]
pull_request:

jobs:
rustfmt:
name: Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5

- name: Install Rust
uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable-2025-11-15
with:
components: rustfmt

- name: Check formatting
run: cargo fmt --all -- --check

clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5

- name: Install Rust
uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable-2025-11-15
with:
components: clippy

- name: Install PostgreSQL
run: |
sudo apt-get update
sudo apt-get install -y curl ca-certificates
sudo install -d /usr/share/postgresql-common/pgdg
sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
sudo apt-get update
sudo apt-get install -y postgresql-17 postgresql-server-dev-17 libclang-dev

- name: Install cargo-pgrx
run: cargo install --locked --version 0.16.1 cargo-pgrx

- name: Initialize pgrx
run: cargo pgrx init --pg17 /usr/bin/pg_config

- name: Run clippy
run: cargo clippy -p pg_session_jwt -- -D warnings
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
run: cargo clippy -p pg_session_jwt -- -D warnings
run: cargo clippy -p pg_session_jwt -- -A unknown_lints -D warnings -D clippy::todo


2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
*.iml
**/*.rs.bk
*.swp
.venv/
.vscode/
20 changes: 20 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Pre-commit hooks configuration
# Install: pip install pre-commit && pre-commit install
# Run manually: pre-commit run --all-files

repos:
- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt --all
entry: cargo
language: system
args: ["fmt", "--all"]
pass_filenames: false

- id: cargo-clippy
name: cargo clippy -p pg_session_jwt -- -D warnings
entry: cargo
language: system
args: ["clippy", "-p", "pg_session_jwt", "--", "-D", "warnings"]
pass_filenames: false
35 changes: 25 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ pgrx](https://github.com/pgcentralfoundation/pgrx#system-requirements).

Now you can install `cargo-pgrx` but make sure to install the same version
that's used by this extension:
```console
```sh
cargo install --locked --version 0.12.6 cargo-pgrx
```

Let's initialize pgrx.
```console
```sh
cargo pgrx init
```

Expand All @@ -20,8 +20,7 @@ cargo pgrx init
It's time to run `pg_session_jwt` locally. Please note that `pg_session_jwt.jwk`
parameter MUST be set when new connection is created (for more details please
refer to the README file).
```console
MY_JWK=...
```sh
export PGOPTIONS="-c pg_session_jwt.jwk=$MY_JWK"

cargo pgrx run pg16
Expand All @@ -45,13 +44,29 @@ CREATE EXTENSION pg_session_jwt;

## Before sending a PR

You can lint your code with
```console
rustfmt src/*.rs tests/*.rs
cargo clippy --fix --allow-staged
### Setup pre-commit hooks (recommended)

```sh
python -m venv .venv
source .venv/bin/activate
pip install pre-commit
pre-commit install
```

If you're using VSCode/Cursor we recommend you to use directly the `rust-analyzer` extension to run the checks with clippy.
```jsonc
{
"rust-analyzer.check.command": "clippy"
}
```

### Manual linting
```sh
cargo fmt --all
cargo clippy -p pg_session_jwt -- -D warnings
```

You can run test-suite
```console
### Run tests
```sh
cargo test
```
9 changes: 3 additions & 6 deletions src/gucs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ use pgrx::*;
use std::ffi::{CStr, CString};

pub static NEON_AUTH_JWK_RUNTIME_PARAM: &CStr = c"pg_session_jwt.jwk";
pub static NEON_AUTH_JWK: GucSetting<Option<CString>> =
GucSetting::<Option<CString>>::new(None);
pub static NEON_AUTH_JWK: GucSetting<Option<CString>> = GucSetting::<Option<CString>>::new(None);
pub static NEON_AUTH_JWT_RUNTIME_PARAM: &CStr = c"pg_session_jwt.jwt";
pub static NEON_AUTH_JWT: GucSetting<Option<CString>> =
GucSetting::<Option<CString>>::new(None);
pub static NEON_AUTH_JWT: GucSetting<Option<CString>> = GucSetting::<Option<CString>>::new(None);
pub static NEON_AUTH_ENABLE_AUDIT_LOG_PARAM: &CStr = c"pg_session_jwt.audit_log";
pub static NEON_AUTH_ENABLE_AUDIT_LOG: GucSetting<Option<CString>> =
GucSetting::<Option<CString>>::new(None);
pub static POSTGREST_JWT_RUNTIME_PARAM: &CStr = c"request.jwt.claims";
pub static POSTGREST_JWT: GucSetting<Option<CString>> =
GucSetting::<Option<CString>>::new(None);
pub static POSTGREST_JWT: GucSetting<Option<CString>> = GucSetting::<Option<CString>>::new(None);

pub fn init() {
GucRegistry::define_string_guc(
Expand Down
46 changes: 29 additions & 17 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,20 @@ pub mod auth {
}

fn get_jwk_guc() -> VerifyingKey {
let jwk = NEON_AUTH_JWK
.get()
.unwrap_or_else(|| {
error_code!(
PgSqlErrorCode::ERRCODE_NO_DATA,
format!("Missing runtime parameter: {}", convert_cstr(NEON_AUTH_JWK_RUNTIME_PARAM))
let jwk = NEON_AUTH_JWK.get().unwrap_or_else(|| {
error_code!(
PgSqlErrorCode::ERRCODE_NO_DATA,
format!(
"Missing runtime parameter: {}",
convert_cstr(NEON_AUTH_JWK_RUNTIME_PARAM)
)
});
)
});
let jwk_bytes = jwk.to_bytes();

JWK.with(|b| {
*b.get_or_init(|| {
let jwk: Ed25519Okp = serde_json::from_slice(&jwk_bytes).unwrap_or_else(|e| {
let jwk: Ed25519Okp = serde_json::from_slice(jwk_bytes).unwrap_or_else(|e| {
error_code!(
PgSqlErrorCode::ERRCODE_DATATYPE_MISMATCH,
"pg_session_jwt.jwk requires an ES256 JWK",
Expand Down Expand Up @@ -236,13 +237,22 @@ pub mod auth {
}

fn get_jwt_guc() -> Option<String> {
Some(NEON_AUTH_JWT.get()?.to_str().unwrap_or_else(|e| {
error_code!(
PgSqlErrorCode::ERRCODE_DATATYPE_MISMATCH,
format!("invalid JWT parameter {}", convert_cstr(NEON_AUTH_JWT_RUNTIME_PARAM)),
e.to_string(),
)
}).to_string())
Some(
NEON_AUTH_JWT
.get()?
.to_str()
.unwrap_or_else(|e| {
error_code!(
PgSqlErrorCode::ERRCODE_DATATYPE_MISMATCH,
format!(
"invalid JWT parameter {}",
convert_cstr(NEON_AUTH_JWT_RUNTIME_PARAM)
),
e.to_string(),
)
})
.to_string(),
)
}

fn validate_jwt() -> Option<serde_json::Map<String, serde_json::Value>> {
Expand Down Expand Up @@ -286,7 +296,9 @@ pub mod auth {
}

fn can_log_audit() -> bool {
let log_var = NEON_AUTH_ENABLE_AUDIT_LOG.get().map(|x| x.to_bytes().to_vec());
let log_var = NEON_AUTH_ENABLE_AUDIT_LOG
.get()
.map(|x| x.to_bytes().to_vec());
matches!(log_var.as_deref(), Some(b"on"))
}

Expand Down Expand Up @@ -380,7 +392,7 @@ pub mod auth {
fn convert_cstr(cstr: &CStr) -> String {
match cstr.to_str() {
Ok(s) => s.to_string(),
Err(e) => format!("Decoding failed with error: {}", e)
Err(e) => format!("Decoding failed with error: {e}"),
}
}
}
61 changes: 46 additions & 15 deletions tests/pg_session_jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ fn invalid_nbf(sk: &SigningKey, tx: &mut postgres::Client) -> Result<(), postgre
.as_secs();
let nbf_leeway = CLOCK_SKEW_LEEWAY.as_secs() + 10;

let jwt = sign_jwt(sk, r#"{"kid":1}"#, json!({"jti": 1, "nbf": now + nbf_leeway}));
let jwt = sign_jwt(
sk,
r#"{"kid":1}"#,
json!({"jti": 1, "nbf": now + nbf_leeway}),
);

tx.execute("select auth.init()", &[])?;
tx.execute("select auth.jwt_session_init($1)", &[&jwt])?;
Expand Down Expand Up @@ -220,7 +224,10 @@ fn test_jwt_claim_sub_with_jwk(
tx.execute("select auth.jwt_session_init($1)", &[&jwt])?;

// Set request.jwt.claims, but it should be ignored since JWK is defined
tx.execute("SET request.jwt.claims = '{\"sub\":\"fallback-user\"}'", &[])?;
tx.execute(
"SET request.jwt.claims = '{\"sub\":\"fallback-user\"}'",
&[],
)?;
let user_id = tx.query_one("SELECT auth.user_id()", &[])?;
let user_id: Option<String> = user_id.get(0);
assert_eq!(
Expand Down Expand Up @@ -261,7 +268,10 @@ fn test_jwt_claim_sub_when_claims_not_set(
Ok(())
}

fn test_session_with_jwk(sk: &SigningKey, tx: &mut postgres::Client) -> Result<(), postgres::Error> {
fn test_session_with_jwk(
sk: &SigningKey,
tx: &mut postgres::Client,
) -> Result<(), postgres::Error> {
let header = r#"{"kid":1}"#;
let jwt = sign_jwt(sk, header, r#"{"sub":"jwt-user","jti":1,"role":"admin"}"#);

Expand All @@ -270,29 +280,47 @@ fn test_session_with_jwk(sk: &SigningKey, tx: &mut postgres::Client) -> Result<(
tx.execute("select auth.jwt_session_init($1)", &[&jwt])?;

// Set request.jwt.claims, but it should be ignored since JWK is defined
tx.execute("SET request.jwt.claims = '{\"sub\":\"fallback-user\",\"role\":\"user\"}'", &[])?;

tx.execute(
"SET request.jwt.claims = '{\"sub\":\"fallback-user\",\"role\":\"user\"}'",
&[],
)?;

let sub: Option<String> = tx.query_one("SELECT (auth.session()->>'sub')", &[])?.get(0);
assert_eq!(sub, Some("jwt-user".to_string()), "Should use JWT sub claim when JWK is defined");

let role: Option<String> = tx.query_one("SELECT (auth.session()->>'role')", &[])?.get(0);
assert_eq!(role, Some("admin".to_string()), "Should use JWT role claim when JWK is defined");
assert_eq!(
sub,
Some("jwt-user".to_string()),
"Should use JWT sub claim when JWK is defined"
);

let role: Option<String> = tx
.query_one("SELECT (auth.session()->>'role')", &[])?
.get(0);
assert_eq!(
role,
Some("admin".to_string()),
"Should use JWT role claim when JWK is defined"
);

Ok(())
}

fn test_session_fallback_when_set(tx: &mut postgres::Client) -> Result<(), postgres::Error> {
// Test when JWK is not defined and request.jwt.claims is set
tx.execute("SET request.jwt.claims = '{\"sub\":\"test-user\",\"role\":\"admin\"}'", &[])?;

tx.execute(
"SET request.jwt.claims = '{\"sub\":\"test-user\",\"role\":\"admin\"}'",
&[],
)?;

let sub: Option<String> = tx.query_one("SELECT (auth.session()->>'sub')", &[])?.get(0);
assert_eq!(
sub,
Some("test-user".to_string()),
"Should return sub from request.jwt.claims when set"
);

let role: Option<String> = tx.query_one("SELECT (auth.session()->>'role')", &[])?.get(0);

let role: Option<String> = tx
.query_one("SELECT (auth.session()->>'role')", &[])?
.get(0);
assert_eq!(
role,
Some("admin".to_string()),
Expand All @@ -305,9 +333,12 @@ fn test_session_fallback_when_set(tx: &mut postgres::Client) -> Result<(), postg
fn test_session_fallback_when_not_set(tx: &mut postgres::Client) -> Result<(), postgres::Error> {
// Test when JWK is not defined and request.jwt.claims is not set
tx.execute("RESET request.jwt.claims", &[])?;

let session: String = tx.query_one("SELECT auth.session()::text", &[])?.get(0);
assert_eq!(session, "null", "Should return null when request.jwt.claims is not set");
assert_eq!(
session, "null",
"Should return null when request.jwt.claims is not set"
);

let is_null: bool = tx.query_one("SELECT auth.session() IS NULL", &[])?.get(0);
assert!(!is_null, "Session should return JSON null, not SQL NULL");
Expand Down