-
Notifications
You must be signed in to change notification settings - Fork 46
140 cognito #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
chnnick
wants to merge
39
commits into
main
Choose a base branch
from
140-Cognito
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
140 cognito #168
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 94a3b9e
correctly importing env variables, type guards for each value so conf…
chnnick dd53297
new cognito env variables, JWTPayload type
chnnick c222613
cognito config + updated types to allow for both id and access tokens
chnnick e61d789
cognito guard, module, and decorators, added to appmodule
chnnick 765e65c
tests for guard and service
chnnick 118ed9b
documentation
chnnick b3d9471
move auth into the correct folder
chnnick 557f6f9
warnings for dual usage of Cognito
chnnick 1e94607
widen aud type to allow for serialization of the aud claim as either …
chnnick f2b0716
updated documentation
chnnick 81729d4
tests clarification for denying partial configurations: Auth is activ…
chnnick fd9f237
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick e4e878a
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick 57c2553
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick f78c0e7
Merge branch '140-Cognito' of https://github.com/Code-4-Community/sca…
chnnick 65d940c
Cognito Module as a global import, export CognitoJWTGuard in module f…
chnnick fc6d8ec
require all three Cognito env variables to enable authentication
chnnick 4f3d4f1
wrapped getUser tests into describe(), use Request type alias instead…
chnnick ad13e14
use cached jswk client for jwks, typed the payload that returns from …
chnnick 167141a
updated documentation after importing CognitoModule into AppModule by…
chnnick 378435b
updated documentation to match new auth disable requirements (missing…
chnnick 35a5127
updated tests for missing env variables -> auth disabled
chnnick 2b96133
try to just use cognitoinformationpresent
chnnick cd44f7a
type vite cognito env vars before passing into amplify.configure
chnnick 0253089
Merge branch '140-Cognito' of https://github.com/Code-4-Community/sca…
chnnick 146b271
get rid of VITE specific cognito variables in .env instead have them …
chnnick b579463
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick 960951e
check for cognito region when turning on auth in the frontend
chnnick 65b5ba8
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick 9dbd28c
specify frontend root for vite
chnnick cba88c4
type guard + make token_use a required field -> strict token checking
chnnick 614ae40
README with new auth flow for newbies, updated access token only
chnnick a3c374c
tokens clarification
chnnick ac27824
get rid of id token in jwtpayload checks for the backend, rename
chnnick e13d23c
updated tests with new groupings
chnnick e0d28e9
Merge branch 'main' of https://github.com/Code-4-Community/scaffoldin…
chnnick 57834c9
rename new payload type in readme
chnnick 836bb28
typo
chnnick File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()`) | ||
| - 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). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. normalized will never be null or undefined here. |
||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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