Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth2 implementation #2

Merged
merged 48 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3f92270
- some oauth2 routes (WIP)
ldgit Jul 27, 2024
4443869
use appropriate heading tag on homepage
ldgit Jul 27, 2024
4e343d9
[WIP] created basic oauth2 flow happy path:
ldgit Aug 25, 2024
f4a4717
how did that get in there
ldgit Aug 26, 2024
24b5713
better installation and use instructions
ldgit Aug 26, 2024
509b62c
[WIP] started on oauth2 parameter validation:
ldgit Aug 26, 2024
420ca92
- `/authorize` endpoint warns resource owner if client does not exist
ldgit Aug 28, 2024
21f8c10
minor text change
ldgit Aug 28, 2024
2b228db
`/authorize` page should redirect user directly to `/approve` page if…
ldgit Aug 28, 2024
31316f4
turn off noExplicitAny completely because biome.js doesn't have a com…
ldgit Aug 28, 2024
a70475e
preserve query parameters on login validation errors
ldgit Aug 28, 2024
c7a0ffa
- test for empty client id
ldgit Aug 28, 2024
1167306
expand `authorization_tokens` table with new columns
ldgit Aug 28, 2024
38a2fcd
[WIP] adding to `/authorize` endpoint functionality:
ldgit Aug 29, 2024
888e86e
moved all functionality of `/approve` routes to `/authorize` to simpl…
ldgit Aug 29, 2024
74ed401
validation of `scope` parameter in `GET /authorize` endpoint
ldgit Aug 29, 2024
37914e6
lint fix
ldgit Aug 29, 2024
d08f8a9
validation of `code_challenge` parameter in `GET /authorize` endpoint
ldgit Aug 29, 2024
5be4a52
validation of `code_challenge_method` parameter in `GET /authorize` e…
ldgit Aug 29, 2024
8848a63
improved `POST /authorize` route:
ldgit Sep 6, 2024
9d5b7f1
playwright config tweak
ldgit Sep 6, 2024
97cdde5
implemented deny auth request button
ldgit Sep 6, 2024
85a51c0
comment cleanup
ldgit Sep 6, 2024
c867739
[WIP] implementing OpenID Connect:
ldgit Sep 6, 2024
135930c
minor tweak: do not send error param leftover from `/login` validatio…
ldgit Sep 6, 2024
c8b906a
do not reuse browser context in API endpoints in e2e tests
ldgit Sep 8, 2024
3a9f74a
add some `/token` API endpoint validations
ldgit Sep 8, 2024
da94e32
implement PKCE verification on access token request
ldgit Sep 13, 2024
0e5e9ce
validate given authorization code exists on /token endpoint
ldgit Sep 13, 2024
0b66524
validate that auth code actually is for the authenticated client on /…
ldgit Sep 13, 2024
6a4cfe4
validate grant_type in /token request
ldgit Sep 18, 2024
9779c4a
validate repeated parameters in /token request
ldgit Sep 18, 2024
6e838d7
validate authorization code has not expired in /token request
ldgit Sep 18, 2024
88b605b
changed all created_at columns to timestamptz type
ldgit Sep 18, 2024
f0856f6
fix e2e test so it doesn't try to store duplicate authorization codes…
ldgit Sep 18, 2024
d6af450
fix flaky test
ldgit Sep 21, 2024
37834ba
better cleanup after tests, don't delete dummy users
ldgit Sep 21, 2024
97f046b
change /userinfo endpoint to return correct user data
ldgit Sep 21, 2024
9417269
bugfix: specify realm for Basic authentication www-authenticate header
ldgit Sep 21, 2024
2efe134
replace deprecated btoa and atob functions
ldgit Sep 21, 2024
dd67ec1
use new findUserBy* functions to find users in database
ldgit Sep 21, 2024
7dc9184
fix typescript errors
ldgit Sep 21, 2024
5cc7835
/userinfo endpoint returns 401 if access token has expired
ldgit Sep 22, 2024
c697f50
/userinfo endpoint should use GET method
ldgit Sep 22, 2024
0fd62ac
refactoring for consistency and easier use
ldgit Sep 23, 2024
0ea449d
revoke access token issued for auth token if auth token is used more …
ldgit Sep 23, 2024
621f375
change return value for function creating access tokens so it contain…
ldgit Sep 23, 2024
9a55b36
install helmet plugin to protect against clickjacking attacks
ldgit Sep 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,34 @@ OAuth 2.0 authorization server implementation. Follows specification defined in:

