diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1b88ff4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# Force LF line endings for all text files — prevents CRLF issues on Windows dev machines +* text=auto eol=lf + +# Explicitly LF +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.env* text eol=lf +*.sh text eol=lf + +# Binary — never diff or merge +*.png binary +*.jpg binary +*.ico binary +*.woff2 binary +*.pdf binary +*.pem binary + +# PEM key files — never show in diffs (sensitive) +*.pem diff=nodiff + +# Linguist — tell GitHub what this repo is +*.ts linguist-language=TypeScript +*.tsx linguist-language=TypeScript + +# Collapse generated/vendored files in GitHub diffs +CHANGELOG.md linguist-generated=true +package-lock.json linguist-generated=true \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b6dd6cf --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,22 @@ +# Global fallback — all files require review from a core maintainer +* @logusivam + +# Backend auth module — security-critical, requires security review +apps/api/src/modules/auth/ @logusivam +apps/api/src/modules/token/ @logusivam +apps/api/src/modules/oauth/ @logusivam +apps/api/src/middleware/ @logusivam + +# RBAC — permission model changes require admin sign-off +apps/api/src/modules/rbac/ @logusivam + +# CI/CD workflows — pipeline changes require maintainer approval +.github/workflows/ @logusivam + +# Shared types — breaking change risk +packages/types/ @logusivam + +# Environment + deployment config +apps/api/.env.example @logusivam +apps/web/.env.example @logusivam +docker-compose*.yml @logusivam \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3ce5e81 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +github: [logusivam] +patreon: logusivam +ko_fi: logusivam +custom: ['https://loganathangp-dev-portfolio.vercel.app'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..45848d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,63 @@ +name: Bug Report +description: Report a bug or unexpected behaviour in TokenForge +title: "[BUG] " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for reporting a bug. Please fill in all fields — incomplete reports will be closed. + + - type: dropdown + id: area + attributes: + label: Affected Area + options: + - Authentication (login / register) + - Token Refresh / Rotation + - OAuth2 (Google / GitHub) + - RBAC / Permissions + - Admin Panel + - Frontend / UI + - CI / CD / Deployment + - Documentation + validations: + required: true + + - type: textarea + id: description + attributes: + label: Bug Description + placeholder: What happened? What did you expect to happen? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + placeholder: | + 1. POST /api/v1/auth/login with ... + 2. Then POST /api/v1/auth/refresh ... + 3. Observe ... + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + placeholder: | + Node.js version: + OS: + Browser (if frontend): + Deployment: local / Railway / other + validations: + required: true + + - type: checkboxes + id: security + attributes: + label: Security Impact + options: + - label: This bug has a security implication (token bypass, privilege escalation, data leak) \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..693fd17 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +name: Feature Request +description: Propose a new feature, enhancement, or visual element for TokenForge +title: '[FEATURE] ' +labels: ['enhancement', 'triage'] +body: + - type: markdown + attributes: + value: | + Thank you for suggesting a feature! Please provide details to help us evaluate the request. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem or limitation? + placeholder: A clear and concise description of what the problem is. e.g., I'm frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Provide a clear description of what you want to happen. + placeholder: A clear and concise description of the feature or enhancement you want added. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or workarounds you have considered. + placeholder: A description of alternative behaviors or API structures you've thought about. + validations: + required: false + + - type: checkboxes + id: scope + attributes: + label: Affected Component Scopes + description: Select the parts of the codebase this change would affect. + options: + - label: '@tokenforge/api (Backend API endpoints, database schemas)' + - label: '@tokenforge/web (Frontend React UI components, states)' + - label: CI/CD Workflows / Docker configs + - label: Documentation diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.yml b/.github/ISSUE_TEMPLATE/security_vulnerability.yml new file mode 100644 index 0000000..8c54fde --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security_vulnerability.yml @@ -0,0 +1,23 @@ +name: Security Vulnerability +description: > + STOP — do not file a public issue for security vulnerabilities. + Please email devbridgeenquirz@gmail.com or use GitHub's private + vulnerability reporting (Security → Report a vulnerability). +title: '[SECURITY] Use private reporting — see description' +labels: ['invalid'] +body: + - type: markdown + attributes: + value: | + ## ⚠️ Do NOT report security vulnerabilities here + + Public issues are visible to everyone, including potential attackers. + + **To report a security vulnerability:** + 1. Go to the **Security** tab of this repository + 2. Click **"Report a vulnerability"** (GitHub private reporting) + 3. Or email **devbridgeenquirz@gmail.com** + + We will respond within 48 hours and coordinate a responsible disclosure. + + See [SECURITY.md](../../SECURITY.md) for our full disclosure policy. diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..d2bbed5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,47 @@ +## Summary + + + +## Type of Change + +- [ ] Bug fix (non-breaking) +- [ ] New feature (non-breaking) +- [ ] Breaking change (requires version bump + CHANGELOG entry) +- [ ] Refactor (no behaviour change) +- [ ] Documentation update +- [ ] CI / tooling change + +## Security Checklist + + + +- [ ] No secrets, API keys, or credentials added to source +- [ ] Input validation added/updated for new endpoints +- [ ] RBAC permissions verified for new/changed routes +- [ ] Rate limiting considered for new public endpoints +- [ ] Audit log event added for new auth actions +- [ ] Cookie options unchanged (httpOnly, Secure, SameSite) +- [ ] No token data exposed in URLs, logs, or response bodies + +## Testing + +- [ ] Unit tests added / updated +- [ ] Integration tests added / updated +- [ ] All existing tests pass (`npm test`) +- [ ] Coverage threshold maintained (≥80%) + +## Documentation + +- [ ] JSDoc added for public functions / classes +- [ ] `docs/api-reference.md` updated if endpoints changed +- [ ] `docs/rbac-model.md` updated if permissions changed +- [ ] `CHANGELOG.md` entry added (or handled by semantic-release) +- [ ] `.env.example` updated if new env vars added + +## Linked Issues + +Closes # + +## Screenshots / Logs (if UI or behaviour change) + + \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b64cd83 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true # Cancel stale runs on new pushes + +jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npx turbo run lint + - run: npx tsc --noEmit --project apps/api/tsconfig.json + - run: npx tsc --noEmit --project apps/web/tsconfig.json + + test: + name: Unit + Integration Tests + runs-on: ubuntu-latest + needs: lint + services: + mongodb: + image: mongo:8 + ports: ['27017:27017'] + redis: + image: redis:7-alpine + ports: ['6379:6379'] + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + NODE_ENV: test + MONGO_URI: ${{ secrets.MONGO_URI_TEST }} + REDIS_URL: ${{ secrets.REDIS_URL_TEST }} + JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY_TEST }} + JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY_TEST }} + JWT_ACCESS_EXPIRY: 15m + JWT_REFRESH_EXPIRY: 7d + COOKIE_SECRET: ${{ secrets.COOKIE_SECRET_TEST }} + CLIENT_URL: https://tokenforge-dev.vercel.app + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npx turbo run test -- --coverage + - uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true + + build: + name: Build + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npx turbo run build + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + - name: Deploy API → Render + run: | + curl -f -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}" + - name: Deploy Web → Vercel + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-args: '--prod' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..2af1292 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +name: CodeQL SAST + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 2 * * 1' # Weekly Monday 2am scan + +jobs: + analyze: + name: Analyze TypeScript + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: typescript + queries: security-extended # Broader security query suite + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 \ No newline at end of file diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..9bb8012 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,22 @@ +name: Dependency Review + +on: + pull_request: + branches: [main, dev] + +jobs: + dependency-review: + name: Block HIGH/CRITICAL CVE Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high # Block high + critical CVEs + deny-licenses: GPL-2.0, GPL-3.0 # Block copyleft licenses + # GHSA-qwww-vcr4-c8h2: react-router RSC Mode CSRF — only affects SSR/RSC apps. + # This project is a Vite client-side app (no SSR/RSC), so this CVE is not exploitable. + # react-router-dom has no v8.x release; no non-breaking fix exists in the 7.x line. + # GHSA-mh99-v99m-4gvg: brace-expansion DoS — in transitive build-tooling deps only, + # not reachable from production server code; no non-breaking fix available in 1.x line. + allow-ghsas: GHSA-qwww-vcr4-c8h2,GHSA-mh99-v99m-4gvg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ed03dd1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Release + +on: + push: + branches: [main] + +jobs: + release: + name: Semantic Release + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for changelog generation + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npx semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..b076148 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,25 @@ +name: Close Stale Issues & Pull Requests + +on: + schedule: + - cron: '30 1 * * *' # Run once a day at 01:30 UTC + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue is stale because it has been open 45 days with no activity. Remove stale label or comment, or this will be closed in 7 days.' + stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment, or this will be closed in 7 days.' + days-before-stale: 45 + days-before-close: 7 + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-issue-labels: 'security,dependencies,pinned' + exempt-pr-labels: 'dependencies,pinned' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d875142 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Environment — NEVER commit real env files +.env +.env.local +.env.*.local +.env.production +# .env.example is committed — it's the template + +# RS256 keys — local dev only, NEVER commit real keys +apps/api/keys/*.pem + +# Test coverage +coverage/ +.nyc_output/ + +# Logs +*.log +logs/ +npm-debug.log* + +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Turborepo cache +.turbo/ + +# Vercel +.vercel/ + +# Playwright +playwright-report/ +test-results/ + +# docs +docus + +err.md + +# agent related +.gemini/ + +# AI related +.claude/ + +# TokenForge Specs Sheet +tokenforge.md \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..d9b49b4 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,2 @@ +# Validate commit message against Conventional Commits spec +npx --no -- commitlint --edit "$1" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d2c8915 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +# Run lint-staged: only lint/format files staged for this commit +npx lint-staged \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..b8090e7 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +# Run unit tests only (fast gate — integration tests run in CI) +npx turbo run test --filter=./apps/api -- --run --reporter=verbose \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4147225 --- /dev/null +++ b/.npmrc @@ -0,0 +1,14 @@ +# Enforce declared Node.js engine version — fail if wrong Node is used +engine-strict=true + +# Pin exact versions — prevent accidental minor bumps in lock file +save-exact=true + +# Audit on install +audit=true + +# No fund messages in CI output +fund=false + +# Bypass peer dependency conflicts automatically (e.g. during ESLint/Prettier resolution on Render) +legacy-peer-deps=true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..07dad7b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to TokenForge are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +Versioning: [Semantic Versioning](https://semver.org/) + + + +## [Unreleased] + +- Initial release: JWT auth, refresh token rotation, OAuth2 PKCE (Google/GitHub), RBAC + +--- + +## Types of Changes +- `Added` — new features +- `Changed` — changes to existing functionality +- `Deprecated` — features to be removed +- `Removed` — removed features +- `Fixed` — bug fixes +- `Security` — security fixes (always include CVE if applicable) \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..415be76 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,78 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best for the overall community, not just the individual + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and unwelcome sexual attention or + advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders at **logusivam@gmail.com**. All complaints +will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][version]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder][mozilla]. + +[homepage]: https://www.contributor-covenant.org +[version]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla]: https://github.com/mozilla/diversity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..904e9e3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing to TokenForge + +Thank you for your interest in contributing. + +## Prerequisites + +- Node.js 22 LTS +- Docker Desktop (for local MongoDB + Redis) +- A GitHub account + +## Setup diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b6ab53c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dark (logusivam) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 8e38499..be713fe 100644 --- a/README.md +++ b/README.md @@ -1 +1,192 @@ -# tokenforge \ No newline at end of file +# TokenForge + +

+ TokenForge Logo +

+ +

+ Forge Your Auth. Own Every Token. +

+ +

+ CI + CodeQL + Coverage + License: MIT + Node.js + TypeScript +

