Skip to content

Commit

Permalink
[WIP] created basic oauth2 flow happy path:
Browse files Browse the repository at this point in the history
- api routes
- db tables for access and authorization tokens
- happy path e2e test
- TODO: actual authentication, parameter validation, error handling
  • Loading branch information
ldgit committed Aug 25, 2024
1 parent 4443869 commit 4e343d9
Show file tree
Hide file tree
Showing 9 changed files with 293 additions and 21 deletions.
19 changes: 12 additions & 7 deletions database/createDummyData.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import * as argon2 from "argon2";
import { query, transactionQuery } from "../database/database.js";

/** Only for use in tests. */
const DUMMY_CLIENT_ID = "23f0706a-f556-477f-a8cb-808bd045384f";
/** Only for use in tests. */
const DUMMY_CLIENT_NAME = "Lumon Industries";
/** Only for use in tests. */
const DUMMY_CLIENT_REDIRECT_URI = "http://lumon.example.com";

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

console.log("Creating dummy data ");
Expand All @@ -31,19 +38,17 @@ export async function createDummyData() {
console.log("---");

console.log("Creating clients");
const clientId = "23f0706a-f556-477f-a8cb-808bd045384f";
const clientName = "Lumon Industries";
await client.query(
"INSERT INTO clients(id, name, redirect_uri, secret, description) VALUES($1, $2, $3, $4, $5) RETURNING id",
[
clientId,
clientName,
"https://lumon.example.com",
DUMMY_CLIENT_ID,
DUMMY_CLIENT_NAME,
DUMMY_CLIENT_REDIRECT_URI,
"secret_123",
"A dummy client used for testing and development purposes.",
],
);
console.log(`Created a test client ${clientName} (id: ${clientId})`);
console.log(`Created a test client ${DUMMY_CLIENT_NAME} (id: ${DUMMY_CLIENT_ID})`);
},
{ destroyClient: true },
);
Expand Down
152 changes: 152 additions & 0 deletions e2e/oauth2.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { createHash } from "node:crypto";
import { URL } from "node:url";
import { expect, test } from "@playwright/test";
import * as argon2 from "argon2";
import cryptoRandomString from "crypto-random-string";
import { query } from "../database/database.js";

let clientCounter = 1;

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_${clientCounter}`;
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;

clientCounter += 1;

return { id, secret, name, redirectUri };
}

/**
* We use PKCE flow.
*
* Based on instructions from https://www.oauth.com/playground/authorization-code-with-pkce.html.
*/
test("oauth2 flow happy path", async ({ page, browserName, baseURL, request }) => {
test.setTimeout(4000);
// TODO remove this
test.skip(browserName.toLowerCase() !== "firefox", "Test only on Firefox!");

const { id, name, redirectUri, secret } = await createTestClient(baseURL as string);

// Generate code verifier:
const codeVerifier = cryptoRandomString({
length: 64,
characters: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~",
});
// Generate code challenge
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=bang&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=bang&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(
`/approve?response_type=code&client_id=${id}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=bang&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 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(/\/\?/);
// We verify the data in the redirect_uri query string is there.
const expectedRedirectUri = new URL(page.url());
const authorizationCode = expectedRedirectUri.searchParams.get("code");
await expect(expectedRedirectUri.searchParams.get("state")).toEqual(state);
// Check that we received the authorization code token.
await expect(authorizationCode?.length).toBeGreaterThan(10);

