feat: add shared decks and multi-account Mastodon OAuth
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
type AccessConfig = { origin: string; allowedLogin: string }
|
||||
|
||||
export function readAccessConfig(): AccessConfig | null {
|
||||
const origin = process.env.TWITTER_LITE_ORIGIN
|
||||
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN
|
||||
if (!origin || !allowedLogin?.trim()) return null
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
const secure = url.protocol === 'https:'
|
||||
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1'
|
||||
if ((!secure && !local) || url.origin !== origin) return null
|
||||
return { origin, allowedLogin }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The backend must bind to loopback; only Serve may supply identity headers. */
|
||||
export function checkAccess(
|
||||
request: Request,
|
||||
config: AccessConfig | null,
|
||||
): Response | null {
|
||||
if (!config) {
|
||||
return new Response('Access configuration is required.', { status: 503 })
|
||||
}
|
||||
if (request.headers.get('Tailscale-User-Login') !== config.allowedLogin) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
if (
|
||||
!['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
|
||||
request.headers.get('Origin') !== config.origin
|
||||
) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { checkAccess, readAccessConfig } from './policy.server'
|
||||
|
||||
const config = {
|
||||
origin: 'https://deck.invalid',
|
||||
allowedLogin: '[email protected]',
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('Serve access boundary', () => {
|
||||
it('fails closed when the deployment is not configured', () => {
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
|
||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
|
||||
expect(readAccessConfig()).toBeNull()
|
||||
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'https://deck.invalid/path',
|
||||
'http://deck.invalid',
|
||||
'not a URL',
|
||||
])('rejects an invalid configured origin: %s', (origin) => {
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
|
||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
|
||||
expect(readAccessConfig()).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://deck.invalid',
|
||||
'http://127.0.0.1:4173',
|
||||
])('accepts an explicit deployment origin: %s', (origin) => {
|
||||
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
|
||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
|
||||
expect(readAccessConfig()).toEqual({ ...config, origin })
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'[email protected]',
|
||||
'[email protected], [email protected]',
|
||||
])('rejects absent, foreign, or ambiguous identities: %s', (login) => {
|
||||
const headers = new Headers()
|
||||
if (login) headers.set('Tailscale-User-Login', login)
|
||||
expect(
|
||||
checkAccess(new Request(config.origin, { headers }), config)?.status,
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
it('permits owner navigation back from an OAuth provider without Origin', () => {
|
||||
const request = new Request(
|
||||
`${config.origin}/oauth/mastodon/callback?code=code`,
|
||||
{
|
||||
headers: {
|
||||
'Tailscale-User-Login': config.allowedLogin,
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(checkAccess(request, config)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
])('requires exact Origin for %s even with same-origin Fetch Metadata', (method) => {
|
||||
const headers = {
|
||||
'Tailscale-User-Login': config.allowedLogin,
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
}
|
||||
expect(
|
||||
checkAccess(new Request(config.origin, { method, headers }), config)
|
||||
?.status,
|
||||
).toBe(403)
|
||||
expect(
|
||||
checkAccess(
|
||||
new Request(config.origin, {
|
||||
method,
|
||||
headers: { ...headers, Origin: 'https://other.invalid' },
|
||||
}),
|
||||
config,
|
||||
)?.status,
|
||||
).toBe(403)
|
||||
expect(
|
||||
checkAccess(
|
||||
new Request(config.origin, {
|
||||
method,
|
||||
headers: { ...headers, Origin: config.origin },
|
||||
}),
|
||||
config,
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user