+ +--- + +## Live Demo + +🔗 [tokenforge.dev](https://tokenforge.dev) · +[API Docs](https://tokenforge-api.railway.app/api/docs) + +--- + +
+❓ 1. What's the problem? (Click to expand) + +Modern web applications depend heavily on black-box SaaS authorization providers +(e.g., Auth0, Clerk, Clerk SDKs) that hold user session data hostage, limit +local security controls, charge high rates for scale, and introduce external +network dependencies. Developers lose insight into how cryptographic keys, +rotation families, and role evaluations operate from first principles. +
+ +
+🛠️ 2. How it solves it (Click to expand) + +**TokenForge** is an open-source, custom authentication engine built from +scratch. It puts full cryptographic authority back in the hands of the +developer. It acts as an on-premise, stateless token layer running on asymmetric +signature schemes (RS256) and memory-mapped cache databases. It provides token +generation, silent rotation tracking, and role verification directly inside your +own application borders. +
+ +
+✨ 3. Key features (Click to expand) + +- 🔐 **JWT RS256** — Asymmetric private/public key signature verification. +- 🔄 **Refresh Token Rotation** — Sliding-window generation with reuse + compromises invalidation tracking. +- 🛡️ **OAuth2 + PKCE** — Secure Google & GitHub authentication flow verifiers. +- 👥 **Fine-Grained RBAC** — Multi-role system resource mapping guards. +- ⚡ **Redis Cache Store** — Rate limit counters and revoked token blacklist + tracking. +- 🗄️ **MongoDB database** — Active security event audit logs with automatic TTL + purges. +- 🔒 **Security Hardening** — Helmet settings, CORS constraints, mongo + sanitization. + +
+ +
+📐 4. System Architecture Diagram (Click to expand) + +```mermaid +graph TD + Browser[📱 Web Client React / Zustand] + API[⚙️ Express API Node/TypeScript] + Redis[⚡ Redis Session / Rate Limit Store] + Mongo[🗄️ MongoDB Database Users / Audit Logs] + + Browser -- 1. HTTPS / JWT / Cookies --> API + API -- 2. Cache queries & Blacklists --> Redis + API -- 3. Persistence & Logs --> Mongo +``` + +
+ +
+📂 5. Project Directory Structure (Click to expand) + +``` +tokenforge/ +├── apps/ +│ ├── api/ # TypeScript Express API Backend +│ └── web/ # React Vite SPA Frontend +├── packages/ # Shared Monorepo Workspaces +├── docus/ # Architectural documentation +├── docker-compose.yml +└── package.json +``` + +
+ +
+🔄 6. How it works flow (Click to expand) + +1. **Registration**: User accounts are created, hashes are computed locally via + `bcryptjs` (salt factor 12), and identities are persisted in MongoDB. +2. **Access Token Generation**: The API signs a JWT payload with an asymmetric + private key using the RS256 algorithm. +3. **Session Verification**: Client applications verify JWT authenticity using + the distributable public key. +4. **Silent Refresh Rotation**: When access tokens expire (15-minute window), + client middleware interceptors exchange refresh tokens via secure httpOnly + cookies. +5. **RBAC Rules Enforcement**: Decoded JWT claims are parsed directly at the + middleware layer to verify route permissions. + +
+ +
+📋 7. Runtime requirements (Click to expand) + +- **Node.js**: `v22.0.0` or higher +- **NPM**: `v10.0.0` or higher +- **Databases**: MongoDB v8.0+ and Redis v7.0+ (running locally or via Docker) + +
+ +
+🚀 8. Install & Setup guide (Click to expand) + +```bash +# Clone the repository +git clone https://github.com/logusivam/tokenforge.git +cd tokenforge + +# Install workspaces dependencies +npm install + +# Generate cryptographic keys +npm run keys:generate + +# Spin up local database containers +npm run docker:up + +# Run local dev environment +npm run dev +``` + +
+ +
+🧪 9. Testing setup (Click to expand) + +```bash +# Run unit and integration test suites +npm run test + +# Run Playwright E2E suites +npm run test:e2e +``` + +
+ +
+📦 10. Release notes (Click to expand) + +Releases are managed using `semantic-release` configurations linked to +conventional commit history scopes (`feat`, `fix`, `docs`, `config`) to +automatically update changelogs. +
+ +
+⚠️ 11. Security Disclaimer (Click to expand) + +This is an educational reference implementation demonstrating secure +authentication principles. Before deploying to high-traffic production +workloads, audit key storage structures and review rate limiting thresholds. +
+ +
+⚖️ 12. MIT License (Click to expand) + +Released under the [MIT License](LICENSE). +
+ +
+✍️ 13. Credits (Click to expand) + +- **Lead Architect**: Developed by + [Loganathan G P (Logusivam Vision)](https://loganathangp-dev-portfolio.vercel.app/) +- Open source libraries used: Express, React, Mongoose, ioredis, TanStack Query, + Zustand, Framer Motion. + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0256c0f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy — TokenForge + +## Supported Versions + +| Version | Supported | +|---|---| +| Latest (`main`) | ✅ | +| Older tags | ❌ No backport patches | + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for security vulnerabilities.** + +1. Go to the **Security** tab of this repository +2. Click **"Report a vulnerability"** +3. Fill in the template + +Send details to: **security@tokenforge.dev** +PGP key available at: `https://tokenforge.dev/.well-known/security.txt` + +## Response Timeline + +| Stage | SLA | +|---|---| +| Acknowledgement | 48 hours | +| Severity assessment | 5 business days | +| Fix + patch release | 14 days (critical) / 30 days (high) | +| Public disclosure | 90 days after fix (coordinated) | + +## Scope + +In scope for responsible disclosure: +- Authentication bypass +- Refresh token theft or reuse bypass +- RBAC privilege escalation +- JWT signature bypass +- OAuth2 state/PKCE bypass +- Rate limit bypass leading to brute force +- NoSQL injection + +Out of scope: +- Denial of service via resource exhaustion (no SLA) +- Social engineering +- Issues in third-party dependencies (report to upstream) \ No newline at end of file diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/.env.test b/apps/api/.env.test new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..eeb5128 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,57 @@ +{ + "name": "@tokenforge/api", + "version": "1.0.0", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@sentry/node": "^10.65.0", + "@tokenforge/types": "*", + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.8", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.9", + "@types/mongo-sanitize": "^1.0.2", + "@types/morgan": "^1.9.9", + "@types/node": "^22.14.1", + "@types/swagger-ui-express": "^4.1.8", + "@types/uuid": "^10.0.0", + "axios": "^1.18.1", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "eslint-plugin-import": "1.14.0", + "express": "^5.2.1", + "express-rate-limit": "^7.3.2", + "helmet": "^8.2.0", + "ioredis": "^5.11.1", + "joi": "^17.13.3", + "jsonwebtoken": "^9.0.3", + "module-alias": "^2.2.3", + "mongo-sanitize": "^1.1.0", + "mongoose": "^9.7.4", + "morgan": "^1.11.0", + "rate-limit-redis": "^5.0.0", + "semantic-release": "25.0.8", + "swagger-ui-express": "^5.0.1", + "typescript": "^5.7.3", + "uuid": "^11.1.0", + "winston": "^3.19.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.10", + "supertest": "^7.1.0", + "tsx": "^4.23.0", + "vitest": "^4.1.10" + }, + "_moduleAliases": { + "@": "dist" + } +} diff --git a/apps/api/src/config/db.ts b/apps/api/src/config/db.ts new file mode 100644 index 0000000..835de47 --- /dev/null +++ b/apps/api/src/config/db.ts @@ -0,0 +1,28 @@ +import mongoose from 'mongoose' +import { env } from './env' +import { logger } from '@/shared/logger' + +export async function connectDB(): Promise { + try { + mongoose.connection.on('connected', () => { + logger.info('MongoDB connected successfully') + }) + + mongoose.connection.on('error', (err) => { + logger.error('MongoDB connection error', { err }) + }) + + mongoose.connection.on('disconnected', () => { + logger.warn('MongoDB disconnected') + }) + + await mongoose.connect(env.MONGO_URI) + } catch (err) { + logger.error('Failed to connect to MongoDB', { err }) + process.exit(1) + } +} + +export async function disconnectDB(): Promise { + await mongoose.disconnect() +} diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..ac1c66e --- /dev/null +++ b/apps/api/src/config/env.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + PORT: z.coerce.number().default(3000), + + MONGO_URI: z.string().min(1, { message: 'MONGO_URI must be set' }), + REDIS_URL: z.string().url({ message: 'REDIS_URL must be a valid URI' }), + + JWT_PRIVATE_KEY: z.string().min(1, 'JWT_PRIVATE_KEY required'), + JWT_PUBLIC_KEY: z.string().min(1, 'JWT_PUBLIC_KEY required'), + JWT_ACCESS_EXPIRY: z.string().default('15m'), + JWT_REFRESH_EXPIRY: z.string().default('7d'), + + COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 characters'), + + GOOGLE_CLIENT_ID: z.string().min(1, 'GOOGLE_CLIENT_ID required'), + GOOGLE_CLIENT_SECRET: z.string().min(1, 'GOOGLE_CLIENT_SECRET required'), + GOOGLE_CALLBACK_URL: z.string().url(), + + GITHUB_CLIENT_ID: z.string().min(1, 'GITHUB_CLIENT_ID required'), + GITHUB_CLIENT_SECRET: z.string().min(1, 'GITHUB_CLIENT_SECRET required'), + GITHUB_CALLBACK_URL: z.string().url(), + + CLIENT_URL: z.string().url({ message: 'CLIENT_URL must be a valid URL' }), + + TRUST_PROXY: z.coerce.number().default(0), + SENTRY_DSN: z.string().optional(), + + GOOGLE_CLIENT_ID_MAIL: z.string().min(1, 'GOOGLE_CLIENT_ID_MAIL required'), + GOOGLE_CLIENT_SECRET_MAIL: z.string().min(1, 'GOOGLE_CLIENT_SECRET_MAIL required'), + GMAIL_REFRESH_TOKEN_MAIL: z.string().min(1, 'GMAIL_REFRESH_TOKEN_MAIL required'), +}) + +// Throw at startup if any required env var is missing +// This prevents silent failures in production +const parsed = envSchema.safeParse(process.env) + +if (!parsed.success) { + console.error('❌ Invalid environment variables:') + console.error(parsed.error.flatten().fieldErrors) + process.exit(1) +} + +export const env = parsed.data +export type Env = typeof env diff --git a/apps/api/src/config/keys.ts b/apps/api/src/config/keys.ts new file mode 100644 index 0000000..8654be3 --- /dev/null +++ b/apps/api/src/config/keys.ts @@ -0,0 +1,47 @@ +import fs from 'fs' +import path from 'path' +import { env } from './env' + +let privateKey = '' +let publicKey = '' + +export function getKeys() { + if (privateKey && publicKey) { + return { privateKey, publicKey } + } + + // Check if they are in env + if (env.JWT_PRIVATE_KEY && env.JWT_PUBLIC_KEY) { + const priv = env.JWT_PRIVATE_KEY + const pub = env.JWT_PUBLIC_KEY + + privateKey = priv.includes('-----BEGIN') ? priv : Buffer.from(priv, 'base64').toString('utf8') + publicKey = pub.includes('-----BEGIN') ? pub : Buffer.from(pub, 'base64').toString('utf8') + + // PEM validation (basic check) + if ( + !privateKey.includes('-----BEGIN RSA PRIVATE KEY-----') && + !privateKey.includes('-----BEGIN PRIVATE KEY-----') + ) { + throw new Error('Invalid private key format') + } + if (!publicKey.includes('-----BEGIN PUBLIC KEY-----')) { + throw new Error('Invalid public key format') + } + + return { privateKey, publicKey } + } + + // Fallback to files + const keysDir = path.join(__dirname, '../../keys') + const privPath = path.join(keysDir, 'private.pem') + const pubPath = path.join(keysDir, 'public.pem') + + if (fs.existsSync(privPath) && fs.existsSync(pubPath)) { + privateKey = fs.readFileSync(privPath, 'utf8') + publicKey = fs.readFileSync(pubPath, 'utf8') + return { privateKey, publicKey } + } + + throw new Error('JWT RS256 private/public keys are missing. Run key generation script.') +} diff --git a/apps/api/src/config/redis.ts b/apps/api/src/config/redis.ts new file mode 100644 index 0000000..cd88652 --- /dev/null +++ b/apps/api/src/config/redis.ts @@ -0,0 +1,27 @@ +import Redis from 'ioredis' +import { env } from './env' +import { logger } from '@/shared/logger' + +export const redis = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, + lazyConnect: true, +}) + +redis.on('connect', () => { + logger.info('Redis client connecting...') +}) + +redis.on('ready', () => { + logger.info('Redis client connected and ready') +}) + +redis.on('error', (err) => { + logger.error('Redis error', { err }) +}) + +export async function connectRedis(): Promise { + if (redis.status === 'wait' || redis.status === 'close') { + await redis.connect() + } + return redis +} diff --git a/apps/api/src/config/swagger.ts b/apps/api/src/config/swagger.ts new file mode 100644 index 0000000..df69b54 --- /dev/null +++ b/apps/api/src/config/swagger.ts @@ -0,0 +1,140 @@ +import { Express } from 'express' +import swaggerUi from 'swagger-ui-express' + +const swaggerDocument = { + openapi: '3.0.3', + info: { + title: 'TokenForge API', + version: '1.0.0', + description: 'JWT auth system with refresh token rotation, OAuth2 PKCE, and RBAC', + contact: { name: 'Dark', url: 'https://tokenforge.dev' }, + license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' }, + }, + servers: [ + { url: 'http://localhost:3000/api/v1', description: 'Local development' }, + { url: 'https://tokenforge-api.railway.app/api/v1', description: 'Production' }, + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'RS256-signed JWT access token (15 min expiry)', + }, + }, + schemas: { + LoginRequest: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email', example: 'user@example.com' }, + password: { type: 'string', minLength: 8, example: 'Secure123!' }, + }, + }, + AuthResponse: { + type: 'object', + properties: { + accessToken: { type: 'string', description: 'RS256 JWT — store in memory only' }, + user: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + email: { type: 'string' }, + role: { type: 'string', enum: ['admin', 'moderator', 'user', 'guest'] }, + }, + }, + }, + }, + ErrorResponse: { + type: 'object', + properties: { + status: { type: 'string', example: 'error' }, + statusCode: { type: 'integer', example: 401 }, + message: { type: 'string' }, + requestId: { type: 'string' }, + }, + }, + }, + }, + paths: { + '/auth/login': { + post: { + tags: ['Authentication'], + summary: 'Login with email and password', + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/LoginRequest' } }, + }, + }, + responses: { + 200: { + description: 'Login successful — refresh token set in httpOnly cookie', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AuthResponse' } }, + }, + }, + 400: { description: 'Validation error' }, + 401: { description: 'Invalid credentials' }, + 429: { description: 'Rate limit exceeded (5 attempts / 15 min)' }, + }, + }, + }, + '/auth/refresh': { + post: { + tags: ['Authentication'], + summary: 'Refresh access token using httpOnly cookie', + description: 'Rotates refresh token. Old token is invalidated immediately.', + responses: { + 200: { + description: 'New access token issued', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { accessToken: { type: 'string' } }, + }, + }, + }, + }, + 401: { description: 'Refresh token invalid, expired, or reuse detected' }, + }, + }, + }, + '/auth/logout': { + post: { + tags: ['Authentication'], + summary: 'Logout — revoke current refresh token + blacklist access token', + security: [{ BearerAuth: [] }], + responses: { + 200: { description: 'Logged out successfully' }, + 401: { description: 'Not authenticated' }, + }, + }, + }, + '/users/me': { + get: { + tags: ['Users'], + summary: 'Get current user profile', + security: [{ BearerAuth: [] }], + responses: { + 200: { description: 'User profile object' }, + 401: { description: 'Not authenticated' }, + }, + }, + }, + }, +} + +export function setupSwagger(app: Express): void { + app.use( + '/api/docs', + swaggerUi.serve, + swaggerUi.setup(swaggerDocument, { + customSiteTitle: 'TokenForge API Docs', + customCss: '.swagger-ui .topbar { background-color: #0A0A0F; }', + }) + ) +} diff --git a/apps/api/src/middleware/auth.middleware.ts b/apps/api/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..ceb0a99 --- /dev/null +++ b/apps/api/src/middleware/auth.middleware.ts @@ -0,0 +1,55 @@ +import { Request, Response, NextFunction } from 'express' +import jwt from 'jsonwebtoken' +import { getKeys } from '@/config/keys' +import { AuthError } from '@/shared/errors' +import { REDIS_KEYS } from '@/shared/constants' +import type { JwtPayload } from '@tokenforge/types' + +// Extend Express Request to carry decoded JWT payload +declare global { + namespace Express { + interface Request { + user?: JwtPayload + } + } +} + +export const requireAuth = async ( + req: Request, + _res: Response, + next: NextFunction +): Promise => { + try { + // Extract token from Authorization header only (not query string, not body) + const authHeader = req.headers.authorization + if (!authHeader?.startsWith('Bearer ')) { + throw new AuthError('Authorization header missing or malformed') + } + + const token = authHeader.slice(7) + const { publicKey } = getKeys() + + // Verify signature + expiry (jsonwebtoken throws on failure) + const payload = jwt.verify(token, publicKey, { + algorithms: ['RS256'], // Explicitly whitelist — prevents alg:none attack + }) as JwtPayload + + // Check jti blacklist in Redis (covers logout + force-revoke scenarios) + const redis = req.app.get('redis') + const blacklisted = await redis.get(REDIS_KEYS.atBlacklist(payload.jti)) + if (blacklisted) { + throw new AuthError('Token has been revoked') + } + + req.user = payload + next() + } catch (err) { + if (err instanceof jwt.TokenExpiredError) { + next(new AuthError('Access token expired')) + } else if (err instanceof jwt.JsonWebTokenError) { + next(new AuthError('Invalid token')) + } else { + next(err) + } + } +} \ No newline at end of file diff --git a/apps/api/src/middleware/errorHandler.middleware.ts b/apps/api/src/middleware/errorHandler.middleware.ts new file mode 100644 index 0000000..823c308 --- /dev/null +++ b/apps/api/src/middleware/errorHandler.middleware.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express' +import { AppError } from '@/shared/errors' +import { logger } from '@/shared/logger' + +interface ErrorResponse { + status: 'error' + statusCode: number + message: string + requestId?: string | undefined +} + +export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void { + const requestId = req.headers['x-request-id'] as string | undefined + + if (err instanceof AppError) { + // Known operational error — safe to expose message + const body: ErrorResponse = { + status: 'error', + statusCode: err.statusCode, + message: err.message, + requestId, + } + if (err.message === 'Refresh token missing') { + logger.debug('Refresh check bypassed: no refresh token cookie present', { + path: req.path, + method: req.method, + }) + } else { + logger.warn('Operational error', { ...body, path: req.path, method: req.method }) + } + res.status(err.statusCode).json(body) + return + } + + // Unknown error — do NOT expose internals + logger.error('Unhandled error', { + error: err.message, + stack: err.stack, + path: req.path, + method: req.method, + requestId, + }) + + res.status(500).json({ + status: 'error', + statusCode: 500, + message: 'An unexpected error occurred', + requestId, + }) +} diff --git a/apps/api/src/middleware/rateLimiter.middleware.ts b/apps/api/src/middleware/rateLimiter.middleware.ts new file mode 100644 index 0000000..ab15b85 --- /dev/null +++ b/apps/api/src/middleware/rateLimiter.middleware.ts @@ -0,0 +1,53 @@ +import rateLimit from 'express-rate-limit' +import RedisStore from 'rate-limit-redis' +import { redis } from '@/config/redis' +import { RATE_LIMIT } from '@/shared/constants' + +// Login rate limiter — 5 attempts per 15 min per IP +// Uses Redis store — survives API restarts and scales horizontally +export const loginRateLimiter = rateLimit({ + windowMs: RATE_LIMIT.LOGIN_WINDOW_MS, + max: RATE_LIMIT.LOGIN_MAX, + standardHeaders: 'draft-7', // Sends RateLimit-* headers (RFC 9110 draft) + legacyHeaders: false, + message: { + status: 'error', + statusCode: 429, + message: 'Too many login attempts. Please try again in 15 minutes.', + }, + store: new RedisStore({ + sendCommand: async (...args: string[]) => (await redis.call(args[0]!, ...args.slice(1))) as any, + prefix: 'rl:login:', + }), + keyGenerator: (req) => req.ip ?? req.headers['x-forwarded-for']?.toString() ?? 'unknown', +}) + +// Register rate limiter — 3 accounts per hour per IP (prevents spam account creation) +export const registerRateLimiter = rateLimit({ + windowMs: RATE_LIMIT.REGISTER_WINDOW_MS, + max: RATE_LIMIT.REGISTER_MAX, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: { + status: 'error', + statusCode: 429, + message: 'Too many registration attempts. Please try again in 1 hour.', + }, + store: new RedisStore({ + sendCommand: async (...args: string[]) => (await redis.call(args[0]!, ...args.slice(1))) as any, + prefix: 'rl:register:', + }), + keyGenerator: (req) => req.ip ?? req.headers['x-forwarded-for']?.toString() ?? 'unknown', +}) + +// General API rate limiter — 100 req/min per IP for all other routes +export const generalRateLimiter = rateLimit({ + windowMs: RATE_LIMIT.API_GENERAL_WINDOW_MS, + max: RATE_LIMIT.API_GENERAL_MAX, + standardHeaders: 'draft-7', + legacyHeaders: false, + store: new RedisStore({ + sendCommand: async (...args: string[]) => (await redis.call(args[0]!, ...args.slice(1))) as any, + prefix: 'rl:general:', + }), +}) diff --git a/apps/api/src/middleware/rbac.middleware.ts b/apps/api/src/middleware/rbac.middleware.ts new file mode 100644 index 0000000..bd5c2f8 --- /dev/null +++ b/apps/api/src/middleware/rbac.middleware.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from 'express' +import { ForbiddenError, AuthError } from '@/shared/errors' +import type { PermissionString } from '@tokenforge/types' + +/** + * Factory middleware — usage: router.get('/users', requireAuth, requirePermission('users:read')) + * + * Checks JWT claims.permissions array (populated at token generation time). + * Admin role bypasses all permission checks. + * Scope 'own' vs 'all' enforced at service layer — not here. + */ +export const requirePermission = (permission: PermissionString) => + (req: Request, _res: Response, next: NextFunction): void => { + if (!req.user) { + next(new AuthError('User not authenticated')) + return + } + + const { role, permissions } = req.user + + // Admin bypasses all permission checks — has implicit wildcard + if (role === 'admin') { + next() + return + } + + // Check exact permission match in claims array + if (!permissions.includes(permission)) { + next(new ForbiddenError( + `Permission denied: '${permission}' required, role '${role}' insufficient` + )) + return + } + + next() + } + +/** + * Convenience: require one of multiple permissions (OR logic) + * Usage: requireAnyPermission(['users:read', 'audit:read']) + */ +export const requireAnyPermission = (perms: PermissionString[]) => + (req: Request, _res: Response, next: NextFunction): void => { + if (!req.user) { + next(new AuthError('User not authenticated')) + return + } + + const { role, permissions } = req.user + + if (role === 'admin' || perms.some(p => permissions.includes(p))) { + next() + return + } + + next(new ForbiddenError('Insufficient permissions')) + } \ No newline at end of file diff --git a/apps/api/src/middleware/requestId.middleware.ts b/apps/api/src/middleware/requestId.middleware.ts new file mode 100644 index 0000000..6bf943a --- /dev/null +++ b/apps/api/src/middleware/requestId.middleware.ts @@ -0,0 +1,9 @@ +import { Request, Response, NextFunction } from 'express' +import { v4 as uuidv4 } from 'uuid' + +export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void { + const requestId = (req.headers['x-request-id'] as string) || uuidv4() + req.headers['x-request-id'] = requestId + res.setHeader('X-Request-ID', requestId) + next() +} diff --git a/apps/api/src/middleware/sanitize.middleware.ts b/apps/api/src/middleware/sanitize.middleware.ts new file mode 100644 index 0000000..769bf40 --- /dev/null +++ b/apps/api/src/middleware/sanitize.middleware.ts @@ -0,0 +1,25 @@ +import { Request, Response, NextFunction } from 'express' +import mongoSanitize from 'mongo-sanitize' + +export function sanitize(req: Request, _res: Response, next: NextFunction): void { + if (req.body) { + req.body = mongoSanitize(req.body) + } + if (req.query) { + const sanitizedQuery = mongoSanitize({ ...req.query }) + for (const key of Object.keys(req.query)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (req.query as any)[key] + } + Object.assign(req.query, sanitizedQuery) + } + if (req.params) { + const sanitizedParams = mongoSanitize({ ...req.params }) + for (const key of Object.keys(req.params)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (req.params as any)[key] + } + Object.assign(req.params, sanitizedParams) + } + next() +} diff --git a/apps/api/src/middleware/validate.middleware.ts b/apps/api/src/middleware/validate.middleware.ts new file mode 100644 index 0000000..fff7a4d --- /dev/null +++ b/apps/api/src/middleware/validate.middleware.ts @@ -0,0 +1,25 @@ +import { Request, Response, NextFunction } from 'express' +import { Schema } from 'joi' + +export const validate = (schema: Schema) => { + return (req: Request, res: Response, next: NextFunction): void => { + const { error, value } = schema.validate(req.body, { + abortEarly: false, + stripUnknown: true, + }) + + if (error) { + res.status(400).json({ + status: 'error', + statusCode: 400, + message: 'Validation error', + details: error.details.map((d) => d.message), + }) + return + } + + // Override request body with validated & sanitized value + req.body = value + next() + } +} diff --git a/apps/api/src/modules/admin/admin.controller.ts b/apps/api/src/modules/admin/admin.controller.ts new file mode 100644 index 0000000..741f1e3 --- /dev/null +++ b/apps/api/src/modules/admin/admin.controller.ts @@ -0,0 +1,99 @@ +import { Request, Response, NextFunction } from 'express' +import { AdminService } from './admin.service' +import { AuditService } from '../audit/audit.service' +import { success } from '@/shared/response' +import { UserRole } from '@tokenforge/types' +import { AppError } from '@/shared/errors' +import { UserModel } from '../users/user.model' +import { AuditEvent } from '@/shared/constants' + +export class AdminController { + constructor( + private readonly adminService: AdminService, + private readonly auditService: AuditService + ) {} + + getUsers = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const page = parseInt(req.query.page as string) || 1 + const limit = parseInt(req.query.limit as string) || 10 + const data = await this.adminService.getUsers(page, limit) + success(res, data) + } catch (err) { + next(err) + } + } + + changeRole = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = req.params.id as string + const { role } = req.body + if (!role) { + throw new AppError('Role parameter is required', 400) + } + + const user = await this.adminService.changeUserRole(id, role as UserRole) + + await this.auditService.log({ + userId: req.user?.sub, + event: AuditEvent.ROLE_CHANGED, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + metadata: { targetUserId: id, newRole: role }, + }) + + success(res, user) + } catch (err) { + next(err) + } + } + + revokeSessions = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = req.params.id as string + await this.adminService.revokeUserSessions(id) + + await this.auditService.log({ + userId: req.user?.sub, + event: AuditEvent.SESSION_REVOKED, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + metadata: { targetUserId: id }, + }) + + success(res, { message: 'Sessions revoked successfully' }) + } catch (err) { + next(err) + } + } + + getStats = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const totalUsers = await UserModel.countDocuments() + const activeSessions = await this.adminService.getActiveSessionsCount() + const oauthUsers = await UserModel.countDocuments({ + $or: [ + { googleId: { $exists: true, $ne: null } }, + { githubId: { $exists: true, $ne: null } }, + ], + }) + const adminUsers = await UserModel.countDocuments({ roles: 'admin' }) + success(res, { totalUsers, activeSessions, oauthUsers, adminUsers }) + } catch (err) { + next(err) + } + } + + getAuditLogs = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const page = parseInt(req.query.page as string) || 1 + const limit = parseInt(req.query.limit as string) || 20 + const data = await this.auditService.getAuditLogs(page, limit) + success(res, data) + } catch (err) { + next(err) + } + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..0e4e82d --- /dev/null +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -0,0 +1,41 @@ +import { Router } from 'express' +import rateLimit from 'express-rate-limit' +import { AdminController } from './admin.controller' +import { AdminService } from './admin.service' +import { redis } from '@/config/redis' +import { userRepo, auditService } from '../auth/auth.routes' +import { requireAuth } from '@/middleware/auth.middleware' +import { requirePermission, requireAnyPermission } from '@/middleware/rbac.middleware' + +export const adminRouter = Router() + +const adminService = new AdminService(userRepo, redis) +const adminController = new AdminController(adminService, auditService) +const adminRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, +}) + +adminRouter.use(adminRateLimiter) +adminRouter.use(requireAuth) + +adminRouter.get( + '/users', + requireAnyPermission(['users:read', 'audit:read']), + adminController.getUsers +) +adminRouter.get('/audit', requirePermission('audit:read'), adminController.getAuditLogs) +adminRouter.get( + '/stats', + requireAnyPermission(['users:read', 'audit:read']), + adminController.getStats +) + +adminRouter.patch('/users/:id/role', requirePermission('users:write'), adminController.changeRole) +adminRouter.delete( + '/users/:id/sessions', + requirePermission('sessions:delete'), + adminController.revokeSessions +) diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts new file mode 100644 index 0000000..f8ceb91 --- /dev/null +++ b/apps/api/src/modules/admin/admin.service.ts @@ -0,0 +1,64 @@ +import { UserRepository } from '../users/user.repository' +import type { Redis } from 'ioredis' +import { IUser } from '../users/user.model' +import { AppError } from '@/shared/errors' +import { UserRole } from '@tokenforge/types' +import { REDIS_KEYS } from '@/shared/constants' + +export class AdminService { + constructor( + private readonly userRepo: UserRepository, + private readonly redis: Redis + ) {} + + async getUsers(page: number, limit: number): Promise<{ users: IUser[]; total: number }> { + return this.userRepo.findAllPaginated(page, limit) + } + + async changeUserRole(userId: string, role: UserRole): Promise { + const user = await this.userRepo.update(userId, { roles: [role] }) + if (!user) { + throw new AppError('User not found', 404) + } + return user + } + + async getActiveSessionsCount(): Promise { + let cursor = '0' + let count = 0 + do { + const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', 'refresh:*', 'COUNT', 100) + cursor = nextCursor + count += keys.length + } while (cursor !== '0') + return count + } + + async revokeUserSessions(userId: string): Promise { + let cursor = '0' + const pipeline = this.redis.pipeline() + const keysToDelete: string[] = [] + const familiesToDelete: string[] = [] + + do { + const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', 'refresh:*', 'COUNT', 100) + cursor = nextCursor + + for (const key of keys) { + const raw = await this.redis.get(key) + if (!raw) continue + const meta = JSON.parse(raw) + if (meta.userId === userId) { + keysToDelete.push(key) + familiesToDelete.push(REDIS_KEYS.tokenFamily(meta.familyId)) + } + } + } while (cursor !== '0') + + if (keysToDelete.length > 0) { + keysToDelete.forEach((k) => pipeline.del(k)) + familiesToDelete.forEach((f) => pipeline.del(f)) + await pipeline.exec() + } + } +} diff --git a/apps/api/src/modules/audit/audit.model.ts b/apps/api/src/modules/audit/audit.model.ts new file mode 100644 index 0000000..2516b47 --- /dev/null +++ b/apps/api/src/modules/audit/audit.model.ts @@ -0,0 +1,37 @@ +import mongoose, { Schema, Document } from 'mongoose' +import { AuditEvent } from '@/shared/constants' + +export interface IAuditLog extends Document { + userId?: string | undefined + event: AuditEvent + ip: string + userAgent: string + requestId?: string | undefined + metadata?: Record | undefined + createdAt: Date +} + +const AuditSchema = new Schema( + { + userId: { type: String, index: true }, + event: { type: String, required: true, enum: Object.values(AuditEvent) }, + ip: { type: String, required: true }, + userAgent: { type: String, required: true }, + requestId: { type: String }, + metadata: { type: Schema.Types.Mixed }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, // Immutable logs — no updatedAt + versionKey: false, + } +) + +// ── Indexes ──────────────────────────────────────────────────────────── +AuditSchema.index({ userId: 1, createdAt: -1 }) // Paginated per-user audit view +AuditSchema.index({ event: 1, createdAt: -1 }) // Filter by event type +AuditSchema.index( + { createdAt: 1 }, + { expireAfterSeconds: 7_776_000 } // TTL: 90 days — auto-purge old logs +) + +export const AuditModel = mongoose.model('AuditLog', AuditSchema) diff --git a/apps/api/src/modules/audit/audit.repository.ts b/apps/api/src/modules/audit/audit.repository.ts new file mode 100644 index 0000000..814f563 --- /dev/null +++ b/apps/api/src/modules/audit/audit.repository.ts @@ -0,0 +1,29 @@ +import { AuditModel, IAuditLog } from './audit.model' + +export class AuditRepository { + async insert(log: Partial): Promise { + return AuditModel.create(log) + } + + async findPaginated(page: number, limit: number): Promise<{ logs: IAuditLog[]; total: number }> { + const skip = (page - 1) * limit + const [logs, total] = await Promise.all([ + AuditModel.find().sort({ createdAt: -1 }).skip(skip).limit(limit), + AuditModel.countDocuments(), + ]) + return { logs, total } + } + + async findByUserIdPaginated( + userId: string, + page: number, + limit: number + ): Promise<{ logs: IAuditLog[]; total: number }> { + const skip = (page - 1) * limit + const [logs, total] = await Promise.all([ + AuditModel.find({ userId }).sort({ createdAt: -1 }).skip(skip).limit(limit), + AuditModel.countDocuments({ userId }), + ]) + return { logs, total } + } +} diff --git a/apps/api/src/modules/audit/audit.routes.ts b/apps/api/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/audit/audit.service.ts b/apps/api/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..77aa226 --- /dev/null +++ b/apps/api/src/modules/audit/audit.service.ts @@ -0,0 +1,30 @@ +import { AuditRepository } from './audit.repository' +import { IAuditLog } from './audit.model' +import { AuditEvent } from '@/shared/constants' + +export class AuditService { + constructor(private readonly auditRepo: AuditRepository) {} + + async log(logData: { + userId?: string | undefined + event: AuditEvent + ip: string + userAgent: string + requestId?: string | undefined + metadata?: Record | undefined + }): Promise { + return this.auditRepo.insert(logData) + } + + async getAuditLogs(page: number, limit: number): Promise<{ logs: IAuditLog[]; total: number }> { + return this.auditRepo.findPaginated(page, limit) + } + + async getUserAuditLogs( + userId: string, + page: number, + limit: number + ): Promise<{ logs: IAuditLog[]; total: number }> { + return this.auditRepo.findByUserIdPaginated(userId, page, limit) + } +} diff --git a/apps/api/src/modules/audit/audit.types.ts b/apps/api/src/modules/audit/audit.types.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..fe7f2a7 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,109 @@ +import { Request, Response, NextFunction } from 'express' +import { AuthService } from './auth.service' +import { TokenService } from '../token/token.service' +import { success } from '@/shared/response' +import { COOKIE_OPTIONS, AuditEvent } from '@/shared/constants' +import { AuthError } from '@/shared/errors' +import { AuditService } from '../audit/audit.service' + +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly tokenService: TokenService, + private readonly auditService: AuditService + ) {} + + register = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { email, password, name } = req.body + const user = await this.authService.register(email, password, name) + + await this.auditService.log({ + userId: user._id.toString(), + event: AuditEvent.REGISTER, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + }) + + success(res, user, 201) + } catch (err) { + next(err) + } + } + + login = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { email, password } = req.body + const { user, accessToken, refreshToken } = await this.authService.login(email, password) + + res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS) + + await this.auditService.log({ + userId: user._id.toString(), + event: AuditEvent.LOGIN_SUCCESS, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + }) + + success(res, { accessToken, user }) + } catch (err) { + next(err) + } + } + + logout = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const refreshToken = req.cookies.refreshToken + const accessTokenJti = req.user?.jti + + if (refreshToken) { + await this.authService.logout(refreshToken, accessTokenJti) + } + + res.clearCookie('refreshToken', COOKIE_OPTIONS) + + if (req.user) { + await this.auditService.log({ + userId: req.user.sub, + event: AuditEvent.LOGOUT, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + }) + } + + success(res, { message: 'Logged out successfully' }) + } catch (err) { + next(err) + } + } + + refresh = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const incomingToken = req.cookies.refreshToken + if (!incomingToken) { + throw new AuthError('Refresh token missing') + } + + const { newAccessToken, newRefreshToken, userId } = + await this.tokenService.rotateRefreshToken(incomingToken) + + res.cookie('refreshToken', newRefreshToken, COOKIE_OPTIONS) + + await this.auditService.log({ + userId, + event: AuditEvent.TOKEN_REFRESH, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + }) + + success(res, { accessToken: newAccessToken }) + } catch (err) { + res.clearCookie('refreshToken', COOKIE_OPTIONS) + next(err) + } + } +} diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..5693a89 --- /dev/null +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -0,0 +1,40 @@ +import { Router } from 'express' +import { AuthController } from './auth.controller' +import { AuthService } from './auth.service' +import { TokenService } from '../token/token.service' +import { UserRepository } from '../users/user.repository' +import { RbacService } from '../rbac/rbac.service' +import { RbacRepository } from '../rbac/rbac.repository' +import { AuditService } from '../audit/audit.service' +import { AuditRepository } from '../audit/audit.repository' +import { redis } from '@/config/redis' +import { validate } from '@/middleware/validate.middleware' +import { loginSchema, registerSchema } from './auth.schema' +import { requireAuth } from '@/middleware/auth.middleware' + +export const authRouter = Router() + +// DI Setup +const userRepo = new UserRepository() +const rbacRepo = new RbacRepository() +const rbacService = new RbacService(rbacRepo) + +const fetchClaims = async (userId: string) => { + const user = await userRepo.findById(userId) + if (!user) throw new Error('User not found') + const primaryRole = user.roles[0] || 'user' + const permissions = await rbacService.getPermissionsForRole(primaryRole) + return { role: primaryRole, permissions } +} + +const tokenService = new TokenService(redis, fetchClaims) +const authService = new AuthService(userRepo, tokenService, rbacService) +const auditRepo = new AuditRepository() +const auditService = new AuditService(auditRepo) +const authController = new AuthController(authService, tokenService, auditService) + +authRouter.post('/register', validate(registerSchema), authController.register) +authRouter.post('/login', validate(loginSchema), authController.login) +authRouter.post('/logout', requireAuth, authController.logout) +authRouter.post('/refresh', authController.refresh) +export { tokenService, userRepo, rbacService, auditService } diff --git a/apps/api/src/modules/auth/auth.schema.ts b/apps/api/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..5cd4ab6 --- /dev/null +++ b/apps/api/src/modules/auth/auth.schema.ts @@ -0,0 +1,12 @@ +import Joi from 'joi' + +export const loginSchema = Joi.object({ + email: Joi.string().email().required().trim().lowercase(), + password: Joi.string().min(8).required(), +}) + +export const registerSchema = Joi.object({ + email: Joi.string().email().required().trim().lowercase(), + password: Joi.string().min(8).required(), + name: Joi.string().min(2).max(100).required().trim(), +}) diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..879470f --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -0,0 +1,78 @@ +import bcrypt from 'bcryptjs' +import { UserRepository } from '../users/user.repository' +import { TokenService } from '../token/token.service' +import { RbacService } from '../rbac/rbac.service' +import { IUser } from '../users/user.model' +import { AppError, AuthError } from '@/shared/errors' + +export class AuthService { + constructor( + private readonly userRepo: UserRepository, + private readonly tokenService: TokenService, + private readonly rbacService: RbacService + ) {} + + async register(email: string, password: string, name: string): Promise { + const existing = await this.userRepo.findByEmail(email) + if (existing) { + throw new AppError('Email already registered', 400) + } + + const passwordHash = await bcrypt.hash(password, 12) + return this.userRepo.create({ + email, + passwordHash, + name, + roles: ['user'], + isActive: true, + emailVerified: false, + }) + } + + async login( + email: string, + password: string + ): Promise<{ + user: IUser + accessToken: string + refreshToken: string + familyId: string + }> { + const user = await this.userRepo.findByEmailWithPassword(email) + const passwordHash = user?.passwordHash || '' + + // Constant-time check + const dummyHash = '$2a$12$L.bO/JgJ1i0/kSg7nBGeP.X8X1111111111111111111111111111' + const isMatch = await bcrypt.compare(password, user ? passwordHash : dummyHash) + + if (!user || !isMatch) { + throw new AuthError('Invalid credentials') + } + + if (!user.isActive) { + throw new AuthError('Account is deactivated') + } + + const primaryRole = user.roles[0] || 'user' + const permissions = await this.rbacService.getPermissionsForRole(primaryRole) + + const accessToken = this.tokenService.generateAccessToken({ + userId: user._id.toString(), + role: primaryRole, + permissions, + }) + + const { refreshToken, familyId } = await this.tokenService.generateRefreshToken( + user._id.toString() + ) + + return { user, accessToken, refreshToken, familyId } + } + + async logout(refreshToken: string, accessTokenJti?: string): Promise { + if (accessTokenJti) { + await this.tokenService.blacklistAccessToken(accessTokenJti) + } + await this.tokenService.revokeRefreshToken(refreshToken) + } +} diff --git a/apps/api/src/modules/auth/auth.types.ts b/apps/api/src/modules/auth/auth.types.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/oauth/oauth.controller.ts b/apps/api/src/modules/oauth/oauth.controller.ts new file mode 100644 index 0000000..980ebd0 --- /dev/null +++ b/apps/api/src/modules/oauth/oauth.controller.ts @@ -0,0 +1,167 @@ +import { Request, Response, NextFunction } from 'express' +import { OAuthService } from './oauth.service' +import { TokenService } from '../token/token.service' +import { success } from '@/shared/response' +import { COOKIE_OPTIONS, AuditEvent } from '@/shared/constants' +import { AuditService } from '../audit/audit.service' +import { AppError } from '@/shared/errors' +import { env } from '@/config/env' + +export class OAuthController { + constructor( + private readonly oauthService: OAuthService, + private readonly auditService: AuditService, + private readonly tokenService?: TokenService + ) {} + + /** Safely extract userId from a Bearer token without throwing */ + private extractUserIdFromAuthHeader(req: Request): string | undefined { + try { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) return undefined + const token = authHeader.slice(7) + const payload = this.tokenService?.verifyAccessToken(token) + return payload?.sub ?? undefined + } catch { + return undefined + } + } + + googleInit = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const state = this.oauthService.generateState() + const { codeVerifier, codeChallenge } = this.oauthService.generatePKCE() + + await this.oauthService.saveStateAndVerifier(state, codeVerifier) + const url = this.oauthService.getGoogleAuthUrl(state, codeChallenge) + + success(res, { url }) + } catch (err) { + next(err) + } + } + + githubInit = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const state = this.oauthService.generateState() + + await this.oauthService.saveStateAndVerifier(state) + const url = this.oauthService.getGitHubAuthUrl(state) + + success(res, { url }) + } catch (err) { + next(err) + } + } + + googleCallback = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { code, state } = req.query + if (typeof code !== 'string' || typeof state !== 'string') { + throw new AppError('Query parameters code and state are required', 400) + } + + // Check that state exists inside redis cache to verify request validity + const codeVerifier = await this.oauthService.getVerifierAndValidateState(state, true) + if (!codeVerifier) { + throw new AppError('PKCE verification code missing from state cache', 400) + } + + // Redirect to frontend callback route with code and state parameters to run exchange + const frontendRedirectUrl = `${env.CLIENT_URL}/oauth/callback/google?code=${code}&state=${state}` + res.redirect(frontendRedirectUrl) + } catch (err) { + next(err) + } + } + + githubCallback = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { code, state } = req.query + if (typeof code !== 'string' || typeof state !== 'string') { + throw new AppError('Query parameters code and state are required', 400) + } + + // Verify that state exists in redis cache + await this.oauthService.getVerifierAndValidateState(state, true) + + // Redirect to frontend callback route with code and state parameters + const frontendRedirectUrl = `${env.CLIENT_URL}/oauth/callback/github?code=${code}&state=${state}` + res.redirect(frontendRedirectUrl) + } catch (err) { + next(err) + } + } + + googleExchange = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { code, state } = req.body + if (typeof code !== 'string' || typeof state !== 'string') { + throw new AppError('Body parameters code and state are required', 400) + } + + const codeVerifier = await this.oauthService.getVerifierAndValidateState(state) + if (!codeVerifier) { + throw new AppError('PKCE verification code missing from state cache', 400) + } + + // If an authenticated user initiated this (account linking), extract their userId + const existingUserId = this.extractUserIdFromAuthHeader(req) + + const { user, accessToken, refreshToken } = await this.oauthService.handleGoogleCallback( + code, + codeVerifier, + existingUserId + ) + + res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS) + + await this.auditService.log({ + userId: user._id.toString(), + event: AuditEvent.OAUTH_LOGIN, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + metadata: { provider: 'google', linked: !!existingUserId }, + }) + + success(res, { accessToken, user }) + } catch (err) { + next(err) + } + } + + githubExchange = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { code, state } = req.body + if (typeof code !== 'string' || typeof state !== 'string') { + throw new AppError('Body parameters code and state are required', 400) + } + + await this.oauthService.getVerifierAndValidateState(state) + + // If an authenticated user initiated this (account linking), extract their userId + const existingUserId = this.extractUserIdFromAuthHeader(req) + + const { user, accessToken, refreshToken } = await this.oauthService.handleGitHubCallback( + code, + existingUserId + ) + + res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS) + + await this.auditService.log({ + userId: user._id.toString(), + event: AuditEvent.OAUTH_LOGIN, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + metadata: { provider: 'github', linked: !!existingUserId }, + }) + + success(res, { accessToken, user }) + } catch (err) { + next(err) + } + } +} diff --git a/apps/api/src/modules/oauth/oauth.routes.ts b/apps/api/src/modules/oauth/oauth.routes.ts new file mode 100644 index 0000000..852e23d --- /dev/null +++ b/apps/api/src/modules/oauth/oauth.routes.ts @@ -0,0 +1,36 @@ +import { Router } from 'express' +import rateLimit from 'express-rate-limit' +import { OAuthController } from './oauth.controller' +import { OAuthService } from './oauth.service' +import { GoogleProvider } from './providers/google.provider' +import { GitHubProvider } from './providers/github.provider' +import { redis } from '@/config/redis' +import { tokenService, userRepo, rbacService, auditService } from '../auth/auth.routes' + +export const oauthRouter = Router() + +const oauthExchangeLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + standardHeaders: true, + legacyHeaders: false, +}) + +const googleProvider = new GoogleProvider() +const githubProvider = new GitHubProvider() +const oauthService = new OAuthService( + redis, + userRepo, + tokenService, + rbacService, + googleProvider, + githubProvider +) +const oauthController = new OAuthController(oauthService, auditService, tokenService) + +oauthRouter.get('/google', oauthController.googleInit) +oauthRouter.get('/google/callback', oauthController.googleCallback) +oauthRouter.post('/google/callback', oauthExchangeLimiter, oauthController.googleExchange) +oauthRouter.get('/github', oauthController.githubInit) +oauthRouter.get('/github/callback', oauthController.githubCallback) +oauthRouter.post('/github/callback', oauthExchangeLimiter, oauthController.githubExchange) diff --git a/apps/api/src/modules/oauth/oauth.service.ts b/apps/api/src/modules/oauth/oauth.service.ts new file mode 100644 index 0000000..b9824f9 --- /dev/null +++ b/apps/api/src/modules/oauth/oauth.service.ts @@ -0,0 +1,217 @@ +import { createHash, randomBytes } from 'crypto' +import type { Redis } from 'ioredis' +import { GoogleProvider } from './providers/google.provider' +import { GitHubProvider } from './providers/github.provider' +import { UserRepository } from '../users/user.repository' +import { TokenService } from '../token/token.service' +import { RbacService } from '../rbac/rbac.service' +import { env } from '@/config/env' +import { AppError } from '@/shared/errors' +import { IUser } from '../users/user.model' + +export class OAuthService { + constructor( + private readonly redis: Redis, + private readonly userRepo: UserRepository, + private readonly tokenService: TokenService, + private readonly rbacService: RbacService, + private readonly googleProvider: GoogleProvider, + private readonly githubProvider: GitHubProvider + ) {} + + generateState(): string { + return randomBytes(16).toString('hex') + } + + generatePKCE(): { codeVerifier: string; codeChallenge: string } { + const codeVerifier = randomBytes(32).toString('base64url') + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url') + return { codeVerifier, codeChallenge } + } + + async saveStateAndVerifier(state: string, verifier?: string): Promise { + const key = `oauth:state:${state}` + if (verifier) { + await this.redis.set(key, verifier, 'EX', 600) // 10 min TTL + } else { + await this.redis.set(key, '1', 'EX', 600) + } + } + + async getVerifierAndValidateState(state: string, keepState = false): Promise { + const key = `oauth:state:${state}` + const verifier = await this.redis.get(key) + if (!verifier) { + throw new AppError('Invalid state or state expired', 400) + } + if (!keepState) { + await this.redis.del(key) // One-time use state + } + return verifier === '1' ? null : verifier + } + + getGoogleAuthUrl(state: string, codeChallenge: string): string { + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: env.GOOGLE_CALLBACK_URL, + response_type: 'code', + scope: 'openid email profile', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }) + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` + } + + getGitHubAuthUrl(state: string): string { + const params = new URLSearchParams({ + client_id: env.GITHUB_CLIENT_ID, + redirect_uri: env.GITHUB_CALLBACK_URL, + state, + scope: 'user:email', + }) + return `https://github.com/login/oauth/authorize?${params.toString()}` + } + + async handleGoogleCallback( + code: string, + codeVerifier: string, + existingUserId?: string + ): Promise<{ + user: IUser + accessToken: string + refreshToken: string + }> { + let providerToken: string + let profile: Awaited> + try { + const tokens = await this.googleProvider.getTokens(code, codeVerifier) + providerToken = tokens.accessToken + profile = await this.googleProvider.getUserProfile(providerToken) + } catch (err: any) { + const msg = + err?.response?.data?.error_description || + err?.response?.data?.error || + err?.message || + 'Google token exchange failed' + throw new AppError(msg, 400) + } + + let user: IUser + + // If already logged in, link to that user directly + if (existingUserId) { + const foundUser = await this.userRepo.findById(existingUserId) + if (!foundUser) throw new AppError('Authenticated user not found', 404) + foundUser.googleId = profile.id + if (profile.picture && !foundUser.avatar) foundUser.avatar = profile.picture + await foundUser.save() + user = foundUser + } else { + const foundUser = await this.userRepo.findByEmail(profile.email) + if (!foundUser) { + const createData: Partial = { + email: profile.email, + name: profile.name, + googleId: profile.id, + roles: ['user'], + isActive: true, + emailVerified: profile.email_verified, + } + if (profile.picture) createData.avatar = profile.picture + user = await this.userRepo.create(createData) + } else { + foundUser.googleId = profile.id + if (profile.picture) foundUser.avatar = profile.picture + await foundUser.save() + user = foundUser + } + } + + const primaryRole = user.roles[0] || 'user' + const permissions = await this.rbacService.getPermissionsForRole(primaryRole) + + const accessToken = this.tokenService.generateAccessToken({ + userId: user._id.toString(), + role: primaryRole, + permissions, + }) + const { refreshToken } = await this.tokenService.generateRefreshToken(user._id.toString()) + + return { user, accessToken, refreshToken } + } + + async handleGitHubCallback( + code: string, + existingUserId?: string + ): Promise<{ + user: IUser + accessToken: string + refreshToken: string + }> { + let providerToken: string + let profile: Awaited> + try { + const tokens = await this.githubProvider.getTokens(code) + providerToken = tokens.accessToken + profile = await this.githubProvider.getUserProfile(providerToken) + } catch (err: any) { + const msg = + err?.response?.data?.error_description || + err?.response?.data?.error || + err?.message || + 'GitHub token exchange failed' + throw new AppError(msg, 400) + } + + if (!profile.email) { + throw new AppError( + 'Email address not provided by GitHub. Please make your GitHub email public.', + 400 + ) + } + + let user: IUser + + // If already logged in, link to that user directly + if (existingUserId) { + const foundUser = await this.userRepo.findById(existingUserId) + if (!foundUser) throw new AppError('Authenticated user not found', 404) + foundUser.githubId = profile.id.toString() + if (profile.avatar_url && !foundUser.avatar) foundUser.avatar = profile.avatar_url + await foundUser.save() + user = foundUser + } else { + const foundUser = await this.userRepo.findByEmail(profile.email) + if (!foundUser) { + const createData: Partial = { + email: profile.email, + name: profile.name || 'GitHub User', + githubId: profile.id.toString(), + roles: ['user'], + isActive: true, + emailVerified: true, + } + if (profile.avatar_url) createData.avatar = profile.avatar_url + user = await this.userRepo.create(createData) + } else { + foundUser.githubId = profile.id.toString() + if (profile.avatar_url) foundUser.avatar = profile.avatar_url + await foundUser.save() + user = foundUser + } + } + + const primaryRole = user.roles[0] || 'user' + const permissions = await this.rbacService.getPermissionsForRole(primaryRole) + + const accessToken = this.tokenService.generateAccessToken({ + userId: user._id.toString(), + role: primaryRole, + permissions, + }) + const { refreshToken } = await this.tokenService.generateRefreshToken(user._id.toString()) + + return { user, accessToken, refreshToken } + } +} diff --git a/apps/api/src/modules/oauth/oauth.types.ts b/apps/api/src/modules/oauth/oauth.types.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/oauth/providers/github.provider.ts b/apps/api/src/modules/oauth/providers/github.provider.ts new file mode 100644 index 0000000..e05c1ed --- /dev/null +++ b/apps/api/src/modules/oauth/providers/github.provider.ts @@ -0,0 +1,46 @@ +import axios from 'axios' +import { env } from '@/config/env' + +export interface GitHubProfile { + id: number + email: string | null + name: string + avatar_url?: string +} + +export class GitHubProvider { + async getTokens(code: string): Promise<{ accessToken: string }> { + const response = await axios.post<{ access_token: string }>( + 'https://github.com/login/oauth/access_token', + { + code, + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + redirect_uri: env.GITHUB_CALLBACK_URL, + }, + { + headers: { Accept: 'application/json' }, + } + ) + return { accessToken: response.data.access_token } + } + + async getUserProfile(accessToken: string): Promise { + const userResponse = await axios.get('https://api.github.com/user', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + const profile = userResponse.data + if (!profile.email) { + const emailsResponse = await axios.get< + Array<{ email: string; primary: boolean; verified: boolean }> + >('https://api.github.com/user/emails', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + const primaryEmail = emailsResponse.data.find((e) => e.primary) || emailsResponse.data[0] + profile.email = primaryEmail ? primaryEmail.email : null + } + + return profile + } +} diff --git a/apps/api/src/modules/oauth/providers/google.provider.ts b/apps/api/src/modules/oauth/providers/google.provider.ts new file mode 100644 index 0000000..f701f59 --- /dev/null +++ b/apps/api/src/modules/oauth/providers/google.provider.ts @@ -0,0 +1,37 @@ +import axios from 'axios' +import { env } from '@/config/env' + +export interface GoogleProfile { + id: string + email: string + name: string + picture?: string + email_verified: boolean +} + +export class GoogleProvider { + async getTokens(code: string, codeVerifier: string): Promise<{ accessToken: string }> { + const response = await axios.post<{ access_token: string }>( + 'https://oauth2.googleapis.com/token', + { + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: env.GOOGLE_CALLBACK_URL, + grant_type: 'authorization_code', + code_verifier: codeVerifier, + } + ) + return { accessToken: response.data.access_token } + } + + async getUserProfile(accessToken: string): Promise { + const response = await axios.get( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + return response.data + } +} diff --git a/apps/api/src/modules/rbac/permission.model.ts b/apps/api/src/modules/rbac/permission.model.ts new file mode 100644 index 0000000..57855a0 --- /dev/null +++ b/apps/api/src/modules/rbac/permission.model.ts @@ -0,0 +1,18 @@ +import mongoose, { Schema, Document } from 'mongoose' + +export interface IPermission extends Document { + resource: string + action: string + scope?: string +} + +const PermissionSchema = new Schema({ + resource: { type: String, required: true }, + action: { type: String, required: true }, + scope: { type: String }, +}) + +// Compound unique index +PermissionSchema.index({ resource: 1, action: 1, scope: 1 }, { unique: true }) + +export const PermissionModel = mongoose.model('Permission', PermissionSchema) diff --git a/apps/api/src/modules/rbac/rbac.repository.ts b/apps/api/src/modules/rbac/rbac.repository.ts new file mode 100644 index 0000000..d9934cd --- /dev/null +++ b/apps/api/src/modules/rbac/rbac.repository.ts @@ -0,0 +1,24 @@ +import { RoleModel, IRole } from './role.model' +import { UserRole, PermissionString } from '@tokenforge/types' + +export class RbacRepository { + async findRoleByName(name: UserRole): Promise { + return RoleModel.findOne({ name }) + } + + async findPermissionsForRole(roleName: UserRole): Promise { + const role = await RoleModel.findOne({ name: roleName }) + return role ? role.permissions : [] + } + + async createRole(role: Partial): Promise { + return RoleModel.create(role) + } + + async updateRolePermissions( + name: UserRole, + permissions: PermissionString[] + ): Promise { + return RoleModel.findOneAndUpdate({ name }, { permissions }, { new: true }) + } +} diff --git a/apps/api/src/modules/rbac/rbac.routes.ts b/apps/api/src/modules/rbac/rbac.routes.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/rbac/rbac.seed.ts b/apps/api/src/modules/rbac/rbac.seed.ts new file mode 100644 index 0000000..01ac962 --- /dev/null +++ b/apps/api/src/modules/rbac/rbac.seed.ts @@ -0,0 +1,38 @@ +import { RoleModel } from './role.model' +import { UserRole, PermissionString } from '@tokenforge/types' +import { logger } from '@/shared/logger' + +const DEFAULT_ROLES: Array<{ name: UserRole; permissions: PermissionString[] }> = [ + { + name: 'admin', + permissions: [], // Admins bypass all checks implicitly + }, + { + name: 'moderator', + permissions: ['users:read', 'audit:read'], + }, + { + name: 'user', + permissions: ['profile:read:own', 'profile:write:own', 'profile:delete:own'], + }, + { + name: 'guest', + permissions: ['profile:read:own'], + }, +] + +export async function seedRbac(): Promise { + logger.info('Starting RBAC seeding...') + + for (const roleDef of DEFAULT_ROLES) { + await RoleModel.updateOne( + { name: roleDef.name }, + { $set: { permissions: roleDef.permissions } }, + { upsert: true } + ) + logger.debug(`Seeded role: ${roleDef.name}`) + } + + logger.info('RBAC seeding completed successfully.') +} +export { DEFAULT_ROLES } diff --git a/apps/api/src/modules/rbac/rbac.service.ts b/apps/api/src/modules/rbac/rbac.service.ts new file mode 100644 index 0000000..f7a0994 --- /dev/null +++ b/apps/api/src/modules/rbac/rbac.service.ts @@ -0,0 +1,16 @@ +import { RbacRepository } from './rbac.repository' +import { UserRole, PermissionString } from '@tokenforge/types' + +export class RbacService { + constructor(private readonly rbacRepo: RbacRepository) {} + + async getPermissionsForRole(role: UserRole): Promise { + return this.rbacRepo.findPermissionsForRole(role) + } + + async hasPermission(role: UserRole, permission: PermissionString): Promise { + if (role === 'admin') return true + const perms = await this.getPermissionsForRole(role) + return perms.includes(permission) + } +} diff --git a/apps/api/src/modules/rbac/rbac.types.ts b/apps/api/src/modules/rbac/rbac.types.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/rbac/role.model.ts b/apps/api/src/modules/rbac/role.model.ts new file mode 100644 index 0000000..514fad4 --- /dev/null +++ b/apps/api/src/modules/rbac/role.model.ts @@ -0,0 +1,19 @@ +import mongoose, { Schema, Document } from 'mongoose' +import { UserRole, PermissionString } from '@tokenforge/types' + +export interface IRole extends Document { + name: UserRole + permissions: PermissionString[] +} + +const RoleSchema = new Schema({ + name: { + type: String, + required: true, + unique: true, + enum: ['admin', 'moderator', 'user', 'guest'], + }, + permissions: { type: [String], default: [] }, +}) + +export const RoleModel = mongoose.model('Role', RoleSchema) diff --git a/apps/api/src/modules/support/support.controller.ts b/apps/api/src/modules/support/support.controller.ts new file mode 100644 index 0000000..18be14a --- /dev/null +++ b/apps/api/src/modules/support/support.controller.ts @@ -0,0 +1,17 @@ +import { Request, Response, NextFunction } from 'express' +import { SupportService } from './support.service' +import { success } from '@/shared/response' + +export class SupportController { + constructor(private readonly supportService: SupportService) {} + + submitContact = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { name, email, message } = req.body + await this.supportService.sendContactEmail(name, email, message) + success(res, { message: 'Support ticket successfully submitted.' }) + } catch (err) { + next(err) + } + } +} diff --git a/apps/api/src/modules/support/support.routes.ts b/apps/api/src/modules/support/support.routes.ts new file mode 100644 index 0000000..1fd55e8 --- /dev/null +++ b/apps/api/src/modules/support/support.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express' +import { SupportController } from './support.controller' +import { SupportService } from './support.service' +import { validate } from '@/middleware/validate.middleware' +import { contactSchema } from './support.validator' + +export const supportRouter = Router() + +const supportService = new SupportService() +const supportController = new SupportController(supportService) + +supportRouter.post('/contact', validate(contactSchema), supportController.submitContact) diff --git a/apps/api/src/modules/support/support.service.ts b/apps/api/src/modules/support/support.service.ts new file mode 100644 index 0000000..2e4ab3c --- /dev/null +++ b/apps/api/src/modules/support/support.service.ts @@ -0,0 +1,76 @@ +import axios from 'axios' +import { env } from '@/config/env' +import { logger } from '@/shared/logger' + +export class SupportService { + /** + * Fetches an access token from Google OAuth2 server using the refresh token. + */ + private async getAccessToken(): Promise { + try { + const response = await axios.post('https://oauth2.googleapis.com/token', { + client_id: env.GOOGLE_CLIENT_ID_MAIL, + client_secret: env.GOOGLE_CLIENT_SECRET_MAIL, + refresh_token: env.GMAIL_REFRESH_TOKEN_MAIL, + grant_type: 'refresh_token', + }) + return response.data.access_token + } catch (err: any) { + logger.error('Failed to retrieve OAuth2 access token for Gmail', { + error: err.response?.data || err.message, + }) + throw new Error('Support system email integration authorization failure', { cause: err }) + } + } + + /** + * Sends an email via Google Gmail HTTP REST API. + * Target recipient: devbridgeenquirz@gmail.com + */ + async sendContactEmail(name: string, email: string, message: string): Promise { + const accessToken = await this.getAccessToken() + + const recipient = 'devbridgeenquirz@gmail.com' + const subject = `Support Enquiry from ${name}` + + // Construct Raw RFC 2822 Message format + const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=` + const messageParts = [ + `From: TokenForge Support <${recipient}>`, + `To: ${recipient}`, + `Reply-To: ${email}`, + `Subject: ${utf8Subject}`, + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset=utf-8', + '', + `
`, + `

New Support Contact Enquiry

`, + `

Name: ${name}

`, + `

Email Address: ${email}

`, + `

Message:

`, + `
${message}
`, + `
`, + ] + + const rawMessage = Buffer.from(messageParts.join('\r\n')).toString('base64url') + + try { + await axios.post( + 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send', + { raw: rawMessage }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + logger.info('Support email successfully sent via Gmail HTTPS OAuth REST API', { name, email }) + } catch (err: any) { + logger.error('Gmail HTTP API message delivery failure', { + error: err.response?.data || err.message, + }) + throw new Error('Failed to send contact support message', { cause: err }) + } + } +} diff --git a/apps/api/src/modules/support/support.validator.ts b/apps/api/src/modules/support/support.validator.ts new file mode 100644 index 0000000..a46c951 --- /dev/null +++ b/apps/api/src/modules/support/support.validator.ts @@ -0,0 +1,7 @@ +import Joi from 'joi' + +export const contactSchema = Joi.object({ + name: Joi.string().min(2).max(100).required().trim(), + email: Joi.string().email().required().trim(), + message: Joi.string().min(10).max(1000).required().trim(), +}) diff --git a/apps/api/src/modules/token/token.repository.ts b/apps/api/src/modules/token/token.repository.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/token/token.service.ts b/apps/api/src/modules/token/token.service.ts new file mode 100644 index 0000000..9a989ad --- /dev/null +++ b/apps/api/src/modules/token/token.service.ts @@ -0,0 +1,162 @@ +import jwt from 'jsonwebtoken' +import { randomUUID } from 'crypto' +import type { Redis } from 'ioredis' +import { getKeys } from '@/config/keys' +import { REDIS_KEYS, TOKEN_CONFIG } from '@/shared/constants' +import { AuthError } from '@/shared/errors' +import type { JwtPayload, RefreshTokenMeta, UserRole, PermissionString } from '@tokenforge/types' + +interface GenerateATOptions { + userId: string + role: UserRole + permissions: PermissionString[] +} + +export class TokenService { + constructor( + private readonly redis: Redis, + private readonly fetchUserClaims?: (userId: string) => Promise<{ + role: UserRole + permissions: PermissionString[] + }> + ) {} + + // ── Access Token ─────────────────────────────────────────────────── + + generateAccessToken(opts: GenerateATOptions): string { + const { privateKey } = getKeys() + const payload: Omit = { + sub: opts.userId, + jti: randomUUID(), // Unique per token — enables blacklisting + role: opts.role, + permissions: opts.permissions, + } + return jwt.sign(payload, privateKey, { + algorithm: 'RS256', + expiresIn: TOKEN_CONFIG.ACCESS_EXPIRY_SECONDS, + }) + } + + verifyAccessToken(token: string): JwtPayload { + const { publicKey } = getKeys() + return jwt.verify(token, publicKey, { + algorithms: ['RS256'], // Explicit whitelist — never allow 'none' + }) as JwtPayload + } + + // ── Refresh Token ────────────────────────────────────────────────── + + async generateRefreshToken( + userId: string, + familyId?: string + ): Promise<{ + refreshToken: string + familyId: string + }> { + const token = randomUUID() + const fId = familyId ?? randomUUID() // New family on fresh login + + const meta: RefreshTokenMeta = { userId, familyId: fId } + + // Atomic pipeline — set token + family reference in one round-trip + await this.redis + .pipeline() + .set( + REDIS_KEYS.refreshToken(token), + JSON.stringify(meta), + 'EX', + TOKEN_CONFIG.REFRESH_EXPIRY_SECONDS + ) + .set(REDIS_KEYS.tokenFamily(fId), token, 'EX', TOKEN_CONFIG.REFRESH_EXPIRY_SECONDS) + .exec() + + return { refreshToken: token, familyId: fId } + } + + async rotateRefreshToken(incomingToken: string): Promise<{ + newAccessToken: string + newRefreshToken: string + userId: string + role: UserRole + permissions: PermissionString[] + }> { + // Step 1: Fetch token metadata + const raw = await this.redis.get(REDIS_KEYS.refreshToken(incomingToken)) + if (!raw) throw new AuthError('Refresh token invalid or expired') + + const meta: RefreshTokenMeta = JSON.parse(raw) as RefreshTokenMeta + const { userId, familyId } = meta + + // Step 2: Check token family — detect reuse + const currentFamilyToken = await this.redis.get(REDIS_KEYS.tokenFamily(familyId)) + if (currentFamilyToken !== incomingToken) { + // REUSE ATTACK — revoke entire family atomically via SCAN + pipeline + await this.revokeFamilyTokens(familyId) + throw new AuthError('Token reuse detected. All sessions terminated.') + } + + // Step 3: Fetch user role + permissions for new AT + if (!this.fetchUserClaims) { + throw new Error('fetchUserClaims handler must be injected') + } + const { role, permissions } = await this.fetchUserClaims(userId) + + // Step 4: Issue new tokens + blacklist old AT jti (caller provides jti) + const newAT = this.generateAccessToken({ userId, role, permissions }) + const { refreshToken: newRT } = await this.generateRefreshToken(userId, familyId) + + // Step 5: Delete old refresh token atomically + await this.redis.del(REDIS_KEYS.refreshToken(incomingToken)) + + return { newAccessToken: newAT, newRefreshToken: newRT, userId, role, permissions } + } + + async revokeRefreshToken(token: string): Promise { + const raw = await this.redis.get(REDIS_KEYS.refreshToken(token)) + if (!raw) return // Already expired or never existed — idempotent + + const { familyId }: RefreshTokenMeta = JSON.parse(raw) as RefreshTokenMeta + + await this.redis + .pipeline() + .del(REDIS_KEYS.refreshToken(token)) + .del(REDIS_KEYS.tokenFamily(familyId)) + .exec() + } + + async blacklistAccessToken(jti: string): Promise { + // TTL matches AT expiry — key auto-expires when AT would have anyway + await this.redis.set( + REDIS_KEYS.atBlacklist(jti), + '1', + 'EX', + TOKEN_CONFIG.AT_BLACKLIST_EXPIRY_SECONDS + ) + } + + // ── Private helpers ──────────────────────────────────────────────── + + private async revokeFamilyTokens(familyId: string): Promise { + // Non-blocking SCAN — never use KEYS in production (blocks Redis event loop) + let cursor = '0' + const pipeline = this.redis.pipeline() + + do { + const [next, keys] = await this.redis.scan(cursor, 'MATCH', `refresh:*`, 'COUNT', 100) + cursor = next + + // Filter to this family by fetching meta (only way without secondary index) + for (const key of keys) { + const raw = await this.redis.get(key) + if (!raw) continue + const m: RefreshTokenMeta = JSON.parse(raw) as RefreshTokenMeta + if (m.familyId === familyId) { + pipeline.del(key) + } + } + } while (cursor !== '0') + + pipeline.del(REDIS_KEYS.tokenFamily(familyId)) + await pipeline.exec() + } +} diff --git a/apps/api/src/modules/token/token.types.ts b/apps/api/src/modules/token/token.types.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/users/user.controller.ts b/apps/api/src/modules/users/user.controller.ts new file mode 100644 index 0000000..d7b7bb3 --- /dev/null +++ b/apps/api/src/modules/users/user.controller.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from 'express' +import { UserService } from './user.service' +import { success } from '@/shared/response' +import { AppError } from '@/shared/errors' +import { AuditService } from '../audit/audit.service' +import { AuditEvent } from '@/shared/constants' + +export class UserController { + constructor( + private readonly userService: UserService, + private readonly auditService: AuditService + ) {} + + getMe = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new AppError('Not authenticated', 401) + } + const user = await this.userService.getUserById(req.user.sub) + success(res, user) + } catch (err) { + next(err) + } + } + + updateMe = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new AppError('Not authenticated', 401) + } + const { name, avatar, password, oldPassword } = req.body + const user = await this.userService.updateProfile(req.user.sub, { + name, + avatar, + password, + oldPassword, + }) + + await this.auditService.log({ + userId: req.user.sub, + event: password ? AuditEvent.PASSWORD_CHANGED : AuditEvent.PROFILE_UPDATED, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + }) + + success(res, user) + } catch (err) { + next(err) + } + } + + deleteMe = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new AppError('Not authenticated', 401) + } + await this.userService.deleteAccount(req.user.sub) + + await this.auditService.log({ + userId: req.user.sub, + event: AuditEvent.ACCOUNT_DELETED, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + }) + + res.clearCookie('refreshToken') + success(res, { message: 'Account deleted successfully' }) + } catch (err) { + next(err) + } + } + + unlinkProvider = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new AppError('Not authenticated', 401) + } + const { provider } = req.params + if (provider !== 'google' && provider !== 'github') { + throw new AppError('Invalid provider. Must be google or github.', 400) + } + const user = await this.userService.unlinkProvider(req.user.sub, provider) + + await this.auditService.log({ + userId: req.user.sub, + event: AuditEvent.PROFILE_UPDATED, + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + requestId: req.headers['x-request-id'] as string, + metadata: { action: 'oauth_unlink', provider }, + }) + + success(res, user) + } catch (err) { + next(err) + } + } +} diff --git a/apps/api/src/modules/users/user.model.ts b/apps/api/src/modules/users/user.model.ts new file mode 100644 index 0000000..3e23ff7 --- /dev/null +++ b/apps/api/src/modules/users/user.model.ts @@ -0,0 +1,58 @@ +import mongoose, { Schema, Document } from 'mongoose' +import type { UserRole } from '@tokenforge/types' + +export interface IUser extends Document { + name: string + email: string + passwordHash?: string // Undefined for OAuth-only accounts + googleId?: string + githubId?: string + avatar?: string + roles: UserRole[] + isActive: boolean + emailVerified: boolean + createdAt: Date + updatedAt: Date +} + +const UserSchema = new Schema( + { + name: { type: String, required: true, trim: true, maxlength: 100 }, + email: { type: String, required: true, lowercase: true, trim: true }, + passwordHash: { type: String, select: false }, // Never returned in queries by default + googleId: { type: String }, + githubId: { type: String }, + avatar: { type: String }, + roles: { type: [String], default: ['user'], enum: ['admin', 'moderator', 'user', 'guest'] }, + isActive: { type: Boolean, default: true }, + emailVerified: { type: Boolean, default: false }, + }, + { + timestamps: true, + // Never return passwordHash in JSON responses + toJSON: { + virtuals: true, + transform: (_doc, ret: Record) => { + ret.id = ret._id.toString() + ret.role = ret.roles?.[0] || 'user' + // Build linkedProviders so the frontend can show which OAuth accounts are connected + const linked: string[] = [] + if (ret.googleId) linked.push('google') + if (ret.githubId) linked.push('github') + ret.linkedProviders = linked + delete ret._id + delete ret.passwordHash + delete ret.__v + return ret + }, + }, + } +) + +// ── Indexes ──────────────────────────────────────────────────────────── +UserSchema.index({ email: 1 }, { unique: true }) +UserSchema.index({ googleId: 1 }, { sparse: true }) // sparse: null values not indexed +UserSchema.index({ githubId: 1 }, { sparse: true }) +UserSchema.index({ createdAt: -1 }) // Descending for admin user list + +export const UserModel = mongoose.model('User', UserSchema) diff --git a/apps/api/src/modules/users/user.repository.ts b/apps/api/src/modules/users/user.repository.ts new file mode 100644 index 0000000..5639562 --- /dev/null +++ b/apps/api/src/modules/users/user.repository.ts @@ -0,0 +1,40 @@ +import { UserModel, IUser } from './user.model' + +export class UserRepository { + async findById(id: string): Promise { + return UserModel.findById(id) + } + + async findByIdWithPassword(id: string): Promise { + return UserModel.findById(id).select('+passwordHash') + } + + async findByEmail(email: string): Promise { + return UserModel.findOne({ email: { $eq: email } }) + } + + async findByEmailWithPassword(email: string): Promise { + return UserModel.findOne({ email: { $eq: email } }).select('+passwordHash') + } + + async create(user: Partial): Promise { + return UserModel.create(user) + } + + async update(id: string, update: Partial): Promise { + return UserModel.findByIdAndUpdate(id, update, { new: true }) + } + + async delete(id: string): Promise { + return UserModel.findByIdAndDelete(id) + } + + async findAllPaginated(page: number, limit: number): Promise<{ users: IUser[]; total: number }> { + const skip = (page - 1) * limit + const [users, total] = await Promise.all([ + UserModel.find().sort({ createdAt: -1 }).skip(skip).limit(limit), + UserModel.countDocuments(), + ]) + return { users, total } + } +} diff --git a/apps/api/src/modules/users/user.routes.ts b/apps/api/src/modules/users/user.routes.ts new file mode 100644 index 0000000..84a50ed --- /dev/null +++ b/apps/api/src/modules/users/user.routes.ts @@ -0,0 +1,57 @@ +import { Router } from 'express' +import rateLimit from 'express-rate-limit' +import { UserController } from './user.controller' +import { UserService } from './user.service' +import { userRepo, auditService } from '../auth/auth.routes' +import { requireAuth } from '@/middleware/auth.middleware' +import { requirePermission } from '@/middleware/rbac.middleware' +import { validate } from '@/middleware/validate.middleware' +import Joi from 'joi' + +export const usersRouter = Router() + +const usersRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, +}) + +const userService = new UserService(userRepo) +const userController = new UserController(userService, auditService) + +const updateMeSchema = Joi.object({ + name: Joi.string().min(2).max(100).optional().trim(), + // Allow regular URLs and base64 data URIs (for avatar uploads) + avatar: Joi.string() + .optional() + .allow('') + .custom((value, helpers) => { + if (!value) return value + const isDataUri = /^data:image\/(png|jpeg|jpg|webp);base64,/.test(value) + const isUrl = /^https?:\/\/.+/.test(value) + if (!isDataUri && !isUrl) { + return helpers.error('any.invalid') + } + return value + }, 'avatar URL or data URI'), + password: Joi.string().min(8).optional(), + oldPassword: Joi.string().optional(), +}) + +usersRouter.use(usersRateLimiter) +usersRouter.use(requireAuth) + +usersRouter.get('/me', requirePermission('profile:read:own'), userController.getMe) +usersRouter.patch( + '/me', + requirePermission('profile:write:own'), + validate(updateMeSchema), + userController.updateMe +) +usersRouter.delete('/me', requirePermission('profile:delete:own'), userController.deleteMe) +usersRouter.delete( + '/me/providers/:provider', + requirePermission('profile:write:own'), + userController.unlinkProvider +) diff --git a/apps/api/src/modules/users/user.schema.ts b/apps/api/src/modules/users/user.schema.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/users/user.service.ts b/apps/api/src/modules/users/user.service.ts new file mode 100644 index 0000000..e301ee7 --- /dev/null +++ b/apps/api/src/modules/users/user.service.ts @@ -0,0 +1,85 @@ +import { UserRepository } from './user.repository' +import { IUser, UserModel } from './user.model' +import { AppError, ConflictError } from '@/shared/errors' + +export class UserService { + constructor(private readonly userRepo: UserRepository) {} + + async getUserById(id: string): Promise { + const user = await this.userRepo.findById(id) + if (!user) { + throw new AppError('User not found', 404) + } + return user + } + + async updateProfile( + id: string, + updateData: { name?: string; avatar?: string; password?: string; oldPassword?: string } + ): Promise { + const dataToUpdate: any = { ...updateData } + delete dataToUpdate.oldPassword + + if (updateData.password) { + const bcrypt = await import('bcryptjs') + const existingUser = await this.userRepo.findByIdWithPassword(id) + if (!existingUser) { + throw new AppError('User not found', 404) + } + + // If user has a password set, strictly validate the oldPassword + if (existingUser.passwordHash) { + if (!updateData.oldPassword) { + throw new AppError('Old password is required to update password', 400) + } + const matches = await bcrypt.default.compare( + updateData.oldPassword, + existingUser.passwordHash + ) + if (!matches) { + throw new AppError('Incorrect old password', 400) + } + } + + dataToUpdate.passwordHash = await bcrypt.default.hash(updateData.password, 12) + delete dataToUpdate.password + } + const user = await this.userRepo.update(id, dataToUpdate) + if (!user) { + throw new AppError('User not found', 404) + } + return user + } + + async deleteAccount(id: string): Promise { + const user = await this.userRepo.delete(id) + if (!user) { + throw new AppError('User not found', 404) + } + } + + async unlinkProvider(userId: string, provider: 'google' | 'github'): Promise { + const user = await this.userRepo.findByIdWithPassword(userId) + if (!user) throw new AppError('User not found', 404) + + const hasPassword = !!user.passwordHash + const hasGoogle = !!user.googleId + const hasGithub = !!user.githubId + const loginMethodCount = [hasPassword, hasGoogle, hasGithub].filter(Boolean).length + + if (loginMethodCount <= 1) { + throw new ConflictError( + 'Cannot disconnect your only login method. Set a password first, or connect another OAuth provider.' + ) + } + + const field = provider === 'google' ? 'googleId' : 'githubId' + const updated = await UserModel.findByIdAndUpdate( + userId, + { $unset: { [field]: 1 } }, + { new: true } + ) + if (!updated) throw new AppError('User not found', 404) + return updated + } +} diff --git a/apps/api/src/modules/users/user.types.ts b/apps/api/src/modules/users/user.types.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..897952c --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,195 @@ +import path from 'path' +import * as moduleAlias from 'module-alias' + +// Register aliases programmatically to ensure robust path resolution in monorepos +moduleAlias.addAlias('@', path.resolve(__dirname, '.')) + +import 'dotenv/config' +import express from 'express' +import helmet from 'helmet' +import cors from 'cors' +import cookieParser from 'cookie-parser' +import morgan from 'morgan' +import mongoose from 'mongoose' +import Redis from 'ioredis' +import * as Sentry from '@sentry/node' + +import { env } from '@/config/env' +import { connectDB } from '@/config/db' +import { connectRedis } from '@/config/redis' +import { logger } from '@/shared/logger' +import { errorHandler } from '@/middleware/errorHandler.middleware' +import { requestIdMiddleware } from '@/middleware/requestId.middleware' +import { generalRateLimiter, loginRateLimiter } from '@/middleware/rateLimiter.middleware' +import { sanitize } from '@/middleware/sanitize.middleware' + +import { authRouter } from '@/modules/auth/auth.routes' +import { oauthRouter } from '@/modules/oauth/oauth.routes' +import { usersRouter } from '@/modules/users/user.routes' +import { adminRouter } from '@/modules/admin/admin.routes' +import { supportRouter } from '@/modules/support/support.routes' +import { seedRbac } from '@/modules/rbac/rbac.seed' + +// ── Sentry (must init before express) ────────────────────────────── +if (env.SENTRY_DSN) { + Sentry.init({ + dsn: env.SENTRY_DSN, + environment: env.NODE_ENV, + tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0, + }) +} + +// ── App ───────────────────────────────────────────────────────────── +export const app = express() + +// Trust Railway's reverse proxy — required for real IP in rate limiter +app.set('trust proxy', env.TRUST_PROXY) + +// ── Security middleware ────────────────────────────────────────────── +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline styles + imgSrc: ["'self'", 'data:', 'https:'], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + frameSrc: ["'none'"], + }, + }, + hsts: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true, + }, + }) +) + +app.use( + cors({ + origin: env.CLIENT_URL, // Exact origin — no wildcard + credentials: true, // Required for httpOnly cookie exchange + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], + exposedHeaders: ['X-Request-ID'], + }) +) + +// ── Request parsing ────────────────────────────────────────────────── +app.use(express.json({ limit: '5mb' })) // Limit body size — prevents large payload attacks +app.use(express.urlencoded({ extended: false, limit: '5mb' })) +app.use(cookieParser(env.COOKIE_SECRET)) // Signed cookie support + +// ── Observability ──────────────────────────────────────────────────── +app.use(requestIdMiddleware) // x-request-id on every request +app.use( + morgan('combined', { + stream: { write: (msg) => logger.http(msg.trim()) }, + skip: (req) => req.url === '/api/v1/health', // Don't log health check spam + }) +) + +// ── Input sanitization ─────────────────────────────────────────────── +app.use(sanitize) // mongo-sanitize: strip $ operators + +// ── Health check (no auth, rate-limited) ──────────────────────────── +app.get('/api/v1/health', generalRateLimiter, async (_req, res) => { + const mongoOk = mongoose.connection.readyState === 1 + const redis = app.get('redis') as Redis + let redisOk = false + try { + redisOk = (await redis.ping()) === 'PONG' + } catch { + /* Redis unreachable */ + } + + const healthy = mongoOk && redisOk + res.status(healthy ? 200 : 503).json({ + status: healthy ? 'healthy' : 'degraded', + timestamp: new Date().toISOString(), + uptime: Math.floor(process.uptime()), + services: { + mongodb: mongoOk ? 'up' : 'down', + redis: redisOk ? 'up' : 'down', + }, + }) +}) + +// ── API routes (versioned) ─────────────────────────────────────────── +app.use('/api/v1/auth', loginRateLimiter, authRouter) +app.use('/api/v1/oauth', oauthRouter) +app.use('/api/v1/users', usersRouter) +app.use('/api/v1/admin', adminRouter) +app.use('/api/v1/support', supportRouter) + +// ── 404 handler ────────────────────────────────────────────────────── +app.use((_req, res) => { + res.status(404).json({ status: 'error', statusCode: 404, message: 'Route not found' }) +}) + +// ── Global error handler (must be last) ───────────────────────────── +app.use(errorHandler) + +// ── Bootstrap ─────────────────────────────────────────────────────── +async function bootstrap(): Promise { + await connectDB() + await seedRbac() + const redis = await connectRedis() + app.set('redis', redis) // Attach to app for health check + middleware access + + // Swagger UI (development + staging only) + if (env.NODE_ENV !== 'production') { + const { setupSwagger } = await import('./config/swagger.js') + setupSwagger(app) + } + + const server = app.listen(env.PORT, () => { + logger.info(`TokenForge API running on port ${env.PORT}`, { + env: env.NODE_ENV, + pid: process.pid, + }) + }) + + // ── Graceful shutdown ────────────────────────────────────────────── + const shutdown = async (signal: string): Promise => { + logger.info(`${signal} received — starting graceful shutdown`) + + // Stop accepting new connections + server.close(async () => { + try { + await mongoose.connection.close() + logger.info('MongoDB connection closed') + await redis.quit() + logger.info('Redis connection closed') + logger.info('Graceful shutdown complete') + process.exit(0) + } catch (err) { + logger.error('Error during shutdown', { err }) + process.exit(1) + } + }) + + // Force exit after 30s — Railway SIGKILL arrives at 30s anyway + setTimeout(() => { + logger.error('Forced shutdown after 30s timeout') + process.exit(1) + }, 30_000).unref() + } + + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) + + // Unhandled promise rejections — log and exit (never swallow) + process.on('unhandledRejection', (reason) => { + logger.error('Unhandled rejection', { reason }) + process.exit(1) + }) +} + +bootstrap().catch((err) => { + logger.error('Bootstrap failed', { err }) + process.exit(1) +}) diff --git a/apps/api/src/shared/constants.ts b/apps/api/src/shared/constants.ts new file mode 100644 index 0000000..9b77382 --- /dev/null +++ b/apps/api/src/shared/constants.ts @@ -0,0 +1,53 @@ +// Token configuration +export const TOKEN_CONFIG = { + ACCESS_EXPIRY_SECONDS: 15 * 60, // 15 minutes + REFRESH_EXPIRY_SECONDS: 7 * 24 * 60 * 60, // 7 days + OAUTH_STATE_EXPIRY_SECONDS: 10 * 60, // 10 minutes + AT_BLACKLIST_EXPIRY_SECONDS: 15 * 60, // Must match ACCESS_EXPIRY +} as const + +// Cookie options — consistent across all cookie writes +export const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict' as const, + path: '/api/v1/auth', // Scoped — not sent to all routes + maxAge: TOKEN_CONFIG.REFRESH_EXPIRY_SECONDS * 1000, // milliseconds +} as const + +// Redis key prefixes — centralised to avoid typos across services +export const REDIS_KEYS = { + refreshToken: (token: string) => `refresh:${token}`, + tokenFamily: (familyId: string) => `family:${familyId}`, + atBlacklist: (jti: string) => `at:blacklist:${jti}`, + oauthState: (state: string) => `oauth:state:${state}`, + rateLimitLogin: (ip: string) => `ratelimit:login:${ip}`, +} as const + +// Audit event enum — single source of truth for event names +export enum AuditEvent { + REGISTER = 'REGISTER', + LOGIN_SUCCESS = 'LOGIN_SUCCESS', + LOGIN_FAILED = 'LOGIN_FAILED', + LOGOUT = 'LOGOUT', + LOGOUT_ALL = 'LOGOUT_ALL', + TOKEN_REFRESH = 'TOKEN_REFRESH', + REFRESH_REUSE_ATTACK = 'REFRESH_REUSE_ATTACK', + OAUTH_LOGIN = 'OAUTH_LOGIN', + OAUTH_LINK = 'OAUTH_LINK', + ROLE_CHANGED = 'ROLE_CHANGED', + SESSION_REVOKED = 'SESSION_REVOKED', + PASSWORD_CHANGED = 'PASSWORD_CHANGED', + PROFILE_UPDATED = 'PROFILE_UPDATED', + ACCOUNT_DELETED = 'ACCOUNT_DELETED', +} + +// Rate limiter config +export const RATE_LIMIT = { + LOGIN_MAX: 50, + LOGIN_WINDOW_MS: 150 * 60 * 1000, // 15 minutes + REGISTER_MAX: 3, + REGISTER_WINDOW_MS: 60 * 60 * 1000, // 1 hour per IP + API_GENERAL_MAX: 100, + API_GENERAL_WINDOW_MS: 60 * 1000, // 100 req/min for general API +} as const diff --git a/apps/api/src/shared/errors.ts b/apps/api/src/shared/errors.ts new file mode 100644 index 0000000..1fbf551 --- /dev/null +++ b/apps/api/src/shared/errors.ts @@ -0,0 +1,48 @@ +export class AppError extends Error { + public readonly statusCode: number + public readonly isOperational: boolean + + constructor(message: string, statusCode = 500, isOperational = true) { + super(message) + this.statusCode = statusCode + this.isOperational = isOperational + Object.setPrototypeOf(this, new.target.prototype) + Error.captureStackTrace(this, this.constructor) + } +} + +export class AuthError extends AppError { + constructor(message = 'Authentication required') { + super(message, 401) + } +} + +export class ForbiddenError extends AppError { + constructor(message = 'Insufficient permissions') { + super(message, 403) + } +} + +export class NotFoundError extends AppError { + constructor(resource = 'Resource') { + super(`${resource} not found`, 404) + } +} + +export class ConflictError extends AppError { + constructor(message = 'Resource already exists') { + super(message, 409) + } +} + +export class ValidationError extends AppError { + constructor(message: string) { + super(message, 400) + } +} + +export class TooManyRequestsError extends AppError { + constructor(message = 'Too many requests. Please try again later.') { + super(message, 429) + } +} \ No newline at end of file diff --git a/apps/api/src/shared/logger.ts b/apps/api/src/shared/logger.ts new file mode 100644 index 0000000..95b93b9 --- /dev/null +++ b/apps/api/src/shared/logger.ts @@ -0,0 +1,30 @@ +import winston from 'winston' +import { env } from '@/config/env' + +const { combine, timestamp, json, colorize, simple, errors } = winston.format + +// Production: structured JSON — parseable by Railway log viewer + Sentry +// Development: colorised human-readable output +const devFormat = combine(colorize(), simple()) +const prodFormat = combine( + errors({ stack: true }), // Include stack traces in JSON + timestamp(), + json() +) + +export const logger = winston.createLogger({ + level: env.NODE_ENV === 'production' ? 'info' : 'debug', + format: env.NODE_ENV === 'production' ? prodFormat : devFormat, + defaultMeta: { service: 'tokenforge-api' }, + transports: [ + new winston.transports.Console(), + // Production: write errors to persistent file for post-mortem analysis + ...(env.NODE_ENV === 'production' + ? [new winston.transports.File({ filename: 'logs/error.log', level: 'error' })] + : []), + ], +}) + +// Helper: attach requestId to every log within a request context +export const requestLogger = (requestId: string): winston.Logger => + logger.child({ requestId }) \ No newline at end of file diff --git a/apps/api/src/shared/response.ts b/apps/api/src/shared/response.ts new file mode 100644 index 0000000..b60788b --- /dev/null +++ b/apps/api/src/shared/response.ts @@ -0,0 +1,16 @@ +import { Response } from 'express' + +export function success(res: Response, data: unknown, statusCode = 200): void { + res.status(statusCode).json({ + status: 'success', + data, + }) +} + +export function error(res: Response, message: string, statusCode = 500): void { + res.status(statusCode).json({ + status: 'error', + statusCode, + message, + }) +} diff --git a/apps/api/tests/e2e/admin-role-change.spec.ts b/apps/api/tests/e2e/admin-role-change.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/e2e/oauth-google.spec.ts b/apps/api/tests/e2e/oauth-google.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/e2e/register-login.spec.ts b/apps/api/tests/e2e/register-login.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/fixtures/tokens.fixture.ts b/apps/api/tests/fixtures/tokens.fixture.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/fixtures/users.fixture.ts b/apps/api/tests/fixtures/users.fixture.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/integration/admin.test.ts b/apps/api/tests/integration/admin.test.ts new file mode 100644 index 0000000..de1eb6a --- /dev/null +++ b/apps/api/tests/integration/admin.test.ts @@ -0,0 +1,6 @@ +// TODO: Implement admin integration tests +describe('Admin API', () => { + it.todo('GET /admin/users - should list all users') + it.todo('PATCH /admin/users/:id/role - should update user role') + it.todo('DELETE /admin/users/:id/sessions - should revoke user sessions') +}) diff --git a/apps/api/tests/integration/auth.test.ts b/apps/api/tests/integration/auth.test.ts new file mode 100644 index 0000000..19ddecd --- /dev/null +++ b/apps/api/tests/integration/auth.test.ts @@ -0,0 +1,7 @@ +// TODO: Implement auth integration tests +describe('Auth API', () => { + it.todo('POST /auth/register - should register user') + it.todo('POST /auth/login - should login and return tokens') + it.todo('POST /auth/refresh - should rotate refresh token') + it.todo('POST /auth/logout - should revoke session') +}) diff --git a/apps/api/tests/integration/oauth.test.ts b/apps/api/tests/integration/oauth.test.ts new file mode 100644 index 0000000..aff82ef --- /dev/null +++ b/apps/api/tests/integration/oauth.test.ts @@ -0,0 +1,7 @@ +// TODO: Implement OAuth integration tests +describe('OAuth API', () => { + it.todo('GET /oauth/google - should redirect to Google OAuth') + it.todo('GET /oauth/google/callback - should handle Google callback') + it.todo('GET /oauth/github - should redirect to GitHub OAuth') + it.todo('GET /oauth/github/callback - should handle GitHub callback') +}) diff --git a/apps/api/tests/integration/rbac.test.ts b/apps/api/tests/integration/rbac.test.ts new file mode 100644 index 0000000..8f24596 --- /dev/null +++ b/apps/api/tests/integration/rbac.test.ts @@ -0,0 +1,6 @@ +// TODO: Implement RBAC integration tests +describe('RBAC API', () => { + it.todo('GET /rbac/roles - should list all roles') + it.todo('POST /rbac/roles - should create a new role') + it.todo('GET /rbac/permissions - should list all permissions') +}) diff --git a/apps/api/tests/integration/users.test.ts b/apps/api/tests/integration/users.test.ts new file mode 100644 index 0000000..9f76b76 --- /dev/null +++ b/apps/api/tests/integration/users.test.ts @@ -0,0 +1,7 @@ +// TODO: Implement users integration tests +describe('Users API', () => { + it.todo('GET /users/me - should return current user profile') + it.todo('PATCH /users/me - should update current user profile') + it.todo('GET /users/me/sessions - should list active sessions') + it.todo('DELETE /users/me/sessions/:id - should revoke a session') +}) diff --git a/apps/api/tests/unit/audit.service.test.ts b/apps/api/tests/unit/audit.service.test.ts new file mode 100644 index 0000000..03a6215 --- /dev/null +++ b/apps/api/tests/unit/audit.service.test.ts @@ -0,0 +1,4 @@ +// TODO: Implement audit service unit tests +describe('AuditService', () => { + it.todo('should log audit events') +}) diff --git a/apps/api/tests/unit/auth.service.test.ts b/apps/api/tests/unit/auth.service.test.ts new file mode 100644 index 0000000..671cbce --- /dev/null +++ b/apps/api/tests/unit/auth.service.test.ts @@ -0,0 +1,7 @@ +// TODO: Implement auth service unit tests +describe('AuthService', () => { + it.todo('should register a new user') + it.todo('should login with valid credentials') + it.todo('should refresh tokens') + it.todo('should logout and revoke refresh token') +}) diff --git a/apps/api/tests/unit/rbac.service.test.ts b/apps/api/tests/unit/rbac.service.test.ts new file mode 100644 index 0000000..a8e4505 --- /dev/null +++ b/apps/api/tests/unit/rbac.service.test.ts @@ -0,0 +1,6 @@ +// TODO: Implement RBAC service unit tests +describe('RbacService', () => { + it.todo('should create a role') + it.todo('should assign role to user') + it.todo('should check user permissions') +}) diff --git a/apps/api/tests/unit/token.service.test.ts b/apps/api/tests/unit/token.service.test.ts new file mode 100644 index 0000000..8c1630b --- /dev/null +++ b/apps/api/tests/unit/token.service.test.ts @@ -0,0 +1,8 @@ +// TODO: Implement token service unit tests +describe('TokenService', () => { + it.todo('should generate access token') + it.todo('should generate refresh token') + it.todo('should verify access token') + it.todo('should rotate refresh token') + it.todo('should detect refresh token reuse') +}) diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..4d8f614 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "paths": { + "@/*": ["./src/*"] + }, + + /* Strict mode — no exceptions */ + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + + /* Output quality */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": false, + + /* Interop */ + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + + /* Unused code as errors */ + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..f071803 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,65 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + passWithNoTests: true, + setupFiles: ['./tests/fixtures/setup.ts'], + + // Coverage — enforced thresholds block CI if not met + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'lcov', 'html'], + reportsDirectory: './coverage', + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.types.ts', + 'src/**/*.schema.ts', + 'src/server.ts', // bootstrap — tested via integration + 'src/config/**', // env/db/redis — mocked in unit tests + ], + // Thresholds set to 0: all API tests are todo placeholders (no coverage yet). + // Raise these once real tests are implemented. + thresholds: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + }, + + // Separate test pools for unit vs integration + // Run: vitest --project unit | vitest --project integration + projects: [ + { + name: 'unit', + test: { + globals: true, + environment: 'node', + passWithNoTests: true, + include: ['tests/unit/**/*.test.ts'], + }, + }, + { + name: 'integration', + test: { + globals: true, + environment: 'node', + passWithNoTests: true, + include: ['tests/integration/**/*.test.ts'], + pool: 'forks', // Isolate each integration test file + forks: { singleFork: false }, // Vitest 4: was poolOptions.forks + // Sequential — avoids DB state conflicts between files + sequence: { concurrent: false }, + }, + }, + ], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..e985853 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..a6aec23 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,139 @@ + + + + + + + + TokenForge — JWT Auth System Built From Scratch | No Auth0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..2ea7bcf --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,41 @@ +{ + "name": "@tokenforge/web", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:ui": "vitest --ui" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "axios": "^1.18.1", + "eslint-plugin-import": "^2.31.0", + "framer-motion": "12.42.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-form": "^7.56.4", + "react-router-dom": "^7.6.0", + "semantic-release": "25.0.8", + "zod": "^3.24.4", + "zustand": "^5.0.4" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "@vitest/coverage-v8": "4.1.10", + "autoprefixer": "^10.4.21", + "jsdom": "^26.1.0", + "tailwindcss": "^4.1.10", + "typescript": "^5.7.3", + "vite": "^6.3.5", + "vitest": "^4.1.10" + } +} diff --git a/apps/web/public/favicon-128.png b/apps/web/public/favicon-128.png new file mode 100644 index 0000000..b1fa20e Binary files /dev/null and b/apps/web/public/favicon-128.png differ diff --git a/apps/web/public/favicon-16.png b/apps/web/public/favicon-16.png new file mode 100644 index 0000000..68c83c3 Binary files /dev/null and b/apps/web/public/favicon-16.png differ diff --git a/apps/web/public/favicon-180.png b/apps/web/public/favicon-180.png new file mode 100644 index 0000000..4a263c2 Binary files /dev/null and b/apps/web/public/favicon-180.png differ diff --git a/apps/web/public/favicon-192.png b/apps/web/public/favicon-192.png new file mode 100644 index 0000000..f647422 Binary files /dev/null and b/apps/web/public/favicon-192.png differ diff --git a/apps/web/public/favicon-256.png b/apps/web/public/favicon-256.png new file mode 100644 index 0000000..2b79a96 Binary files /dev/null and b/apps/web/public/favicon-256.png differ diff --git a/apps/web/public/favicon-32.png b/apps/web/public/favicon-32.png new file mode 100644 index 0000000..bbd3f9c Binary files /dev/null and b/apps/web/public/favicon-32.png differ diff --git a/apps/web/public/favicon-512.png b/apps/web/public/favicon-512.png new file mode 100644 index 0000000..ef146b4 Binary files /dev/null and b/apps/web/public/favicon-512.png differ diff --git a/apps/web/public/favicon-64.png b/apps/web/public/favicon-64.png new file mode 100644 index 0000000..83e6b50 Binary files /dev/null and b/apps/web/public/favicon-64.png differ diff --git a/apps/web/public/favicon-96.png b/apps/web/public/favicon-96.png new file mode 100644 index 0000000..40d2928 Binary files /dev/null and b/apps/web/public/favicon-96.png differ diff --git a/apps/web/public/footer.svg b/apps/web/public/footer.svg new file mode 100644 index 0000000..5f9d349 --- /dev/null +++ b/apps/web/public/footer.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/logo-1024x1024.png b/apps/web/public/logo-1024x1024.png new file mode 100644 index 0000000..419231f Binary files /dev/null and b/apps/web/public/logo-1024x1024.png differ diff --git a/apps/web/public/navbar-logo-full@2x.svg b/apps/web/public/navbar-logo-full@2x.svg new file mode 100644 index 0000000..940d93b --- /dev/null +++ b/apps/web/public/navbar-logo-full@2x.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + TokenForge + + + + + + + diff --git a/apps/web/public/og-image.png b/apps/web/public/og-image.png new file mode 100644 index 0000000..1e83cc8 Binary files /dev/null and b/apps/web/public/og-image.png differ diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..fd97494 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,8 @@ +User-agent: * +Allow: / +Disallow: /admin +Disallow: /profile +Disallow: /dashboard +Disallow: /oauth/ + +Sitemap: https://tokenforge.dev/sitemap.xml diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml new file mode 100644 index 0000000..b895f18 --- /dev/null +++ b/apps/web/public/sitemap.xml @@ -0,0 +1,27 @@ + + + + https://tokenforge.dev/ + 2026-07-22 + monthly + 1.0 + + + https://tokenforge.dev/login + 2026-07-22 + monthly + 0.8 + + + https://tokenforge.dev/register + 2026-07-22 + monthly + 0.8 + + + https://tokenforge.dev/terms-of-service + 2026-07-22 + yearly + 0.3 + + diff --git a/apps/web/src/components/admin/ActiveSessionsTable.tsx b/apps/web/src/components/admin/ActiveSessionsTable.tsx new file mode 100644 index 0000000..7b03c4a --- /dev/null +++ b/apps/web/src/components/admin/ActiveSessionsTable.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import { adminService } from '../../services/admin.service' +import { Spinner } from '../ui/Spinner' + +export function ActiveSessionsTable() { + const { data: stats, isLoading } = useQuery({ + queryKey: ['adminStats'], + queryFn: adminService.getStats, + }) + + return ( +
+

+ System-wide Active Sessions +

+ {isLoading ? ( +
+ +
+ ) : ( +
+
+
+ + Active Sessions + + + {stats?.activeSessions || 0} + +
+
+ + Total Users + + + {stats?.totalUsers || 0} + +
+
+ + OAuth Users + + + {stats?.oauthUsers || 0} + +
+
+ + Admin Users + + + {stats?.adminUsers || 1} + +
+
+

+ Active sessions represent current valid user refresh token chains stored inside the + Redis database. Revoking a user's sessions immediately invalidates their refresh token + chain and prevents silent token rotations, forcing them to re-authenticate on next + request. +

+
+ )} +
+ ) +} diff --git a/apps/web/src/components/admin/AuditLogTable.tsx b/apps/web/src/components/admin/AuditLogTable.tsx new file mode 100644 index 0000000..4402d87 --- /dev/null +++ b/apps/web/src/components/admin/AuditLogTable.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { adminService } from '../../services/admin.service' +import { Spinner } from '../ui/Spinner' +import { Badge } from '../ui/Badge' +import { Button } from '../ui/Button' + +export function AuditLogTable() { + const [page, setPage] = useState(1) + + const { data, isLoading } = useQuery({ + queryKey: ['adminAuditLogs', page], + queryFn: () => adminService.getAuditLogs(page, 20), + }) + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+ + + + + + + + + + + {data?.logs && data.logs.length > 0 ? ( + data.logs.map((log: any, index: number) => ( + + + + + + + )) + ) : ( + + + + )} + +
EventUser IDIPTime
+ + {log.event} + + {log.userId || 'Guest'}{log.ip} + {new Date(log.createdAt).toLocaleString()} +
+ No logs found. +
+
+ +
+ Total: {data?.total || 0} events +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/admin/RoleManager.tsx b/apps/web/src/components/admin/RoleManager.tsx new file mode 100644 index 0000000..3fdbeba --- /dev/null +++ b/apps/web/src/components/admin/RoleManager.tsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react' +import { Badge } from '../ui/Badge' +import { Button } from '../ui/Button' +import { Modal } from '../ui/Modal' + +interface RoleData { + name: string + color: string + bg: string + permissions: string[] +} + +export function RoleManager() { + const [selectedRole, setSelectedRole] = useState(null) + + const roles: RoleData[] = [ + { + name: 'admin', + color: 'text-[#E0E7FF]', + bg: 'bg-[#3730A3]', + permissions: [ + 'users:read', + 'users:write', + 'users:delete', + 'audit:read', + 'sessions:read', + 'sessions:write', + 'profile:read:own', + 'profile:write:own', + ], + }, + { + name: 'moderator', + color: 'text-[#D1FAE5]', + bg: 'bg-[#065F46]', + permissions: [ + 'users:read', + 'audit:read', + 'sessions:read', + 'profile:read:own', + 'profile:write:own', + ], + }, + { + name: 'user', + color: 'text-[#BAE6FD]', + bg: 'bg-[#1E3A5F]', + permissions: ['profile:read:own', 'profile:write:own'], + }, + { + name: 'guest', + color: 'text-[#E7E5E4]', + bg: 'bg-[#292524]', + permissions: ['profile:read:own'], + }, + ] + + return ( +
+
+ {roles.map((r) => ( +
+
+ + {r.name} + +

+ Authorized with {r.permissions.length} security permissions. +

+
+ + +
+ ))} +
+ + {selectedRole && ( + { + setSelectedRole(null) + }} + title={`Permissions Matrix: ${selectedRole.name}`} + > +
+

+ The following cryptographic permission claims are attached to user sessions holding + this role: +

+ +
+ {selectedRole.permissions.map((perm) => ( + + {perm} + + ))} +
+ +
+ +
+
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/admin/UsersTable.tsx b/apps/web/src/components/admin/UsersTable.tsx new file mode 100644 index 0000000..74df3bf --- /dev/null +++ b/apps/web/src/components/admin/UsersTable.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { adminService } from '../../services/admin.service' +import { Button } from '../ui/Button' +import { Spinner } from '../ui/Spinner' +import { usePermission } from '../../hooks/usePermission' + +export function UsersTable() { + const queryClient = useQueryClient() + const { hasPermission } = usePermission() + const [page, setPage] = useState(1) + + const canWriteRoles = hasPermission('users:write') + const canDeleteSessions = hasPermission('sessions:delete') + + const { data, isLoading } = useQuery({ + queryKey: ['adminUsers', page], + queryFn: () => adminService.getUsers(page, 10), + }) + + const roleMutation = useMutation({ + mutationFn: ({ userId, role }: { userId: string; role: string }) => + adminService.changeRole(userId, role), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['adminUsers'] }), + }) + + const revokeMutation = useMutation({ + mutationFn: (userId: string) => adminService.revokeSession(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['adminUsers'] }) + queryClient.invalidateQueries({ queryKey: ['adminStats'] }) + }, + }) + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+ + + + + + + {canDeleteSessions && } + + + + {data?.users && data.users.length > 0 ? ( + data.users.map((u: any) => ( + + + + + {canDeleteSessions && ( + + )} + + )) + ) : ( + + + + )} + +
NameEmailRoleActions
{u.name}{u.email} + + + +
+ No users found. +
+
+ +
+ Total: {data?.total || 0} users +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/auth/LoginForm.tsx b/apps/web/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..b8ebec3 --- /dev/null +++ b/apps/web/src/components/auth/LoginForm.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react' +import { useForm } from 'react-hook-form' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../../hooks/useAuth' +import { Input } from '../ui/Input' +import { Button } from '../ui/Button' +import { OAuthButtons } from './OAuthButtons' + +export function LoginForm() { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { email: '', password: '', rememberMe: false }, + }) + const { login } = useAuth() + const navigate = useNavigate() + const [errorMsg, setErrorMsg] = useState('') + const [loading, setLoading] = useState(false) + const [showPassword, setShowPassword] = useState(false) + + const onSubmit = async (data: any) => { + setErrorMsg('') + setLoading(true) + try { + await login(data.email, data.password) + navigate('/') + } catch (err: any) { + if (err.response?.status === 429) { + setErrorMsg('Too many attempts. Try again in 60s.') + } else { + setErrorMsg(err.response?.data?.message || 'Invalid credentials.') + } + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+
+ + or + +
+
+
+ +
+ {errorMsg ? ( +
+ {errorMsg} +
+ ) : null} + + + +
+ +
+ +
+ +
+ + +
+
+ ) +} diff --git a/apps/web/src/components/auth/OAuthButtons.tsx b/apps/web/src/components/auth/OAuthButtons.tsx new file mode 100644 index 0000000..17822c4 --- /dev/null +++ b/apps/web/src/components/auth/OAuthButtons.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { api } from '../../services/api' +import { Button } from '../ui/Button' + +export function OAuthButtons() { + const handleOAuth = async (provider: 'google' | 'github') => { + try { + const response = await api.get(`/oauth/${provider}`) + const { url } = response.data.data + window.location.href = url + } catch (err) { + console.error(`${provider} oauth initiation failed:`, err) + } + } + + return ( +
+ + +
+ ) +} diff --git a/apps/web/src/components/auth/PasswordStrengthBar.tsx b/apps/web/src/components/auth/PasswordStrengthBar.tsx new file mode 100644 index 0000000..4b83464 --- /dev/null +++ b/apps/web/src/components/auth/PasswordStrengthBar.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +export function PasswordStrengthBar({ password = '' }: { password?: string }) { + const getStrength = (pass: string) => { + let score = 0 + if (!pass) return score + if (pass.length >= 8) score++ + if (/[A-Z]/.test(pass)) score++ + if (/[0-9]/.test(pass)) score++ + if (/[^A-Za-z0-9]/.test(pass)) score++ + return score + } + + const strength = getStrength(password) + + const colors = [ + 'bg-slate-800', + 'bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.4)]', + 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)]', + 'bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.4)]', + 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)]', + ] + + const labels = ['None', 'Weak', 'Fair', 'Strong', 'Excellent'] + + return ( +
+
+ Password Strength + {labels[strength]} +
+
+ {[1, 2, 3, 4].map((step) => ( +
= step ? colors[strength] : 'bg-slate-800/50' + }`} + /> + ))} +
+
+ ) +} diff --git a/apps/web/src/components/auth/RegisterForm.tsx b/apps/web/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..162e1b7 --- /dev/null +++ b/apps/web/src/components/auth/RegisterForm.tsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react' +import { useForm } from 'react-hook-form' +import { useNavigate, Link } from 'react-router-dom' +import { authService } from '../../services/auth.service' +import { Input } from '../ui/Input' +import { Button } from '../ui/Button' +import { PasswordStrengthBar } from './PasswordStrengthBar' +import { OAuthButtons } from './OAuthButtons' + +export function RegisterForm() { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + defaultValues: { name: '', email: '', password: '', confirmPassword: '', terms: false }, + }) + const navigate = useNavigate() + const [errorMsg, setErrorMsg] = useState('') + const [loading, setLoading] = useState(false) + const [showPassword, setShowPassword] = useState(false) + + const passwordValue = watch('password', '') + + const onSubmit = async (data: any) => { + if (!data.terms) { + setErrorMsg('You must agree to the Terms of Service.') + return + } + if (data.password !== data.confirmPassword) { + setErrorMsg('Passwords do not match.') + return + } + setErrorMsg('') + setLoading(true) + try { + await authService.register(data.email, data.name, data.password) + navigate('/login', { state: { registered: true } }) + } catch (err: any) { + setErrorMsg(err.response?.data?.message || 'Registration failed. Try again.') + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+
+ + or + +
+
+
+ +
+ {errorMsg ? ( +
+ {errorMsg} +
+ ) : null} + + + + + +
+ +
+ + + + + + + + + +
+ ) +} diff --git a/apps/web/src/components/dashboard/RolePermissionsCard.tsx b/apps/web/src/components/dashboard/RolePermissionsCard.tsx new file mode 100644 index 0000000..7293554 --- /dev/null +++ b/apps/web/src/components/dashboard/RolePermissionsCard.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { useAuthStore } from '../../store/authStore' +import { decodeJwt } from '../../utils/jwt.utils' +import { Badge } from '../ui/Badge' + +export function RolePermissionsCard() { + const { accessToken, user } = useAuthStore() + const payload = accessToken ? decodeJwt(accessToken) : null + + return ( +
+

+ Role & Permissions +

+
+
+ Assigned Role: + + {user?.role} + +
+
+ Granted Security Permissions: + {payload?.permissions && payload.permissions.length > 0 ? ( +
+ {payload.permissions.map((perm) => ( + + {perm} + + ))} +
+ ) : user?.role === 'admin' ? ( +
+ ⚡ Admin bypass enabled (wildcard access to all resources) +
+ ) : ( +
+ {['profile:read:own', 'profile:write:own'].map((perm) => ( + + {perm} + + ))} +
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/components/dashboard/SecurityEventsList.tsx b/apps/web/src/components/dashboard/SecurityEventsList.tsx new file mode 100644 index 0000000..bbf9f1a --- /dev/null +++ b/apps/web/src/components/dashboard/SecurityEventsList.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import { adminService } from '../../services/admin.service' +import { usePermission } from '../../hooks/usePermission' +import { Badge } from '../ui/Badge' +import { timeAgo } from '../../utils/time.utils' +import { Spinner } from '../ui/Spinner' + +export function SecurityEventsList() { + const { hasPermission } = usePermission() + const canReadAudit = hasPermission('audit:read') + + const { data, isLoading } = useQuery({ + queryKey: ['securityEvents'], + queryFn: () => adminService.getAuditLogs(1, 5), + enabled: canReadAudit, + }) + + if (!canReadAudit) { + return ( +
+

+ Live Security Audit Logs +

+

+ Insufficient permissions to view system-wide logs. Admin or Moderator privileges are + required. +

+
+ ) + } + + return ( +
+

+ Live Security Audit Logs +

+ {isLoading ? ( +
+ +
+ ) : data?.logs && data.logs.length > 0 ? ( +
+ {data.logs.map((log: any, idx: number) => ( +
+
+ {log.event} + IP: {log.ip} +
+
+ {timeAgo(log.createdAt)} + + {log.event.includes('LOGIN') ? 'Auth' : 'System'} + +
+
+ ))} +
+ ) : ( + No security events found. + )} +
+ ) +} diff --git a/apps/web/src/components/dashboard/SessionCard.tsx b/apps/web/src/components/dashboard/SessionCard.tsx new file mode 100644 index 0000000..2d5f7a6 --- /dev/null +++ b/apps/web/src/components/dashboard/SessionCard.tsx @@ -0,0 +1,168 @@ +import React, { useState } from 'react' +import { useAuthStore } from '../../store/authStore' +import { Button } from '../ui/Button' +import { useAuth } from '../../hooks/useAuth' +import { authService } from '../../services/auth.service' +import { Link } from 'react-router-dom' +import { decodeJwt } from '../../utils/jwt.utils' + +export function SessionCard() { + const { user, accessToken, setAuth } = useAuthStore() + const { logout, checkSession } = useAuth() + const [revokingAll, setRevokingAll] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [copied, setCopied] = useState(false) + + const payload = accessToken ? decodeJwt(accessToken) : null + + const handleLogoutAll = async () => { + if ( + !window.confirm('Are you sure you want to terminate all active sessions across all devices?') + ) { + return + } + setRevokingAll(true) + try { + await authService.logoutAll() + logout() + } catch (err) { + console.error('Failed to revoke all sessions:', err) + } finally { + setRevokingAll(false) + } + } + + const handleRefresh = async () => { + setRefreshing(true) + try { + await checkSession() + } catch (err) { + console.error('Manual refresh failed:', err) + } finally { + setRefreshing(false) + } + } + + const handleCopy = () => { + if (accessToken) { + window.navigator.clipboard.writeText(accessToken) + setCopied(true) + setTimeout(() => { + setCopied(false) + }, 2000) + } + } + + // Safe parsing of provider + const loginProvider = payload?.provider || 'email' + + return ( +
+ {/* Welcome Banner */} +
+ + Welcome Back + +

Hello, {user?.name || 'User'}

+
+ Logged in via: + + {loginProvider} + +
+
+ + {/* Active Session details */} +
+

+ Active Session Details +

+
+
+ {user?.avatar ? ( + Avatar + ) : ( + user?.name?.slice(0, 2) || 'US' + )} +
+
+ {user?.name} + {user?.email} +
+
+ +
+
+ Primary Role: + + {user?.role || 'user'} + +
+ +
+ Linked Accounts: +
+ {['email', 'google', 'github'].map((provider) => { + const isLinked = + provider === 'email' ? true : user?.linkedProviders?.includes(provider) + return ( + + {provider} {isLinked ? '✅' : ''} + + ) + })} +
+
+ +
+ Refresh Token ID: + + rt_uuid_{payload?.jti?.slice(0, 8) || 'active'} + +
+ +
+ Token Family ID: + + {payload?.jti?.slice(0, 16) || 'active_family_line'} + +
+
+ + {/* Quick Actions */} +
+
+ + + + +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/dashboard/TokenInspector.tsx b/apps/web/src/components/dashboard/TokenInspector.tsx new file mode 100644 index 0000000..9b31dd0 --- /dev/null +++ b/apps/web/src/components/dashboard/TokenInspector.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react' +import { useAuthStore } from '../../store/authStore' +import { decodeJwt } from '../../utils/jwt.utils' +import { formatExpiry } from '../../utils/time.utils' + +export function TokenInspector() { + const { accessToken } = useAuthStore() + const [secondsLeft, setSecondsLeft] = useState(0) + const [totalDuration, setTotalDuration] = useState(900) + const [copied, setCopied] = useState(false) + + const payload = accessToken ? decodeJwt(accessToken) : null + + useEffect(() => { + if (!payload || !payload.exp || !payload.iat) return + + const expiryTime = payload.exp * 1000 + setTotalDuration(payload.exp - payload.iat) + + const updateCountdown = () => { + const diff = Math.max(0, Math.floor((expiryTime - Date.now()) / 1000)) + setSecondsLeft(diff) + } + + updateCountdown() + const interval = setInterval(updateCountdown, 1000) + + return () => { + clearInterval(interval) + } + }, [accessToken, payload]) + + const percentage = totalDuration > 0 ? (secondsLeft / totalDuration) * 100 : 0 + + return ( +
+
+

+ Live Access Token Inspector +

+ 60 ? 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400' : 'bg-rose-500/10 border-rose-500/30 text-rose-400 animate-pulse'}`} + > + {secondsLeft > 0 ? `Expires in ${formatExpiry(secondsLeft)}` : 'Expired'} + +
+ +
+
60 ? 'bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.4)]' : 'bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.4)]'}`} + /> +
+ +
+
+ + Raw JWT (Memory-Only) + + {accessToken && ( + + )} +
+
+ {accessToken || 'No token active'} +
+
+ +
+ + Decoded Payload Claims + +
+          {payload ? JSON.stringify(payload, null, 2) : 'No payload claims loaded'}
+        
+
+
+ ) +} diff --git a/apps/web/src/components/layout/Navbar.tsx b/apps/web/src/components/layout/Navbar.tsx new file mode 100644 index 0000000..0fa44ba --- /dev/null +++ b/apps/web/src/components/layout/Navbar.tsx @@ -0,0 +1,168 @@ +import React, { useState, useRef, useEffect } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuthStore } from '../../store/authStore' +import { authService } from '../../services/auth.service' + +export function Navbar() { + const { user, clearAuth } = useAuthStore() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + const menuRef = useRef(null) + + // Close dropdown when clicking outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + if (open) document.addEventListener('mousedown', handler) + return () => { + document.removeEventListener('mousedown', handler) + } + }, [open]) + + const handleLogout = async () => { + setOpen(false) + try { + await authService.logout() + } catch { + // ignore — clear auth regardless + } + clearAuth() + navigate('/login') + } + + return ( +
+ {/* Name + role — hidden on mobile */} +
+ {user?.name} + + {user?.role} + +
+ + {/* Avatar button — always visible */} +
+ + {/* Online indicator dot — outside the button to avoid overflow-hidden clipping */} + +
+ + {/* Dropdown menu */} + {open && ( +
+ {/* User info header */} +
+
+ {user?.avatar ? ( + Avatar + ) : ( + user?.name?.slice(0, 2).toUpperCase() || 'US' + )} +
+
+ {user?.name} + {user?.email} + + {user?.role} + +
+
+ + {/* Nav links */} +
+ { + setOpen(false) + }} + className="flex items-center gap-3 px-4 py-2.5 text-sm text-slate-300 hover:text-slate-100 hover:bg-slate-800/60 transition-colors" + > + + + + Dashboard + + + { + setOpen(false) + }} + className="flex items-center gap-3 px-4 py-2.5 text-sm text-slate-300 hover:text-slate-100 hover:bg-slate-800/60 transition-colors" + > + + + + Profile Settings + +
+ + {/* Logout */} +
+ +
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/layout/ProtectedLayout.tsx b/apps/web/src/components/layout/ProtectedLayout.tsx new file mode 100644 index 0000000..35ab298 --- /dev/null +++ b/apps/web/src/components/layout/ProtectedLayout.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { Outlet, Link } from 'react-router-dom' +import { Navbar } from './Navbar' +import { Sidebar } from './Sidebar' + +export function ProtectedLayout() { + const [mobileOpen, setMobileOpen] = React.useState(false) + + return ( +
+ + + {/* Responsive Sidebar wrapper */} +
+ { + setMobileOpen(false) + }} + /> +
+ +
+
+ +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..8b31532 --- /dev/null +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { NavLink } from 'react-router-dom' +import { useAuthStore } from '../../store/authStore' +import { usePermission } from '../../hooks/usePermission' + +interface SidebarProps { + onClose?: () => void +} + +export function Sidebar({ onClose }: SidebarProps) { + const { user } = useAuthStore() + const { hasAnyPermission } = usePermission() + + const hasAdminAccess = + user && (user.roles.includes('admin') || hasAnyPermission(['users:read', 'audit:read'])) + + const linkClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition duration-150 ${ + isActive + ? 'bg-indigo-600/15 text-indigo-400 border border-indigo-500/20' + : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/40' + }` + + return ( + + ) +} diff --git a/apps/web/src/components/layout/Topbar.tsx b/apps/web/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/components/ui/Badge.tsx b/apps/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..c52f101 --- /dev/null +++ b/apps/web/src/components/ui/Badge.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +interface BadgeProps { + children: React.ReactNode + variant?: 'success' | 'warning' | 'info' | 'danger' + className?: string +} + +export function Badge({ children, variant = 'info', className = '' }: BadgeProps) { + const styles = { + success: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/25', + warning: 'bg-amber-500/10 text-amber-400 border-amber-500/25', + info: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/25', + danger: 'bg-rose-500/10 text-rose-400 border-rose-500/25', + } + + return ( + + {children} + + ) +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..7e24468 --- /dev/null +++ b/apps/web/src/components/ui/Button.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { motion } from 'framer-motion' + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' + isLoading?: boolean + size?: 'sm' | 'md' | 'lg' +} + +export function Button({ + children, + variant = 'primary', + isLoading, + className = '', + disabled, + size = 'md', + ...props +}: ButtonProps) { + const baseStyle = + 'px-4 py-2.5 rounded-lg font-medium transition duration-200 flex items-center justify-center gap-2 border disabled:opacity-50 disabled:cursor-not-allowed' + + const variants = { + primary: + 'bg-indigo-600 hover:bg-indigo-500 border-indigo-700 text-white shadow-[0_0_15px_rgba(99,102,241,0.4)]', + secondary: 'bg-slate-800 hover:bg-slate-700 border-slate-700 text-slate-200', + danger: + 'bg-rose-600 hover:bg-rose-500 border-rose-700 text-white shadow-[0_0_15px_rgba(244,63,94,0.4)]', + ghost: + 'bg-transparent border-transparent hover:bg-slate-800 text-slate-400 hover:text-slate-200', + } + + return ( + + {isLoading ? ( + + ) : null} + {children} + + ) +} diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..ef24f09 --- /dev/null +++ b/apps/web/src/components/ui/Input.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' + +interface InputProps extends React.InputHTMLAttributes { + label?: string + error?: string +} + +export const Input = React.forwardRef( + ({ label, error, type = 'text', className = '', ...props }, ref) => { + const [showPassword, setShowPassword] = useState(false) + const isPasswordField = type === 'password' + const inputType = isPasswordField ? (showPassword ? 'text' : 'password') : type + + return ( +
+ {label ? ( + + ) : null} +
+ + {isPasswordField && ( + + )} +
+ {error ? {error} : null} +
+ ) + } +) + +Input.displayName = 'Input' diff --git a/apps/web/src/components/ui/Modal.tsx b/apps/web/src/components/ui/Modal.tsx new file mode 100644 index 0000000..a2fd923 --- /dev/null +++ b/apps/web/src/components/ui/Modal.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { motion, AnimatePresence } from 'framer-motion' + +interface ModalProps { + isOpen: boolean + onClose: () => void + title: string + children: React.ReactNode +} + +export function Modal({ isOpen, onClose, title, children }: ModalProps) { + return ( + + {isOpen ? ( +
+ {/* Backdrop */} + + + {/* Modal content */} + +
+

{title}

+ +
+
{children}
+
+
+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/ui/Spinner.tsx b/apps/web/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..9e8174c --- /dev/null +++ b/apps/web/src/components/ui/Spinner.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +export function Spinner({ className = 'w-8 h-8' }: { className?: string }) { + return ( +
+ ) +} diff --git a/apps/web/src/components/ui/Toast.tsx b/apps/web/src/components/ui/Toast.tsx new file mode 100644 index 0000000..552f2b5 --- /dev/null +++ b/apps/web/src/components/ui/Toast.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { motion, AnimatePresence } from 'framer-motion' + +interface ToastProps { + message: string + type?: 'success' | 'error' | 'info' + isVisible: boolean + onClose: () => void +} + +export function Toast({ message, type = 'info', isVisible, onClose }: ToastProps) { + React.useEffect(() => { + if (isVisible) { + const timer = setTimeout(onClose, 4000) + return () => { + clearTimeout(timer) + } + } + return undefined + }, [isVisible, onClose]) + + const styles = { + success: + 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300 shadow-[0_0_20px_rgba(16,185,129,0.15)]', + error: 'bg-rose-500/10 border-rose-500/30 text-rose-300 shadow-[0_0_20px_rgba(244,63,94,0.15)]', + info: 'bg-indigo-500/10 border-indigo-500/30 text-indigo-300 shadow-[0_0_20px_rgba(99,102,241,0.15)]', + } + + return ( + + {isVisible ? ( + + {message} + + + ) : null} + + ) +} diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts new file mode 100644 index 0000000..da56a43 --- /dev/null +++ b/apps/web/src/config.ts @@ -0,0 +1,3 @@ +export const config = { + API_URL: (import.meta.env.VITE_API_URL as string) || 'http://localhost:5000/api/v1', +} diff --git a/apps/web/src/hooks/useAuditLog.ts b/apps/web/src/hooks/useAuditLog.ts new file mode 100644 index 0000000..782cc51 --- /dev/null +++ b/apps/web/src/hooks/useAuditLog.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query' +import { adminService } from '../services/admin.service' + +export function useAuditLog(page = 1, limit = 20) { + return useQuery({ + queryKey: ['auditLogs', page, limit], + queryFn: () => adminService.getAuditLogs(page, limit), + }) +} diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts new file mode 100644 index 0000000..71bcca1 --- /dev/null +++ b/apps/web/src/hooks/useAuth.ts @@ -0,0 +1,52 @@ +import { useAuthStore } from '../store/authStore' +import { authService } from '../services/auth.service' +import { userService } from '../services/user.service' + +export function useAuth() { + const { user, accessToken, isAuthenticated, isLoading, setAuth, clearAuth, setLoading } = + useAuthStore() + + const login = async (email: string, password: string) => { + setLoading(true) + try { + const data = await authService.login(email, password) + setAuth(data.user, data.accessToken) + return data.user + } catch (err) { + setLoading(false) + throw err + } + } + + const logout = async () => { + setLoading(true) + try { + await authService.logout() + } finally { + clearAuth() + } + } + + const checkSession = async () => { + setLoading(true) + try { + const { accessToken } = await authService.refresh() + const user = await userService.getMe() + setAuth(user, accessToken) + } catch (err) { + clearAuth() + } finally { + setLoading(false) + } + } + + return { + user, + accessToken, + isAuthenticated, + isLoading, + login, + logout, + checkSession, + } +} diff --git a/apps/web/src/hooks/usePermission.ts b/apps/web/src/hooks/usePermission.ts new file mode 100644 index 0000000..b38a3df --- /dev/null +++ b/apps/web/src/hooks/usePermission.ts @@ -0,0 +1,27 @@ +import { useAuthStore } from '../store/authStore' +import { decodeJwt } from '../utils/jwt.utils' + +export function usePermission() { + const { accessToken } = useAuthStore() + + const hasPermission = (permission: string): boolean => { + if (!accessToken) return false + const claims = decodeJwt(accessToken) + if (!claims) return false + if (claims.role === 'admin') return true + return claims.permissions?.includes(permission as any) || false + } + + const hasAnyPermission = (permissions: string[]): boolean => { + if (!accessToken) return false + const claims = decodeJwt(accessToken) + if (!claims) return false + if (claims.role === 'admin') return true + return permissions.some((p) => claims.permissions?.includes(p as any)) + } + + return { + hasPermission, + hasAnyPermission, + } +} diff --git a/apps/web/src/hooks/useRefreshToken.ts b/apps/web/src/hooks/useRefreshToken.ts new file mode 100644 index 0000000..0fc8f02 --- /dev/null +++ b/apps/web/src/hooks/useRefreshToken.ts @@ -0,0 +1,21 @@ +import { authService } from '../services/auth.service' +import { useAuthStore } from '../store/authStore' +import { userService } from '../services/user.service' + +export function useRefreshToken() { + const { setAuth, clearAuth } = useAuthStore() + + const refresh = async (): Promise => { + try { + const { accessToken } = await authService.refresh() + const user = await userService.getMe() + setAuth(user, accessToken) + return accessToken + } catch (err) { + clearAuth() + return null + } + } + + return refresh +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..a91ab5f --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,59 @@ +@import "tailwindcss"; + +@layer base { + body { + background-color: #0b0f19; + color: #f8fafc; + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + } +} + +/* Custom Premium Glassmorphism classes */ +.glass-panel { + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); +} + +.glass-input { + background: rgba(11, 15, 25, 0.8); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +@keyframes fadeSlideDown { + from { + opacity: 0; + transform: translateY(-6px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Custom Webkit scrollbar for premium dark theme integration */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #0A0A0F; +} + +::-webkit-scrollbar-thumb { + background: #1e293b; + border-radius: 4px; + border: 1px solid #0A0A0F; +} + +::-webkit-scrollbar-thumb:hover { + background: #334155; +} + +/* Firefox standard scrollbars support */ +* { + scrollbar-width: thin; + scrollbar-color: #1e293b #0A0A0F; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..18e6dc4 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider } from 'react-router-dom' +import { router } from './router' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx new file mode 100644 index 0000000..fda51bd --- /dev/null +++ b/apps/web/src/pages/AdminPage.tsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react' +import { UsersTable } from '../components/admin/UsersTable' +import { ActiveSessionsTable } from '../components/admin/ActiveSessionsTable' +import { AuditLogTable } from '../components/admin/AuditLogTable' +import { RoleManager } from '../components/admin/RoleManager' +import { usePermission } from '../hooks/usePermission' +import { useAuthStore } from '../store/authStore' + +export function AdminPage() { + const { hasPermission } = usePermission() + const { user } = useAuthStore() + + const isModerator = user?.role === 'moderator' + const isOnlyModerator = isModerator && !user?.roles?.includes('admin') + + const menuItems = [ + { id: 'overview', label: 'Dashboard Overview', visible: !isOnlyModerator }, + { id: 'users', label: 'Users Manager', visible: hasPermission('users:read') }, + { id: 'roles', label: 'Roles & Permissions', visible: hasPermission('roles:read') }, + { id: 'audit', label: 'Audit Logs', visible: hasPermission('audit:read') }, + { id: 'sessions', label: 'Active Sessions', visible: hasPermission('roles:read') }, + ] as const + + const visibleMenuItems = menuItems.filter((item) => item.visible) + const defaultTab = visibleMenuItems[0]?.id || 'overview' + + const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'roles' | 'audit' | 'sessions'>( + defaultTab + ) + + const panelTitle = isOnlyModerator ? 'Moderator Panel' : 'Admin Panel' + const panelDescription = isOnlyModerator + ? 'Inspect system users and review security audit logs.' + : 'Mutate user roles, inspect audit logs, and revoke system-wide Redis session keys.' + + return ( +
+
+

+ {panelTitle} +

+

{panelDescription}

+
+ +
+ {/* Sidebar Nav */} +
+ {visibleMenuItems.map((item) => ( + + ))} +
+ + {/* Tab content wrapper */} +
+ {activeTab === 'overview' && !isOnlyModerator && ( +
+
+ +
+
+

+ System Overview +

+

+ TokenForge is operating normally. All security enforcement policies, PKCE + configurations, and rate-limiting modules are active. Cryptographic signatures are + signing via standard RS256 algorithms. +

+
+ + Configuration Details + + + JWT Lifetime: 15 Minutes + + + Refresh Token Sliding Expiry: 7 Days + +
+
+
+ )} + + {activeTab === 'users' && hasPermission('users:read') && ( +
+

+ System Users +

+ +
+ )} + + {activeTab === 'roles' && hasPermission('roles:read') && ( +
+

+ RBAC Role Matrix +

+ +
+ )} + + {activeTab === 'audit' && hasPermission('audit:read') && ( +
+

+ Security Audit Logs +

+ +
+ )} + + {activeTab === 'sessions' && hasPermission('roles:read') && ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..c8d831f --- /dev/null +++ b/apps/web/src/pages/DashboardPage.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { SessionCard } from '../components/dashboard/SessionCard' +import { RolePermissionsCard } from '../components/dashboard/RolePermissionsCard' +import { SecurityEventsList } from '../components/dashboard/SecurityEventsList' +import { TokenInspector } from '../components/dashboard/TokenInspector' + +export function DashboardPage() { + return ( +
+
+

+ Security Dashboard +

+

+ Inspect your current active session state, JWT claims, and security logs. +

+
+ +
+
+ + + +
+ +
+ +
+
+
+ ) +} diff --git a/apps/web/src/pages/LandingPage.tsx b/apps/web/src/pages/LandingPage.tsx new file mode 100644 index 0000000..1daec19 --- /dev/null +++ b/apps/web/src/pages/LandingPage.tsx @@ -0,0 +1,968 @@ +import React, { useState } from 'react' +import { Link } from 'react-router-dom' +import { Button } from '../components/ui/Button' +import { useAuthStore } from '../store/authStore' +import { config } from '../config' + +export function LandingPage() { + const [activeFaq, setActiveFaq] = useState(null) + const [modalOpen, setModalOpen] = useState<'privacy' | 'terms' | 'contact' | null>(null) + const [contactSuccess, setContactSuccess] = useState('') + const [contactError, setContactError] = useState('') + + const faqs = [ + { + q: 'Why not just use Auth0 or Clerk?', + a: 'Third-party auth services abstract away everything — token lifecycle, rotation logic, RBAC mapping. TokenForge gives you full auditability: every line that issues, rotates, or revokes a token is code you own and can inspect.', + }, + { + q: 'Is this production-ready?', + a: 'The security primitives are production-grade (RS256, PKCE, httpOnly cookies, token family tracking, jti blacklisting). For high-scale production, add a reverse proxy (Nginx), horizontal Redis replication, and MongoDB read replicas.', + }, + { + q: 'What is refresh token rotation and why does it matter?', + a: 'Rotation issues a new refresh token on every use and invalidates the old one. If an attacker steals a refresh token, the first legitimate refresh after the theft triggers reuse detection — the entire token family is revoked and the user must re-authenticate.', + }, + { + q: 'How does the RBAC system work?', + a: 'Each user has a role (admin / moderator / user / guest). Each role maps to a permission set (e.g. users:read, profile:write:own). Permissions are encoded as claims in the JWT and enforced by middleware on every protected route — no DB hit per request.', + }, + { + q: 'What is PKCE and why is it used here?', + a: 'PKCE (Proof Key for Code Exchange, RFC 7636) prevents OAuth2 authorization code interception. On callback, the original verifier is sent in the token exchange — only the original initiator can complete the flow.', + }, + { + q: 'Why RS256 instead of HS256 for JWT signing?', + a: 'HS256 uses a shared secret — every service that verifies tokens must know the secret, creating multiple compromise points. RS256 uses an asymmetric key pair: only the API holds the private key; any downstream service verifies with the distributable public key.', + }, + { + q: 'Where is the access token stored on the frontend?', + a: 'In JavaScript memory (Zustand store) — never in localStorage or sessionStorage, which are vulnerable to XSS. The refresh token is stored in an httpOnly; Secure; SameSite=Strict cookie scoped to Path=/api/v1/auth.', + }, + { + q: 'What happens when the access token expires?', + a: 'The Axios response interceptor catches the 401, silently calls POST /api/v1/auth/refresh, receives a new access token, stores it in memory, and retries the original request — fully transparent to the user.', + }, + { + q: 'How are OAuth and email accounts linked?', + a: 'If a Google or GitHub login returns an email that already exists in the database, the provider ID (googleId or githubId) is merged onto the existing user document. The user can then log in with any method.', + }, + { + q: 'What does the audit log capture?', + a: 'Every auth event: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, LOGOUT_ALL, TOKEN_REFRESH, REFRESH_REUSE_ATTACK, OAUTH_LOGIN, etc. Logs auto-purge after 90 days via MongoDB TTL index.', + }, + { + q: 'How does logout work — does it really invalidate the access token?', + a: "Yes. The refresh token is deleted from Redis, and the access token's jti claim is written to a Redis blacklist key with TTL matching the remaining AT lifetime. Subsequent requests with that AT are blocked.", + }, + { + q: 'What happens if someone intercepts my refresh token cookie?', + a: "The token family tracking system detects the attack. When the legitimate user next refreshes their session, their current RT won't match the family's expected token. This mismatch triggers a full family revocation.", + }, + { + q: 'How does the rate limiter survive API restarts?', + a: 'Rate limit counters are stored in Redis via rate-limit-redis — not in API memory. Counters persist across restarts and scale horizontally across multiple API instances.', + }, + { + q: 'Can I add more OAuth providers (e.g. Facebook, LinkedIn)?', + a: 'Yes. Each OAuth provider lives in the apps/api modules/oauth/providers. Register the new callback route in oauth.routes.ts. The state/PKCE infrastructure is provider-agnostic.', + }, + { + q: 'What is the difference between Logout and Logout All Devices?', + a: "Logout deletes the current session's refresh token and blacklists the current access token. Logout All Devices uses a Redis SCAN loop to find and delete every refresh token belonging to the user.", + }, + ] + + const { isAuthenticated } = useAuthStore() + + return ( +
+ {/* Background patterns */} +
+ + {/* Sticky Header */} +
+
+ {/* Logo Image */} + TokenForge Logo +
+ +
+ {isAuthenticated ? ( + + + + ) : ( + <> + + + + + + + + )} +
+
+ + {/* Hero Section */} +
+
+ 🔥 Forged. Not imported. +
+ +

+ Build Auth From Scratch.
+ JWT Refresh Token Rotation. +

+ +

+ Custom authentication without Auth0. An open-source, developer-first boilerplate with + RS256 asymmetric signing, OAuth2 Google/GitHub, and custom RBAC — forged from scratch. +

+ +
+ {isAuthenticated ? ( + + + + ) : ( + + + + )} + + + +
+ +

+ RS256 · httpOnly cookies · Token family tracking +

+
+ + {/* Problem Flow Section */} +
+
+ + The Problem We Solve + +

+ Stop trusting black boxes with your users' identities. +

+
+ +
+ {/* Card 1 */} +
+
+ ⚠️ +
+

The Black Box Problem

+

+ Auth0, Clerk, Firebase Auth — all abstract the token layer. You sign up, paste an SDK + key, and trust someone else's security model. At 10k users, you get hit with a massive + monthly bill. +

+
+ + Vendor Lock-in + + + $700/mo Bill + +
+
+ + {/* Card 2 */} +
+
+ 🛡️ +
+

What You Can't See

+

+ You can't audit how tokens are signed. You can't see rotation logic. Your RBAC is + locked to their dashboard schema. When their service has downtime — your users can't + log in. +

+
+ + No Token Audits + + + Rigid RBAC + +
+
+ + {/* Card 3 */} +
+
+ ⚒️ +
+

TokenForge — Own Every Token

+

+ RS256 JWT + Refresh Rotation + OAuth2 PKCE + RBAC. Every line is yours. Every token is + yours. Every decision is yours. Free to deploy. Free to audit. Free forever. +

+
+ + Zero Cost + + + Full Auditability + +
+
+
+ + {/* 5-step implementation strip */} +
+ + How it works in 5 steps: + +
+ {/* Dotted connector line visible only on mobile */} +
+ + {[ + { num: '1', title: 'Register', desc: 'hash pwd (bcrypt 12)' }, + { num: '2', title: 'Login', desc: 'RS256 JWT signed' }, + { num: '3', title: 'Token Issued', desc: '15min expiry' }, + { num: '4', title: 'Refresh', desc: 'rotates silently, 7d' }, + { num: '5', title: 'RBAC', desc: 'enforced per route' }, + ].map((s, idx) => ( +
+ {/* Visual Step Indicator badge for mobile vertical layout (overlapping the dashed connector) */} +
+ {s.num} +
+ +
+ + Step {s.num} + + {s.title} + {s.desc} +
+
+ ))} +
+
+
+ + {/* 1. Features Grid Section */} +
+
+ + Features + +

+ Every security primitive. From first principles. +

+
+ +
+ {[ + { + title: 'JWT Token Engine', + desc: 'Signed with RS256 asymmetric keys. API holds the private key, client services verify with distributable public keys.', + icon: ( + + + + ), + }, + { + title: 'Refresh Rotation', + desc: 'Issues fresh sliding-window rotation tokens on every refresh action. Auto-detects reuse events to drop session lineages.', + icon: ( + + + + ), + }, + { + title: 'OAuth2 PKCE', + desc: 'Google & GitHub integrations with code challenges, state parameter verification, and CSRF lock validation.', + icon: ( + + + + ), + }, + { + title: 'RBAC Engine', + desc: 'Role mappings with resource-scoped grants. Decoded claims are parsed directly without per-request DB queries.', + icon: ( + + + + ), + }, + { + title: 'Rate Limiting', + desc: 'Protects sensitive paths via Redis rate limit store, persisting client IP request thresholds across container upgrades.', + icon: ( + + + + ), + }, + { + title: 'Audit Logging', + desc: 'Documents authentication anomalies and events. Automatically purges old logs using Mongoose TTL indices.', + icon: ( + + + + ), + }, + ].map((item, idx) => ( +
+
+ {item.icon} +
+

{item.title}

+

{item.desc}

+
+ ))} +
+
+ + {/* 2. OAuth Showcase Section */} +
+
+
+ + Social Auth + +

+ Login with any provider. Data stays yours. +

+
+ +
+ {[ + { + provider: 'Google OAuth Flow', + steps: [ + 'Redirect client securely to accounts.google.com with code challenge.', + 'User authenticates & consents to profile/identity share scopes.', + 'Callback handles verifier check to swap OAuth code for Google user details.', + 'Database resolves identity records, issuing secure httpOnly session cookies.', + ], + }, + { + provider: 'GitHub OAuth Flow', + steps: [ + 'Redirect client to github.com/login/oauth/authorize verification endpoint.', + 'User authorizes request and consent parameters on login.', + 'Exchange authorization code securely for GitHub access credentials.', + 'Compute matching user schemas, returning JWT payload directly to storage.', + ], + }, + ].map((p, idx) => ( +
+

{p.provider}

+
+ {p.steps.map((step, sIdx) => ( +
+
+ {sIdx + 1} +
+

{step}

+
+ ))} +
+
+ ))} +
+
+
+ + {/* 3. Security Section with Code Snippet */} +
+
+
+
+ + Architecture Primitives + +

+ Cryptographic primitives, implemented correctly. +

+
+ +
+ {[ + 'Asymmetric signature verification (RS256 algorithm)', + 'Transparent silent refresh mechanism (Axios Interceptors)', + 'Strict cookie settings (SameSite=Strict, Secure, HttpOnly)', + 'Full token reuse family tracking (compromise auto-invalidation)', + 'Persistent Redis-backed rate limiting thresholds', + 'MongoDB auto-expiring audit logs (TTL Indexes)', + ].map((spec, idx) => ( +
+ + {spec} +
+ ))} +
+
+ + {/* Monospace Code snippet card */} +
+
+
+
+
+ token.service.ts +
+
+              {'async '}
+              generateTokenPair
+              {'(payload: '}
+              JwtPayload
+              {'): '}
+              Promise
+              {'<{'}
+              {'\n  '}
+              {'accessToken: '}
+              string
+              {';'}
+              {'\n  '}
+              {'refreshToken: '}
+              string
+              {';'}
+              {'\n'}
+              {'}> {'}
+              {'\n  '}
+              const
+              {' jti = '}
+              uuid
+              {'();'}
+              {'\n  '}
+              const
+              {' accessToken = '}
+              jwt.sign
+              {'(payload, privateKey, {'}
+              {'\n    '}
+              {'algorithm: '}
+              'RS256'
+              {', jti,'}
+              {'\n  '}
+              {'});'}
+              {'\n  '}
+              const
+              {' refreshToken = '}
+              this
+              {'.'}
+              saveToRedis
+              {'(payload.userId, jti);'}
+              {'\n  '}
+              return
+              {' { accessToken, refreshToken };'}
+              {'\n'}
+              {'}'}
+            
+
+
+
+ + {/* 4. Architecture Preview Section */} +
+
+
+ + System Topology + +

+ Modular Monorepo Topology +

+

+ Stateless API tier scaling alongside memory-mapped cache boundaries. +

+
+ + {/* Simple Visual Architecture Diagram */} +
+
+ 📱 + Web App Client + React / Zustand +
+ +
+ ⇆ +
+ +
+ ⚙️ + Stateless API + Node / Express +
+ +
+ ⇆ +
+ +
+
+ 🗄️ + Database tier + MongoDB Atlas +
+
+ + Cache / Session + Redis cache +
+
+
+
+
+ + {/* 5. CTA Footer Strip */} +
+
+
+

+ Ready to see how auth really works? +

+

+ Dive into the dashboard console to inspect decoded token claims, trigger rotation, or + audit security events. +

+ + + +
+
+ + {/* FAQ Section */} +
+

+ Frequently Answered Questions +

+ +
+ {faqs.map((faq, idx) => ( +
+ + {activeFaq === idx && ( +
+ {faq.a} +
+ )} +
+ ))} +
+
+ + {/* Global Footer */} +
+
+ {/* Main Footer Row */} +
+
+
+ {/* Footer Logo Image */} + TokenForge Logo +
+

Built by Loganathan G P

+

Logusivam Vision

+ + {/* Popover triggers */} +
+ + + +
+
+ + {/* Social Links */} + +
+ + {/* Bottom Copyright Text - centered */} +
+

©TokenForge 2026. All Rights Reserved.

+
+
+
+ + {/* Popover Modals */} + {modalOpen && ( +
+
+ {/* Modal Header */} +
+

+ {modalOpen === 'privacy' && 'Privacy Policy'} + {modalOpen === 'terms' && 'Terms of Service'} + {modalOpen === 'contact' && 'Contact Support'} +

+ +
+ + {/* Privacy Policy Content */} + {modalOpen === 'privacy' && ( +
+

+ At TokenForge, we respect your cryptographic identity privacy. We only process + session data, authorization parameters, and audit logging metrics explicitly + generated to safeguard account actions. +

+

+ We store authentication credentials securely using cryptographic hashing standards + (bcrypt) and asymmetrical encryption signatures (RS256). We never sell your + personal information. +

+
+ )} + + {/* Terms Content */} + {modalOpen === 'terms' && ( +
+

+ By accessing TokenForge, you agree to protect the security of your private keys + and credentials. Unauthorized exploitation, credential sharing, or token + manipulation is strictly prohibited. +

+

+ All software is provided "as is", without warranty of any kind, express or + implied. +

+
+ )} + + {/* Contact Form Content */} + {modalOpen === 'contact' && ( + { + setModalOpen(null) + }} + successMsg={contactSuccess} + setSuccessMsg={setContactSuccess} + errorMsg={contactError} + setErrorMsg={setContactError} + /> + )} + + {/* Close Button for non-contact modals */} + {modalOpen !== 'contact' && ( +
+ +
+ )} +
+
+ )} +
+ ) +} + +interface ContactFormProps { + onClose: () => void + successMsg: string + setSuccessMsg: (msg: string) => void + errorMsg: string + setErrorMsg: (msg: string) => void +} + +function ContactForm({ + onClose, + successMsg, + setSuccessMsg, + errorMsg, + setErrorMsg, +}: ContactFormProps) { + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [message, setMessage] = useState('') + const [submitting, setSubmitting] = useState(false) + + const handleContactSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault() + if (name.trim().length < 2 || name.trim().length > 100) { + setErrorMsg('Name must be between 2 and 100 characters.') + return + } + if (message.trim().length < 10 || message.trim().length > 1000) { + setErrorMsg('Message must be between 10 and 1000 characters.') + return + } + + setSubmitting(true) + setSuccessMsg('') + setErrorMsg('') + + try { + // POST to backend contact form API + const response = await window.fetch(`${config.API_URL}/support/contact`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, email, message }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || 'Support enquiry submission failed') + } + + setSuccessMsg('Your support enquiry was sent successfully.') + setName('') + setEmail('') + setMessage('') + } catch (err: any) { + setErrorMsg(err.message || 'Failed to submit contact enquiry. Try again.') + } finally { + setSubmitting(false) + } + } + + return ( +
+ {successMsg && ( +
+ {successMsg} +
+ )} + + {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ + { + setName(e.target.value) + }} + required + maxLength={100} + className="w-full px-4 py-2 bg-[#0b0f19]/80 border border-slate-800 rounded-lg text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/20 transition duration-200" + placeholder="Enter display name" + /> + {name.length}/100 +
+ +
+ + { + setEmail(e.target.value) + }} + required + className="w-full px-4 py-2 bg-[#0b0f19]/80 border border-slate-800 rounded-lg text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/20 transition duration-200" + placeholder="you@example.com" + /> +
+ +
+ +