feat: add shared decks and multi-account Mastodon OAuth
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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' },
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user