/**
* 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
*/
const response = await page.request.post("/api/v1/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${btoa(`${id}:${secret}`)}`,
},
form: {
grant_type: "authorization_code",
redirect_uri: redirectUri,
code: authorizationCode as string,
code_verifier: codeVerifier,
},
});
expect(response.ok()).toBeTruthy();
const responseJson = await response.json();
/**
* Verify that access token response follows rfc6749 specification.
*
* @see https://datatracker.ietf.org/doc/html/rfc6749.html#section-5.1
*/
expect(responseJson.access_token).not.toBeFalsy();
expect(responseJson.token_type).toEqual("Bearer");
expect(responseJson.expires_in).toEqual(86400);
expect(responseJson.scope).toEqual("basic_info");
const headers = await response.headers();
expect(headers["cache-control"]).toEqual("no-store");
expect(headers.pragma).toEqual("no-cache");
expect(headers["content-type"]).toEqual("application/json; charset=utf-8");

/**
* Using the access token fetch basic user info from the resource server.
*/
const resourceResponse = await page.request.post("/api/v1/resource/basic-info", {
headers: { Authorization: `Bearer ${responseJson.access_token}` },
});
expect(resourceResponse.ok()).toBeTruthy();
expect(await resourceResponse.json()).toEqual({
username: "MarkS",
name: "Mark",
surname: "Scout",
});
});

/**
* TODO validation for POST /token tests:
* - one of the parameters is missing: respond with 400 http status code, `error: invalid_request`
* - one of the parameters unsupported: respond with 400 http status code, `error: invalid_request`
* - one of the parameters is repeated: respond with 400 http status code, `error: invalid_request`
* - redirect uri does not match: respond with 400 http status code, `error: invalid_grant`
* - authorization code is invalid or expired: respond with 400 http status code, `error: invalid_grant`
* - unknown grant type: respond with 400 http status code, `error: unsupported_grant_type`
* - if access token is requested twice for the same auth code, access_token is invalidated and user must sign in again
*/
6 changes: 3 additions & 3 deletions frontend/templates/approvePage.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<main class="container mx-auto min-w-fit flex flex-col items-center text-center justify-center">
<h2 class="text-2xl">"<%= clientName %>" wants to access your user data</h2>
<a href="<%= redirectUri %>">
<%- include('button', { color: 'blue', type: 'button', text: 'Approve' }); %>
</a>
<form action="" method="post">
<%- include('button', { color: 'blue', type: 'submit', text: 'Approve' }); %>
</form>
</main>
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fastify.register(pointOfView, {
layout: "layout.ejs",
});
fastify.register(frontendRoutes);
fastify.register(apiRoutes);
fastify.register(apiRoutes, { prefix: "/api/v1" });

