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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,12 @@ Useful endpoints:
- `POST /oauth/register`
- `GET /oauth/authorize` (browser consent screen; loopback by default, optional trusted-header for non-loopback)
- `POST /oauth/authorize/decision` (consent-screen form submission)
- `POST /oauth/token` for authorization-code + PKCE token exchange and refresh-token grants
- `POST /oauth/consent/revoke` (browser-session form; revokes a client's consent and its live tokens, after which `/oauth/authorize` re-renders the consent screen)
- `POST /oauth/token` for authorization-code + PKCE token exchange and refresh-token grants (RFC 6749 §5.2 JSON error bodies)
- `GET /api/health` with `Authorization: Bearer <token>`
- `/mcp` with `Authorization: Bearer <token>`

`/oauth/authorize` drives a browser consent flow (ADR-0020). After a client dynamic-registers via `POST /oauth/register`, it redirects the user to `/oauth/authorize` with the standard PKCE parameters. Aver renders a consent screen showing the client name, redirect URI, and all supported scopes as checkboxes, with the client's requested scopes pre-selected; on **Approve** it stores a per-client consent row, mints an authorization code bound to the checked scopes, and redirects back to the client's `redirect_uri` with `code` and `state`. The client then exchanges the code at `/oauth/token` for an `access_token` plus `refresh_token`; refresh grants issue a new access token while preserving the existing refresh token, and access tokens carry only the scopes recorded on the consent row.
`/oauth/authorize` drives a browser consent flow (ADR-0020). After a client dynamic-registers via `POST /oauth/register`, it redirects the user to `/oauth/authorize` with the standard PKCE parameters. Aver renders a consent screen showing the client name, redirect URI, and all supported scopes as checkboxes, with the client's requested scopes pre-selected; on **Approve** it stores a per-client consent row, mints an authorization code bound to the checked scopes, and redirects back to the client's `redirect_uri` with `code` and `state`. Approving with zero scopes records an empty grant that never skips the consent screen, so a later visit can grant real access. The client then exchanges the code at `/oauth/token` for an `access_token` plus `refresh_token` (and an `expires_in` lifetime); refresh grants rotate both halves of the token pair, presenting an already-rotated refresh token revokes the client's whole token family (RFC 6819 §5.2.2.3 reuse detection), and access tokens carry only the scopes recorded on the consent row.

The flow supports loopback (`127.0.0.1` / `::1`) callers by default (Profile A in ADR-0020). Non-loopback callers can also authenticate via Profile C when `AVER_TRUSTED_AUTH_HEADER` is set to a trusted upstream identity header (for example `X-Forwarded-User`); otherwise they are rejected with an HTML 403.

Expand Down
152 changes: 116 additions & 36 deletions crates/aver-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ fn now_unix() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp()
}

const ACCESS_TOKEN_TTL_SECS: i64 = 60 * 60;
/// Access-token lifetime, in seconds. Exposed so the token endpoint can
/// report it as RFC 6749 §5.1 `expires_in`.
pub const ACCESS_TOKEN_TTL_SECS: i64 = 60 * 60;
const REFRESH_TOKEN_TTL_SECS: i64 = 30 * 24 * 60 * 60;

fn encode_scopes(scopes: &[String]) -> String {
Expand Down Expand Up @@ -139,6 +141,37 @@ fn random_session_id() -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}

/// Applies an `ALTER TABLE ... ADD COLUMN` migration.
///
/// SQLite has no `ADD COLUMN IF NOT EXISTS`, so re-opening an already
/// migrated database surfaces a duplicate-column error — that one case means
/// "migration already ran" and is ignored. Every other error (syntax, I/O,
/// permissions, corruption) is a real failure and propagates instead of
/// being swallowed. rusqlite 0.32 has no dedicated error-code variant for
/// duplicate columns: SQLite reports it as `SQLITE_ERROR` (extended code 1,
/// mapped to [`rusqlite::ErrorCode::Unknown`]) with a
/// `duplicate column name: <col>` message, so we match code and message.
fn apply_column_migration(conn: &Connection, statement: &str) -> anyhow::Result<()> {
match conn.execute_batch(statement) {
Ok(()) => Ok(()),
Err(rusqlite::Error::SqliteFailure(err, Some(msg)))
if err.extended_code == 1 && msg.contains("duplicate column name") =>
{
Ok(())
}
Err(err) => Err(err.into()),
}
}

