Fix: Authentication on containing namespace (#2980)

fix #2979

Authentication wasn't resolving values set on the namespaces.
This commit is contained in:
Timothee Guerin 2024-03-04 10:55:35 -08:00 коммит произвёл GitHub
Родитель 01ba99b061
Коммит 82fab0a5e6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
6 изменённых файлов: 233 добавлений и 61 удалений

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

@ -0,0 +1,9 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: internal
packages:
- "@typespec/http"
- "@typespec/openapi3"
---
Fix: Authentication on containing namespace

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

@ -1,79 +1,57 @@
import { Operation, Program } from "@typespec/compiler";
import { deepClone, deepEquals } from "@typespec/compiler/utils";
import { HttpStateKeys } from "./lib.js";
import { getAuthentication } from "./decorators.js";
import {
Authentication,
AuthenticationOptionReference,
AuthenticationReference,
HttpAuth,
HttpAuthRef,
HttpService,
NoAuth,
HttpServiceAuthentication,
OAuth2Flow,
OAuth2Scope,
Oauth2Auth,
} from "./types.js";
/**
* Resolve the authentication for a given operation.
* @param program Program
* @param operation Operation
* @returns Authentication provided on the operation or containing interface or namespace.
*/
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;
const operationAuth = getAuthentication(program, operation);
if (operationAuth) {
return operationAuth;
}
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>;
if (operation.interface !== undefined) {
const interfaceAuth = getAuthentication(program, operation.interface);
if (interfaceAuth) {
return interfaceAuth;
}
}
let namespace = operation.namespace;
while (namespace) {
const namespaceAuth = getAuthentication(program, namespace);
if (namespaceAuth) {
return namespaceAuth;
}
namespace = namespace.namespace;
}
return undefined;
}
/**
* Compute the authentication for a given service.
* @param service Http Service
* @returns The normalized authentication for a service.
*/
export function resolveAuthentication(service: HttpService): HttpServiceAuthentication {
let schemes: Record<string, HttpAuth> = {};
let defaultAuth: AuthenticationReference = { options: [] };

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

@ -197,6 +197,57 @@ export interface NoAuth extends HttpAuthBase {
type: "noAuth";
}
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 type OperationContainer = Namespace | Interface;
export type OperationVerbSelector = (

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

@ -0,0 +1,85 @@
import { Operation } from "@typespec/compiler";
import { BasicTestRunner } from "@typespec/compiler/testing";
import { ok, strictEqual } from "assert";
import { beforeEach, describe, expect, it } from "vitest";
import { getAuthenticationForOperation } from "../src/auth.js";
import { createHttpTestRunner } from "./test-host.js";
let runner: BasicTestRunner;
beforeEach(async () => {
runner = await createHttpTestRunner();
});
describe("per operation authentication", () => {
/** Test function that will expect api key auth only and return the name of the one selected */
async function getTestOperationApiKeyAuthName(code: string) {
const { test } = (await runner.compile(code)) as { test: Operation };
ok(test, "Should have operation called test marked with @test");
const auth = getAuthenticationForOperation(runner.program, test);
const scheme = auth?.options[0].schemes[0];
strictEqual(scheme?.type, "apiKey");
return scheme.name;
}
it("use explicit value on operation", async () => {
const auth = await getTestOperationApiKeyAuthName(`
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-namespace">)
namespace MyNamespace {
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-interface">)
interface MyInterface {
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-op">)
@test op test(): void;
}
}
`);
expect(auth).toEqual("x-for-op");
});
it("go up to interface", async () => {
const auth = await getTestOperationApiKeyAuthName(`
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-namespace">)
namespace MyNamespace {
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-interface">)
interface MyInterface {
@test op test(): void;
}
}
`);
expect(auth).toEqual("x-for-interface");
});
it("go up to first namespace", async () => {
const auth = await getTestOperationApiKeyAuthName(`
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-namespace">)
namespace MyNamespace {
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-sub-namespace">)
namespace MySubNamespace {
interface MyInterface {
@test op test(): void;
}
}
}
`);
expect(auth).toEqual("x-for-sub-namespace");
});
it("go up to top namespace", async () => {
const auth = await getTestOperationApiKeyAuthName(`
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "x-for-namespace">)
namespace MyNamespace {
namespace MySubNamespace {
interface MyInterface {
@test op test(): void;
}
}
}
`);
expect(auth).toEqual("x-for-namespace");
});
});

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

@ -866,7 +866,7 @@ function createOAPIEmitter(
attachExtensions(program, op, currentEndpoint);
}
if (authReference) {
emitSecurity(authReference);
emitEndpointSecurity(authReference);
}
}
@ -906,7 +906,7 @@ function createOAPIEmitter(
emitRequestBody(parameters.body, visibility);
emitResponses(operation.responses);
if (authReference) {
emitSecurity(authReference);
emitEndpointSecurity(authReference);
}
if (isDeprecated(program, op)) {
currentEndpoint.deprecated = true;
@ -1659,8 +1659,11 @@ function createOAPIEmitter(
return security;
}
function emitSecurity(authReference: AuthenticationReference) {
function emitEndpointSecurity(authReference: AuthenticationReference) {
const security = getOpenAPISecurity(authReference);
if (deepEquals(security, root.security)) {
return;
}
if (security.length > 0) {
currentEndpoint.security = security;
}

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

@ -208,6 +208,52 @@ describe("openapi3: security", () => {
]);
});
it("can specify security on containing namespace", 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">)
namespace 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(
`