Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions AI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# AI Use

Throughout this assignment I collaborated with an AI assistant to develop faster, improve my design, and develop a robust testing suite

## How AI Was Used

### 1. Codebase Exploration & Analysis
I have not worked with Fastify or Passport before. The AI tools were very useful to help me understand what both repos were doing and why fastify-passport needed to exist. I learned that Passport was created years ago to work alongside Express using a callback based architecture. And although Express less today, Passport continues to thrive with new frameworks like Fastify that use a more modern promise based architecture. Fastify-passport exists to bridge that gap from legacy Express architecture to modern fastify.

### 2. Architectural Brainstorming
I discussed various directions we could take the project with the AI tool. I honestly probably spent most of my time going back and forth with the AI in the types.ts file before writing any execution logic. It was incredibly helpful to bounce ideas off of and to lookup certain properties or conventions already existed in the legacy Passport code so we could reuse them or stay consistent. Once I was confident with the types.ts the rest of the application was easier and fit together nicely.

### 3. AI as a reviewer
As development went, I used a second AI to review the code to catch any edge cases that I may have missed in my own review
- **Object.assign vs Object.create:** The original implementation used Object.assign on review by the AI reviewer it caught that this was going to cause errors. We then wrote a test to confirm and found that it was true. It's something that we would've caught anyway in testing but it's great to catch that as soon as possible.
- **Timing Accuracy:** The AI pointed out that we should use `performance.now()` instead of `Date.now()` for more accurate metrics on performance.
- **Empty strategy array:** The AI reviewer found that even if the stragey array was empty we passed the array along which would confusinly return empty data. We cleaned that up by returning early and giving a more explicit message that the array was empty.

### 4. Test Generation & Validation
I used the AI to help write the unit tests. It was great at identifying edge cases and it helped make an extremly robust testing suite. The AI reviewer even pointed out that one of the tests didn't accurately test that `AuthContext` wasn't being shared across requests. Before the two requests were run together but since they return immdiately there really wasn't any time for the 2nd request to overwrite the firsts context. It gave the good advice to have the first one wait a little bit for the 2nd one to finish and then check if the 1st requests `AuthContext` was corrupted by the 2nds or if it is what we expected it to be.

### 5. What I didn't use AI for (mostly)
I didn't use AI for this file or the DESIGN.md. In a real setting I would probably use AI to speed up drafting a design or requirements doc before reviewing and editting it. But since this project was to go over my competency I thought it was important that all of this was my own words. I used AI to check my grammar and help with some of the flow but for the most part this came from me.
67 changes: 67 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Fastify-Passport Programmatic API Design

This document outlines my thought process through important decisions that were made while
implementing the `authenticateRequest` function that provides a programmatic auth API for
`@fastify/passport`

## 1. Implementation Architecture

**Promise-based Flow**
Passport code is callback-based but we want it to be Promise-based. Passport strategies work by executing and then calling one of five callbacks (`success`, `fail`, `error`, `redirect`, `pass`) when the execution is done. To change this to be a promise based flow we make a clone of the strategy and patch the callback methods to resolve or reject a promise. So `strategy.authenticate(request, options)` will run the same exact strategy code as before but return a promise instead of using callbacks.

**How we clone matters**
At first I considered the classic cloning method of using `Object.assign` to clone the strategy but after some research I realized this would break the prototype chain. This would cause issues where the strategy would lose it's parent class methods. This would break the strategy's ability to call `this.success()`, `this.fail()`, etc. I found that `Object.create(strategy)` would create a new object that kept the prototype chain intact.

**Stateless Method vs. Stateful Class**
I considered following the originals codes design of the `Authenticator` class calling another class to handle the execution logic. After looking deeper into how the original code worked, I realized that `AuthenticatorRoute` is a factory that needed to be stateful to to hold the data for the route hook function. Our new method is just a function that executes statelessly. It seemed overkill to create a new class even if thats what the original code did. So I decided to just keep the code in `Authenticator.ts`

**Duplication of logic over helper functions**
I noticed the `getStrategyName` and `getStrategy` in the `AuthenticatorRoute` was code that could be used for our new logic too. I considered moving it to a shared utility file to avoid duplicating code, but realized that since `AuthenticatorRoute` was a separate class, it had to call the public `this.authenticator.strategy()` method to get the strategy. And since our code was already in `Authenticator.ts`, we had access to the `this.strategies` variable directly. A helper would have to pass around `this.strategies` or the `Authenticator` class to the helper function which felt messy and some duplicate code was better than a confusing abstraction. That's how we ended up with `resolveStrategyName` and `resolveStrategy` in `Authenticator.ts`.

