Skip to content

Commit a75d742

Browse files
committed
fix(client-admin): bound and validate sign-in proof and show the reason once
Proof that cannot be sent in a header, or that does not arrive within ten seconds, is treated as unavailable. The reason for a failed sign-in is shown once. Page reloads go through one module so that tests can observe them.
1 parent adfc56e commit a75d742

10 files changed

Lines changed: 293 additions & 52 deletions

File tree

‎client-admin/src/app-login.test.js‎

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { ThemeUIProvider } from 'theme-ui'
66
import App from './app'
77
import rootReducer from './reducers'
88
import theme from './theme'
9+
import { reloadPage, replacePage } from './util/page-navigation'
10+
11+
jest.setTimeout(30000)
12+
const PATIENT = { timeout: 15000 }
913

1014
const mockInit = jest.fn()
1115
const mockRestore = jest.fn()
@@ -33,6 +37,11 @@ jest.mock('@atproto/api', () => ({
3337
}
3438
}))
3539

40+
jest.mock('./util/page-navigation', () => ({
41+
reloadPage: jest.fn(),
42+
replacePage: jest.fn()
43+
}))
44+
3645
jest.mock('./components/conversations-and-account/conversations', () => {
3746
return function MockConversations() {
3847
return <div>All Conversations</div>
@@ -50,7 +59,7 @@ const PROOF_REQUEST = {
5059
const PROOF_MESSAGE =
5160
'We could not confirm this account with its host. Sign in again. If this keeps happening, your host may not support this sign-in yet.'
5261
const UNSUPPORTED_DID_MESSAGE =
53-
'Accounts with a did:web identifier cannot sign in to the admin console yet.'
62+
'This kind of account identifier cannot sign in to the admin console yet.'
5463
const LOOKUP_MESSAGE = 'We could not look up your account just now. Try again in a minute.'
5564

5665
const did = 'did:plc:abcdefghijklmnopqrstuvwx'
@@ -126,40 +135,48 @@ describe('admin console sign-in', () => {
126135

127136
it('restores the OAuth session of that identity and signs in with proof', async () => {
128137
mockRestore.mockResolvedValue(session)
129-
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof-1' } })
138+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
130139
window.fetch.mockResolvedValue(serverAnswers(200, { token: 'admin-1', uid: 7 }))
131140

132141
openConsole('/')
133142

134-
await waitFor(() => expect(localStorage.getItem(ADMIN_TOKEN_KEY)).toBe('admin-1'))
143+
await waitFor(() => expect(localStorage.getItem(ADMIN_TOKEN_KEY)).toBe('admin-1'), PATIENT)
135144
expect(mockRestore.mock.calls).toEqual([[did]])
136145
expect(mockAgent.mock.calls).toEqual([[session]])
137146
expect(mockGetServiceAuth.mock.calls).toEqual([[PROOF_REQUEST]])
138147
expect(loginRequests()).toEqual([
139148
{
140-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer proof-1' },
149+
headers: {
150+
'Content-Type': 'application/json',
151+
Authorization: 'Bearer proof.number1.signature'
152+
},
141153
body: storedIdentity
142154
}
143155
])
144156
expect(JSON.parse(localStorage.getItem(IDENTITY_KEY))).toEqual(storedIdentity)
157+
await waitFor(() => expect(reloadPage).toHaveBeenCalledTimes(1), PATIENT)
158+
expect(replacePage).toHaveBeenCalledTimes(0)
145159
})
146160

147161
it('ends signed out on the sign-in page with the reason when the server refuses the proof', async () => {
148162
mockRestore.mockResolvedValue(session)
149-
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof-1' } })
163+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
150164
window.fetch.mockResolvedValue(
151165
serverAnswers(401, { error: 'polis_err_atproto_auth_invalid', status: 401 })
152166
)
153167

154168
openConsole('/')
155169

156-
expect(await screen.findAllByText(PROOF_MESSAGE)).toHaveLength(1)
170+
expect(await screen.findAllByText(PROOF_MESSAGE, {}, PATIENT)).toHaveLength(1)
157171
expect(window.location.pathname).toBe('/signin')
158172
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument()
159173
expect(screen.queryByText('All Conversations')).toBeNull()
160174
expect(loginRequests()).toEqual([
161175
{
162-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer proof-1' },
176+
headers: {
177+
'Content-Type': 'application/json',
178+
Authorization: 'Bearer proof.number1.signature'
179+
},
163180
body: storedIdentity
164181
}
165182
])
@@ -175,7 +192,7 @@ describe('admin console sign-in', () => {
175192

176193
openConsole('/')
177194

178-
expect(await screen.findAllByText(PROOF_MESSAGE)).toHaveLength(1)
195+
expect(await screen.findAllByText(PROOF_MESSAGE, {}, PATIENT)).toHaveLength(1)
179196
expect(window.location.pathname).toBe('/signin')
180197
expect(mockRestore.mock.calls).toEqual([[did]])
181198
expect(mockGetServiceAuth).toHaveBeenCalledTimes(0)
@@ -197,7 +214,7 @@ describe('admin console sign-in', () => {
197214

198215
openConsole('/')
199216

200-
expect(await screen.findAllByText(PROOF_MESSAGE)).toHaveLength(1)
217+
expect(await screen.findAllByText(PROOF_MESSAGE, {}, PATIENT)).toHaveLength(1)
201218
expect(mockGetServiceAuth.mock.calls).toEqual([[PROOF_REQUEST]])
202219
expect(loginRequests()).toEqual([
203220
{ headers: { 'Content-Type': 'application/json' }, body: storedIdentity }
@@ -208,14 +225,14 @@ describe('admin console sign-in', () => {
208225

209226
it('shows the lookup message when the server cannot look the account up', async () => {
210227
mockRestore.mockResolvedValue(session)
211-
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof-1' } })
228+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
212229
window.fetch.mockResolvedValue(
213230
serverAnswers(503, { error: 'polis_err_atproto_did_resolution_failed', status: 503 })
214231
)
215232

216233
openConsole('/')
217234

218-
expect(await screen.findAllByText(LOOKUP_MESSAGE)).toHaveLength(1)
235+
expect(await screen.findAllByText(LOOKUP_MESSAGE, {}, PATIENT)).toHaveLength(1)
219236
expect(window.location.pathname).toBe('/signin')
220237
expect(localStorage.getItem(ADMIN_TOKEN_KEY)).toBeNull()
221238
expect(JSON.parse(localStorage.getItem(IDENTITY_KEY))).toEqual(storedIdentity)
@@ -225,39 +242,62 @@ describe('admin console sign-in', () => {
225242
describe('returning from the authorization server', () => {
226243
it('signs in with proof from the new session', async () => {
227244
mockInit.mockResolvedValue({ session })
228-
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof-1' } })
245+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
229246
window.fetch.mockResolvedValue(serverAnswers(200, { token: 'admin-1', uid: 7 }))
230247

231248
openConsole('/?state=s1&code=c1')
232249

233-
await waitFor(() => expect(localStorage.getItem(ADMIN_TOKEN_KEY)).toBe('admin-1'))
250+
await waitFor(() => expect(localStorage.getItem(ADMIN_TOKEN_KEY)).toBe('admin-1'), PATIENT)
234251
expect(mockInit).toHaveBeenCalledTimes(1)
235252
expect(mockRestore).toHaveBeenCalledTimes(0)
236253
expect(mockAgent.mock.calls).toEqual([[session]])
237254
expect(mockGetServiceAuth.mock.calls).toEqual([[PROOF_REQUEST]])
238255
expect(loginRequests()).toEqual([
239256
{
240-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer proof-1' },
257+
headers: {
258+
'Content-Type': 'application/json',
259+
Authorization: 'Bearer proof.number1.signature'
260+
},
241261
body: { ...freshIdentity, email }
242262
}
243263
])
244264
expect(JSON.parse(localStorage.getItem(IDENTITY_KEY))).toEqual(freshIdentity)
265+
await waitFor(() => expect(replacePage.mock.calls).toEqual([['/']]), PATIENT)
266+
expect(reloadPage).toHaveBeenCalledTimes(0)
267+
})
268+
269+
it('shows the reason on the sign-in page when the return address is the callback route', async () => {
270+
mockInit.mockResolvedValueOnce({ session }).mockResolvedValue(null)
271+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
272+
window.fetch.mockResolvedValue(
273+
serverAnswers(401, { error: 'polis_err_atproto_auth_invalid', status: 401 })
274+
)
275+
276+
openConsole('/auth/callback?state=s1&code=c1')
277+
278+
expect(await screen.findAllByText(PROOF_MESSAGE, {}, PATIENT)).toHaveLength(1)
279+
expect(window.location.pathname).toBe('/signin')
280+
expect(localStorage.getItem(ADMIN_TOKEN_KEY)).toBeNull()
281+
expect(replacePage).toHaveBeenCalledTimes(0)
245282
})
246283

247284
it('ends on the sign-in page with the reason and stores nothing when the server refuses', async () => {
248285
mockInit.mockResolvedValue({ session })
249-
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof-1' } })
286+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
250287
window.fetch.mockResolvedValue(
251288
serverAnswers(400, { error: 'polis_err_atproto_unsupported_did', status: 400 })
252289
)
253290

254291
openConsole('/?state=s1&code=c1')
255292

256-
expect(await screen.findAllByText(UNSUPPORTED_DID_MESSAGE)).toHaveLength(1)
293+
expect(await screen.findAllByText(UNSUPPORTED_DID_MESSAGE, {}, PATIENT)).toHaveLength(1)
257294
expect(window.location.pathname).toBe('/signin')
258295
expect(loginRequests()).toEqual([
259296
{
260-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer proof-1' },
297+
headers: {
298+
'Content-Type': 'application/json',
299+
Authorization: 'Bearer proof.number1.signature'
300+
},
261301
body: { ...freshIdentity, email }
262302
}
263303
])
@@ -267,14 +307,14 @@ describe('admin console sign-in', () => {
267307
it('does not remove an identity stored earlier when the server refuses', async () => {
268308
localStorage.setItem(IDENTITY_KEY, JSON.stringify(storedIdentity))
269309
mockInit.mockResolvedValue({ session })
270-
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof-1' } })
310+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
271311
window.fetch.mockResolvedValue(
272312
serverAnswers(401, { error: 'polis_err_atproto_auth_replayed', status: 401 })
273313
)
274314

275315
openConsole('/?state=s1&code=c1')
276316

277-
expect(await screen.findAllByText(PROOF_MESSAGE)).toHaveLength(1)
317+
expect(await screen.findAllByText(PROOF_MESSAGE, {}, PATIENT)).toHaveLength(1)
278318
expect(localStorage.getItem(ADMIN_TOKEN_KEY)).toBeNull()
279319
expect(JSON.parse(localStorage.getItem(IDENTITY_KEY))).toEqual(storedIdentity)
280320
})

‎client-admin/src/app.js‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
isAdminTokenExpired
3636
} from './util/atproto-oauth'
3737
import { ADMIN_TOKEN_KEY, loginWithProof, signInErrorMessage } from './util/atproto-login'
38+
import { reloadPage, replacePage } from './util/page-navigation'
3839
import { Agent } from '@atproto/api'
3940

4041
const AUTH_LOADING_TIMEOUT = 3000
@@ -142,7 +143,7 @@ const App = () => {
142143
.then((success) => {
143144
if (success) {
144145
// Reload to cleanly initialize with the new identity and admin JWT
145-
window.location.replace('/')
146+
replacePage('/')
146147
} else {
147148
setIsLoading(false)
148149
}
@@ -163,7 +164,7 @@ const App = () => {
163164
} else if (identity && !adminToken) {
164165
loginWithStoredIdentity(identity)
165166
.then(() => {
166-
window.location.reload()
167+
reloadPage()
167168
})
168169
.catch((err) => {
169170
console.error('Sign-in with the stored identity failed:', err)
@@ -229,6 +230,10 @@ const App = () => {
229230
}
230231
}, [loadUserDataIfNeeded])
231232

233+
const clearSignInError = useCallback(() => {
234+
setSignInError('')
235+
}, [])
236+
232237
// Re-check auth when returning from OAuth callback
233238
const handleAuthComplete = useCallback(() => {
234239
const identity = getAtprotoIdentity()
@@ -241,7 +246,16 @@ const App = () => {
241246
<Routes>
242247
{/* Public routes */}
243248
<Route path="/home" element={<Home />} />
244-
<Route path="/signin" element={<SignIn authed={isAuthed()} signInError={signInError} />} />
249+
<Route
250+
path="/signin"
251+
element={
252+
<SignIn
253+
authed={isAuthed()}
254+
signInError={signInError}
255+
onSignInErrorShown={clearSignInError}
256+
/>
257+
}
258+
/>
245259
<Route path="/signout" element={<SignOut />} />
246260
<Route path="/tos" element={<TOS />} />
247261
<Route path="/privacy" element={<Privacy />} />

‎client-admin/src/components/auth-callback.test.js‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { ThemeUIProvider } from 'theme-ui'
44
import theme from '../theme'
55
import AuthCallback from './auth-callback'
66

7+
jest.setTimeout(30000)
8+
const PATIENT = { timeout: 15000 }
9+
710
const mockInit = jest.fn()
811
const mockGetProfile = jest.fn()
912
const mockGetSession = jest.fn()
@@ -84,7 +87,7 @@ describe('AuthCallback', () => {
8487
}
8588
})
8689
mockGetSession.mockResolvedValue({ data: { email } })
87-
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof-1' } })
90+
mockGetServiceAuth.mockResolvedValue({ data: { token: 'proof.number1.signature' } })
8891
})
8992

9093
afterEach(() => {
@@ -111,7 +114,10 @@ describe('AuthCallback', () => {
111114
expect(loginRequests()).toEqual([
112115
{
113116
url: 'http://localhost/api/v3/auth/atproto-login',
114-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer proof-1' },
117+
headers: {
118+
'Content-Type': 'application/json',
119+
Authorization: 'Bearer proof.number1.signature'
120+
},
115121
body: { ...identity, email }
116122
}
117123
])
@@ -136,4 +142,15 @@ describe('AuthCallback', () => {
136142
expect(window.fetch).toHaveBeenCalledTimes(1)
137143
expect(localStorage.length).toBe(0)
138144
})
145+
146+
it('goes to the sign-in page when the server refuses and nobody listens for the reason', async () => {
147+
window.fetch.mockResolvedValue(
148+
serverAnswers(401, { error: 'polis_err_atproto_auth_invalid', status: 401 })
149+
)
150+
151+
openCallback({})
152+
153+
expect(await screen.findAllByText('sign-in page', {}, PATIENT)).toHaveLength(1)
154+
expect(localStorage.length).toBe(0)
155+
})
139156
})

‎client-admin/src/components/landers/signin-error.test.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,22 @@ describe('SignIn reason for a failed sign-in', () => {
5959
expect(mockSignIn).toHaveBeenCalledWith('organizer.example.com')
6060
expect(screen.queryByText(PROOF_MESSAGE)).toBeNull()
6161
})
62+
63+
it('reports once that the message was shown, so that it is not shown again later', () => {
64+
const onSignInErrorShown = jest.fn()
65+
const { rerender } = render(page({ signInError: PROOF_MESSAGE, onSignInErrorShown }))
66+
67+
rerender(page({ signInError: '', onSignInErrorShown }))
68+
69+
expect(onSignInErrorShown).toHaveBeenCalledTimes(1)
70+
expect(screen.getAllByText(PROOF_MESSAGE)).toHaveLength(1)
71+
})
72+
73+
it('reports nothing when there is no message', () => {
74+
const onSignInErrorShown = jest.fn()
75+
76+
render(page({ signInError: '', onSignInErrorShown }))
77+
78+
expect(onSignInErrorShown).toHaveBeenCalledTimes(0)
79+
})
6280
})

‎client-admin/src/components/landers/signin.js‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import { Heading, Box, Button, Input, Text } from 'theme-ui'
77
import StaticLayout from './lander-layout'
88
import { getOAuthClient } from '../../util/atproto-oauth'
99

10-
const SignIn = ({ authed, signInError }) => {
10+
const SignIn = ({ authed, signInError, onSignInErrorShown }) => {
1111
const [handle, setHandle] = useState('')
1212
const [isProcessing, setIsProcessing] = useState(false)
1313
const [error, setError] = useState('')
1414

1515
useEffect(() => {
16-
if (signInError) setError(signInError)
17-
}, [signInError])
16+
if (!signInError) return
17+
setError(signInError)
18+
if (onSignInErrorShown) onSignInErrorShown()
19+
}, [signInError, onSignInErrorShown])
1820

1921
const handleSignIn = async () => {
2022
const trimmed = handle.trim()
@@ -73,7 +75,8 @@ const SignIn = ({ authed, signInError }) => {
7375

7476
SignIn.propTypes = {
7577
authed: PropTypes.bool,
76-
signInError: PropTypes.string
78+
signInError: PropTypes.string,
79+
onSignInErrorShown: PropTypes.func
7780
}
7881

7982
export default SignIn

0 commit comments

Comments
 (0)