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