## Local Development

Steps to run the app:
1. Create the `.env` file: `cp .dev.env .env`
2. Start up the database docker container: `docker-compose up -d`
3. Set up the dev database: `npm run dev-db`
4. Start the local server: `npm run dev`
5. If working on css styles run `npm run styles -- --watch` separately.
6. Access in browser on `http://127.0.0.1:3000/`
### First time setup

1. Create the `.env` file: `cp .dev.env .env`.
2. Install npm packages: `npm ci`.
3. `npx playwright install --with-deps` to install Playwright browsers for e2e testing.

### Starting the server

1. Start up the database docker container: `docker-compose up -d`.
2. Set up the dev database: `npm run dev-db`.
3. Start the local server: `npm run dev`.
4. If working on css styles run `npm run styles -- --watch` separately.
5. Access in browser on `http://127.0.0.1:3000/`.

### Testing

To run unit and integration tests: `npm t`

To run end to end tests:
1. ***Optional*** `npm run dev` to start up the dev server. Playwright will do this automatically if the server is not running, but this is useful if you wish to see server errors in the console.
2. `npm run e2e` to run the tests.

## Tech used

- Fastify web framework
- PostgreSQL for database
- ejs for templating
- Tailwind for styles
- Vitest for unit and integration testing
- Playwright for e2e testing
- biome.js for linting and enforcing code style
6 changes: 4 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
"rules": {
"recommended": true,
"complexity": {
"useArrowFunction": "off"
"useArrowFunction": "off",
"noForEach": "off"
},
"suspicious": {
"useAwait": "error"
"useAwait": "error",
"noExplicitAny": "off"
}
}
},
Expand Down
30 changes: 27 additions & 3 deletions database/createDummyData.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import * as argon2 from "argon2";
import { query, transactionQuery } from "../database/database.js";

/** Only for use in tests. */
export const DUMMY_CLIENT_ID = "23f0706a-f556-477f-a8cb-808bd045384f";
export const DUMMY_CLIENT_NAME = "Lumon Industries";
export const DUMMY_CLIENT_REDIRECT_URI = "http://127.0.0.1:3000/";
export const DUMMY_CLIENT_SECRET = "secret_123";

