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
40 changes: 30 additions & 10 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ function makeAuthRouter({ authService, storageService }) {
});
});

/** Returns the usable client, or sends a 401 GITHUB_AUTH_REVOKED and returns null. */
function getOctokit(clients, res) {
const octokit = clients.githubUserClient || clients.githubClient;
if (!octokit) {
githubAccessRevoked(res);
return null;
}
return octokit;
}

router.get('/storages', requireAuth, async (req, res) => {
try {
const provider = req.query.provider;
Expand All @@ -74,11 +84,11 @@ function makeAuthRouter({ authService, storageService }) {
}

const clients = await authService.clientsFor(req.user);
if (!clients.githubClient) {
return res.status(400).json({ error: 'GitHub client is not available' });
}

const repos = await storageService.listGitHubRepos(clients.githubClient);
const octokit = getOctokit(clients, res);
if (!octokit) return;

const repos = await storageService.listGitHubRepos(octokit);
return res.json({
provider: 'github',
storages: repos.map((repo) => ({
Expand All @@ -90,6 +100,9 @@ function makeAuthRouter({ authService, storageService }) {
})),
});
} catch (err) {
if (err.status === 401) {
return githubAccessRevoked(res);
}
console.error(err);
return res.status(500).json({ error: 'Failed to list storages' });
}
Expand All @@ -105,15 +118,17 @@ function makeAuthRouter({ authService, storageService }) {
}

const clients = await authService.clientsFor(req.user);
if (!clients.githubUserClient && !clients.githubClient) {
return res.status(400).json({ error: 'GitHub client is not available' });
}

if (!getOctokit(clients, res)) return;

const result = await storageService.discoverAccountStores(provider, clients, {
sessionStorageRef: req.user?.storage || null,
});
return res.json(result);
} catch (err) {
if (err.status === 401) {
return githubAccessRevoked(res);
}
console.error(err);
return res.status(500).json({
error: err.message || 'Failed to discover storage',
Expand All @@ -131,9 +146,7 @@ function makeAuthRouter({ authService, storageService }) {
}

const clients = await authService.clientsFor(req.user);
if (!clients.githubUserClient && !clients.githubClient) {
return res.status(400).json({ error: 'GitHub client is not available' });
}
if (!getOctokit(clients, res)) return;

const installUrl = await authService.getInstallationSetupUrl();
const result =
Expand Down Expand Up @@ -260,6 +273,13 @@ function makeAuthRouter({ authService, storageService }) {
return router;
}

function githubAccessRevoked(res) {
return res.status(401).json({
error: 'GitHub access was revoked. Sign in with GitHub again.',
code: 'GITHUB_AUTH_REVOKED',
});
}

/** @param {import('express').Request} req */
function requireAuth(req, res, next) {
if (req.isAuthenticated()) {
Expand Down
4 changes: 3 additions & 1 deletion backend/services/storageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,13 @@ class StorageService {
const { data: user } = await octokit.rest.users.getAuthenticated();
owner = user.login;
} catch (err) {
throw new Error(
const wrapped = new Error(
err?.status === 401
? 'GitHub authentication failed. Sign out and sign in again.'
: 'Could not look up your GitHub username.',
);
wrapped.status = err?.status;
throw wrapped;
}

try {
Expand Down
92 changes: 92 additions & 0 deletions backend/tests/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,98 @@ test('GET /api/auth/storages returns mapped GitHub repos', async () => {
assert.equal(res.body.storages[0].id, 'R_kg');
});

test('GET /api/auth/storages returns GITHUB_AUTH_REVOKED when no client is available', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({}),
},
storageService: {
listGitHubRepos: async () => {
throw new Error('should not be called without a client');
},
},
});
const res = await request(app).get('/api/auth/storages?provider=github');
assert.equal(res.status, 401);
assert.equal(res.body.code, 'GITHUB_AUTH_REVOKED');
});

test('GET /api/auth/storages returns GITHUB_AUTH_REVOKED when GitHub rejects the token', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubClient: { mock: true } }),
},
storageService: {
listGitHubRepos: async () => {
const err = new Error('Bad credentials');
err.status = 401;
throw err;
},
},
});
const res = await request(app).get('/api/auth/storages?provider=github');
assert.equal(res.status, 401);
assert.equal(res.body.code, 'GITHUB_AUTH_REVOKED');
});

test('GET /api/auth/storage/discover returns GITHUB_AUTH_REVOKED when no client is available', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({}),
},
storageService: {
discoverAccountStores: async () => {
throw new Error('should not be called without a client');
},
},
});
const res = await request(app).get('/api/auth/storage/discover?provider=github');
assert.equal(res.status, 401);
assert.equal(res.body.code, 'GITHUB_AUTH_REVOKED');
});