/// Raw `refresh_tokens` row as loaded for the refresh grant.
struct RefreshTokenRow {
user_id: String,
client_id: Option<String>,
granted_scopes: String,
revoked_at: Option<i64>,
expires_at: i64,
}

pub struct AuthDb {
conn: Connection,
}
Expand Down Expand Up @@ -220,24 +253,30 @@ impl AuthDb {
value BLOB NOT NULL
);",
)?;
// Migrate existing DBs that lack the new columns (SQLite returns an
// error if the column already exists; we intentionally ignore it).
let _ = conn.execute_batch(
// Migrate existing DBs that lack the new columns (SQLite has no
// `ADD COLUMN IF NOT EXISTS`; a duplicate-column error means the
// migration already ran).
apply_column_migration(
&conn,
"ALTER TABLE authorization_codes ADD COLUMN redirect_uri TEXT NOT NULL DEFAULT '';",
);
let _ = conn.execute_batch(
)?;
apply_column_migration(
&conn,
"ALTER TABLE authorization_codes ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;",
);
)?;
// ADR-0020 slice 3: per-token scope persistence.
let _ = conn.execute_batch(
apply_column_migration(
&conn,
"ALTER TABLE authorization_codes ADD COLUMN granted_scopes TEXT NOT NULL DEFAULT '';",
);
let _ = conn.execute_batch(
)?;
apply_column_migration(
&conn,
"ALTER TABLE access_tokens ADD COLUMN granted_scopes TEXT NOT NULL DEFAULT '';",
);
let _ = conn.execute_batch(
)?;
apply_column_migration(
&conn,
"ALTER TABLE refresh_tokens ADD COLUMN granted_scopes TEXT NOT NULL DEFAULT '';",
);
)?;
for statement in [
"ALTER TABLE access_tokens ADD COLUMN client_id TEXT;",
"ALTER TABLE access_tokens ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;",
Expand All @@ -246,17 +285,17 @@ impl AuthDb {
"ALTER TABLE refresh_tokens ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;",
"ALTER TABLE refresh_tokens ADD COLUMN revoked_at INTEGER;",
] {
let _ = conn.execute_batch(statement);
apply_column_migration(&conn, statement)?;
}
let now = now_unix();
let _ = conn.execute(
conn.execute(
"UPDATE access_tokens SET expires_at = ?1 WHERE expires_at = 0",
[now + ACCESS_TOKEN_TTL_SECS],
);
let _ = conn.execute(
)?;
conn.execute(
"UPDATE refresh_tokens SET expires_at = ?1 WHERE expires_at = 0",
[now + REFRESH_TOKEN_TTL_SECS],
);
)?;
Ok(Self { conn })
}

Expand Down Expand Up @@ -473,14 +512,13 @@ impl AuthDb {
params![now, code],
)?;
let scopes = decode_scopes(&granted_scopes);
self.issue_token_pair(&user_id, Some(client_id), None, &scopes)
self.issue_token_pair(&user_id, Some(client_id), &scopes)
}

