Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
918f560
wrap app in authenticator if cognito information is set in env file, …
chnnick May 15, 2026
94a3b9e
correctly importing env variables, type guards for each value so conf…
chnnick May 15, 2026
dd53297
new cognito env variables, JWTPayload type
chnnick May 20, 2026
c222613
cognito config + updated types to allow for both id and access tokens
chnnick May 20, 2026
e61d789
cognito guard, module, and decorators, added to appmodule
chnnick May 20, 2026
765e65c
tests for guard and service
chnnick May 23, 2026
118ed9b
documentation
chnnick May 23, 2026
b3d9471
move auth into the correct folder
chnnick May 23, 2026
557f6f9
warnings for dual usage of Cognito
chnnick May 24, 2026
1e94607
widen aud type to allow for serialization of the aud claim as either …
chnnick May 25, 2026
f2b0716
updated documentation
chnnick May 25, 2026
81729d4
tests clarification for denying partial configurations: Auth is activ…
chnnick May 25, 2026
fd9f237
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick May 26, 2026
e4e878a
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick May 26, 2026
57c2553
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick May 26, 2026
f78c0e7
Merge branch '140-Cognito' of https://github.com/Code-4-Community/sca…
chnnick May 26, 2026
65d940c
Cognito Module as a global import, export CognitoJWTGuard in module f…
chnnick May 29, 2026
fc6d8ec
require all three Cognito env variables to enable authentication
chnnick Jun 2, 2026
4f3d4f1
wrapped getUser tests into describe(), use Request type alias instead…
chnnick Jun 2, 2026
ad13e14
use cached jswk client for jwks, typed the payload that returns from …
chnnick Jun 2, 2026
167141a
updated documentation after importing CognitoModule into AppModule by…
chnnick Jun 2, 2026
378435b
updated documentation to match new auth disable requirements (missing…
chnnick Jun 2, 2026
35a5127
updated tests for missing env variables -> auth disabled
chnnick Jun 2, 2026
2b96133
try to just use cognitoinformationpresent
chnnick Jun 2, 2026
cd44f7a
type vite cognito env vars before passing into amplify.configure
chnnick Jun 2, 2026
0253089
Merge branch '140-Cognito' of https://github.com/Code-4-Community/sca…
chnnick Jun 2, 2026
146b271
get rid of VITE specific cognito variables in .env instead have them …
chnnick Jun 2, 2026
b579463
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick Jun 2, 2026
960951e
check for cognito region when turning on auth in the frontend
chnnick Jun 2, 2026
65b5ba8
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick Jun 5, 2026
9dbd28c
specify frontend root for vite
chnnick Jun 5, 2026
cba88c4
type guard + make token_use a required field -> strict token checking
chnnick Jun 10, 2026
614ae40
README with new auth flow for newbies, updated access token only
chnnick Jun 11, 2026
a3c374c
tokens clarification
chnnick Jun 11, 2026
ac27824
get rid of id token in jwtpayload checks for the backend, rename
chnnick Jun 11, 2026
e13d23c
updated tests with new groupings
chnnick Jun 11, 2026
e0d28e9
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick Jun 12, 2026
57834c9
rename new payload type in readme
chnnick Jun 12, 2026
836bb28
typo
chnnick Jun 12, 2026
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
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import AppDataSource from './data-source';
import { CognitoModule } from './aws/cognito/cognito.module';
import { UsersModule } from './users/users.module';

@Module({
Expand All @@ -12,6 +13,7 @@ import { UsersModule } from './users/users.module';
isGlobal: true,
}),
TypeOrmModule.forRoot(AppDataSource.options),
CognitoModule,
UsersModule,
],
controllers: [AppController],
Expand Down
124 changes: 124 additions & 0 deletions apps/backend/src/aws/cognito/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
## Scaffolding auth flow
Some key concepts you'll need to know are:
- **Authentication (authn)** = *"who are you?"* -> ex: distinguishing a known user from an unknown one
- **Authorization (authz)** = *"what are you allowed to do?"* -> ex: distinguishing admin access vs normal user access to routes

