-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
change /userinfo endpoint to return correct user data
also /userinfo now expects authorization header value to be base64 encoded
- Loading branch information
Showing
7 changed files
with
407 additions
and
20 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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-._~", | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
Oops, something went wrong.