Skip to content
Merged
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
62 changes: 59 additions & 3 deletions src-tauri/src/web/auth.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
use axum::{
extract::Request,
http::StatusCode,
http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};

pub const WS_EVENT_PROTOCOL: &str = "codeg-events";
const WS_TOKEN_PROTOCOL_PREFIX: &str = "codeg-token.";
// Sent with every 401 this middleware returns. The web client reads it to
// tell Codeg's own rejection (re-enter the token) from a 401 sent by an
// identity-aware proxy in front of Codeg whose session expired (reload, so the
// proxy can run its login flow); see `src/lib/transport/web-auth.ts`.
const CODEG_CHALLENGE: &str = r#"Bearer realm="codeg""#;

fn unauthorized(message: &'static str) -> Response {
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, CODEG_CHALLENGE)],
message,
)
.into_response()
}

fn token_from_ws_protocols(value: &str) -> Option<String> {
value
Expand All @@ -22,7 +36,7 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo
// Fail closed on a misconfigured empty token: otherwise `Bearer ` (an empty
// bearer value) would match it and silently disable authentication.
if token.is_empty() {
return (StatusCode::UNAUTHORIZED, "Server token is not configured").into_response();
return unauthorized("Server token is not configured");
}

if let Some(auth_header) = request.headers().get("authorization") {
Expand All @@ -41,7 +55,7 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo
}
}

(StatusCode::UNAUTHORIZED, "Invalid or missing token").into_response()
unauthorized("Invalid or missing token")
}

#[cfg(test)]
Expand All @@ -63,4 +77,46 @@ mod tests {
fn ignores_invalid_ws_protocol_token() {
assert!(token_from_ws_protocols("codeg-events, codeg-token.not-valid-@@@@").is_none());
}

fn guarded(server_token: &str) -> axum_test::TestServer {
let server_token = server_token.to_string();
let app = axum::Router::new()
.route("/api/health", axum::routing::post(|| async { "ok" }))
.layer(axum::middleware::from_fn(move |req, next| {
require_token(req, next, server_token.clone())
}));
axum_test::TestServer::new(app).unwrap()
}

// The web client tells Codeg's own 401 from one sent by a proxy in front
// of it (whose session expired) by this challenge; see `web-auth.ts`.
#[tokio::test]
async fn every_rejection_carries_the_codeg_challenge() {
let server = guarded("tok");

let missing = server.post("/api/health").await;
missing.assert_status_unauthorized();
assert_eq!(missing.header("www-authenticate"), CODEG_CHALLENGE);

let wrong = server
.post("/api/health")
.authorization_bearer("nope")
.await;
wrong.assert_status_unauthorized();
assert_eq!(wrong.header("www-authenticate"), CODEG_CHALLENGE);

let unconfigured = guarded("").post("/api/health").await;
unconfigured.assert_status_unauthorized();
assert_eq!(unconfigured.header("www-authenticate"), CODEG_CHALLENGE);
}

#[tokio::test]
async fn an_accepted_request_carries_no_challenge() {
let ok = guarded("tok")
.post("/api/health")
.authorization_bearer("tok")
.await;
ok.assert_status_ok();
assert!(ok.maybe_header("www-authenticate").is_none());
}
}
74 changes: 74 additions & 0 deletions src/app/login/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { NextIntlClientProvider } from "next-intl"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

const router = vi.hoisted(() => ({ replace: vi.fn() }))
vi.mock("next/navigation", () => ({ useRouter: () => router }))
vi.mock("@/lib/platform", () => ({ isDesktop: () => false }))

import LoginPage from "./page"
import enMessages from "@/i18n/messages/en.json"

const reply = (status: number, challenge?: string) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(challenge ? { "WWW-Authenticate": challenge } : {}),
})

let fetchMock: ReturnType<typeof vi.fn>

beforeEach(() => {
router.replace.mockClear()
fetchMock = vi.fn()
vi.stubGlobal("fetch", fetchMock)
})

afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})

function submit(token: string) {
render(
<NextIntlClientProvider locale="en" messages={enMessages}>
<LoginPage />
</NextIntlClientProvider>
)
fireEvent.change(screen.getByPlaceholderText("Access Token"), {
target: { value: token },
})
fireEvent.click(screen.getByRole("button", { name: "Connect" }))
}

describe("login page", () => {
it("asks a proxy for a 401 instead of a redirect", async () => {
fetchMock.mockResolvedValue(reply(200))
submit("tok")
await vi.waitFor(() => expect(router.replace).toHaveBeenCalled())
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
Authorization: "Bearer tok",
"X-Requested-With": "XMLHttpRequest",
})
})

it("calls the token invalid when Codeg rejects it", async () => {
fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"'))
submit("bad")
expect(
await screen.findByText("Invalid token. Please check and try again.")
).toBeInTheDocument()
})

it("does not blame the token when a proxy in front of Codeg refused", async () => {
fetchMock.mockResolvedValue(
reply(401, 'Cloudflare-Access resource_metadata="https://x.test/m"')
)
submit("tok")
expect(
await screen.findByText(/Your sign-in to the proxy/)
).toBeInTheDocument()
expect(
screen.queryByText("Invalid token. Please check and try again.")
).not.toBeInTheDocument()
})
})
10 changes: 9 additions & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { isDesktop } from "@/lib/platform"
import { codegAuthHeaders, unauthorizedSource } from "@/lib/transport/web-auth"