## 2. API Design Decisions

**Naming Conventions in Types.ts**
The most common question I had when making the types was how does the current Passport code and `Authenticator` class handle the types. If we needed data and it already was called something else in the Passport code (like challenge, status, or info), we just used that name. If we needed data and it didn't exist in the Passport code, we tried to keep to same naming convention of other types

**AuthResult**
The assignment gives a pretty comprehensive example of what `AuthResult` should look like. Here's my reaosoning for some of my departures from the example:
**Strategy is optional**
I didn't want to return a strategy if all strategies failed. So I made it optional, the developer would have to check if `ok` is true before using the strategy. But I think this is better than returning a strategy that could've failed.

**Staus code NOT optional**
It's a little redundant to return a 200 when `ok` it true but I think the consistency of always having a status code is worth the redundancy. I realize this seems to contradict my `Strategy is optional` decision but I think the difference is that status code always does exist, it's just a decision to give it to the developer or not. Whereas `strategy` can be misleading if returned when all strategies failed.

**RedirectUrl**
Passport right now automatically set a header when the `redirect` callback fires. We want to keep that behavior possible but hand the final decision to the developer on what to ultimately do. So instead of setting the header we return a `redirectUrl` property. The developer can then use that data to redirect the user or whatever else they want to do.

**Info unknown**
I wasn't sure what `info` was at first and wondered if it could be typed. But `info` is defined by the strategy and there's no standard so we truly don't know what it is.

**AuthContext**
Again the assignment gives a good example of what `AuthContext` should look like and I'll share the decisions I made to change it and why.

**Change attempts to an object**
AuthContext is an object to store the overall outcome of the authentication attempt. I used the attempts field on it to store data on each individual strategy attempt. We stored the strategy name, outcome, elapsed time and error type. One concern I have is if its harder for metrics to read the length of an array compared to just a number. I decided that modern metrics tools are able to determine a length of an array so I decided it was fine to replace the attempts amounts number.

**successfulStrategy**
Same as `strategy` in `AuthResult` I thought optional would be good to only appear on success for the same reasons. I renamed it to `successfulStrategy` though to be more clear since the `AuthAttempt` type also has a `strategy` field. And the top level `successfulStrategy` field is for the whole authentication attempt.

**API Design**
Most of the design decisions were based on giving the developer to power to control the flow of the authentication process. Here's some of the more niche decisions I made.
**`authenticateRequest()` does NOT auto-login (but it can)**
The assignment says `implicit side effects only`. Passport has a `AuthenticateOptions` with a `session` option that defaults to `true` and auto logs the user in on a successful authenticate so we want to turn that off. But a developer might want an easy way to auto login, Passports `AuthenticateOptions` has a `session` option. So instead of calling `authContext.login(user)` after a successful authentication, the developer can just pass `session: true` to `authenticateRequest` and it will auto login the user.

**`error()` halts the loop**
I considered allowing `error()` try the next strategy. I thought it might be good to try another strategy if one strategy was temporarily down or something. But I noticed that the original code stop's the execution on any error. This made me think of the unexpected behaviours that could emerge if we continued the loop on an error. Like an example is if we used our database to blacklist a user and the database was down, if we skipped over the error and tried another the malicious user could still login. So ultimately I decided to just do what Passport was doing.

## 3. Security & Observability

**Sanitized Errors**
The errors returned from strategies can be anything the strategies want to throw. So we can't really trust the payload. A database could error with raw SQL or an OAUTH provider could throw a token. To hopefully give the developer some context without risking leaking important infrastructure secrets or PII I decided to return the `error.name` of the error thrown. It should give some context like `TokenExpiredError` or `InvalidTokenError` but not the full error message. We could maybe use regex to redact PII from the error message but I think it's not worth it for being computaionally expensive and having too much risking of letting PII slip through the filters.

**User ID PII Tradeoff**
With our implementation `userId` could have PII in it. If the application developer uses something like an `email` as the user id then we're logging that email here. I considered not logging it but I think at some point we have to offload the responsibility to the application developer to not use PII as the user id. Maybe this is a mistake but I think the `userId` is too valuable of context to not log it for some applications. Hopefully no one is using SSN's as userIds!

