diff --git a/.chronus/changes/flexible-auth-2024-1-9-0-8-11.md b/.chronus/changes/flexible-auth-2024-1-9-0-8-11.md new file mode 100644 index 000000000..799619ef2 --- /dev/null +++ b/.chronus/changes/flexible-auth-2024-1-9-0-8-11.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/http" +--- + +Add ability to sepcify authentication and different scopes per operation diff --git a/.chronus/changes/flexible-auth-2024-1-9-0-8-12.md b/.chronus/changes/flexible-auth-2024-1-9-0-8-12.md new file mode 100644 index 000000000..813212689 --- /dev/null +++ b/.chronus/changes/flexible-auth-2024-1-9-0-8-12.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: internal +packages: + - "@typespec/openapi3" +--- + +Add support for per operation authentication and scopes diff --git a/docs/libraries/http/reference/data-types.md b/docs/libraries/http/reference/data-types.md index ef32d587f..fd6a41ca1 100644 --- a/docs/libraries/http/reference/data-types.md +++ b/docs/libraries/http/reference/data-types.md @@ -174,6 +174,15 @@ The URL of the requested resource has been changed permanently. The new URL is g model TypeSpec.Http.MovedResponse ``` +### `NoAuth` {#TypeSpec.Http.NoAuth} + +This authentication option signifies that API is not secured at all. +It might be useful when overriding authentication on interface of operation level. + +```typespec +model TypeSpec.Http.NoAuth +``` + ### `NoContentResponse` {#TypeSpec.Http.NoContentResponse} There is no content to send for this request, but the headers may be useful. @@ -207,14 +216,15 @@ For that purpose, an OAuth 2.0 server issues access tokens that the client appli For more information about OAuth 2.0, see oauth.net and RFC 6749. ```typespec -model TypeSpec.Http.OAuth2Auth +model TypeSpec.Http.OAuth2Auth ``` #### Template Parameters -| Name | Description | -| ----- | ---------------------------------- | -| Flows | The list of supported OAuth2 flows | +| Name | Description | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Flows | The list of supported OAuth2 flows | +| Scopes | The list of OAuth2 scopes, which are common for every flow from `Flows`. This list is combined with the scopes defined in specific OAuth2 flows. | ### `OkResponse` {#TypeSpec.Http.OkResponse} diff --git a/docs/libraries/http/reference/decorators.md b/docs/libraries/http/reference/decorators.md index 29d75b1c0..db907c0fe 100644 --- a/docs/libraries/http/reference/decorators.md +++ b/docs/libraries/http/reference/decorators.md @@ -402,7 +402,7 @@ op create(): {@statusCode: 201 | 202} ### `@useAuth` {#@TypeSpec.Http.useAuth} -Specify this service authentication. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details. +Specify authentication for a whole service or specific methods. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details. ```typespec @TypeSpec.Http.useAuth(auth: {} | Union | {}[]) @@ -410,7 +410,7 @@ Specify this service authentication. See the [documentation in the Http library] #### Target -`Namespace` +`union Namespace | Interface | Operation` #### Parameters diff --git a/docs/libraries/http/reference/index.mdx b/docs/libraries/http/reference/index.mdx index 9c6cf9c17..035944359 100644 --- a/docs/libraries/http/reference/index.mdx +++ b/docs/libraries/http/reference/index.mdx @@ -69,6 +69,7 @@ npm install --save-peer @typespec/http - [`ImplicitFlow`](./data-types.md#TypeSpec.Http.ImplicitFlow) - [`LocationHeader`](./data-types.md#TypeSpec.Http.LocationHeader) - [`MovedResponse`](./data-types.md#TypeSpec.Http.MovedResponse) +- [`NoAuth`](./data-types.md#TypeSpec.Http.NoAuth) - [`NoContentResponse`](./data-types.md#TypeSpec.Http.NoContentResponse) - [`NotFoundResponse`](./data-types.md#TypeSpec.Http.NotFoundResponse) - [`NotModifiedResponse`](./data-types.md#TypeSpec.Http.NotModifiedResponse) diff --git a/packages/http/README.md b/packages/http/README.md index b35b6e2ea..099ef915b 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -447,7 +447,7 @@ op create(): {@statusCode: 201 | 202} #### `@useAuth` -Specify this service authentication. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details. +Specify authentication for a whole service or specific methods. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details. ```typespec @TypeSpec.Http.useAuth(auth: {} | Union | {}[]) @@ -455,7 +455,7 @@ Specify this service authentication. See the [documentation in the Http library] ##### Target -`Namespace` +`union Namespace | Interface | Operation` ##### Parameters diff --git a/packages/http/lib/auth.tsp b/packages/http/lib/auth.tsp index 465d8e699..1cf81dd2a 100644 --- a/packages/http/lib/auth.tsp +++ b/packages/http/lib/auth.tsp @@ -13,6 +13,9 @@ enum AuthType { @doc("OpenID connect") openIdConnect, + + @doc("Empty auth") + noAuth, } /** @@ -105,14 +108,18 @@ model ApiKeyAuth { * For more information about OAuth 2.0, see oauth.net and RFC 6749. * * @template Flows The list of supported OAuth2 flows + * @template Scopes The list of OAuth2 scopes, which are common for every flow from `Flows`. This list is combined with the scopes defined in specific OAuth2 flows. */ @doc("") -model OAuth2Auth { +model OAuth2Auth { @doc("OAuth2 authentication") type: AuthType.oauth2; @doc("Supported OAuth2 flows") flows: Flows; + + @doc("Oauth2 scopes of every flow. Overridden by scope definitions in specific flows") + defaultScopes: Scopes; } @doc("Describes the OAuth2 flow type") @@ -147,7 +154,7 @@ model AuthorizationCodeFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes: string[]; + scopes?: string[]; } @doc("Implicit flow") @@ -162,7 +169,7 @@ model ImplicitFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes: string[]; + scopes?: string[]; } @doc("Resource Owner Password flow") @@ -177,7 +184,7 @@ model PasswordFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes: string[]; + scopes?: string[]; } @doc("Client credentials flow") @@ -192,7 +199,7 @@ model ClientCredentialsFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes: string[]; + scopes?: string[]; } /** @@ -212,3 +219,12 @@ model OpenIdConnectAuth { /** Connect url. It can be specified relative to the server URL */ openIdConnectUrl: ConnectUrl; } + +/** + * This authentication option signifies that API is not secured at all. + * It might be useful when overriding authentication on interface of operation level. + */ +@doc("") +model NoAuth { + type: AuthType.noAuth; +} diff --git a/packages/http/lib/http-decorators.tsp b/packages/http/lib/http-decorators.tsp index b73c8ba53..79755250e 100644 --- a/packages/http/lib/http-decorators.tsp +++ b/packages/http/lib/http-decorators.tsp @@ -202,7 +202,7 @@ extern dec server( ); /** - * Specify this service authentication. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details. + * Specify authentication for a whole service or specific methods. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details. * * @param auth Authentication configuration. Can be a single security scheme, a union(either option is valid authentication) or a tuple (must use all authentication together) * @example @@ -213,7 +213,7 @@ extern dec server( * namespace PetStore; * ``` */ -extern dec useAuth(target: Namespace, auth: {} | Union | {}[]); +extern dec useAuth(target: Namespace | Interface | Operation, auth: {} | Union | {}[]); /** * Specify if inapplicable metadata should be included in the payload for the given entity. diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts new file mode 100644 index 000000000..d1f3852e3 --- /dev/null +++ b/packages/http/src/auth.ts @@ -0,0 +1,185 @@ +import { Operation, Program } from "@typespec/compiler"; +import { deepClone, deepEquals } from "@typespec/compiler/utils"; +import { HttpStateKeys } from "./lib.js"; +import { + Authentication, + HttpAuth, + HttpService, + NoAuth, + OAuth2Flow, + OAuth2Scope, + Oauth2Auth, +} from "./types.js"; + +export function getAuthenticationForOperation( + program: Program, + operation: Operation +): Authentication | undefined { + const operationAuth = program.stateMap(HttpStateKeys.authentication).get(operation); + if (operationAuth === undefined && operation.interface !== undefined) { + const interfaceAuth = program.stateMap(HttpStateKeys.authentication).get(operation.interface); + return interfaceAuth; + } + return operationAuth; +} + +export type HttpAuthRef = AnyHttpAuthRef | OAuth2HttpAuthRef | NoHttpAuthRef; + +export interface AnyHttpAuthRef { + readonly kind: "any"; + readonly auth: HttpAuth; +} + +export interface NoHttpAuthRef { + readonly kind: "noAuth"; + readonly auth: NoAuth; +} + +/* Holder of this reference needs only a `scopes` subset of all scopes defined at `auth` */ +export interface OAuth2HttpAuthRef { + readonly kind: "oauth2"; + readonly auth: Oauth2Auth; + readonly scopes: string[]; +} + +export interface AuthenticationReference { + /** + * Either one of those options can be used independently to authenticate. + */ + readonly options: AuthenticationOptionReference[]; +} + +export interface AuthenticationOptionReference { + /** + * For this authentication option all the given auth have to be used together. + */ + readonly all: HttpAuthRef[]; +} + +export interface HttpServiceAuthentication { + /** + * All the authentication schemes used in this service. + * Some might only be used in certain operations. + */ + readonly schemes: HttpAuth[]; + + /** + * Default authentication for operations in this service. + */ + readonly defaultAuth: AuthenticationReference; + + /** + * Authentication overrides for individual operations. + */ + readonly operationsAuth: Map; +} + +export function resolveAuthentication(service: HttpService): HttpServiceAuthentication { + let schemes: Record = {}; + let defaultAuth: AuthenticationReference = { options: [] }; + const operationsAuth: Map = new Map(); + + if (service.authentication) { + const { newServiceSchemes, authOptions } = gatherAuth(service.authentication, {}); + schemes = newServiceSchemes; + defaultAuth = authOptions; + } + + for (const op of service.operations) { + if (op.authentication) { + const { newServiceSchemes, authOptions } = gatherAuth(op.authentication, schemes); + schemes = newServiceSchemes; + operationsAuth.set(op.operation, authOptions); + } + } + + return { schemes: Object.values(schemes), defaultAuth, operationsAuth }; +} + +function gatherAuth( + authentication: Authentication, + serviceSchemes: Record +): { + newServiceSchemes: Record; + authOptions: AuthenticationReference; +} { + const newServiceSchemes: Record = serviceSchemes; + const authOptions: AuthenticationReference = { options: [] }; + for (const option of authentication.options) { + const authOption: AuthenticationOptionReference = { all: [] }; + for (const optionScheme of option.schemes) { + const serviceScheme = serviceSchemes[optionScheme.id]; + let newServiceScheme = optionScheme; + if (serviceScheme) { + // If we've seen a different scheme by this id, + // Make sure to not overwrite it + if (!authsAreEqual(serviceScheme, optionScheme)) { + while (serviceSchemes[newServiceScheme.id]) { + newServiceScheme.id = newServiceScheme.id + "_"; + } + } + // Merging scopes when encountering the same Oauth2 scheme + else if (serviceScheme.type === "oauth2" && optionScheme.type === "oauth2") { + const x = mergeOAuthScopes(serviceScheme, optionScheme); + newServiceScheme = x; + } + } + const httpAuthRef = makeHttpAuthRef(optionScheme, newServiceScheme); + newServiceSchemes[newServiceScheme.id] = newServiceScheme; + authOption.all.push(httpAuthRef); + } + authOptions.options.push(authOption); + } + return { newServiceSchemes, authOptions }; +} + +function makeHttpAuthRef(local: HttpAuth, reference: HttpAuth): HttpAuthRef { + if (reference.type === "oauth2" && local.type === "oauth2") { + const scopes: string[] = []; + for (const flow of local.flows) { + scopes.push(...flow.scopes.map((x) => x.value)); + } + return { kind: "oauth2", auth: reference, scopes: scopes }; + } else if (reference.type === "noAuth") { + return { kind: "noAuth", auth: reference }; + } else { + return { kind: "any", auth: reference }; + } +} + +function mergeOAuthScopes( + scheme1: Oauth2Auth, + scheme2: Oauth2Auth +): Oauth2Auth { + const flows = deepClone(scheme1.flows); + flows.forEach((flow1, i) => { + const flow2 = scheme2.flows[i]; + const scopes = Array.from(new Set(flow1.scopes.concat(flow2.scopes))); + flows[i].scopes = scopes; + }); + return { + ...scheme1, + flows, + }; +} + +function setOauth2Scopes( + scheme: Oauth2Auth, + scopes: OAuth2Scope[] +): Oauth2Auth { + const flows: Flows = deepClone(scheme.flows); + flows.forEach((flow) => { + flow.scopes = scopes; + }); + return { + ...scheme, + flows, + }; +} + +function authsAreEqual(scheme1: HttpAuth, scheme2: HttpAuth): boolean { + if (scheme1.type === "oauth2" && scheme2.type === "oauth2") { + return deepEquals(setOauth2Scopes(scheme1, []), setOauth2Scopes(scheme2, [])); + } + return deepEquals(scheme1, scheme2); +} diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 4777f0513..54e37b889 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -2,6 +2,7 @@ import { DecoratorContext, Diagnostic, DiagnosticTarget, + Interface, Model, ModelProperty, Namespace, @@ -25,6 +26,7 @@ import { HttpStateKeys, createDiagnostic, reportDiagnostic } from "./lib.js"; import { setRoute, setSharedRoute } from "./route.js"; import { getStatusCodesFromType } from "./status-codes.js"; import { + Authentication, AuthenticationOption, HeaderFieldOptions, HttpAuth, @@ -33,7 +35,6 @@ import { HttpVerb, PathParameterOptions, QueryParameterOptions, - ServiceAuthentication, } from "./types.js"; import { extractParamsFromPath } from "./utils.js"; @@ -432,28 +433,28 @@ setTypeSpecNamespace("Private", $plainData); export function $useAuth( context: DecoratorContext, - serviceNamespace: Namespace, + entity: Namespace | Interface | Operation, authConfig: Model | Union | Tuple ) { - const [auth, diagnostics] = extractServiceAuthentication(context.program, authConfig); + const [auth, diagnostics] = extractAuthentication(context.program, authConfig); if (diagnostics.length > 0) context.program.reportDiagnostics(diagnostics); if (auth !== undefined) { - setAuthentication(context.program, serviceNamespace, auth); + setAuthentication(context.program, entity, auth); } } export function setAuthentication( program: Program, - serviceNamespace: Namespace, - auth: ServiceAuthentication + entity: Namespace | Interface | Operation, + auth: Authentication ) { - program.stateMap(HttpStateKeys.authentication).set(serviceNamespace, auth); + program.stateMap(HttpStateKeys.authentication).set(entity, auth); } -function extractServiceAuthentication( +function extractAuthentication( program: Program, type: Model | Union | Tuple -): [ServiceAuthentication | undefined, readonly Diagnostic[]] { +): [Authentication | undefined, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); switch (type.kind) { @@ -473,7 +474,7 @@ function extractHttpAuthenticationOptions( program: Program, tuple: Union, diagnosticTarget: DiagnosticTarget -): [ServiceAuthentication, readonly Diagnostic[]] { +): [Authentication, readonly Diagnostic[]] { const options: AuthenticationOption[] = []; const diagnostics = createDiagnosticCollector(); for (const variant of tuple.variants.values()) { @@ -565,12 +566,16 @@ function extractOAuth2Auth(data: any): HttpAuth { Array.isArray(data.flows) && data.flows.every((x: any) => typeof x === "object") ? data.flows : []; + + const defaultScopes = Array.isArray(data.defaultScopes) ? data.defaultScopes : []; return { - ...data, + id: data.id, + type: data.type, flows: flows.map((flow: any) => { + const scopes: Array = flow.scopes ? flow.scopes : defaultScopes; return { ...flow, - scopes: (flow.scopes || []).map((x: string) => ({ value: x })), + scopes: scopes.map((x: string) => ({ value: x })), }; }), }; @@ -578,9 +583,9 @@ function extractOAuth2Auth(data: any): HttpAuth { export function getAuthentication( program: Program, - namespace: Namespace -): ServiceAuthentication | undefined { - return program.stateMap(HttpStateKeys.authentication).get(namespace); + entity: Namespace | Interface | Operation +): Authentication | undefined { + return program.stateMap(HttpStateKeys.authentication).get(entity); } /** diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 7f1cd2456..c3d8bcd88 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,6 +1,7 @@ export { $lib } from "./lib.js"; export { $linter } from "./linter.js"; +export * from "./auth.js"; export * from "./content-types.js"; export * from "./decorators.js"; export * from "./metadata.js"; diff --git a/packages/http/src/operations.ts b/packages/http/src/operations.ts index 720c82f85..6c9c58456 100644 --- a/packages/http/src/operations.ts +++ b/packages/http/src/operations.ts @@ -15,6 +15,8 @@ import { Program, SyntaxKind, } from "@typespec/compiler"; +import { getAuthenticationForOperation } from "./auth.js"; +import { getAuthentication } from "./decorators.js"; import { createDiagnostic, reportDiagnostic } from "./lib.js"; import { getResponsesForOperation } from "./responses.js"; import { isSharedRoute, resolvePathAndParameters } from "./route.js"; @@ -95,6 +97,7 @@ export function getHttpService( }, }) ); + const authentication = getAuthentication(program, serviceNamespace); validateProgram(program, diagnostics); validateRouteUnique(program, diagnostics, httpOperations); @@ -102,6 +105,7 @@ export function getHttpService( const service: HttpService = { namespace: serviceNamespace, operations: httpOperations, + authentication: authentication, }; return diagnostics.wrap(service); } @@ -213,6 +217,7 @@ function getHttpOperationInternal( resolvePathAndParameters(program, operation, overloading, options ?? {}) ); const responses = diagnostics.pipe(getResponsesForOperation(program, operation)); + const authentication = getAuthenticationForOperation(program, operation); const httpOperation: HttpOperation = { path: route.path, @@ -220,8 +225,9 @@ function getHttpOperationInternal( verb: route.parameters.verb, container: operation.interface ?? operation.namespace ?? program.getGlobalNamespaceType(), parameters: route.parameters, - operation, responses, + operation, + authentication, }; Object.assign(httpOperationRef, httpOperation); diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index c6dbfcb48..0b9e039b4 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -16,7 +16,11 @@ export type OperationDetails = HttpOperation; export type HttpVerb = "get" | "put" | "post" | "patch" | "delete" | "head"; -export interface ServiceAuthentication { +/** @deprecated use Authentication */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ServiceAuthentication = Authentication; + +export interface Authentication { /** * Either one of those options can be used independently to authenticate. */ @@ -35,7 +39,8 @@ export type HttpAuth = | BearerAuth | ApiKeyAuth | Oauth2Auth - | OpenIDConnectAuth; + | OpenIDConnectAuth + | NoAuth; export interface HttpAuthBase { /** @@ -184,6 +189,14 @@ export interface OpenIDConnectAuth extends HttpAuthBase { openIdConnectUrl: string; } +/** + * This authentication option signifies that API is not secured at all. + * It might be useful when overriding authentication on interface of operation level. + */ +export interface NoAuth extends HttpAuthBase { + type: "noAuth"; +} + export type OperationContainer = Namespace | Interface; export type OperationVerbSelector = ( @@ -287,6 +300,7 @@ export interface HttpOperationParameters { export interface HttpService { namespace: Namespace; operations: HttpOperation[]; + authentication?: Authentication; } export interface HttpOperation { @@ -325,6 +339,11 @@ export interface HttpOperation { */ operation: Operation; + /** + * Operation authentication. Overrides HttpService authentication + */ + authentication?: Authentication; + /** * Overload this operation */ diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 8aafa986b..85271879f 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -623,20 +623,6 @@ describe("http: decorators", () => { }); describe("@useAuth", () => { - it("emit diagnostics when @useAuth is not used on namespace", async () => { - const diagnostics = await runner.diagnose(` - @useAuth(BasicAuth) op test(): string; - `); - - expectDiagnostics(diagnostics, [ - { - code: "decorator-wrong-target", - message: - "Cannot apply @useAuth decorator to test since it is not assignable to Namespace", - }, - ]); - }); - it("emit diagnostics when config is not a model, tuple or union", async () => { const diagnostics = await runner.diagnose(` @useAuth(anOp) @@ -792,6 +778,60 @@ describe("http: decorators", () => { }); }); + it("can specify OAuth2 with scopes, which are default for every flow", async () => { + const { Foo } = (await runner.compile(` + alias MyAuth = OAuth2Auth; + + @useAuth(MyAuth<["read", "write"]>) + @test namespace Foo {} + `)) as { Foo: Namespace }; + + deepStrictEqual(getAuthentication(runner.program, Foo), { + options: [ + { + schemes: [ + { + id: "OAuth2Auth", + type: "oauth2", + flows: [ + { + type: "implicit", + authorizationUrl: "https://api.example.com/oauth2/authorize", + refreshUrl: "https://api.example.com/oauth2/refresh", + scopes: [{ value: "read" }, { value: "write" }], + }, + ], + }, + ], + }, + ], + }); + }); + + it("can specify NoAuth", async () => { + const { Foo } = (await runner.compile(` + @useAuth(NoAuth) + @test namespace Foo {} + `)) as { Foo: Namespace }; + + deepStrictEqual(getAuthentication(runner.program, Foo), { + options: [ + { + schemes: [ + { + id: "NoAuth", + type: "noAuth", + }, + ], + }, + ], + }); + }); + it("can specify multiple auth options", async () => { const { Foo } = (await runner.compile(` @useAuth(BasicAuth | BearerAuth) @@ -853,6 +893,50 @@ describe("http: decorators", () => { ], }); }); + + it("can override auth schemes on interface", async () => { + const { Foo } = (await runner.compile(` + alias ServiceKeyAuth = ApiKeyAuth; + @useAuth(ServiceKeyAuth) + @test namespace Foo { + @useAuth(BasicAuth | BearerAuth) + interface Bar { } + } + `)) as { Foo: Namespace }; + + deepStrictEqual(getAuthentication(runner.program, Foo.interfaces.get("Bar")!), { + options: [ + { + schemes: [{ id: "BasicAuth", type: "http", scheme: "basic" }], + }, + { + schemes: [{ id: "BearerAuth", type: "http", scheme: "bearer" }], + }, + ], + }); + }); + + it("can override auth schemes on operation", async () => { + const { Foo } = (await runner.compile(` + alias ServiceKeyAuth = ApiKeyAuth; + @useAuth(ServiceKeyAuth) + @test namespace Foo { + @useAuth([BasicAuth, BearerAuth]) + op bar(): void; + } + `)) as { Foo: Namespace }; + + deepStrictEqual(getAuthentication(runner.program, Foo.operations.get("bar")!), { + options: [ + { + schemes: [ + { id: "BasicAuth", type: "http", scheme: "basic" }, + { id: "BearerAuth", type: "http", scheme: "bearer" }, + ], + }, + ], + }); + }); }); describe("@visibility", () => { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index a57184c98..2456923e0 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -50,8 +50,10 @@ import { import { AssetEmitter, createAssetEmitter, EmitEntity } from "@typespec/compiler/emitter-framework"; import {} from "@typespec/compiler/utils"; import { + Authentication, + AuthenticationOptionReference, + AuthenticationReference, createMetadataInfo, - getAuthentication, getHttpService, getServers, getStatusCodeDescription, @@ -72,8 +74,8 @@ import { MetadataInfo, QueryParameterOptions, reportIfNoRoutes, + resolveAuthentication, resolveRequestVisibility, - ServiceAuthentication, Visibility, } from "@typespec/http"; import { @@ -274,7 +276,12 @@ function createOAPIEmitter( } } - function initializeEmitter(service: Service, version?: string) { + function initializeEmitter( + service: Service, + allHttpAuthentications: HttpAuth[], + defaultAuth: AuthenticationReference, + version?: string + ) { currentService = service; metadataInfo = createMetadataInfo(program, { canonicalVisibility: Visibility.Read, @@ -294,7 +301,8 @@ function createOAPIEmitter( } as any ); - const auth = processAuth(service.type); + const securitySchemes = getOpenAPISecuritySchemes(allHttpAuthentications); + const security = getOpenAPISecurity(defaultAuth); root = { openapi: "3.0.0", @@ -307,14 +315,14 @@ function createOAPIEmitter( externalDocs: getExternalDocs(program, service.type), tags: [], paths: {}, - security: auth?.security, + security: security.length > 0 ? security : undefined, components: { parameters: {}, requestBodies: {}, responses: {}, schemas: {}, examples: {}, - securitySchemes: auth?.securitySchemes ?? {}, + securitySchemes: securitySchemes, }, }; diagnostics = createDiagnosticCollector(); @@ -578,6 +586,7 @@ function createOAPIEmitter( verb: HttpVerb; parameters: HttpOperationParameters; bodies: HttpOperationRequestBody[] | undefined; + authentication?: Authentication; responses: Map; operations: Operation[]; } @@ -682,6 +691,7 @@ function createOAPIEmitter( parameters: [], }, bodies: undefined, + authentication: operations[0].authentication, responses: new Map(), }; for (const [paramName, ops] of paramMap) { @@ -729,16 +739,20 @@ function createOAPIEmitter( service: Service, version?: string ): Promise<[OpenAPI3Document, Readonly] | undefined> { - initializeEmitter(service, version); try { const httpService = ignoreDiagnostics(getHttpService(program, service.type)); + const auth = resolveAuthentication(httpService); + + initializeEmitter(service, auth.schemes, auth.defaultAuth, version); reportIfNoRoutes(program, httpService.operations); for (const op of resolveOperations(httpService.operations)) { if ((op as SharedHttpOperation).kind === "shared") { - emitSharedOperation(op as SharedHttpOperation); + const opAuth = auth.operationsAuth.get((op as SharedHttpOperation).operations[0]); + emitSharedOperation(op as SharedHttpOperation, opAuth); } else { - emitOperation(op as HttpOperation); + const opAuth = auth.operationsAuth.get((op as HttpOperation).operation); + emitOperation(op as HttpOperation, opAuth); } } emitParameters(); @@ -781,7 +795,10 @@ function createOAPIEmitter( } } - function emitSharedOperation(shared: SharedHttpOperation): void { + function emitSharedOperation( + shared: SharedHttpOperation, + authReference?: AuthenticationReference + ): void { const { path: fullPath, verb: verb, operations: ops } = shared; if (!root.paths[fullPath]) { root.paths[fullPath] = {}; @@ -845,9 +862,12 @@ function createOAPIEmitter( } attachExtensions(program, op, currentEndpoint); } + if (authReference) { + emitSecurity(authReference); + } } - function emitOperation(operation: HttpOperation): void { + function emitOperation(operation: HttpOperation, authReference?: AuthenticationReference): void { const { path: fullPath, operation: op, verb, parameters } = operation; // If path contains a query string, issue msg and don't emit this endpoint if (fullPath.indexOf("?") > 0) { @@ -877,10 +897,14 @@ function createOAPIEmitter( currentEndpoint.description = getDoc(program, operation.operation); currentEndpoint.parameters = []; currentEndpoint.responses = {}; + const visibility = resolveRequestVisibility(program, operation.operation, verb); emitEndpointParameters(parameters.parameters, visibility); emitRequestBody(parameters.body, visibility); emitResponses(operation.responses); + if (authReference) { + emitSecurity(authReference); + } if (isDeprecated(program, op)) { currentEndpoint.deprecated = true; } @@ -1599,53 +1623,52 @@ function createOAPIEmitter( } } - function processAuth(serviceNamespace: Namespace): - | { - securitySchemes: Record; - security: Record[]; + function getOpenAPISecuritySchemes( + httpAuthentications: HttpAuth[] + ): Record { + const schemes: Record = {}; + for (const httpAuth of httpAuthentications) { + const scheme = getOpenAPI3Scheme(httpAuth); + if (scheme) { + schemes[httpAuth.id] = scheme; } - | undefined { - const authentication = getAuthentication(program, serviceNamespace); - if (authentication) { - return processServiceAuthentication(authentication); } - return undefined; + return schemes; } - function processServiceAuthentication(authentication: ServiceAuthentication): { - securitySchemes: Record; - security: Record[]; - } { - const oaiSchemes: Record = {}; - const security: Record[] = []; - for (const option of authentication.options) { - const oai3SecurityOption: Record = {}; - for (const scheme of option.schemes) { - const result = getOpenAPI3Scheme(scheme); - if (result) { - oaiSchemes[scheme.id] = result.scheme; - oai3SecurityOption[scheme.id] = result.scopes; + function getOpenAPISecurity(authReference: AuthenticationReference) { + const security = authReference.options.map((authOption: AuthenticationOptionReference) => { + const securityOption: Record = {}; + for (const httpAuthRef of authOption.all) { + switch (httpAuthRef.kind) { + case "noAuth": + // should emit "{}" as a security option https://github.com/OAI/OpenAPI-Specification/issues/14#issuecomment-297457320 + continue; + case "oauth2": + securityOption[httpAuthRef.auth.id] = httpAuthRef.scopes; + continue; + default: + securityOption[httpAuthRef.auth.id] = []; } } - security.push(oai3SecurityOption); - } - return { securitySchemes: oaiSchemes, security }; + return securityOption; + }); + return security; } - function getOpenAPI3Scheme( - auth: HttpAuth - ): { scheme: OpenAPI3SecurityScheme; scopes: string[] } | undefined { + function emitSecurity(authReference: AuthenticationReference) { + const security = getOpenAPISecurity(authReference); + if (security.length > 0) { + currentEndpoint.security = security; + } + } + + function getOpenAPI3Scheme(auth: HttpAuth): OpenAPI3SecurityScheme | undefined { switch (auth.type) { case "http": - return { - scheme: { type: "http", scheme: auth.scheme, description: auth.description }, - scopes: [], - }; + return { type: "http", scheme: auth.scheme, description: auth.description }; case "apiKey": - return { - scheme: { type: "apiKey", in: auth.in, name: auth.name, description: auth.description }, - scopes: [], - }; + return { type: "apiKey", in: auth.in, name: auth.name, description: auth.description }; case "oauth2": const flows: OpenAPI3OAuthFlows = {}; const scopes: string[] = []; @@ -1658,16 +1681,15 @@ function createOAPIEmitter( scopes: Object.fromEntries(flow.scopes.map((x) => [x.value, x.description ?? ""])), }; } - return { scheme: { type: "oauth2", flows, description: auth.description }, scopes }; + return { type: "oauth2", flows, description: auth.description }; case "openIdConnect": return { - scheme: { - type: "openIdConnect", - openIdConnectUrl: auth.openIdConnectUrl, - description: auth.description, - }, - scopes: [], + type: "openIdConnect", + openIdConnectUrl: auth.openIdConnectUrl, + description: auth.description, }; + case "noAuth": + return undefined; default: diagnostics.add( createDiagnostic({ diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index b9d90993c..c554638db 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -662,6 +662,7 @@ export type OpenAPI3Operation = Extensions & { requestBody?: any; parameters: OpenAPI3Parameter[]; deprecated?: boolean; + security?: Record[]; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/openapi3/test/security.test.ts b/packages/openapi3/test/security.test.ts index 18edc0cc2..83381c2eb 100644 --- a/packages/openapi3/test/security.test.ts +++ b/packages/openapi3/test/security.test.ts @@ -161,4 +161,144 @@ describe("openapi3: security", () => { }, ]); }); + + it("can override security on methods of interface", async () => { + const res = await openApiFor( + ` + namespace Test; + alias ServiceKeyAuth = ApiKeyAuth; + + @service + @useAuth(ServiceKeyAuth) + @route("/my-service") + namespace MyService { + @route("/file") + @useAuth(ServiceKeyAuth | ApiKeyAuth) + interface FileManagement { + @route("/download") + op download(fileId: string): bytes; + } + } + ` + ); + deepStrictEqual(res.components.securitySchemes, { + ApiKeyAuth: { + in: "header", + name: "X-API-KEY", + type: "apiKey", + }, + ApiKeyAuth_: { + in: "query", + name: "token", + type: "apiKey", + }, + }); + deepStrictEqual(res.security, [ + { + ApiKeyAuth: [], + }, + ]); + deepStrictEqual(res.paths["/my-service/file/download"]["post"].security, [ + { + ApiKeyAuth: [], + }, + { + ApiKeyAuth_: [], + }, + ]); + }); + + it("can override security on methods of operation", async () => { + const res = await openApiFor( + ` + namespace Test; + + alias ServiceKeyAuth = ApiKeyAuth; + + @service + @useAuth(ServiceKeyAuth) + @route("/my-service") + namespace MyService { + @useAuth(NoAuth | ServiceKeyAuth | ApiKeyAuth) + @route("download") + op download(fileId: string): bytes; + } + ` + ); + deepStrictEqual(res.components.securitySchemes, { + ApiKeyAuth: { + in: "header", + name: "X-API-KEY", + type: "apiKey", + }, + ApiKeyAuth_: { + in: "query", + name: "token", + type: "apiKey", + }, + }); + deepStrictEqual(res.security, [ + { + ApiKeyAuth: [], + }, + ]); + deepStrictEqual(res.paths["/my-service/download"]["post"].security, [ + {}, + { + ApiKeyAuth: [], + }, + { + ApiKeyAuth_: [], + }, + ]); + }); + + it("can override Oauth2 scopes on operation", async () => { + const res = await openApiFor( + ` + namespace Test; + + alias MyOauth = OAuth2Auth; + + @route("/my-service") + @useAuth(MyOauth<["read", "write"]>) + @service + namespace MyService { + @route("/delete") + @useAuth(MyOauth<["delete"]>) + @post op delete(): void; + } + ` + ); + deepStrictEqual(res.components.securitySchemes, { + OAuth2Auth: { + type: "oauth2", + flows: { + implicit: { + authorizationUrl: "https://api.example.com/oauth2/authorize", + refreshUrl: "https://api.example.com/oauth2/refresh", + scopes: { + read: "", + write: "", + delete: "", + }, + }, + }, + }, + }); + deepStrictEqual(res.security, [ + { + OAuth2Auth: ["read", "write"], + }, + ]); + deepStrictEqual(res.paths["/my-service/delete"]["post"].security, [ + { + OAuth2Auth: ["delete"], + }, + ]); + }); }); diff --git a/packages/samples/specs/authentication/interface-auth.tsp b/packages/samples/specs/authentication/interface-auth.tsp new file mode 100644 index 000000000..d0c472b95 --- /dev/null +++ b/packages/samples/specs/authentication/interface-auth.tsp @@ -0,0 +1,20 @@ +import "@typespec/rest"; + +using TypeSpec.Http; + +@service({ + title: "Authenticated service with interface override", +}) +@useAuth(BearerAuth) +namespace TypeSpec.InterfaceAuth; + +// requires BearerAuth +@route("/one") +op one(): void; + +@useAuth(BasicAuth) +interface Sample { + // requires BasicAuth + @route("/two") + two(): void; +} diff --git a/packages/samples/specs/authentication/main.tsp b/packages/samples/specs/authentication/main.tsp index 9ea76fc36..322f5b179 100644 --- a/packages/samples/specs/authentication/main.tsp +++ b/packages/samples/specs/authentication/main.tsp @@ -1,24 +1,3 @@ -import "@typespec/rest"; - -using TypeSpec.Http; - -@service({ - title: "Authenticated service", -}) -@useAuth( - // Here authentication can either be a - // - ApiKey AND Basic Auth together - // - Bearer token - // - OAuth2 - BearerAuth | [ApiKeyAuth, BasicAuth] | OAuth2Auth<[MyFlow]> -) -namespace TypeSpec.Samples; - -model MyFlow { - type: OAuth2FlowType.implicit; - authorizationUrl: "https://api.example.com/oauth2/authorize"; - refreshUrl: "https://api.example.com/oauth2/refresh"; - scopes: ["read", "write"]; -} - -op test(): string; +import "./interface-auth.tsp"; +import "./operation-auth.tsp"; +import "./service-auth.tsp"; diff --git a/packages/samples/specs/authentication/operation-auth.tsp b/packages/samples/specs/authentication/operation-auth.tsp new file mode 100644 index 000000000..21d7dd448 --- /dev/null +++ b/packages/samples/specs/authentication/operation-auth.tsp @@ -0,0 +1,34 @@ +import "@typespec/rest"; + +using TypeSpec.Http; + +@service({ + title: "Authenticated service with method override", +}) +@useAuth(BearerAuth | MyAuth<["read", "write"]>) +namespace TypeSpec.OperationAuth; + +alias MyAuth = OAuth2Auth< + Flows = [ + { + type: OAuth2FlowType.implicit; + authorizationUrl: "https://api.example.com/oauth2/authorize"; + refreshUrl: "https://api.example.com/oauth2/refresh"; + } + ], + Scopes = Scopes +>; + +// requires BearerAuth | MyAuth<["read", "write"]> +@route("/one") +op one(): void; + +// requires optional ApiKeyAuth +@useAuth(NoAuth | ApiKeyAuth) +@route("/two") +op two(): void; + +// requires MyAuth<"delete"> +@useAuth(MyAuth<["delete"]>) +@route("/three") +op three(): void; diff --git a/packages/samples/specs/authentication/service-auth.tsp b/packages/samples/specs/authentication/service-auth.tsp new file mode 100644 index 000000000..acb28e420 --- /dev/null +++ b/packages/samples/specs/authentication/service-auth.tsp @@ -0,0 +1,24 @@ +import "@typespec/rest"; + +using TypeSpec.Http; + +@service({ + title: "Authenticated service", +}) +@useAuth( + // Here authentication can either be a + // - ApiKey AND Basic Auth together + // - Bearer token + // - OAuth2 + BearerAuth | [ApiKeyAuth, BasicAuth] | OAuth2Auth<[MyFlow]> +) +namespace TypeSpec.ServiceAuth; + +model MyFlow { + type: OAuth2FlowType.implicit; + authorizationUrl: "https://api.example.com/oauth2/authorize"; + refreshUrl: "https://api.example.com/oauth2/refresh"; + scopes: ["read", "write"]; +} + +op test(): string; diff --git a/packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.InterfaceAuth.yaml b/packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.InterfaceAuth.yaml new file mode 100644 index 000000000..dc88e1db4 --- /dev/null +++ b/packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.InterfaceAuth.yaml @@ -0,0 +1,32 @@ +openapi: 3.0.0 +info: + title: Authenticated service with interface override + version: 0000-00-00 +tags: [] +paths: + /one: + get: + operationId: one + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + /two: + get: + operationId: Sample_two + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + security: + - BasicAuth: [] +security: + - BearerAuth: [] +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + BasicAuth: + type: http + scheme: basic diff --git a/packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.OperationAuth.yaml b/packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.OperationAuth.yaml new file mode 100644 index 000000000..dc13d3fa4 --- /dev/null +++ b/packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.OperationAuth.yaml @@ -0,0 +1,57 @@ +openapi: 3.0.0 +info: + title: Authenticated service with method override + version: 0000-00-00 +tags: [] +paths: + /one: + get: + operationId: one + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + /three: + get: + operationId: three + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + security: + - OAuth2Auth: + - delete + /two: + get: + operationId: two + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + security: + - {} + - ApiKeyAuth: [] +security: + - BearerAuth: [] + - OAuth2Auth: + - read + - write +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + OAuth2Auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://api.example.com/oauth2/authorize + refreshUrl: https://api.example.com/oauth2/refresh + scopes: + read: '' + write: '' + delete: '' + ApiKeyAuth: + type: apiKey + in: header + name: x-my-header diff --git a/packages/samples/test/output/authentication/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.ServiceAuth.yaml similarity index 100% rename from packages/samples/test/output/authentication/@typespec/openapi3/openapi.yaml rename to packages/samples/test/output/authentication/@typespec/openapi3/openapi.TypeSpec.ServiceAuth.yaml