1. **Unauthenticated user hits the app.** A user opens the frontend with no token. If they call a protected (Non Public) backend route, `CognitoJWTGuard` finds no `Authorization: Bearer <token>` header and responds `401 Unauthorized`.

2. **User authenticates with Cognito** The frontend sends the user's credentials to Cognito. Cognito verifies the credentials and *authenticates* the user. This happens entirely between the client and Cognito. Our backend is not involved and never sees the password.

3. **Cognito issues tokens.** On success, Cognito returns separate signed JWTs for the following:
- **ID token**: describes *who the user is* (identity claims), meant for the frontend.
- **access token**: the *authorization* credential, meant to be sent to backend APIs and checked by the `CognitoJWTGuard`. (See [Token validation](#token-validation))
- **refresh token**: used to obtain fresh ID/access tokens when they expire.

4. **Frontend calls the backend with the access token.** The client attaches it on every request as a header: `Authorization: Bearer <access_token>`.

5. **The Guard checks the token.** `CognitoJWTGuard` runs on every route (it's registered as a global `APP_GUARD`). For each request it:
- lets the request through immediately if auth is disabled (Cognito env vars unset or the route is marked `@Public()`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes it seem like Public disables auth. I think you should specify here that specifying a route as public does not disable anything, but rather just makes an easy bypass

- extracts and verifies the Bearer token, then checks the RS256 signature against the pool's public keys (JWKS), the issuer, expiration, that `token_use === 'access'`, and that `client_id` matches our app client.

6. **Allow or deny.**
- **Valid token** → the guard attaches the decoded claims to `request.user` and the request proceeds to the controller -> Read with `CognitoService.getUser(req)`.
- **Missing or invalid token** (bad signature, expired, wrong token type, wrong client) → `401 Unauthorized`, and the controller never runs.

So: **every route is protected by default, a request is allowed only if it carries a valid Cognito access token or if the route is marked `@Public()`, which skips the check entirely.** Public routes are for things that must work without a login, like health checks, webhooks, or the login entry point itself.

## QUICKSTART:

Copy placeholders from the repo root `example.env` into `.env` (or your deployment secrets):

| Variable | Purpose |
|----------|---------|
| `COGNITO_USER_POOL_ID` | Your registered users in Cognito to authenticate with |
| `COGNITO_CLIENT_ID` | The application you are building's own id linked to Cognito used to validate `client_id` on tokens |
| `COGNITO_REGION` | AWS region |

> [!IMPORTANT]
> If any Cognito env variables are unset: `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID`, `COGNITO_REGION`, authentication via JWT enforcement is **disabled entirely**

### Auth model

- **Verification** — `CognitoJWTGuard` is the only component that validates JWTs (signature, issuer, audience).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CognitoJwtGuard does not validate the audience the way we have it implemented here

- **Global guard** — `CognitoModule` registers `CognitoJWTGuard` as an `APP_GUARD`, so every route is protected by default. You do **not** need `@UseGuards(CognitoJWTGuard)` on controllers when using this setup.
- **`request.user`** — After a successful check, the guard sets `request.user` to the decoded JWT payload (`AccessTokenPayload`: `sub`, `client_id`, `cognito:groups`, `token_use`, etc.)

### Using Cognito in your app (recommended + implemented: global guard)

Import `CognitoModule` into `AppModule` (Already ). This enables auth app-wide and exports `CognitoService` for reading `request.user`:

> [!IMPORTANT]
> Note: This has already been implemented by default

```typescript
@Module({
imports: [TypeOrmModule.forRoot(...), CognitoModule],
})
export class AppModule {}
```

New controllers are protected automatically. Opt out with `@Public()` (see below). Read the caller with `CognitoService.getUser(req)` or `req.user` after the guard runs.

### Public Routes
Use the `@Public()` decorator on routes that are technically protected, but don't require authentication. i.e. health checks, webhooks, or unauthenticated entry points:

```typescript
import { Public } from './aws/cognito/cognito.decorator';

@Controller('health')
export class HealthController {
@Public()
@Get()
check() {
return { ok: true };
}
}
```

### `CognitoService.getUser()`

Inject `CognitoService` to extract the same `AccessTokenPayload` decoded token payload the guard attached to `request.user`:

```typescript
@Get('me')
me(@Req() req: Request) {
const user = this.cognitoService.getUser(req);
// null when auth env is incomplete/disabled, or when request.user was never set
return user;
}
```

Returns `null` if Cognito auth is disabled (missing env) or if no verified token was attached. On protected routes with a valid Bearer token, it returns the JWT claims object.

## Token validation

The guard validates access tokens by
- JWKS: https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
- Signature: RS256; iss must match the pool.
- Expiration: exp is enforced automatically by jsonwebtoken.verify
- token_use: must equal `access`. This is what rejects an ID token presented to the backend.
- client_id: must equal `COGNITO_CLIENT_ID`. On access tokens the app client ID lives in the client_id claim

> [!IMPORTANT]
> The scaffold accepts access tokens only, by design. Backend APIs are resource servers and authorize requests using access tokens; ID tokens are for the frontend to establish who the user is. The token_use check in isAccessTokenValid (below) is what enforces the access-token-only rule.

> [!WARNING]
> Do not use the ID token for API authorization. ID tokens are intended for your client application to establish who the user is; passing them to a backend API exposes identity claims unnecessarily and confuses authentication with authorization. Backend APIs should validate access tokens only. The token_use check in isAccessTokenValid below enforces this.

```
function isAccessTokenValid(payload: AccessTokenPayload, clientId: string): boolean {
if (payload.token_use !== 'access') return false;
return payload.client_id === clientId;
}
```

> [!NOTE]
> `client_id` validation currently accepts a single client. If this pool ever serves
multiple app clients (e.g. a separate web and mobile app sharing one user pool),
change COGNITO_CLIENT_ID to accept a comma-separated list and validate membership
in that allowlist instead of simply an equality check.

## Helpful Resources for understanding Auth!
- The most amazing explanation of authn (OAUTH 2.0) and authz (OIDC) you'll ever watch: https://www.youtube.com/watch?v=996OiexHze0&t=2126s
- Difference between id and access tokens: https://auth0.com/blog/id-token-access-token-what-is-the-difference/
- Using AWS to verify JWT: https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
42 changes: 42 additions & 0 deletions apps/backend/src/aws/cognito/cognito.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Checks if the authentication is enabled
export function isAuthEnabled(): boolean {
return (
isNonEmptyEnv(process.env.COGNITO_USER_POOL_ID) &&
isNonEmptyEnv(process.env.COGNITO_CLIENT_ID) &&
isNonEmptyEnv(process.env.COGNITO_REGION)
);
}

// Gets the Cognito configuration information
export function getCognitoConfig(): {
region: string;
userPoolId: string;
clientId: string;
issuer: string;
} | null {
const region = process.env.COGNITO_REGION;
const userPoolId = process.env.COGNITO_USER_POOL_ID;
const clientId = process.env.COGNITO_CLIENT_ID;

if (!isAuthEnabled()) {
return null;
}

return {
region,
userPoolId,
clientId,
issuer: `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`,
};
}

// Checks if the value is a non-empty string
function isNonEmptyEnv(value: string | undefined): value is string {
if (value === undefined || value === '') {
return false;
}
const normalized = value.trim().toLowerCase();
return (
normalized !== '' && normalized !== 'null' && normalized !== 'undefined'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalized will never be null or undefined here.

);
}
5 changes: 5 additions & 0 deletions apps/backend/src/aws/cognito/cognito.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';

// Metadata key to state that the route is PUBLIC and thus NOT PROTECTED by authentication(Cognito JWT Guard)
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Loading
Loading