diff --git a/.dev.vars.example b/.dev.vars.example index 3883c5e..271d251 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -18,6 +18,7 @@ CONTEXT7_API_ORIGIN=https://context7.com/api CONTEXT7_OAUTH_ISSUER=https://clerk.context7.com CONTEXT7_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key TODOIST_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +TODOIST_LOGIN_ENDPOINT=https://app.todoist.com/users/showlogin LINEAR_API_ORIGIN=https://api.linear.app LINEAR_AUTHORIZATION_ORIGIN=https://linear.app LINEAR_CLIENT_ID=replace-with-linear-oauth-client-id diff --git a/providers/todoist/README.md b/providers/todoist/README.md index 2ecf710..6a1442b 100644 --- a/providers/todoist/README.md +++ b/providers/todoist/README.md @@ -20,6 +20,10 @@ The provider URLs have production defaults. Set the adapter. Optional URL overrides are declared in `wrangler.jsonc` for local or test environments. +The authorization URL is wrapped in Todoist's login page with the complete +OAuth request as `success_page`. Todoist otherwise drops PKCE parameters when +an unauthenticated browser is redirected through login. + The adapter persists one dynamically registered public client and stores each provider credential encrypted in D1. Todoist's current revocation endpoints require confidential-client authentication, so disconnecting revokes the diff --git a/src/core/dynamic-oauth-client.ts b/src/core/dynamic-oauth-client.ts index 59e64fa..01dd782 100644 --- a/src/core/dynamic-oauth-client.ts +++ b/src/core/dynamic-oauth-client.ts @@ -30,6 +30,11 @@ export type DynamicOAuthEndpoints = Readonly<{ revocation?: string }> +export type DynamicOAuthAuthorizationWrapper = Readonly<{ + endpoint: string + returnUrlParameter: string +}> + export class D1DynamicOAuthRegistrationStore implements DynamicOAuthRegistrationStore { constructor(private readonly db: D1Database) {} @@ -59,6 +64,7 @@ export function createDynamicOAuthClient(input: { redirectUri: string scopes: readonly string[] authorizationScopeSeparator?: ' ' | ',' + authorizationWrapper?: DynamicOAuthAuthorizationWrapper registrationStore: DynamicOAuthRegistrationStore fetcher?: typeof fetch now?: () => number @@ -79,7 +85,11 @@ export function createDynamicOAuthClient(input: { url.searchParams.set('state', state) url.searchParams.set('code_challenge', await sha256Base64Url(verifier)) url.searchParams.set('code_challenge_method', 'S256') - return { url: url.toString(), verifier } + const wrapper = input.authorizationWrapper + if (!wrapper) return { url: url.toString(), verifier } + const wrapped = new URL(wrapper.endpoint) + wrapped.searchParams.set(wrapper.returnUrlParameter, url.toString()) + return { url: wrapped.toString(), verifier } }, exchangeCode(code: string, verifier: string) { return tokenRequest({ diff --git a/src/providers/todoist/config.ts b/src/providers/todoist/config.ts index f41190e..4613639 100644 --- a/src/providers/todoist/config.ts +++ b/src/providers/todoist/config.ts @@ -4,6 +4,7 @@ import type { AppConfig } from '../../config.js' const environmentSchema = z.object({ TODOIST_API_ORIGIN: z.url().default('https://api.todoist.com/api/v1'), TODOIST_AUTHORIZATION_ENDPOINT: z.url().default('https://app.todoist.com/oauth/authorize'), + TODOIST_LOGIN_ENDPOINT: z.url().default('https://app.todoist.com/users/showlogin'), TODOIST_TOKEN_ENDPOINT: z.url().default('https://api.todoist.com/oauth/access_token'), TODOIST_REGISTRATION_ENDPOINT: z.url().default('https://api.todoist.com/oauth/register'), TODOIST_USERINFO_ENDPOINT: z.url().default('https://api.todoist.com/api/v1/user'), @@ -13,6 +14,7 @@ const environmentSchema = z.object({ export type TodoistAdapterConfig = AppConfig & { todoistApiOrigin: string todoistAuthorizationEndpoint: string + todoistLoginEndpoint: string todoistTokenEndpoint: string todoistRegistrationEndpoint: string todoistUserInfoEndpoint: string @@ -25,6 +27,7 @@ export function loadTodoistConfig(environment: unknown, config: AppConfig): Todo ...config, todoistApiOrigin: parsed.TODOIST_API_ORIGIN.replace(/\/+$/, ''), todoistAuthorizationEndpoint: parsed.TODOIST_AUTHORIZATION_ENDPOINT, + todoistLoginEndpoint: parsed.TODOIST_LOGIN_ENDPOINT, todoistTokenEndpoint: parsed.TODOIST_TOKEN_ENDPOINT, todoistRegistrationEndpoint: parsed.TODOIST_REGISTRATION_ENDPOINT, todoistUserInfoEndpoint: parsed.TODOIST_USERINFO_ENDPOINT, diff --git a/src/worker.ts b/src/worker.ts index ae1a6a4..55685c5 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -243,6 +243,10 @@ export default { redirectUri: `${config.origin}/oauth/todoist/provider/callback`, scopes: todoistProviderScopes, authorizationScopeSeparator: ',', + authorizationWrapper: { + endpoint: todoistConfig.todoistLoginEndpoint, + returnUrlParameter: 'success_page', + }, registrationStore: new D1DynamicOAuthRegistrationStore(env.DB), fetcher: fetch, }) diff --git a/test/core/dynamic-oauth-client.test.ts b/test/core/dynamic-oauth-client.test.ts index 0c28657..0ecde2d 100644 --- a/test/core/dynamic-oauth-client.test.ts +++ b/test/core/dynamic-oauth-client.test.ts @@ -100,6 +100,10 @@ describe('Dynamic OAuth client', () => { redirectUri: 'https://adapter.example/oauth/todoist/provider/callback', scopes: ['data:read', 'user:read'], authorizationScopeSeparator: ',', + authorizationWrapper: { + endpoint: 'https://app.todoist.com/users/showlogin', + returnUrlParameter: 'success_page', + }, registrationStore: store, fetcher: fetcher as typeof fetch, now: () => 1_000, @@ -107,8 +111,12 @@ describe('Dynamic OAuth client', () => { const started = await client.authorizationUrl('todoist-state') const authorizationUrl = new URL(started.url) - expect(authorizationUrl.origin).toBe('https://app.todoist.com') - expect(authorizationUrl.searchParams.get('scope')).toBe('data:read,user:read') + expect(authorizationUrl.pathname).toBe('/users/showlogin') + const providerUrl = new URL(authorizationUrl.searchParams.get('success_page') ?? '') + expect(providerUrl.pathname).toBe('/oauth/authorize') + expect(providerUrl.searchParams.get('scope')).toBe('data:read,user:read') + expect(providerUrl.searchParams.get('code_challenge')).toHaveLength(43) + expect(providerUrl.searchParams.get('code_challenge_method')).toBe('S256') expect(client.revoke).toBeUndefined() await expect(client.exchangeCode('todoist-code', started.verifier)).resolves.toMatchObject({ accessToken: 'todoist-access', diff --git a/vitest.config.ts b/vitest.config.ts index dbb6852..f7185a4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ CONTEXT7_OAUTH_ISSUER: 'https://clerk.context7.com', TODOIST_API_ORIGIN: 'https://api.todoist.com/api/v1', TODOIST_AUTHORIZATION_ENDPOINT: 'https://app.todoist.com/oauth/authorize', + TODOIST_LOGIN_ENDPOINT: 'https://app.todoist.com/users/showlogin', TODOIST_TOKEN_ENDPOINT: 'https://api.todoist.com/oauth/access_token', TODOIST_REGISTRATION_ENDPOINT: 'https://api.todoist.com/oauth/register', TODOIST_USERINFO_ENDPOINT: 'https://api.todoist.com/api/v1/user', diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 2635ced..66b53b1 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: 8a6e409f7418611a7b81357c8708bc8b) +// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: 3395f9fa4ba66e7161740a66c8c7a39e) // Runtime types generated with workerd@1.20260801.1 2026-08-08 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -16,6 +16,7 @@ interface __BaseEnv_Env { CONTEXT7_OAUTH_ISSUER: string; TODOIST_API_ORIGIN: string; TODOIST_AUTHORIZATION_ENDPOINT: string; + TODOIST_LOGIN_ENDPOINT: string; TODOIST_TOKEN_ENDPOINT: string; TODOIST_REGISTRATION_ENDPOINT: string; TODOIST_USERINFO_ENDPOINT: string; @@ -46,7 +47,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index f0ca823..58b15c7 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -21,6 +21,7 @@ "CONTEXT7_OAUTH_ISSUER": "https://clerk.context7.com", "TODOIST_API_ORIGIN": "https://api.todoist.com/api/v1", "TODOIST_AUTHORIZATION_ENDPOINT": "https://app.todoist.com/oauth/authorize", + "TODOIST_LOGIN_ENDPOINT": "https://app.todoist.com/users/showlogin", "TODOIST_TOKEN_ENDPOINT": "https://api.todoist.com/oauth/access_token", "TODOIST_REGISTRATION_ENDPOINT": "https://api.todoist.com/oauth/register", "TODOIST_USERINFO_ENDPOINT": "https://api.todoist.com/api/v1/user"