Fix: Authentication on containing namespace (#2980)
fix #2979 Authentication wasn't resolving values set on the namespaces.
This commit is contained in:
Родитель
01ba99b061
Коммит
82fab0a5e6
|
@ -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(
|
||||
`
|
||||
|
|
Загрузка…
Ссылка в новой задаче