Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API key to gRPC server and client #394

Merged
merged 3 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/grpc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@walmartlabs/cookie-cutter-grpc",
"version": "1.6.0-beta.2",
"version": "1.6.0-beta.3",
"license": "Apache-2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
81 changes: 68 additions & 13 deletions packages/grpc/src/__test__/grpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "..";
import { sample } from "./Sample";

const apiKey = "token";
let nextPort = 56011;

export interface ISampleService {
Expand Down Expand Up @@ -78,16 +79,19 @@ export const SampleServiceDefinition = {
},
};

function testApp(handler: any, host?: string): CancelablePromise<void> {
function testApp(handler: any, host?: string, apiKey?: string): CancelablePromise<void> {
return Application.create()
.input()
.add(
grpcSource({
port: nextPort,
host,
definitions: [SampleServiceDefinition],
skipNoStreamingValidation: true,
})
grpcSource(
{
port: nextPort,
host,
definitions: [SampleServiceDefinition],
skipNoStreamingValidation: true,
},
apiKey
)
)
.done()
.dispatch(handler)
Expand All @@ -96,13 +100,18 @@ function testApp(handler: any, host?: string): CancelablePromise<void> {

async function createClient(
host?: string,
config?: Partial<IGrpcClientConfiguration & IGrpcConfiguration>
config?: Partial<IGrpcClientConfiguration & IGrpcConfiguration>,
apiKey?: string
): Promise<ISampleService & IRequireInitialization & IDisposable> {
const client = grpcClient<ISampleService & IRequireInitialization & IDisposable>({
endpoint: `${host || "localhost"}:${nextPort++}`,
definition: SampleServiceDefinition,
...config,
});
const client = grpcClient<ISampleService & IRequireInitialization & IDisposable>(
{
endpoint: `${host || "localhost"}:${nextPort++}`,
definition: SampleServiceDefinition,
...config,
},
undefined,
apiKey
);
return client;
}

Expand All @@ -126,6 +135,29 @@ describe("gRPC source", () => {
}
});

it("serves requests with api key validation", async () => {
const app = testApp(
{
onNoStreaming: async (
request: sample.ISampleRequest,
_: IDispatchContext
): Promise<sample.ISampleResponse> => {
return { name: request.id.toString() };
},
},
undefined,
apiKey
);
try {
const client = await createClient(undefined, undefined, apiKey);
const response = await client.NoStreaming({ id: 15 });
expect(response).toMatchObject({ name: "15" });
} finally {
app.cancel();
await app;
}
});

it("serves response streams", async () => {
const app = testApp({
onStreamingOut: async (
Expand Down Expand Up @@ -235,6 +267,29 @@ describe("gRPC source", () => {
}
});

it("throws error for missing/invalid api key", async () => {
const app = testApp(
{
onNoStreaming: async (
request: sample.ISampleRequest,
_: IDispatchContext
): Promise<sample.ISampleResponse> => {
return { name: request.id.toString() };
},
},
undefined,
apiKey
);
try {
const client = await createClient();
const response = client.NoStreaming({ id: 15 });
await expect(response).rejects.toThrowError(/Invalid API Key/);
} finally {
app.cancel();
await app;
}
});

it("validates that no streaming operations are exposed", () => {
const a = () =>
grpcSource({
Expand Down
10 changes: 6 additions & 4 deletions packages/grpc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export interface IResponseStream<TResponse> {
}

export function grpcSource(
configuration: IGrpcServerConfiguration & IGrpcConfiguration
configuration: IGrpcServerConfiguration & IGrpcConfiguration,
apiKey?: string
): IInputSource & IRequireInitialization {
configuration = config.parse<IGrpcServerConfiguration & IGrpcConfiguration>(
GrpcSourceConfiguration,
Expand All @@ -90,7 +91,7 @@ export function grpcSource(
allocator: Buffer,
}
);
return new GrpcInputSource(configuration);
return new GrpcInputSource(configuration, apiKey);
}

export function grpcMsg(operation: IGrpcServiceMethod, request: any): IMessage {
Expand All @@ -102,7 +103,8 @@ export function grpcMsg(operation: IGrpcServiceMethod, request: any): IMessage {

export function grpcClient<T>(
configuration: IGrpcClientConfiguration & IGrpcConfiguration,
certPath?: string
certPath?: string,
apiKey?: string
): T & IRequireInitialization & IDisposable {
configuration = config.parse<IGrpcClientConfiguration & IGrpcConfiguration>(
GrpcClientConfiguration,
Expand All @@ -122,5 +124,5 @@ export function grpcClient<T>(
},
}
);
return createGrpcClient<T>(configuration, certPath);
return createGrpcClient<T>(configuration, certPath, apiKey);
}
32 changes: 29 additions & 3 deletions packages/grpc/src/internal/GrpcInputSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
OpenTracingTagKeys,
} from "@walmartlabs/cookie-cutter-core";
import {
Metadata,
sendUnaryData,
Server,
ServerCredentials,
Expand Down Expand Up @@ -58,8 +59,12 @@ export class GrpcInputSource implements IInputSource, IRequireInitialization {
private logger: ILogger;
private tracer: Tracer;
private metrics: IMetrics;
private apiKey: string;

constructor(private readonly config: IGrpcServerConfiguration & IGrpcConfiguration) {
constructor(
private readonly config: IGrpcServerConfiguration & IGrpcConfiguration,
apiKey?: string
) {
if (!config.skipNoStreamingValidation) {
for (const def of config.definitions) {
for (const key of Object.keys(def)) {
Expand All @@ -79,6 +84,7 @@ export class GrpcInputSource implements IInputSource, IRequireInitialization {
this.logger = DefaultComponentContext.logger;
this.tracer = DefaultComponentContext.tracer;
this.metrics = DefaultComponentContext.metrics;
this.apiKey = apiKey;
}

public async initialize(context: IComponentContext): Promise<void> {
Expand Down Expand Up @@ -180,7 +186,11 @@ export class GrpcInputSource implements IInputSource, IRequireInitialization {
if (value !== undefined) {
callback(undefined, value);
} else if (error !== undefined) {
callback(this.createError(error), null);
if ((error as ServerErrorResponse).code !== undefined) {
callback(error, null);
} else {
callback(this.createError(error), null);
}
} else {
callback(
this.createError("not implemented", status.UNIMPLEMENTED),
Expand All @@ -198,7 +208,15 @@ export class GrpcInputSource implements IInputSource, IRequireInitialization {
path: method.path,
});
});

if (this.apiKey) {
if (!this.isApiKeyValid(call.metadata)) {
await msgRef.release(
undefined,
this.createError("Invalid API Key", status.UNAUTHENTICATED)
);
return;
}
}
if (!(await this.queue.enqueue(msgRef))) {
await msgRef.release(undefined, new Error("service unavailable"));
}
Expand Down Expand Up @@ -239,4 +257,12 @@ export class GrpcInputSource implements IInputSource, IRequireInitialization {
message: error.toString(),
};
}

private isApiKeyValid(meta: Metadata) {
const headerValue = meta.get("custom-auth-header");
plameniv marked this conversation as resolved.
Show resolved Hide resolved
if (!headerValue || headerValue.length < 1 || headerValue[0].toString() !== this.apiKey) {
return false;
}
return true;
plameniv marked this conversation as resolved.
Show resolved Hide resolved
}
}
18 changes: 14 additions & 4 deletions packages/grpc/src/internal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ enum GrpcMetricResult {
Success = "success",
Error = "error",
}
const DEFAULT_API_KEY = "token";
plameniv marked this conversation as resolved.
Show resolved Hide resolved

class ClientBase implements IRequireInitialization, IDisposable {
private pendingStreams: Set<ClientReadableStream<any>>;
Expand Down Expand Up @@ -75,7 +76,8 @@ class ClientBase implements IRequireInitialization, IDisposable {

export function createGrpcClient<T>(
config: IGrpcClientConfiguration & IGrpcConfiguration,
certPath?: string
certPath?: string,
apiKey?: string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be we can turn this into object while this version is still in beta.
option?: {

Suggested change
certPath?: string,
apiKey?: string
options?: { certPath?: string,
apiKey?: string
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a separate interfaces for server and client

): T & IDisposable & IRequireInitialization {
const serviceDef = createServiceDefinition(config.definition);
let client: Client;
Expand All @@ -86,7 +88,7 @@ export function createGrpcClient<T>(

const metaCallback = (_params: any, callback: (arg0: null, arg1: Metadata) => void) => {
const meta = new Metadata();
meta.add("custom-auth-header", "token");
meta.add("custom-auth-header", apiKey || DEFAULT_API_KEY);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can skip adding the header if there is no API Key provided. Also header name should be "authorization"

Suggested change
meta.add("custom-auth-header", apiKey || DEFAULT_API_KEY);
if(apiKey) {
meta.add("authorization", apiKey);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the if check a bit earlier

callback(null, meta);
};

Expand Down Expand Up @@ -165,12 +167,16 @@ export function createGrpcClient<T>(

const stream = await retrier.retry((bail) => {
try {
const meta = createTracingMetadata(wrapper.tracer, span);
if (!certPath && apiKey) {
meta.set("custom-auth-header", apiKey);
}
return client.makeServerStreamRequest(
method.path,
method.requestSerialize,
method.responseDeserialize,
request,
createTracingMetadata(wrapper.tracer, span),
meta,
callOptions()
);
} catch (e) {
Expand Down Expand Up @@ -239,12 +245,16 @@ export function createGrpcClient<T>(
return await retrier.retry(async (bail) => {
try {
return await new Promise((resolve, reject) => {
const meta = createTracingMetadata(wrapper.tracer, span);
if (!certPath && apiKey) {
meta.set("custom-auth-header", apiKey);
}
client.makeUnaryRequest(
method.path,
method.requestSerialize,
method.responseDeserialize,
request,
createTracingMetadata(wrapper.tracer, span),
meta,
callOptions(),
(error, value) => {
this.metrics.increment(GrpcMetrics.RequestProcessed, {
Expand Down
Loading