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
13 changes: 13 additions & 0 deletions backend/src/control_center/api/routes_auth_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ async def auth_refresh_proxy(request: Request) -> JSONResponse:
return await _proxy_to_auth("/auth/refresh", request)


# Mode B Phase 2: Workspace Switcher. Same _proxy_to_auth helper as every
# other route in this file, no new logic -- the request body (team_id,
# optionally refresh_token) and the omnibioai_session cookie both pass
# through unmodified, and auth-service's own response (access_token/
# refresh_token JSON body + its Set-Cookie) is relayed back exactly like
# /auth/login and /auth/refresh already are. This service never inspects
# or trusts team_id itself -- it's an opaque relay, same posture as
# every other route here.
@router.post("/auth/switch-team")
async def auth_switch_team_proxy(request: Request) -> JSONResponse:
return await _proxy_to_auth("/auth/switch-team", request)


@router.post("/auth/logout")
async def auth_logout_proxy(request: Request) -> JSONResponse:
# SSO Phase 2 PR13: auth-service's LogoutRequest.refresh_token is still
Expand Down
72 changes: 72 additions & 0 deletions backend/tests/test_routes_auth_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,78 @@ def test_auth_service_unreachable_returns_503(self) -> None:
self.assertEqual(resp.status_code, 503)


class TestSwitchTeamProxy(unittest.TestCase):
"""Mode B Phase 2: /auth/switch-team -- same _proxy_to_auth relay
every other route in this file already uses, no new logic. This
service never inspects team_id itself; these tests only prove the
relay (path/body/status/cookie), matching TestAuthRefreshProxy's own
shape exactly."""

def test_request_reaches_auth_service_at_correct_path(self) -> None:
upstream = _mock_response(200, {"access_token": "new-tok", "refresh_token": "new-rtok"})
mock_ctx = _mock_async_client(upstream)
with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=mock_ctx):
client.post("/auth/switch-team", json={"team_id": 7})
call_args = mock_ctx.__aenter__.return_value.post.call_args
self.assertEqual(call_args.args[0], "http://auth-service:8001/auth/switch-team")

def test_successful_response_returned(self) -> None:
upstream = _mock_response(200, {"access_token": "new-tok", "refresh_token": "new-rtok"})
with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)):
resp = client.post("/auth/switch-team", json={"team_id": 7})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["access_token"], "new-tok")

def test_null_team_id_forwarded_unchanged_for_personal_workspace(self) -> None:
"""team_id: null is a real, meaningful request (switch back to
the personal workspace), not an omitted/optional field -- must
reach auth-service exactly as sent, not stripped or defaulted."""
upstream = _mock_response(200, {"access_token": "new-tok", "refresh_token": "new-rtok"})
mock_ctx = _mock_async_client(upstream)
with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=mock_ctx):
client.post("/auth/switch-team", json={"team_id": None})
call_kwargs = mock_ctx.__aenter__.return_value.post.call_args.kwargs
self.assertIn(b'"team_id":null', call_kwargs["content"].replace(b" ", b""))

def test_denied_response_propagated(self) -> None:
upstream = _mock_response(403, {"detail": "Not a member of this team"})
with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)):
resp = client.post("/auth/switch-team", json={"team_id": 999})
self.assertEqual(resp.status_code, 403)
self.assertEqual(resp.json()["detail"], "Not a member of this team")

def test_not_found_response_propagated(self) -> None:
upstream = _mock_response(404, {"detail": "Team not found"})
with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)):
resp = client.post("/auth/switch-team", json={"team_id": 999})
self.assertEqual(resp.status_code, 404)

def test_auth_service_unreachable_returns_503(self) -> None:
with patch(
"control_center.api.routes_auth_proxy.httpx.AsyncClient",
return_value=_mock_async_client(side_effect=httpx.ConnectError("refused")),
):
resp = client.post("/auth/switch-team", json={"team_id": 7})
self.assertEqual(resp.status_code, 503)

def test_forwards_set_cookie_to_browser(self) -> None:
upstream = _mock_response(
200, {"access_token": "new-tok", "refresh_token": "new-rtok"},
set_cookies=["omnibioai_session=new-rtok; HttpOnly; Path=/; Domain=.omnibioai.org"],
)
with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)):
resp = client.post("/auth/switch-team", json={"team_id": 7})
self.assertIn("omnibioai_session=new-rtok", resp.headers.get("set-cookie", ""))

def test_forwards_incoming_session_cookie_upstream(self) -> None:
upstream = _mock_response(200, {"access_token": "tok"})
mock_ctx = _mock_async_client(upstream)
with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=mock_ctx):
client.post("/auth/switch-team", json={"team_id": 7}, cookies={"omnibioai_session": "cookie-rtok"})
call_kwargs = mock_ctx.__aenter__.return_value.post.call_args.kwargs
self.assertEqual(call_kwargs["headers"]["Cookie"], "omnibioai_session=cookie-rtok")


