From 8ed1d82e7ccb0a8b72ae902149b86d68ac684872 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 5 Feb 2024 14:38:55 -0800 Subject: [PATCH] Add support for openIdConnect auth scheme (#2811) fix [#2774](https://github.com/microsoft/typespec/issues/2774) --- .changeset/light-countries-cross.md | 5 ++ .changeset/shiny-crabs-guess.md | 5 ++ cspell.yaml | 1 + docs/libraries/http/reference/data-types.md | 21 ++++++++ docs/libraries/http/reference/index.mdx | 1 + packages/http/lib/auth.tsp | 18 +++++++ packages/http/package.json | 1 + packages/http/src/types.ts | 18 ++++++- packages/openapi3/src/lib.ts | 6 +++ packages/openapi3/src/openapi.ts | 55 ++++++++++++++------- packages/openapi3/test/security.test.ts | 20 +++++++- 11 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 .changeset/light-countries-cross.md create mode 100644 .changeset/shiny-crabs-guess.md diff --git a/.changeset/light-countries-cross.md b/.changeset/light-countries-cross.md new file mode 100644 index 0000000000..409bd56145 --- /dev/null +++ b/.changeset/light-countries-cross.md @@ -0,0 +1,5 @@ +--- +"@typespec/http": patch +--- + +Fix: OpenIDConnect types not exposed on the TypeScript types diff --git a/.changeset/shiny-crabs-guess.md b/.changeset/shiny-crabs-guess.md new file mode 100644 index 0000000000..3494a50e0d --- /dev/null +++ b/.changeset/shiny-crabs-guess.md @@ -0,0 +1,5 @@ +--- +"@typespec/openapi3": patch +--- + +Add support for OpenIdConnect auth scheme diff --git a/cspell.yaml b/cspell.yaml index e987c3b256..3797dc0bcc 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -49,6 +49,7 @@ words: - nostdlib - npmjs - oapi + - OIDC - oneof - onig - onigasm diff --git a/docs/libraries/http/reference/data-types.md b/docs/libraries/http/reference/data-types.md index 488d487263..ef32d587f6 100644 --- a/docs/libraries/http/reference/data-types.md +++ b/docs/libraries/http/reference/data-types.md @@ -224,6 +224,27 @@ The request has succeeded. model TypeSpec.Http.OkResponse ``` +### `OpenIdConnectAuth` {#TypeSpec.Http.OpenIdConnectAuth} + +OpenID Connect (OIDC) is an identity layer built on top of the OAuth 2.0 protocol and supported by some OAuth 2.0 providers, such as Google and Azure Active Directory. +It defines a sign-in flow that enables a client application to authenticate a user, and to obtain information (or "claims") about that user, such as the user name, email, and so on. +User identity information is encoded in a secure JSON Web Token (JWT), called ID token. +OpenID Connect defines a discovery mechanism, called OpenID Connect Discovery, where an OpenID server publishes its metadata at a well-known URL, typically + +```http +https://server.com/.well-known/openid-configuration +``` + +```typespec +model TypeSpec.Http.OpenIdConnectAuth +``` + +#### Template Parameters + +| Name | Description | +| ---------- | ----------- | +| ConnectUrl | | + ### `PasswordFlow` {#TypeSpec.Http.PasswordFlow} Resource Owner Password flow diff --git a/docs/libraries/http/reference/index.mdx b/docs/libraries/http/reference/index.mdx index 725b01bad4..9c6cf9c172 100644 --- a/docs/libraries/http/reference/index.mdx +++ b/docs/libraries/http/reference/index.mdx @@ -74,6 +74,7 @@ npm install --save-peer @typespec/http - [`NotModifiedResponse`](./data-types.md#TypeSpec.Http.NotModifiedResponse) - [`OAuth2Auth`](./data-types.md#TypeSpec.Http.OAuth2Auth) - [`OkResponse`](./data-types.md#TypeSpec.Http.OkResponse) +- [`OpenIdConnectAuth`](./data-types.md#TypeSpec.Http.OpenIdConnectAuth) - [`PasswordFlow`](./data-types.md#TypeSpec.Http.PasswordFlow) - [`PlainData`](./data-types.md#TypeSpec.Http.PlainData) - [`QueryOptions`](./data-types.md#TypeSpec.Http.QueryOptions) diff --git a/packages/http/lib/auth.tsp b/packages/http/lib/auth.tsp index 3ee66aebee..28a8942e97 100644 --- a/packages/http/lib/auth.tsp +++ b/packages/http/lib/auth.tsp @@ -194,3 +194,21 @@ model ClientCredentialsFlow { @doc("list of scopes for the credential") scopes: string[]; } + +/** + * OpenID Connect (OIDC) is an identity layer built on top of the OAuth 2.0 protocol and supported by some OAuth 2.0 providers, such as Google and Azure Active Directory. + * It defines a sign-in flow that enables a client application to authenticate a user, and to obtain information (or "claims") about that user, such as the user name, email, and so on. + * User identity information is encoded in a secure JSON Web Token (JWT), called ID token. + * OpenID Connect defines a discovery mechanism, called OpenID Connect Discovery, where an OpenID server publishes its metadata at a well-known URL, typically + * + * ```http + * https://server.com/.well-known/openid-configuration + * ``` + */ +model OpenIdConnectAuth { + /** Auth type */ + type: AuthType.openIdConnect; + + /** Connect url. It can be specified relative to the server URL */ + openIdConnectUrl: ConnectUrl; +} diff --git a/packages/http/package.json b/packages/http/package.json index 6f783df09d..0f6182f24c 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -39,6 +39,7 @@ "watch": "tsc -p . --watch", "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", "test": "vitest run", + "test:watch": "vitest -w", "test:ui": "vitest --ui", "test-official": "vitest run --coverage --reporter=junit --reporter=default --no-file-parallelism", "lint": "eslint . --ext .ts --max-warnings=0", diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 694d4d0e9e..c6dbfcb48f 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -34,7 +34,8 @@ export type HttpAuth = | BasicAuth | BearerAuth | ApiKeyAuth - | Oauth2Auth; + | Oauth2Auth + | OpenIDConnectAuth; export interface HttpAuthBase { /** @@ -168,6 +169,21 @@ export interface OAuth2Scope { description?: string; } +/** + * OpenID Connect (OIDC) is an identity layer built on top of the OAuth 2.0 protocol and supported by some OAuth 2.0 providers, such as Google and Azure Active Directory. + * It defines a sign-in flow that enables a client application to authenticate a user, and to obtain information (or "claims") about that user, such as the user name, email, and so on. + * User identity information is encoded in a secure JSON Web Token (JWT), called ID token. + * OpenID Connect defines a discovery mechanism, called OpenID Connect Discovery, where an OpenID server publishes its metadata at a well-known URL, typically + * + * ```http + * https://server.com/.well-known/openid-configuration + * ``` + */ +export interface OpenIDConnectAuth extends HttpAuthBase { + type: "openIdConnect"; + openIdConnectUrl: string; +} + export type OperationContainer = Namespace | Interface; export type OperationVerbSelector = ( diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index feca60d2f3..45b3ccffc5 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -230,6 +230,12 @@ export const libDef = { default: paramMessage`Status code range '${"start"} to '${"end"}' is not supported. OpenAPI 3.0 can only represent range 1XX, 2XX, 3XX, 4XX and 5XX. Example: \`@minValue(400) @maxValue(499)\` for 4XX.`, }, }, + "unsupported-auth": { + severity: "warning", + messages: { + default: paramMessage`Authentication "${"authType"}" is not a known authentication by the openapi3 emitter, it will be ignored.`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index c0fcc7091c..bef13007b3 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -169,9 +169,9 @@ function createOAPIEmitter( let schemaEmitter: AssetEmitter; let root: OpenAPI3Document; - + let currentService: Service; // Get the service namespace string for use in name shortening - let serviceNamespace: string | undefined; + let serviceNamespaceName: string | undefined; let currentPath: any; let currentEndpoint: OpenAPI3Operation; @@ -194,13 +194,14 @@ function createOAPIEmitter( // shorten type names by removing TypeSpec and service namespace namespaceFilter(ns) { const name = getNamespaceFullName(ns); - return name !== serviceNamespace; + return name !== serviceNamespaceName; }, }; return { emitOpenAPI }; function initializeEmitter(service: Service, version?: string) { + currentService = service; metadataInfo = createMetadataInfo(program, { canonicalVisibility: Visibility.Read, canShareProperty: (p) => isReadonlyProperty(program, p), @@ -247,7 +248,7 @@ function createOAPIEmitter( root.servers = resolveServers(servers); } - serviceNamespace = getNamespaceFullName(service.type); + serviceNamespaceName = getNamespaceFullName(service.type); currentPath = root.paths; params = new Map(); @@ -957,7 +958,7 @@ function createOAPIEmitter( } contentType = contentType === "application/json" ? undefined : contentType; return schemaEmitter.emitType(type, { - referenceContext: { visibility, serviceNamespaceName: serviceNamespace, contentType }, + referenceContext: { visibility, serviceNamespaceName: serviceNamespaceName, contentType }, }) as any; } @@ -1538,24 +1539,31 @@ function createOAPIEmitter( for (const option of authentication.options) { const oai3SecurityOption: Record = {}; for (const scheme of option.schemes) { - const [oaiScheme, scopes] = getOpenAPI3Scheme(scheme); - oaiSchemes[scheme.id] = oaiScheme; - oai3SecurityOption[scheme.id] = scopes; + const result = getOpenAPI3Scheme(scheme); + if (result) { + oaiSchemes[scheme.id] = result.scheme; + oai3SecurityOption[scheme.id] = result.scopes; + } } security.push(oai3SecurityOption); } return { securitySchemes: oaiSchemes, security }; } - function getOpenAPI3Scheme(auth: HttpAuth): [OpenAPI3SecurityScheme, string[]] { + function getOpenAPI3Scheme( + auth: HttpAuth + ): { scheme: OpenAPI3SecurityScheme; scopes: string[] } | undefined { switch (auth.type) { case "http": - return [{ type: "http", scheme: auth.scheme, description: auth.description }, []]; + return { + scheme: { type: "http", scheme: auth.scheme, description: auth.description }, + scopes: [], + }; case "apiKey": - return [ - { type: "apiKey", in: auth.in, name: auth.name, description: auth.description }, - [], - ]; + return { + scheme: { type: "apiKey", in: auth.in, name: auth.name, description: auth.description }, + scopes: [], + }; case "oauth2": const flows: OpenAPI3OAuthFlows = {}; const scopes: string[] = []; @@ -1568,10 +1576,23 @@ function createOAPIEmitter( scopes: Object.fromEntries(flow.scopes.map((x) => [x.value, x.description ?? ""])), }; } - return [{ type: "oauth2", flows, description: auth.description }, scopes]; + return { scheme: { type: "oauth2", flows, description: auth.description }, scopes }; + case "openIdConnect": + return { + scheme: { + type: "openIdConnect", + openIdConnectUrl: auth.openIdConnectUrl, + description: auth.description, + }, + scopes: [], + }; default: - const _assertNever: never = auth; - compilerAssert(false, "Unreachable"); + reportDiagnostic(program, { + code: "unsupported-auth", + format: { authType: (auth as any).type }, + target: currentService.type, + }); + return undefined; } } } diff --git a/packages/openapi3/test/security.test.ts b/packages/openapi3/test/security.test.ts index 0d6d04729d..18edc0cc24 100644 --- a/packages/openapi3/test/security.test.ts +++ b/packages/openapi3/test/security.test.ts @@ -1,5 +1,5 @@ import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { openApiFor } from "./test-host.js"; describe("openapi3: security", () => { @@ -89,6 +89,24 @@ describe("openapi3: security", () => { deepStrictEqual(res.security, [{ OAuth2Auth: ["read", "write"] }]); }); + it("set openId auth", async () => { + const res = await openApiFor( + ` + @service + @useAuth(OpenIdConnectAuth<"https://api.example.com/openid">) + namespace MyService {} + ` + ); + expect(res.components.securitySchemes).toEqual({ + OpenIdConnectAuth: { + type: "openIdConnect", + openIdConnectUrl: "https://api.example.com/openid", + description: expect.stringMatching(/^OpenID Connect/), + }, + }); + deepStrictEqual(res.security, [{ OpenIdConnectAuth: [] }]); + }); + it("can specify custom auth name with description", async () => { const res = await openApiFor( `