fn issue_token_pair(
&self,
user_id: &str,
client_id: Option<&str>,
existing_refresh_token: Option<String>,
granted_scopes: &[String],
) -> anyhow::Result<TokenPair> {
let access_token = random_session_id();
Expand All @@ -490,7 +528,7 @@ impl AuthDb {
client_id,
granted_scopes,
)?;
let refresh_token = existing_refresh_token.unwrap_or_else(random_session_id);
let refresh_token = random_session_id();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="crates/aver-server/src/auth.rs"

echo '--- outline ---'
ast-grep outline "$FILE" --view expanded || true

echo '--- lines 480-640 ---'
sed -n '480,640p' "$FILE" | cat -n

echo '--- search for refresh token / transaction keywords ---'
rg -n "refresh_token|transaction|begin|commit|rollback|affected|execute|query" "$FILE"

Repository: 5queezer/aver

Length of output: 13032


🏁 Script executed:

sed -n '500,620p' crates/aver-server/src/auth.rs | cat -n

Repository: 5queezer/aver

Length of output: 5902


🏁 Script executed:

#!/bin/bash
set -euo pipefail
grep -n "let refresh_token = random_session_id();" -n crates/aver-server/src/auth.rs

Repository: 5queezer/aver

Length of output: 204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- refresh_access_token call sites ---'
rg -n "refresh_access_token\(" crates -g '!target'

echo '--- transaction usage in auth.rs ---'
rg -n "transaction|BEGIN|COMMIT|ROLLBACK|savepoint|with_transaction|unchecked_transaction" crates/aver-server/src/auth.rs

echo '--- auth db open / connection setup ---'
sed -n '175,240p' crates/aver-server/src/auth.rs | cat -n

Repository: 5queezer/aver

Length of output: 1403


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path("crates/aver-server/src/auth.rs")
text = path.read_text()

def extract(fn_name):
    m = re.search(rf'(?m)^\s*(?:pub\s+)?fn\s+{re.escape(fn_name)}\b', text)
    if not m:
        return None
    start = m.start()
    brace = text.find("{", m.end())
    depth = 0
    i = brace
    while i < len(text):
        c = text[i]
        if c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return text[start:i+1]
        i += 1
    return None

for fn in ["issue_token_pair", "refresh_access_token", "revoke_token_family"]:
    body = extract(fn)
    print(f"--- {fn} ---")
    if body:
        print(body)
    else:
        print("not found")

print("--- transaction keywords in auth.rs ---")
for kw in ["transaction(", "begin", "commit", "rollback", "savepoint"]:
    hits = [m.start() for m in re.finditer(kw, text, flags=re.I)]
    print(kw, len(hits), hits[:10])
PY

Repository: 5queezer/aver

Length of output: 4947


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '420,455p' crates/aver-server/src/http.rs | cat -n

Repository: 5queezer/aver

Length of output: 1807


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- AuthDb struct and open ---'
sed -n '175,240p' crates/aver-server/src/auth.rs | cat -n

echo '--- any Clone/Arc/Mutex around AuthDb in repo ---'
rg -n "AuthDb|Arc<Mutex<|Mutex<.*AuthDb|Connection" crates/aver-server/src -g '!target'

Repository: 5queezer/aver

Length of output: 5596


Move refresh rotation into one transaction. refresh_access_token() revokes the presented token, then issue_token_pair() persists the replacement rows in separate statements. If anything fails between those writes, the old token is dead and no successor exists. Make the revoke conditional, require one affected row, and commit revoke + mint together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aver-server/src/auth.rs` at line 531, Update refresh_access_token() so
token revocation and issue_token_pair() persistence execute within one database
transaction. Make revocation conditional on the presented token, require exactly
one affected row before minting, and commit both the revocation and
replacement-row inserts together so any failure rolls back the entire rotation.

let now = time::OffsetDateTime::now_utc().unix_timestamp();
let scopes = encode_scopes(granted_scopes);
self.conn.execute(
Expand All @@ -514,22 +552,64 @@ impl AuthDb {

pub fn refresh_access_token(&self, refresh_token: &str) -> anyhow::Result<TokenPair> {
let token_hash = hash_token(refresh_token);
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let (user_id, client_id, granted_scopes): (String, Option<String>, String) =
self.conn.query_row(
"SELECT user_id, client_id, granted_scopes
FROM refresh_tokens
WHERE token_hash = ?1 AND revoked_at IS NULL AND expires_at > ?2",
params![token_hash, now],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
let now = now_unix();
let row: Option<RefreshTokenRow> = self
.conn
.query_row(
"SELECT user_id, client_id, granted_scopes, revoked_at, expires_at
FROM refresh_tokens
WHERE token_hash = ?1",
params![token_hash],
|row| {
Ok(RefreshTokenRow {
user_id: row.get(0)?,
client_id: row.get(1)?,
granted_scopes: row.get(2)?,
revoked_at: row.get(3)?,
expires_at: row.get(4)?,
})
},
)
.optional()?;
let Some(row) = row else {
anyhow::bail!("unknown refresh token");
};
if row.revoked_at.is_some() {
// Reuse of an already-rotated (or revoked) refresh token: treat
// as potential token theft and kill the whole family for this
// (user, client), RFC 6819 §5.2.2.3 style.
self.revoke_token_family(&row.user_id, row.client_id.as_deref())?;
anyhow::bail!("refresh token reuse detected; token family revoked");
Comment on lines +577 to +582

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Track the actual refresh-token family.

Keying reuse revocation by (user_id, client_id) lets any retired token from an older login or consent generation revoke every current login for that client. Persist a family identifier, inherit it during rotation, and revoke only that lineage.

Also applies to: 596-610

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aver-server/src/auth.rs` around lines 577 - 582, Update the
refresh-token model and rotation flow around the revoked-token handling in the
authentication implementation to persist a unique family identifier, inherit the
existing identifier when issuing rotated tokens, and assign a new identifier for
a fresh login or consent generation. Change revoke_token_family and its callers
to target that family identifier rather than the broad user/client pair,
ensuring reuse revokes only the affected lineage while preserving current-token
behavior.

}
anyhow::ensure!(now < row.expires_at, "refresh token expired");

