Skip to content

Commit f3adf76

Browse files
committed
feat(api): add support for ClientCreds style cognito tokens
Before, only user creds were supported by the oauth2/token route.
1 parent 5bcf24e commit f3adf76

File tree

5 files changed

+194
-44
lines changed

5 files changed

+194
-44
lines changed

src/__tests__/mockTokenGenerator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ import { TokenGenerator } from "../services/tokenGenerator";
22

33
export const newMockTokenGenerator = (): jest.Mocked<TokenGenerator> => ({
44
generate: jest.fn(),
5+
generateWithClientCreds: jest.fn(),
56
});

src/server/server.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,28 @@ export const createServer = (
6868
req.on("end", function () {
6969
const target = "GetToken";
7070
const route = router(target);
71-
route({ logger: req.log }, rawBody).then(
71+
72+
const parsed = new URLSearchParams(rawBody);
73+
const params = {
74+
grant_type: parsed.get("grant_type"),
75+
client_id: parsed.get("client_id"),
76+
client_secret: parsed.get("client_secret"),
77+
refresh_token: parsed.get("refresh_token"),
78+
};
79+
80+
const auth = req.get("Authorization");
81+
if (auth && auth.startsWith("Basic ")) {
82+
const sliced = auth.slice("Basic ".length);
83+
const buff = new Buffer(sliced, "base64");
84+
const decoded = buff.toString("ascii");
85+
const creds = decoded.split(":");
86+
if (creds.length == 2) {
87+
params.client_id = creds[0];
88+
params.client_secret = creds[1];
89+
}
90+
}
91+
92+
route({ logger: req.log }, params).then(
7293
(output) => {
7394
res.status(200).type("json").send(JSON.stringify(output));
7495
},

src/services/tokenGenerator.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ const applyTokenOverrides = (
8686

8787
export interface Tokens {
8888
readonly AccessToken: string;
89-
readonly IdToken: string;
90-
readonly RefreshToken: string;
89+
readonly IdToken?: string;
90+
readonly RefreshToken?: string;
9191
}
9292

9393
export interface TokenGenerator {
@@ -104,6 +104,10 @@ export interface TokenGenerator {
104104
| "NewPasswordChallenge"
105105
| "RefreshTokens"
106106
): Promise<Tokens>;
107+
generateWithClientCreds(
108+
ctx: Context,
109+
userPoolClient: AppClient
110+
): Promise<Tokens>;
107111
}
108112

109113
const formatExpiration = (
@@ -240,4 +244,38 @@ export class JwtTokenGenerator implements TokenGenerator {
240244
),
241245
};
242246
}
247+
248+
public async generateWithClientCreds(
249+
ctx: Context,
250+
userPoolClient: AppClient
251+
): Promise<Tokens> {
252+
const eventId = uuid.v4();
253+
const authTime = Math.floor(this.clock.get().getTime() / 1000);
254+
255+
const accessToken: RawToken = {
256+
auth_time: authTime,
257+
client_id: userPoolClient.ClientId,
258+
event_id: eventId,
259+
iat: authTime,
260+
jti: uuid.v4(),
261+
scope: "aws.cognito.signin.user.admin", // TODO: scopes
262+
sub: userPoolClient.ClientId,
263+
token_use: "access",
264+
};
265+
266+
const issuer = `${this.tokenConfig.IssuerDomain}/${userPoolClient.UserPoolId}`;
267+
268+
return await Promise.resolve({
269+
AccessToken: jwt.sign(accessToken, PrivateKey.pem, {
270+
algorithm: "RS256",
271+
issuer,
272+
expiresIn: formatExpiration(
273+
userPoolClient.AccessTokenValidity,
274+
userPoolClient.TokenValidityUnits?.AccessToken ?? "hours",
275+
"24h"
276+
),
277+
keyid: "CognitoLocal",
278+
}),
279+
});
280+
}
243281
}

src/targets/getToken.test.ts

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1-
import {newMockCognitoService} from "../__tests__/mockCognitoService";
2-
import {newMockTokenGenerator} from "../__tests__/mockTokenGenerator";
3-
import {newMockTriggers} from "../__tests__/mockTriggers";
4-
import {newMockUserPoolService} from "../__tests__/mockUserPoolService";
5-
import {TestContext} from "../__tests__/testContext";
1+
import { newMockCognitoService } from "../__tests__/mockCognitoService";
2+
import { newMockTokenGenerator } from "../__tests__/mockTokenGenerator";
3+
import { newMockTriggers } from "../__tests__/mockTriggers";
4+
import { newMockUserPoolService } from "../__tests__/mockUserPoolService";
5+
import { TestContext } from "../__tests__/testContext";
66
import * as TDB from "../__tests__/testDataBuilder";
7-
import {CognitoService, Triggers, UserPoolService} from "../services";
8-
import {TokenGenerator} from "../services/tokenGenerator";
7+
import { CognitoService, Triggers, UserPoolService } from "../services";
8+
import { TokenGenerator } from "../services/tokenGenerator";
99

10-
import {
11-
GetToken,
12-
GetTokenTarget,
13-
} from "./getToken";
10+
import { GetToken, GetTokenTarget } from "./getToken";
1411

1512
describe("GetToken target", () => {
16-
let target: GetTokenTarget;
17-
13+
let getToken: GetTokenTarget;
1814
let mockCognitoService: jest.Mocked<CognitoService>;
1915
let mockTokenGenerator: jest.Mocked<TokenGenerator>;
2016
let mockTriggers: jest.Mocked<Triggers>;
@@ -23,42 +19,82 @@ describe("GetToken target", () => {
2319

2420
beforeEach(() => {
2521
mockUserPoolService = newMockUserPoolService({
26-
Id : userPoolClient.UserPoolId,
22+
Id: userPoolClient.UserPoolId,
2723
});
2824
mockCognitoService = newMockCognitoService(mockUserPoolService);
2925
mockCognitoService.getAppClient.mockResolvedValue(userPoolClient);
3026
mockTriggers = newMockTriggers();
3127
mockTokenGenerator = newMockTokenGenerator();
3228
getToken = GetToken({
33-
triggers : mockTriggers,
34-
cognito : mockCognitoService,
35-
tokenGenerator : mockTokenGenerator,
29+
cognito: mockCognitoService,
30+
tokenGenerator: mockTokenGenerator,
3631
});
3732
});
3833

3934
it("issues access tokens via refresh tokens", async () => {
4035
mockTokenGenerator.generate.mockResolvedValue({
41-
AccessToken : "access",
42-
IdToken : "id",
43-
RefreshToken : "refresh",
36+
AccessToken: "access",
37+
IdToken: "id",
38+
RefreshToken: "refresh",
4439
});
4540

4641
const existingUser = TDB.user({
47-
RefreshTokens : [ "refresh-orig" ],
42+
RefreshTokens: ["refresh-orig"],
4843
});
4944
mockUserPoolService.getUserByRefreshToken.mockResolvedValue(existingUser);
5045
mockUserPoolService.listUserGroupMembership.mockResolvedValue([]);
5146

52-
const response = await getToken(
53-
TestContext,
54-
new URLSearchParams(`client_id=${
55-
userPoolClient
56-
.ClientId}&grant_type=refresh_token&refresh_token=refresh-orig`));
57-
expect(mockUserPoolService.getUserByRefreshToken)
58-
.toHaveBeenCalledWith(TestContext, "refresh-orig");
47+
const response = await getToken(TestContext, {
48+
client_id: userPoolClient.ClientId,
49+
grant_type: "refresh_token",
50+
refresh_token: "refresh-orig",
51+
});
52+
expect(mockUserPoolService.getUserByRefreshToken).toHaveBeenCalledWith(
53+
TestContext,
54+
"refresh-orig"
55+
);
5956
expect(mockUserPoolService.storeRefreshToken).not.toHaveBeenCalled();
6057

6158
expect(response.access_token).toEqual("access");
6259
expect(response.refresh_token).toEqual("refresh");
6360
});
6461
});
62+
63+
describe("GetToken target - Client Creds", () => {
64+
let getToken: GetTokenTarget;
65+
let mockCognitoService: jest.Mocked<CognitoService>;
66+
let mockTokenGenerator: jest.Mocked<TokenGenerator>;
67+
let mockUserPoolService: jest.Mocked<UserPoolService>;
68+
const userPoolClient = TDB.appClient({
69+
ClientSecret: "secret",
70+
ClientId: "id",
71+
});
72+
73+
beforeEach(() => {
74+
mockUserPoolService = newMockUserPoolService({
75+
Id: userPoolClient.UserPoolId,
76+
});
77+
mockCognitoService = newMockCognitoService(mockUserPoolService);
78+
mockCognitoService.getAppClient.mockResolvedValue(userPoolClient);
79+
mockTokenGenerator = newMockTokenGenerator();
80+
getToken = GetToken({
81+
cognito: mockCognitoService,
82+
tokenGenerator: mockTokenGenerator,
83+
});
84+
});
85+
86+
it("issues access tokens via client credentials", async () => {
87+
mockTokenGenerator.generateWithClientCreds.mockResolvedValue({
88+
AccessToken: "access",
89+
RefreshToken: null,
90+
IdToken: null,
91+
});
92+
93+
const response = await getToken(TestContext, {
94+
client_id: userPoolClient.ClientId,
95+
client_secret: userPoolClient.ClientSecret,
96+
grant_type: "client_credentials",
97+
});
98+
expect(response.access_token).toEqual("access");
99+
});
100+
});

src/targets/getToken.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,42 @@ import { Target } from "../targets/Target";
99

1010
type HandleTokenServices = Pick<Services, "cognito" | "tokenGenerator">;
1111

12-
type GetTokenRequest = URLSearchParams;
12+
export type GetTokenRequest =
13+
| GetTokenRequestClientCreds
14+
| GetTokenRequestRefreshToken
15+
| GetTokenRequestAuthCode;
16+
17+
interface GetTokenRequestGrantType {
18+
grant_type: "authorization_code" | "client_credentials" | "refresh_token";
19+
client_id: string;
20+
}
21+
22+
interface GetTokenRequestClientCreds extends GetTokenRequestGrantType {
23+
client_secret: string;
24+
}
25+
26+
type GetTokenRequestAuthCode = GetTokenRequestGrantType;
27+
28+
interface GetTokenRequestRefreshToken extends GetTokenRequestGrantType {
29+
refresh_token: string;
30+
}
1331

1432
interface GetTokenResponse {
1533
access_token: string;
16-
refresh_token: string;
34+
refresh_token?: string;
1735
}
1836

1937
export type GetTokenTarget = Target<GetTokenRequest, GetTokenResponse>;
2038

21-
async function getRefreshToken(
39+
async function getWithRefreshToken(
2240
ctx: Context,
2341
services: HandleTokenServices,
24-
params: GetTokenRequest
42+
params: GetTokenRequestRefreshToken
2543
) {
26-
const clientId = params.get("client_id");
44+
const clientId = params.client_id;
2745
const userPool = await services.cognito.getUserPoolForClientId(ctx, clientId);
2846
const userPoolClient = await services.cognito.getAppClient(ctx, clientId);
29-
const user = await userPool.getUserByRefreshToken(
30-
ctx,
31-
params.get("refresh_token")
32-
);
47+
const user = await userPool.getUserByRefreshToken(ctx, params.refresh_token);
3348
if (!user || !userPoolClient) {
3449
throw new NotAuthorizedError();
3550
}
@@ -51,21 +66,60 @@ async function getRefreshToken(
5166
};
5267
}
5368

69+
async function getWithClientCredentials(
70+
ctx: Context,
71+
services: HandleTokenServices,
72+
params: GetTokenRequestClientCreds
73+
) {
74+
const clientId = params.client_id;
75+
const clientSecret = params.client_secret;
76+
const userPoolClient = await services.cognito.getAppClient(ctx, clientId);
77+
if (!userPoolClient) {
78+
throw new NotAuthorizedError();
79+
}
80+
if (
81+
userPoolClient.ClientSecret &&
82+
userPoolClient.ClientSecret != clientSecret
83+
) {
84+
throw new NotAuthorizedError();
85+
}
86+
87+
const tokens = await services.tokenGenerator.generateWithClientCreds(
88+
ctx,
89+
userPoolClient
90+
);
91+
if (!tokens) {
92+
throw new NotAuthorizedError();
93+
}
94+
95+
return {
96+
access_token: tokens.AccessToken,
97+
};
98+
}
99+
54100
export const GetToken =
55101
(services: HandleTokenServices): GetTokenTarget =>
56102
async (ctx, req) => {
57-
const params = new URLSearchParams(req);
58-
switch (params.get("grant_type")) {
103+
switch (req.grant_type) {
59104
case "authorization_code": {
60105
throw new NotImplementedError();
61106
}
62107
case "client_credentials": {
63-
throw new NotImplementedError();
108+
return getWithClientCredentials(
109+
ctx,
110+
services,
111+
req as GetTokenRequestClientCreds
112+
);
64113
}
65114
case "refresh_token": {
66-
return getRefreshToken(ctx, services, params);
115+
return getWithRefreshToken(
116+
ctx,
117+
services,
118+
req as GetTokenRequestRefreshToken
119+
);
67120
}
68121
default: {
122+
console.log("Invalid grant type passed:", req.grant_type);
69123
throw new InvalidParameterError();
70124
}
71125
}

0 commit comments

Comments
 (0)