diff --git a/README.md b/README.md index edf3486..92dc90d 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,27 @@ 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 @@ -24,4 +38,6 @@ Steps to run the app: - 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 diff --git a/biome.json b/biome.json index b57c3f5..4984f69 100644 --- a/biome.json +++ b/biome.json @@ -8,10 +8,12 @@ "rules": { "recommended": true, "complexity": { - "useArrowFunction": "off" + "useArrowFunction": "off", + "noForEach": "off" }, "suspicious": { - "useAwait": "error" + "useAwait": "error", + "noExplicitAny": "off" } } }, diff --git a/database/createDummyData.ts b/database/createDummyData.ts index cfbef2c..1dca847 100644 --- a/database/createDummyData.ts +++ b/database/createDummyData.ts @@ -1,6 +1,12 @@ 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. * @@ -8,10 +14,11 @@ import { query, transactionQuery } from "../database/database.js"; */ 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) => { @@ -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 }, ); diff --git a/database/database.test.ts b/database/database.test.ts index 60829d8..5a33753 100644 --- a/database/database.test.ts +++ b/database/database.test.ts @@ -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); }); @@ -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); diff --git a/e2e/authentication.spec.ts b/e2e/authentication.spec.ts index 4e9199a..f359aea 100644 --- a/e2e/authentication.spec.ts +++ b/e2e/authentication.spec.ts @@ -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("/"); diff --git a/e2e/oauth2.spec.ts b/e2e/oauth2.spec.ts new file mode 100644 index 0000000..ded231d --- /dev/null +++ b/e2e/oauth2.spec.ts @@ -0,0 +1,1042 @@ +import { createHash } from "node:crypto"; +import { URL } from "node:url"; +import { type Page, expect, request, test } from "@playwright/test"; +import * as argon2 from "argon2"; +import cryptoRandomString from "crypto-random-string"; +import { formatISO, subHours, subSeconds } from "date-fns"; +import { v4 as uuidv4 } from "uuid"; +import { + DUMMY_CLIENT_ID, + DUMMY_CLIENT_REDIRECT_URI, + DUMMY_CLIENT_SECRET, +} from "../database/createDummyData.js"; +import { query } from "../database/database.js"; +import { createAccessTokenForAuthorizationToken } from "../library/oauth2/accessToken.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) { + const secret = cryptoRandomString({ length: 44, type: "alphanumeric" }); + /** + * We deliberately use authorization server homepage as client redirect uri. + * + * Ideally we would use a different domain for client redirect_uri and intercept that redirect through playwright mock API functionality. + * Unfortunately this is not possible because playwright won't mock redirect requests by design. + * + * @see https://github.com/microsoft/playwright/issues/23301 + * @see https://github.com/microsoft/playwright/pull/3994 + */ + const redirectUri = `${baseURL}/`; + const name = `client_name_${uuidv4()}`; + + const id = ( + await query( + "INSERT INTO clients(name, description, secret, redirect_uri) VALUES($1, $2, $3, $4) RETURNING id", + [name, "A e2e test client", await argon2.hash(secret), redirectUri], + ) + ).rows[0].id as string; + + return { id, secret, name, redirectUri }; +} + +async function signInUser(page: Page, username: string, password: string) { + await page.goto("/login"); + await page.getByLabel(/Username/).fill(username); + await page.getByLabel(/Password/).fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL("/"); +} + +/** + * We use PKCE flow. + * + * @see https://www.oauth.com/playground/authorization-code-with-pkce.html. + */ +test("oauth2 flow happy path", async ({ page, baseURL }) => { + const { id, name, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + // Create code challenge from code verifier. + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + // State can just be a random string for test purposes. + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + + /** + * Start with request for an authorization token. + */ + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + + // User is taken to the login page to sign in first while preserving the query parameters. + await page.waitForURL( + `/login?response_type=code&client_id=${id}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + await page.getByLabel(/Username/).fill("MarkS"); + await page.getByLabel(/Password/).fill("test"); + await page.getByRole("button", { name: "Sign in" }).click(); + + // Signed in user is asked to approve the client. + await page.waitForURL( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + await expect( + page.getByRole("heading", { name: `"${name}" wants to access your user data` }), + ).toBeVisible(); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + + /** + * Authorization server then redirects us to the client-provided redirect_uri. + * + * In this case it's the auth server homepage for ease of testing (we pretend that the auth server homepage is the client). + */ + await page.waitForURL(/\/\?/); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString(page.url(), state); + + const response = await requestAccessToken({ + clientId: id, + clientSecret: secret, + authorizationCode, + redirectUri, + codeVerifier, + }); + expect(response.status()).toEqual(200); + const responseJson = await response.json(); + assertAccessTokenResponseFollowsSpecs(responseJson, await response.headers()); + + 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, "IrvingB", "test"); + + const { id, name, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + // Create code challenge from code verifier. + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + // State can just be a random string for test purposes. + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + + /** + * Start with request for an authorization token. + */ + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + + // Signed in user is immediately asked to approve the client. + await expect( + page.getByRole("heading", { name: `"${name}" wants to access your user data` }), + ).toBeVisible(); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + + await page.waitForURL(/\/\?/); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString(page.url(), state); + + const response = await requestAccessToken({ + clientId: id, + clientSecret: secret, + authorizationCode, + redirectUri, + codeVerifier, + }); + expect(response.status()).toEqual(200); + const responseJson = await response.json(); + assertAccessTokenResponseFollowsSpecs(responseJson, await response.headers()); + + 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) => { + test(`/authorize endpoint should should warn resource owner (user) if client doesn't exists (${notAClientId})`, async ({ + page, + }) => { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + + await page.goto( + `/authorize?response_type=code&client_id=${notAClientId}&redirect_uri=https://www.google.com&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + + await page.waitForURL( + `/error/client-id?response_type=code&client_id=${notAClientId}&redirect_uri=${encodeURIComponent("https://www.google.com")}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + await expect(page.getByRole("heading", { name: "Error" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: `Client with "${notAClientId}" id does not exist.` }), + ).toBeVisible(); + }); + + 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`); + + await page.waitForURL( + `/error/client-id?client_id=${notAClientId}&redirect_uri=${encodeURIComponent("https://www.google.com")}`, + ); + await expect(page.getByRole("heading", { name: "Error" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: `Client with "${notAClientId}" id does not exist.` }), + ).toBeVisible(); + }); +}); + +test("/authorize endpoint should warn resource owner (user) about the incorrect redirect_uri", async ({ + page, + baseURL, +}) => { + const { id } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=https://www.google.com&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + + await page.waitForURL( + `/error/redirect-uri?response_type=code&client_id=${id}&redirect_uri=${encodeURIComponent("https://www.google.com")}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + await expect(page.getByRole("heading", { name: "Error" })).toBeVisible(); + await expect( + page.getByRole("heading", { + name: "The redirect_uri query parameter is missing or not allowed.", + }), + ).toBeVisible(); +}); + +test("/authorize endpoint should warn resource owner (user) about the incorrect redirect_uri even if every other query param is missing", async ({ + page, + baseURL, +}) => { + const { id } = await createTestClient(baseURL as string); + + await page.goto(`/authorize?client_id=${id}&redirect_uri=https://www.google.com`); + + await page.waitForURL( + `/error/redirect-uri?client_id=${id}&redirect_uri=${encodeURIComponent("https://www.google.com")}`, + ); + await expect(page.getByRole("heading", { name: "Error" })).toBeVisible(); + await expect( + page.getByRole("heading", { + name: "The redirect_uri query parameter is missing or not allowed.", + }), + ).toBeVisible(); +}); + +const validPKCEChallenge = "B3b_JHueqI6LBp_WhuR7NfViLSgGVeXBpfpEMjoSdok"; +[ + { + description: "unsupported response_type", + invalidQueryString: `response_type=token&scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256`, + expectedError: "unsupported_response_type", + }, + { + description: "invalid response_type", + invalidQueryString: `response_type=qwerty&scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256`, + expectedError: "invalid_request", + }, + { + description: "missing response_type", + invalidQueryString: `scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256`, + expectedError: "invalid_request", + }, + { + description: "duplicate response_type", + invalidQueryString: `response_type=code&scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256&response_type=code`, + expectedError: "invalid_request", + }, + { + description: "invalid scope", + invalidQueryString: `response_type=code&scope=full-info&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256`, + expectedError: "invalid_scope", + }, + { + description: "missing scope", + invalidQueryString: `response_type=code&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256`, + expectedError: "invalid_request", + }, + { + description: "duplicate scope", + invalidQueryString: `response_type=code&scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256&scope=openid`, + expectedError: "invalid_request", + }, + { + description: "missing code_challenge", + invalidQueryString: + "response_type=code&scope=openid&state=validState&code_challenge_method=S256", + expectedError: "invalid_request", + }, + { + description: "unsupported code_challenge", + invalidQueryString: + "response_type=code&scope=openid&state=validState&code_challenge=&code_challenge_method=S256", + expectedError: "invalid_request", + }, + { + description: "duplicate code_challenge", + invalidQueryString: `response_type=code&scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256&code_challenge=${validPKCEChallenge}`, + expectedError: "invalid_request", + }, + { + description: "unsupported code_challenge_method", + invalidQueryString: `response_type=code&scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S224`, + expectedError: "invalid_request", + }, + { + description: "missing code_challenge_method", + invalidQueryString: `response_type=code&scope=openid&state=validState&code_challenge=${validPKCEChallenge}`, + expectedError: "invalid_request", + }, + { + description: "duplicate code_challenge_method", + invalidQueryString: `response_type=code&code_challenge_method=S256&scope=openid&state=validState&code_challenge=${validPKCEChallenge}&code_challenge_method=S256`, + expectedError: "invalid_request", + }, +].forEach(({ description, invalidQueryString, expectedError }) => { + test(`GET /authorize endpoint should redirect back with ${expectedError} error in case of ${description} (${invalidQueryString})`, async ({ + page, + baseURL, + }) => { + // We don't need to sign in the user for this test, these checks are performed before the user check. + const { id, redirectUri } = await createTestClient(baseURL as string); + + await page.goto(`/authorize?client_id=${id}&redirect_uri=${redirectUri}&${invalidQueryString}`); + + await page.waitForURL(/\/\?/); + + const expectedRedirectUri = new URL(page.url()); + expect(expectedRedirectUri.searchParams.get("error")).toEqual(expectedError); + expect(expectedRedirectUri.searchParams.get("code")).toBeNull(); + await expect(expectedRedirectUri.searchParams.get("state")).toEqual("validState"); + }); + + test(`POST /authorize endpoint should redirect back with ${expectedError} error in case of ${description} (${invalidQueryString})`, async ({ + page, + baseURL, + }) => { + const { id, redirectUri } = await createTestClient(baseURL as string); + await signInUser(page, "MarkS", "test"); + + const response = await page.request.post( + `/authorize?client_id=${id}&redirect_uri=${redirectUri}&${invalidQueryString}`, + { + form: { approved: "" }, + maxRedirects: 0, + }, + ); + + expect(response.status()).toEqual(302); + expect(response.headers().location).toContain(redirectUri); + const expectedRedirectUri = new URL(response.headers().location); + expect(expectedRedirectUri.searchParams.get("error")).toEqual(expectedError); + expect(expectedRedirectUri.searchParams.get("code")).toBeNull(); + expect(expectedRedirectUri.searchParams.get("state")).toEqual("validState"); + }); +}); + +test("POST /authorize endpoint should return 403 error if user is not signed in", async ({ + page, +}) => { + // We make the request with a invalid query string because we want to check if user is signed in first. + const response = await page.request.post("/authorize", { + form: { approved: "" }, + maxRedirects: 0, + }); + + expect(response.status()).toEqual(403); + expect(response.statusText()).toEqual("Forbidden"); +}); + +test("/authorize endpoint should redirect with access_denied error code if user denies the authorization request", async ({ + page, + baseURL, +}) => { + await signInUser(page, "MarkS", "test"); + + const { id, name, redirectUri } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + // Create code challenge from code verifier. + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + // State can just be a random string for test purposes. + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + + /** Request an authorization token. */ + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + + // Signed in user is asked to approve the client, but denies access instead. + await expect( + page.getByRole("heading", { name: `"${name}" wants to access your user data` }), + ).toBeVisible(); + // User approves the client. + await page.getByRole("button", { name: "Deny" }).click(); + + await page.waitForURL(/\/\?/); + const { protocol, host, pathname, searchParams } = new URL(page.url()); + const actualRedirectUri = `${protocol}//${host}${pathname}`; + expect(actualRedirectUri).toEqual(redirectUri); + // We verify the data in the redirect_uri query string is there. + expect(searchParams.get("code")).toBeNull(); + expect(searchParams.get("state")).toEqual(state); + expect(searchParams.get("error")).toEqual("access_denied"); +}); + +["grant_type", "redirect_uri", "code", "code_verifier"].forEach((missingParameter: string) => { + test(`/token endpoint should respond with 400 error code if ${missingParameter} parameter is missing`, async ({ + page, + baseURL, + request, + }) => { + await signInUser(page, "MarkS", "test"); + const { id, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(/\/\?/); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString( + page.url(), + state, + ); + + const formParameters: AccessTokenRequestQueryParams = { + grant_type: "authorization_code", + redirect_uri: redirectUri, + code: authorizationCode as string, + code_verifier: codeVerifier, + }; + delete formParameters[missingParameter as keyof AccessTokenRequestQueryParams]; + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${id}:${secret}`)}`, + }, + form: formParameters as any, + }); + expect(response.status()).toEqual(400); + expect(response.statusText()).toEqual("Bad Request"); + expect(await response.json()).toEqual({ error: "invalid_request" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); + }); +}); + +test("/token endpoint should respond with 401 error if client credentials are invalid (no authorization header)", async ({ + page, + request, +}) => { + await signInUser(page, "MarkS", "test"); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${DUMMY_CLIENT_ID}&redirect_uri=${DUMMY_CLIENT_REDIRECT_URI}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(`${DUMMY_CLIENT_REDIRECT_URI}**`); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString(page.url(), state); + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + form: { + grant_type: "authorization_code", + redirect_uri: DUMMY_CLIENT_REDIRECT_URI, + code: authorizationCode as string, + code_verifier: codeVerifier, + }, + }); + expect(response.status()).toEqual(401); + expect(response.statusText()).toEqual("Unauthorized"); + expect(response.headers()["www-authenticate"]).toEqual('Basic realm="Client authentication"'); + expect(await response.json()).toEqual({ error: "invalid_client" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); +}); + +test("/token endpoint should respond with 401 error if client credentials are invalid (different client id)", async ({ + page, + baseURL, + request, +}) => { + await signInUser(page, "MarkS", "test"); + // We start authorization as default dummy client, but then use this client's id. + const differentClient = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${DUMMY_CLIENT_ID}&redirect_uri=${DUMMY_CLIENT_REDIRECT_URI}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(`${DUMMY_CLIENT_REDIRECT_URI}**`); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString(page.url(), state); + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${differentClient.id}:${DUMMY_CLIENT_SECRET}`)}`, + }, + form: { + grant_type: "authorization_code", + redirect_uri: DUMMY_CLIENT_REDIRECT_URI, + code: authorizationCode as string, + code_verifier: codeVerifier, + }, + }); + expect(response.status()).toEqual(401); + expect(response.statusText()).toEqual("Unauthorized"); + expect(response.headers()["www-authenticate"]).toEqual('Basic realm="Client authentication"'); + expect(await response.json()).toEqual({ error: "invalid_client" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); +}); + +[ + ["unknown client", { id: "4998308c-071a-4191-abbd-2372b48c9d20", secret: DUMMY_CLIENT_SECRET }], + ["different client secret", { id: DUMMY_CLIENT_ID, secret: "different_secret" }], +].forEach(([description, client]: any) => { + test(`/token endpoint should respond with 401 error if client credentials are invalid (${description})`, async ({ + page, + request, + }) => { + await signInUser(page, "MarkS", "test"); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${DUMMY_CLIENT_ID}&redirect_uri=${DUMMY_CLIENT_REDIRECT_URI}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(`${DUMMY_CLIENT_REDIRECT_URI}**`); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString( + page.url(), + state, + ); + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${client.id}:${client.secret}`)}`, + }, + form: { + grant_type: "authorization_code", + redirect_uri: DUMMY_CLIENT_REDIRECT_URI, + code: authorizationCode as string, + code_verifier: codeVerifier, + }, + }); + expect(response.status()).toEqual(401); + expect(response.statusText()).toEqual("Unauthorized"); + expect(response.headers()["www-authenticate"]).toEqual('Basic realm="Client authentication"'); + expect(await response.json()).toEqual({ error: "invalid_client" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); + }); +}); + +[ + ["redirect_uri", "invalid_grant"], + ["code_verifier", "invalid_request"], // Requires working PKCE verification to pass. +].forEach(([parameter, expectedError]) => { + test(`/token endpoint should respond with 400 error code if ${parameter} parameter has unsupported value`, async ({ + page, + baseURL, + request, + }) => { + await signInUser(page, "MarkS", "test"); + const { id, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(/\/\?/); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString( + page.url(), + state, + ); + + const formParameters: any = { + grant_type: "authorization_code", + redirect_uri: redirectUri, + code: authorizationCode as string, + code_verifier: codeVerifier, + }; + formParameters[parameter] = "unsupported value"; + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${id}:${secret}`)}`, + }, + form: formParameters, + }); + expect(response.status()).toEqual(400); + expect(response.statusText()).toEqual("Bad Request"); + expect(await response.json()).toEqual({ error: expectedError }); + expectTokenEndpointHeadersAreCorrect(response.headers()); + }); +}); + +test("/token endpoint should respond with 400 status code and invalid_grant error if code does not exist", async ({ + page, + baseURL, + request, +}) => { + await signInUser(page, "MarkS", "test"); + const { id, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(/\/\?/); + + const formParameters: any = { + grant_type: "authorization_code", + redirect_uri: redirectUri, + code: "invalid authorization code", + code_verifier: codeVerifier, + }; + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${id}:${secret}`)}`, + }, + form: formParameters, + }); + expect(response.status()).toEqual(400); + expect(response.statusText()).toEqual("Bad Request"); + expect(await response.json()).toEqual({ error: "invalid_grant" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); +}); + +test("/token endpoint should respond with 400 status code and invalid_grant error if code is for a different client", async ({ + page, + baseURL, + request, +}) => { + await signInUser(page, "MarkS", "test"); + const { id, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(/\/\?/); + + // Create authorization token for the dummy client for the same user, to force a client check in production code. + const userId = ((await findUserByUsername("MarkS")) as UserData).id; + const differentAuthCode = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId, + codeChallenge, + }); + + const formParameters: any = { + grant_type: "authorization_code", + redirect_uri: redirectUri, + // We send an auth code for a different client. + code: differentAuthCode, + code_verifier: codeVerifier, + }; + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${id}:${secret}`)}`, + }, + form: formParameters, + }); + expect(response.status()).toEqual(400); + expect(response.statusText()).toEqual("Bad Request"); + expect(await response.json()).toEqual({ error: "invalid_grant" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); +}); + +test("/token endpoint should respond with 400 status code and unsupported_grant_type error if grant_type is of unknown value", async ({ + page, + baseURL, + request, +}) => { + await signInUser(page, "MarkS", "test"); + const { id, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(/\/\?/); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString(page.url(), state); + + const formParameters: any = { + // We send the wrong grant type. + grant_type: "client_credentials", + redirect_uri: redirectUri, + code: authorizationCode, + code_verifier: codeVerifier, + }; + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${id}:${secret}`)}`, + }, + form: formParameters, + }); + expect(response.status()).toEqual(400); + expect(response.statusText()).toEqual("Bad Request"); + expect(await response.json()).toEqual({ error: "unsupported_grant_type" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); +}); + +["redirect_uri", "code_verifier", "code", "grant_type"].forEach((repeatedParameter) => { + test(`/token endpoint should respond with 400 status code and invalid_request error if ${repeatedParameter} parameter is repeated`, async ({ + page, + baseURL, + request, + }) => { + await signInUser(page, "MarkS", "test"); + const { id, redirectUri, secret } = await createTestClient(baseURL as string); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const state = cryptoRandomString({ length: 16, type: "alphanumeric" }); + await page.goto( + `/authorize?response_type=code&client_id=${id}&redirect_uri=${redirectUri}&scope=openid&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`, + ); + // User approves the client. + await page.getByRole("button", { name: "Approve" }).click(); + await page.waitForURL(/\/\?/); + const authorizationCode = await getAuthorizationCodeFromRedirectUriQueryString( + page.url(), + state, + ); + + const formParameters: any = { + grant_type: "authorization_code", + redirect_uri: encodeURIComponent(redirectUri), + code: authorizationCode, + code_verifier: codeVerifier, + }; + // Convert formParameters object to application/x-www-form-urlencoded string manually + // and add the repeated parameter. + const formString = `${Object.keys(formParameters).reduce((previous, currentKey) => { + return `${previous}${currentKey}=${formParameters[currentKey]}&`; + }, "")}${repeatedParameter}=${formParameters[repeatedParameter]}`; + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${id}:${secret}`)}`, + }, + data: formString, + }); + expect(response.status()).toEqual(400); + expect(response.statusText()).toEqual("Bad Request"); + expect(await response.json()).toEqual({ error: "invalid_request" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); + }); +}); + +test("/token endpoint should respond with 400 status code and invalid_grant error if authorization code has expired", async ({ + request, +}) => { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const userId = ((await findUserByUsername("MarkS")) as UserData).id; + const authorizationCode = cryptoRandomString({ length: 64, characters: "alphanumeric" }); + // Set up so the user has already approved the authorization request and we manually create the + // already *expired* authorization code. + await query( + "INSERT INTO authorization_tokens(created_at, value, scope, client_id, user_id, code_challenge, code_challenge_method) VALUES($1, $2, $3, $4, $5, $6, $7)", + [ + formatISO(subSeconds(new Date(), 121)), + authorizationCode, + "openid", + DUMMY_CLIENT_ID, + userId, + codeChallenge, + "S256", + ], + ); + + const formParameters: any = { + grant_type: "authorization_code", + redirect_uri: DUMMY_CLIENT_REDIRECT_URI, + code: authorizationCode, + code_verifier: codeVerifier, + }; + + const response = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${DUMMY_CLIENT_ID}:${DUMMY_CLIENT_SECRET}`)}`, + }, + form: formParameters, + }); + expect(response.status()).toEqual(400); + expect(response.statusText()).toEqual("Bad Request"); + expect(await response.json()).toEqual({ error: "invalid_grant" }); + expectTokenEndpointHeadersAreCorrect(response.headers()); +}); + +test("/token endpoint should revoke all access tokens previously issued from an authorization code if that code is used twice", async ({ + request, +}) => { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const userId = ((await findUserByUsername("MarkS")) as UserData).id; + // Create an authorization code that was already used to get an access token. + const authorizationCode = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + codeChallenge, + userId, + }); + const accessToken = await createAccessTokenForAuthorizationToken(authorizationCode); + // Guard assertion that checks that access token initially works for fetching user info. + await assertUserinfoEndpointWorks(accessToken.value, { + sub: userId, + preferred_username: "MarkS", + given_name: "Mark", + family_name: "Scout", + }); + + const tokenRequestParameters = { + grant_type: "authorization_code", + redirect_uri: DUMMY_CLIENT_REDIRECT_URI, + code: authorizationCode, + code_verifier: codeVerifier, + }; + const tokenResponse = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${DUMMY_CLIENT_ID}:${DUMMY_CLIENT_SECRET}`)}`, + }, + form: tokenRequestParameters, + }); + + expect(tokenResponse.status()).toEqual(400); + expect(tokenResponse.statusText()).toEqual("Bad Request"); + expect(await tokenResponse.json()).toEqual({ error: "invalid_grant" }); + expectTokenEndpointHeadersAreCorrect(tokenResponse.headers()); + + // Trying to use old access token to fetch user info will not work now. + const response = await request.get("/api/v1/userinfo", { + headers: { Authorization: `Bearer ${base64encode(accessToken.value)}` }, + }); + 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" }); + + // Requesting the access token with the same authorization token will also not work. + const secondTokenResponse = await request.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${DUMMY_CLIENT_ID}:${DUMMY_CLIENT_SECRET}`)}`, + }, + form: tokenRequestParameters, + }); + expect(secondTokenResponse.status()).toEqual(400); + expect(secondTokenResponse.statusText()).toEqual("Bad Request"); + expect(await secondTokenResponse.json()).toEqual({ error: "invalid_grant" }); + expectTokenEndpointHeadersAreCorrect(secondTokenResponse.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.get("/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.get("/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("/userinfo endpoint should respond with 401 error code if access token has expired", async ({ + request, +}) => { + const user = (await findUserByUsername("DylanG")) as UserData; + const codeVerifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const authorizationCode = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId: user?.id, + codeChallenge, + }); + const { value: accessToken } = await createAccessTokenForAuthorizationToken(authorizationCode); + + // First test that endpoint still works minutes before expiration. + await query("UPDATE access_tokens SET created_at = $1 WHERE value = $2", [ + formatISO(subHours(new Date(), 23.95)), + accessToken, + ]); + await assertUserinfoEndpointWorks(accessToken, { + sub: user.id, + preferred_username: user.username, + given_name: user.firstname, + family_name: user.lastname, + }); + + // Now test that endpoint still returns 401 because access token has expired. + await query("UPDATE access_tokens SET created_at = $1 WHERE value = $2", [ + formatISO(subHours(new Date(), 24.05)), + accessToken, + ]); + const response = await request.get("/api/v1/userinfo", { + headers: { Authorization: `Bearer ${base64encode(accessToken)}` }, + }); + + 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" }); +}); + +function generateCodeVerifier() { + return cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); +} + +async function getAuthorizationCodeFromRedirectUriQueryString( + url: string, + expectedState: string, +): Promise { + // We verify the data in the redirect_uri query string is there. + const expectedRedirectUri = new URL(url); + const authorizationCode = expectedRedirectUri.searchParams.get("code"); + await expect(expectedRedirectUri.searchParams.get("state")).toEqual(expectedState); + // Check that we received the authorization code token. + await expect(authorizationCode?.length).toBeGreaterThan(10); + + return authorizationCode as string; +} + +/** + * Using the authorization code we received we request an access token from the authorization server. + * + * The client is authenticated by the auth server using Basic Authentication Scheme as described in RFC2617. + * + * @see https://datatracker.ietf.org/doc/html/rfc2617#section-2 + * @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 requestAccessToken({ + clientId, + clientSecret, + redirectUri, + authorizationCode, + codeVerifier, +}: any) { + const apiRequest = await request.newContext(); + return await apiRequest.post("/api/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${base64encode(`${clientId}:${clientSecret}`)}`, + }, + form: { + grant_type: "authorization_code", + redirect_uri: redirectUri, + code: authorizationCode as string, + code_verifier: codeVerifier, + }, + }); +} + +/** + * Verify that access token response follows rfc6749 specification. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749.html#section-5.1 + */ +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("openid"); + expectTokenEndpointHeadersAreCorrect(headers); +} + +function expectTokenEndpointHeadersAreCorrect(headers: any) { + expect(headers["cache-control"]).toEqual("no-store"); + expect(headers.pragma).toEqual("no-cache"); + expect(headers["content-type"]).toEqual("application/json; charset=utf-8"); +} + +/** + * @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, expectedUserData: any) { + const apiRequest = await request.newContext(); + /** + * Using the access token fetch basic user info from the resource server. + */ + const resourceResponse = await apiRequest.get("/api/v1/userinfo", { + headers: { Authorization: `Bearer ${base64encode(accessToken)}` }, + }); + expect(resourceResponse.status()).toEqual(200); + expect(resourceResponse.ok()).toBeTruthy(); + await expect(await resourceResponse.json()).toEqual(expectedUserData); +} + +function base64encode(text: string): string { + return Buffer.from(text).toString("base64"); +} + +async function getUserIdFromUsername(username: string): Promise { + return ((await findUserByUsername(username)) as UserData)?.id; +} diff --git a/frontend/templates/approvePage.ejs b/frontend/templates/approvePage.ejs new file mode 100644 index 0000000..86c96f8 --- /dev/null +++ b/frontend/templates/approvePage.ejs @@ -0,0 +1,9 @@ +<%- include('header', { title: `An application wants to connect to your account` }); %> + +
+