## 4. Bonus, Edge Cases, Known Limitations
**Hanging Promise**
I didn't get to this but I'll explain the timeout problem and how we would solve it. Just like Passport itself, we are using strategies that aren't designed and written by us. If a strategy is poorly written and never calls any of the functions that resolve the promise then we will hang indefinitely. We would handle this is add a timeout that rejects the promise if it takes too long. This should work the same way as if the strategy called `error()` since it opens us to the same security risks if we contiue
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ Beta. `@fastify/passport` is still a relatively new project. There may be incomp
npm i @fastify/passport
```

## Running the Demo

This repository includes a fully-functional Demo Server (`demo-auth-server.ts`) that showcases exactly how to use the programmatic `authenticateRequest()` API and `request.authContext` observability hook. The demo utilizes two mocked strategies: stateful `Session` and stateless `API Key` fallback.

To test the demo server locally:

**1. Start the server (Requires ts-node):**
```bash
npm run build
npx ts-node demo-auth-server.ts
```

**2. Test the endpoints:**
You can test the programmatic auth fallback logic using the following curl commands. The protected route will attempt to authenticate via the stateful Session cookie first, and if that fails, it will attempt to authenticate statelessly via the `x-api-key` header.

```bash
# Login (creates a secure session)
curl http://127.0.0.1:3000/login

# Test Protected Route (fails if no session/key is present)
# Succeeds statelessly with API key header
curl -H "x-api-key: secret-key" http://127.0.0.1:3000/protected

# Test with an invalid API key header
curl -H "x-api-key: invalid" http://127.0.0.1:3000/protected

# Logout (clears session and forces fallback to API key on next request)
curl http://127.0.0.1:3000/logout
```

## Google OAuth2 Video tutorial

The community created this fast introduction to `@fastify/passport`:
Expand Down Expand Up @@ -231,6 +261,37 @@ fastify.get(
)
```

### authenticateRequest(strategy: string | Strategy | (string | Strategy)[], request: FastifyRequest, reply: FastifyReply, options?: AuthenticateOptions)

A programmatic alternative to `.authenticate()`. Returns a `Promise<AuthResult>` instead of a Fastify route hook, allowing you to explicitly handle the authentication result and control the response flow inside your route handler.

The `AuthResult` object contains the outcome of the authentication attempt, including `ok: boolean`, the `statusCode` (e.g. 200, 401), and the authenticated `user` (if successful). If authentication fails across all strategies, it may contain `challenges` from the attempted strategies.

Options:
- `session` Save login state in session, defaults to _false_ (different from `.authenticate()`)
- Other options like `assignProperty`, `state`, and `keepSessionInfo` are also supported. Note that redirection options (`successRedirect`, `failureRedirect`) will not automatically trigger a redirect; instead, their URLs will be returned in the `AuthResult.redirectUrl` property for you to handle manually.

Example:

```js
fastify.get('/protected', async (request, reply) => {
const result = await fastifyPassport.authenticateRequest(['bearer', 'basic'], request, reply)

if (result.ok) {
return reply.code(200).send({
message: 'Authentication successful!',
user: result.user
})
} else {
// result.statusCode will typically be 401
return reply.code(result.statusCode).send({
error: 'Authentication failed',
challenges: result.challenges
})
}
})
```

### authorize(strategy: string | Strategy | (string | Strategy)[], options: AuthenticateOptions = {}, callback?: AuthenticateCallback)

Returns a hook that will authorize a third-party account using the given `strategy`, with optional `options`. Intended for use as a `preValidation` hook on any route. `.authorize` has the same API as `.authenticate`, but has one key difference: it doesn't modify the logged in user's details. Instead, if authorization is successful, the result provided by the strategy's verify callback will be assigned to `request.account`. The existing login session and `request.user` will be unaffected.
Expand Down Expand Up @@ -325,6 +386,19 @@ Therefore, a deserializer can return several things:

Test if request is unauthenticated.

### Request#authContext

During an authentication attempt (either via `.authenticate()` hook or `.authenticateRequest()`), `@fastify/passport` exposes `request.authContext` to provide deep observability into the authentication process. This object is populated incrementally as strategies execute and provides a summary upon completion.

The context includes:
- `outcome`: The final result (`'authenticated'`, `'rejected'`, `'error'`, etc.)
- `successfulStrategy`: The name of the strategy that eventually succeeded (if any)
- `elapsedMs`: Total time spent authenticating across all strategies
- `userId`: The `id` property from the authenticated user object, if present
- `attempts`: An array detailing the `strategy` name, `outcome`, `elapsedMs`, and specific `errorType` (if applicable) for each individual strategy tried

This is highly useful for audit logging, metrics aggregation, and debugging complex multi-strategy scenarios.

## Using with TypeScript

`@fastify/passport` is written in TypeScript, so it includes type definitions for all of its API. You can also strongly type the `FastifyRequest.user` property using TypeScript declaration merging. You must re-declare the `PassportUser` interface in the `fastify` module within your own code to add the properties you expect to be assigned by the strategy when authenticating:
Expand Down
Loading
Loading