Repository navigation
Conversation
Router no longer exports isPlainArray/isPlainObject and types route errors as unknown. getAccessTokenSilently can now resolve undefined, which redirects to login.
signoutRedirect revokes it at /oauth2/revoke before leaving for /logout.
A token with under 60 seconds left counts as expired. Concurrent fetches await the same signinSilent call.
Also note that the other auth errors can hang as well as loop.
The multiselect validates with resolveAuthProvider instead of re-prompting in a loop.
Nothing read `features`; templates and synth only use `auth`. With one union type, resolveAuthProvider only needs to reject more than one pick.
The README already explains the localStorage tradeoff, and the useLogout docblock no longer spells out signoutRedirect's internals.
Offer Cognito as an alternative to Auth0. The CLI now asks for one authentication provider (None / Auth0 / Cognito), and Cognito is offered only when an AWS env is configured, since the CDK stack provisions the user pool. - Add a CognitoAuth construct that provisions the user pool, managed login domain and branding, the app client, and branded invitation and verification emails, with optional SES sending - Deliver the pool IDs to the app at runtime as /config.json, written by the bucket deployment, and load it in entry.ts before the app renders. Local dev reads the VITE_APP_COGNITO_* variables from the stack outputs - Sign in through react-oidc-context with the shared AuthGuard and useAuthenticatedFetch, now templated per provider, and add a UserMenu with sign-out - Optional cognito-admin feature: an identity pool scoped to an admin group, and /admin routes to list, create, edit, disable and reset users and to manage role membership through the Cognito admin API - Validate the domain prefix against Cognito's reserved words and the length limit once the environment suffix is added - test-synth takes a preset (none, auth0, cognito, cognito-admin) and synthesizes into test-synth-<preset> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dmin UI Merges Brian's feat/cognito branch. The pool, app client, login domain and emails now live in the app's CDK stack, and the app loads the pool IDs from /config.json at runtime. Adds the optional /admin user management UI. Kept from the earlier Cognito branch: the MFA prompt, token renewal that shares one refresh, the Auth0 token fix, the SES verified-domain option and an optional API resource server scope. The stack also outputs the issuer and pool ARN for the API. The generator now rebuilds the route tree when the admin UI is included, since tsc failed on a fresh project without it.
Cognito rejects a URL as a resource server name, so the name is now fixed. It also rejects the version and xml:space attributes on the SVG login logo, so they are removed from the default favicon.
The API starter's example route checks for the admin group, which only existed when the user admin UI was included. The README now also says what an administrator can do to other accounts.
In a pool that requires MFA, Cognito does not list the authenticator a user enrolls at sign-in, so the user page said "Not set up". It now says "Required at sign-in". Cognito also ignores a reset for those users, so the README explains to delete and re-invite someone who lost their device.
lavere
left a comment
There was a problem hiding this comment.
Verdict: comment; 🔴 1 🟠 3 🟢 22 ❓ 2
Posted as a comment because this is the reviewer's own pull request.
| # | Repo | Issue | Severity |
|---|---|---|---|
| 1 | create-react-app | CDK type check fails when the admin UI is picked with MFA optional or off | 🔴 Blocker |
| 2 | create-react-app | A failed renewal redirects to login while the current token is still valid | 🟠 Should-fix |
| 3 | create-react-app | The retry stops for good after a manual renewal times out | 🟠 Should-fix |
| 4 | create-react-app | A Cognito project without the admin UI is never told how to create its first user | 🟠 Should-fix |
| 5 | create-react-app | Every admin can join any group, so any future group with an IAM role is open to all admins | ❓ Open question |
| 6 | create-react-app | [Both] The access scope from apiIdentifier is issued but the API never checks it |
❓ Open question |
| 7 | create-react-app | returnTo is used without checking its shape |
🟢 Nit |
| 8 | create-react-app | This catch cannot run |
🟢 Nit |
| 9 | create-react-app | "Concurrent calls share one renewal" only holds per hook instance | 🟢 Nit |
| 10 | create-react-app | The docblock's example is the one case the PR says does not work | 🟢 Nit |
| 11 | create-react-app | FORCE_CHANGE_PASSWORD is not the same as "has not signed in yet" |
🟢 Nit |
| 12 | create-react-app | The app bar reads only the email, not the name | 🟢 Nit |
| 13 | create-react-app | The ref stops a second refresh, not a redirect | 🟢 Nit |
| 14 | create-react-app | The last two outputs under this comment are not API values | 🟢 Nit |
| 15 | create-react-app | The empty list only holds because rotation is on | 🟢 Nit |
| 16 | create-react-app | The account takeover warning sits under "Lost authenticator" | 🟢 Nit |
| 17 | create-react-app | These HTML comments are sent in every email | 🟢 Nit |
| 18 | create-react-app | The suggested domain prefix can fail the prompt's own validation | 🟢 Nit |
| 19 | create-react-app | Generator behavior with no test | 🟢 Nit |
| 20 | create-react-app | Duplicate module augmentation | 🟢 Nit |
| 21 | create-react-app | The props rename is half applied | 🟢 Nit |
| 22 | create-react-app | No loader or pending component, unlike the sibling routes | 🟢 Nit |
| 23 | create-react-app | The role chip stays clickable while a removal is pending | 🟢 Nit |
| 24 | create-react-app | A failed second call reports failure after the first change was saved | 🟢 Nit |
| 25 | create-react-app | The new test/ folder is never type checked |
🟢 Nit |
| 26 | create-react-app | useCurrentUserGroups is exported but only used in this file |
🟢 Nit |
| 27 | create-react-app | A leftover test-synth/ folder is no longer ignored |
🟢 Nit |
| 28 | create-react-app | The commented-out ses line breaks on a title with a quote |
🟢 Nit |
Adds Cognito login with the user pool in the app's own CDK stack, runtime /config.json, and an optional browser-only user admin UI. The design holds up and matches the API side on issuer, client id, token type and group name. One change made today breaks the CDK type check for two of the three MFA choices when the admin UI is included, and the token renewal path sends users to the login page on a failed refresh that the guard was written to ride out.
src/tasks/features.ts
🟢 Nit: Generator behavior with no test
Nothing asserts that Cognito is only offered with an AWS env, that cognitoSettings is null unless Cognito is chosen (the templates render with strict variables), the MFA prompt, the ignore list in src/tasks/synth.ts:52-74, or the build step after install. A typo in an ignore path stays green in CI.
Pre-existing, not introduced by this pull request, so not a condition for merging:
.github/workflows/release.yml:37
🟢 Nit: The new test step only runs after merge
The workflow triggers on push to main only, so pnpm test does not run on a pull request, and no workflow runs the four test-synth presets. Fix: add a pull_request trigger.
src/test-cli.ts:54
🟢 Nit: The test account id has 9 digits
accountId: "123456789". create-taxum-api#5 fixed the same value to 12 digits. It matters once a preset needs a valid ARN.
Cross-repo checks that came back clean (against create-taxum-api#5)
CognitoIssuer,CognitoClientIdandCognitoUserPoolArnexist as outputs for both thecognitoandcognito-adminfeature sets, with the names the API README asks for.- The app sends the access token, not the ID token. The ID token only goes to the identity pool.
- The group name
adminis the same in the CDK construct,useIsAdmin.tsand the API README. - Every
VITE_APP_*read is declared invite-env.d.ts.liquidand present in.env.liquidfor all four presets. The config schema matchesCognitoRuntimeConfig. - The ignore list globs match the real files, and no rendered file imports an ignored one in any preset.
- All 18 SDK commands the admin UI uses are in the admin role's action list.
Security sweep, nothing found: no secrets in source, no dangerouslySetInnerHTML, email templates escape the project title, the ListUsers filter is escaped, the identity pool has allowClassicFlow: false, resolveAmbiguousRoles: false, and a trust policy limited to this pool.
Already stated in the description, not repeated as findings: MFA cannot be reset in a required pool, the admin role can set passwords and attributes, authFlows: { userSrp: false }, the favicon attributes, the build after install for the route tree, SES verification, 7 day refresh tokens, localStorage token storage, and the commits shared with #5.
Dimensions
- D1 AC consistency: no ticket referenced.
- D2 design fidelity: skipped, no design mock is configured for this workspace.
- D3 and D3a ran on sibling comparison and first principles, because no rules shelf is configured.
- Reviewer Would Flag: patterns unavailable, skipped.
- D5: the skeleton has no test runner on
main, so no gap is raised for React components.
No CI checks are reported on this branch.
| scope: scopes.map((scope) => scope.scopeName).join(" "), | ||
| {%- if features contains "cognito-admin" %} | ||
| identityPoolId: identityPool.identityPoolId, | ||
| mfaRequired: mfa === Mfa.REQUIRED, |
There was a problem hiding this comment.
🔴 Blocker: CDK type check fails when the admin UI is picked with MFA optional or off
Line 88 renders const mfa: Mfa = Mfa.OFF; (or Mfa.OPTIONAL). TypeScript narrows the const to that one member, so mfa === Mfa.REQUIRED here fails with TS2367, "the types 'Mfa.OFF' and 'Mfa.REQUIRED' have no overlap". Checked with tsc against the real aws-cdk-lib types: Mfa.OFF errors, Mfa.REQUIRED passes.
That breaks pnpm --dir=./cdk tsc --noEmit in ci-check.sh and the CDK build before deploy, for every new project with the admin UI and MFA optional or off.
It went unseen because src/test-cli.ts:72 renders every preset with mfa: "required".
Fix: render the literal, for example mfaRequired: {% if cognitoSettings.mfa == "required" %}true{% else %}false{% endif %},, and add an optional or off preset to test-cli.ts so the other two branches are rendered and type checked.
| } | ||
| } | ||
|
|
||
| if (!accessToken) { |
There was a problem hiding this comment.
🟠 Should-fix: A failed renewal redirects to login while the current token is still valid
signinSilent from useAuth() does not throw. react-oidc-context catches the failure, dispatches an error and returns null, so the catch on line 74 never runs. Any failed renewal in the last 60 seconds of a token, including a network blip, falls through to signinRedirect here, and open form state is lost.
That is the opposite of what AuthGuard.tsx.liquid:65-68 promises (a transient failure keeps the page and retries) and of "A failed renewal is retried" in the description.
Fix: when the renewal returns null and current is not expired, send the request with current.access_token. Redirect only when the token has really expired or the error is invalid_grant.
| mustSignIn || | ||
| error === undefined || | ||
| !silentSigninFailed || | ||
| error.innerError instanceof ErrorTimeout |
There was a problem hiding this comment.
🟠 Should-fix: The retry stops for good after a manual renewal times out
oidc-client-ts retries a timed out automatic renewal itself and raises no error for it (_tokenExpiring, retry in 5 seconds). So an ErrorTimeout only reaches this effect from a manual signinSilent(): this 30 second retry, or the one in useAuthenticatedFetch. Nothing retries those.
Sequence: the automatic renewal fails with a network error, the retry 30 seconds later hangs and times out, this effect returns without a timer, and renewal is dead until the tokens expire.
Fix: drop the ErrorTimeout condition, and correct the comment above, which says each failed attempt re-arms the retry.
|
|
||
| The CDK stack owns the Cognito user pool, the app client and the managed login domain | ||
| (`{{cognitoSettings.domainPrefix}}-<env>.auth.{{awsEnv.region}}.amazoncognito.com`). Users are invited by an | ||
| administrator; self sign-up is disabled. |
There was a problem hiding this comment.
🟠 Should-fix: A Cognito project without the admin UI is never told how to create its first user
The README says users are invited by an administrator and self sign-up is disabled. The only instructions for creating a user, "First administrator" at line 102, sit inside the cognito-admin block. A project with Cognito alone deploys an app nobody can sign in to until someone works out aws cognito-idp admin-create-user.
Fix: move the admin-create-user command outside the admin block, and keep only the admin-add-user-to-group step tied to the admin UI.
| "cognito-idp:AdminDeleteUserAttributes", | ||
| "cognito-idp:AdminUserGlobalSignOut", | ||
| "cognito-idp:AdminSetUserMFAPreference", | ||
| "cognito-idp:AdminAddUserToGroup", |
There was a problem hiding this comment.
❓ Open question: Every admin can join any group, so any future group with an IAM role is open to all admins
The admin role has AdminAddUserToGroup on the whole pool, so an admin can add anyone, themselves included, to any group. Today only admin carries an IAM role, so nothing escalates beyond what the description discloses.
README.md.liquid:74 invites projects to "Add further groups next to it". If one of those groups gets a stronger role, every admin can take it. Should the README or a comment at the group say so?
| } | ||
| } | ||
|
|
||
| if (attributes.length > 0) { |
There was a problem hiding this comment.
🟢 Nit: A failed second call reports failure after the first change was saved
If the delete-attributes call fails after the update succeeded, the user sees "Failed to update user" although the email was already changed. With nothing changed, no call is made but the page still says "User has been updated". Fix: skip the mutation when the form is not dirty.
| }, | ||
| "scripts": { | ||
| "test-synth": "rm -rf ./test-synth && tsx ./src/test-cli.ts test-synth", | ||
| "test": "tsx --test 'test/**/*.test.ts'", |
There was a problem hiding this comment.
🟢 Nit: The new test/ folder is never type checked
tsconfig.json includes only src/**/* and tsx does not type check. Fix: a tsconfig for tests, or run tsc --noEmit over test.
| */ | ||
| export const cognitoAdminGroupName = "admin"; | ||
|
|
||
| export const useCurrentUserGroups = (): string[] => { |
There was a problem hiding this comment.
🟢 Nit: useCurrentUserGroups is exported but only used in this file
Fix: drop the export.
| /node_modules | ||
| /dist | ||
| /test-synth | ||
| /test-synth-* |
There was a problem hiding this comment.
🟢 Nit: A leftover test-synth/ folder is no longer ignored
/test-synth became /test-synth-*, so a folder from before this PR shows as untracked. Fix: keep /test-synth ignored too.
| cognito: { | ||
| domainPrefix: "{{cognitoSettings.domainPrefix}}-staging", | ||
| // The address, or its domain via `verifiedDomain`, must be verified in SES first. | ||
| // ses: { fromEmail: "no-reply@example.com", fromName: "{{project.title}}" }, |
There was a problem hiding this comment.
🟢 Nit: The commented-out ses line breaks on a title with a quote
fromName: "{{project.title}}" is unescaped inside a string, so uncommenting it in a project whose title has a double quote gives invalid code. The same line is in env-production.ts.liquid:33. Fix: render the title through a JSON filter.
- Render mfaRequired as a literal. Comparing a narrowed const failed the CDK type check when the admin UI was picked with MFA optional or off. Two new test-synth presets render those modes. - Keep using a token that has not expired when an early renewal fails, instead of sending the user to the login page. - Retry a renewal that timed out. Nothing else retries a manual renewal. - Tell every Cognito project how to create its first user, not only those with the admin UI.
The apiIdentifier option added a resource server scope that the API never checked, so it is removed along with the scope setting it needed. The API already limits tokens to this app by client ID. The README and the stack now say that administrators can add any user to any group, so a role on a further group is open to every administrator.
DASPRiD
left a comment
There was a problem hiding this comment.
This is a solid, carefully built Cognito integration. The security-sensitive parts look right to me: the identity pool only maps the admin group to a role, ambiguous roles are denied, classic flow is off, and the role is scoped to the one user pool. I found nothing that should block the merge. The main gap is the "Optional" MFA choice: no generated project gives users a way to enroll an authenticator. CI also runs only one small unit test, and none of the synth presets that carry almost all of this change. The rest is small cleanup.
Nits:
-
src/tasks/synth.ts:28Full production build only to regenerate the route treepnpm vite buildruns a complete production build of the new project during synth. That costs time, and it leavesdist/and the visualizer'sstats.htmlbehind in the fresh project. With Sentry enabled andSENTRY_AUTH_TOKENset in the developer's shell, it also turns on the Sentry plugin, before the project is a git repo and with noBITBUCKET_COMMIT.Running only the TanStack Router generator (
tsr generate), or deletingdistafterward, would keep synth side-effect free. -
skeleton/src/routes/admin/users_/$username/-components/RolesCard.tsx:54Thiscatchcan't run:signinSilentresolves to nullreact-oidc-context's
signinSilentcatches errors itself. It dispatches them to the auth state and resolves tonull, as the comment inuseAuthenticatedFetch.ts.liquidalready notes. So thistry/catchnever catches anything, and the comment describes a path that never runs. A failed refresh shows up aserror.source === "signinSilent"inAuthGuardinstead. Checking the resolved value (or just awaiting it) would match what actually happens. The same pattern is inedit.tsxat line 81. -
skeleton/src/routes/admin/users_/$username/edit.tsx:76Comment says the app bar shows the name; it shows only the emailUserMenurendersauth.user?.profile.emailand nothing else from the token, so "reads name and email" is wrong about the name. Thetry/catchbelow it also can't run, becausesigninSilentresolves tonullinstead of throwing. -
skeleton/src/mutations/admin-user.ts:28Inline option type ininvalidateUser{ refetchDetail?: boolean }is an inline object shape in the signature. A named alias such asInvalidateUserOptionsabove the function matches the named-type pattern the rest of the file follows (UsernameValues,CreateAdminUserValues, …). -
src/tasks/features.ts:13Name thecognitoSettingsshapeFeaturesContext.cognitoSettingsinlines its object shape. A namedCognitoSettingstype would also letfeaturesTask(FeaturesContext["cognitoSettings"]) andtest-cli.tsrefer to it directly. -
src/test-cli.ts:40Preset shape is written out twice{ features: Feature[]; mfa?: MfaMode }appears here and in thesatisfiesclause on line 28. One namedPresettype would keep both in sync. -
skeleton/src/routes/admin/-components/UserStatusChips.tsx:6Inline value type instatusesrecordThe record's value type
{ label: string; color: ChipProps["color"] }is inline, and the fallback on line 20 repeats that shape. A namedStatusChiptype would cover both. -
src/tasks/synth.ts:61signOut.tsfilename isn't dash-case like its siblingsrc/utils/signOut.tsexports a plain function, so by filename convention it should besign-out.ts. It sits next to the new dash-casesrc/utils/cognito-error.tsin the same directory. The ignore list entry here and the imports inUserMenuandAuthGuardwould need to follow the rename.
| message: "MFA:", | ||
| choices: [ | ||
| { message: "Required (authenticator app)", name: "required" }, | ||
| { message: "Optional (users are not prompted to set it up)", name: "optional" }, |
There was a problem hiding this comment.
Non-blocking: "Optional" MFA leaves users no way to enroll
With mfa: "optional", nothing in the generated project lets a user set up an authenticator:
cognito-auth.ts.liquidsays managed login only walks users through setup when MFA is required.- The app requests only
openid email profile, so the access token lacksaws.cognito.signin.user.admin. Without that scope the user can't callAssociateSoftwareToken/VerifySoftwareTokenthemselves. - There is no MFA setup UI.
So "Optional" behaves exactly like "Off", except the pool reports MFA as optional. It also means UserMFASettingList stays empty and Reset MFA in the admin UI is always disabled. Either drop the choice, or add a self-service enrollment path (with the extra scope) and mention it in the README.
| }, | ||
| "scripts": { | ||
| "test-synth": "rm -rf ./test-synth && tsx ./src/test-cli.ts test-synth", | ||
| "test": "tsx --test 'test/**/*.test.ts'", |
There was a problem hiding this comment.
Non-blocking: CI covers only validateDomainPrefix
The new Test step runs test/features.test.ts, and that file only exercises validateDomainPrefix. Most of the change is template output: the Liquid conditionals across main.tsx, AuthGuard, useAuthenticatedFetch, app-stack.ts and cognito-auth.ts, plus the per-feature ignoreList entries such as the admin-*.ts globs. None of that is checked in CI.
The new presets in test-cli.ts would catch a broken template, but CI never runs them. Consider a CI job that synthesizes each preset and runs tsc/biome on it, at least for none, auth0, cognito and cognito-admin. suggestDomainPrefix has no test either.
Summary
Adds Cognito as a login option next to Auth0. The user pool is built in this app's own CDK stack, so there is no separate Cognito starter. This combines the earlier version of this PR with Brian's #7 and replaces both.
CognitoAuthconstruct creates the user pool, app client, managed login domain and branding, anadmingroup, and branded invitation and verification emails. Optionalsessender (withverifiedDomain)./config.jsonat deploy time and the app loads it before rendering, so one build fits every environment. Local dev readsVITE_APP_COGNITO_*from.env.react-oidc-context, authorization code + PKCE against managed login. Tokens under 60 seconds from expiry are renewed first. A failed renewal is retried while the current token still works;invalid_grantsends the user to sign in again. The renewal changes made after the review have not been run against a real pool./logout. Handles the back button after signing out./adminroutes to list, invite, edit, disable and delete users, reset passwords and MFA, and manage role membership. There is no backend: an identity pool gives members of theadmingroup temporary AWS credentials scoped to this pool.CognitoIssuer,CognitoUserPoolArnandCognitoClientId(used by create-taxum-api#5).Includes the TanStack Router / auth0-react build fix from #5. Merge that first; this branch will need a rebase after.
Testing
pnpm test,tscandbiome ci .pass.none,auth0,cognito,cognito-admin, andcognito-adminwith MFA optional and off) pass theirci-check.shandpnpm build.cdk synthwithsesset. That run predates the review fixes./config.jsonis served uncached with the real pool IDs, and the deployed build logs in with it. Deep links load.admingroup.Notes
AdminSetUserMFAPreferencewithEnabled=falsewithout changing anything: the next sign-in still asks for a code. Tested from the admin UI and from the CLI. The user page now shows "Required at sign-in", Reset MFA stays disabled for those users, and the README says to delete and re-invite someone who lost their device (they get a new user ID). Reset MFA still applies to methods Cognito does list.AdminSetUserPasswordandAdminUpdateUserAttributes, which the set-password and edit screens need. Any member ofadmincan use them from the browser, so an admin can take over any account. The README says so.authFlows: { userSrp: false }. Managed login does not need SRP, and refresh-token auth conflicts with rotation.versionandxml:space, so they were removed from the defaultfavicon.svg.tscbecause the shippedrouteTree.gen.tslacks the admin routes. The generator now runs a build after install to regenerate it.apiIdentifieroption that added an API scope. It was removed after review because the API never checked it; the API limits tokens to this app by client ID.admingroup is always created; it only gets AWS permissions when the admin UI is included.🤖 Generated with Claude Code