"<%= clientName %>" wants to access your user data

+
+ <%- include('button', { color: 'blue', type: 'submit', text: 'Approve', name: 'approved' }); %> + <%- include('button', { color: 'red', type: 'submit', text: 'Deny', name: 'denied' }); %> +
+
\ No newline at end of file diff --git a/frontend/templates/button.ejs b/frontend/templates/button.ejs index e7e4458..7016525 100644 --- a/frontend/templates/button.ejs +++ b/frontend/templates/button.ejs @@ -1,9 +1,9 @@ <% if (color === 'blue') { %> - <% } else if (color === 'red') { %> - <% } %> diff --git a/frontend/templates/errorPage.ejs b/frontend/templates/errorPage.ejs new file mode 100644 index 0000000..465a1e5 --- /dev/null +++ b/frontend/templates/errorPage.ejs @@ -0,0 +1,9 @@ +<%- include('./header', { title: "Error" }); %> + +
+ <% if (errorType === 'redirect-uri') { %> +

The redirect_uri query parameter is missing or not allowed.

+ <% } else if (errorType === 'client-id') { %> +

<%= `Client with "${clientId}" id does not exist.` %>

+ <% } %> +
diff --git a/frontend/templates/homePage.ejs b/frontend/templates/homePage.ejs index 494bfda..4f2bd1f 100644 --- a/frontend/templates/homePage.ejs +++ b/frontend/templates/homePage.ejs @@ -2,7 +2,7 @@
<% if (user) { %> -

Welcome <%= user.name %> <%= user.surname %>! 🎉

+

Welcome <%= user.name %> <%= user.surname %>! 🎉

<% } else { %> <%- include('button', { color: 'red', type: 'button', text: 'Create new account' }); %> diff --git a/frontend/templates/layout.ejs b/frontend/templates/layout.ejs index 2cf98b8..4b6cc82 100644 --- a/frontend/templates/layout.ejs +++ b/frontend/templates/layout.ejs @@ -4,8 +4,8 @@ Authorization Server - - + + <%- body %> diff --git a/index.ts b/index.ts index 3cbdf7f..6c8276d 100644 --- a/index.ts +++ b/index.ts @@ -3,16 +3,31 @@ import path from "node:path"; import fastifyCookie from "@fastify/cookie"; import type { FastifyCookieOptions } from "@fastify/cookie"; import formbody from "@fastify/formbody"; +import helmet from "@fastify/helmet"; import type { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; import pointOfView from "@fastify/view"; import ejs from "ejs"; import Fastify, { type FastifyInstance } from "fastify"; +import apiRoutes from "./routes/api.js"; import frontendRoutes from "./routes/frontend.js"; const fastify: FastifyInstance = Fastify({ logger: true, }).withTypeProvider(); +fastify.register(helmet, { + contentSecurityPolicy: { + directives: { + // Not needed because all our requests will already be https in production. + // Also breaks Safari when accessing localhost. + upgradeInsecureRequests: null, + }, + }, + // For some reason setting this option to true makes Playwright tests occasionally timeout + // on page.goto commands. + crossOriginOpenerPolicy: false, +}); + /** * @todo store an actual session secret in env * @todo use secure: true in prod env @@ -29,6 +44,7 @@ fastify.register(pointOfView, { layout: "layout.ejs", }); fastify.register(frontendRoutes); +fastify.register(apiRoutes, { prefix: "/api/v1" }); fastify.listen({ port: 3000 }, (err, address) => { if (err) { diff --git a/library/authentication.test.ts b/library/authentication.test.ts index 1a4413c..f166ce9 100644 --- a/library/authentication.test.ts +++ b/library/authentication.test.ts @@ -11,11 +11,12 @@ import { signInUser, signOut, } from "./authentication.js"; +import { type UserData, findUserById } from "./user.js"; const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$P5wGfnyG6tNP2iwvWPp9SA$Gp3wgJZC1xe6fVzUTMmqgCGgFPyZeCt1aXjUtlwSMmo"; -describe("authentication", () => { +describe("user authentication", () => { const userIds: string[] = []; const sessionIds: string[] = []; @@ -83,7 +84,7 @@ describe("authentication", () => { const userId = ( await query( 'INSERT INTO users(firstname, lastname, username, "password") VALUES($1, $2, $3, $4) RETURNING id', - ["Dylan", "George", "DylanG", passwordHash], + ["Harmony", "Cobel", "hCobel", passwordHash], ) ).rows[0].id as string; userIds.push(userId); @@ -97,9 +98,9 @@ describe("authentication", () => { expect(await getSignedInUser(request)).toEqual({ id: userId, - name: "Dylan", - surname: "George", - username: "DylanG", + name: "Harmony", + surname: "Cobel", + username: "hCobel", }); }); @@ -112,13 +113,13 @@ describe("authentication", () => { }); userIds.push(userId); - const newUserResult = await query("SELECT * FROM users WHERE id = $1", [userId]); + const newUserData = (await findUserById(userId)) as UserData; - expect(newUserResult.rowCount).toEqual(1); - expect(newUserResult.rows[0].firstname).toEqual("Seth"); - expect(newUserResult.rows[0].lastname).toEqual("Milchick"); - expect(newUserResult.rows[0].username).toEqual("sMilchick"); - const passwordMatches = await argon2.verify(newUserResult.rows[0].password, "a test"); + expect(newUserData).not.toBeNull(); + expect(newUserData.firstname).toEqual("Seth"); + expect(newUserData.lastname).toEqual("Milchick"); + expect(newUserData.username).toEqual("sMilchick"); + const passwordMatches = await argon2.verify(newUserData.password, "a test"); expect(passwordMatches).toStrictEqual(true); }); diff --git a/library/oauth2/accessToken.test.ts b/library/oauth2/accessToken.test.ts new file mode 100644 index 0000000..4239de0 --- /dev/null +++ b/library/oauth2/accessToken.test.ts @@ -0,0 +1,241 @@ +import { createHash } from "node:crypto"; +import cryptoRandomString from "crypto-random-string"; +import { differenceInSeconds, subHours } from "date-fns"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DUMMY_CLIENT_ID } from "../../database/createDummyData.js"; +import { query } from "../../database/database.js"; +import { findUserByUsername } from "../user.js"; +import { + type AccessTokenData, + createAccessTokenForAuthorizationToken, + extractAccessTokenFromHeader, + findAccessTokenByValue, + hasTokenExpired, + revokeAccessTokenIssuedByAuthorizationToken, +} from "./accessToken.js"; +import { createAuthorizationToken, findAuthorizationTokenByCode } 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 findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256") + .update(generateRandomString({ length: 64 })) + .digest("base64url"); + const authorizationToken = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId, + codeChallenge, + }); + const accessToken = await createAccessTokenForAuthorizationToken(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 findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256") + .update(generateRandomString({ length: 64 })) + .digest("base64url"); + const authorizationToken = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId, + codeChallenge, + }); + const expectedCreationDate = new Date(); + + const { + id, + value, + scope, + expiresIn, + authorizationTokenId, + clientId, + userId: actualUserId, + createdAt, + } = await createAccessTokenForAuthorizationToken(authorizationToken); + + expect(value.length).toEqual(64); + expect(scope).toEqual("openid"); + expect(expiresIn).toStrictEqual(86400); + expect(clientId).toEqual(DUMMY_CLIENT_ID); + expect(actualUserId).toEqual(userId); + expect(differenceInSeconds(createdAt, expectedCreationDate)).toBeLessThan(2); + expect(id).toEqual((await findAccessTokenByValue(value))?.id); + expect(authorizationTokenId).toStrictEqual( + (await findAuthorizationTokenByCode(authorizationToken))?.id, + ); + }); + + it("generating access token should throw error if provided authorization token does not exist", async () => { + await expect( + async () => await createAccessTokenForAuthorizationToken("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 findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256") + .update(generateRandomString({ length: 64 })) + .digest("base64url"); + const authorizationToken = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId, + codeChallenge, + }); + + await createAccessTokenForAuthorizationToken(authorizationToken); + + await expect( + async () => await createAccessTokenForAuthorizationToken(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(""); + }); + }, + ); +}); + +describe("hasTokenExpired", () => { + const notRelevantData = { + id: 1, + authorizationTokenId: 2, + clientId: DUMMY_CLIENT_ID, + scope: "openid", + userId: "cd913d98-49cd-44e6-b414-86971b6b6385", + value: "not-important", + }; + + it("should return true if token has expired", () => { + expect( + hasTokenExpired({ + expiresIn: 86400, + createdAt: subHours(new Date(), 24.01), + ...notRelevantData, + }), + ).toStrictEqual(true); + + expect( + hasTokenExpired({ + // 1 hour + expiresIn: 3600, + createdAt: subHours(new Date(), 1.01), + ...notRelevantData, + }), + ).toStrictEqual(true); + }); + + it("should return false if token has not expired", () => { + expect( + hasTokenExpired({ + expiresIn: 86400, + createdAt: subHours(new Date(), 23.99), + ...notRelevantData, + }), + ).toStrictEqual(false); + + expect( + hasTokenExpired({ + // 1 hour + expiresIn: 3600, + createdAt: subHours(new Date(), 0.99), + ...notRelevantData, + }), + ).toStrictEqual(false); + }); +}); + +describe("revokeAccessTokenForCode", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should delete access token issued for the code and revoke the authorization code", async () => { + const userId = (await findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256") + .update(generateRandomString({ length: 64 })) + .digest("base64url"); + const authorizationToken = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId, + codeChallenge, + }); + const accessToken = await createAccessTokenForAuthorizationToken(authorizationToken); + + await revokeAccessTokenIssuedByAuthorizationToken(authorizationToken); + + expect(await findAccessTokenByValue(accessToken.value)).toBeNull(); + expect((await findAuthorizationTokenByCode(authorizationToken))?.revoked).toStrictEqual(true); + }); + + it("should log a warning if authorization token does not exist", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await revokeAccessTokenIssuedByAuthorizationToken("some_nonexistent_authorization_token"); + + expect(consoleWarnSpy).toHaveBeenCalledOnce(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Revocation error: authorization token does not exist", + ); + }); + + it("should do nothing if access token does not exist for authorization token", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const userId = (await findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256") + .update(generateRandomString({ length: 64 })) + .digest("base64url"); + const authorizationToken = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId, + codeChallenge, + }); + + await revokeAccessTokenIssuedByAuthorizationToken(authorizationToken); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); +}); + +function base64encode(text: string): string { + return Buffer.from(text).toString("base64"); +} + +function generateRandomString({ length }: { length: number }) { + return cryptoRandomString({ + length, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); +} diff --git a/library/oauth2/accessToken.ts b/library/oauth2/accessToken.ts new file mode 100644 index 0000000..0b5e666 --- /dev/null +++ b/library/oauth2/accessToken.ts @@ -0,0 +1,116 @@ +import cryptoRandomString from "crypto-random-string"; +import { addSeconds, isFuture } from "date-fns"; +import { query } from "../../database/database.js"; +import { findAuthorizationTokenByCode, revokeAuthorizationToken } 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 createAccessTokenForAuthorizationToken( + authorizationToken: string, +): Promise { + const value = cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); + + const authorizationTokenData = await findAuthorizationTokenByCode(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.toString()], + ); + if (queryResult.rowCount !== null && queryResult.rowCount > 0) { + throw new Error("Authorization code already has an access token."); + } + + const insertResult = await query( + "INSERT INTO access_tokens(value, scope, client_id, user_id, authorization_token_id, expires_in) VALUES($1, $2, $3, $4, $5, $6) RETURNING *", + [ + value, + authorizationTokenData?.scope, + authorizationTokenData?.clientId, + authorizationTokenData?.userId, + authorizationTokenData?.id.toString(), + "86400", + ], + ); + + return { + value, + expiresIn: 86400, + scope: authorizationTokenData?.scope, + authorizationTokenId: authorizationTokenData.id, + clientId: authorizationTokenData.clientId, + createdAt: insertResult.rows[0].created_at, + id: insertResult.rows[0].id, + userId: authorizationTokenData?.userId, + }; +} + +export async function findAccessTokenByValue(accessToken: string): Promise { + 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; +} + +export function hasTokenExpired(accessTokenData: AccessTokenData): boolean { + return !isFuture(addSeconds(accessTokenData.createdAt, accessTokenData.expiresIn)); +} + +export async function revokeAccessTokenIssuedByAuthorizationToken(authorizationToken: string) { + const authorizationCodeData = await findAuthorizationTokenByCode(authorizationToken); + + if (authorizationCodeData === null) { + console.warn("Revocation error: authorization token does not exist"); + return; + } + + await revokeAuthorizationToken(authorizationToken); + await query("DELETE FROM access_tokens WHERE authorization_token_id = $1", [ + authorizationCodeData.id.toString(), + ]); +} diff --git a/library/oauth2/authorizationToken.test.ts b/library/oauth2/authorizationToken.test.ts new file mode 100644 index 0000000..767306a --- /dev/null +++ b/library/oauth2/authorizationToken.test.ts @@ -0,0 +1,140 @@ +import { createHash } from "node:crypto"; +import cryptoRandomString from "crypto-random-string"; +import { addSeconds, differenceInSeconds, formatISO, subSeconds } from "date-fns"; +import { describe, expect, it } from "vitest"; +import { DUMMY_CLIENT_ID } from "../../database/createDummyData.js"; +import { query } from "../../database/database.js"; +import { findUserByUsername } from "../user.js"; +import { + type AuthorizationTokenData, + createAuthorizationToken, + findAuthorizationTokenByCode, + hasAuthorizationTokenExpired, +} from "./authorizationToken.js"; + +describe("fetching authorization token from database by code", () => { + it("if token with specified code is not found, return null", async () => { + const token = await findAuthorizationTokenByCode("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 findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256").update(generateCodeVerifier()).digest("base64url"); + const code = await createAuthorizationToken({ + clientId: DUMMY_CLIENT_ID, + userId, + codeChallenge, + }); + const expectedTokenCreationDate = new Date(); + + const token = (await findAuthorizationTokenByCode(code)) as AuthorizationTokenData; + + expect(token?.id).not.toBeFalsy(); + expect(token?.clientId).toEqual(DUMMY_CLIENT_ID); + expect(token?.userId).toEqual(userId); + expect(token?.value).toEqual(code); + expect(token?.scope).toEqual("openid"); + expect(token?.codeChallenge).toEqual(codeChallenge); + expect(token?.codeChallengeMethod).toEqual("S256"); + expect(token?.revoked).toStrictEqual(false); + expect(differenceInSeconds(token.createdAt, expectedTokenCreationDate)).toBeLessThan(2); + }); +}); + +describe("checking if authorization token has expired", () => { + [118, 54, 5, 0].forEach((seconds) => { + it(`should return false if token was created within ${seconds} from now`, async () => { + const userId = (await findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256").update(generateCodeVerifier()).digest("base64url"); + const authorizationCode = cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); + + await query( + "INSERT INTO authorization_tokens(created_at, value, scope, client_id, user_id, code_challenge, code_challenge_method) VALUES($1, $2, $3, $4, $5, $6, $7)", + [ + formatISO(subSeconds(new Date(), seconds)), + authorizationCode, + "openid", + DUMMY_CLIENT_ID, + userId, + codeChallenge, + "S256", + ], + ); + + const codeData = (await findAuthorizationTokenByCode( + authorizationCode, + )) as AuthorizationTokenData; + + expect(hasAuthorizationTokenExpired(codeData)).toStrictEqual(false); + }); + }); + + it("should return true if token was created more than 2 minutes from now", async () => { + const userId = (await findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256").update(generateCodeVerifier()).digest("base64url"); + const authorizationCode = cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); + + await query( + "INSERT INTO authorization_tokens(created_at, value, scope, client_id, user_id, code_challenge, code_challenge_method) VALUES($1, $2, $3, $4, $5, $6, $7)", + [ + formatISO(subSeconds(new Date(), 121)), + authorizationCode, + "openid", + DUMMY_CLIENT_ID, + userId, + codeChallenge, + "S256", + ], + ); + + const codeData = (await findAuthorizationTokenByCode( + authorizationCode, + )) as AuthorizationTokenData; + + expect(hasAuthorizationTokenExpired(codeData)).toStrictEqual(true); + }); + + [2, 15, 121, 10000000].forEach((seconds) => { + it(`should return true if token was created ${seconds} seconds in the future (?)`, async () => { + const userId = (await findUserByUsername("HellyR"))?.id as string; + const codeChallenge = createHash("sha256").update(generateCodeVerifier()).digest("base64url"); + const authorizationCode = cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); + + await query( + "INSERT INTO authorization_tokens(created_at, value, scope, client_id, user_id, code_challenge, code_challenge_method) VALUES($1, $2, $3, $4, $5, $6, $7)", + [ + formatISO(addSeconds(new Date(), seconds)), + authorizationCode, + "openid", + DUMMY_CLIENT_ID, + userId, + codeChallenge, + "S256", + ], + ); + + const codeData = (await findAuthorizationTokenByCode( + authorizationCode, + )) as AuthorizationTokenData; + + expect(hasAuthorizationTokenExpired(codeData)).toStrictEqual(true); + }); + }); +}); + +function generateCodeVerifier() { + return cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); +} diff --git a/library/oauth2/authorizationToken.ts b/library/oauth2/authorizationToken.ts new file mode 100644 index 0000000..ca18d71 --- /dev/null +++ b/library/oauth2/authorizationToken.ts @@ -0,0 +1,86 @@ +import cryptoRandomString from "crypto-random-string"; +import { differenceInSeconds, isAfter } from "date-fns"; +import { query } from "../../database/database.js"; + +export interface AuthorizationTokenData { + id: number; + value: string; + scope: string; + createdAt: Date; + clientId: string; + userId: string; + codeChallenge: string; + codeChallengeMethod: string; + revoked: boolean; +} + +interface CreateAuthorizationTokenArguments { + clientId: string; + userId: string; + /** Defaults to "openid". */ + scope?: string; + codeChallenge: string; + /** Defaults to "S256". */ + codeChallengeMethod?: string; +} + +export async function createAuthorizationToken({ + clientId, + userId, + scope = "openid", + codeChallenge, + codeChallengeMethod = "S256", +}: CreateAuthorizationTokenArguments): Promise { + const authorizationCode = cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); + + await query( + "INSERT INTO authorization_tokens(value, scope, client_id, user_id, code_challenge, code_challenge_method) VALUES($1, $2, $3, $4, $5, $6)", + [authorizationCode, scope, clientId, userId, codeChallenge, codeChallengeMethod], + ); + + return authorizationCode; +} + +export async function findAuthorizationTokenByCode( + code: string, +): Promise { + const result = await query( + "SELECT id, value, scope, created_at, client_id, user_id, revoked, code_challenge, code_challenge_method FROM authorization_tokens WHERE value = $1", + [code], + ); + + if (result.rowCount !== 1) { + return null; + } + + const tokenData = result.rows[0]; + + return { + id: tokenData.id, + createdAt: tokenData.created_at, + clientId: tokenData.client_id, + userId: tokenData.user_id, + value: tokenData.value, + scope: tokenData.scope, + revoked: tokenData.revoked, + codeChallenge: tokenData.code_challenge, + codeChallengeMethod: tokenData.code_challenge_method, + }; +} + +export async function revokeAuthorizationToken(authorizationToken: string) { + await query("UPDATE authorization_tokens SET revoked = true WHERE value = $1", [ + authorizationToken, + ]); +} + +export function hasAuthorizationTokenExpired(authorizationToken: AuthorizationTokenData): boolean { + if (isAfter(authorizationToken.createdAt, new Date())) { + return true; + } + + return differenceInSeconds(new Date(), authorizationToken.createdAt) > 120; +} diff --git a/library/oauth2/client.test.ts b/library/oauth2/client.test.ts new file mode 100644 index 0000000..2eeeec6 --- /dev/null +++ b/library/oauth2/client.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { DUMMY_CLIENT_ID, DUMMY_CLIENT_REDIRECT_URI } from "../../database/createDummyData.js"; +import type { AuthorizationResponseErrorType } from "../../routes/frontend.js"; +import { + attachErrorInformationToRedirectUri, + clientExists, + extractClientCredentials, + isRedirectUriValid, +} from "./client.js"; + +describe("client authentication", () => { + it("clientExists should return false if provided client id is undefined or not in database", async () => { + expect(await clientExists(undefined)).toEqual(false); + expect(await clientExists("a17d060f-607a-47eb-9113-0f6402dcf089")).toEqual(false); + expect(await clientExists("")).toEqual(false); + expect(await clientExists("jdfhercndsjkvcns")).toEqual(false); + }); + + it("clientExists should return true if provided client id exists in database", async () => { + expect(await clientExists(DUMMY_CLIENT_ID)).toEqual(true); + }); + + it("extractClientCredentials should extract client credentials from an authorization request header", () => { + const clientCredentials = extractClientCredentials( + `Basic ${base64Encode(`${clientId}:${clientSecret}`)}`, + ); + + expect(clientCredentials).toEqual({ + clientId, + clientSecret, + }); + }); + + const clientId = "e2062e6b-7af1-4c45-9b13-9ebfe9263fe6"; + const clientSecret = "eqCwSoGkm2Uo0WgzjyKGJSrHHApYuljEv1ceEBeMoF8d"; + for (const [description, authorizationHeader] of new Map([ + [ + "Bearer ${base64Encode(`${clientId}:${clientSecret}`)}", + `Bearer ${base64Encode(`${clientId}:${clientSecret}`)}`, + ], + [ + "Basic${base64Encode(`${clientId}:${clientSecret}`)}", + `Basic${base64Encode(`${clientId}:${clientSecret}`)}`, + ], + [ + "Basic ${base64Encode(`${clientId}${clientSecret}`)}", + `Basic ${base64Encode(`${clientId}${clientSecret}`)}`, + ], + ["Basic ${base64Encode(`:${clientSecret}`)}", `Basic ${base64Encode(`:${clientSecret}`)}`], + [ + "${base64Encode(`${clientId}:${clientSecret}`)}`)}", + `${base64Encode(`${clientId}:${clientSecret}`)}`, + ], + ["undefined", undefined], + ])) { + it(`extractClientCredentials should return empty object if authorization header is invalid (${description})`, () => { + expect(extractClientCredentials(authorizationHeader)).toEqual({ + clientId: "", + clientSecret: "", + }); + }); + } + + it("isRedirectUriValid should return true if redirect uri matches for the client", async () => { + expect(await isRedirectUriValid(DUMMY_CLIENT_ID, DUMMY_CLIENT_REDIRECT_URI)).toEqual(true); + }); + + it("isRedirectUriValid should return false if redirect uri does not match for the client", async () => { + expect(await isRedirectUriValid(DUMMY_CLIENT_ID, "https://someotheruri.example.com")).toEqual( + false, + ); + expect(await isRedirectUriValid(DUMMY_CLIENT_ID, `${DUMMY_CLIENT_REDIRECT_URI}/other`)).toEqual( + false, + ); + expect( + await isRedirectUriValid(DUMMY_CLIENT_ID, `${DUMMY_CLIENT_REDIRECT_URI}?query=param`), + ).toEqual(false); + }); + + it("isRedirectUriValid should throw error if client does not exist in database", async () => { + await expect(async () => { + await isRedirectUriValid("0d15269c-a0f3-4e07-8432-a47faede1f53", DUMMY_CLIENT_REDIRECT_URI); + }).rejects.toThrowError("Client with id 0d15269c-a0f3-4e07-8432-a47faede1f53 not found."); + }); +}); + +describe("attachErrorInformationToRedirectUri", () => { + [ + { + redirectUri: "https://redirecturi.example.com", + state: "", + errorType: "access_denied", + expectedRedirectUri: "https://redirecturi.example.com/?error=access_denied", + }, + { + redirectUri: "https://redirecturi.example.com", + state: "someState", + errorType: "invalid_request", + expectedRedirectUri: "https://redirecturi.example.com/?state=someState&error=invalid_request", + }, + { + redirectUri: "https://redirecturi.example.com?existing_query=very_yes", + state: "otherState", + errorType: "invalid_scope", + expectedRedirectUri: + "https://redirecturi.example.com/?existing_query=very_yes&state=otherState&error=invalid_scope", + }, + { + redirectUri: "https://redirecturi.example.com?existing_query=very_yes", + state: "", + errorType: "server_error", + expectedRedirectUri: + "https://redirecturi.example.com/?existing_query=very_yes&error=server_error", + }, + ].forEach(({ redirectUri, state, errorType, expectedRedirectUri }) => { + it(`should attach "${errorType}" error type to a redirect uri ${redirectUri} (with state "${state}")`, () => { + const actualRedirectUri = attachErrorInformationToRedirectUri( + redirectUri, + state, + errorType as AuthorizationResponseErrorType, + ); + expect(actualRedirectUri).toEqual(expectedRedirectUri); + }); + }); +}); + +function base64Encode(text: string): string { + return Buffer.from(text).toString("base64"); +} diff --git a/library/oauth2/client.ts b/library/oauth2/client.ts new file mode 100644 index 0000000..3511322 --- /dev/null +++ b/library/oauth2/client.ts @@ -0,0 +1,112 @@ +import { validate as isValidUUID } from "uuid"; +import { query } from "../../database/database.js"; +import type { AuthorizationResponseErrorType } from "../../routes/frontend.js"; + +export interface Client { + id: string; + name: string; + redirectUri: string; + secret: string; + description: string; +} + +export async function getClientById(id: string): Promise { + if (!isValidUUID(id)) { + return null; + } + + const result = await query( + "SELECT id, name, redirect_uri, secret, description FROM clients WHERE id = $1", + [id], + ); + + if (result.rowCount !== 1) { + return null; + } + + const client = result.rows[0]; + + return { + id: client.id, + redirectUri: client.redirect_uri, + name: client.name, + secret: client.secret, + description: client.description, + }; +} + +export async function clientExists(clientId: string | undefined): Promise { + if (!clientId || !isValidUUID(clientId)) { + return false; + } + + const exists = await query("SELECT EXISTS(SELECT 1 FROM clients WHERE id = $1)", [clientId]); + + return exists.rows[0].exists; +} + +/** + * @param authorizationHeader Authorization request header that uses RFC2617 Basic Authentication Scheme. + * @see https://datatracker.ietf.org/doc/html/rfc2617#section-2 + */ +export function extractClientCredentials(authorizationHeader: string | undefined): { + clientId: string; + clientSecret: string; +} { + if (!authorizationHeader) { + return { clientId: "", clientSecret: "" }; + } + + const [authorizationType, base64EncodedCredentials] = authorizationHeader.split(" "); + + if (authorizationType !== "Basic") { + return { clientId: "", clientSecret: "" }; + } + + const credentials = Buffer.from(base64EncodedCredentials, "base64").toString(); + const [clientId, clientSecret] = credentials.split(":"); + + if (!clientSecret || !clientId) { + return { clientId: "", clientSecret: "" }; + } + + return { clientId, clientSecret }; +} + +export async function isRedirectUriValid(clientId: string, redirectUri: string): Promise { + const clientData = await query("SELECT id, redirect_uri FROM clients WHERE id = $1", [clientId]); + if (clientData.rowCount !== 1) { + throw new Error(`Client with id ${clientId} not found.`); + } + + const clientRedirectUri = clientData.rows[0].redirect_uri; + if (clientRedirectUri !== redirectUri) { + return false; + } + + return true; +} + +/** + * Attaches `error` parameter to `redirect_uri` query string with relevant `errorType`. + * + * If a `state` parameter is provided it is attached as well. + * All original query parameters in the `redirect_uri` are preserved. + * + * @returns `redirect_uri` with error data added to query string + */ +export function attachErrorInformationToRedirectUri( + redirectUri: string, + state: string, + errorType: AuthorizationResponseErrorType, +): string { + const redirectUrl = new URL(redirectUri); + const searchParams = redirectUrl.searchParams; + + if (state) { + searchParams.append("state", state); + } + searchParams.append("error", errorType); + + return redirectUrl.toString(); +} diff --git a/library/oauth2/pkce.test.ts b/library/oauth2/pkce.test.ts new file mode 100644 index 0000000..3f32717 --- /dev/null +++ b/library/oauth2/pkce.test.ts @@ -0,0 +1,26 @@ +import { createHash } from "node:crypto"; +import cryptoRandomString from "crypto-random-string"; +import { describe, expect, it } from "vitest"; +import { verifyPkceCodeAgainstCodeChallenge } from "./pkce.js"; + +describe("verifyPkceCodeAgainstCodeChallenge", () => { + it("should return false if PKCE verifier does not match the challenge", () => { + const codeChallenge = createHash("sha256").update(generateCodeVerifier()).digest("base64url"); + const isValid = verifyPkceCodeAgainstCodeChallenge(generateCodeVerifier(), codeChallenge); + expect(isValid).toStrictEqual(false); + }); + + it("should return true if PKCE verifier does matches the challenge", () => { + const verifier = generateCodeVerifier(); + const codeChallenge = createHash("sha256").update(verifier).digest("base64url"); + const isValid = verifyPkceCodeAgainstCodeChallenge(verifier, codeChallenge); + expect(isValid).toStrictEqual(true); + }); +}); + +function generateCodeVerifier() { + return cryptoRandomString({ + length: 64, + characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~", + }); +} diff --git a/library/oauth2/pkce.ts b/library/oauth2/pkce.ts new file mode 100644 index 0000000..0080337 --- /dev/null +++ b/library/oauth2/pkce.ts @@ -0,0 +1,7 @@ +import { createHash } from "node:crypto"; + +export function verifyPkceCodeAgainstCodeChallenge(verifier: string, challenge: string): boolean { + const challengeFromVerifier = createHash("sha256").update(verifier).digest("base64url"); + + return challengeFromVerifier === challenge; +} diff --git a/library/user.test.ts b/library/user.test.ts new file mode 100644 index 0000000..1818184 --- /dev/null +++ b/library/user.test.ts @@ -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); + }); +}); diff --git a/library/user.ts b/library/user.ts new file mode 100644 index 0000000..38b09fd --- /dev/null +++ b/library/user.ts @@ -0,0 +1,59 @@ +import { validate as isValidUUID } from "uuid"; +import { query } from "../database/database.js"; + +export interface UserData { + id: string; + createdAt: Date; + username: string; + firstname: string; + lastname: string; + password: string; +} + +export async function findUserByUsername(username: string): Promise { + const result = await query( + "SELECT id, username, created_at, firstname, lastname, password FROM users WHERE username = $1", + [username], + ); + + if (result.rowCount !== 1) { + return null; + } + + const userRow = result.rows[0]; + + return { + id: userRow.id, + username: userRow.username, + firstname: userRow.firstname, + lastname: userRow.lastname, + password: userRow.password, + createdAt: userRow.created_at, + }; +} + +export async function findUserById(id: string): Promise { + if (!isValidUUID(id)) { + return null; + } + + const result = await query( + "SELECT id, username, created_at, firstname, lastname, password FROM users WHERE id = $1", + [id], + ); + + if (result.rowCount !== 1) { + return null; + } + + const userRow = result.rows[0]; + + return { + id: userRow.id, + username: userRow.username, + firstname: userRow.firstname, + lastname: userRow.lastname, + password: userRow.password, + createdAt: userRow.created_at, + }; +} diff --git a/library/validation.ts b/library/validation.ts index aaef3a1..14d7bef 100644 --- a/library/validation.ts +++ b/library/validation.ts @@ -1,5 +1,5 @@ -import { query } from "../database/database.js"; import type { UserRegisterType } from "../routes/frontend.ts"; +import { findUserByUsername } from "./user.js"; export interface ValidationError { name?: string; @@ -25,8 +25,7 @@ export async function validateNewUser(user: UserRegisterType): Promise= 8" } }, + "node_modules/crypto-random-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-5.0.0.tgz", + "integrity": "sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==", + "dev": true, + "dependencies": { + "type-fest": "^2.12.2" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1661,6 +1688,15 @@ "node": ">=4" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -2164,6 +2200,14 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", + "integrity": "sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -3928,6 +3972,18 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", diff --git a/package.json b/package.json index e12ed51..ad85f84 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "start": "node build/index.js", "dev": "tsx watch index.ts", - "be": "tsc -p tsconfig.json", "build": "rm -rf ./build && tsc -p tsconfig.json && npm run styles-prod && cp -R ./public ./build/public && cp -R ./frontend ./build/frontend", "migrations": "node bin/migrate.js", "dummy-data": "tsx bin/dummy_data.ts", @@ -24,11 +23,13 @@ "dependencies": { "@fastify/cookie": "^9.3.1", "@fastify/formbody": "^7.4.0", + "@fastify/helmet": "^11.1.1", "@fastify/static": "^7.0.4", "@fastify/type-provider-typebox": "^4.0.0", "@fastify/view": "^9.1.0", "@sinclair/typebox": "^0.32.34", "argon2": "^0.40.1", + "date-fns": "^3.6.0", "dotenv": "^16.4.5", "ejs": "^3.1.10", "fastify": "^4.26.1", @@ -42,6 +43,7 @@ "@types/node": "^20.11.24", "@types/pg": "^8.11.2", "@types/uuid": "^9.0.8", + "crypto-random-string": "^5.0.0", "fastify-tsconfig": "^2.0.0", "tailwindcss": "^3.4.4", "tsx": "^4.7.1", diff --git a/playwright.config.ts b/playwright.config.ts index db6208b..4b49ab7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", + reporter: [["html", { open: "never" }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -68,7 +68,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "npm run build && npm start", + command: process.env.CI ? "npm run build && npm start" : "npm run dev", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, }, diff --git a/routes/api.ts b/routes/api.ts new file mode 100644 index 0000000..7235669 --- /dev/null +++ b/routes/api.ts @@ -0,0 +1,123 @@ +import * as argon2 from "argon2"; +import type { FastifyInstance } from "fastify"; +import { + createAccessTokenForAuthorizationToken, + extractAccessTokenFromHeader, + findAccessTokenByValue, + hasTokenExpired, + revokeAccessTokenIssuedByAuthorizationToken, +} from "../library/oauth2/accessToken.js"; +import { + findAuthorizationTokenByCode, + hasAuthorizationTokenExpired, +} from "../library/oauth2/authorizationToken.js"; +import { extractClientCredentials, getClientById } from "../library/oauth2/client.js"; +import { verifyPkceCodeAgainstCodeChallenge } from "../library/oauth2/pkce.js"; +import { findUserById } from "../library/user.js"; + +export interface AccessTokenRequestQueryParams { + grant_type: "authorization_code"; + code: string; + redirect_uri: string; + code_verifier: string; +} + +// biome-ignore lint/suspicious/useAwait: Fastify requires this to return a Promise to run. +export default async function frontend(fastify: FastifyInstance) { + fastify.post<{ Body: AccessTokenRequestQueryParams }>("/token", async function (request, reply) { + const { code, code_verifier, grant_type, redirect_uri } = request.body; + const { clientId, clientSecret } = extractClientCredentials(request.headers.authorization); + + reply.header("cache-control", "no-store").header("pragma", "no-cache"); + const client = await getClientById(clientId); + + if (!client || !(await argon2.verify(client.secret, clientSecret))) { + return reply + .code(401) + .header("www-authenticate", 'Basic realm="Client authentication"') + .send({ error: "invalid_client" }); + } + + if ( + !code || + !code_verifier || + !grant_type || + !redirect_uri || + Array.isArray(code) || + Array.isArray(grant_type) || + Array.isArray(code_verifier) || + Array.isArray(redirect_uri) + ) { + return reply.code(400).send({ error: "invalid_request" }); + } + + if (grant_type !== "authorization_code") { + return reply.code(400).send({ error: "unsupported_grant_type" }); + } + + if (client.redirectUri !== redirect_uri) { + return reply.code(400).send({ error: "invalid_grant" }); + } + + const authorizationTokenData = await findAuthorizationTokenByCode(code); + if ( + authorizationTokenData === null || + authorizationTokenData.clientId !== clientId || + authorizationTokenData.revoked === true || + hasAuthorizationTokenExpired(authorizationTokenData) + ) { + return reply.code(400).send({ error: "invalid_grant" }); + } + + if (!verifyPkceCodeAgainstCodeChallenge(code_verifier, authorizationTokenData?.codeChallenge)) { + return reply.code(400).send({ error: "invalid_request" }); + } + + let accessTokenData: { + value: string; + expiresIn: number; + scope: string; + }; + + try { + accessTokenData = await createAccessTokenForAuthorizationToken(code); + } catch (error: any) { + if (error.message === "Authorization code already has an access token.") { + await revokeAccessTokenIssuedByAuthorizationToken(code); + + return reply.code(400).send({ error: "invalid_grant" }); + } + + throw error; + } + + return reply.send({ + access_token: accessTokenData.value, + token_type: "Bearer", + expires_in: accessTokenData.expiresIn, + scope: accessTokenData.scope, + }); + }); + + fastify.get("/userinfo", async function (request, reply) { + if (!request.headers.authorization) { + return reply.code(401).header("www-authenticate", "Bearer").send(); + } + + const accessToken = extractAccessTokenFromHeader(request.headers.authorization); + const accessTokenData = await findAccessTokenByValue(accessToken); + + if (accessTokenData === null || hasTokenExpired(accessTokenData)) { + return reply.code(401).header("www-authenticate", "Bearer").send({ error: "invalid_token" }); + } + + const userData = await findUserById(accessTokenData.userId); + + return reply.send({ + sub: userData?.id, + given_name: userData?.firstname, + family_name: userData?.lastname, + preferred_username: userData?.username, + }); + }); +} diff --git a/routes/frontend.ts b/routes/frontend.ts index 49ac7f5..0adb603 100644 --- a/routes/frontend.ts +++ b/routes/frontend.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import querystring from "node:querystring"; +import type { ParsedUrlQueryInput } from "node:querystring"; import StaticServer from "@fastify/static"; import { type Static, Type } from "@sinclair/typebox"; import * as argon2 from "argon2"; @@ -6,14 +8,46 @@ import type { FastifyInstance, FastifyRequest } from "fastify"; import { query } from "../database/database.js"; import { type SetCookieHandler, + type User, createNewAccount, getSignedInUser, isUserSignedIn, signInUser, signOut, } from "../library/authentication.js"; +import { createAuthorizationToken } from "../library/oauth2/authorizationToken.js"; +import { + attachErrorInformationToRedirectUri, + clientExists, + isRedirectUriValid, +} from "../library/oauth2/client.js"; +import { findUserByUsername } from "../library/user.js"; import { validateNewUser } from "../library/validation.js"; +export interface AuthorizationRequestQueryParams extends ParsedUrlQueryInput { + response_type: "code"; + redirect_uri: string; + client_id: string; + scope: string; + state: string; + code_challenge: string; + code_challenge_method: "S256"; +} + +/** + * Specifies possible `error` parameter values for /authorize response, per RFC 6749. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1 + */ +export type AuthorizationResponseErrorType = + | "invalid_request" + | "unauthorized_client" + | "access_denied" + | "unsupported_response_type" + | "invalid_scope" + | "server_error" + | "temporarily_unavailable"; + const UserLogin = Type.Object({ username: Type.String(), password: Type.String(), @@ -102,35 +136,37 @@ export default async function frontend(fastify: FastifyInstance) { /** * Handles login page submit action. */ - fastify.post<{ Body: UserLoginType }>( + fastify.post<{ Body: UserLoginType; Querystring: AuthorizationRequestQueryParams }>( "/login", { schema: { body: UserLogin } }, async function (request, reply) { + // In case of an validation error we want to preserve existing query string parameters + const loginErrorRouteWithQueryParameters = `/login?error=1&${querystring.stringify(request.query)}`; const { username, password } = request.body; if (!username || !password) { - return reply.redirect("/login?error=1"); + return reply.redirect(loginErrorRouteWithQueryParameters); } - const result = await query("SELECT id, username, password FROM users WHERE username = $1", [ - username, - ]); - if (result.rowCount !== 1) { - return reply.redirect("/login?error=1"); + const userData = await findUserByUsername(username); + if (userData === null) { + return reply.redirect(loginErrorRouteWithQueryParameters); } - const user = result.rows[0]; - - const passwordMatches = await argon2.verify(user.password as string, password); + const passwordMatches = await argon2.verify(userData.password as string, password); if (!passwordMatches) { - return reply.redirect("/login?error=1"); + return reply.redirect(loginErrorRouteWithQueryParameters); } - if (user.username !== username) { - return reply.redirect("/login?error=1"); - } + await signInUser(userData.id, reply.setCookie.bind(reply) as SetCookieHandler); - await signInUser(user.id, reply.setCookie.bind(reply) as SetCookieHandler); + // If there are Oauth2 parameters in the query string redirect user back to /authorize endpoint + // so the user can approve or deny the authorization request. + // We check that query string parameters are valid there. + if (request.query.redirect_uri) { + const { error, ...oauth2Params } = request.query; // Remove the error parameter if present. + return reply.redirect(`/authorize?${querystring.stringify(oauth2Params)}`); + } return reply.redirect("/"); }, @@ -140,4 +176,139 @@ export default async function frontend(fastify: FastifyInstance) { await signOut(request, reply.clearCookie.bind(reply)); return reply.redirect("/"); }); + + /** + * Follows rfc6749 standard for authorization request handling and response. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.1 + */ + fastify.get<{ Querystring: AuthorizationRequestQueryParams }>( + "/authorize", + async function (request, reply) { + if (!(await clientExists(request.query.client_id))) { + return reply.redirect(`/error/client-id?${querystring.stringify(request.query)}`); + } + + if ( + (await isRedirectUriValid(request.query.client_id, request.query.redirect_uri)) === false + ) { + return reply.redirect(`/error/redirect-uri?${querystring.stringify(request.query)}`); + } + + const errorType = validateAuthorizeQueryString(request); + if (errorType !== "valid") { + return reply.redirect( + attachErrorInformationToRedirectUri( + request.query.redirect_uri, + request.query.state, + errorType, + ), + ); + } + + if (!(await isUserSignedIn(request))) { + return reply.redirect(`/login?${querystring.stringify(request.query)}`); + } + + const clientName = ( + await query("SELECT name FROM clients WHERE id = $1", [request.query.client_id]) + ).rows[0].name; + + return reply.view("approvePage.ejs", { clientName }); + }, + ); + + interface AuthorizeRequestBody { + approved: "" | undefined; + denied: "" | undefined; + } + + /** + * Called when the user approves (or denies) the client's request for authorization. + */ + fastify.post<{ Querystring: AuthorizationRequestQueryParams; Body: AuthorizeRequestBody }>( + "/authorize", + async function (request, reply) { + if (!(await isUserSignedIn(request))) { + return reply.code(403).send("Forbidden"); + } + + const errorType = validateAuthorizeQueryString(request); + if (errorType !== "valid") { + return reply.redirect( + attachErrorInformationToRedirectUri( + request.query.redirect_uri, + request.query.state, + errorType, + ), + ); + } + + // User denied the authorization request. + if (request.body.denied !== undefined) { + return reply.redirect( + attachErrorInformationToRedirectUri( + request.query.redirect_uri, + request.query.state, + "access_denied", + ), + ); + } + + const user = (await getSignedInUser(request)) as User; + const authorizationCode = await createAuthorizationToken({ + clientId: request.query.client_id, + userId: user.id, + scope: request.query.scope, + codeChallenge: request.query.code_challenge, + codeChallengeMethod: request.query.code_challenge_method, + }); + + return reply.redirect( + `${request.query.redirect_uri}?code=${authorizationCode}&state=${request.query.state}`, + ); + }, + ); + + fastify.get<{ Querystring: AuthorizationRequestQueryParams; Params: { errorType: string } }>( + "/error/:errorType", + function (request, reply) { + const { errorType } = request.params; + if (errorType === "redirect-uri") { + return reply.view("errorPage.ejs", { errorType }); + } + + return reply.view("errorPage.ejs", { errorType, clientId: request.query.client_id }); + }, + ); +} + +/** + * @param request Fastify `/authorize` request. + * @returns error type or 'valid'. + */ +function validateAuthorizeQueryString( + request: FastifyRequest<{ Querystring: AuthorizationRequestQueryParams }>, +): AuthorizationResponseErrorType | "valid" { + if (request.query.response_type !== "code") { + return request.query.response_type === "token" + ? "unsupported_response_type" + : "invalid_request"; + } + + if (request.query.scope !== "openid") { + return !request.query.scope || typeof request.query.scope === "object" + ? "invalid_request" + : "invalid_scope"; + } + + if (!request.query.code_challenge || typeof request.query.code_challenge === "object") { + return "invalid_request"; + } + + if (request.query.code_challenge_method !== "S256") { + return "invalid_request"; + } + + return "valid"; }