Files
plspace/src/lib/api/oauth.test.ts
T
2026-07-29 10:45:17 +09:00

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',
})
})
})