diff --git a/.chronus/changes/fix-allow-scope-descriptions-2025-11-2-12-5-39.md b/.chronus/changes/fix-allow-scope-descriptions-2025-11-2-12-5-39.md new file mode 100644 index 00000000000..0f11f4453ca --- /dev/null +++ b/.chronus/changes/fix-allow-scope-descriptions-2025-11-2-12-5-39.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http" +--- + +Allow for OAuth2 scopes to be properly specified with descriptions \ No newline at end of file diff --git a/packages/http/lib/auth.tsp b/packages/http/lib/auth.tsp index cc643eac10e..308e42749f9 100644 --- a/packages/http/lib/auth.tsp +++ b/packages/http/lib/auth.tsp @@ -100,6 +100,16 @@ model ApiKeyAuth { name: Name; } +model OAuth2Scope { + @doc("The scope identifier") + value: string; + + @doc("A description of the scope") + description?: string; +} + +alias ScopeList = string[] | OAuth2Scope[]; + /** * OAuth 2.0 is an authorization protocol that gives an API client limited access to user data on a web server. * @@ -111,7 +121,7 @@ model ApiKeyAuth { * @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; @@ -154,7 +164,7 @@ model AuthorizationCodeFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes?: string[]; + scopes?: ScopeList; } @doc("Implicit flow") @@ -169,7 +179,7 @@ model ImplicitFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes?: string[]; + scopes?: ScopeList; } @doc("Resource Owner Password flow") @@ -184,7 +194,7 @@ model PasswordFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes?: string[]; + scopes?: ScopeList; } @doc("Client credentials flow") @@ -199,7 +209,7 @@ model ClientCredentialsFlow { refreshUrl?: string; @doc("list of scopes for the credential") - scopes?: string[]; + scopes?: ScopeList; } /** diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index cb5457b1e93..09c53864d43 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -52,6 +52,9 @@ import { HttpStatusCodeRange, HttpStatusCodes, HttpVerb, + OAuth2Flow, + OAuth2Scope, + Oauth2Auth, PathParameterOptions, QueryParameterOptions, } from "./types.js"; @@ -621,7 +624,11 @@ function extractHttpAuthentication( ]; } -function extractOAuth2Auth(modelType: Model, data: any): HttpAuth { +function normalizeScope(scope: string | OAuth2Scope): OAuth2Scope { + return typeof scope === "string" ? { value: scope } : scope; +} + +function extractOAuth2Auth(modelType: Model, data: any): Oauth2Auth { // Validation of OAuth2Flow models in this function is minimal because the // type system already validates whether the model represents a flow // configuration. This code merely avoids runtime errors. @@ -630,16 +637,19 @@ function extractOAuth2Auth(modelType: Model, data: any): HttpAuth { ? data.flows : []; - const defaultScopes = Array.isArray(data.defaultScopes) ? data.defaultScopes : []; + const defaultScopes: Array = Array.isArray(data.defaultScopes) + ? data.defaultScopes + : []; + return { id: data.id, type: data.type, model: modelType, - flows: flows.map((flow: any) => { - const scopes: Array = flow.scopes ? flow.scopes : defaultScopes; + flows: flows.map((flow: OAuth2Flow) => { + const scopes = flow.scopes ? flow.scopes : defaultScopes; return { ...flow, - scopes: scopes.map((x: string) => ({ value: x })), + scopes: scopes.map(normalizeScope), }; }), }; diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 1e88a9f7c63..656715d872d 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -897,6 +897,47 @@ describe("http: decorators", () => { }); }); + it("can specify OAuth2 with object scopes", async () => { + const { Foo, program } = await Tester.compile(t.code` + model MyFlow { + type: OAuth2FlowType.implicit; + authorizationUrl: "https://api.example.com/oauth2/authorize"; + refreshUrl: "https://api.example.com/oauth2/refresh"; + scopes: [ + {value: "read", description: "read data"}, + {value: "write", description: "write data"}, + ]; + } + @useAuth(OAuth2Auth<[MyFlow]>) + namespace ${t.namespace("Foo")} {} + `); + + expect(getAuthentication(program, Foo)).toEqual({ + 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", description: "read data" }, + { value: "write", description: "write data" }, + ], + }, + ], + model: expect.objectContaining({ kind: "Model" }), + }, + ], + }, + ], + }); + }); + it("can specify OAuth2 with scopes, which are default for every flow", async () => { const { Foo, program } = await Tester.compile(t.code` alias MyAuth = OAuth2Auth