/**
* Fills the database with dummy data.
*
* For use in automated tests and local testing. Deletes all existing data.
*/
export async function createDummyData() {
const password = "test";
await query("TRUNCATE clients");
await query("TRUNCATE sessions, users");

console.log("Creating dummy data ");
console.log("Deleting all existing data");
await query("TRUNCATE clients, authorization_tokens, access_tokens, sessions, users");

console.log("Creating dummy data");

await transactionQuery(
async (client) => {
Expand All @@ -27,7 +34,24 @@ export async function createDummyData() {
hash = await argon2.hash(password);
await client.query(queryText, ["Irving", "Bailiff", "IrvingB", hash]);
console.log("Created user IrvingB");
await client.query(queryText, ["Dylan", "George", "DylanG", hash]);
console.log("Created user DylanG");
console.log('All users use same password: "test"');
console.log("---");

console.log("Creating clients");
await client.query(
"INSERT INTO clients(id, name, redirect_uri, secret, description) VALUES($1, $2, $3, $4, $5) RETURNING id",
[
DUMMY_CLIENT_ID,
DUMMY_CLIENT_NAME,
DUMMY_CLIENT_REDIRECT_URI,
await argon2.hash(DUMMY_CLIENT_SECRET),
"A dummy client used for testing and development purposes.",
],
);
console.log(`Created a test client ${DUMMY_CLIENT_NAME} (id: ${DUMMY_CLIENT_ID})`);
console.log(`Client redirect URI: ${DUMMY_CLIENT_REDIRECT_URI}`);
},
{ destroyClient: true },
);
Expand Down
26 changes: 17 additions & 9 deletions database/database.test.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import type { QueryResult } from "pg";
import { v4 as uuidv4 } from "uuid";
import { beforeAll, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { query, transactionQuery } from "./database.js";

const passwordHash =
"$argon2id$v=19$m=65536,t=3,p=4$P5wGfnyG6tNP2iwvWPp9SA$Gp3wgJZC1xe6fVzUTMmqgCGgFPyZeCt1aXjUtlwSMmo";

describe("database adapter", () => {
beforeAll(async () => {
await query("TRUNCATE users CASCADE");
const usersForCleanup: string[] = [];

afterEach(async () => {
for (const username of usersForCleanup) {
await query("DELETE FROM users WHERE username = $1", [username]);
}
});

it("should support storing and fetching data from database", async () => {
await query(
'INSERT INTO users(firstname, lastname, username, "password") VALUES($1, $2, $3, $4)',
["Mark", "Scout", "MarkS", passwordHash],
["Harmony", "Cobel", "HarmonyC", passwordHash],
);
usersForCleanup.push("HarmonyC");

const result = await query(
"SELECT firstname, lastname, username, password FROM users WHERE username = $1",
["MarkS"],
["HarmonyC"],
);

expect(result.rowCount).toEqual(1);
expect(result.rows[0].username).toEqual("MarkS");
expect(result.rows[0].firstname).toEqual("Mark");
expect(result.rows[0].lastname).toEqual("Scout");
expect(result.rows[0].username).toEqual("HarmonyC");
expect(result.rows[0].firstname).toEqual("Harmony");
expect(result.rows[0].lastname).toEqual("Cobel");
expect(result.rows[0].password).toEqual(passwordHash);
});

Expand All @@ -45,9 +52,10 @@ describe("database adapter", () => {
expectedUserId = uuidv4();
return await client.query(
'INSERT INTO users(id, firstname, lastname, username, "password") VALUES($1, $2, $3, $4, $5) RETURNING id',
[expectedUserId, "Helly", "Riggs", "HellyR", passwordHash],
[expectedUserId, "Seth", "Milchick", "SethM", passwordHash],
);
})) as QueryResult;
usersForCleanup.push("SethM");

expect(result.rowCount).toEqual(1);
expect(result.rows[0].id).toEqual(expectedUserId);
Expand Down
53 changes: 53 additions & 0 deletions e2e/authentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,59 @@ test("Login validation error", async ({ page }) => {
await expect(page.getByRole("heading", { name: "Welcome Mark Scout! 🎉" })).toBeVisible();
});

test("Login should preserve query parameters on validation error", async ({ page }) => {
await page.goto(
"/login?client_id=123&response_type=code&redirect_uri=https://www.example.com&scope=openid",
);

// Everything empty.
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText("Wrong username or password.")).toBeVisible();
expectQueryParametersToBePreserved(page.url());

// Unknown username
await page.getByLabel(/Username/).fill("notMarkS");
await page.getByLabel(/Password/).fill("test");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText("Wrong username or password.")).toBeVisible();
expectQueryParametersToBePreserved(page.url());

// Correct username, wrong password.
await page.getByLabel(/Username/).fill("MarkS");
await page.getByLabel(/Password/).fill("wrong password");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText("Wrong username or password.")).toBeVisible();
expectQueryParametersToBePreserved(page.url());
});

test("Login should remove error query parameter once user inputs correct credentials", async ({
page,
}) => {
await page.goto(
"/login?client_id=123&response_type=code&redirect_uri=https://www.example.com&scope=openid",
);

// Cause the error by submitting invalid data.
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText("Wrong username or password.")).toBeVisible();
expect(page.url()).toContain("error=1");

// Enter correct credentials.
await page.getByLabel(/Username/).fill("MarkS");
await page.getByLabel(/Password/).fill("test");
await page.getByRole("button", { name: "Sign in" }).click();
expectQueryParametersToBePreserved(page.url());
expect(page.url()).not.toContain("error=1");
});

function expectQueryParametersToBePreserved(url: string) {
const loginPageSearchParams = new URL(url).searchParams;
expect(loginPageSearchParams.get("client_id")).toEqual("123");
expect(loginPageSearchParams.get("response_type")).toEqual("code");
expect(loginPageSearchParams.get("redirect_uri")).toEqual("https://www.example.com");
expect(loginPageSearchParams.get("scope")).toEqual("openid");
}

test("Create new account happy path", async ({ page, browserName }) => {
await page.goto("/");

Expand Down
Loading