@owlmeans/web-oauth 0.1.18-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/web-oauth/SKILL.md +131 -0
- package/build/@/components/ui/button.d.ts +11 -0
- package/build/@/components/ui/button.d.ts.map +1 -0
- package/build/@/components/ui/button.js +32 -0
- package/build/@/components/ui/button.js.map +1 -0
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/input.d.ts +4 -0
- package/build/@/components/ui/input.d.ts.map +1 -0
- package/build/@/components/ui/input.js +7 -0
- package/build/@/components/ui/input.js.map +1 -0
- package/build/@/components/ui/label.d.ts +5 -0
- package/build/@/components/ui/label.d.ts.map +1 -0
- package/build/@/components/ui/label.js +8 -0
- package/build/@/components/ui/label.js.map +1 -0
- package/build/@/lib/utils.d.ts +3 -0
- package/build/@/lib/utils.d.ts.map +1 -0
- package/build/@/lib/utils.js +6 -0
- package/build/@/lib/utils.js.map +1 -0
- package/build/append.d.ts +14 -0
- package/build/append.d.ts.map +1 -0
- package/build/append.js +19 -0
- package/build/append.js.map +1 -0
- package/build/components/consent-screen.d.ts +12 -0
- package/build/components/consent-screen.d.ts.map +1 -0
- package/build/components/consent-screen.js +30 -0
- package/build/components/consent-screen.js.map +1 -0
- package/build/components/device-screen.d.ts +10 -0
- package/build/components/device-screen.d.ts.map +1 -0
- package/build/components/device-screen.js +48 -0
- package/build/components/device-screen.js.map +1 -0
- package/build/components/done-screen.d.ts +8 -0
- package/build/components/done-screen.d.ts.map +1 -0
- package/build/components/done-screen.js +18 -0
- package/build/components/done-screen.js.map +1 -0
- package/build/components/index.d.ts +4 -0
- package/build/components/index.d.ts.map +1 -0
- package/build/components/index.js +4 -0
- package/build/components/index.js.map +1 -0
- package/build/consts.d.ts +6 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +6 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +8 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +20 -0
- package/build/entrypoints.js.map +1 -0
- package/build/hooks/use-oauth-consent.d.ts +27 -0
- package/build/hooks/use-oauth-consent.d.ts.map +1 -0
- package/build/hooks/use-oauth-consent.js +128 -0
- package/build/hooks/use-oauth-consent.js.map +1 -0
- package/build/i18n/be.json +35 -0
- package/build/i18n/de.json +35 -0
- package/build/i18n/en.json +35 -0
- package/build/i18n/es.json +35 -0
- package/build/i18n/pl.json +35 -0
- package/build/i18n/ru.json +35 -0
- package/build/i18n/uk.json +35 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +17 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/components.json +21 -0
- package/package.json +77 -0
- package/src/@/components/ui/button.tsx +60 -0
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/input.tsx +22 -0
- package/src/@/components/ui/label.tsx +23 -0
- package/src/@/globals.css +57 -0
- package/src/@/lib/utils.ts +6 -0
- package/src/append.ts +24 -0
- package/src/components/consent-screen.tsx +95 -0
- package/src/components/device-screen.tsx +81 -0
- package/src/components/done-screen.tsx +30 -0
- package/src/components/index.ts +3 -0
- package/src/consts.ts +6 -0
- package/src/entrypoints.ts +22 -0
- package/src/hooks/use-oauth-consent.ts +163 -0
- package/src/i18n/be.json +35 -0
- package/src/i18n/de.json +35 -0
- package/src/i18n/en.json +35 -0
- package/src/i18n/es.json +35 -0
- package/src/i18n/pl.json +35 -0
- package/src/i18n/ru.json +35 -0
- package/src/i18n/uk.json +35 -0
- package/src/i18n.ts +18 -0
- package/src/index.ts +6 -0
- package/tests/consent.spec.ts +260 -0
- package/tests/context.ts +48 -0
- package/tests/device.spec.ts +64 -0
- package/tests/done.spec.ts +41 -0
- package/tests/harness/css.d.ts +1 -0
- package/tests/harness/frame.html +15 -0
- package/tests/harness/index.html +11 -0
- package/tests/harness/mount.tsx +89 -0
- package/tests/helpers.ts +97 -0
- package/tests/i18n.spec.ts +41 -0
- package/tests/languages.spec.ts +38 -0
- package/tests/package-boundary.spec.ts +19 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser } from '@owlmeans/test-ui'
|
|
3
|
+
import { OAUTH_CONSENT_PATH, oauth } from '@owlmeans/oauth'
|
|
4
|
+
import { OAUTH_SUSPEND_TTL_MS } from '../src/consts.js'
|
|
5
|
+
import en from '../src/i18n/en.json' with { type: 'json' }
|
|
6
|
+
import { bearer, codeView, deviceView, open, refusal, TIMEOUT } from './helpers.js'
|
|
7
|
+
import { HARNESS_URL } from './context.js'
|
|
8
|
+
|
|
9
|
+
afterAll(async () => { await closeBrowser() })
|
|
10
|
+
|
|
11
|
+
const CONSENT = `${OAUTH_CONSENT_PATH}?ref=ABCD-EFGH`
|
|
12
|
+
|
|
13
|
+
describe('consent screen — signed out', () => {
|
|
14
|
+
test('shows no approve button, suspends the consent flow and leaves for the dispatcher', async () => {
|
|
15
|
+
const before = Date.now()
|
|
16
|
+
const { page, calls, close } = await open(CONSENT)
|
|
17
|
+
try {
|
|
18
|
+
await page.waitForURL(url => url.pathname === '/dispatcher', { timeout: 45_000 })
|
|
19
|
+
|
|
20
|
+
// Nobody is signed in, so there is nothing to load and nothing to approve.
|
|
21
|
+
expect(calls).toEqual([])
|
|
22
|
+
expect(await page.getByTestId('oauth-consent-approve').count()).toBe(0)
|
|
23
|
+
|
|
24
|
+
// The real dispatcher chooser is what the person lands on.
|
|
25
|
+
await page.locator('[data-login-screen], [data-login-method]').first().waitFor({ state: 'attached' })
|
|
26
|
+
|
|
27
|
+
// The suspension is a real record in the real flow store: destination, payload, an expiry.
|
|
28
|
+
const record = await page.evaluate(async () => await (window as any).__oauth.suspended()) as
|
|
29
|
+
{ id: string, entrypoint: string, query: Record<string, string>, expiresAt: number } | null
|
|
30
|
+
expect(record).not.toBeNull()
|
|
31
|
+
expect(record!.entrypoint).toBe(oauth.consentScreen)
|
|
32
|
+
expect(record!.query.ref).toBe('ABCD-EFGH')
|
|
33
|
+
expect(record!.expiresAt).toBeGreaterThan(before + OAUTH_SUSPEND_TTL_MS - 5_000)
|
|
34
|
+
expect(record!.expiresAt).toBeLessThan(Date.now() + OAUTH_SUSPEND_TTL_MS + 5_000)
|
|
35
|
+
} finally {
|
|
36
|
+
await close()
|
|
37
|
+
}
|
|
38
|
+
}, TIMEOUT)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('consent screen — the round trip through sign-in', () => {
|
|
42
|
+
test('after signing in at the dispatcher the person is back on the SAME request, ready to approve', async () => {
|
|
43
|
+
const { page, calls, close } = await open(CONSENT, {
|
|
44
|
+
authenticate: true, stubs: { load: { json: deviceView() }, approve: { json: {} } },
|
|
45
|
+
})
|
|
46
|
+
try {
|
|
47
|
+
await page.waitForURL(url => url.pathname === '/dispatcher', { timeout: 45_000 })
|
|
48
|
+
expect(await page.evaluate(async () => await (window as any).__oauth.suspended())).not.toBeNull()
|
|
49
|
+
|
|
50
|
+
// Any sign-in method ends the same way: the dispatcher is handed a token.
|
|
51
|
+
await page.goto(`${HARNESS_URL}/dispatcher?token=signed-in-elsewhere`, { waitUntil: 'domcontentloaded' })
|
|
52
|
+
|
|
53
|
+
await page.getByTestId('oauth-consent-approve').waitFor({ state: 'visible', timeout: 45_000 })
|
|
54
|
+
const url = new URL(page.url())
|
|
55
|
+
expect(url.pathname).toBe(OAUTH_CONSENT_PATH)
|
|
56
|
+
expect(url.searchParams.get('ref')).toBe('ABCD-EFGH')
|
|
57
|
+
expect(calls).toContain('POST authenticate')
|
|
58
|
+
expect(calls).toContain('GET ABCD-EFGH')
|
|
59
|
+
// The suspension answered exactly one sign-in.
|
|
60
|
+
expect(await page.evaluate(async () => await (window as any).__oauth.suspended())).toBeNull()
|
|
61
|
+
} finally {
|
|
62
|
+
await close()
|
|
63
|
+
}
|
|
64
|
+
}, TIMEOUT)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('consent screen — signed in', () => {
|
|
68
|
+
test('a device request shows who asks, the code and the device, and approving ends on Done', async () => {
|
|
69
|
+
const { page, calls, close } = await open(CONSENT, {
|
|
70
|
+
signedIn: true, stubs: { load: { json: deviceView() }, approve: { json: {} } },
|
|
71
|
+
})
|
|
72
|
+
try {
|
|
73
|
+
await page.getByTestId('oauth-consent-approve').waitFor({ state: 'visible', timeout: 45_000 })
|
|
74
|
+
|
|
75
|
+
expect(await page.getByTestId('oauth-consent-client').textContent()).toContain('Viable MCP')
|
|
76
|
+
expect(await page.getByTestId('oauth-consent-code').textContent()).toContain('ABCD-EFGH')
|
|
77
|
+
expect(await page.getByText('my-laptop').count()).toBe(1)
|
|
78
|
+
expect(await page.getByRole('alert').count()).toBe(0)
|
|
79
|
+
expect(calls).toEqual(['GET ABCD-EFGH'])
|
|
80
|
+
|
|
81
|
+
await page.getByTestId('oauth-consent-approve').click()
|
|
82
|
+
await page.waitForURL(url => url.pathname === '/oauth/done', { timeout: 30_000 })
|
|
83
|
+
|
|
84
|
+
expect(calls).toEqual(['GET ABCD-EFGH', 'POST ABCD-EFGH/approve'])
|
|
85
|
+
expect(new URL(page.url()).searchParams.get('kind')).toBe('device')
|
|
86
|
+
expect(await page.getByTestId('oauth-done-card').textContent()).toContain(en.done['device-message'])
|
|
87
|
+
} finally {
|
|
88
|
+
await close()
|
|
89
|
+
}
|
|
90
|
+
}, TIMEOUT)
|
|
91
|
+
|
|
92
|
+
test('a code request names the redirect host and warns when every redirect is on this computer', async () => {
|
|
93
|
+
const { page, close } = await open(`${OAUTH_CONSENT_PATH}?ref=req-1`, {
|
|
94
|
+
signedIn: true, stubs: { load: { json: codeView() } },
|
|
95
|
+
})
|
|
96
|
+
try {
|
|
97
|
+
await page.getByTestId('oauth-consent-approve').waitFor({ state: 'visible', timeout: 45_000 })
|
|
98
|
+
|
|
99
|
+
expect(await page.getByTestId('oauth-consent-client').textContent()).toContain('Claude Code')
|
|
100
|
+
expect(await page.getByText('claude.ai').count()).toBeGreaterThan(0)
|
|
101
|
+
expect(await page.getByText('127.0.0.1').count()).toBeGreaterThan(0)
|
|
102
|
+
expect(await page.getByRole('alert').textContent()).toContain('only registered addresses on your own computer')
|
|
103
|
+
// A code grant has no user code and no device.
|
|
104
|
+
expect(await page.getByTestId('oauth-consent-code').count()).toBe(0)
|
|
105
|
+
} finally {
|
|
106
|
+
await close()
|
|
107
|
+
}
|
|
108
|
+
}, TIMEOUT)
|
|
109
|
+
|
|
110
|
+
test('a request from a client with no localhost-only concern carries no warning', async () => {
|
|
111
|
+
const { page, close } = await open(`${OAUTH_CONSENT_PATH}?ref=req-1`, {
|
|
112
|
+
signedIn: true, stubs: { load: { json: codeView({ localhostOnly: false, redirectHost: 'app.example.com' }) } },
|
|
113
|
+
})
|
|
114
|
+
try {
|
|
115
|
+
await page.getByTestId('oauth-consent-approve').waitFor({ state: 'visible', timeout: 45_000 })
|
|
116
|
+
expect(await page.getByRole('alert').count()).toBe(0)
|
|
117
|
+
} finally {
|
|
118
|
+
await close()
|
|
119
|
+
}
|
|
120
|
+
}, TIMEOUT)
|
|
121
|
+
|
|
122
|
+
test('approving a code request sends the browser to the redirect the server returned', async () => {
|
|
123
|
+
const target = `${HARNESS_URL}/client-callback?code=one-time&state=s1&iss=https%3A%2F%2Fapi.example.test`
|
|
124
|
+
const { page, calls, close } = await open(`${OAUTH_CONSENT_PATH}?ref=req-1`, {
|
|
125
|
+
signedIn: true, stubs: { load: { json: codeView() }, approve: { json: { redirect: target } } },
|
|
126
|
+
})
|
|
127
|
+
try {
|
|
128
|
+
await page.getByTestId('oauth-consent-approve').waitFor({ state: 'visible', timeout: 45_000 })
|
|
129
|
+
await page.getByTestId('oauth-consent-approve').click()
|
|
130
|
+
await page.waitForURL(url => url.pathname === '/client-callback', { timeout: 30_000 })
|
|
131
|
+
|
|
132
|
+
expect(page.url()).toBe(target)
|
|
133
|
+
expect(calls).toEqual(['GET req-1', 'POST req-1/approve'])
|
|
134
|
+
} finally {
|
|
135
|
+
await close()
|
|
136
|
+
}
|
|
137
|
+
}, TIMEOUT)
|
|
138
|
+
|
|
139
|
+
test('denying is symmetric: a code request follows the redirect, a device request ends on Done', async () => {
|
|
140
|
+
const back = `${HARNESS_URL}/client-callback?error=access_denied&state=s1`
|
|
141
|
+
const code = await open(`${OAUTH_CONSENT_PATH}?ref=req-1`, {
|
|
142
|
+
signedIn: true, stubs: { load: { json: codeView() }, deny: { json: { redirect: back } } },
|
|
143
|
+
})
|
|
144
|
+
try {
|
|
145
|
+
await code.page.getByTestId('oauth-consent-deny').waitFor({ state: 'visible', timeout: 45_000 })
|
|
146
|
+
await code.page.getByTestId('oauth-consent-deny').click()
|
|
147
|
+
await code.page.waitForURL(url => url.pathname === '/client-callback', { timeout: 30_000 })
|
|
148
|
+
|
|
149
|
+
expect(code.page.url()).toBe(back)
|
|
150
|
+
expect(code.calls).toEqual(['GET req-1', 'POST req-1/deny'])
|
|
151
|
+
} finally {
|
|
152
|
+
await code.close()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const device = await open(CONSENT, {
|
|
156
|
+
signedIn: true, stubs: { load: { json: deviceView() }, deny: { json: {} } },
|
|
157
|
+
})
|
|
158
|
+
try {
|
|
159
|
+
await device.page.getByTestId('oauth-consent-deny').waitFor({ state: 'visible', timeout: 45_000 })
|
|
160
|
+
await device.page.getByTestId('oauth-consent-deny').click()
|
|
161
|
+
await device.page.waitForURL(url => url.pathname === '/oauth/done', { timeout: 30_000 })
|
|
162
|
+
|
|
163
|
+
expect(device.calls).toEqual(['GET ABCD-EFGH', 'POST ABCD-EFGH/deny'])
|
|
164
|
+
expect(await device.page.getByTestId('oauth-done-card').count()).toBe(1)
|
|
165
|
+
} finally {
|
|
166
|
+
await device.close()
|
|
167
|
+
}
|
|
168
|
+
}, TIMEOUT)
|
|
169
|
+
|
|
170
|
+
test('"Use another account" signs the session out and goes through the same suspension', async () => {
|
|
171
|
+
const { page, close } = await open(CONSENT, {
|
|
172
|
+
signedIn: true, stubs: { load: { json: deviceView() } },
|
|
173
|
+
})
|
|
174
|
+
try {
|
|
175
|
+
await page.getByTestId('oauth-consent-switch').waitFor({ state: 'visible', timeout: 45_000 })
|
|
176
|
+
expect(await page.evaluate(async () => await (window as any).__oauth.token())).not.toBeNull()
|
|
177
|
+
|
|
178
|
+
await page.getByTestId('oauth-consent-switch').click()
|
|
179
|
+
await page.waitForURL(url => url.pathname === '/dispatcher', { timeout: 45_000 })
|
|
180
|
+
|
|
181
|
+
expect(await page.evaluate(async () => await (window as any).__oauth.token())).toBeNull()
|
|
182
|
+
const record = await page.evaluate(async () => await (window as any).__oauth.suspended()) as
|
|
183
|
+
{ entrypoint: string, query: Record<string, string> } | null
|
|
184
|
+
expect(record?.entrypoint).toBe(oauth.consentScreen)
|
|
185
|
+
expect(record?.query.ref).toBe('ABCD-EFGH')
|
|
186
|
+
} finally {
|
|
187
|
+
await close()
|
|
188
|
+
}
|
|
189
|
+
}, TIMEOUT)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('consent screen — when it cannot go on', () => {
|
|
193
|
+
const cases = [
|
|
194
|
+
['an expired request', refusal('OAuthRequestExpired', 'oauth:request-expired:ABCD-EFGH'), 'error-expired'],
|
|
195
|
+
['an unknown or already used request', refusal('OAuthRequestNotFound', 'oauth:request-not-found:ABCD-EFGH'), 'error-not-found'],
|
|
196
|
+
['an end-user identity refused with 403', refusal('AuthForbidden', 'auth:forbidden:profile'), 'error-forbidden'],
|
|
197
|
+
] as const
|
|
198
|
+
|
|
199
|
+
for (const [name, answer, key] of cases) {
|
|
200
|
+
test(`${name} is one plain sentence and offers nothing to approve`, async () => {
|
|
201
|
+
const { page, close } = await open(CONSENT, { signedIn: true, stubs: { load: answer } })
|
|
202
|
+
try {
|
|
203
|
+
await page.getByTestId('oauth-consent-error').waitFor({ state: 'visible', timeout: 45_000 })
|
|
204
|
+
|
|
205
|
+
const said = (await page.getByTestId('oauth-consent-error').textContent()) ?? ''
|
|
206
|
+
expect(said).toBe(en.consent[key])
|
|
207
|
+
// Not the wire text, and not a stack.
|
|
208
|
+
expect(said).not.toMatch(/oauth:|auth:|\|\|\||at server/)
|
|
209
|
+
expect(await page.getByTestId('oauth-consent-approve').count()).toBe(0)
|
|
210
|
+
expect(await page.getByTestId('oauth-consent-deny').count()).toBe(0)
|
|
211
|
+
} finally {
|
|
212
|
+
await close()
|
|
213
|
+
}
|
|
214
|
+
}, TIMEOUT)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
test('a link with no request in it says there is nothing to approve', async () => {
|
|
218
|
+
const { page, calls, close } = await open(OAUTH_CONSENT_PATH, { signedIn: true })
|
|
219
|
+
try {
|
|
220
|
+
await page.getByTestId('oauth-consent-error').waitFor({ state: 'visible', timeout: 45_000 })
|
|
221
|
+
|
|
222
|
+
expect(await page.getByTestId('oauth-consent-error').textContent()).toBe(en.consent['error-missing'])
|
|
223
|
+
expect(calls).toEqual([])
|
|
224
|
+
} finally {
|
|
225
|
+
await close()
|
|
226
|
+
}
|
|
227
|
+
}, TIMEOUT)
|
|
228
|
+
|
|
229
|
+
test('a server failure that is none of those says only that something went wrong', async () => {
|
|
230
|
+
const { page, close } = await open(CONSENT, {
|
|
231
|
+
signedIn: true, stubs: { load: { status: 500, error: 'boom' } },
|
|
232
|
+
})
|
|
233
|
+
try {
|
|
234
|
+
await page.getByTestId('oauth-consent-error').waitFor({ state: 'visible', timeout: 45_000 })
|
|
235
|
+
|
|
236
|
+
expect(await page.getByTestId('oauth-consent-error').textContent()).toBe(en.consent.error)
|
|
237
|
+
} finally {
|
|
238
|
+
await close()
|
|
239
|
+
}
|
|
240
|
+
}, TIMEOUT)
|
|
241
|
+
|
|
242
|
+
test('refuses to render inside a frame, so an approval cannot be a clickjacked click', async () => {
|
|
243
|
+
// The FRAMED app is the one that has to be signed in, so the bearer rides in its own address.
|
|
244
|
+
const src = encodeURIComponent(`${CONSENT}&bearer=${encodeURIComponent(await bearer())}`)
|
|
245
|
+
const { page, calls, close } = await open(`/frame.html?src=${src}`, {
|
|
246
|
+
stubs: { load: { json: deviceView() }, approve: { json: {} } },
|
|
247
|
+
})
|
|
248
|
+
try {
|
|
249
|
+
const frame = page.frameLocator('#frame')
|
|
250
|
+
await frame.getByTestId('oauth-consent-card').waitFor({ state: 'visible', timeout: 45_000 })
|
|
251
|
+
|
|
252
|
+
expect(await frame.getByRole('alert').textContent()).toBe(en.consent.framed)
|
|
253
|
+
expect(await frame.getByTestId('oauth-consent-approve').count()).toBe(0)
|
|
254
|
+
expect(await frame.getByTestId('oauth-consent-deny').count()).toBe(0)
|
|
255
|
+
expect(calls.some(call => call.startsWith('POST'))).toBe(false)
|
|
256
|
+
} finally {
|
|
257
|
+
await close()
|
|
258
|
+
}
|
|
259
|
+
}, TIMEOUT)
|
|
260
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createServer } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { dirname, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
|
|
9
|
+
let url: string | null = null
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Boot a Vite dev server over `tests/harness/` — a real HTTP origin, because the OwlMeans router
|
|
13
|
+
* drives the History API and a `data:` URL has no history to drive.
|
|
14
|
+
*
|
|
15
|
+
* `@` resolves to the package's own primitive copy, exactly as a consuming app's bundler resolves
|
|
16
|
+
* it to theirs. React is deduped so hooks cross the workspace links.
|
|
17
|
+
*/
|
|
18
|
+
export const getHarnessUrl = async (): Promise<string> => {
|
|
19
|
+
if (url != null) return url
|
|
20
|
+
const server = await createServer({
|
|
21
|
+
configFile: false,
|
|
22
|
+
root: resolve(here, './harness'),
|
|
23
|
+
plugins: [react(), tailwindcss()],
|
|
24
|
+
resolve: {
|
|
25
|
+
alias: { '@': resolve(here, '../src/@') },
|
|
26
|
+
dedupe: ['react', 'react-dom'],
|
|
27
|
+
},
|
|
28
|
+
// Pre-bundled at server start rather than discovered while the first request is being served:
|
|
29
|
+
// a dependency found mid-transform re-optimizes and reloads the page underneath the navigation,
|
|
30
|
+
// which the first `goto` of a cold run pays for as a timeout.
|
|
31
|
+
optimizeDeps: {
|
|
32
|
+
include: [
|
|
33
|
+
'react', 'react-dom/client', 'react-i18next', 'i18next',
|
|
34
|
+
'@radix-ui/react-label', '@radix-ui/react-slot', 'class-variance-authority', 'clsx', 'tailwind-merge',
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
server: { port: 0 },
|
|
38
|
+
logLevel: 'warn',
|
|
39
|
+
})
|
|
40
|
+
await server.listen()
|
|
41
|
+
const local = server.resolvedUrls?.local?.[0]
|
|
42
|
+
if (local == null) throw new Error('vite did not expose a local URL')
|
|
43
|
+
url = local
|
|
44
|
+
|
|
45
|
+
return url
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const HARNESS_URL = (await getHarnessUrl()).replace(/\/$/, '')
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser } from '@owlmeans/test-ui'
|
|
3
|
+
import { OAUTH_CONSENT_PATH, OAUTH_DEVICE_PATH } from '@owlmeans/oauth'
|
|
4
|
+
import { deviceView, open, TIMEOUT } from './helpers.js'
|
|
5
|
+
|
|
6
|
+
afterAll(async () => { await closeBrowser() })
|
|
7
|
+
|
|
8
|
+
describe('device screen — a link that already carries the code', () => {
|
|
9
|
+
test('goes straight to the consent screen for that code, normalised', async () => {
|
|
10
|
+
const { page, calls, close } = await open(`${OAUTH_DEVICE_PATH}?user_code=abcd-efgh`, {
|
|
11
|
+
signedIn: true, stubs: { load: { json: deviceView() } },
|
|
12
|
+
})
|
|
13
|
+
try {
|
|
14
|
+
await page.getByTestId('oauth-consent-approve').waitFor({ state: 'visible', timeout: 45_000 })
|
|
15
|
+
|
|
16
|
+
const url = new URL(page.url())
|
|
17
|
+
expect(url.pathname).toBe(OAUTH_CONSENT_PATH)
|
|
18
|
+
expect(url.searchParams.get('ref')).toBe('ABCD-EFGH')
|
|
19
|
+
// The typed form is never shown when the code came with the link.
|
|
20
|
+
expect(await page.getByTestId('oauth-device-input').count()).toBe(0)
|
|
21
|
+
expect(calls).toEqual(['GET ABCD-EFGH'])
|
|
22
|
+
} finally {
|
|
23
|
+
await close()
|
|
24
|
+
}
|
|
25
|
+
}, TIMEOUT)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('device screen — a bare link', () => {
|
|
29
|
+
test('asks for the code, and cannot be submitted while it is empty', async () => {
|
|
30
|
+
const { page, close } = await open(OAUTH_DEVICE_PATH, { signedIn: true })
|
|
31
|
+
try {
|
|
32
|
+
const input = page.getByTestId('oauth-device-input')
|
|
33
|
+
await input.waitFor({ state: 'visible', timeout: 45_000 })
|
|
34
|
+
|
|
35
|
+
expect(await input.getAttribute('placeholder')).toBe('XXXX-XXXX')
|
|
36
|
+
expect(await input.inputValue()).toBe('')
|
|
37
|
+
expect(await page.getByTestId('oauth-device-submit').isDisabled()).toBe(true)
|
|
38
|
+
|
|
39
|
+
await input.fill(' ')
|
|
40
|
+
expect(await page.getByTestId('oauth-device-submit').isDisabled()).toBe(true)
|
|
41
|
+
|
|
42
|
+
await input.fill('abcd')
|
|
43
|
+
expect(await page.getByTestId('oauth-device-submit').isDisabled()).toBe(false)
|
|
44
|
+
} finally {
|
|
45
|
+
await close()
|
|
46
|
+
}
|
|
47
|
+
}, TIMEOUT)
|
|
48
|
+
|
|
49
|
+
test('a typed code continues to consent the same way a linked one does', async () => {
|
|
50
|
+
const { page, calls, close } = await open(OAUTH_DEVICE_PATH, {
|
|
51
|
+
signedIn: true, stubs: { load: { json: deviceView() } },
|
|
52
|
+
})
|
|
53
|
+
try {
|
|
54
|
+
await page.getByTestId('oauth-device-input').fill('abcdefgh')
|
|
55
|
+
await page.getByTestId('oauth-device-submit').click()
|
|
56
|
+
await page.getByTestId('oauth-consent-approve').waitFor({ state: 'visible', timeout: 45_000 })
|
|
57
|
+
|
|
58
|
+
expect(new URL(page.url()).searchParams.get('ref')).toBe('ABCD-EFGH')
|
|
59
|
+
expect(calls).toEqual(['GET ABCD-EFGH'])
|
|
60
|
+
} finally {
|
|
61
|
+
await close()
|
|
62
|
+
}
|
|
63
|
+
}, TIMEOUT)
|
|
64
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser } from '@owlmeans/test-ui'
|
|
3
|
+
import { OAUTH_DONE_PATH } from '@owlmeans/oauth'
|
|
4
|
+
import en from '../src/i18n/en.json' with { type: 'json' }
|
|
5
|
+
import { open, TIMEOUT } from './helpers.js'
|
|
6
|
+
|
|
7
|
+
afterAll(async () => { await closeBrowser() })
|
|
8
|
+
|
|
9
|
+
describe('done screen', () => {
|
|
10
|
+
test('a device sign-in tells the person to close the window — the application picks it up', async () => {
|
|
11
|
+
const { page, close } = await open(`${OAUTH_DONE_PATH}?kind=device&ref=ABCD-EFGH`)
|
|
12
|
+
try {
|
|
13
|
+
const card = page.getByTestId('oauth-done-card')
|
|
14
|
+
await card.waitFor({ state: 'visible', timeout: 45_000 })
|
|
15
|
+
|
|
16
|
+
expect(await card.textContent()).toContain(en.done.title)
|
|
17
|
+
expect(await card.textContent()).toContain(en.done['device-message'])
|
|
18
|
+
expect(en.done['device-message']).toMatch(/close this window/i)
|
|
19
|
+
} finally {
|
|
20
|
+
await close()
|
|
21
|
+
}
|
|
22
|
+
}, TIMEOUT)
|
|
23
|
+
|
|
24
|
+
test('any other completion says the same, without the device promise', async () => {
|
|
25
|
+
const { page, close } = await open(OAUTH_DONE_PATH)
|
|
26
|
+
try {
|
|
27
|
+
const card = page.getByTestId('oauth-done-card')
|
|
28
|
+
await card.waitFor({ state: 'visible', timeout: 45_000 })
|
|
29
|
+
|
|
30
|
+
const text = (await card.textContent()) ?? ''
|
|
31
|
+
expect(text).toContain(en.done.message)
|
|
32
|
+
expect(text).not.toContain(en.done['device-message'])
|
|
33
|
+
expect(en.done.message).toMatch(/close this window/i)
|
|
34
|
+
// Nothing to click and nowhere further to go: the tab's only remaining job is to be closed.
|
|
35
|
+
expect(await page.getByRole('button').count()).toBe(0)
|
|
36
|
+
expect(await page.getByRole('link').count()).toBe(0)
|
|
37
|
+
} finally {
|
|
38
|
+
await close()
|
|
39
|
+
}
|
|
40
|
+
}, TIMEOUT)
|
|
41
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '*.css'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>@owlmeans/web-oauth framed</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<iframe id="frame" title="framed consent" width="600" height="500"></iframe>
|
|
9
|
+
<script>
|
|
10
|
+
// The consent screen, framed by a page of the same origin: the clickjacking case the screen
|
|
11
|
+
// refuses to render into, whoever the framing page is.
|
|
12
|
+
document.getElementById('frame').src = new URLSearchParams(location.search).get('src') || '/'
|
|
13
|
+
</script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import '../../src/@/globals.css'
|
|
2
|
+
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { config } from '@owlmeans/client-context'
|
|
5
|
+
import { AppType, service } from '@owlmeans/config'
|
|
6
|
+
import { HOME } from '@owlmeans/context'
|
|
7
|
+
import { App, handler } from '@owlmeans/client'
|
|
8
|
+
import { I18nContext } from '@owlmeans/client-i18n'
|
|
9
|
+
import { bindAll, bindScreen } from '@owlmeans/client-entrypoint'
|
|
10
|
+
import type { EntrypointTree } from '@owlmeans/entrypoint'
|
|
11
|
+
import { openProtocol } from '@owlmeans/entrypoint'
|
|
12
|
+
import { backend, frontend, route } from '@owlmeans/route'
|
|
13
|
+
import { AUTH_RESOURCE, USER_ID } from '@owlmeans/client-auth'
|
|
14
|
+
import { FLOW_STATE, RESUME_FLOW } from '@owlmeans/client-flow'
|
|
15
|
+
import { set } from 'idb-keyval'
|
|
16
|
+
import type { ClientResource } from '@owlmeans/client-resource'
|
|
17
|
+
import { appendFlowService } from '@owlmeans/web-flow'
|
|
18
|
+
import { entrypoints as baseEntrypoints, makeContext } from '@owlmeans/web-client'
|
|
19
|
+
import { makeOAuthProtocols } from '@owlmeans/oauth'
|
|
20
|
+
import { appendOAuthScreens, oauthEntrypoints } from '../../src/index.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A real web application around the three screens: the real web context (auth service, IndexedDB
|
|
24
|
+
* resources, History-API router), the real flow service, the real dispatcher — and the consent API
|
|
25
|
+
* declared as ordinary backend entrypoints. Only the SERVER is absent: the specs answer the API's
|
|
26
|
+
* requests with `page.route`, on this very origin, so nothing needs CORS.
|
|
27
|
+
*
|
|
28
|
+
* Query switches (one harness process serves every case):
|
|
29
|
+
* `?bearer=<token>` the person is signed in with this bearer before the app renders
|
|
30
|
+
* `?lng=<code>` render in that language
|
|
31
|
+
*/
|
|
32
|
+
const params = new URLSearchParams(window.location.search)
|
|
33
|
+
|
|
34
|
+
const SERVICE = 'web-oauth-test'
|
|
35
|
+
const API = `${SERVICE}-api`
|
|
36
|
+
const API_BASE = `${SERVICE}:api:base`
|
|
37
|
+
const lng = params.get('lng') ?? 'en'
|
|
38
|
+
|
|
39
|
+
const origin = window.location
|
|
40
|
+
const base = service({
|
|
41
|
+
type: AppType.Frontend, service: SERVICE, host: origin.hostname, port: Number(origin.port),
|
|
42
|
+
})
|
|
43
|
+
service({
|
|
44
|
+
type: AppType.Backend, service: API, host: origin.hostname, port: Number(origin.port), base: 'api',
|
|
45
|
+
}, base)
|
|
46
|
+
base.security = { unsecure: true }
|
|
47
|
+
;(base as { i18n?: unknown }).i18n = { defaultLng: lng, fallbackLng: lng }
|
|
48
|
+
|
|
49
|
+
// `ready` stays false: the Router compiles the entrypoint tree into routes ONLY while the context
|
|
50
|
+
// is un-initialized.
|
|
51
|
+
const cfg = config(SERVICE, base as never)
|
|
52
|
+
const context = makeContext(cfg as never)
|
|
53
|
+
context.serviceRoute(SERVICE, true)
|
|
54
|
+
context.serviceRoute(API, true)
|
|
55
|
+
appendFlowService(context as never)
|
|
56
|
+
appendOAuthScreens(context as never)
|
|
57
|
+
|
|
58
|
+
// The consent API, mounted where the application mounts it: under a backend parent.
|
|
59
|
+
const apiBase = openProtocol(route(API_BASE, '/', backend({ service: API })))
|
|
60
|
+
const consentApi = makeOAuthProtocols({ parent: API_BASE })
|
|
61
|
+
|
|
62
|
+
const home = openProtocol(route(HOME, '/', frontend({ default: true })))
|
|
63
|
+
|
|
64
|
+
context.registerEntrypoints([
|
|
65
|
+
// The framework's own entrypoints: the real dispatcher among them.
|
|
66
|
+
...baseEntrypoints,
|
|
67
|
+
...bindAll({
|
|
68
|
+
apiBase,
|
|
69
|
+
oauth: { base: consentApi.base, load: consentApi.load, approve: consentApi.approve, deny: consentApi.deny },
|
|
70
|
+
} as unknown as EntrypointTree),
|
|
71
|
+
...oauthEntrypoints(),
|
|
72
|
+
bindScreen(home, handler(() => <div id="home">home-screen</div>)),
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/** What the specs read back from the page — the real stores, never a copy. */
|
|
76
|
+
;(window as unknown as { __oauth: unknown }).__oauth = {
|
|
77
|
+
suspended: async () => await context.resource<ClientResource<Record<string, unknown>>>(FLOW_STATE).load(RESUME_FLOW),
|
|
78
|
+
token: async () => await context.auth().authenticated(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A person who signed in on an earlier visit: the record the auth service reads back on
|
|
82
|
+
// `authenticated()`, written where the web store keeps it (`<db alias>:<record id>`). The services
|
|
83
|
+
// are not initialized yet — and must not be, or the router would compile no routes.
|
|
84
|
+
const bearer = params.get('bearer')
|
|
85
|
+
if (bearer != null && bearer !== '') await set(`${AUTH_RESOURCE}:${USER_ID}`, { id: USER_ID, token: bearer })
|
|
86
|
+
|
|
87
|
+
createRoot(document.getElementById('root')!).render(
|
|
88
|
+
<I18nContext config={context.cfg}><App context={context as never} /></I18nContext>
|
|
89
|
+
)
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { launchBrowser } from '@owlmeans/test-ui'
|
|
2
|
+
import type { Page } from '@owlmeans/test-ui'
|
|
3
|
+
import { makeBearer, USER } from '@owlmeans/test-auth'
|
|
4
|
+
import type { ConsentView } from '@owlmeans/oauth'
|
|
5
|
+
import { HARNESS_URL } from './context.js'
|
|
6
|
+
|
|
7
|
+
/** Generous: a cold harness compiles the whole app on its first request. */
|
|
8
|
+
export const TIMEOUT = 60_000
|
|
9
|
+
|
|
10
|
+
/** A real, parseable bearer — the harness `?bearer=` puts it through the real auth service. */
|
|
11
|
+
export const bearer = async (): Promise<string> => await makeBearer(USER)
|
|
12
|
+
|
|
13
|
+
export const deviceView = (over: Partial<ConsentView> = {}): ConsentView => ({
|
|
14
|
+
ref: 'ABCD-EFGH', kind: 'device',
|
|
15
|
+
client: { name: 'Viable MCP', origin: 'static' },
|
|
16
|
+
userCode: 'ABCD-EFGH', deviceName: 'my-laptop',
|
|
17
|
+
scopes: ['*'], expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
18
|
+
...over,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const codeView = (over: Partial<ConsentView> = {}): ConsentView => ({
|
|
22
|
+
ref: 'req-1', kind: 'code',
|
|
23
|
+
client: { name: 'Claude Code', origin: 'cimd', host: 'claude.ai' },
|
|
24
|
+
redirectHost: '127.0.0.1', localhostOnly: true,
|
|
25
|
+
scopes: ['*'], expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
26
|
+
...over,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** What the server answers a consent-API call with. */
|
|
30
|
+
export type Answer =
|
|
31
|
+
| { status?: number, json: unknown }
|
|
32
|
+
| { status: number, error: string }
|
|
33
|
+
|
|
34
|
+
export interface Stubs {
|
|
35
|
+
load?: Answer
|
|
36
|
+
approve?: Answer
|
|
37
|
+
deny?: Answer
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The wire shape of a refused request: `type|||message|||stack`, exactly what the API client unmarshals. */
|
|
41
|
+
export const refusal = (type: string, message: string): Answer =>
|
|
42
|
+
({ status: type === 'AuthForbidden' ? 403 : 404, error: `${type}|||${message}|||at server` })
|
|
43
|
+
|
|
44
|
+
export interface Opened {
|
|
45
|
+
page: Page
|
|
46
|
+
/** Every consent-API request the page made, in order — `GET`/`POST` and the path after `/oauth-consent/`. */
|
|
47
|
+
calls: string[]
|
|
48
|
+
close: () => Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Open a screen of the harness app in a fresh browser context, with the consent API answered by
|
|
53
|
+
* `stubs`. The stubs sit on this origin (the harness declares the API service on it), so there is
|
|
54
|
+
* no CORS to satisfy and no live server to reach.
|
|
55
|
+
*/
|
|
56
|
+
export const open = async (
|
|
57
|
+
path: string, opts: { stubs?: Stubs, signedIn?: boolean, lng?: string, authenticate?: boolean } = {}
|
|
58
|
+
): Promise<Opened> => {
|
|
59
|
+
const browser = await launchBrowser({ headless: true })
|
|
60
|
+
const context = await browser.newContext()
|
|
61
|
+
const page = await context.newPage()
|
|
62
|
+
page.setDefaultTimeout(30_000)
|
|
63
|
+
|
|
64
|
+
const calls: string[] = []
|
|
65
|
+
await context.route(/\/api\/.*oauth-consent\//, async route => {
|
|
66
|
+
const request = route.request()
|
|
67
|
+
const tail = new URL(request.url()).pathname.split('/oauth-consent/')[1] ?? ''
|
|
68
|
+
calls.push(`${request.method()} ${tail}`)
|
|
69
|
+
|
|
70
|
+
const kind = request.method() === 'GET' ? 'load' : tail.endsWith('/deny') ? 'deny' : 'approve'
|
|
71
|
+
const answer = opts.stubs?.[kind]
|
|
72
|
+
if (answer == null) return await route.fulfill({ status: 500, body: `no stub for ${kind}` })
|
|
73
|
+
|
|
74
|
+
return 'error' in answer
|
|
75
|
+
? await route.fulfill({ status: answer.status, contentType: 'text/plain', body: answer.error })
|
|
76
|
+
: await route.fulfill({
|
|
77
|
+
status: answer.status ?? 200, contentType: 'application/json', body: JSON.stringify(answer.json),
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// The sign-in exchange the dispatcher makes when it is handed a token: answered with a bearer the
|
|
82
|
+
// way the platform's own authentication endpoint does.
|
|
83
|
+
if (opts.authenticate === true) {
|
|
84
|
+
const issued = await bearer()
|
|
85
|
+
await context.route(/\/authenticate$/, async route => {
|
|
86
|
+
calls.push(`${route.request().method()} authenticate`)
|
|
87
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ token: issued }) })
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const url = new URL(`${HARNESS_URL}${path}`)
|
|
92
|
+
if (opts.signedIn === true) url.searchParams.set('bearer', await bearer())
|
|
93
|
+
if (opts.lng != null) url.searchParams.set('lng', opts.lng)
|
|
94
|
+
await page.goto(url.toString(), { waitUntil: 'domcontentloaded' })
|
|
95
|
+
|
|
96
|
+
return { page, calls, close: async () => { await context.close() } }
|
|
97
|
+
}
|