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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
"dependencies": {
"@aws-sdk/client-sns": "^3.1145.0",
"@monaco-editor/react": "^4.7.0",
"@robosystems/client": "^2.34.0",
"@robosystems/core": "^0.17.0",
"@robosystems/client": "^2.34.1",
"@robosystems/core": "^0.17.1",
"@simplewebauthn/browser": "14.0.0",
"date-fns": "^4.1.0",
"flowbite": "^3.1",
Expand Down
14 changes: 12 additions & 2 deletions src/components/auth/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PlatformIdentityHeader } from './PlatformIdentityHeader'
import {
markBridgeAttempt,
parseDestination,
parseNonceHash,
shouldSuppressAutoBridge,
useCarriedAuthParams,
} from './return-to'
Expand Down Expand Up @@ -78,6 +79,12 @@ export const SIGN_IN_NOTICES: Record<string, string> = {
bridge_failed: "Automatic sign-in didn't complete. Sign in to continue.",
}

/** The product app's nonce hash, read at bridge time; see `parseNonceHash`. */
const bridgeNonceHash = (): string | undefined =>
parseNonceHash(
new URLSearchParams(window.location.search).get('nonce_hash')
) ?? undefined

interface BridgeInterstitial {
user: AuthUser
targetApp: string
Expand Down Expand Up @@ -156,7 +163,9 @@ export function SignInForm({
setRedirecting(true)
if (destination?.kind === 'app' && rawReturnTo) {
markBridgeAttempt(rawReturnTo)
await navigateToApp(destination.app, destination.path ?? undefined)
await navigateToApp(destination.app, destination.path ?? undefined, {
nonceHash: bridgeNonceHash(),
})
return
}
window.location.href =
Expand Down Expand Up @@ -315,7 +324,8 @@ export function SignInForm({
markBridgeAttempt(interstitial.returnTo)
await navigateToApp(
interstitial.targetApp,
interstitial.targetPath ?? undefined
interstitial.targetPath ?? undefined,
{ nonceHash: bridgeNonceHash() }
)
}

Expand Down
43 changes: 40 additions & 3 deletions src/components/auth/__tests__/SignInForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const mockUser = {
}

const originalLocation = window.location
// The hash of the nonce the product app minted when it sent the browser here.
const NONCE_HASH = 'ab'.repeat(32)

function fakeLocation(search: string) {
delete (window as { location?: Location }).location
Expand Down Expand Up @@ -103,7 +105,9 @@ describe('SignInForm (login home fork)', () => {
})

it('silently bridges an authenticated visit with a cross-app return_to', async () => {
fakeLocation('?return_to=roboledger%3A%2Freports%2Fx')
fakeLocation(
`?return_to=roboledger%3A%2Freports%2Fx&nonce_hash=${NONCE_HASH}`
)
mockedGetCurrentAuthUser.mockResolvedValue({
data: { user: mockUser },
} as never)
Expand All @@ -117,11 +121,42 @@ describe('SignInForm (login home fork)', () => {
)
})
expect(String(window.location.href)).toContain('returnUrl=%2Freports%2Fx')
// The handoff is bound to the browser that minted the nonce.
expect(mockedSsoTokenExchange).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ browser_nonce_hash: NONCE_HASH }),
})
)
expect(
sessionStorage.getItem('bridge_attempt:roboledger:/reports/x')
).not.toBeNull()
})

it('without the product app’s nonce hash, mints an unbound handoff for an app that predates the binding', async () => {
fakeLocation('?return_to=roboledger%3A%2Freports%2Fx')
mockedGetCurrentAuthUser.mockResolvedValue({
data: { user: mockUser },
} as never)
mockBridgeEndpoints()

render(<SignInForm apiUrl="http://localhost:8000" />)

await waitFor(() => {
expect(String(window.location.href)).toContain(
'https://roboledger.ai/login?session_id=sess-1'
)
})
// Nothing to bind to, so the exchange carries no hash; a target that does
// bind strips a handoff it never asked for and starts its own bridge.
expect(mockedSsoTokenExchange).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.not.objectContaining({
browser_nonce_hash: expect.anything(),
}),
})
)
})

it('lands an authenticated visit with a local return_to on that path', async () => {
fakeLocation('?return_to=%2Fgraphs%2Fabc')
mockedGetCurrentAuthUser.mockResolvedValue({
Expand All @@ -137,7 +172,9 @@ describe('SignInForm (login home fork)', () => {
})

it('downgrades to a manual interstitial after a failed bridge', async () => {
fakeLocation('?return_to=roboledger&reason=bridge_failed')
fakeLocation(
`?return_to=roboledger&reason=bridge_failed&nonce_hash=${NONCE_HASH}`
)
mockedGetCurrentAuthUser.mockResolvedValue({
data: { user: mockUser },
} as never)
Expand Down Expand Up @@ -175,7 +212,7 @@ describe('SignInForm (login home fork)', () => {
})

it('bridges onward after a manual sign-in with a cross-app return_to', async () => {
fakeLocation('?return_to=roboledger')
fakeLocation(`?return_to=roboledger&nonce_hash=${NONCE_HASH}`)
mockedGetCurrentAuthUser.mockRejectedValue(new Error('unauthorized'))
mockedLoginUser.mockResolvedValue({
data: { user: mockUser, message: 'ok' },
Expand Down
21 changes: 21 additions & 0 deletions src/components/auth/__tests__/return-to.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
loginPathWith,
markBridgeAttempt,
parseDestination,
parseNonceHash,
shouldSuppressAutoBridge,
} from '../return-to'

Expand Down Expand Up @@ -112,3 +113,23 @@ describe('loginPathWith', () => {
expect(loginPathWith(null)).toBe('/login')
})
})

describe('parseNonceHash', () => {
const hash = 'ab'.repeat(32)

it('accepts a SHA-256 hex digest', () => {
expect(parseNonceHash(hash)).toBe(hash)
})

it.each([
null,
undefined,
'',
hash.slice(1),
hash + 'a',
hash.toUpperCase(),
'../x',
])('ignores %j', (raw) => {
expect(parseNonceHash(raw)).toBeNull()
})
})
12 changes: 12 additions & 0 deletions src/components/auth/return-to.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ export function parseDestination(
return { kind: 'app', app: parsed.app, path }
}

const NONCE_HASH_PATTERN = /^[0-9a-f]{64}$/

/**
* The hash of the nonce the product app minted when it sent the browser here
* (`?nonce_hash=`). Passed into the bridge, it binds the handoff to that
* browser; anything but a SHA-256 hex digest is ignored and the bridge runs
* through the product app's own login instead.
*/
export function parseNonceHash(raw: string | null | undefined): string | null {
return raw && NONCE_HASH_PATTERN.test(raw) ? raw : null
}

const BRIDGE_MARKER_PREFIX = 'bridge_attempt:'
const BRIDGE_MARKER_TTL_MS = 60 * 1000

Expand Down
Loading