Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
67 changes: 67 additions & 0 deletions src/app/logout/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* © Copyright Union Systems Inc 2026. All rights reserved.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'

import { safeRedirectPath } from '@/lib/urlUtils'

import { GET } from './route'

const cookieJar = vi.hoisted(() => ({ names: [] as string[] }))
vi.mock('next/headers', () => ({
cookies: async () => ({
getAll: () => cookieJar.names.map((name) => ({ name, value: 'x' })),
}),
}))

describe('safeRedirectPath', () => {
it('keeps same-origin absolute paths', () => {
expect(safeRedirectPath('/v2/projects/foo')).toBe('/v2/projects/foo')
})

it('rejects off-site redirects', () => {
for (const bad of ['//evil.com', 'https://evil.com', '/\\evil.com', null]) {
expect(safeRedirectPath(bad)).toBe('/v2/projects')
}
})
})

describe('GET /v2/logout', () => {
beforeEach(() => {
cookieJar.names = []
delete process.env.OIDC_LOGOUT_URL
})

it('expires every ALB session cookie shard and redirects', async () => {
cookieJar.names = [
'AWSELBAuthSessionCookie-0',
'AWSELBAuthSessionCookie-1',
'unrelated',
]
const res = await GET(new Request('https://flyte.example/v2/logout'))

expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('https://flyte.example/v2/projects')

const setCookie = res.headers.getSetCookie()
expect(setCookie).toHaveLength(2)
expect(setCookie.every((c) => c.includes('Max-Age=0'))).toBe(true)
expect(setCookie.some((c) => c.includes('unrelated'))).toBe(false)
})

it('redirects to the IdP when OIDC_LOGOUT_URL is set', async () => {
process.env.OIDC_LOGOUT_URL = 'https://okta.example/oauth2/v1/logout'
const res = await GET(new Request('https://flyte.example/v2/logout'))
expect(res.headers.get('location')).toBe(
'https://okta.example/oauth2/v1/logout',
)
})

it('ignores an attacker-supplied redirect_url', async () => {
const res = await GET(
new Request('https://flyte.example/v2/logout?redirect_url=//evil.com'),
)
expect(res.headers.get('location')).toBe('https://flyte.example/v2/projects')
})
})
48 changes: 48 additions & 0 deletions src/app/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* © Copyright Union Systems Inc 2026. All rights reserved.
*/

import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'

import { safeRedirectPath } from '@/lib/urlUtils'

export const dynamic = 'force-dynamic'

/**
* ALB shards the OIDC session across `AWSELBAuthSessionCookie-0`, `-1`, … as the
* token grows, so we expire every cookie with this prefix rather than a fixed pair.
*/
const ALB_SESSION_COOKIE_PREFIX = 'AWSELBAuthSessionCookie'

/** ALB sets its session cookies host-only on `/`; deletion must match. */
const EXPIRED = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax'

/**
* Signs the user out by expiring the ALB OIDC session cookies.
*
* ponytail: clearing the cookie only ends the *ALB* session. If the IdP session is
* still live, the next request re-authenticates silently — set `OIDC_LOGOUT_URL`
* (e.g. the Okta `/v1/logout` endpoint) to end that one too.
Comment thread
pingsutw marked this conversation as resolved.
Outdated
*/
export async function GET(request: Request) {
const url = new URL(request.url)
const target =
process.env.OIDC_LOGOUT_URL ||
new URL(safeRedirectPath(url.searchParams.get('redirect_url')), url.origin)
.toString()

const response = NextResponse.redirect(target, 302)

const names = (await cookies())
.getAll()
.map((c) => c.name)
.filter((name) => name.startsWith(ALB_SESSION_COOKIE_PREFIX))

// Nothing to clear (no ALB in front, or already signed out) — still redirect.
for (const name of names) {
response.headers.append('set-cookie', `${name}=; ${EXPIRED}`)
}

return response
}
33 changes: 33 additions & 0 deletions src/components/Header/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* © Copyright Union Systems Inc 2026. All rights reserved.
*/

import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'

import { Header } from './Header'

const identity = vi.hoisted(() => ({ data: undefined as unknown }))
vi.mock('@/hooks/useIdentity', () => ({ useIdentity: () => identity }))

describe('Header', () => {
it('renders no user menu when there is no identity', () => {
identity.data = undefined
render(<Header />)
expect(screen.queryByLabelText('User menu')).not.toBeInTheDocument()
})

it('shows the user name and a Sign out link', async () => {
identity.data = { givenName: 'Kevin', familyName: 'Su', email: '', subject: 'k' }
render(<Header />)
expect(screen.getByText('Kevin Su')).toBeInTheDocument()

await userEvent.click(screen.getByLabelText('User menu'))
expect(screen.getByRole('link', { name: 'Sign out' })).toHaveAttribute(
'href',
expect.stringContaining('/logout'),
)
})
})
62 changes: 61 additions & 1 deletion src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,79 @@

