diff --git a/projects/api/src/contracts/oauth/index.ts b/projects/api/src/contracts/oauth/index.ts index f83c3891..44e4b32a 100644 --- a/projects/api/src/contracts/oauth/index.ts +++ b/projects/api/src/contracts/oauth/index.ts @@ -114,7 +114,7 @@ export const oauth = builder `Construct then redirect to this URL. The Trakt website will request permissions for your app, which the user needs to approve. If the user isn't signed into Trakt, it will ask them to do so. > ### Important -> _Use the website **https://trakt.tv** hostname when creating this URL and not the API URL._ +> _Use the **https://auth.trakt.tv** hostname for all OAuth requests, including authorization, device authentication, token exchange and refresh, and token revocation. Do not use the API hostname for these requests._ #### Optional URL Parameters diff --git a/projects/openapi/generate.test.ts b/projects/openapi/generate.test.ts new file mode 100644 index 00000000..68ea78f2 --- /dev/null +++ b/projects/openapi/generate.test.ts @@ -0,0 +1,60 @@ +import type { OperationObject } from 'openapi3-ts'; +import { generate } from './generate.ts'; + +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} + +function getOperations(): OperationObject[] { + return Object.values(generate().paths).flatMap((path) => + Object.values(path ?? {}).filter((value): value is OperationObject => + typeof value === 'object' && value !== null && 'responses' in value + ) + ); +} + +Deno.test('uses the API host by default', () => { + const document = generate(); + const apiOperation = document.paths['/movies/trending']?.get; + + assertEquals(document.servers?.[0]?.url, 'https://api.trakt.tv'); + assertEquals(apiOperation?.servers, undefined); +}); + +Deno.test('uses the auth host for every OAuth endpoint', () => { + const oauthOperations = getOperations().filter((operation) => + operation.tags?.includes('oauth') + ); + + assertEquals(oauthOperations.length > 0, true); + for (const operation of oauthOperations) { + assertEquals(operation.servers, [{ + url: 'https://auth.trakt.tv', + description: 'Authentication', + }]); + } +}); + +Deno.test('uses the auth host for the OAuth2 flow URLs', () => { + const document = generate(); + const oauth2 = document.components?.securitySchemes?.traktOAuth; + + if (!oauth2 || !('flows' in oauth2) || !oauth2.flows) { + throw new Error('Expected the traktOAuth OAuth2 security scheme'); + } + + assertEquals( + oauth2.flows.authorizationCode?.authorizationUrl, + 'https://auth.trakt.tv/oauth/authorize', + ); + assertEquals( + oauth2.flows.authorizationCode?.tokenUrl, + 'https://auth.trakt.tv/oauth/token', + ); +}); diff --git a/projects/openapi/generate.ts b/projects/openapi/generate.ts index 88785dd1..5b38f947 100644 --- a/projects/openapi/generate.ts +++ b/projects/openapi/generate.ts @@ -40,7 +40,7 @@ function getSecurityFromAuth( } } -const servers = [ +const apiServers = [ { url: Environment.production, description: 'Production', @@ -55,6 +55,13 @@ const servers = [ }, ]; +const authServers = [ + { + url: 'https://auth.trakt.tv', + description: 'Authentication', + }, +]; + export function generate(): ReturnType { return generateOpenApi( traktContract, @@ -63,7 +70,7 @@ export function generate(): ReturnType { title: 'Trakt API', version: '2.0.0', }, - servers, + servers: apiServers, components: { securitySchemes: { traktAPI: { @@ -75,8 +82,8 @@ export function generate(): ReturnType { type: 'oauth2', flows: { authorizationCode: { - authorizationUrl: 'https://trakt.tv/oauth/authorize', - tokenUrl: 'https://api.trakt.tv/oauth/token', + authorizationUrl: 'https://auth.trakt.tv/oauth/authorize', + tokenUrl: 'https://auth.trakt.tv/oauth/token', scopes: { public: 'Access public data', }, @@ -106,10 +113,12 @@ export function generate(): ReturnType { ]; const auth = getAuthRequirement(route.metadata, operation.tags ?? []); + const isAuthEndpoint = operation.tags?.includes('oauth') ?? false; return { ...operation, security: getSecurityFromAuth(auth), + ...(isAuthEndpoint ? { servers: authServers } : {}), operationId: `${route.method.toLowerCase()}${ parts .map((part) => part.charAt(0).toUpperCase() + part.slice(1))