Skip to content

Commit

Permalink
change /userinfo endpoint to return correct user data
Browse files Browse the repository at this point in the history
also /userinfo now expects authorization header value to be base64 encoded
  • Loading branch information
ldgit committed Sep 21, 2024
1 parent 37834ba commit 97f046b
Show file tree
Hide file tree
Showing 7 changed files with 407 additions and 20 deletions.
70 changes: 58 additions & 12 deletions e2e/oauth2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../database/createDummyData.js";
import { query } from "../database/database.js";
import { createAuthorizationToken } from "../library/oauth2/authorizationToken.js";
import { type UserData, findUserByUsername } from "../library/user.js";
import type { AccessTokenRequestQueryParams } from "../routes/api.js";

async function createTestClient(baseURL: string) {
Expand Down Expand Up @@ -106,12 +107,17 @@ test("oauth2 flow happy path", async ({ page, baseURL }) => {
const responseJson = await response.json();
assertAccessTokenResponseFollowsSpecs(responseJson, await response.headers());

await assertUserinfoEndpointWorks(responseJson.access_token);
await assertUserinfoEndpointWorks(responseJson.access_token, {
sub: await getUserIdFromUsername("MarkS"),
preferred_username: "MarkS",
given_name: "Mark",
family_name: "Scout",
});
});

test("oauth2 flow happy path when the user is already signed in", async ({ page, baseURL }) => {
// Sign in the user first.
await signInUser(page, "MarkS", "test");
await signInUser(page, "IrvingB", "test");

const { id, name, redirectUri, secret } = await createTestClient(baseURL as string);
const codeVerifier = generateCodeVerifier();
Expand Down Expand Up @@ -148,7 +154,12 @@ test("oauth2 flow happy path when the user is already signed in", async ({ page,
const responseJson = await response.json();
assertAccessTokenResponseFollowsSpecs(responseJson, await response.headers());

await assertUserinfoEndpointWorks(responseJson.access_token);
await assertUserinfoEndpointWorks(responseJson.access_token, {
sub: await getUserIdFromUsername("IrvingB"),
preferred_username: "IrvingB",
given_name: "Irving",
family_name: "Bailiff",
});
});

["", "0054478d-431c-4e21-bc48-ffb4c3eb2ac0"].forEach((notAClientId) => {
Expand All @@ -172,7 +183,7 @@ test("oauth2 flow happy path when the user is already signed in", async ({ page,
).toBeVisible();
});

test(`authorization endpoint should should warn resource owner (user) if client doesn't exists (${notAClientId}) even if other parameters are missing`, async ({
test(`/authorize endpoint should should warn resource owner (user) if client doesn't exists (${notAClientId}) even if other parameters are missing`, async ({
page,
}) => {
await page.goto(`/authorize?client_id=${notAClientId}&redirect_uri=https://www.google.com`);
Expand Down Expand Up @@ -807,6 +818,37 @@ test("/token endpoint should respond with 400 status code and invalid_grant erro
expectTokenEndpointHeadersAreCorrect(response.headers());
});

test("/userinfo endpoint should respond with 401 error code if access token is invalid", async ({
request,
}) => {
const invalidAccessToken = cryptoRandomString({
length: 64,
characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~",
});

const response = await request.post("/api/v1/userinfo", {
headers: { Authorization: `Bearer ${base64encode(invalidAccessToken)}` },
});

expect(response.status()).toEqual(401);
expect(response.statusText()).toEqual("Unauthorized");
expect(response.headers()["www-authenticate"]).toEqual("Bearer");
expect(await response.json()).toEqual({ error: "invalid_token" });
});

test("/userinfo endpoint should respond with 401 error code if no authentication is provided", async ({
request,
}) => {
const response = await request.post("/api/v1/userinfo");

expect(response.status()).toEqual(401);
expect(response.statusText()).toEqual("Unauthorized");
expect(response.headers()["www-authenticate"]).toEqual("Bearer");
expect(await response.text()).toEqual("");
});

test.skip("/userinfo endpoint should respond with 401 error code if access token has expired", () => {});

/**
* TODO validation for POST /token tests:
* + one of the parameters is missing: respond with 400 http status code, `error: invalid_request`
Expand Down Expand Up @@ -881,7 +923,7 @@ function assertAccessTokenResponseFollowsSpecs(responseJson: any, headers: any)
expect(responseJson.access_token).not.toBeFalsy();
expect(responseJson.token_type).toEqual("Bearer");
expect(responseJson.expires_in).toEqual(86400);
expect(responseJson.scope).toEqual("basic_info");
expect(responseJson.scope).toEqual("openid");
expectTokenEndpointHeadersAreCorrect(headers);
}

Expand All @@ -895,18 +937,22 @@ function expectTokenEndpointHeadersAreCorrect(headers: any) {
* @see https://playwright.dev/docs/api-testing#using-request-context We deliberately create new request context
* so we don't accidentally send user's session cookie in the API request.
*/
async function assertUserinfoEndpointWorks(accessToken: string) {
async function assertUserinfoEndpointWorks(accessToken: string, expectedUserData: any) {
const apiRequest = await request.newContext();
/**
* Using the access token fetch basic user info from the resource server.
*/
const resourceResponse = await apiRequest.post("/api/v1/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` },
headers: { Authorization: `Bearer ${base64encode(accessToken)}` },
});
expect(resourceResponse.ok()).toBeTruthy();
await expect(await resourceResponse.json()).toEqual({
username: "MarkS",
name: "Mark",
surname: "Scout",
});
await expect(await resourceResponse.json()).toEqual(expectedUserData);
}

function base64encode(text: string): string {
return Buffer.from(text).toString("base64");
}

async function getUserIdFromUsername(username: string): Promise<string> {
return ((await findUserByUsername(username)) as UserData)?.id;
}
128 changes: 128 additions & 0 deletions library/oauth2/accessToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import cryptoRandomString from "crypto-random-string";
import { differenceInSeconds } from "date-fns";
import { describe, expect, it } from "vitest";
import { DUMMY_CLIENT_ID } from "../../database/createDummyData.js";
import { query } from "../../database/database.js";
import {
type AccessTokenData,
createAccessTokenForAuthorizationCode,
extractAccessTokenFromHeader,
findAccessTokenByValue,
} from "./accessToken.js";
import { createAuthorizationToken } from "./authorizationToken.js";

describe("fetching access token from database by code", () => {
it("if token with specified code is not found, return null", async () => {
const token = await findAccessTokenByValue("this code does not exist in the database");
expect(token).toBeNull();
});

it("if token with specified code is found, return its data", async () => {
const userId = (await query("SELECT id FROM users WHERE username = $1", ["HellyR"])).rows[0].id;
const codeChallenge = createHash("sha256")
.update(generateRandomString({ length: 64 }))
.digest("base64url");
const authorizationToken = await createAuthorizationToken(
DUMMY_CLIENT_ID,
userId,
"openid",
codeChallenge,
"S256",
);
const accessToken = await createAccessTokenForAuthorizationCode(authorizationToken);
const expectedTokenCreationDate = new Date();

const tokenData = (await findAccessTokenByValue(accessToken.value)) as AccessTokenData;

expect(tokenData?.id).not.toBeFalsy();
expect(tokenData?.clientId).toEqual(DUMMY_CLIENT_ID);
expect(tokenData?.userId).toEqual(userId);
expect(tokenData?.value).toEqual(accessToken.value);
expect(tokenData?.scope).toEqual("openid");
expect(tokenData?.expiresIn).toStrictEqual(86400);
expect(differenceInSeconds(tokenData.createdAt, expectedTokenCreationDate)).toBeLessThan(2);
const queryResult = query("SELECT id FROM authorization_tokens WHERE id = $1", [
tokenData.authorizationTokenId.toString(),
]);
expect((await queryResult).rowCount).toEqual(1);
});
});

describe("generating access token", () => {
it("generating access token should return freshly created token value, expiration info and scope", async () => {
const userId = (await query("SELECT id FROM users WHERE username = $1", ["HellyR"])).rows[0].id;
const codeChallenge = createHash("sha256")
.update(generateRandomString({ length: 64 }))
.digest("base64url");
const authorizationToken = await createAuthorizationToken(
DUMMY_CLIENT_ID,
userId,
"openid",
codeChallenge,
"S256",
);

const { value, scope, expiresIn } =
await createAccessTokenForAuthorizationCode(authorizationToken);

expect(value).not.toBeFalsy();
expect(value.length).toEqual(64);
expect(scope).toEqual("openid");
expect(expiresIn).toStrictEqual(86400);
});

it("generating access token should throw error if provided authorization token does not exist", async () => {
await expect(
async () => await createAccessTokenForAuthorizationCode("this auth code does not exist"),
).rejects.toThrowError("Authorization code not found.");
});

it("generating access token should throw error if provided authorization token is already tied to an access token", async () => {
const userId = (await query("SELECT id FROM users WHERE username = $1", ["HellyR"])).rows[0].id;
const codeChallenge = createHash("sha256")
.update(generateRandomString({ length: 64 }))
.digest("base64url");
const authorizationToken = await createAuthorizationToken(
DUMMY_CLIENT_ID,
userId,
"openid",
codeChallenge,
"S256",
);

await createAccessTokenForAuthorizationCode(authorizationToken);

await expect(
async () => await createAccessTokenForAuthorizationCode(authorizationToken),
).rejects.toThrowError("Authorization code already has an access token.");
});
});

describe("extractAccessTokenFromHeader should extract and base64 decode the header value to return a token", () => {
it("should extract access token from authorization request header", () => {
const expectedAccessToken = generateRandomString({ length: 64 });
const accessToken = extractAccessTokenFromHeader(`Bearer ${base64encode(expectedAccessToken)}`);
expect(accessToken).toEqual(expectedAccessToken);
});

[base64encode("an access token"), "Bearer", "", "Bearer "].forEach(
(malformedAuthorizationHeader) => {
it(`should return empty string if authorization header is malformed (${malformedAuthorizationHeader})`, () => {
const accessToken = extractAccessTokenFromHeader(malformedAuthorizationHeader);
expect(accessToken).toStrictEqual("");
});
},
);
});

function base64encode(text: string): string {
return Buffer.from(text).toString("base64");
}

function generateRandomString({ length }: { length: number }) {
return cryptoRandomString({
length,
characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~",
});
}
88 changes: 88 additions & 0 deletions library/oauth2/accessToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import cryptoRandomString from "crypto-random-string";
import { query } from "../../database/database.js";
import { getAuthorizationTokenByCode } from "./authorizationToken.js";

export interface AccessTokenData {
id: number;
createdAt: Date;
/** In seconds. */
expiresIn: number;
value: string;
scope: string;
clientId: string;
userId: string;
authorizationTokenId: number;
}

/**
* Generates an access token that lasts for 24 hours.
*/
export async function createAccessTokenForAuthorizationCode(
authorizationToken: string,
): Promise<{ value: string; expiresIn: number; scope: string }> {
const value = cryptoRandomString({
length: 64,
characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~",
});

const authorizationTokenData = await getAuthorizationTokenByCode(authorizationToken);
if (!authorizationTokenData) {
throw new Error("Authorization code not found.");
}

const queryResult = await query(
"SELECT id FROM access_tokens WHERE authorization_token_id = $1",
[authorizationTokenData.id],
);
if (queryResult.rowCount !== null && queryResult.rowCount > 0) {
throw new Error("Authorization code already has an access token.");
}

await query(
"INSERT INTO access_tokens(value, scope, client_id, user_id, authorization_token_id, expires_in) VALUES($1, $2, $3, $4, $5, $6)",
[
value,
authorizationTokenData?.scope,
authorizationTokenData?.clientId,
authorizationTokenData?.userId,
authorizationTokenData?.id,
"86400",
],
);

return { value, expiresIn: 86400, scope: authorizationTokenData?.scope };
}

export async function findAccessTokenByValue(accessToken: string): Promise<AccessTokenData | null> {
const result = await query(
"SELECT id, value, scope, created_at, client_id, user_id, authorization_token_id, expires_in FROM access_tokens WHERE value = $1",
[accessToken],
);

if (result.rowCount !== 1) {
return null;
}

const tokenData = result.rows[0];

return {
id: tokenData.id,
clientId: tokenData.client_id,
userId: tokenData.user_id,
value: tokenData.value,
scope: tokenData.scope,
expiresIn: tokenData.expires_in,
authorizationTokenId: tokenData.authorization_token_id,
createdAt: tokenData.created_at,
};
}

export function extractAccessTokenFromHeader(authorizationHeader: string): string {
if (!authorizationHeader.startsWith("Bearer ")) {
return "";
}
const encodedAccessToken = authorizationHeader.split(" ")[1];
const accessToken = Buffer.from(encodedAccessToken, "base64").toString();

return accessToken;
}
40 changes: 40 additions & 0 deletions library/user.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as argon2 from "argon2";
import { isDate } from "date-fns";
import { validate as isValidUUID, v4 as uuidv4 } from "uuid";
import { describe, expect, it } from "vitest";
import { type UserData, findUserById, findUserByUsername } from "./user.js";

describe("user functions", () => {
it("findUserByUsername should return null if user does not exist", async () => {
expect(await findUserByUsername("thisUserDoesNotExist")).toBeNull();
});

it("findUserByUsername should return user data if user exists", async () => {
const userData = (await findUserByUsername("DylanG")) as UserData;

expect(isValidUUID(userData.id));
expect(userData.username).toEqual("DylanG");
expect(userData.firstname).toEqual("Dylan");
expect(userData.lastname).toEqual("George");
expect(isDate(userData.createdAt)).toStrictEqual(true);
const passwordMatches = await argon2.verify(userData.password, "test");
expect(passwordMatches).toStrictEqual(true);
});

it("findUserId should return null if user does not exists", async () => {
const actualUserData = (await findUserById(uuidv4())) as UserData;
expect(actualUserData).toEqual(null);
});

it("findUserId should return null if user id is not a uuid", async () => {
const actualUserData = (await findUserById("someId")) as UserData;
expect(actualUserData).toEqual(null);
});

it("findUserId should return user data if user exists", async () => {
const expectedUserData = (await findUserByUsername("DylanG")) as UserData;
const actualUserData = (await findUserById(expectedUserData.id)) as UserData;

expect(actualUserData).toEqual(expectedUserData);
});
});
Loading

0 comments on commit 97f046b

Please sign in to comment.