diff --git a/.eslintrc b/.eslintrc index 91e59b01..668f2847 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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", diff --git a/integration-tests/aws-sdk/setup.ts b/integration-tests/aws-sdk/setup.ts index 439b43dc..3e052c4d 100644 --- a/integration-tests/aws-sdk/setup.ts +++ b/integration-tests/aws-sdk/setup.ts @@ -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, diff --git a/integration-tests/server.test.ts b/integration-tests/server.test.ts index dede7dcd..c7391041 100644 --- a/integration-tests/server.test.ts +++ b/integration-tests/server.test.ts @@ -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, @@ -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("/"); @@ -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("/") @@ -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("/") @@ -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("/") @@ -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("/") @@ -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" diff --git a/src/server/defaults.ts b/src/server/defaults.ts index 92f63d23..3ebcb24d 100644 --- a/src/server/defaults.ts +++ b/src/server/defaults.ts @@ -81,6 +81,7 @@ export const createDefaultServer = async ( triggers, }), logger, + cognitoClient, { development: !!process.env.COGNITO_LOCAL_DEVMODE, } diff --git a/src/server/server.ts b/src/server/server.ts index 227f94a5..2750c78d 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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 { @@ -23,6 +27,7 @@ export interface Server { export const createServer = ( router: Router, logger: Logger, + cognito: CognitoService, options: Partial = {} ): Server => { const pino = Pino({ @@ -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) => {