Skip to content

Commit

Permalink
feat(api): add support for ClientCreds style cognito tokens
Browse files Browse the repository at this point in the history
Before, only user creds were supported by the oauth2/token route.
  • Loading branch information
acwrenn committed Apr 25, 2023
1 parent 743caf5 commit 493572b
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 33 deletions.
1 change: 1 addition & 0 deletions src/__tests__/mockTokenGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import { TokenGenerator } from "../services/tokenGenerator";

export const newMockTokenGenerator = (): jest.Mocked<TokenGenerator> => ({
generate: jest.fn(),
generateWithClientCreds: jest.fn(),
});
13 changes: 13 additions & 0 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ export const createServer = (
req.on("end", function () {
const target = "GetToken";
const route = router(target);
const auth = req.get("Authorization");
if (auth && auth.startsWith("Basic ")) {
const sliced = auth.slice("Basic ".length);
const buff = new Buffer(sliced, "base64");
const decoded = buff.toString("ascii");
const creds = decoded.split(":");
if (creds.length == 2) {
const id = creds[0];
const secret = creds[1];
rawBody += `&client_id=${id}`;
rawBody += `&client_secret=${secret}`;
}
}
route({ logger: req.log }, rawBody).then(
(output) => {
res.status(200).type("json").send(JSON.stringify(output));
Expand Down
42 changes: 40 additions & 2 deletions src/services/tokenGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ const applyTokenOverrides = (

export interface Tokens {
readonly AccessToken: string;
readonly IdToken: string;
readonly RefreshToken: string;
readonly IdToken?: string;
readonly RefreshToken?: string;
}

export interface TokenGenerator {
Expand All @@ -104,6 +104,10 @@ export interface TokenGenerator {
| "NewPasswordChallenge"
| "RefreshTokens"
): Promise<Tokens>;
generateWithClientCreds(
ctx: Context,
userPoolClient: AppClient
): Promise<Tokens>;
}

const formatExpiration = (
Expand Down Expand Up @@ -240,4 +244,38 @@ export class JwtTokenGenerator implements TokenGenerator {
),
};
}

public async generateWithClientCreds(
ctx: Context,
userPoolClient: AppClient
): Promise<Tokens> {
const eventId = uuid.v4();
const authTime = Math.floor(this.clock.get().getTime() / 1000);

const accessToken: RawToken = {
auth_time: authTime,
client_id: userPoolClient.ClientId,
event_id: eventId,
iat: authTime,
jti: uuid.v4(),
scope: "aws.cognito.signin.user.admin", // TODO: scopes
sub: userPoolClient.ClientId,
token_use: "access",
};

const issuer = `${this.tokenConfig.IssuerDomain}/${userPoolClient.UserPoolId}`;

return await Promise.resolve({
AccessToken: jwt.sign(accessToken, PrivateKey.pem, {
algorithm: "RS256",
issuer,
expiresIn: formatExpiration(
userPoolClient.AccessTokenValidity,
userPoolClient.TokenValidityUnits?.AccessToken ?? "hours",
"24h"
),
keyid: "CognitoLocal",
}),
});
}
}
92 changes: 65 additions & 27 deletions src/targets/getToken.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import {newMockCognitoService} from "../__tests__/mockCognitoService";
import {newMockTokenGenerator} from "../__tests__/mockTokenGenerator";
import {newMockTriggers} from "../__tests__/mockTriggers";
import {newMockUserPoolService} from "../__tests__/mockUserPoolService";
import {TestContext} from "../__tests__/testContext";
import { newMockCognitoService } from "../__tests__/mockCognitoService";
import { newMockTokenGenerator } from "../__tests__/mockTokenGenerator";
import { newMockTriggers } from "../__tests__/mockTriggers";
import { newMockUserPoolService } from "../__tests__/mockUserPoolService";
import { TestContext } from "../__tests__/testContext";
import * as TDB from "../__tests__/testDataBuilder";
import {CognitoService, Triggers, UserPoolService} from "../services";
import {TokenGenerator} from "../services/tokenGenerator";
import { CognitoService, Triggers, UserPoolService } from "../services";
import { TokenGenerator } from "../services/tokenGenerator";

import {
GetToken,
GetTokenTarget,
} from "./getToken";
import { GetToken, GetTokenTarget } from "./getToken";

describe("GetToken target", () => {
let target: GetTokenTarget;

let getToken: GetTokenTarget;
let mockCognitoService: jest.Mocked<CognitoService>;
let mockTokenGenerator: jest.Mocked<TokenGenerator>;
let mockTriggers: jest.Mocked<Triggers>;
Expand All @@ -23,42 +19,84 @@ describe("GetToken target", () => {

beforeEach(() => {
mockUserPoolService = newMockUserPoolService({
Id : userPoolClient.UserPoolId,
Id: userPoolClient.UserPoolId,
});
mockCognitoService = newMockCognitoService(mockUserPoolService);
mockCognitoService.getAppClient.mockResolvedValue(userPoolClient);
mockTriggers = newMockTriggers();
mockTokenGenerator = newMockTokenGenerator();
getToken = GetToken({
triggers : mockTriggers,
cognito : mockCognitoService,
tokenGenerator : mockTokenGenerator,
cognito: mockCognitoService,
tokenGenerator: mockTokenGenerator,
});
});

it("issues access tokens via refresh tokens", async () => {
mockTokenGenerator.generate.mockResolvedValue({
AccessToken : "access",
IdToken : "id",
RefreshToken : "refresh",
AccessToken: "access",
IdToken: "id",
RefreshToken: "refresh",
});

const existingUser = TDB.user({
RefreshTokens : [ "refresh-orig" ],
RefreshTokens: ["refresh-orig"],
});
mockUserPoolService.getUserByRefreshToken.mockResolvedValue(existingUser);
mockUserPoolService.listUserGroupMembership.mockResolvedValue([]);

const response = await getToken(
TestContext,
new URLSearchParams(`client_id=${
userPoolClient
.ClientId}&grant_type=refresh_token&refresh_token=refresh-orig`));
expect(mockUserPoolService.getUserByRefreshToken)
.toHaveBeenCalledWith(TestContext, "refresh-orig");
TestContext,
new URLSearchParams(
`client_id=${userPoolClient.ClientId}&grant_type=refresh_token&refresh_token=refresh-orig`
)
);
expect(mockUserPoolService.getUserByRefreshToken).toHaveBeenCalledWith(
TestContext,
"refresh-orig"
);
expect(mockUserPoolService.storeRefreshToken).not.toHaveBeenCalled();

expect(response.access_token).toEqual("access");
expect(response.refresh_token).toEqual("refresh");
});
});

describe("GetToken target - Client Creds", () => {
let getToken: GetTokenTarget;
let mockCognitoService: jest.Mocked<CognitoService>;
let mockTokenGenerator: jest.Mocked<TokenGenerator>;
let mockUserPoolService: jest.Mocked<UserPoolService>;
const userPoolClient = TDB.appClient({
ClientSecret: "secret",
ClientId: "id",
});

beforeEach(() => {
mockUserPoolService = newMockUserPoolService({
Id: userPoolClient.UserPoolId,
});
mockCognitoService = newMockCognitoService(mockUserPoolService);
mockCognitoService.getAppClient.mockResolvedValue(userPoolClient);
mockTokenGenerator = newMockTokenGenerator();
getToken = GetToken({
cognito: mockCognitoService,
tokenGenerator: mockTokenGenerator,
});
});

it("issues access tokens via client credentials", async () => {
mockTokenGenerator.generateWithClientCreds.mockResolvedValue({
AccessToken: "access",
RefreshToken: null,
IdToken: null,
});

const response = await getToken(
TestContext,
new URLSearchParams(
`client_id=${userPoolClient.ClientId}&client_secret=${userPoolClient.ClientSecret}&grant_type=client_credentials`
)
);
expect(response.access_token).toEqual("access");
});
});
40 changes: 36 additions & 4 deletions src/targets/getToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ type GetTokenRequest = URLSearchParams;

interface GetTokenResponse {
access_token: string;
refresh_token: string;
refresh_token?: string;
}

export type GetTokenTarget = Target<GetTokenRequest, GetTokenResponse>;

async function getRefreshToken(
async function getWithRefreshToken(
ctx: Context,
services: HandleTokenServices,
params: GetTokenRequest
Expand Down Expand Up @@ -51,6 +51,37 @@ async function getRefreshToken(
};
}

async function getWithClientCredentials(
ctx: Context,
services: HandleTokenServices,
params: GetTokenRequest
) {
const clientId = params.get("client_id");
const clientSecret = params.get("client_secret");
const userPoolClient = await services.cognito.getAppClient(ctx, clientId);
if (!userPoolClient) {
throw new NotAuthorizedError();
}
if (
userPoolClient.ClientSecret &&
userPoolClient.ClientSecret != clientSecret
) {
throw new NotAuthorizedError();
}

const tokens = await services.tokenGenerator.generateWithClientCreds(
ctx,
userPoolClient
);
if (!tokens) {
throw new NotAuthorizedError();
}

return {
access_token: tokens.AccessToken,
};
}

export const GetToken =
(services: HandleTokenServices): GetTokenTarget =>
async (ctx, req) => {
Expand All @@ -60,12 +91,13 @@ export const GetToken =
throw new NotImplementedError();
}
case "client_credentials": {
throw new NotImplementedError();
return getWithClientCredentials(ctx, services, params);
}
case "refresh_token": {
return getRefreshToken(ctx, services, params);
return getWithRefreshToken(ctx, services, params);
}
default: {
console.log("Invalid grant type passed:", params.get("grant_type"));
throw new InvalidParameterError();
}
}
Expand Down

0 comments on commit 493572b

Please sign in to comment.