class TestSessionCookieForwarding(unittest.TestCase):
"""SSO Phase 2 PR13: _proxy_to_auth relays the omnibioai_session cookie
in both directions -- previously it silently dropped Set-Cookie from
Expand Down
6 changes: 3 additions & 3 deletions frontend/cc-ui/src/apps/AdminApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,15 @@ vi.mock('../pages/billing/BillingPage', () => ({

const admin: SessionUser = {
userId: '1', email: 'admin@omnibioai.org', roles: ['admin'],
permissions: ['manage_config'], orgId: null, orgRoles: [], schemaVersion: 2,
permissions: ['manage_config'], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2,
}
const nonAdmin: SessionUser = {
userId: '2', email: 'no-perms@omnibioai.org', roles: ['user'],
permissions: [], orgId: null, orgRoles: [], schemaVersion: 2,
permissions: [], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2,
}
const orgOnlyUser: SessionUser = {
userId: '3', email: 'org-admin@acme.test', roles: ['user'],
permissions: ['manage_org'], orgId: '9', orgRoles: ['org_admin'], schemaVersion: 2,
permissions: ['manage_org'], orgId: '9', orgRoles: ['org_admin'], teamId: null, teamRole: null, schemaVersion: 2,
}

/** Clicks a SidebarNav item by its visible label -- the click lands on
Expand Down
6 changes: 3 additions & 3 deletions frontend/cc-ui/src/apps/ControlApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ vi.mock('../pages/CloudPage', () => ({ default: () => <div data-testid="CloudPag

const admin: SessionUser = {
userId: '1', email: 'admin@omnibioai.org', roles: ['admin'],
permissions: ['manage_config'], orgId: null, orgRoles: [], schemaVersion: 2,
permissions: ['manage_config'], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2,
}
const nonAdmin: SessionUser = {
userId: '2', email: 'no-perms@omnibioai.org', roles: ['user'],
permissions: [], orgId: null, orgRoles: [], schemaVersion: 2,
permissions: [], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2,
}
const orgOnlyUser: SessionUser = {
userId: '3', email: 'org-admin@acme.test', roles: ['user'],
permissions: ['manage_org'], orgId: '9', orgRoles: ['org_admin'], schemaVersion: 2,
permissions: ['manage_org'], orgId: '9', orgRoles: ['org_admin'], teamId: null, teamRole: null, schemaVersion: 2,
}

describe('ControlApp auth gate (existing behavior, unchanged)', () => {
Expand Down
196 changes: 196 additions & 0 deletions frontend/cc-ui/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,202 @@ describe('logout', () => {
})
})

// ── Mode B Phase 2: Workspace Switcher ──────────────────────────────────────

describe('switchTeam', () => {
it('POSTs /auth/switch-team with team_id and no X-Team-Id/X-Workspace-Id header', async () => {
const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
const fetchMock = mockFetchByUrl({
'/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'refresh-new' }),
'/auth/validate': jsonResponse({ ...validUser, team_id: '7', team_role: 'admin' }),
})
vi.stubGlobal('fetch', fetchMock)

await auth.switchTeam(7)

expect(fetchMock).toHaveBeenCalledWith(
'/auth/switch-team',
expect.objectContaining({ body: JSON.stringify({ team_id: 7 }) }),
)
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
const headers = init.headers as Record<string, string>
expect(headers['X-Team-Id']).toBeUndefined()
expect(headers['X-Workspace-Id']).toBeUndefined()
})

it('sends team_id: null (not omitted) for "switch to personal workspace"', async () => {
const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
vi.stubGlobal('fetch', mockFetchByUrl({
'/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }),
'/auth/validate': jsonResponse({ ...validUser, team_id: null, team_role: null }),
}))

await auth.switchTeam(null)

const fetchMock = vi.mocked(fetch)
expect(fetchMock).toHaveBeenCalledWith(
'/auth/switch-team',
expect.objectContaining({ body: JSON.stringify({ team_id: null }) }),
)
})

it('on success, persists the new access token and rehydrates teamId/teamRole', async () => {
const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
vi.stubGlobal('fetch', mockFetchByUrl({
'/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }),
'/auth/validate': jsonResponse({ ...validUser, team_id: '7', team_role: 'admin' }),
}))

const user = await auth.switchTeam(7)

expect(localStorage.getItem('omnibioai_access_token')).toBe(newToken)
expect(user.teamId).toBe('7')
expect(user.teamRole).toBe('admin')
expect(auth.getSessionUser()?.teamId).toBe('7')
})

it('Team A -> Team B: teamId updates to the newly selected team', async () => {
const tokenB = makeToken({ sub: '1', exp: nowSeconds() + 900 })
vi.stubGlobal('fetch', mockFetchByUrl({
'/auth/switch-team': jsonResponse({ access_token: tokenB, refresh_token: 'r2' }),
'/auth/validate': jsonResponse({ ...validUser, team_id: '2', team_role: 'member' }),
}))

const user = await auth.switchTeam(2)
expect(user.teamId).toBe('2')
})

it('Team -> Personal: teamId becomes null', async () => {
const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
vi.stubGlobal('fetch', mockFetchByUrl({
'/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }),
'/auth/validate': jsonResponse({ ...validUser, team_id: null, team_role: null }),
}))

const user = await auth.switchTeam(null)
expect(user.teamId).toBeNull()
expect(user.teamRole).toBeNull()
})

it('Personal -> Team: teamId becomes the selected team', async () => {
const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
vi.stubGlobal('fetch', mockFetchByUrl({
'/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }),
'/auth/validate': jsonResponse({ ...validUser, team_id: '5', team_role: 'viewer' }),
}))

const user = await auth.switchTeam(5)
expect(user.teamId).toBe('5')
})

it('401: reports unauthorized and throws a session-expired message, without leaking which team/org', async () => {
localStorage.setItem('omnibioai_access_token', 'stale-token')
vi.stubGlobal('fetch', vi.fn(async () => ({
status: 401, ok: false, json: async () => ({ detail: 'Invalid refresh token' }),
} as unknown as Response)))

let thrown: Error | null = null
try {
await auth.switchTeam(7)
} catch (e) {
thrown = e as Error
}
expect(thrown?.message).toMatch(/session has expired/i)
// reportUnauthorized() already clears the token -- same forced-logout
// path every other 401 in this app goes through.
expect(localStorage.getItem('omnibioai_access_token')).toBeNull()
})

it('403: throws a generic denial message that does not name the org/team boundary', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
status: 403, ok: false, json: async () => ({ detail: 'Not a member of this team' }),
} as unknown as Response)))

let thrown: Error | null = null
try {
await auth.switchTeam(7)
} catch (e) {
thrown = e as Error
}
expect(thrown?.message).not.toMatch(/not a member/i)
expect(thrown?.message).not.toMatch(/organization/i)
expect(thrown?.message).toBeTruthy()
})

it('404: throws the same generic denial message as 403 -- does not reveal existence', async () => {
let thrown403: Error | null = null
vi.stubGlobal('fetch', vi.fn(async () => ({ status: 403, ok: false, json: async () => ({}) } as unknown as Response)))
try { await auth.switchTeam(7) } catch (e) { thrown403 = e as Error }

let thrown404: Error | null = null
vi.stubGlobal('fetch', vi.fn(async () => ({ status: 404, ok: false, json: async () => ({ detail: 'Team not found' }) } as unknown as Response)))
try { await auth.switchTeam(7) } catch (e) { thrown404 = e as Error }

expect(thrown404?.message).toBe(thrown403?.message)
})

it('network failure: throws a generic connectivity message, does not clear the session', async () => {
localStorage.setItem('omnibioai_access_token', 'still-valid-token')
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch') }))

let thrown: Error | null = null
try {
await auth.switchTeam(7)
} catch (e) {
thrown = e as Error
}
expect(thrown).not.toBeNull()
// Unlike a 401, a network blip is not a session problem -- the
// previously valid token must survive it (same fail-open posture
// silent refresh's own network-failure path already has).
expect(localStorage.getItem('omnibioai_access_token')).toBe('still-valid-token')
})

it('successful switch followed by a validate failure throws rather than silently succeeding', async () => {
const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
let call = 0
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
call += 1
if (url === '/auth/switch-team') {
return jsonResponse({ access_token: newToken, refresh_token: 'r' }) as unknown as Response
}
// /auth/validate network failure right after a successful switch.
throw new TypeError('Failed to fetch')
}))

let thrown: Error | null = null
try {
await auth.switchTeam(7)
} catch (e) {
thrown = e as Error
}
expect(thrown).not.toBeNull()
expect(call).toBeGreaterThanOrEqual(2)
// The new token is still persisted (switch itself did succeed) even
// though this call couldn't confirm it -- not rolled back, matching
// validateSession's own existing fail-open behavior for a network
// blip.
expect(localStorage.getItem('omnibioai_access_token')).toBe(newToken)
})
})

describe('logout clears team state', () => {
it('getSessionUser().teamId is gone after logout (cachedUser fully cleared, not merged)', async () => {
const accessToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
vi.stubGlobal('fetch', mockFetchByUrl({
'/auth/login': jsonResponse({ access_token: accessToken, refresh_token: 'refresh-abc' }),
'/auth/validate': jsonResponse({ ...validUser, team_id: '7', team_role: 'admin' }),
}))
await auth.login('admin@omnibioai.org', 'password')
expect(auth.getSessionUser()?.teamId).toBe('7')

vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ message: 'Logged out' }) as unknown as Response))
await auth.logout()

expect(auth.getSessionUser()).toBeNull()
})
})

describe('clearToken', () => {
it('clears cached session state (getSessionUser)', async () => {
const accessToken = makeToken({ sub: '1', exp: nowSeconds() + 900 })
Expand Down
Loading
Loading