From 3fd384182cdcfb2835c538922d7320355a1a6caa Mon Sep 17 00:00:00 2001 From: Christian Tellnes Date: Thu, 2 Jul 2026 18:32:13 +0200 Subject: [PATCH] Add scope support to OpenIdConnectAuth The model now accepts an optional `Scopes` template parameter so operations can declare required scopes. The OpenAPI3 emitter emits those scopes on each operation's security requirement while the scheme object itself remains unchanged. Existing usages without scopes are unaffected. --- ...idconnect-auth-scopes-2026-6-25-22-45-0.md | 8 ++++ packages/http/lib/auth.tsp | 8 +++- packages/http/src/auth.ts | 14 +++++- packages/http/src/decorators.ts | 10 ++++- packages/http/src/types.ts | 8 ++++ packages/http/test/auth.test.ts | 29 ++++++++++++ packages/openapi3/src/openapi.ts | 4 +- packages/openapi3/test/security.test.ts | 44 +++++++++++++++++++ .../docs/libraries/http/authentication.md | 25 +++++++++++ .../libraries/http/reference/data-types.md | 18 ++++---- 10 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 .chronus/changes/openidconnect-auth-scopes-2026-6-25-22-45-0.md diff --git a/.chronus/changes/openidconnect-auth-scopes-2026-6-25-22-45-0.md b/.chronus/changes/openidconnect-auth-scopes-2026-6-25-22-45-0.md new file mode 100644 index 00000000000..0a02e0a5e03 --- /dev/null +++ b/.chronus/changes/openidconnect-auth-scopes-2026-6-25-22-45-0.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@typespec/http" + - "@typespec/openapi3" +--- + +Add scope support to `OpenIdConnectAuth`. The model now accepts an optional `Scopes` template parameter (`OpenIdConnectAuth`) and the OpenAPI3 emitter emits those scopes on each operation's `openIdConnect` security requirement. The scheme object itself remains unchanged (scopes are discovered via the `openIdConnectUrl`). Existing `OpenIdConnectAuth` usages are unaffected. diff --git a/packages/http/lib/auth.tsp b/packages/http/lib/auth.tsp index cc643eac10e..0b3920e1573 100644 --- a/packages/http/lib/auth.tsp +++ b/packages/http/lib/auth.tsp @@ -211,13 +211,19 @@ model ClientCredentialsFlow { * ```http * https://server.com/.well-known/openid-configuration * ``` + * + * @template ConnectUrl The openIdConnectUrl, where the OpenID provider publishes its discovery metadata. It can be specified relative to the server URL. + * @template Scopes The scope names required for operations that use this scheme. */ -model OpenIdConnectAuth { +model OpenIdConnectAuth { /** Auth type */ type: AuthType.openIdConnect; /** Connect url. It can be specified relative to the server URL */ openIdConnectUrl: ConnectUrl; + + /** Scope names required for operations that use this authentication scheme. */ + scopes: Scopes; } /** diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index a667ed984c8..a0aa8e7cbac 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -122,7 +122,12 @@ function makeHttpAuthRef(local: HttpAuth, reference: HttpAuth): HttpAuthRef { } else if (reference.type === "noAuth") { return { kind: "noAuth", auth: reference }; } else { - return { kind: "any", auth: reference }; + // Requirement scopes are read from the per-option (`local`) scheme so that + // operation-level `@useAuth` can request a different scope subset than the + // service default. Only openIdConnect currently surfaces scopes; the ref is + // scheme-agnostic so other scheme types can opt in without a new ref kind. + const scopes = local.type === "openIdConnect" ? (local.scopes ?? []) : []; + return { kind: "any", auth: reference, scopes }; } } @@ -161,5 +166,12 @@ function authsAreEqual(scheme1: HttpAuth, scheme2: HttpAuth): boolean { if (withoutModel1.type === "oauth2" && withoutModel2.type === "oauth2") { return deepEquals(ignoreScopes(withoutModel1), ignoreScopes(withoutModel2)); } + // Scopes live on the security requirement, not on the scheme identity, so two + // openIdConnect schemes that differ only by scopes are the same scheme and + // must dedupe to a single id (per-requirement scopes are still carried on the + // auth ref). No scope merge is needed because the scheme object lists no scopes. + if (withoutModel1.type === "openIdConnect" && withoutModel2.type === "openIdConnect") { + return deepEquals({ ...withoutModel1, scopes: [] }, { ...withoutModel2, scopes: [] }); + } return deepEquals(withoutModel1, withoutModel2); } diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 8eebcd529c6..407849a0b7f 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -659,7 +659,15 @@ function extractHttpAuthentication( const auth = result.type === "oauth2" ? extractOAuth2Auth(modelType, result) - : { ...result, model: modelType }; + : { + ...result, + // OpenID Connect requirement scopes come from the `scopes` tuple on the + // model. Normalize to an array so downstream resolution can rely on it. + ...(result.type === "openIdConnect" && { + scopes: Array.isArray((result as any).scopes) ? (result as any).scopes : [], + }), + model: modelType, + }; return [ { ...auth, diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 86486e93a16..6012f1d8817 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -188,6 +188,8 @@ export interface OAuth2Scope { export interface OpenIDConnectAuth extends HttpAuthBase { type: "openIdConnect"; openIdConnectUrl: string; + /** Scope names required for operations that use this scheme. */ + scopes: string[]; } /** @@ -203,6 +205,12 @@ export type HttpAuthRef = AnyHttpAuthRef | OAuth2HttpAuthRef | NoHttpAuthRef; export interface AnyHttpAuthRef { readonly kind: "any"; readonly auth: HttpAuth; + /** + * Scope names required for this scheme in the containing auth option. Empty + * for schemes that do not carry scopes. Populated for `openIdConnect`; kept + * scheme-agnostic so other scheme types can carry scopes without a new ref kind. + */ + readonly scopes: string[]; } export interface NoHttpAuthRef { diff --git a/packages/http/test/auth.test.ts b/packages/http/test/auth.test.ts index 1dab123c2f6..4d438ef331a 100644 --- a/packages/http/test/auth.test.ts +++ b/packages/http/test/auth.test.ts @@ -124,3 +124,32 @@ it("should deduplicate scopes when multiple flows share the same scopes", async expect(oauthRef.scopes).toHaveLength(1); } }); + +it("carries scopes on the auth ref for OpenIdConnectAuth", async () => { + const { program } = await Tester.compile(` + model oidc + is OpenIdConnectAuth<"https://example.org/.well-known/openid-configuration", Scopes>; + + @useAuth(oidc<["read", "write"]>) + @test op testOp(): void; + `); + + const [services] = getAllHttpServices(program); + const httpService = services[0]; + const auth = resolveAuthentication(httpService); + + const testOp = httpService.operations.find((op) => op.operation.name === "testOp"); + ok(testOp, "Should find test operation"); + + const operationAuth = auth.operationsAuth.get(testOp.operation); + ok(operationAuth, "Should have operation auth"); + + const ref = operationAuth.options[0].all[0]; + // OIDC routes through the scheme-agnostic `any` ref which carries the + // requirement scopes. + strictEqual(ref.kind, "any"); + strictEqual(ref.auth.type, "openIdConnect"); + if (ref.kind === "any") { + expect(ref.scopes).toEqual(["read", "write"]); + } +}); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index ad085c74e47..ca569f79da7 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1985,7 +1985,9 @@ function createOAPIEmitter( securityOption[httpAuthRef.auth.id] = httpAuthRef.scopes; continue; default: - securityOption[httpAuthRef.auth.id] = []; + // Requirement scopes for any scheme that carries them (e.g. + // openIdConnect). Schemes without scopes resolve to an empty array. + securityOption[httpAuthRef.auth.id] = httpAuthRef.scopes; } } return securityOption; diff --git a/packages/openapi3/test/security.test.ts b/packages/openapi3/test/security.test.ts index 0380635afa3..5c7583ef6e8 100644 --- a/packages/openapi3/test/security.test.ts +++ b/packages/openapi3/test/security.test.ts @@ -247,6 +247,50 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, openApiFor }) => { deepStrictEqual(res.security, [{ OpenIdConnectAuth: [] }]); }); + it("set openId auth with scopes", async () => { + const res = await openApiFor( + ` + @service + @useAuth(OpenIdConnectAuth<"https://api.example.com/openid", ["read", "write"]>) + namespace MyService {} + `, + ); + // The scheme object must NOT list scopes (clients discover them via the + // openIdConnectUrl); only the security requirement lists required scopes. + expect(res.components.securitySchemes).toEqual({ + OpenIdConnectAuth: { + type: "openIdConnect", + openIdConnectUrl: "https://api.example.com/openid", + description: expect.stringMatching(/^OpenID Connect/), + }, + }); + deepStrictEqual(res.security, [{ OpenIdConnectAuth: ["read", "write"] }]); + }); + + it("set openId auth scopes at the operation level", async () => { + const res = await openApiFor( + ` + @service + @useAuth(OpenIdConnectAuth<"https://api.example.com/openid", ["read"]>) + namespace MyService { + @route("/a") + @useAuth(OpenIdConnectAuth<"https://api.example.com/openid", ["read", "write"]>) + op a(): void; + } + `, + ); + // Same OIDC scheme (differs only by scopes) must dedupe to a single scheme. + expect(res.components.securitySchemes).toEqual({ + OpenIdConnectAuth: { + type: "openIdConnect", + openIdConnectUrl: "https://api.example.com/openid", + description: expect.stringMatching(/^OpenID Connect/), + }, + }); + deepStrictEqual(res.security, [{ OpenIdConnectAuth: ["read"] }]); + deepStrictEqual(res.paths["/a"].get.security, [{ OpenIdConnectAuth: ["read", "write"] }]); + }); + it("set a unsupported auth", async () => { const diagnostics = await diagnoseOpenApiFor( ` diff --git a/website/src/content/docs/docs/libraries/http/authentication.md b/website/src/content/docs/docs/libraries/http/authentication.md index 5b6532f7ae3..aacf76ff38e 100644 --- a/website/src/content/docs/docs/libraries/http/authentication.md +++ b/website/src/content/docs/docs/libraries/http/authentication.md @@ -150,6 +150,31 @@ OAuth relies on authentication scenarios called flows, which allow the resource For this purpose, an OAuth 2.0 server issues access tokens that client applications can use to access protected resources on behalf of the resource owner. For more information about OAuth 2.0, see [oauth.net](https://oauth.net) and [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749). +### `OpenIdConnectAuth` + +OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. The provider publishes its metadata (including its authorization and token endpoints) at a well-known discovery URL, so the scheme only needs that URL rather than explicit flow endpoints. + +```typespec +@useAuth(OpenIdConnectAuth<"https://api.example.com/.well-known/openid-configuration">) +``` + +Operations can require specific scopes via the second template argument. As with OAuth2, define a template alias so different operations can request different scopes without repeating the discovery URL: + +```tsp +alias MyOidc = OpenIdConnectAuth< + "https://api.example.com/.well-known/openid-configuration", + Scopes +>; + +// Use OpenID Connect with the "read" scope +@useAuth(MyOidc<["read"]>) +op list(): string[]; + +// Use OpenID Connect with the "write" scope +@useAuth(MyOidc<["write"]>) +op write(value: string): void; +``` + ## Application hierarchy The `@useAuth` decorator can be used on a service namespace, sub-namespace, interface or operation. The security scheme specified will be applied to all operations contained in the type. diff --git a/website/src/content/docs/docs/libraries/http/reference/data-types.md b/website/src/content/docs/docs/libraries/http/reference/data-types.md index bd4681e0542..7375babe930 100644 --- a/website/src/content/docs/docs/libraries/http/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/http/reference/data-types.md @@ -567,21 +567,23 @@ https://server.com/.well-known/openid-configuration ``` ```typespec -model TypeSpec.Http.OpenIdConnectAuth +model TypeSpec.Http.OpenIdConnectAuth ``` #### Template Parameters -| Name | Description | -| ---------- | ----------- | -| ConnectUrl | | +| Name | Description | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------- | +| ConnectUrl | The openIdConnectUrl, where the OpenID provider publishes its discovery metadata. It can be specified relative to the server URL. | +| Scopes | The scope names required for operations that use this scheme. | #### Properties -| Name | Type | Description | -| ---------------- | -------------------------------------- | ----------------------------------------------------------- | -| type | `TypeSpec.Http.AuthType.openIdConnect` | Auth type | -| openIdConnectUrl | `ConnectUrl` | Connect url. It can be specified relative to the server URL | +| Name | Type | Description | +| ---------------- | -------------------------------------- | ------------------------------------------------------------------------ | +| type | `TypeSpec.Http.AuthType.openIdConnect` | Auth type | +| openIdConnectUrl | `ConnectUrl` | Connect url. It can be specified relative to the server URL | +| scopes | `Scopes` | Scope names required for operations that use this authentication scheme. | ### `PasswordFlow` {#TypeSpec.Http.PasswordFlow}