diff --git a/projects/api/src/contracts/oauth/index.ts b/projects/api/src/contracts/oauth/index.ts index 0cfd3ff4..1a26eab8 100644 --- a/projects/api/src/contracts/oauth/index.ts +++ b/projects/api/src/contracts/oauth/index.ts @@ -1,5 +1,6 @@ import { authMetadata, builder } from '../_internal/builder.ts'; import { z } from '../_internal/z.ts'; +import { clientSecretSchema } from './schema/request/clientSecretSchema.ts'; import { codeRequestSchema } from './schema/request/codeRequestSchema.ts'; import { deviceTokenRequestSchema, @@ -17,14 +18,19 @@ const authorizeQuerySchema = z.object({ client_id: z.string().optional(), redirect_uri: z.string().optional(), state: z.string().optional(), + code_challenge: z.string().optional(), + code_challenge_method: z.literal('S256').optional(), }); const revokeRequestSchema = z.object({ token: z.string(), client_id: z.string(), - client_secret: z.string(), + client_secret: clientSecretSchema, }); +const CLIENT_SECRET_NOTE = `> ### Note +> _\`client_secret\` is deprecated in favor of [PKCE](https://developer.trakt.tv/docs/pkce). It is optional, and should only be sent from your own server, never from a website, mobile app, or desktop app._`; + const device = builder.router({ code: { summary: 'Generate new device codes', @@ -38,7 +44,8 @@ You might consider generating a QR code for the user to easily scan on their mob #### JSON POST Data | Key | Type | Value | |---|---|---| -| \`client_id\` * | string | Get this from your app settings. |`, +| \`client_id\` * | string | Get this from your app settings. | +`, method: 'POST', path: '/code', body: codeRequestSchema, @@ -58,7 +65,6 @@ When you receive a \`200\` success response, save the \`access_token\` so your a |---|---|---| | \`code\` * | string | \`device_code\` from the initial method. | | \`client_id\` * | string | Get this from your app settings. | -| \`client_secret\` * | string | Get this from your app settings. | #### Status Codes This method will send various HTTP status codes that you should handle accordingly. @@ -126,7 +132,18 @@ When building the authorization URL, you can optionally include the following qu | Parameter | Value | Description | |---|---|---| | \`signup\` | \`true\` | Prefer the account sign up page to be the default. | -| \`prompt\` | \`login\` | Force the user to sign in and authorize your app. |`, +| \`prompt\` | \`login\` | Force the user to sign in and authorize your app. | + +#### PKCE + +[PKCE](https://developer.trakt.tv/docs/pkce) is the recommended way to sign users in. Send these parameters, then send the matching \`code_verifier\` instead of a \`client_secret\` when you exchange the code. + +| Parameter | Value | Description | +|---|---|---| +| \`code_challenge\` | string | The base64url-encoded SHA-256 hash of your \`code_verifier\`. | +| \`code_challenge_method\` | \`S256\` | The only supported method. | + +${CLIENT_SECRET_NOTE}`, path: '/authorize', method: 'GET', query: authorizeQuerySchema, @@ -140,6 +157,10 @@ When building the authorization URL, you can optionally include the following qu description: `Exchange an OAuth authorization code or refresh token for an access token. +If you started the flow with [PKCE](https://developer.trakt.tv/docs/pkce), send the \`code_verifier\`. + +${CLIENT_SECRET_NOTE} + #### Refreshing Tokens Refresh tokens are single-use. Each successful refresh returns a new \`refresh_token\` and invalidates the previous one. Store the new \`access_token\` and \`refresh_token\` from the response before the next refresh. @@ -166,7 +187,8 @@ If the exchange cannot be completed, this endpoint returns a \`400\` response wi |---|---|---| | \`token\` * | string | A valid OAuth \`access_token\`. | | \`client_id\` * | string | Get this from your app settings. | -| \`client_secret\` * | string | Get this from your app settings. |`, + +${CLIENT_SECRET_NOTE}`, path: '/revoke', method: 'POST', body: revokeRequestSchema, diff --git a/projects/api/src/contracts/oauth/schema/request/clientSecretSchema.ts b/projects/api/src/contracts/oauth/schema/request/clientSecretSchema.ts new file mode 100644 index 00000000..74cd79de --- /dev/null +++ b/projects/api/src/contracts/oauth/schema/request/clientSecretSchema.ts @@ -0,0 +1,7 @@ +import { z } from '../../../_internal/z.ts'; + +/** Zod schema for the deprecated, optional `client_secret` of OAuth requests. */ +export const clientSecretSchema = z.string({ + description: + 'Deprecated. Only send it from your own server, never from a website, mobile app, or desktop app.', +}).openapi({ deprecated: true }).optional(); diff --git a/projects/api/src/contracts/oauth/schema/request/deviceTokenRequestSchema.ts b/projects/api/src/contracts/oauth/schema/request/deviceTokenRequestSchema.ts index 215fbe01..4d18fbf7 100644 --- a/projects/api/src/contracts/oauth/schema/request/deviceTokenRequestSchema.ts +++ b/projects/api/src/contracts/oauth/schema/request/deviceTokenRequestSchema.ts @@ -1,4 +1,5 @@ import { z } from '../../../_internal/z.ts'; +import { clientSecretSchema } from './clientSecretSchema.ts'; /** Zod schema for the device token request. */ export const deviceTokenRequestSchema = z.object({ @@ -10,8 +11,5 @@ export const deviceTokenRequestSchema = z.object({ description: `The client ID of the application. You can find it in the application details here: https://app.trakt.tv/settings/apps`, }), - client_secret: z.string({ - description: `The client secret of the application. - You can find it in the application details here: https://app.trakt.tv/settings/apps`, - }), + client_secret: clientSecretSchema, }); diff --git a/projects/api/src/contracts/oauth/schema/request/tokenBaseSchema.ts b/projects/api/src/contracts/oauth/schema/request/tokenBaseSchema.ts index a5449e00..576d601b 100644 --- a/projects/api/src/contracts/oauth/schema/request/tokenBaseSchema.ts +++ b/projects/api/src/contracts/oauth/schema/request/tokenBaseSchema.ts @@ -1,4 +1,5 @@ import { z } from '../../../_internal/z.ts'; +import { clientSecretSchema } from './clientSecretSchema.ts'; /** Zod schema for token base. */ export const tokenBaseSchema = z.object({ @@ -6,10 +7,7 @@ export const tokenBaseSchema = z.object({ description: `The client ID of the application. You can find it in the application details here: https://app.trakt.tv/settings/apps`, }), - client_secret: z.string({ - description: `The client secret of the application. - You can find it in the application details here: https://app.trakt.tv/settings/apps`, - }), + client_secret: clientSecretSchema, redirect_uri: z.string({ description: 'URI specified in your app settings.', }), diff --git a/projects/api/src/contracts/oauth/schema/request/tokenRequestSchema.ts b/projects/api/src/contracts/oauth/schema/request/tokenRequestSchema.ts index defff208..a546811a 100644 --- a/projects/api/src/contracts/oauth/schema/request/tokenRequestSchema.ts +++ b/projects/api/src/contracts/oauth/schema/request/tokenRequestSchema.ts @@ -10,4 +10,8 @@ export const tokenRequestSchema = tokenBaseSchema.extend({ grant_type: z.string({ description: 'Defines how an access token is obtained.', }), + code_verifier: z.string({ + description: + 'The PKCE code verifier whose SHA-256 hash was sent as `code_challenge`. Required when the flow started with a `code_challenge`. See https://developer.trakt.tv/docs/pkce', + }).optional(), }); diff --git a/projects/developer/src/lib/features/apps/ApplicationForm.svelte b/projects/developer/src/lib/features/apps/ApplicationForm.svelte index ab800697..d984d2cc 100644 --- a/projects/developer/src/lib/features/apps/ApplicationForm.svelte +++ b/projects/developer/src/lib/features/apps/ApplicationForm.svelte @@ -1,6 +1,8 @@ + + + + diff --git a/projects/developer/src/lib/features/apps/RedirectUriWarning.svelte b/projects/developer/src/lib/features/apps/RedirectUriWarning.svelte new file mode 100644 index 00000000..ae9748c7 --- /dev/null +++ b/projects/developer/src/lib/features/apps/RedirectUriWarning.svelte @@ -0,0 +1,67 @@ + + +{#if uris.length > 0}
+

+ Insecure redirect URIs +

+

+ Use https:// redirect URIs only. Any app on + the device can claim a custom scheme or a localhost port and receive your users' + authorization codes. Mobile apps should use iOS Universal Links and Android + verified App Links instead. +

+ + How to set up secure redirects → +
{/if} + + diff --git a/projects/developer/src/lib/features/apps/applicationSchema.ts b/projects/developer/src/lib/features/apps/applicationSchema.ts index 27521096..0ac5cf05 100644 --- a/projects/developer/src/lib/features/apps/applicationSchema.ts +++ b/projects/developer/src/lib/features/apps/applicationSchema.ts @@ -5,7 +5,7 @@ export const applicationSchema = z.object({ name: z.string(), description: z.string().nullish(), client_id: z.string(), - client_secret: z.string(), + client_secret: z.string().nullish(), redirect_uri: z.string(), origins: z.array(z.string()), approved: z.boolean(), diff --git a/projects/developer/src/lib/features/apps/githubConnectUrl.spec.ts b/projects/developer/src/lib/features/apps/githubConnectUrl.spec.ts index 68d16fcf..35d673a0 100644 --- a/projects/developer/src/lib/features/apps/githubConnectUrl.spec.ts +++ b/projects/developer/src/lib/features/apps/githubConnectUrl.spec.ts @@ -1,9 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -vi.mock( - '$env/static/public', - () => ({ PUBLIC_GITHUB_CLIENT_ID: 'test-client' }), -); - import { githubConnectUrl } from './githubConnectUrl.ts'; function fakeStorage(): Storage { @@ -30,7 +25,9 @@ describe('githubConnectUrl', () => { vi.stubGlobal('location', { origin: 'https://developer.trakt.tv' }); vi.stubGlobal('crypto', { randomUUID: () => 'fixed-state' }); - const url = new URL(githubConnectUrl('switch')); + const url = new URL( + githubConnectUrl({ intent: 'switch', clientId: 'test-client' }), + ); expect(url.origin + url.pathname).toBe( 'https://github.com/login/oauth/authorize', diff --git a/projects/developer/src/lib/features/apps/githubConnectUrl.ts b/projects/developer/src/lib/features/apps/githubConnectUrl.ts index 7d1c8b87..2f967daf 100644 --- a/projects/developer/src/lib/features/apps/githubConnectUrl.ts +++ b/projects/developer/src/lib/features/apps/githubConnectUrl.ts @@ -1,14 +1,20 @@ -import { PUBLIC_GITHUB_CLIENT_ID } from '$env/static/public'; import type { GithubConnectIntent } from './GithubConnectIntent.ts'; import { GITHUB_INTENT_KEY } from './GITHUB_INTENT_KEY.ts'; import { GITHUB_STATE_KEY } from './GITHUB_STATE_KEY.ts'; -export function githubConnectUrl(intent: GithubConnectIntent): string { +type GithubConnectUrlParams = { + intent: GithubConnectIntent; + clientId: string; +}; + +export function githubConnectUrl( + { intent, clientId }: GithubConnectUrlParams, +): string { const state = globalThis.crypto.randomUUID(); globalThis.sessionStorage?.setItem(GITHUB_STATE_KEY, state); globalThis.sessionStorage?.setItem(GITHUB_INTENT_KEY, intent); const url = new URL('https://github.com/login/oauth/authorize'); - url.searchParams.set('client_id', PUBLIC_GITHUB_CLIENT_ID); + url.searchParams.set('client_id', clientId); url.searchParams.set('redirect_uri', `${globalThis.location.origin}/apps`); url.searchParams.set('state', state); return url.toString(); diff --git a/projects/developer/src/lib/features/apps/unsafeRedirectUris.spec.ts b/projects/developer/src/lib/features/apps/unsafeRedirectUris.spec.ts new file mode 100644 index 00000000..c67ad320 --- /dev/null +++ b/projects/developer/src/lib/features/apps/unsafeRedirectUris.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { unsafeRedirectUris } from './unsafeRedirectUris.ts'; + +describe('unsafeRedirectUris', () => { + it('accepts public https redirect URIs', () => { + expect( + unsafeRedirectUris( + 'https://example.com/callback\nhttps://app.example.com/auth', + ), + ).toEqual([]); + }); + + it('accepts public addresses next to the private ranges', () => { + expect( + unsafeRedirectUris( + [ + 'https://172.15.0.1/callback', + 'https://172.32.0.1/callback', + 'https://11.0.0.1/callback', + 'https://[2001:db8::1]/callback', + 'https://localhost.example.com/callback', + ].join('\n'), + ), + ).toEqual([]); + }); + + it.each([ + 'myapp://callback', + 'com.example.app:/oauth', + 'urn:ietf:wg:oauth:2.0:oob', + 'http://example.com/callback', + 'https://localhost:3000/callback', + 'http://localhost/callback', + 'https://dev.localhost/callback', + 'http://127.0.0.1:8080/callback', + 'http://[::1]/callback', + 'https://0.0.0.0/callback', + 'https://10.0.0.1/callback', + 'https://172.16.4.2/callback', + 'https://172.31.255.1/callback', + 'https://192.168.1.10/callback', + 'https://169.254.10.20/callback', + 'https://[fd12:3456::1]/callback', + 'https://[fc00::1]/callback', + 'https://[fe80::1]/callback', + 'https://printer.local/callback', + ])('flags %s', (uri) => { + expect(unsafeRedirectUris(uri)).toEqual([uri]); + }); + + it('returns only the unsafe lines, trimmed, and skips blank lines', () => { + expect( + unsafeRedirectUris( + ' https://example.com/callback \n\n myapp://callback \n', + ), + ).toEqual(['myapp://callback']); + }); + + it('lists a repeated unsafe URI once', () => { + expect(unsafeRedirectUris('myapp://callback\nmyapp://callback')).toEqual([ + 'myapp://callback', + ]); + }); +}); diff --git a/projects/developer/src/lib/features/apps/unsafeRedirectUris.ts b/projects/developer/src/lib/features/apps/unsafeRedirectUris.ts new file mode 100644 index 00000000..8744fb28 --- /dev/null +++ b/projects/developer/src/lib/features/apps/unsafeRedirectUris.ts @@ -0,0 +1,40 @@ +const LOCAL_HOSTS: ReadonlySet = new Set(['localhost', '0.0.0.0']); + +// Loopback, private (RFC 1918), and link-local IPv4 ranges. +const PRIVATE_IPV4 = + /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/; + +// Loopback, unique local (fc00::/7), and link-local (fe80::/10) IPv6. +const PRIVATE_IPV6 = /^\[(::1|f[cd][0-9a-f]{0,2}:|fe[89ab][0-9a-f]?:)/; + +function isPrivateHost(hostname: string): boolean { + return LOCAL_HOSTS.has(hostname) || hostname.endsWith('.localhost') || + hostname.endsWith('.local') || PRIVATE_IPV4.test(hostname) || + PRIVATE_IPV6.test(hostname); +} + +function parseUrl(uri: string): URL | null { + try { + return new URL(uri); + } catch { + return null; + } +} + +function isSafe(uri: string): boolean { + const url = parseUrl(uri); + if (!url) return false; + + return url.protocol === 'https:' && !isPrivateHost(url.hostname); +} + +/** + * Redirect URIs, one per line, that are not a public `https://` address: + * custom schemes, `http://`, loopback, private-network, and link-local hosts, + * and the out-of-band URI. Each URI is listed once. + */ +export function unsafeRedirectUris(redirects: string): ReadonlyArray { + const lines = redirects.split('\n').map((line) => line.trim()); + + return [...new Set(lines.filter(Boolean).filter((uri) => !isSafe(uri)))]; +} diff --git a/projects/developer/src/lib/guides/authentication-oauth.md b/projects/developer/src/lib/guides/authentication-oauth.md index 93041114..a7e84c99 100644 --- a/projects/developer/src/lib/guides/authentication-oauth.md +++ b/projects/developer/src/lib/guides/authentication-oauth.md @@ -1,5 +1,5 @@ --- -updatedAt: 2026-09-09T02:34:16.068Z +updatedAt: 2026-10-01T00:00:00.000Z --- # Authentication @@ -23,6 +23,12 @@ Trakt supports two OAuth flows: Check each endpoint’s documentation to see whether OAuth is required, optional, or not needed. +> ### 🔐 Use PKCE +> +> Add [PKCE](/docs/pkce) to the Authorization Code Flow. It is the recommended +> way to sign users in and does not need a `client_secret`. The Client Secret is +> deprecated for user sign-in and should only be used server to server. + ## Register Your Application To obtain a `client_id` and `client_secret`, create an application on the Trakt diff --git a/projects/developer/src/lib/guides/contents.md b/projects/developer/src/lib/guides/contents.md index e2c8b5e4..2bd26cf3 100644 --- a/projects/developer/src/lib/guides/contents.md +++ b/projects/developer/src/lib/guides/contents.md @@ -6,6 +6,7 @@ - [Create an App](/docs/create-an-app) - [API Use Policy](/docs/api-use-policy) - [Authentication](/docs/authentication-oauth) +- [PKCE](/docs/pkce) ## Request basics diff --git a/projects/developer/src/lib/guides/create-an-app.md b/projects/developer/src/lib/guides/create-an-app.md index b96fb2f4..6523296e 100644 --- a/projects/developer/src/lib/guides/create-an-app.md +++ b/projects/developer/src/lib/guides/create-an-app.md @@ -1,12 +1,13 @@ --- -updatedAt: 2026-09-23T00:00:00.000Z +updatedAt: 2026-10-01T00:00:00.000Z --- # ✅ Create an App To use the Trakt API, you'll first need to [**create a new API app**](/apps). -Once done, you'll have everything you need to use the API (Client ID and Client Secret). +Once done, you'll have everything you need to use the API: your Client ID. +Sign users in with [PKCE](/docs/pkce), which does not need a Client Secret. Creating an app requires a verified GitHub account. Connect it once from the Developer panel on My Apps, and every app you create afterwards uses it. diff --git a/projects/developer/src/lib/guides/pkce.md b/projects/developer/src/lib/guides/pkce.md new file mode 100644 index 00000000..e0d9f0a9 --- /dev/null +++ b/projects/developer/src/lib/guides/pkce.md @@ -0,0 +1,74 @@ +--- +updatedAt: 2026-10-01T00:00:00.000Z +--- + +# 🔐 PKCE + +PKCE (Proof Key for Code Exchange, pronounced "pixy") is the recommended way to +sign users in to your app. It adds a one-time secret to every sign-in, so an +authorization `code` is useless to anyone except the app that started the +request. Your app never needs a `client_secret`. + +Use PKCE for every app that signs users in: websites, single-page apps, mobile +apps, desktop apps, and CLI tools that use the Authorization Code Flow. + +> ### ⚠️ The Client Secret is deprecated for user sign-in +> +> A `client_secret` shipped inside a website, mobile app, or desktop app is not +> secret: anyone can extract it and impersonate your app. Only use it for +> server-to-server calls where it never leaves your backend. New integrations +> should use PKCE, and future apps may not receive a Client Secret at all. + +## Why PKCE + +Your `client_id` is public. It is visible in every authorization URL, so anyone +can copy it. Without PKCE, an attacker who copies your `client_id` and the +bundled `client_secret`, or intercepts a `code` on its way back to your app, can +exchange it for the user's tokens. + +With PKCE, the token exchange also needs the `code_verifier` that only your app +knows. A stolen `client_id` or `code` cannot be turned into tokens, so copying +your app's credentials stops being useful. + +## How It Works + +1. **Create a code verifier.** Generate a random string of 43-128 characters + from `A-Z`, `a-z`, `0-9`, and `-._~`. Keep it in memory or session storage + for this sign-in only. +2. **Derive the code challenge.** Hash the verifier with SHA-256 and encode the + result as base64url without padding. Trakt only supports the `S256` method. +3. **Redirect to authorize.** Send the user to + `https://auth.trakt.tv/oauth/authorize` with the usual parameters plus + `code_challenge` and `code_challenge_method=S256`. +4. **Receive the code.** Trakt redirects back to your `redirect_uri` with a + `code`. +5. **Exchange the code.** Call + [`POST /oauth/token`](/?section=reference&operation=postOauthToken) with the + `code` and the original `code_verifier`. Do not send a `client_secret`. + Trakt checks that the verifier matches the challenge from step 3. + +## Redirect URIs + +PKCE protects the `code`, but your app should still only receive it on an +address it truly owns. Use `https://` redirect URIs only. + +- **Websites** use an `https://` callback on their own domain. +- **iOS apps** use + [Universal Links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app), + so iOS opens the callback only in your app. +- **Android apps** use + [verified App Links](https://developer.android.com/training/app-links/verify-android-applinks), + so Android opens the callback only in your app. + +Avoid custom schemes such as `myapp://callback`, `localhost` addresses, and the +`urn:ietf:wg:oauth:2.0:oob` out-of-band URI. Any app installed on the device can +register the same custom scheme or listen on the same local port and receive +your users' codes. + +## Checklist + +- Generate a new `code_verifier` for every sign-in and discard it afterwards. +- Always send `code_challenge_method=S256`. +- Never ship a `client_secret` in a website, mobile app, or desktop app. +- Register `https://` redirect URIs only, backed by Universal Links or verified + App Links on mobile. diff --git a/projects/developer/src/lib/openapi/parseOpenApiDocument.spec.ts b/projects/developer/src/lib/openapi/parseOpenApiDocument.spec.ts index a07cff3e..f271e4d9 100644 --- a/projects/developer/src/lib/openapi/parseOpenApiDocument.spec.ts +++ b/projects/developer/src/lib/openapi/parseOpenApiDocument.spec.ts @@ -201,4 +201,39 @@ describe('parseOpenApiDocument', () => { '{\n "rating": 9\n}', ); }); + + it('should leave deprecated properties out of JSON body examples', () => { + const catalog = parseOpenApiDocument({ + source: 'test', + document: { + openapi: '3.0.2', + info: { title: 'Trakt API', version: '2.0.0' }, + paths: { + '/oauth/token': { + post: { + operationId: 'postOauthToken', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + client_id: { type: 'string' }, + client_secret: { type: 'string', deprecated: true }, + }, + }, + }, + }, + }, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + }); + + expect(catalog.endpoints.at(0)?.requestBody?.example).toBe( + '{\n "client_id": ""\n}', + ); + }); }); diff --git a/projects/developer/src/lib/openapi/parseOpenApiDocument.ts b/projects/developer/src/lib/openapi/parseOpenApiDocument.ts index ae5fc19e..4866712f 100644 --- a/projects/developer/src/lib/openapi/parseOpenApiDocument.ts +++ b/projects/developer/src/lib/openapi/parseOpenApiDocument.ts @@ -126,15 +126,19 @@ function sampleFromSchema({ switch (schemaType(schema)) { case 'object': return Object.fromEntries( - Object.entries(asObject(schema.properties)).map(([name, property]) => [ - name, - sampleFromSchema({ - root, - schemaValue: property, - depth: depth + 1, - references: nextReferences, - }), - ]), + Object.entries(asObject(schema.properties)) + .filter(([, property]) => + !asBoolean(resolveReference({ root, value: property }).deprecated) + ) + .map(([name, property]) => [ + name, + sampleFromSchema({ + root, + schemaValue: property, + depth: depth + 1, + references: nextReferences, + }), + ]), ); case 'array': return [ diff --git a/projects/developer/src/lib/redirects/guideRedirects.json b/projects/developer/src/lib/redirects/guideRedirects.json index c09078ea..23bda38b 100644 --- a/projects/developer/src/lib/redirects/guideRedirects.json +++ b/projects/developer/src/lib/redirects/guideRedirects.json @@ -3,6 +3,7 @@ "/docs/stay-connected": "stay-connected", "/docs/create-an-app": "create-an-app", "/docs/authentication-oauth": "authentication-oauth", + "/docs/pkce": "pkce", "/docs/api-url": "api-url", "/docs/required-headers": "required-headers", "/docs/rate-limiting": "rate-limiting",