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

Id token #384

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
},
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/camelcase": "off"
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/no-misused-promises": "off"
},
"overrides": [{
"files": "**/*.test.ts",
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/aws-sdk/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const withCognitoSdk =
DefaultConfig.TokenConfig
),
});
const server = createServer(router, ctx.logger);
const server = createServer(router, ctx.logger, cognitoClient);
httpServer = await server.start({
hostname: "127.0.0.1",
port: 0,
Expand Down
40 changes: 34 additions & 6 deletions integration-tests/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import supertest from "supertest";
import { MockLogger } from "../src/__tests__/mockLogger";
import { newMockCognitoService } from "../src/__tests__/mockCognitoService";
import { newMockUserPoolService } from "../src/__tests__/mockUserPoolService";
import {
CodeMismatchError,
CognitoError,
Expand All @@ -11,10 +13,16 @@ import {
import { createServer } from "../src";

describe("HTTP server", () => {
const mockUserPoolService = newMockUserPoolService();

describe("/", () => {
it("errors with missing x-azm-target header", async () => {
const router = jest.fn();
const server = createServer(router, MockLogger as any);
const server = createServer(
router,
MockLogger as any,
newMockCognitoService(mockUserPoolService)
);

const response = await supertest(server.application).post("/");

Expand All @@ -24,7 +32,11 @@ describe("HTTP server", () => {

it("errors with an poorly formatted x-azm-target header", async () => {
const router = jest.fn();
const server = createServer(router, MockLogger as any);
const server = createServer(
router,
MockLogger as any,
newMockCognitoService(mockUserPoolService)
);

const response = await supertest(server.application)
.post("/")
Expand All @@ -43,7 +55,11 @@ describe("HTTP server", () => {
});
const router = (target: string) =>
target === "valid" ? route : () => Promise.reject();
const server = createServer(router, MockLogger as any);
const server = createServer(
router,
MockLogger as any,
newMockCognitoService(mockUserPoolService)
);

const response = await supertest(server.application)
.post("/")
Expand All @@ -61,7 +77,11 @@ describe("HTTP server", () => {
.mockRejectedValue(new UnsupportedError("integration test"));
const router = (target: string) =>
target === "valid" ? route : () => Promise.reject();
const server = createServer(router, MockLogger as any);
const server = createServer(
router,
MockLogger as any,
newMockCognitoService(mockUserPoolService)
);

const response = await supertest(server.application)
.post("/")
Expand All @@ -87,7 +107,11 @@ describe("HTTP server", () => {
const route = jest.fn().mockRejectedValue(error);
const router = (target: string) =>
target === "valid" ? route : () => Promise.reject();
const server = createServer(router, MockLogger as any);
const server = createServer(
router,
MockLogger as any,
newMockCognitoService(mockUserPoolService)
);

const response = await supertest(server.application)
.post("/")
Expand All @@ -105,7 +129,11 @@ describe("HTTP server", () => {

describe("jwks endpoint", () => {
it("responds with our public key", async () => {
const server = createServer(jest.fn(), MockLogger as any);
const server = createServer(
jest.fn(),
MockLogger as any,
newMockCognitoService(mockUserPoolService)
);

const response = await supertest(server.application).get(
"/any-user-pool/.well-known/jwks.json"
Expand Down
1 change: 1 addition & 0 deletions src/server/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const createDefaultServer = async (
triggers,
}),
logger,
cognitoClient,
{
development: !!process.env.COGNITO_LOCAL_DEVMODE,
}
Expand Down
170 changes: 168 additions & 2 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import * as uuid from "uuid";
import { CognitoError, UnsupportedError } from "../errors";
import { Router } from "./Router";
import PublicKey from "../keys/cognitoLocal.public.json";
import { CognitoService } from "../services/cognitoService";
import { AppClient } from "../services/appClient";
import PrivateKey from "../keys/cognitoLocal.private.json";
import jwt from "jsonwebtoken";
import Pino from "pino-http";

export interface ServerOptions {
Expand All @@ -23,6 +27,7 @@ export interface Server {
export const createServer = (
router: Router,
logger: Logger,
cognito: CognitoService,
options: Partial<ServerOptions> = {}
): Server => {
const pino = Pino({
Expand All @@ -46,17 +51,178 @@ export const createServer = (
app.use(
bodyParser.json({
type: "application/x-amz-json-1.1",
}),
bodyParser.urlencoded({
extended: true,
})
);

app.get("/health", (req, res) => {
res.status(200).json({ ok: true });
});

app.get("/:userPoolId/.well-known/jwks.json", (req, res) => {
res.status(200).json({
keys: [PublicKey.jwk],
});
});

app.get("/health", (req, res) => {
res.status(200).json({ ok: true });
app.get("/:userPoolId/.well-known/openid-configuration", (req, res) => {
const proxyHost = req.headers["x-forwarded-host"];
const host = proxyHost ? proxyHost : req.headers.host;
const userPoolURL = `http://${host}/${req.params.userPoolId}`;

res.status(200).json({
authorization_endpoint: `${userPoolURL}/oauth2/authorize`,
grant_types_supported: ["client_credentials", "authorization_code"],
id_token_signing_alg_values_supported: ["RS256"],
issuer: userPoolURL,
jwks_uri: `${userPoolURL}/.well-known/jwks.json`,
token_endpoint: `${userPoolURL}/oauth2/token`,
token_endpoint_auth_methods_supported: ["client_secret_basic"],
});
});

app.get("/:userPoolId/oauth2/authorize", (req, res) => {
res.redirect(
`${req.query.redirect_uri}?code=AUTHORIZATION_CODE&state=${req.query.state}`
);
});

/**
* Generate a new access token for client credentials and authorization code flows.
*/
app.post("/:userPoolId/oauth2/token", async (req, res) => {
const contentType = req.headers["content-type"];

if (!contentType?.includes("application/x-www-form-urlencoded")) {
res.status(400).json({
error: "invalid_request",
description: "content-type must be 'application/x-www-form-urlencoded'",
});
return;
}

const grantType = req.body.grant_type;

if (
grantType !== "client_credentials" &&
grantType !== "authorization_code"
) {
res.status(400).json({
error: "unsupported_grant_type",
description:
"only 'client_credentials' and 'authorization_code' grant types are supported",
});
return;
}

const authHeader = req.headers.authorization?.split(" ");

if (
authHeader === undefined ||
authHeader.length !== 2 ||
authHeader[0] !== "Basic"
) {
res.status(400).json({
error: "invalid_request",
description:
"authorization header must be present and use HTTP Basic authentication scheme",
});
return;
}

const [clientId, clientSecret] = Buffer.from(authHeader[1], "base64")
.toString("ascii")
.split(":");

let userPoolClient: AppClient | null;

try {
userPoolClient = await cognito.getAppClient(
{ logger: req.log },
clientId
);
} catch (e) {
res.status(500).json({
error: "server_error",
description: "failed to retrieve user pool client",
});
return;
}

if (!userPoolClient || userPoolClient.ClientSecret !== clientSecret) {
res.status(400).json({
error: "invalid_client",
description: "invalid client id or secret",
});
return;
}

if (!userPoolClient.AllowedOAuthFlows?.includes(grantType)) {
res.status(400).json({
error: "unsupported_grant_type",
description: `grant type '${grantType}' is not supported by this client`,
});
return;
}

if (grantType === "client_credentials") {
if (!userPoolClient.AllowedOAuthScopes?.includes(req.body.scope)) {
res.status(400).json({
error: "invalid_scope",
description: `invalid scope '${req.body.scope}'`,
});
return;
}
} else if (grantType === "authorization_code") {
if (req.body.code !== "AUTHORIZATION_CODE") {
res.status(400).json({
error: "invalid_grant",
description: "invalid authorization code",
});
return;
}
}

const now = Math.floor(Date.now() / 1000);

const accessToken = {
sub: clientId,
client_id: clientId,
scope: req.body.scope,
jti: uuid.v4(),
auth_time: now,
iat: now,
token_use: "access",
};

const idToken = {
sub: clientId,
client_id: clientId,
jti: uuid.v4(),
auth_time: now,
iat: now,
token_use: "id",
"custom:tenant_id": uuid.v4(),
};

res.status(200).json({
access_token: jwt.sign(accessToken, PrivateKey.pem, {
algorithm: "RS256",
issuer: `https://cognito-local/${userPoolClient.UserPoolId}`,
expiresIn: 3600,
keyid: "CognitoLocal",
}),
expiresIn: 3600,
id_token: jwt.sign(idToken, PrivateKey.pem, {
algorithm: "RS256",
issuer: `https://cognito-local/${userPoolClient.UserPoolId}`,
expiresIn: 3600,
keyid: "CognitoLocal",
}),
token_type: "Bearer",
});
});

app.post("/", (req, res) => {
Expand Down
Loading