Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ SKETCHFAB_API_KEY=

# OAuth credentials (register your own apps at github.com/settings/developers
# and console.cloud.google.com to enable /oauth connect)
AXION_GITHUB_CLIENT_ID=
AXION_GITHUB_CLIENT_SECRET=
AXION_GOOGLE_CLIENT_ID=
AXION_GOOGLE_CLIENT_SECRET=
SENNORIC_GITHUB_CLIENT_ID=
SENNORIC_GITHUB_CLIENT_SECRET=
SENNORIC_GOOGLE_CLIENT_ID=
SENNORIC_GOOGLE_CLIENT_SECRET=
SENNORIC_NOTION_CLIENT_ID=
SENNORIC_NOTION_CLIENT_SECRET=

# Overrides (optional)
AXION_MODEL=claude # default model alias on startup
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,10 +371,12 @@ Connect services to give the agent access to them:
To enable OAuth, register your own apps and add credentials to `~/.axion/.env`:

```
AXION_GITHUB_CLIENT_ID=...
AXION_GITHUB_CLIENT_SECRET=...
AXION_GOOGLE_CLIENT_ID=...
AXION_GOOGLE_CLIENT_SECRET=...
SENNORIC_GITHUB_CLIENT_ID=...
SENNORIC_GITHUB_CLIENT_SECRET=...
SENNORIC_GOOGLE_CLIENT_ID=...
SENNORIC_GOOGLE_CLIENT_SECRET=...
SENNORIC_NOTION_CLIENT_ID=...
SENNORIC_NOTION_CLIENT_SECRET=...
```

---
Expand Down
2 changes: 1 addition & 1 deletion api-proxy-cf/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"private": true,
"engines": {
"node": ">=22.0.0"
"node": ">=22.13.0"
},
"scripts": {
"dev": "wrangler dev",
Expand Down
85 changes: 75 additions & 10 deletions api-proxy-cf/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ async function purgeExpiredDesktopAuthCodes(db) {

// ── Desktop integration OAuth broker ─────────────────────────────────────
// Native apps cannot keep an OAuth client secret. The Worker already owns
// the registered Google/GitHub credentials, so it performs the provider code
// the registered Google/GitHub/Notion credentials, so it performs the provider code
// exchange and hands Desktop a short-lived, PKCE-bound one-time code. Provider
// tokens are encrypted even during their brief stay in D1 and never travel in
// a URL, renderer process, or log.
Expand All @@ -604,6 +604,11 @@ const DESKTOP_INTEGRATION_PROVIDERS = {
redirectUri: 'https://api.amplifiedsmp.org/auth/google/callback',
scopes: 'https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events openid email profile',
},
notion: {
authURL: 'https://api.notion.com/v1/oauth/authorize',
redirectUri: 'https://api.amplifiedsmp.org/auth/notion/callback',
scopes: '',
},
}

function bytesToBase64(bytes) {
Expand Down Expand Up @@ -636,19 +641,22 @@ async function decryptDesktopIntegrationToken(value, secret) {

function desktopIntegrationAuthUrl(provider, env, state) {
const cfg = DESKTOP_INTEGRATION_PROVIDERS[provider]
const clientId = provider === 'github' ? env.GITHUB_CLIENT_ID : env.GOOGLE_CLIENT_ID
const clientId = provider === 'github' ? env.GITHUB_CLIENT_ID
: provider === 'notion' ? env.NOTION_CLIENT_ID
: env.GOOGLE_CLIENT_ID
if (!cfg || !clientId) return null
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: cfg.redirectUri,
response_type: 'code',
scope: cfg.scopes,
state,
})
if (cfg.scopes) params.set('scope', cfg.scopes)
if (provider === 'google') {
params.set('access_type', 'offline')
params.set('prompt', 'consent select_account')
}
if (provider === 'notion') params.set('owner', 'user')
return `${cfg.authURL}?${params}`
}

Expand All @@ -669,7 +677,10 @@ app.post('/auth/desktop/integrations/:provider/start', async (c) => {
exp: Date.now() + DESKTOP_INTEGRATION_CODE_TTL,
}, c.env.TOKEN_SECRET)
const authorizationUrl = desktopIntegrationAuthUrl(provider, c.env, state)
if (!authorizationUrl) return json({ error: `${provider === 'github' ? 'GitHub' : 'Google'} connections are temporarily unavailable.` }, 503)
if (!authorizationUrl) {
const label = provider === 'github' ? 'GitHub' : provider === 'notion' ? 'Notion' : 'Google'
return json({ error: `${label} connections are temporarily unavailable.` }, 503)
}
return json({ authorization_url: authorizationUrl })
})

