mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
207 lines
6.5 KiB
TypeScript
207 lines
6.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
authorizationUrl,
|
|
beginLogin,
|
|
completeLogin,
|
|
ensureApp,
|
|
pkceChallenge,
|
|
redirectUri,
|
|
revoke,
|
|
SCOPES,
|
|
} from './oauth'
|
|
import type { OAuthApp } from './types'
|
|
|
|
const APP_KEY = 'plspace:oauth:apps'
|
|
const PENDING_KEY = 'plspace:oauth:pending'
|
|
|
|
function response(body: unknown, status = 200): Response {
|
|
return new Response(body === null ? null : JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
function app(overrides: Partial<OAuthApp> = {}): OAuthApp {
|
|
return {
|
|
id: 'app-1',
|
|
name: 'plspace',
|
|
client_id: 'client-id',
|
|
client_secret: 'client-secret',
|
|
redirect_uris: [redirectUri()],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function saveApp(value = app()): void {
|
|
localStorage.setItem(
|
|
APP_KEY,
|
|
JSON.stringify({
|
|
'social.example': { ...value, plspace_redirect_uri: redirectUri() },
|
|
}),
|
|
)
|
|
}
|
|
|
|
function savePending(overrides: Record<string, unknown> = {}): void {
|
|
sessionStorage.setItem(
|
|
PENDING_KEY,
|
|
JSON.stringify({
|
|
host: 'social.example',
|
|
state: 'expected-state',
|
|
verifier: 'pkce-verifier',
|
|
createdAt: Date.now(),
|
|
returnTo: '#/timeline/home',
|
|
...overrides,
|
|
}),
|
|
)
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('OAuth request compatibility', () => {
|
|
it('produces the RFC 7636 S256 challenge', async () => {
|
|
await expect(
|
|
pkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
|
|
).resolves.toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM')
|
|
})
|
|
|
|
it('builds a Mastodon-compatible authorization URL with state and PKCE', () => {
|
|
const url = new URL(
|
|
authorizationUrl('social.example', app(), 'oauth-state', 'pkce-challenge'),
|
|
)
|
|
|
|
expect(url.origin).toBe('https://social.example')
|
|
expect(url.pathname).toBe('/oauth/authorize')
|
|
expect(url.searchParams.get('client_id')).toBe('client-id')
|
|
expect(url.searchParams.get('redirect_uri')).toBe(redirectUri())
|
|
expect(url.searchParams.get('response_type')).toBe('code')
|
|
expect(url.searchParams.get('scope')).toBe(SCOPES)
|
|
expect(url.searchParams.get('state')).toBe('oauth-state')
|
|
expect(url.searchParams.get('code_challenge')).toBe('pkce-challenge')
|
|
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
|
})
|
|
|
|
it('registers once and understands the Mastodon 4.3 redirect_uris response', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(response(app()))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await ensureApp('social.example')
|
|
await ensureApp('social.example')
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://social.example/api/v1/apps')
|
|
expect(JSON.parse(String(options.body))).toMatchObject({
|
|
client_name: 'plspace',
|
|
redirect_uris: redirectUri(),
|
|
scopes: SCOPES,
|
|
})
|
|
})
|
|
|
|
it('persists one state-bound PKCE attempt before navigating away', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response(app())))
|
|
const navigate = vi.fn()
|
|
|
|
await beginLogin('social.example', '#/mail', navigate)
|
|
|
|
expect(navigate).toHaveBeenCalledOnce()
|
|
const url = new URL(navigate.mock.calls[0][0])
|
|
const pending = JSON.parse(String(sessionStorage.getItem(PENDING_KEY))) as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
expect(pending).toMatchObject({
|
|
host: 'social.example',
|
|
returnTo: '#/mail',
|
|
state: url.searchParams.get('state'),
|
|
})
|
|
expect(typeof pending.verifier).toBe('string')
|
|
expect(typeof pending.createdAt).toBe('number')
|
|
expect(url.searchParams.get('code_challenge')).toBeTruthy()
|
|
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
|
})
|
|
|
|
it('exchanges a code as URL-encoded form data and accepts Pleroma extras', async () => {
|
|
saveApp()
|
|
savePending()
|
|
window.history.replaceState(
|
|
{},
|
|
'',
|
|
'/?code=authorization-code&state=expected-state&iss=https%3A%2F%2Fsocial.example',
|
|
)
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
response({
|
|
access_token: 'access-token',
|
|
token_type: 'Bearer',
|
|
scope: SCOPES,
|
|
created_at: 123,
|
|
id: 42,
|
|
me: 'https://social.example/users/alice',
|
|
}),
|
|
)
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(completeLogin()).resolves.toEqual({
|
|
host: 'social.example',
|
|
token: 'access-token',
|
|
returnTo: '#/timeline/home',
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://social.example/oauth/token')
|
|
expect(options.body).toBeInstanceOf(URLSearchParams)
|
|
expect(Object.fromEntries(new URLSearchParams(String(options.body)))).toEqual({
|
|
grant_type: 'authorization_code',
|
|
client_id: 'client-id',
|
|
client_secret: 'client-secret',
|
|
redirect_uri: redirectUri(),
|
|
code: 'authorization-code',
|
|
code_verifier: 'pkce-verifier',
|
|
})
|
|
expect(window.location.search).toBe('')
|
|
})
|
|
|
|
it('does not retry a PKCE-bound code without its verifier', async () => {
|
|
saveApp()
|
|
savePending()
|
|
window.history.replaceState({}, '', '/?code=bad-code&state=expected-state')
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(response({ error: 'invalid_grant' }, 400))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(completeLogin()).rejects.toThrow('invalid_grant')
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('rejects expired attempts before exchanging the code', async () => {
|
|
saveApp()
|
|
savePending({ createdAt: Date.now() - 11 * 60 * 1000 })
|
|
window.history.replaceState({}, '', '/?code=old-code&state=expected-state')
|
|
const fetchMock = vi.fn()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(completeLogin()).rejects.toThrow('expired')
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('revokes with URL-encoded form data', async () => {
|
|
saveApp()
|
|
const fetchMock = vi.fn().mockResolvedValue(response({}))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await revoke('social.example', 'access-token')
|
|
|
|
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://social.example/oauth/revoke')
|
|
expect(options.body).toBeInstanceOf(URLSearchParams)
|
|
expect(Object.fromEntries(new URLSearchParams(String(options.body)))).toEqual({
|
|
client_id: 'client-id',
|
|
client_secret: 'client-secret',
|
|
token: 'access-token',
|
|
})
|
|
})
|
|
})
|