feat: add shared decks and multi-account Mastodon OAuth

This commit is contained in:
2026-09-24 16:52:55 +09:00
parent d2cbf4dbd3
commit c47f58f065
100 changed files with 9215 additions and 1027 deletions
+65
View File
@@ -0,0 +1,65 @@
import { eq } from 'drizzle-orm'
import { decryptCredential } from '../connections/credentials.server'
import { getDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
export async function requestMastodon(
connectionId: string,
path: string,
query?: URLSearchParams,
): Promise<Response> {
if (
!path.startsWith('/api/') ||
path.startsWith('//') ||
path.includes('\\') ||
path.includes('?') ||
path.includes('#')
) {
throw new Error('Invalid Mastodon API path.')
}
const database = getDatabase()
const connection = database
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
throw new Error(
'Mastodon connection is unavailable. Reconnect this account.',
)
const credential = database
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (!credential)
throw new Error(
'Mastodon credentials are unavailable. Reconnect this account.',
)
const url = new URL(path, requireMastodonOrigin(connection.origin))
if (query) url.search = query.toString()
const response = await safeMastodonRequest(url, {
headers: {
authorization: `Bearer ${decryptCredential(credential.encryptedToken, `connection:${connectionId}:access-token`)}`,
},
})
if (response.status === 401) {
database.transaction(
(tx) => {
const current = tx
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (current?.encryptedToken !== credential.encryptedToken) return
tx.update(connections)
.set({ status: 'expired', updatedAt: Date.now() })
.where(eq(connections.id, connectionId))
.run()
},
{ behavior: 'immediate' },
)
}
return response
}
+100
View File
@@ -0,0 +1,100 @@
// @vitest-environment node
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { requestMastodon } from './client.server'
let database: AppDatabase
const send = vi.hoisted(() => vi.fn())
vi.mock('../storage/database.server', async (original) => ({
...(await original<typeof import('../storage/database.server')>()),
getDatabase: () => database,
}))
vi.mock('../connections/credentials.server', () => ({
decryptCredential: (value: string) => value,
}))
vi.mock('./transport.server', () => ({
requireMastodonOrigin: (origin: string) => origin,
safeMastodonRequest: send,
}))
beforeEach(() => {
send.mockReset()
database = openDatabase(':memory:')
database
.insert(connections)
.values({
id: 'account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: 'Account',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
database
.insert(connectionCredentials)
.values({
connectionId: 'account',
encryptedToken: 'old-token',
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
it('marks the current rejected credential expired', async () => {
send.mockResolvedValue(new Response(null, { status: 401 }))
expect((await requestMastodon('account', '/api/v1/lists')).status).toBe(401)
expect(database.select().from(connections).get()?.status).toBe('expired')
})
it('does not expire a reconnected account when an old request returns 401 later', async () => {
let resolveResponse!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
send.mockReturnValueOnce(response)
const request = requestMastodon('account', '/api/v1/lists')
expect(send).toHaveBeenCalledWith(
new URL('https://mastodon.invalid/api/v1/lists'),
{ headers: { authorization: 'Bearer old-token' } },
)
database
.update(connectionCredentials)
.set({
encryptedToken: 'new-token',
updatedAt: 2,
})
.where(eq(connectionCredentials.connectionId, 'account'))
.run()
resolveResponse(new Response(null, { status: 401 }))
expect((await request).status).toBe(401)
expect(database.select().from(connections).get()?.status).toBe('connected')
expect(
database.select().from(connectionCredentials).get()?.encryptedToken,
).toBe('new-token')
send.mockResolvedValueOnce(Response.json([]))
expect((await requestMastodon('account', '/api/v1/lists')).ok).toBe(true)
expect(send).toHaveBeenLastCalledWith(
new URL('https://mastodon.invalid/api/v1/lists'),
{ headers: { authorization: 'Bearer new-token' } },
)
})
it('does not change a disconnected account when an old request returns 401 later', async () => {
let resolveResponse!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
send.mockReturnValueOnce(response)
const request = requestMastodon('account', '/api/v1/lists')
database.delete(connectionCredentials).run()
database.update(connections).set({ status: 'disconnected' }).run()
resolveResponse(new Response(null, { status: 401 }))
await request
expect(database.select().from(connections).get()?.status).toBe('disconnected')
})
+364
View File
@@ -0,0 +1,364 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { and, eq, gt, isNull, lt } from 'drizzle-orm'
import { z } from 'zod'
import { readAccessConfig } from '../access/policy.server'
import {
decryptCredential,
encryptCredential,
} from '../connections/credentials.server'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
oauthApps,
oauthAttempts,
} from '../storage/schema'
import { requireMastodonOrigin, safeMastodonRequest } from './transport.server'
export const oauthLifetimeSeconds = 600
const scopes = 'read:accounts read:statuses read:lists read:search'
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
const random = () => randomBytes(32).toString('base64url')
const appSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
})
const tokenSchema = z.object({
access_token: z.string().min(1),
token_type: z.string().regex(/^bearer$/i),
scope: z.string(),
})
const accountSchema = z.object({
id: z.string().min(1),
acct: z.string().min(1),
})
type Send = typeof safeMastodonRequest
export function oauthCookieName(state: string) {
return `mastodon_oauth_${hash(state).slice(0, 24)}`
}
function callbackUri() {
const config = readAccessConfig()
if (!config) throw new Error('Access configuration is required.')
return `${config.origin}/oauth/mastodon/callback`
}
async function formRequest(
send: Send,
origin: string,
path: string,
fields: Record<string, string>,
) {
const response = await send(new URL(path, origin), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields),
})
if (!response.ok)
throw new Error(`Mastodon request failed with HTTP ${response.status}.`)
return response
}
async function getApp(origin: string, database: AppDatabase, send: Send) {
const redirectUri = callbackUri()
const existing = database
.select()
.from(oauthApps)
.where(
and(
eq(oauthApps.origin, origin),
eq(oauthApps.redirectUri, redirectUri),
eq(oauthApps.scopes, scopes),
),
)
.get()
if (existing) return existing
const metadataResponse = await send(
new URL('/.well-known/oauth-authorization-server', origin),
)
if (!metadataResponse.ok)
throw new Error('Mastodon OAuth metadata is unavailable.')
const metadata = z
.object({ code_challenge_methods_supported: z.array(z.string()) })
.parse(await metadataResponse.json())
if (!metadata.code_challenge_methods_supported.includes('S256'))
throw new Error('This instance does not support PKCE S256.')
const registered = appSchema.parse(
await (
await formRequest(send, origin, '/api/v1/apps', {
client_name: 'Twitter Lite',
redirect_uris: redirectUri,
scopes,
})
).json(),
)
const id = randomUUID()
const app = {
id,
origin,
redirectUri,
scopes,
clientId: registered.client_id,
encryptedClientSecret: encryptCredential(
registered.client_secret,
`oauth-app:${id}:client-secret`,
),
createdAt: Date.now(),
}
database.insert(oauthApps).values(app).onConflictDoNothing().run()
return (
database
.select()
.from(oauthApps)
.where(
and(
eq(oauthApps.origin, origin),
eq(oauthApps.redirectUri, redirectUri),
eq(oauthApps.scopes, scopes),
),
)
.get() ?? app
)
}
export async function beginMastodonOAuth(
input: { origin: string; connectionId?: string },
database = getDatabase(),
send: Send = safeMastodonRequest,
) {
database
.delete(oauthAttempts)
.where(lt(oauthAttempts.expiresAt, Date.now()))
.run()
const origin = requireMastodonOrigin(input.origin)
if (input.connectionId) {
const connection = database
.select()
.from(connections)
.where(eq(connections.id, input.connectionId))
.get()
if (connection?.platform !== 'mastodon' || connection.origin !== origin)
throw new Error('The reconnect account does not match this instance.')
}
const app = await getApp(origin, database, send)
const state = random()
const browserCookie = random()
const verifier = random()
const stateHash = hash(state)
database
.insert(oauthAttempts)
.values({
stateHash,
browserHash: hash(browserCookie),
appId: app.id,
encryptedVerifier: encryptCredential(
verifier,
`oauth-attempt:${stateHash}:verifier`,
),
connectionId: input.connectionId ?? null,
expiresAt: Date.now() + oauthLifetimeSeconds * 1000,
})
.run()
const authorize = new URL('/oauth/authorize', origin)
authorize.search = new URLSearchParams({
response_type: 'code',
client_id: app.clientId,
redirect_uri: app.redirectUri,
scope: app.scopes,
state,
force_login: 'true',
code_challenge_method: 'S256',
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
}).toString()
return { authorizationUrl: authorize.toString(), state, browserCookie }
}
export async function completeMastodonOAuth(
input: {
state: string
browserCookie?: string
code?: string
error?: string
},
database = getDatabase(),
send: Send = safeMastodonRequest,
): Promise<string> {
if (!input.state || !input.browserCookie)
throw new Error('OAuth state or browser binding is missing.')
const stateHash = hash(input.state)
const now = Date.now()
const attempt = database
.update(oauthAttempts)
.set({ consumedAt: now })
.where(
and(
eq(oauthAttempts.stateHash, stateHash),
eq(oauthAttempts.browserHash, hash(input.browserCookie)),
isNull(oauthAttempts.consumedAt),
gt(oauthAttempts.expiresAt, now),
),
)
.returning()
.get()
if (!attempt)
throw new Error('OAuth attempt is invalid, expired, or already used.')
if (input.error || !input.code)
throw new Error('Mastodon authorization was not completed.')
const app = database
.select()
.from(oauthApps)
.where(eq(oauthApps.id, attempt.appId))
.get()
if (!app) throw new Error('OAuth app is unavailable.')
requireMastodonOrigin(app.origin)
const token = tokenSchema.parse(
await (
await formRequest(send, app.origin, '/oauth/token', {
grant_type: 'authorization_code',
code: input.code,
client_id: app.clientId,
client_secret: decryptCredential(
app.encryptedClientSecret,
`oauth-app:${app.id}:client-secret`,
),
redirect_uri: app.redirectUri,
code_verifier: decryptCredential(
attempt.encryptedVerifier,
`oauth-attempt:${stateHash}:verifier`,
),
})
).json(),
)
const granted = new Set(token.scope.split(' '))
if (!scopes.split(' ').every((scope) => granted.has(scope)))
throw new Error('Required Mastodon read permissions were not granted.')
const verified = await send(
new URL('/api/v1/accounts/verify_credentials', app.origin),
{
headers: { authorization: `Bearer ${token.access_token}` },
},
)
if (!verified.ok) throw new Error('Mastodon account verification failed.')
const account = accountSchema.parse(await verified.json())
return database.transaction((tx) => {
const existing = tx
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'mastodon'),
eq(connections.origin, app.origin),
eq(connections.accountId, account.id),
),
)
.get()
if (attempt.connectionId && existing?.id !== attempt.connectionId)
throw new Error(
'The authorized account does not match the account being reconnected.',
)
const id = existing?.id ?? randomUUID()
tx.insert(connections)
.values({
id,
platform: 'mastodon',
origin: app.origin,
accountId: account.id,
displayName: `@${account.acct}`,
status: 'connected',
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: connections.id,
set: {
displayName: `@${account.acct}`,
status: 'connected',
updatedAt: now,
},
})
.run()
const credential = {
connectionId: id,
appId: app.id,
encryptedToken: encryptCredential(
token.access_token,
`connection:${id}:access-token`,
),
updatedAt: now,
}
tx.insert(connectionCredentials)
.values(credential)
.onConflictDoUpdate({
target: connectionCredentials.connectionId,
set: credential,
})
.run()
return id
})
}
export async function disconnectMastodon(
connectionId: string,
database = getDatabase(),
send: Send = safeMastodonRequest,
) {
const connection = database
.select()
.from(connections)
.where(eq(connections.id, connectionId))
.get()
if (connection?.platform !== 'mastodon')
throw new Error('Mastodon connection was not found.')
const credential = database
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (credential) {
const app = credential.appId
? database
.select()
.from(oauthApps)
.where(eq(oauthApps.id, credential.appId))
.get()
: undefined
if (!app)
throw new Error(
'The OAuth app required to revoke this connection is missing.',
)
requireMastodonOrigin(connection.origin)
await formRequest(send, connection.origin, '/oauth/revoke', {
client_id: app.clientId,
client_secret: decryptCredential(
app.encryptedClientSecret,
`oauth-app:${app.id}:client-secret`,
),
token: decryptCredential(
credential.encryptedToken,
`connection:${connectionId}:access-token`,
),
})
}
database.transaction(
(tx) => {
const current = tx
.select()
.from(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.get()
if (current?.encryptedToken !== credential?.encryptedToken)
throw new Error(
'This account was reconnected during revocation. Its new connection has been kept. Refresh the account list before trying again.',
)
tx.delete(connectionCredentials)
.where(eq(connectionCredentials.connectionId, connectionId))
.run()
tx.update(connections)
.set({ status: 'disconnected', updatedAt: Date.now() })
.where(eq(connections.id, connectionId))
.run()
},
{ behavior: 'immediate' },
)
}
+357
View File
@@ -0,0 +1,357 @@
// @vitest-environment node
import { createHash, randomBytes } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { decryptCredential } from '../connections/credentials.server'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
oauthApps,
oauthAttempts,
} from '../storage/schema'
import {
beginMastodonOAuth,
completeMastodonOAuth,
disconnectMastodon,
} from './oauth.server'
let database: AppDatabase
let directory: string
let accountId: string
const origin = 'https://mastodon.invalid'
const token = 'private-access-token'
const clientSecret = 'private-client-secret'
const send = vi.fn(async (url: URL, _init?: RequestInit): Promise<Response> => {
if (url.pathname === '/.well-known/oauth-authorization-server')
return Response.json({ code_challenge_methods_supported: ['S256'] })
if (url.pathname === '/api/v1/apps')
return Response.json({ client_id: 'client', client_secret: clientSecret })
if (url.pathname === '/oauth/token')
return Response.json({
access_token: token,
token_type: 'Bearer',
scope: 'read:accounts read:statuses read:lists read:search',
})
if (url.pathname === '/api/v1/accounts/verify_credentials')
return Response.json({ id: accountId, acct: `user${accountId}` })
if (url.pathname === '/oauth/revoke') return Response.json({})
throw new Error('Unexpected mock endpoint')
})
beforeEach(() => {
database = openDatabase(':memory:')
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-oauth-'))
const key = join(directory, 'key')
writeFileSync(key, randomBytes(32).toString('base64'), { mode: 0o600 })
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', key)
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://deck.invalid')
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '[email protected]')
vi.stubEnv(
'TWITTER_LITE_MASTODON_ORIGINS',
`${origin},https://second.invalid`,
)
accountId = '1'
send.mockClear()
})
afterEach(() => {
database.$client.close()
rmSync(directory, { force: true, recursive: true })
vi.unstubAllEnvs()
})
it('registers once and uses force-login, state and PKCE while storing only encrypted secrets', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const url = new URL(attempt.authorizationUrl)
expect(url.origin).toBe(origin)
expect(url.searchParams.get('redirect_uri')).toBe(
'https://deck.invalid/oauth/mastodon/callback',
)
expect(url.searchParams.get('force_login')).toBe('true')
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
expect(url.searchParams.get('state')).toBe(attempt.state)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'authorization-code' },
database,
send,
)
const credentials = database.select().from(connectionCredentials).get()
expect(credentials?.connectionId).toBe(id)
expect(
decryptCredential(
credentials?.encryptedToken ?? '',
`connection:${id}:access-token`,
),
).toBe(token)
const saved = JSON.stringify({
apps: database.select().from(oauthApps).all(),
attempts: database.select().from(oauthAttempts).all(),
credentials,
})
expect(saved).not.toContain(token)
expect(saved).not.toContain(clientSecret)
expect(saved).not.toContain(attempt.state)
expect(saved).not.toContain(attempt.browserCookie)
const exchange = send.mock.calls.find(
([request]) => request.pathname === '/oauth/token',
)
const fields = exchange?.[1]?.body as URLSearchParams
expect(
createHash('sha256')
.update(fields.get('code_verifier') ?? '')
.digest('base64url'),
).toBe(url.searchParams.get('code_challenge'))
await beginMastodonOAuth({ origin }, database, send)
expect(
send.mock.calls.filter(([request]) => request.pathname === '/api/v1/apps'),
).toHaveLength(1)
})
it('supports two accounts per instance and accounts on another instance without changing reconnect IDs', async () => {
const first = await beginMastodonOAuth({ origin }, database, send)
const firstId = await completeMastodonOAuth(
{ ...first, code: 'first' },
database,
send,
)
accountId = '2'
const second = await beginMastodonOAuth({ origin }, database, send)
const secondId = await completeMastodonOAuth(
{ ...second, code: 'second' },
database,
send,
)
expect(secondId).not.toBe(firstId)
const another = await beginMastodonOAuth(
{ origin: 'https://second.invalid' },
database,
send,
)
const anotherId = await completeMastodonOAuth(
{ ...another, code: 'another' },
database,
send,
)
expect(anotherId).not.toBe(secondId)
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: secondId },
database,
send,
)
expect(
await completeMastodonOAuth(
{ ...reconnect, code: 'reconnect' },
database,
send,
),
).toBe(secondId)
expect(database.select().from(connections).all()).toHaveLength(3)
})
it('rejects mismatched reconnect accounts without replacing the existing credential', async () => {
const first = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...first, code: 'first' },
database,
send,
)
const before = database.select().from(connectionCredentials).get()
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: id },
database,
send,
)
accountId = 'different'
await expect(
completeMastodonOAuth({ ...reconnect, code: 'other' }, database, send),
).rejects.toThrow('does not match')
expect(database.select().from(connectionCredentials).get()).toEqual(before)
})
it('rejects browser mismatch and unknown state before exchanging a code', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const calls = send.mock.calls.length
await expect(
completeMastodonOAuth(
{ ...attempt, browserCookie: 'another-browser', code: 'code' },
database,
send,
),
).rejects.toThrow('invalid, expired, or already used')
await expect(
completeMastodonOAuth(
{ ...attempt, state: 'unknown-state', code: 'code' },
database,
send,
),
).rejects.toThrow('invalid, expired, or already used')
expect(send).toHaveBeenCalledTimes(calls)
})
it('consumes rejected authorization attempts so they cannot be retried', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
await expect(
completeMastodonOAuth(
{ ...attempt, error: 'access_denied' },
database,
send,
),
).rejects.toThrow('not completed')
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('already used')
expect(database.select().from(connections).all()).toHaveLength(0)
})
it('rejects expired attempts without exchanging the code', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
database
.update(oauthAttempts)
.set({ expiresAt: Date.now() - 1 })
.run()
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('expired')
expect(
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
).toHaveLength(0)
})
it('allows exactly one exchange when callbacks race', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const results = await Promise.allSettled([
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
])
expect(results.map((result) => result.status).sort()).toEqual([
'fulfilled',
'rejected',
])
expect(
send.mock.calls.filter(([request]) => request.pathname === '/oauth/token'),
).toHaveLength(1)
})
it('rejects instances outside the configured allowlist without network requests', async () => {
await expect(
beginMastodonOAuth(
{ origin: 'https://unconfigured.invalid' },
database,
send,
),
).rejects.toThrow('not configured')
expect(send).not.toHaveBeenCalled()
})
it('revokes with the original app, erases credentials, and retains the column binding ID', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
vi.stubEnv('TWITTER_LITE_ORIGIN', 'https://new-deck.invalid')
await disconnectMastodon(id, database, send)
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
expect(
database.select().from(connections).where(eq(connections.id, id)).get()
?.status,
).toBe('disconnected')
const revoke = send.mock.calls.find(
([request]) => request.pathname === '/oauth/revoke',
)
const fields = revoke?.[1]?.body as URLSearchParams
expect(fields.get('client_secret')).toBe(clientSecret)
expect(fields.get('token')).toBe(token)
})
it('keeps credentials available for another revoke attempt when revocation fails', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
const failedRevoke = vi.fn(async () => new Response(null, { status: 503 }))
await expect(disconnectMastodon(id, database, failedRevoke)).rejects.toThrow(
'HTTP 503',
)
expect(database.select().from(connectionCredentials).all()).toHaveLength(1)
expect(database.select().from(connections).get()?.status).toBe('connected')
})
it('keeps a new credential when an older disconnect completes after reconnection', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const id = await completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
send,
)
let resolveResponse!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve
})
const pendingRevoke = vi.fn(() => response)
const disconnect = disconnectMastodon(id, database, pendingRevoke)
expect(pendingRevoke).toHaveBeenCalledTimes(1)
const reconnect = await beginMastodonOAuth(
{ origin, connectionId: id },
database,
send,
)
await completeMastodonOAuth(
{ ...reconnect, code: 'reconnect' },
database,
async (url, init) =>
url.pathname === '/oauth/token'
? Response.json({
access_token: 'new-access-token',
token_type: 'Bearer',
scope: 'read:accounts read:statuses read:lists read:search',
})
: send(url, init),
)
resolveResponse(Response.json({}))
await expect(disconnect).rejects.toThrow('reconnected during revocation')
const credential = database.select().from(connectionCredentials).get()
expect(
decryptCredential(
credential?.encryptedToken ?? '',
`connection:${id}:access-token`,
),
).toBe('new-access-token')
expect(database.select().from(connections).get()?.status).toBe('connected')
})
it('rejects incomplete scopes without saving the token', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const limitedGrant = vi.fn(async () =>
Response.json({
access_token: token,
token_type: 'Bearer',
scope: 'read:accounts',
}),
)
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, limitedGrant),
).rejects.toThrow('permissions were not granted')
expect(database.select().from(connectionCredentials).all()).toHaveLength(0)
})
it('consumes an attempt before a failing token exchange', async () => {
const attempt = await beginMastodonOAuth({ origin }, database, send)
const failedExchange = vi.fn(async () => new Response(null, { status: 400 }))
await expect(
completeMastodonOAuth(
{ ...attempt, code: 'code' },
database,
failedExchange,
),
).rejects.toThrow('HTTP 400')
await expect(
completeMastodonOAuth({ ...attempt, code: 'code' }, database, send),
).rejects.toThrow('already used')
})
+42
View File
@@ -0,0 +1,42 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const loadMastodonInstances = createServerFn({ method: 'GET' }).handler(
async () => {
const { mastodonOrigins } = await import('./transport.server')
return { origins: mastodonOrigins() }
},
)
export const startMastodonOAuth = createServerFn({ method: 'POST' })
.validator(
z
.object({
origin: z.string(),
connectionId: z.string().min(1).optional(),
})
.strict(),
)
.handler(async ({ data }) => {
const { setCookie } = await import('@tanstack/react-start/server')
const { beginMastodonOAuth, oauthCookieName, oauthLifetimeSeconds } =
await import('./oauth.server')
const { readAccessConfig } = await import('../access/policy.server')
const attempt = await beginMastodonOAuth(data)
setCookie(oauthCookieName(attempt.state), attempt.browserCookie, {
httpOnly: true,
secure: readAccessConfig()?.origin.startsWith('https:') === true,
sameSite: 'lax',
path: '/oauth/mastodon/callback',
maxAge: oauthLifetimeSeconds,
})
return { authorizationUrl: attempt.authorizationUrl }
})
export const disconnectMastodonAccount = createServerFn({ method: 'POST' })
.validator(z.object({ connectionId: z.string().min(1) }).strict())
.handler(async ({ data }) => {
const { disconnectMastodon } = await import('./oauth.server')
await disconnectMastodon(data.connectionId)
return { disconnected: true }
})
+128
View File
@@ -0,0 +1,128 @@
import { lookup } from 'node:dns/promises'
import { request } from 'node:https'
import { isIP } from 'node:net'
export function mastodonOrigins(): string[] {
return (process.env.TWITTER_LITE_MASTODON_ORIGINS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.map((value) => {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
url.origin !== value ||
url.port ||
isIP(url.hostname)
) {
throw new Error(
'Mastodon origins must be HTTPS host origins without paths or custom ports.',
)
}
return url.origin
})
}
export function requireMastodonOrigin(origin: string): string {
if (!mastodonOrigins().includes(origin))
throw new Error('This Mastodon instance is not configured.')
return origin
}
export function isPublicAddress(address: string): boolean {
if (isIP(address) === 4) {
const [a = 0, b = 0, c = 0] = address.split('.').map(Number)
return !(
a === 0 ||
a === 10 ||
a === 127 ||
a >= 224 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 192 && b === 0) ||
(a === 192 && b === 88 && c === 99) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113)
)
}
if (isIP(address) === 6) {
const normalized = address.toLowerCase()
return (
/^[23]/.test(normalized) &&
!/^2001:(0*:|db8:)/.test(normalized) &&
!normalized.startsWith('2002:')
)
}
return false
}
/** Fixed approved HTTPS origins, pinned public DNS results, no redirects. */
export async function safeMastodonRequest(
url: URL,
init: RequestInit = {},
): Promise<Response> {
requireMastodonOrigin(url.origin)
if (url.username || url.password) throw new Error('Invalid Mastodon URL.')
const addresses = await lookup(url.hostname, { all: true, verbatim: true })
const address = addresses[0]
if (!address || addresses.some((value) => !isPublicAddress(value.address))) {
throw new Error('Mastodon must resolve to public network addresses.')
}
const headers = Object.fromEntries(new Headers(init.headers))
const body =
init.body instanceof URLSearchParams ? init.body.toString() : init.body
if (body !== undefined && body !== null && typeof body !== 'string')
throw new Error('Unsupported Mastodon request body.')
return new Promise((resolve, reject) => {
const outgoing = request(
url,
{
method: init.method ?? 'GET',
headers,
family: address.family,
lookup: (_hostname, _options, callback) =>
callback(null, address.address, address.family),
},
(incoming) => {
const chunks: Buffer[] = []
let bytes = 0
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.length
if (bytes > 5 * 1024 * 1024)
incoming.destroy(new Error('Mastodon response is too large.'))
else chunks.push(chunk)
})
incoming.on('error', () =>
reject(new Error('Mastodon response failed.')),
)
incoming.on('end', () => {
const status = incoming.statusCode ?? 502
if (status >= 300 && status < 400)
return reject(new Error('Mastodon redirects are not allowed.'))
const responseHeaders = new Headers()
for (const [name, value] of Object.entries(incoming.headers)) {
if (value !== undefined)
responseHeaders.set(
name,
Array.isArray(value) ? value.join(', ') : value,
)
}
resolve(
new Response(status === 204 ? null : Buffer.concat(chunks), {
status,
headers: responseHeaders,
}),
)
})
},
)
const timeout = setTimeout(() => {
outgoing.destroy(new Error('Mastodon request timed out.'))
}, 20_000)
outgoing.on('close', () => clearTimeout(timeout))
outgoing.on('error', () => reject(new Error('Mastodon request failed.')))
outgoing.end(body)
})
}
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment node
import { afterEach, expect, it, vi } from 'vitest'
import {
isPublicAddress,
mastodonOrigins,
requireMastodonOrigin,
} from './transport.server'
afterEach(() => vi.unstubAllEnvs())
it.each([
'127.0.0.1',
'10.0.0.1',
'172.16.0.1',
'192.168.1.1',
'100.91.91.87',
'169.254.169.254',
'0.0.0.0',
'224.0.0.1',
'198.18.0.1',
'192.0.2.1',
'::1',
'::ffff:127.0.0.1',
'fe80::1',
'fd00::1',
'2001:db8::1',
'2002:7f00:1::1',
])('rejects non-public destination %s', (address) =>
expect(isPublicAddress(address)).toBe(false))
it.each([
'1.1.1.1',
'8.8.8.8',
'2606:4700:4700::1111',
])('accepts public destination %s', (address) =>
expect(isPublicAddress(address)).toBe(true))
it.each([
'http://mastodon.invalid',
'https://mastodon.invalid/path',
'https://mastodon.invalid:8443',
'https://127.0.0.1',
'https://user:[email protected]',
])('rejects unsafe configured origin %s', (origin) => {
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', origin)
expect(() => mastodonOrigins()).toThrow()
})
it('requires exact configured origin equality', () => {
vi.stubEnv('TWITTER_LITE_MASTODON_ORIGINS', 'https://mastodon.invalid')
expect(requireMastodonOrigin('https://mastodon.invalid')).toBe(
'https://mastodon.invalid',
)
expect(() =>
requireMastodonOrigin('https://mastodon.invalid.attacker.invalid'),
).toThrow('not configured')
})