Operation level authentication and scopes (#2901)

Hi! 🖖🏻 
This PR resolves #2624 by implementing the [design
doc](https://gist.github.com/timotheeguerin/56690786e61a436710dd647de9febc0f),
but in its initial form:
- `@useAuth` can now be applied not only to service namespace, but to
interfaces and operations as well. Its arguments override all
authentication, which was set for enclosing scopes.
- OAuth2 scopes can now be set at operation level (though, the code
doing this in OpenAPI emitter is a bit clunky).
- New `NoAuth` authentication option allows to declare optional
authentication (`NoAuth | AnyOtherAuth`) or override authentication to
none in nested scopes.

This implementation does not introduce new `@authScopes` decorator as
design doc comments suggest, and here's why:

1. It does not compose well with `@useAuth` at operation level. For
example
```
...
@useAuth(BasicAuth)
@authScopes(MyOauth2, ["read"])
op gogo(): void
```
Should that be equivalent to `BasicAuth | MyOauth2`, or to `[BasicAuth,
MyOauth2]`?

2. Introducing new decorator would increase complexity, but (imho) it
would not reduce the amount of boilerplate:
```
alias MyOAuth2 = OAuth2Auth<{ ... }>;

@useAuth(MyOAuth2)
@authAcopes(MyOauth2, ["read"])
@service
namepsace Foo;
```
vs
```
model MyOAuth2Flow<T extends string[]>  {  ...  };
alias MyOauth2<T extends string[]> = Oauth2Auth<[MyOauth2Flow[T]]>

@useAuth(MyOAuth2<["read"]>)
@service
namepsace Foo
```

I would be happy to hear any feedback and apply suggested changes.

And thanks for a convenient development setup and thorough test
coverage!

---------

Co-authored-by: Timothee Guerin <timothee.guerin@outlook.com>
This commit is contained in:
Vasil Markoukin 2024-02-27 18:39:30 +03:00 коммит произвёл GitHub
Родитель aa6e53f4ab
Коммит d2d397cb67
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
24 изменённых файлов: 777 добавлений и 125 удалений

Просмотреть файл

@ -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

Просмотреть файл

@ -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

Просмотреть файл

@ -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<Flows>
model TypeSpec.Http.OAuth2Auth<Flows, Scopes>
```
#### 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}

Просмотреть файл

@ -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

Просмотреть файл

@ -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)

Просмотреть файл

@ -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

Просмотреть файл

@ -13,6 +13,9 @@ enum AuthType {
@doc("OpenID connect")
openIdConnect,
@doc("Empty auth")
noAuth,
}
/**
@ -105,14 +108,18 @@ model ApiKeyAuth<Location extends ApiKeyLocation, Name extends string> {
* 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<Flows extends OAuth2Flow[]> {
model OAuth2Auth<Flows extends OAuth2Flow[], Scopes extends string[] = []> {
@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<ConnectUrl extends valueof string> {
/** 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;
}

Просмотреть файл

@ -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.

185
packages/http/src/auth.ts Normal file
Просмотреть файл

@ -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<OAuth2Flow[]>;
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<Operation, AuthenticationReference>;
}
export function resolveAuthentication(service: HttpService): HttpServiceAuthentication {
let schemes: Record<string, HttpAuth> = {};
let defaultAuth: AuthenticationReference = { options: [] };
const operationsAuth: Map<Operation, AuthenticationReference> = 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<string, HttpAuth>
): {
newServiceSchemes: Record<string, HttpAuth>;
authOptions: AuthenticationReference;
} {
const newServiceSchemes: Record<string, HttpAuth> = 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<Flows extends OAuth2Flow[]>(
scheme1: Oauth2Auth<Flows>,
scheme2: Oauth2Auth<Flows>
): Oauth2Auth<Flows> {
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<Flows extends OAuth2Flow[]>(
scheme: Oauth2Auth<Flows>,
scopes: OAuth2Scope[]
): Oauth2Auth<Flows> {
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);
}

Просмотреть файл

@ -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<string> = 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);
}
/**

Просмотреть файл

@ -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";

Просмотреть файл

@ -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);

Просмотреть файл

@ -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<ApiKeyLocation, string>
| Oauth2Auth<OAuth2Flow[]>
| 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
*/

Просмотреть файл

@ -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<T extends string[]> = OAuth2Auth<Flows=[{
type: OAuth2FlowType.implicit;
authorizationUrl: "https://api.example.com/oauth2/authorize";
refreshUrl: "https://api.example.com/oauth2/refresh";
}], Scopes=T>;
@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<ApiKeyLocation.header, "X-API-KEY">;
@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<ApiKeyLocation.header, "X-API-KEY">;
@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", () => {

Просмотреть файл

@ -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<string, HttpOperationResponse[]>;
operations: Operation[];
}
@ -682,6 +691,7 @@ function createOAPIEmitter(
parameters: [],
},
bodies: undefined,
authentication: operations[0].authentication,
responses: new Map<string, HttpOperationResponse[]>(),
};
for (const [paramName, ops] of paramMap) {
@ -729,16 +739,20 @@ function createOAPIEmitter(
service: Service,
version?: string
): Promise<[OpenAPI3Document, Readonly<Diagnostic[]>] | 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<string, OpenAPI3SecurityScheme>;
security: Record<string, string[]>[];
function getOpenAPISecuritySchemes(
httpAuthentications: HttpAuth[]
): Record<string, OpenAPI3SecurityScheme> {
const schemes: Record<string, OpenAPI3SecurityScheme> = {};
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<string, OpenAPI3SecurityScheme>;
security: Record<string, string[]>[];
} {
const oaiSchemes: Record<string, OpenAPI3SecurityScheme> = {};
const security: Record<string, string[]>[] = [];
for (const option of authentication.options) {
const oai3SecurityOption: Record<string, string[]> = {};
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<string, string[]> = {};
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({

Просмотреть файл

@ -662,6 +662,7 @@ export type OpenAPI3Operation = Extensions & {
requestBody?: any;
parameters: OpenAPI3Parameter[];
deprecated?: boolean;
security?: Record<string, string[]>[];
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars

Просмотреть файл

@ -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<ApiKeyLocation.header, "X-API-KEY">;
@service
@useAuth(ServiceKeyAuth)
@route("/my-service")
namespace MyService {
@route("/file")
@useAuth(ServiceKeyAuth | ApiKeyAuth<ApiKeyLocation.query, "token">)
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<ApiKeyLocation.header, "X-API-KEY">;
@service
@useAuth(ServiceKeyAuth)
@route("/my-service")
namespace MyService {
@useAuth(NoAuth | ServiceKeyAuth | ApiKeyAuth<ApiKeyLocation.query, "token">)
@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<T extends string[]> = OAuth2Auth<Flows=[{
type: OAuth2FlowType.implicit;
authorizationUrl: "https://api.example.com/oauth2/authorize";
refreshUrl: "https://api.example.com/oauth2/refresh";
}], Scopes=T>;
@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"],
},
]);
});
});

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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<ApiKeyLocation.header, "x-my-header">, 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";

Просмотреть файл

@ -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<Scopes extends string[]> = 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<ApiKeyLocation.header, "x-my-header">)
@route("/two")
op two(): void;
// requires MyAuth<"delete">
@useAuth(MyAuth<["delete"]>)
@route("/three")
op three(): void;

Просмотреть файл

@ -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<ApiKeyLocation.header, "x-my-header">, 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;

Просмотреть файл

@ -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

Просмотреть файл

@ -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