diff --git a/docs/libraries/http/reference/data-types.md b/docs/libraries/http/reference/data-types.md index ef32d587f6..89d243e5fb 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. diff --git a/docs/libraries/http/reference/decorators.md b/docs/libraries/http/reference/decorators.md index 29d75b1c02..db907c0fe2 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 9c6cf9c172..035944359b 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 b35b6e2ea3..099ef915ba 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 465d8e6993..61d20cfb03 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, } /** @@ -212,3 +215,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 b73c8ba534..79755250e7 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 0000000000..e44f852ac6 --- /dev/null +++ b/packages/http/src/auth.ts @@ -0,0 +1,15 @@ +import { Operation, Program } from "@typespec/compiler"; +import { HttpStateKeys } from "./lib.js"; +import { Authentication } 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; +} diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 4777f05130..da43c56153 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()) { @@ -578,9 +579,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/operations.ts b/packages/http/src/operations.ts index 720c82f858..6c9c584568 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 c6dbfcb48f..fd60d2d5b4 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -16,7 +16,7 @@ export type OperationDetails = HttpOperation; export type HttpVerb = "get" | "put" | "post" | "patch" | "delete" | "head"; -export interface ServiceAuthentication { +export interface Authentication { /** * Either one of those options can be used independently to authenticate. */ @@ -35,7 +35,8 @@ export type HttpAuth = | BearerAuth | ApiKeyAuth | Oauth2Auth - | OpenIDConnectAuth; + | OpenIDConnectAuth + | NoAuth; export interface HttpAuthBase { /** @@ -184,6 +185,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 +296,7 @@ export interface HttpOperationParameters { export interface HttpService { namespace: Namespace; operations: HttpOperation[]; + authentication?: Authentication; } export interface HttpOperation { @@ -325,6 +335,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 8aafa986b6..0b132a0d92 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,26 @@ describe("http: decorators", () => { }); }); + 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 +859,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 8f85bc6134..aad060294e 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -46,6 +46,7 @@ import { import { AssetEmitter, EmitEntity } from "@typespec/compiler/emitter-framework"; import { + Authentication, createMetadataInfo, getAuthentication, getHttpService, @@ -69,7 +70,6 @@ import { QueryParameterOptions, reportIfNoRoutes, resolveRequestVisibility, - ServiceAuthentication, Visibility, } from "@typespec/http"; import { @@ -91,6 +91,7 @@ import { getDefaultValue, OpenAPI3SchemaEmitter } from "./schema-emitter.js"; import { OpenAPI3Document, OpenAPI3Header, + OpenAPI3OAuth2SecurityScheme, OpenAPI3OAuthFlows, OpenAPI3Operation, OpenAPI3Parameter, @@ -456,6 +457,7 @@ function createOAPIEmitter( verb: HttpVerb; parameters: HttpOperationParameters; bodies: HttpOperationRequestBody[] | undefined; + authentication?: Authentication; responses: Map; operations: Operation[]; } @@ -558,6 +560,7 @@ function createOAPIEmitter( parameters: [], }, bodies: undefined, + authentication: operations[0].authentication, responses: new Map(), }; for (const [paramName, ops] of paramMap) { @@ -760,10 +763,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 (operation.authentication) { + emitSecurity(operation.authentication); + } if (isDeprecated(program, op)) { currentEndpoint.deprecated = true; } @@ -1480,12 +1487,15 @@ function createOAPIEmitter( | undefined { const authentication = getAuthentication(program, serviceNamespace); if (authentication) { - return processServiceAuthentication(authentication); + return getSecurity(authentication); } return undefined; } - function processServiceAuthentication(authentication: ServiceAuthentication): { + function getSecurity( + authentication: Authentication, + existingSchemes: Record = {} + ): { securitySchemes: Record; security: Record[]; } { @@ -1493,18 +1503,101 @@ function createOAPIEmitter( const security: Record[] = []; for (const option of authentication.options) { const oai3SecurityOption: Record = {}; + let noAuth = false; for (const scheme of option.schemes) { const result = getOpenAPI3Scheme(scheme); if (result) { - oaiSchemes[scheme.id] = result.scheme; - oai3SecurityOption[scheme.id] = result.scopes; + const existingScheme = existingSchemes[scheme.id]; + let schemeId = scheme.id; + if (existingScheme) { + // If we've seen a different scheme by this id, + // Make sure to not overwrite it in resulting OpenAPI spec. + if (!openAPI3SecuritySchemesAreEqual(existingScheme, result.scheme)) { + while (existingSchemes[schemeId]) { + schemeId = scheme.id + "_"; + } + } + // Merging scopes when encountering the same Oauth2 scheme + else if (existingScheme.type === "oauth2" && result.scheme.type === "oauth2") { + const scopes = getOauth2Scopes(existingScheme); + for (const [name, descr] of Object.entries(getOauth2Scopes(result.scheme))) { + scopes[name] = descr; + } + result.scheme = setOauth2Scopes(result.scheme, scopes); + } + } + if (scheme.type === "noAuth") { + noAuth = true; + continue; + } + oaiSchemes[schemeId] = result.scheme; + oai3SecurityOption[schemeId] = result.scopes; } } - security.push(oai3SecurityOption); + if (noAuth) { + security.push({}); + } else { + security.push(oai3SecurityOption); + } } return { securitySchemes: oaiSchemes, security }; } + function openAPI3SecuritySchemesAreEqual( + scheme1: OpenAPI3SecurityScheme, + scheme2: OpenAPI3SecurityScheme + ): boolean { + if (scheme1.type === "oauth2" && scheme2.type === "oauth2") { + return deepEquals(setOauth2Scopes(scheme1, {}), setOauth2Scopes(scheme2, {})); + } + return deepEquals(scheme1, scheme2); + } + + function setOauth2Scopes( + scheme: OpenAPI3OAuth2SecurityScheme, + scopes: Record + ): OpenAPI3OAuth2SecurityScheme { + const f = scheme.flows; + return { + ...scheme, + flows: { + implicit: f.implicit ? { ...f.implicit, scopes: scopes } : undefined, + password: f.password ? { ...f.password, scopes: scopes } : undefined, + clientCredentials: f.clientCredentials + ? { ...f.clientCredentials, scopes: scopes } + : undefined, + authorizationCode: f.authorizationCode + ? { ...f.authorizationCode, scopes: scopes } + : undefined, + }, + }; + } + + function getOauth2Scopes(scheme: OpenAPI3OAuth2SecurityScheme): Record { + const f = scheme.flows; + return ( + f.implicit?.scopes ?? + f.password?.scopes ?? + f.clientCredentials?.scopes ?? + f.authorizationCode?.scopes ?? + {} + ); + } + + function emitSecurity(authentication: Authentication) { + if (root.components === undefined) return; + const existingSchemes = root.components.securitySchemes || {}; + const operationSecurity = getSecurity(authentication, existingSchemes); + + for (const [name, schema] of Object.entries(operationSecurity.securitySchemes)) { + existingSchemes[name] = schema; + } + if (operationSecurity.security.length > 0) { + currentEndpoint.security = operationSecurity.security; + } + root.components.securitySchemes = existingSchemes; + } + function getOpenAPI3Scheme( auth: HttpAuth ): { scheme: OpenAPI3SecurityScheme; scopes: string[] } | undefined { @@ -1541,6 +1634,13 @@ function createOAPIEmitter( }, scopes: [], }; + case "noAuth": + return { + scheme: { + type: "noAuth", + }, + scopes: [], + }; default: reportDiagnostic(program, { code: "unsupported-auth", diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index c21fd0ead4..b9c64fdd76 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -166,7 +166,8 @@ export type OpenAPI3SecurityScheme = | OpenAPI3ApiKeySecurityScheme | OpenAPI3OAuth2SecurityScheme | OpenAPI3OpenIdConnectSecurityScheme - | OpenAPI3HttpSecurityScheme; + | OpenAPI3HttpSecurityScheme + | OpenAPI3NoAuthSecurityScheme; /** * defines a security scheme that can be used by the operations. Supported schemes are HTTP authentication, an API key (either as a header, a cookie parameter or as a query parameter), OAuth2's common flows (implicit, password, application and access code) as defined in RFC6749, and OpenID Connect Discovery. @@ -206,6 +207,14 @@ export interface OpenAPI3HttpSecurityScheme extends OpenAPI3SecuritySchemeBase { bearerFormat?: string; } +/** + * defines a security scheme that signifies that authentication is optional + * https://github.com/OAI/OpenAPI-Specification/issues/14#issuecomment-297457320 + */ +export interface OpenAPI3NoAuthSecurityScheme extends OpenAPI3SecuritySchemeBase { + type: "noAuth"; +} + /** * defines an OAuth2 security scheme that can be used by the operations */ @@ -610,6 +619,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 18edc0cc24..ab3d6d9591 100644 --- a/packages/openapi3/test/security.test.ts +++ b/packages/openapi3/test/security.test.ts @@ -161,4 +161,146 @@ 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 ServiceFlow = { + type: OAuth2FlowType.implicit; + authorizationUrl: "https://api.example.com/oauth2/authorize"; + refreshUrl: "https://api.example.com/oauth2/refresh"; + scopes: T; + }; + alias MyOauth = OAuth2Auth<[ServiceFlow]>; + + @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 0000000000..d0c472b955 --- /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 9ea76fc362..322f5b1799 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 0000000000..e97a56d387 --- /dev/null +++ b/packages/samples/specs/authentication/operation-auth.tsp @@ -0,0 +1,31 @@ +import "@typespec/rest"; + +using TypeSpec.Http; + +@service({ + title: "Authenticated service with method override", +}) +@useAuth(BearerAuth | MyAuth<["read", "write"]>) +namespace TypeSpec.OperationAuth; + +model MyFlow { + type: OAuth2FlowType.implicit; + authorizationUrl: "https://api.example.com/oauth2/authorize"; + refreshUrl: "https://api.example.com/oauth2/refresh"; + scopes: Scopes; +} +alias MyAuth = OAuth2Auth<[MyFlow]>; + +// 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 0000000000..acb28e4203 --- /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 0000000000..dc88e1db48 --- /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 0000000000..dc13d3fa45 --- /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