export default function LoginPage() {
const router = useRouter()
const t = useTranslations("LoginPage")
const tConnection = useTranslations("WebConnection")
const [token, setToken] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
Expand All @@ -33,14 +35,20 @@ export default function LoginPage() {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...codegAuthHeaders(token),
},
body: "{}",
})

if (res.ok) {
localStorage.setItem("codeg_token", token)
router.replace("/workspace")
} else if (
res.status === 401 &&
unauthorizedSource(res.headers.get("www-authenticate")) === "proxy"
) {
// The proxy in front of Codeg refused before the token was checked.
setError(tConnection("proxySignInDescription"))
} else if (res.status === 401) {
setError(t("invalidToken"))
} else {
Expand Down
59 changes: 59 additions & 0 deletions src/app/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

const router = vi.hoisted(() => ({ replace: vi.fn() }))
vi.mock("next/navigation", () => ({ useRouter: () => router }))
vi.mock("@/lib/platform", () => ({ isDesktop: () => false }))

import Page from "./page"

const reply = (status: number, challenge?: string) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(challenge ? { "WWW-Authenticate": challenge } : {}),
})

let fetchMock: ReturnType<typeof vi.fn>

beforeEach(() => {
router.replace.mockClear()
localStorage.setItem("codeg_token", "tok")
fetchMock = vi.fn()
vi.stubGlobal("fetch", fetchMock)
})

afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})

describe("root page token check", () => {
it("asks a proxy for a 401 instead of a redirect", async () => {
fetchMock.mockResolvedValue(reply(200))
render(<Page />)
await waitFor(() => expect(router.replace).toHaveBeenCalled())
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
Authorization: "Bearer tok",
"X-Requested-With": "XMLHttpRequest",
})
})

it("clears the token and goes to /login when Codeg rejects it", async () => {
fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"'))
render(<Page />)
await waitFor(() => expect(router.replace).toHaveBeenCalledWith("/login"))
expect(localStorage.getItem("codeg_token")).toBeNull()
})

it("keeps the token when the 401 came from a proxy in front of Codeg", async () => {
fetchMock.mockResolvedValue(
reply(401, 'Cloudflare-Access resource_metadata="https://x.test/m"')
)
render(<Page />)
// The workspace's connection guard offers the reload from there.
await waitFor(() =>
expect(router.replace).toHaveBeenCalledWith("/workspace")
)
expect(localStorage.getItem("codeg_token")).toBe("tok")
})
})
13 changes: 9 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { isDesktop } from "@/lib/platform"
import { codegAuthHeaders, unauthorizedSource } from "@/lib/transport/web-auth"

export default function Page() {
const router = useRouter()
Expand All @@ -22,7 +23,7 @@ export default function Page() {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...codegAuthHeaders(token),
},
body: "{}",
})
Expand All @@ -31,14 +32,18 @@ export default function Page() {
router.replace("/workspace")
return
}
if (res.status === 401) {
if (
res.status === 401 &&
unauthorizedSource(res.headers.get("www-authenticate")) === "codeg"
) {
// Token genuinely rejected → clear it and re-authenticate.
localStorage.removeItem("codeg_token")
router.replace("/login")
return
}
// Server reachable but unhealthy (5xx / proxy error). Keep the token
// and enter the app; the in-app reconnect dialog handles recovery
// Server reachable but unhealthy (5xx / proxy error), or a proxy in
// front of Codeg refused (its own session expired). Keep the token
// and enter the app; the in-app connection dialog handles recovery
// instead of bouncing a valid session to /login.
router.replace("/workspace")
})
Expand Down
30 changes: 28 additions & 2 deletions src/components/connection/web-connection-guard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
// Controllable stand-in for the connection store. `vi.hoisted` guarantees the
// shared object exists before the hoisted `vi.mock` factory runs.
const store = vi.hoisted(() => {
let state: "connected" | "reconnecting" | "unauthorized" = "connected"
type State =
| "connected"
| "reconnecting"
| "unauthorized"
| "proxyUnauthorized"
let state: State = "connected"
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (s: "connected" | "reconnecting" | "unauthorized") => {
setState: (s: State) => {
state = s
for (const l of listeners) l()
},
Expand Down Expand Up @@ -105,6 +110,27 @@ describe("WebConnectionGuard", () => {
expect(store.redirectToCodegLogin).toHaveBeenCalledTimes(1)
})

it("offers a reload, not the token login, when the proxy's sign-in expired", () => {
const reload = vi.fn()
vi.stubGlobal("location", { ...window.location, reload })
renderGuard()
act(() => store.setState("proxyUnauthorized"))
// Surfaced at once, like unauthorized: a rejected session is definitive.
expect(screen.getByText("Sign-in expired")).toBeInTheDocument()

fireEvent.click(screen.getByRole("button", { name: "Reload" }))
expect(reload).toHaveBeenCalledTimes(1)
expect(store.redirectToCodegLogin).not.toHaveBeenCalled()
vi.unstubAllGlobals()
})

it("still offers the token login from the proxy dialog", () => {
renderGuard()
act(() => store.setState("proxyUnauthorized"))
fireEvent.click(screen.getByRole("button", { name: "Go to login" }))
expect(store.redirectToCodegLogin).toHaveBeenCalledTimes(1)
})

it("dismisses automatically once the connection recovers", () => {
renderGuard()
act(() => store.setState("reconnecting"))
Expand Down
Loading
Loading