Expand Down Expand Up @@ -1004,17 +1015,25 @@ app.get('/auth/github/callback', async (c) => {
return desktopFailure || new Response('Missing code', { status: 400 })
}
const return_to = decodeState(c.req.query('state'))
const signedState = await parseToken(c.req.query('state'), c.env.TOKEN_SECRET)

const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ client_id: c.env.GITHUB_CLIENT_ID, client_secret: c.env.GITHUB_CLIENT_SECRET, code }),
body: JSON.stringify({
client_id: c.env.GITHUB_CLIENT_ID,
client_secret: c.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: 'https://api.amplifiedsmp.org/auth/github/callback',
}),
})
const githubTokens = await tokenRes.json()
const { access_token } = githubTokens
if (!access_token) return new Response('GitHub OAuth failed', { status: 400 })
if (!access_token) {
const desktopFailure = failDesktopIntegration(signedState, 'github', 'provider_error')
return desktopFailure || new Response('GitHub could not authorize this connection. Try again.', { status: 400 })
}

const signedState = await parseToken(c.req.query('state'), c.env.TOKEN_SECRET)
const desktopIntegration = await finishDesktopIntegration(c, signedState, 'github', githubTokens)
if (desktopIntegration) return desktopIntegration

Expand All @@ -1034,6 +1053,37 @@ app.get('/auth/github/callback', async (c) => {
return oauthFinish(c, { id_field: 'github_id', email, provider_id: String(profile.id), return_to })
})

// ── Notion integration OAuth ─────────────────────────────────────────────

app.get('/auth/notion/callback', async (c) => {
const signedState = await parseToken(c.req.query('state'), c.env.TOKEN_SECRET)
const code = c.req.query('code')
if (!code) {
const desktopFailure = failDesktopIntegration(
signedState, 'notion', c.req.query('error') || 'access_denied'
)
return desktopFailure || new Response('Missing code', { status: 400 })
}

const basic = btoa(`${c.env.NOTION_CLIENT_ID}:${c.env.NOTION_CLIENT_SECRET}`)
const tokenRes = await fetch('https://api.notion.com/v1/oauth/token', {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://api.amplifiedsmp.org/auth/notion/callback',
}),
})
const tokens = await tokenRes.json()
if (!tokens.access_token) {
const desktopFailure = failDesktopIntegration(signedState, 'notion', 'provider_error')
return desktopFailure || new Response('Notion could not authorize this connection. Try again.', { status: 400 })
}
return await finishDesktopIntegration(c, signedState, 'notion', tokens)
|| new Response('This Notion connection request expired. Return to Sennoric and try again.', { status: 400 })
})

// ── Discord OAuth ──────────────────────────────────────────────────────────