test('GET /api/auth/storage/discover returns GITHUB_AUTH_REVOKED when GitHub rejects the token', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubUserClient: { mock: true } }),
},
storageService: {
discoverAccountStores: async () => {
const err = new Error('GitHub authentication failed. Sign out and sign in again.');
err.status = 401;
throw err;
},
},
});
const res = await request(app).get('/api/auth/storage/discover?provider=github');
assert.equal(res.status, 401);
assert.equal(res.body.code, 'GITHUB_AUTH_REVOKED');
});

test('POST /api/auth/storage/create returns GITHUB_AUTH_REVOKED when no client is available', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({}),
getInstallationSetupUrl: async () => 'https://github.com/settings/installations',
},
storageService: {
createGitHubRepository: async () => {
throw new Error('should not be called without a client');
},
},
});
const res = await request(app)
.post('/api/auth/storage/create')
.send({ name: 'vizably-new' });
assert.equal(res.status, 401);
assert.equal(res.body.code, 'GITHUB_AUTH_REVOKED');
});

test('POST /api/auth/storage/create returns storageRef and needsInstall', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
Expand Down
43 changes: 39 additions & 4 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,16 @@ function AppRoutes() {
navigate(PATHS.dashboard)
}

const reconnectGitHub = async () => {
try {
await apiClient.logout()
} catch {
// Clear local state even if the network call fails.
}
setUser(null)
apiClient.githubLogin()
}

const signOut = async () => {
try {
await apiClient.logout()
Expand All @@ -342,6 +352,32 @@ function AppRoutes() {
navigate(PATHS.landing)
}

const redirectToSignIn = async () => {
try {
await apiClient.logout()
} catch {
// Clear local state even if the network call fails.
}
navigate(PATHS.signin)
setUser(null)
setAuthLoading(false)
setScan(null)
setProblem(null)
}

/**
* Cancel out of storage setup. Only forces a fresh sign-in when GitHub
* access was actually revoked — someone who just clicked Connect by
* mistake keeps their session and can pick up onboarding again later.
*/
const cancelConnect = (isRevoked) => {
if (isRevoked) {
redirectToSignIn()
return
}
navigate(PATHS.landing)
}

const route = routeKeyFor(location.pathname)

useEffect(() => {
Expand All @@ -363,7 +399,8 @@ function AppRoutes() {
<ConnectView
provider={connectProvider}
onDone={connectDone}
onCancel={() => navigate(PATHS.signin)}
onCancel={cancelConnect}
onReconnect={reconnectGitHub}
storageError={storageError}
/>
)
Expand Down Expand Up @@ -397,9 +434,7 @@ function AppRoutes() {
element={
authed && storageReady
? <Navigate to={PATHS.dashboard} replace />
: authed
? <Navigate to={PATHS.connect} replace />
: <SignInView onNav={nav} onAuth={auth} />
: <SignInView onNav={nav} onAuth={auth} />
}
/>
<Route path={PATHS.connect} element={<ConnectRoute />} />
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/__tests__/connectView.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,62 @@ describe('ConnectView', () => {
await waitFor(() => expect(client.discoverStorages).toHaveBeenCalled())
})

it('Back does not signal a revoke when access is fine', async () => {
const onCancel = vi.fn()
const client = mockClient()
render(
<ConnectView provider="github" onDone={vi.fn()} onCancel={onCancel} client={client} />,
)

expect(await screen.findByText('Vizably account found')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^back$/i }))
expect(onCancel).toHaveBeenCalledWith(false)
})

it('offers reconnect instead of create when GitHub access was revoked', async () => {
const onReconnect = vi.fn()
const err = new Error('GitHub access was revoked. Sign in with GitHub again.')
err.status = 401
err.code = 'GITHUB_AUTH_REVOKED'
const client = mockClient({
discoverStorages: vi.fn().mockRejectedValue(err),
})

render(
<ConnectView
provider="github"
onDone={vi.fn()}
onCancel={vi.fn()}
onReconnect={onReconnect}
client={client}
/>,
)

expect(await screen.findByText(/Reconnect to authorize/i)).toBeInTheDocument()
expect(screen.getByText(/GitHub access revoked/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /reconnect github/i }))
await waitFor(() => expect(onReconnect).toHaveBeenCalled())
expect(client.createStorage).not.toHaveBeenCalled()
})

it('Back signals a revoke so the caller can force a fresh sign-in', async () => {
const onCancel = vi.fn()
const err = new Error('GitHub access was revoked. Sign in with GitHub again.')
err.status = 401
err.code = 'GITHUB_AUTH_REVOKED'
const client = mockClient({
discoverStorages: vi.fn().mockRejectedValue(err),
})

render(
<ConnectView provider="github" onDone={vi.fn()} onCancel={onCancel} client={client} />,
)

expect(await screen.findByText(/Reconnect to authorize/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^back$/i }))
expect(onCancel).toHaveBeenCalledWith(true)
})

it('shows install hop when create returns needsInstall', async () => {
const client = mockClient({
discoverStorages: vi.fn().mockResolvedValue({
Expand Down
Loading
Loading