fastify.listen({ port: 3000 }, (err, address) => {
if (err) {
Expand Down
35 changes: 34 additions & 1 deletion library/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { query } from "../database/database.js";
import {
SESSION_COOKIE_NAME,
createNewAccount,
extractClientCredentials,
getSignedInUser,
isUserSignedIn,
signInUser,
Expand All @@ -15,7 +16,7 @@ import {
const passwordHash =
"$argon2id$v=19$m=65536,t=3,p=4$P5wGfnyG6tNP2iwvWPp9SA$Gp3wgJZC1xe6fVzUTMmqgCGgFPyZeCt1aXjUtlwSMmo";

describe("authentication", () => {
describe("user authentication", () => {
const userIds: string[] = [];
const sessionIds: string[] = [];

Expand Down Expand Up @@ -184,3 +185,35 @@ describe("authentication", () => {
expect(clearCookieHandler).toHaveBeenCalledWith(SESSION_COOKIE_NAME);
});
});

describe("client authentication", () => {
const clientId = "e2062e6b-7af1-4c45-9b13-9ebfe9263fe6";
const clientSecret = "eqCwSoGkm2Uo0WgzjyKGJSrHHApYuljEv1ceEBeMoF8d";

it("extractClientCredentials should extract client credentials from an authorization request header", () => {
const clientCredentials = extractClientCredentials(
`Basic ${btoa(`${clientId}:${clientSecret}`)}`,
);

expect(clientCredentials).toEqual({
clientId,
clientSecret,
});
});

for (const [description, authorizationHeader] of new Map([
[
"Bearer ${btoa(`${clientId}:${clientSecret}`)}",
`Bearer ${btoa(`${clientId}:${clientSecret}`)}`,
],
["Basic${btoa(`${clientId}:${clientSecret}`)}", `Basic${btoa(`${clientId}:${clientSecret}`)}`],
["Basic ${btoa(`${clientId}${clientSecret}`)}", `Basic ${btoa(`${clientId}${clientSecret}`)}`],
["Basic ${btoa(`:${clientSecret}`)}", `Basic ${btoa(`:${clientSecret}`)}`],
["${btoa(`${clientId}:${clientSecret}`)}`)}", `${btoa(`${clientId}:${clientSecret}`)}`],
["undefined", undefined],
])) {
it(`extractClientCredentials should return null if authorization header is invalid (${description})`, () => {
expect(extractClientCredentials(authorizationHeader)).toBeNull();
});
}
});
28 changes: 28 additions & 0 deletions library/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,31 @@ export async function signOut(request: FastifyRequest, clearCookieHandler: Clear
await query("DELETE FROM sessions WHERE id = $1", [sessionId]);
clearCookieHandler(SESSION_COOKIE_NAME);
}

/**
* @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;
} | null {
if (!authorizationHeader) {
return null;
}

const [authorizationType, base64EncodedCredentials] = authorizationHeader.split(" ");

if (authorizationType !== "Basic") {
return null;
}

const credentials = atob(base64EncodedCredentials);
const [clientId, clientSecret] = credentials.split(":");

if (!clientSecret || !clientId) {
return null;
}

return { clientId, clientSecret };
}
17 changes: 17 additions & 0 deletions migrations/20240801001.do.create_tokens_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS authorization_tokens (
id BIGSERIAL PRIMARY KEY,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
value text NOT NULL UNIQUE,
scope text NOT NULL,
client_id uuid NOT NULL references clients(id)
);

CREATE TABLE IF NOT EXISTS access_tokens (
id BIGSERIAL PRIMARY KEY,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_in integer NOT NULL,
value text NOT NULL UNIQUE,
scope text NOT NULL,
client_id uuid NOT NULL references clients(id),
authorization_token_id BIGSERIAL NOT NULL references authorization_tokens(id)
);
24 changes: 19 additions & 5 deletions routes/api.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import querystring, { type ParsedUrlQueryInput } from "node:querystring";
import type { ParsedUrlQueryInput } from "node:querystring";
import type { FastifyInstance } from "fastify";
import { type User, extractClientCredentials, getSignedInUser } from "../library/authentication.js";

Check failure on line 3 in routes/api.ts

View workflow job for this annotation

GitHub Actions / build

'extractClientCredentials' is declared but its value is never read.

export interface AuthorizationQueryParams extends ParsedUrlQueryInput {
export interface AccessTokenRequestQueryParams extends ParsedUrlQueryInput {
response_type: "code";
redirect_uri: string;
client_id: string;
client_secret: string;
scope: string;
state: string;
code_challenge: string;
code_challenge_method: "S256";
}

export default async function frontend(fastify: FastifyInstance) {
fastify.get<{ Querystring: AuthorizationQueryParams }>("/authorize", function (request, reply) {
// TODO check that query params are valid
return reply.redirect(`/login?${querystring.stringify(request.query)}`);
fastify.post<{ Querystring: AccessTokenRequestQueryParams }>("/token", function (request, reply) {
// TODO validation

return reply.header("cache-control", "no-store").header("pragma", "no-cache").send({
access_token: "TODO GENERATE ME RANDOMLY",
token_type: "Bearer",
// 24 hours.
expires_in: 86400,
scope: "basic_info",
});
});

fastify.post("/resource/basic-info", async function (request, reply) {
const { username, name, surname } = (await getSignedInUser(request)) as User;
return reply.send({ username, name, surname });
});
}
Loading

0 comments on commit 4e343d9

Please sign in to comment.