app.get('/auth/discord', (c) => {
Expand Down Expand Up @@ -3945,10 +3995,25 @@ app.put('/dashboard/prefs', async (c) => {
const user = await requireAuth(c)
if (!user) return json({ error: 'Not authenticated' }, 401)
const { notify_limit, notify_announcements, notify_scheduled, sandbox_mode } = await c.req.json().catch(() => ({}))
const preference = (value) => (
typeof value === 'boolean' || value === 0 || value === 1
? (value ? 1 : 0)
: null
)
const limit = preference(notify_limit)
const announcements = preference(notify_announcements)
const scheduled = preference(notify_scheduled)
await c.env.DB.prepare(
`INSERT INTO email_prefs (user_id, notify_limit, notify_announcements, notify_scheduled) VALUES (?,?,?,?)
ON CONFLICT (user_id) DO UPDATE SET notify_limit=excluded.notify_limit, notify_announcements=excluded.notify_announcements, notify_scheduled=excluded.notify_scheduled`
).bind(user.id, notify_limit ? 1 : 0, notify_announcements ? 1 : 0, notify_scheduled ? 1 : 0).run()
`INSERT INTO email_prefs (user_id, notify_limit, notify_announcements, notify_scheduled)
VALUES (?, COALESCE(?, 1), COALESCE(?, 1), COALESCE(?, 1))
ON CONFLICT (user_id) DO UPDATE SET
notify_limit=COALESCE(?, email_prefs.notify_limit),
notify_announcements=COALESCE(?, email_prefs.notify_announcements),
notify_scheduled=COALESCE(?, email_prefs.notify_scheduled)`
).bind(
user.id, limit, announcements, scheduled,
limit, announcements, scheduled,
).run()
if (sandbox_mode === 'ask' || sandbox_mode === 'auto') {
await c.env.DB.prepare('UPDATE users SET sandbox_mode=? WHERE id=?').bind(sandbox_mode, user.id).run()
}
Expand Down
56 changes: 55 additions & 1 deletion api-proxy-cf/test/desktop-integrations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function makeEnv() {
DB: db, TOKEN_SECRET: SECRET,
GITHUB_CLIENT_ID: 'github-client', GITHUB_CLIENT_SECRET: 'github-secret',
GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret',
NOTION_CLIENT_ID: 'notion-client', NOTION_CLIENT_SECRET: 'notion-secret',
}
}

Expand Down Expand Up @@ -80,8 +81,9 @@ test('GitHub integration OAuth is brokered with PKCE and the provider token is s
assert.equal(authorizationUrl.searchParams.get('client_id'), 'github-client')
assert.match(authorizationUrl.searchParams.get('scope'), /\brepo\b/)

mock.method(globalThis, 'fetch', async (input) => {
mock.method(globalThis, 'fetch', async (input, init) => {
assert.equal(String(input), 'https://github.com/login/oauth/access_token')
assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.amplifiedsmp.org/auth/github/callback')
return Response.json({ access_token: 'gho_secret', scope: 'repo read:user', token_type: 'bearer' })
})
const callback = await app.request(`/auth/github/callback?code=provider-code&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env)
Expand Down Expand Up @@ -128,3 +130,55 @@ test('a cancelled provider flow returns cleanly to Desktop instead of a raw erro
assert.equal(callback.searchParams.get('error'), 'access_denied')
assert.equal(callback.searchParams.get('state'), clientState)
})

test('Notion integration OAuth is brokered without exposing its client secret', async () => {
const env = makeEnv()
const bearer = await sessionToken()
const { challenge } = await pkcePair()
const clientState = base64Url(crypto.getRandomValues(new Uint8Array(24)))
const started = await app.request('/auth/desktop/integrations/notion/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
body: JSON.stringify({ code_challenge: challenge, state: clientState }),
}, env)
assert.equal(started.status, 200)
const authorizationUrl = new URL((await started.json()).authorization_url)
assert.equal(authorizationUrl.hostname, 'api.notion.com')
assert.equal(authorizationUrl.searchParams.get('client_id'), 'notion-client')
assert.equal(authorizationUrl.searchParams.get('owner'), 'user')
assert.equal(authorizationUrl.searchParams.has('client_secret'), false)

mock.method(globalThis, 'fetch', async (input, init) => {
assert.equal(String(input), 'https://api.notion.com/v1/oauth/token')
assert.equal(init.headers.Authorization, `Basic ${btoa('notion-client:notion-secret')}`)
assert.equal(JSON.parse(init.body).redirect_uri, 'https://api.amplifiedsmp.org/auth/notion/callback')
return Response.json({ access_token: 'secret_notion', workspace_name: 'Sennoric' })
})
const callback = await app.request(`/auth/notion/callback?code=provider-code&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env)
assert.equal(callback.status, 302)
const desktopUrl = new URL(callback.headers.get('Location'))
assert.equal(desktopUrl.protocol, 'sennoric:')
assert.equal(desktopUrl.searchParams.get('provider'), 'notion')
assert.equal(desktopUrl.searchParams.get('state'), clientState)
})

test('a failed GitHub token exchange returns to Desktop with a polished failure', async () => {
const env = makeEnv()
const bearer = await sessionToken()
const { challenge } = await pkcePair()
const clientState = base64Url(crypto.getRandomValues(new Uint8Array(24)))
const started = await app.request('/auth/desktop/integrations/github/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
body: JSON.stringify({ code_challenge: challenge, state: clientState }),
}, env)
const authorizationUrl = new URL((await started.json()).authorization_url)
mock.method(globalThis, 'fetch', async () => Response.json({ error: 'bad_verification_code' }, { status: 200 }))

const response = await app.request(`/auth/github/callback?code=expired&state=${encodeURIComponent(authorizationUrl.searchParams.get('state'))}`, {}, env)
assert.equal(response.status, 302)
const callback = new URL(response.headers.get('Location'))
assert.equal(callback.protocol, 'sennoric:')
assert.equal(callback.searchParams.get('error'), 'provider_error')
assert.equal(callback.searchParams.get('state'), clientState)
})
67 changes: 67 additions & 0 deletions api-proxy-cf/test/preferences.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { DatabaseSync } from 'node:sqlite'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import app from '../src/index.js'

class Statement {
constructor(database, sql, values = []) { this.database = database; this.sql = sql; this.values = values }
bind(...values) { return new Statement(this.database, this.sql, values) }
first() { return this.database.prepare(this.sql).get(...this.values) || null }
all() { return { results: this.database.prepare(this.sql).all(...this.values) } }
run() {
const result = this.database.prepare(this.sql).run(...this.values)
return { meta: { changes: Number(result.changes) } }
}
}

const SECRET = 'preferences-test-secret'

function makeEnv() {
const database = new DatabaseSync(':memory:')
database.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, banned INTEGER NOT NULL DEFAULT 0,
token_version INTEGER NOT NULL DEFAULT 0, sandbox_mode TEXT NOT NULL DEFAULT 'ask'
);
CREATE TABLE email_prefs (
user_id TEXT PRIMARY KEY, notify_limit INTEGER DEFAULT 1,
notify_announcements INTEGER DEFAULT 1, notify_scheduled INTEGER DEFAULT 1
);
INSERT INTO users (id, email) VALUES ('u1', 'prefs@example.com');
`)
return { DB: { prepare: (sql) => new Statement(database, sql) }, TOKEN_SECRET: SECRET }
}

async function bearer() {
const payload = btoa(JSON.stringify({ uid: 'u1', v: 0, exp: Date.now() + 60_000 }))
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
return `${payload}.${btoa(String.fromCharCode(...new Uint8Array(signature)))}`
}

test('partial preference updates leave every unrelated switch unchanged', async () => {
const env = makeEnv()
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${await bearer()}` }
const update = (body) => app.request('/dashboard/prefs', { method: 'PUT', headers, body: JSON.stringify(body) }, env)
const read = async () => (await app.request('/dashboard/prefs', { headers }, env)).json()
const values = async () => {
const { notify_limit, notify_announcements, notify_scheduled } = await read()
return { notify_limit, notify_announcements, notify_scheduled }
}

for (const field of ['notify_limit', 'notify_announcements', 'notify_scheduled']) {
await update({ notify_limit: true, notify_announcements: true, notify_scheduled: true })
assert.equal((await update({ [field]: 'invalid' })).status, 200)
assert.deepEqual(await values(), { notify_limit: 1, notify_announcements: 1, notify_scheduled: 1 })
assert.equal((await update({ [field]: false })).status, 200)
assert.deepEqual(await values(), {
notify_limit: field === 'notify_limit' ? 0 : 1,
notify_announcements: field === 'notify_announcements' ? 0 : 1,
notify_scheduled: field === 'notify_scheduled' ? 0 : 1,
})
}

await update({ notify_limit: true, notify_announcements: true, notify_scheduled: true })
await Promise.all([update({ notify_limit: false }), update({ notify_announcements: false })])
assert.deepEqual(await values(), { notify_limit: 0, notify_announcements: 0, notify_scheduled: 1 })
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading