Setting up Speakeasy managed API keys without an API gateway
When setting up Speakeasy managed API keys in production we recommend using an API gateway to provide request termination before requests reach your application. Here is the complete documentation for integrating with an API gateway.
However, it is possible to use Speakeasy managed API keys without an API gateway. This is not recommended for production use, but can be useful for testing and development. Here is an example of using Speakeasy middleware with an API using Express.js and tRPC for routing.
Define a generic middleware
This middleware can be used to ensure users are logged in before handling requests. In the following snippet SPEAKEASY_API_KEY
is an API key created in your Speakeasy workspace and SPEAKEASY_API_ID
is the ID of the API which can be found on your API Dashboard.
/**
*
* This is where the trpc api is initialized, connecting the context and
* transformer
*/
import { initTRPC, TRPCError } from "@trpc/server";
import { OpenApiMeta } from "trpc-openapi";
import superjson from "superjson";
import { env } from "./env.mjs";
import { validateEmailWithACL } from "./utils";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const t = initTRPC
.meta<OpenApiMeta>()
.context<typeof createTRPCContext>()
.create({
transformer: superjson,
errorFormatter({ shape }) {
return shape;
},
});
/**
* This is how you create new routers and subrouters in your tRPC API
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Public (unauthed) procedure
*
* This is the base piece you use to build new queries and mutations on your
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
const speakeasyMiddleware = t.middleware(({ ctx, next }) => {
if (env.SPEAKEASY_API_KEY) {
const handler = speakeasy.expressMiddleware();
handler(ctx.req as any, ctx.res as any, next);
} else {
logger.warn(
{ key: env.SPEAKEASY_API_KEY, id: env.SPEAKEASY_API_ID },
"Speakeasy config not ready, controller is not set up"
);
}
return next();
});
/**
* Reusable middleware that enforces users are logged in before running any
* procedures
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
// if we have no auth set, reject
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
// infers auth as non-nullable
auth: ctx.auth,
},
});
});
Check
Speakeasy middleware allows you to set custom claims on the JWT token. You can use these claims to check if the user is authorized to access a specific API. JWKS Client in the example below is validating the JWT, but if you're using a Gateway the Gateway will validate it for you, and you can usually trust the traffic without needing to re-validate it.
// https://mojoauth.com/blog/jwt-validation-with-jwks-nodejs/
const speakeasyClaimsByApiKey = async (apiKey: string) => {
if (!env.SPEAKEASY_WORKSPACE_ID) {
logger.error("SPEAKEASY_WORKSPACE_ID is not set and cannot accept api key");
return null;
}
// process jwt token
const decodedToken = jwt.decode(apiKey, { complete: true });
if (!decodedToken) {
logger.error({ apiKey }, "Failed to decode jwt");
return null;
}
const kid = decodedToken.header.kid;
logger.debug({ jwtHeader: decodedToken.header }, "Decoded jwt headers");
const issuer = `https://app.speakeasyapi.dev/v1/auth/oauth/${env.SPEAKEASY_WORKSPACE_ID}`;
const audience = env.SPEAKEASY_WORKSPACE_ID;
const client = jwksClient({
jwksUri: `https://app.speakeasyapi.dev/v1/auth/oauth/${env.SPEAKEASY_WORKSPACE_ID}/.well-known/jwks.json`,
requestHeaders: {}, // Optional
cache: true,
cacheMaxAge: 300000, // cache for 5 min
timeout: 30000, // Defaults to 30s
});
const signingKey = await client.getSigningKey(kid);
const publicKey = signingKey.getPublicKey();
const decoded = jwt.verify(apiKey, publicKey, {
issuer,
audience,
});
if (typeof decoded === "string") {
logger.error({ decoded }, "We failed to properly decode the claim");
return null;
}
logger.debug(
{ productId: decoded.productId, userId: decoded.userId },
"We decoded the api key successfully"
);
return {
productId: decoded.productId as string,
userId: decoded.userId as string,
};
};
Integrate with a developer portal to expose API keys to your customer
Use the speakeasy middleware to create a portal login token that is used to authenticate the user in the developer portal.
// method based on
// https://docs.speakeasyapi.dev/docs/integrate-speakeasy/manage-api-keys/#setting-custom-jwt-claims
speakeasyPortalLoginToken: protectedProcedure
.input(z.object({ productId: z.string() }))
.query(async ({ ctx, input }) => {
if (!input.productId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid product id",
});
}
// String customerId="some-customer-id";
const customerId = `${input.productId}${ctx.auth.id}`;
logger.info(
{
displayName,
customerId,
},
"Creating speakeasy portal login token"
);
const req = new EmbedAccessTokenRequest();
if (!ctx.req.controller) {
return "";
}
// Restrict data by time (last 24 hours)
// Instant startTime=Instant.now().minusSeconds(60*60*24);
// filterBuilder.withTimeFilter(startTime,SpeakeasyAccessTokenFilterOperator.GreaterThan);
const timeFilter = new EmbedAccessTokenRequest.Filter();
timeFilter.setKey("time");
timeFilter.setOperator(">");
timeFilter.setValue(
add(Date.now(), {
days: -1,
}).toISOString()
);
req.setCustomerId(customerId);
req.setDisplayName(displayName);
// Populate with any custom claims you want added to your created API keys
// Map<String, String> jwtCustomClaims = new HashMap<>();
const jwtClaimsMap = req.getJwtCustomClaimsMap();
jwtClaimsMap.set("productId", input.productId);
jwtClaimsMap.set("userId", ctx.auth.id);
req.setFiltersList([timeFilter]);
// Populate with any permissions you want enabled/disabled for the user
const permissionsMap = req.getPermissionsMap();
// https://docs.speakeasyapi.dev/docs/integrate-speakeasy/manage-api-keys/#setting-custom-permissions-for-portal-users
permissionsMap.set("end_user:api_keys:write", true);
permissionsMap.set("end_user:api_keys:read", true);
return ctx.req.controller.getSDKInstance().getEmbedAccessToken(req);
}),
});