@rashidee/co2 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- package/static/index.html +24 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
# Security Patterns — OAuth2 PKCE (Keycloak) and Local JWT Auth
|
|
2
|
+
|
|
3
|
+
This reference describes the complete authentication architecture for the spec. Include
|
|
4
|
+
all of this content in Section 6 of the generated specification.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Architecture Overview — Keycloak PKCE (Auth = Keycloak)
|
|
9
|
+
|
|
10
|
+
The application operates as an OAuth2 Public Client using Authorization Code flow with
|
|
11
|
+
PKCE (Proof Key for Code Exchange) and OIDC. Keycloak is the external identity provider
|
|
12
|
+
that handles login UI, user registration, and role management. The application's
|
|
13
|
+
responsibilities are:
|
|
14
|
+
|
|
15
|
+
1. Redirect unauthenticated users to Keycloak login page (PKCE challenge generated client-side)
|
|
16
|
+
2. Handle the OAuth2 callback and exchange the authorization code for tokens
|
|
17
|
+
3. Store tokens securely in memory (Zustand store) — NOT in localStorage
|
|
18
|
+
4. Extract roles from OIDC ID token claims
|
|
19
|
+
5. Attach Bearer token to all API requests via Axios interceptor
|
|
20
|
+
6. Silently refresh the access token before expiry
|
|
21
|
+
|
|
22
|
+
The flow:
|
|
23
|
+
```
|
|
24
|
+
User navigates to protected route
|
|
25
|
+
→ ProtectedRoute detects no auth
|
|
26
|
+
→ oidc-client-ts generates code_verifier + code_challenge (PKCE)
|
|
27
|
+
→ 302 Redirect to Keycloak /auth?response_type=code&code_challenge=...
|
|
28
|
+
→ User logs in at Keycloak
|
|
29
|
+
→ Keycloak redirects to /auth/callback?code=...
|
|
30
|
+
→ AuthCallback page exchanges code for tokens (ID token + access token)
|
|
31
|
+
→ react-oidc-context stores tokens in memory
|
|
32
|
+
→ AuthProvider exposes user + token via useAuth() hook
|
|
33
|
+
→ Axios interceptor reads token from auth context
|
|
34
|
+
→ User redirected to originally-requested page
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Package Dependencies
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"oidc-client-ts": "^3.1.0",
|
|
45
|
+
"react-oidc-context": "^3.2.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Environment Variables
|
|
53
|
+
|
|
54
|
+
```env
|
|
55
|
+
# .env.development
|
|
56
|
+
VITE_KEYCLOAK_AUTHORITY=http://localhost:8180/realms/{{KEYCLOAK_REALM}}
|
|
57
|
+
VITE_KEYCLOAK_CLIENT_ID={{KEYCLOAK_CLIENT_ID}}
|
|
58
|
+
VITE_KEYCLOAK_REDIRECT_URI=http://localhost:3000/auth/callback
|
|
59
|
+
VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=http://localhost:3000
|
|
60
|
+
VITE_KEYCLOAK_SCOPE=openid profile email
|
|
61
|
+
|
|
62
|
+
# .env.production
|
|
63
|
+
VITE_KEYCLOAK_AUTHORITY=${KEYCLOAK_AUTHORITY}
|
|
64
|
+
VITE_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
|
|
65
|
+
VITE_KEYCLOAK_REDIRECT_URI=${APP_URL}/auth/callback
|
|
66
|
+
VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=${APP_URL}
|
|
67
|
+
VITE_KEYCLOAK_SCOPE=openid profile email
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## OIDC Configuration
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// src/lib/auth/oidcConfig.ts
|
|
76
|
+
import type { AuthProviderProps } from 'react-oidc-context';
|
|
77
|
+
|
|
78
|
+
export const oidcConfig: AuthProviderProps = {
|
|
79
|
+
authority: import.meta.env.VITE_KEYCLOAK_AUTHORITY,
|
|
80
|
+
client_id: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
|
|
81
|
+
redirect_uri: import.meta.env.VITE_KEYCLOAK_REDIRECT_URI,
|
|
82
|
+
post_logout_redirect_uri: import.meta.env.VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI,
|
|
83
|
+
scope: import.meta.env.VITE_KEYCLOAK_SCOPE,
|
|
84
|
+
response_type: 'code',
|
|
85
|
+
// PKCE is enabled by default in oidc-client-ts
|
|
86
|
+
// Silently renew the access token 60 seconds before expiry
|
|
87
|
+
automaticSilentRenew: true,
|
|
88
|
+
// Load user info from the OIDC userinfo endpoint
|
|
89
|
+
loadUserInfo: true,
|
|
90
|
+
onSigninCallback: (_user) => {
|
|
91
|
+
// Remove the auth params from the URL after successful login
|
|
92
|
+
window.history.replaceState({}, document.title, window.location.pathname);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## AuthProvider Setup
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// src/main.tsx
|
|
103
|
+
import React from 'react';
|
|
104
|
+
import ReactDOM from 'react-dom/client';
|
|
105
|
+
import { AuthProvider } from 'react-oidc-context';
|
|
106
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
107
|
+
import { oidcConfig } from './lib/auth/oidcConfig';
|
|
108
|
+
import { queryClient } from './lib/queryClient';
|
|
109
|
+
import { App } from './App';
|
|
110
|
+
|
|
111
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
112
|
+
<React.StrictMode>
|
|
113
|
+
<AuthProvider {...oidcConfig}>
|
|
114
|
+
<QueryClientProvider client={queryClient}>
|
|
115
|
+
<App />
|
|
116
|
+
</QueryClientProvider>
|
|
117
|
+
</AuthProvider>
|
|
118
|
+
</React.StrictMode>,
|
|
119
|
+
);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Role Extraction from OIDC Token
|
|
125
|
+
|
|
126
|
+
Keycloak places roles in both `realm_access.roles` and `resource_access.<client_id>.roles`
|
|
127
|
+
within the ID token claims. Extract them using a utility:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// src/lib/auth/keycloakRoles.ts
|
|
131
|
+
import type { User } from 'oidc-client-ts';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract Keycloak roles from the OIDC user's ID token claims.
|
|
135
|
+
* Combines realm roles and client-specific resource roles.
|
|
136
|
+
*/
|
|
137
|
+
export function extractKeycloakRoles(user: User | null | undefined): string[] {
|
|
138
|
+
if (!user?.profile) return [];
|
|
139
|
+
|
|
140
|
+
const profile = user.profile as Record<string, unknown>;
|
|
141
|
+
const roles: string[] = [];
|
|
142
|
+
|
|
143
|
+
// Realm-level roles
|
|
144
|
+
const realmAccess = profile['realm_access'] as { roles?: string[] } | undefined;
|
|
145
|
+
if (realmAccess?.roles) {
|
|
146
|
+
roles.push(...realmAccess.roles);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Client-specific roles
|
|
150
|
+
const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID as string;
|
|
151
|
+
const resourceAccess = profile['resource_access'] as
|
|
152
|
+
Record<string, { roles?: string[] }> | undefined;
|
|
153
|
+
if (resourceAccess?.[clientId]?.roles) {
|
|
154
|
+
roles.push(...resourceAccess[clientId].roles);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [...new Set(roles.map((r) => r.toUpperCase()))];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check if the user has a specific role.
|
|
162
|
+
*/
|
|
163
|
+
export function hasRole(user: User | null | undefined, role: string): boolean {
|
|
164
|
+
return extractKeycloakRoles(user).includes(role.toUpperCase());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if the user has any of the specified roles.
|
|
169
|
+
*/
|
|
170
|
+
export function hasAnyRole(user: User | null | undefined, roles: string[]): boolean {
|
|
171
|
+
const userRoles = extractKeycloakRoles(user);
|
|
172
|
+
return roles.some((r) => userRoles.includes(r.toUpperCase()));
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## useAuthUser Hook
|
|
179
|
+
|
|
180
|
+
A thin wrapper over `react-oidc-context`'s `useAuth()` that adds role-based utilities:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// src/lib/auth/useAuthUser.ts
|
|
184
|
+
import { useAuth } from 'react-oidc-context';
|
|
185
|
+
import { extractKeycloakRoles, hasRole, hasAnyRole } from './keycloakRoles';
|
|
186
|
+
|
|
187
|
+
export interface AuthUser {
|
|
188
|
+
id: string;
|
|
189
|
+
username: string;
|
|
190
|
+
email: string;
|
|
191
|
+
name: string;
|
|
192
|
+
roles: string[];
|
|
193
|
+
accessToken: string | undefined;
|
|
194
|
+
isAuthenticated: boolean;
|
|
195
|
+
isLoading: boolean;
|
|
196
|
+
login: () => Promise<void>;
|
|
197
|
+
logout: () => Promise<void>;
|
|
198
|
+
hasRole: (role: string) => boolean;
|
|
199
|
+
hasAnyRole: (roles: string[]) => boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function useAuthUser(): AuthUser {
|
|
203
|
+
const auth = useAuth();
|
|
204
|
+
const roles = extractKeycloakRoles(auth.user);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: auth.user?.profile.sub ?? '',
|
|
208
|
+
username: (auth.user?.profile.preferred_username as string) ?? '',
|
|
209
|
+
email: auth.user?.profile.email ?? '',
|
|
210
|
+
name: auth.user?.profile.name ?? '',
|
|
211
|
+
roles,
|
|
212
|
+
accessToken: auth.user?.access_token,
|
|
213
|
+
isAuthenticated: auth.isAuthenticated,
|
|
214
|
+
isLoading: auth.isLoading,
|
|
215
|
+
login: () => auth.signinRedirect(),
|
|
216
|
+
logout: () =>
|
|
217
|
+
auth.signoutRedirect({
|
|
218
|
+
post_logout_redirect_uri: import.meta.env.VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI,
|
|
219
|
+
}),
|
|
220
|
+
hasRole: (role) => hasRole(auth.user, role),
|
|
221
|
+
hasAnyRole: (r) => hasAnyRole(auth.user, r),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## ProtectedRoute Component
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
// src/shared/components/ProtectedRoute.tsx
|
|
232
|
+
import { useAuth } from 'react-oidc-context';
|
|
233
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
234
|
+
import { hasAnyRole } from '../../lib/auth/keycloakRoles';
|
|
235
|
+
import { LoadingOverlay } from './LoadingOverlay';
|
|
236
|
+
|
|
237
|
+
interface ProtectedRouteProps {
|
|
238
|
+
children: React.ReactNode;
|
|
239
|
+
/** If provided, the user must have at least one of these roles */
|
|
240
|
+
requiredRoles?: string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
|
244
|
+
const auth = useAuth();
|
|
245
|
+
const location = useLocation();
|
|
246
|
+
|
|
247
|
+
if (auth.isLoading) {
|
|
248
|
+
return <LoadingOverlay />;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!auth.isAuthenticated) {
|
|
252
|
+
// Trigger Keycloak login redirect
|
|
253
|
+
auth.signinRedirect({ state: { from: location.pathname } });
|
|
254
|
+
return <LoadingOverlay />;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (requiredRoles && !hasAnyRole(auth.user, requiredRoles)) {
|
|
258
|
+
return <Navigate to="/unauthorized" replace />;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return <>{children}</>;
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## AuthCallback Page
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
// src/features/auth/pages/AuthCallbackPage.tsx
|
|
271
|
+
import { useEffect } from 'react';
|
|
272
|
+
import { useAuth } from 'react-oidc-context';
|
|
273
|
+
import { useNavigate } from 'react-router-dom';
|
|
274
|
+
import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Handles the OAuth2 callback redirect from Keycloak.
|
|
278
|
+
* react-oidc-context processes the code automatically via onSigninCallback;
|
|
279
|
+
* this page just shows a loading state during the exchange.
|
|
280
|
+
*/
|
|
281
|
+
export function AuthCallbackPage() {
|
|
282
|
+
const auth = useAuth();
|
|
283
|
+
const navigate = useNavigate();
|
|
284
|
+
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (auth.isAuthenticated) {
|
|
287
|
+
// Navigate to the page the user was originally trying to access, or home
|
|
288
|
+
const from = (auth.user?.state as { from?: string })?.from ?? '/';
|
|
289
|
+
navigate(from, { replace: true });
|
|
290
|
+
}
|
|
291
|
+
}, [auth.isAuthenticated, auth.user, navigate]);
|
|
292
|
+
|
|
293
|
+
if (auth.error) {
|
|
294
|
+
return (
|
|
295
|
+
<div style={{ padding: 32 }}>
|
|
296
|
+
<h2>Authentication Error</h2>
|
|
297
|
+
<p>{auth.error.message}</p>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return <LoadingOverlay message="Completing sign-in..." />;
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Axios Interceptor (Keycloak)
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
// src/lib/api/axiosInstance.ts
|
|
312
|
+
import axios from 'axios';
|
|
313
|
+
import { getAuthUser } from './authBridge';
|
|
314
|
+
|
|
315
|
+
export const axiosInstance = axios.create({
|
|
316
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
317
|
+
timeout: 30_000,
|
|
318
|
+
headers: {
|
|
319
|
+
'Content-Type': 'application/json',
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Attach Bearer token to every request
|
|
324
|
+
axiosInstance.interceptors.request.use(
|
|
325
|
+
(config) => {
|
|
326
|
+
const token = getAuthUser()?.access_token;
|
|
327
|
+
if (token) {
|
|
328
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
329
|
+
}
|
|
330
|
+
return config;
|
|
331
|
+
},
|
|
332
|
+
(error) => Promise.reject(error),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Global error handling
|
|
336
|
+
axiosInstance.interceptors.response.use(
|
|
337
|
+
(response) => response,
|
|
338
|
+
(error) => {
|
|
339
|
+
if (axios.isAxiosError(error)) {
|
|
340
|
+
const status = error.response?.status;
|
|
341
|
+
if (status === 401) {
|
|
342
|
+
// Token expired — oidc-client-ts silent renewal handles this automatically.
|
|
343
|
+
// If silent renewal fails, the user is redirected to Keycloak login.
|
|
344
|
+
console.warn('Unauthorized — triggering silent renew');
|
|
345
|
+
} else if (status === 403) {
|
|
346
|
+
console.warn('Forbidden — insufficient permissions');
|
|
347
|
+
} else if (status && status >= 500) {
|
|
348
|
+
console.error('Server error', error.response?.data);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return Promise.reject(error);
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The `authBridge` module provides a way to access the current OIDC user from outside
|
|
357
|
+
React (needed for the Axios interceptor, which runs outside React's component tree):
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
// src/lib/api/authBridge.ts
|
|
361
|
+
import type { User } from 'oidc-client-ts';
|
|
362
|
+
|
|
363
|
+
let _currentUser: User | null = null;
|
|
364
|
+
|
|
365
|
+
export function setAuthUser(user: User | null): void {
|
|
366
|
+
_currentUser = user;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function getAuthUser(): User | null {
|
|
370
|
+
return _currentUser;
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Register the bridge in the `App` component:
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
// src/App.tsx (relevant excerpt)
|
|
378
|
+
import { useAuth } from 'react-oidc-context';
|
|
379
|
+
import { useEffect } from 'react';
|
|
380
|
+
import { setAuthUser } from './lib/api/authBridge';
|
|
381
|
+
|
|
382
|
+
export function App() {
|
|
383
|
+
const auth = useAuth();
|
|
384
|
+
|
|
385
|
+
// Keep the auth bridge in sync with the current OIDC user
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
setAuthUser(auth.user ?? null);
|
|
388
|
+
}, [auth.user]);
|
|
389
|
+
|
|
390
|
+
return (/* router and providers */);
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## OIDC ID Token Structure Reference
|
|
397
|
+
|
|
398
|
+
Keycloak places roles in the ID token as follows:
|
|
399
|
+
|
|
400
|
+
```json
|
|
401
|
+
{
|
|
402
|
+
"sub": "f1234-abcd-5678",
|
|
403
|
+
"preferred_username": "john.doe",
|
|
404
|
+
"email": "john.doe@example.com",
|
|
405
|
+
"name": "John Doe",
|
|
406
|
+
"realm_access": {
|
|
407
|
+
"roles": ["USER", "offline_access"]
|
|
408
|
+
},
|
|
409
|
+
"resource_access": {
|
|
410
|
+
"{{KEYCLOAK_CLIENT_ID}}": {
|
|
411
|
+
"roles": ["ADMIN"]
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
"scope": "openid profile email"
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
This structure is what `extractKeycloakRoles()` parses.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Role Constants
|
|
423
|
+
|
|
424
|
+
Define roles as a constants object to avoid string duplication:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
// src/lib/auth/roles.ts
|
|
428
|
+
export const Roles = {
|
|
429
|
+
ADMIN: 'ADMIN',
|
|
430
|
+
EDITOR: 'EDITOR',
|
|
431
|
+
USER: 'USER',
|
|
432
|
+
} as const;
|
|
433
|
+
|
|
434
|
+
export type Role = (typeof Roles)[keyof typeof Roles];
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## Architecture Overview — Local JWT Auth (Auth = Local)
|
|
440
|
+
|
|
441
|
+
When the backend manages authentication (no external IdP), the flow is:
|
|
442
|
+
|
|
443
|
+
```
|
|
444
|
+
User submits login form
|
|
445
|
+
→ POST /api/auth/login with { email, password }
|
|
446
|
+
→ Backend returns { accessToken, user }
|
|
447
|
+
→ Zustand auth store saves token in memory
|
|
448
|
+
→ Axios interceptor reads token from Zustand store
|
|
449
|
+
→ On page reload: token is lost, user must re-login (or use refresh token cookie)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Zustand Auth Store (Local JWT)
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
// src/store/auth.store.ts
|
|
456
|
+
import { create } from 'zustand';
|
|
457
|
+
import { axiosInstance } from '../lib/api/axiosInstance';
|
|
458
|
+
|
|
459
|
+
interface UserInfo {
|
|
460
|
+
id: string;
|
|
461
|
+
email: string;
|
|
462
|
+
name: string;
|
|
463
|
+
roles: string[];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
interface AuthState {
|
|
467
|
+
user: UserInfo | null;
|
|
468
|
+
accessToken: string | null;
|
|
469
|
+
isAuthenticated: boolean;
|
|
470
|
+
isLoading: boolean;
|
|
471
|
+
login: (email: string, password: string) => Promise<void>;
|
|
472
|
+
logout: () => Promise<void>;
|
|
473
|
+
setToken: (token: string, user: UserInfo) => void;
|
|
474
|
+
clearAuth: () => void;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export const useAuthStore = create<AuthState>((set) => ({
|
|
478
|
+
user: null,
|
|
479
|
+
accessToken: null,
|
|
480
|
+
isAuthenticated: false,
|
|
481
|
+
isLoading: false,
|
|
482
|
+
|
|
483
|
+
login: async (email, password) => {
|
|
484
|
+
set({ isLoading: true });
|
|
485
|
+
try {
|
|
486
|
+
const response = await axiosInstance.post<{ accessToken: string; user: UserInfo }>(
|
|
487
|
+
'/auth/login',
|
|
488
|
+
{ email, password },
|
|
489
|
+
);
|
|
490
|
+
set({
|
|
491
|
+
accessToken: response.data.accessToken,
|
|
492
|
+
user: response.data.user,
|
|
493
|
+
isAuthenticated: true,
|
|
494
|
+
isLoading: false,
|
|
495
|
+
});
|
|
496
|
+
} catch (error) {
|
|
497
|
+
set({ isLoading: false });
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
logout: async () => {
|
|
503
|
+
try {
|
|
504
|
+
await axiosInstance.post('/auth/logout');
|
|
505
|
+
} finally {
|
|
506
|
+
set({ user: null, accessToken: null, isAuthenticated: false });
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
setToken: (token, user) => set({ accessToken: token, user, isAuthenticated: true }),
|
|
511
|
+
clearAuth: () => set({ user: null, accessToken: null, isAuthenticated: false }),
|
|
512
|
+
}));
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### Axios Interceptor (Local JWT)
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
// src/lib/api/axiosInstance.ts
|
|
519
|
+
import axios from 'axios';
|
|
520
|
+
import { useAuthStore } from '../../store/auth.store';
|
|
521
|
+
|
|
522
|
+
export const axiosInstance = axios.create({
|
|
523
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
524
|
+
timeout: 30_000,
|
|
525
|
+
withCredentials: true, // Required for httpOnly refresh token cookie
|
|
526
|
+
headers: { 'Content-Type': 'application/json' },
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
axiosInstance.interceptors.request.use((config) => {
|
|
530
|
+
// Read token directly from Zustand store state (outside React)
|
|
531
|
+
const token = useAuthStore.getState().accessToken;
|
|
532
|
+
if (token) {
|
|
533
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
534
|
+
}
|
|
535
|
+
return config;
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
axiosInstance.interceptors.response.use(
|
|
539
|
+
(response) => response,
|
|
540
|
+
async (error) => {
|
|
541
|
+
if (!axios.isAxiosError(error)) return Promise.reject(error);
|
|
542
|
+
const status = error.response?.status;
|
|
543
|
+
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
|
544
|
+
|
|
545
|
+
if (status === 401 && !originalRequest._retry) {
|
|
546
|
+
originalRequest._retry = true;
|
|
547
|
+
try {
|
|
548
|
+
// Attempt token refresh using httpOnly cookie
|
|
549
|
+
const res = await axiosInstance.post<{ accessToken: string; user: unknown }>(
|
|
550
|
+
'/auth/refresh',
|
|
551
|
+
);
|
|
552
|
+
useAuthStore.getState().setToken(
|
|
553
|
+
res.data.accessToken,
|
|
554
|
+
res.data.user as Parameters<typeof useAuthStore.getState().setToken>[1],
|
|
555
|
+
);
|
|
556
|
+
// Retry the original request with the new token
|
|
557
|
+
originalRequest.headers = originalRequest.headers ?? {};
|
|
558
|
+
originalRequest.headers.Authorization = `Bearer ${res.data.accessToken}`;
|
|
559
|
+
return axiosInstance(originalRequest);
|
|
560
|
+
} catch {
|
|
561
|
+
useAuthStore.getState().clearAuth();
|
|
562
|
+
window.location.href = '/login';
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return Promise.reject(error);
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### ProtectedRoute Component (Local JWT)
|
|
571
|
+
|
|
572
|
+
```tsx
|
|
573
|
+
// src/shared/components/ProtectedRoute.tsx
|
|
574
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
575
|
+
import { useAuthStore } from '../../store/auth.store';
|
|
576
|
+
import { LoadingOverlay } from './LoadingOverlay';
|
|
577
|
+
|
|
578
|
+
interface ProtectedRouteProps {
|
|
579
|
+
children: React.ReactNode;
|
|
580
|
+
requiredRoles?: string[];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
|
584
|
+
const { isAuthenticated, isLoading, user } = useAuthStore();
|
|
585
|
+
const location = useLocation();
|
|
586
|
+
|
|
587
|
+
if (isLoading) return <LoadingOverlay />;
|
|
588
|
+
|
|
589
|
+
if (!isAuthenticated) {
|
|
590
|
+
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (requiredRoles && !requiredRoles.some((r) => user?.roles.includes(r.toUpperCase()))) {
|
|
594
|
+
return <Navigate to="/unauthorized" replace />;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return <>{children}</>;
|
|
598
|
+
}
|
|
599
|
+
```
|