// Rotate: retire the presented token, mint a fresh pair. The old
// refresh token is invalid from this point on.
self.conn.execute(
"UPDATE refresh_tokens SET revoked_at = ?2 WHERE token_hash = ?1",
params![token_hash, now],
)?;
let scopes = decode_scopes(&row.granted_scopes);
self.issue_token_pair(&row.user_id, row.client_id.as_deref(), &scopes)
}

/// Revokes every live access and refresh token for a `(user, client)`
/// pair. Used when a rotated refresh token is presented again.
fn revoke_token_family(&self, user_id: &str, client_id: Option<&str>) -> anyhow::Result<()> {
let now = now_unix();
for table in ["access_tokens", "refresh_tokens"] {
self.conn.execute(
&format!(
"UPDATE {table}
SET revoked_at = ?3
WHERE user_id = ?1
AND revoked_at IS NULL
AND (client_id = ?2 OR (client_id IS NULL AND ?2 IS NULL))"
),
params![user_id, client_id, now],
)?;
let scopes = decode_scopes(&granted_scopes);
self.issue_token_pair(
&user_id,
client_id.as_deref(),
Some(refresh_token.to_string()),
&scopes,
)
}
Ok(())
}

/// Returns the access-token row's `(user_id, granted_scopes_raw)` if the
Expand Down
5 changes: 4 additions & 1 deletion crates/aver-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ pub struct ServerConfig {

impl ServerConfig {
pub fn from_env() -> anyhow::Result<Self> {
use anyhow::Context;

let host = std::env::var("AVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("AVER_PORT")
.unwrap_or_else(|_| "3317".to_string())
.parse()?;
.parse()
.context("invalid AVER_PORT")?;
let base_url =
std::env::var("AVER_BASE_URL").unwrap_or_else(|_| format!("http://{host}:{port}"));
let memory_dir = std::env::var("AVER_MEMORY_DIR").unwrap_or_else(|_| ".aver".to_string());
Expand Down
Loading
Loading