'use client'

import React from 'react'
import React, { useMemo, useState } from 'react'

import { type MenuItem, PopoverMenu } from '@/components/Popovers'
import { UserIcon } from '@/components/UserIdentityInfo'
import { useIdentity } from '@/hooks/useIdentity'
import { getLogoutUrl } from '@/lib/apiUtils'
import { resolveUserNameFields } from '@/lib/userIdentityUtils'

interface HeaderProps {
logoComponent?: React.ReactNode
showSearch?: boolean
}

export function Header({ logoComponent }: HeaderProps) {
const { data: identity } = useIdentity()
const [menuOpen, setMenuOpen] = useState(false)

const menuItems: MenuItem[] = useMemo(
() => [
{
id: 'logout',
type: 'custom',
component: (
<a
href={getLogoutUrl()}
className="flex w-full cursor-pointer items-center px-4.5 py-2 text-[13px] text-(--system-gray-6)"
onClick={() => setMenuOpen(false)}
>
Sign out
</a>
),
},
],
[],
)

const { firstName, lastName, userIdentityString } = resolveUserNameFields(
identity?.givenName,
identity?.familyName,
identity?.email,
identity?.subject,
)

return (
<div
className={`flex h-[56px] w-full items-center justify-between bg-(--system-gray-1) px-5 py-3`}
>
{logoComponent ? logoComponent : <div />}
{/* No identity — nothing is authenticating this deployment, so no menu. */}
{identity && (
<PopoverMenu
items={menuItems}
showChevron={false}
showCheckboxes={false}
noSelectedBackground={true}
open={menuOpen}
onOpenChange={setMenuOpen}
placement="bottom-end"
>
<button
type="button"
className="cursor-pointer rounded-full !p-0 focus:ring-2 focus:ring-(--union) focus:ring-offset-2 focus:ring-offset-(--system-gray-1) focus:outline-none"
aria-label="User menu"
>
<UserIcon
firstName={firstName}
lastName={lastName}
userIdentityString={userIdentityString}
showUserName
/>
</button>
</PopoverMenu>
)}
</div>
)
}
28 changes: 28 additions & 0 deletions src/hooks/useIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* © Copyright Union Systems Inc 2026. All rights reserved.
*/

import { useQuery } from '@tanstack/react-query'

import { IdentityService } from '@/gen/flyteidl2/auth/identity_pb'
import { isBrowser } from '@/lib/windowUtils'

import { useConnectRpcClient } from './useConnectRpc'

/**
* Current user, or null when the deployment has no auth in front of it
* (UserInfo answers Unauthenticated when identity headers aren't trusted).
*/
export function useIdentity() {
const client = useConnectRpcClient(IdentityService)

return useQuery({
queryKey: ['identity'],
// ponytail: swallow the error instead of letting it reach the query cache —
// an errored query trips AuthStatusProvider's refresh/login-panel flow, and
// "this deployment has no auth" is not an expired session.
Comment thread
pingsutw marked this conversation as resolved.
Outdated
queryFn: () => client.userInfo({}).catch(() => null),
Comment on lines +20 to +24
enabled: isBrowser(),
refetchInterval: 1000 * 60,
})
}
8 changes: 8 additions & 0 deletions src/lib/apiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export function getLoginUrl(): string {
return `${baseUrl || ''}/login?redirect_url=${encodeURIComponent(LOGIN_REDIRECT_PATH)}`
}

/**
* Logout URL. Unlike login, this is served by this app (`src/app/logout/route.ts`,
* under the `/v2` basePath) — the proxy authenticates but has no logout endpoint.
*/
export function getLogoutUrl(): string {
return `/v2/logout?redirect_url=${encodeURIComponent(LOGIN_REDIRECT_PATH)}`
}

export function createTransport(_useBinaryFormat: boolean) {
return createConnectTransport({
baseUrl: BASE_ADMIN_API,
Expand Down
15 changes: 15 additions & 0 deletions src/lib/urlUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,18 @@ export function getS3ConsoleUrl(
const encodedKey = encodeURIComponent(keyPart)
return `${base}/s3/object/${encodedBucket}?prefix=${encodedKey}`
}

/** Post-login/logout landing path. Mirrors apiUtils' `LOGIN_REDIRECT_PATH`. */
export const DEFAULT_REDIRECT_PATH = '/v2/projects'
Comment thread
pingsutw marked this conversation as resolved.
Outdated

/**
* Only same-origin absolute paths may be used as a post-logout redirect —
* the value comes off the query string, so anything else is an open redirect.
*/
export function safeRedirectPath(raw: string | null): string {
if (!raw) return DEFAULT_REDIRECT_PATH
if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) {
return DEFAULT_REDIRECT_PATH
}
return raw
}
Loading