mirror of
https://github.com/OpenBankProject/API-Explorer-II.git
synced 2026-02-06 10:47:04 +00:00
Compare commits
157 Commits
last-oauth
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f0bf2b90 | ||
|
|
efec0fda9f | ||
|
|
a1f6bede09 | ||
|
|
bcfc533853 | ||
|
|
3606ef739b | ||
|
|
5146db9d00 | ||
|
|
2337e9cab3 | ||
|
|
bf87c919c1 | ||
|
|
db129fd8c2 | ||
|
|
171aabaecb | ||
|
|
171ca01610 | ||
|
|
2b5b824d1f | ||
|
|
6f9a5d14bd | ||
|
|
f7b7dfb598 | ||
|
|
da698bb095 | ||
|
|
1c41a81d80 | ||
|
|
4ace47d1ab | ||
|
|
8c51cf2951 | ||
|
|
10e14a2738 | ||
|
|
99c4d4d22c | ||
|
|
1221884e8e | ||
|
|
5baa409f2b | ||
|
|
2923ce268c | ||
|
|
74b0b12b90 | ||
|
|
cb9bfbfbcb | ||
|
|
8c8630c547 | ||
|
|
d1fb24898c | ||
|
|
0e8e7df8d5 | ||
|
|
ac5f978ada | ||
|
|
5c92d4f0fb | ||
|
|
7a8097da85 | ||
|
|
b422980a03 | ||
|
|
c755b47e80 | ||
|
|
5cb5cfc229 | ||
|
|
f754b4fde6 | ||
|
|
a955b85146 | ||
|
|
aa2e192da9 | ||
|
|
e34b939a0e | ||
|
|
fa7866e981 | ||
|
|
fceecde656 | ||
|
|
0d4f0d6b82 | ||
|
|
04cf791f90 | ||
|
|
e701522734 | ||
|
|
0eace070f9 | ||
|
|
743038953d | ||
|
|
7695d3c314 | ||
|
|
02250d3c92 | ||
|
|
ed41dc2463 | ||
|
|
04018b82fe | ||
|
|
32a15c2653 | ||
|
|
7a0c1d901b | ||
|
|
dee726bb1b | ||
|
|
ef4cf2f791 | ||
|
|
6f690c830a | ||
|
|
b2d04f2e4d | ||
|
|
8970a5bd2d | ||
|
|
370f80f7fd | ||
|
|
6fd988a0a7 | ||
|
|
8a8ee8eb5b | ||
|
|
2f00ca98e1 | ||
|
|
b64ce3bf3e | ||
|
|
27fe4e45ad | ||
|
|
44b2998e6f | ||
|
|
6379231a21 | ||
|
|
bf4b74c746 | ||
|
|
94fc898f5d | ||
|
|
52dfe6fb6b | ||
|
|
22eb98867d | ||
|
|
93a11c709e | ||
|
|
5bea5a2be4 | ||
|
|
c4bd93c52b | ||
|
|
41ddc8fa0d | ||
|
|
3a03812801 | ||
|
|
07d47ca70f | ||
|
|
755dc70d1b | ||
|
|
8b90bb4265 | ||
|
|
3dadca8234 | ||
|
|
100a79ce70 | ||
|
|
a19e196fe6 | ||
|
|
f3e02fc305 | ||
|
|
4e4ac8047b | ||
|
|
29b299c4c9 | ||
|
|
295df13e57 | ||
|
|
85aa76a71f | ||
|
|
6030965947 | ||
|
|
1033693b09 | ||
|
|
8346a529f6 | ||
|
|
498a509b2d | ||
|
|
245893a33d | ||
|
|
15301c576c | ||
|
|
c40f2b117b | ||
|
|
643d021fe7 | ||
|
|
6b6c22d52e | ||
|
|
656f2511e5 | ||
|
|
1840e3f3ce | ||
|
|
df62d85097 | ||
|
|
1b13bf2844 | ||
|
|
b49f61c347 | ||
|
|
dee58c39d8 | ||
|
|
04e945d430 | ||
|
|
b6f5461f47 | ||
|
|
2066e62bbf | ||
|
|
4a856123ad | ||
|
|
a70b352b06 | ||
|
|
bc245663cb | ||
|
|
8286c2fdd6 | ||
|
|
4af5061319 | ||
|
|
5bb7f74d70 | ||
|
|
8bdfe68db4 | ||
|
|
cc888e4660 | ||
|
|
519109b17f | ||
|
|
e6890fc551 | ||
|
|
0b4825fc7c | ||
|
|
cd9ba264ec | ||
|
|
ca923f7b5a | ||
|
|
37c2688fb5 | ||
|
|
cf5412173b | ||
|
|
53bef156f9 | ||
|
|
c870628ad3 | ||
|
|
23a1615dcb | ||
|
|
d7638b1d70 | ||
|
|
9b14179d33 | ||
|
|
6d3c2a4806 | ||
|
|
028a1d7c1d | ||
|
|
e2558a4558 | ||
|
|
3dbc9f7426 | ||
|
|
7b4136f26d | ||
|
|
ad9a15cecd | ||
|
|
24ca25caff | ||
|
|
1a8dfb3975 | ||
|
|
046ba2d96d | ||
|
|
6a83e547a4 | ||
|
|
85de00bdfe | ||
|
|
307a6359a3 | ||
|
|
9eb7265894 | ||
|
|
5db4848b29 | ||
|
|
d329a02fe8 | ||
|
|
63bec0dd00 | ||
|
|
ed5de46361 | ||
|
|
3daae4d67c | ||
|
|
1cfb968268 | ||
|
|
e87f2e6389 | ||
|
|
b0d927c1e1 | ||
|
|
6d1aac927e | ||
|
|
8277fa4532 | ||
|
|
056171388f | ||
|
|
b97f39b4e1 | ||
|
|
4a5239e5aa | ||
|
|
f450946ca6 | ||
|
|
1c8c8f09b8 | ||
|
|
3450df5d01 | ||
|
|
dc69f9664d | ||
|
|
d14fb57005 | ||
|
|
a86f1455bb | ||
|
|
b2df3a9791 | ||
|
|
86295f827a | ||
|
|
ba783c0f22 |
95
.env.example
95
.env.example
@ -1,49 +1,56 @@
|
||||
VITE_OBP_API_HOST=https://apisandbox.openbankproject.com
|
||||
|
||||
### OBP-API mode ###################################
|
||||
# If OBP-API split to two instances, eg: apis,portal
|
||||
# Then API_Explorer need to set two api hosts: api_hostname and this api_portal_hostname, for all Rest Apis will call api_hostname
|
||||
# but for all the portal home page link, we need to use this props. If do not set this, it will use api_hostname value instead.
|
||||
VITE_OBP_API_PORTAL_HOST=https://apisandbox.openbankproject.com
|
||||
####################################################################################
|
||||
|
||||
VITE_OBP_API_VERSION=v6.0.0
|
||||
#The default version of the root page, it has the default value `OBP+VITE_OBP_API_VERSION`
|
||||
#The format must follow standard+Version, e.g., OBPv5.1.0, BGv1, or BGv1.3.
|
||||
#VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv6.0.0
|
||||
|
||||
# API Manager
|
||||
VITE_OBP_API_MANAGER_HOST=https://apimanagersandbox.openbankproject.com
|
||||
VITE_SHOW_API_MANAGER_BUTTON=false
|
||||
### OBP API Configuration ###
|
||||
VITE_OBP_API_HOST=http://127.0.0.1:8080
|
||||
VITE_OBP_API_VERSION=v5.1.0
|
||||
|
||||
### API Explorer Host ###
|
||||
VITE_OBP_API_EXPLORER_HOST=http://localhost:5173
|
||||
VITE_OBP_CONSUMER_KEY=your_consumer_key
|
||||
VITE_OBP_CONSUMER_SECRET=your_consumer_secret
|
||||
VITE_OBP_REDIRECT_URL=http://localhost:5173/api/callback
|
||||
VITE_OPB_SERVER_SESSION_PASSWORD=very secret
|
||||
# The above code connects to localhost on port 6379.
|
||||
# To connect to a different host or port, use a connection string in the format
|
||||
# redis[s]://[[username][:password]@][host][:port][/db-number]
|
||||
# Be sure to secure your Redis instance
|
||||
VITE_OBP_REDIS_URL = redis://127.0.0.1:6379
|
||||
|
||||
# Enable the chatbot interface "Opey"
|
||||
# Note: For Opey to be connected you will need to create a public key for API Explorer II
|
||||
# To do this:
|
||||
### Session Configuration ###
|
||||
VITE_OBP_SERVER_SESSION_PASSWORD=change-me-to-a-secure-random-string
|
||||
|
||||
### OAuth2 Redirect URL (shared by all providers) ###
|
||||
VITE_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
### Redis Configuration (Optional - uses localhost:6379 if not set) ###
|
||||
# VITE_OBP_REDIS_URL=redis://127.0.0.1:6379
|
||||
# VITE_OBP_REDIS_PASSWORD=
|
||||
# VITE_OBP_REDIS_USERNAME=
|
||||
|
||||
### Multi-Provider OAuth2/OIDC Configuration ###
|
||||
### If VITE_OBP_OAUTH2_WELL_KNOWN_URL is set, it will be used
|
||||
### Otherwise, the system fetches available providers from: VITE_OBP_API_HOST/obp/v5.1.0/well-known
|
||||
### Configure credentials below for each provider you want to support
|
||||
|
||||
### (Optional) ###
|
||||
# VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
|
||||
### OBP-OIDC Provider ###
|
||||
VITE_OBP_OIDC_CLIENT_ID=your-obp-oidc-client-id
|
||||
VITE_OBP_OIDC_CLIENT_SECRET=your-obp-oidc-client-secret
|
||||
|
||||
### OBP Consumer Key (for API calls) ###
|
||||
VITE_OBP_CONSUMER_KEY=your-obp-oidc-client-id
|
||||
|
||||
### Keycloak Provider (Optional) ###
|
||||
# VITE_KEYCLOAK_CLIENT_ID=your-keycloak-client-id
|
||||
# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||
|
||||
### Google Provider (Optional) ###
|
||||
# VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||
# VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
|
||||
### GitHub Provider (Optional) ###
|
||||
# VITE_GITHUB_CLIENT_ID=your-github-client-id
|
||||
# VITE_GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
|
||||
### Custom OIDC Provider (Optional) ###
|
||||
# VITE_CUSTOM_OIDC_PROVIDER_NAME=my-custom-provider
|
||||
# VITE_CUSTOM_OIDC_CLIENT_ID=your-custom-client-id
|
||||
# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-custom-client-secret
|
||||
|
||||
### Chatbot Configuration (Optional) ###
|
||||
VITE_CHATBOT_ENABLED=false
|
||||
VITE_CHATBOT_URL=http://localhost:5000
|
||||
VITE_OPEY_CONSUMER_ID=opey_consumer_id # For granting a consent to Opey
|
||||
# VITE_CHATBOT_URL=http://localhost:5000
|
||||
|
||||
# Product styling setting
|
||||
#VITE_OBP_LINKS_COLOR="#52b165"
|
||||
#VITE_OBP_HEADER_LINKS_COLOR="#39455f"
|
||||
#VITE_OBP_HEADER_LINKS_HOVER_COLOR="#39455f"
|
||||
#VITE_OBP_HEADER_LINKS_BACKGROUND_COLOR="#eef0f4"
|
||||
#VITE_OBP_LOGO_URL=https://static.openbankproject.com/images/obp_logo.png
|
||||
|
||||
# https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production
|
||||
# The value could be: development, staging, production
|
||||
# NODE_ENV=development
|
||||
|
||||
# If you have a problem with session storage (which will cause problems with login) you can enable this. See README for further info.
|
||||
#DEBUG=express-session
|
||||
### Resource Docs Version ###
|
||||
VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv6.0.0
|
||||
|
||||
8
.github/workflows/build_container_image.yml
vendored
8
.github/workflows/build_container_image.yml
vendored
@ -8,7 +8,6 @@ env:
|
||||
DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }}
|
||||
DOCKER_HUB_REPOSITORY: api-explorer-ii
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@ -24,9 +23,11 @@ jobs:
|
||||
echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io
|
||||
docker build . --file Dockerfiles/Dockerfile_backend --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ steps.extract_branch.outputs.branch }} --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest
|
||||
docker build . --file Dockerfiles/Dockerfile_frontend --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}-nginx:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}-nginx:${{ steps.extract_branch.outputs.branch }} --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}-nginx:latest
|
||||
echo docker api-explorer-ii with latest tag done
|
||||
|
||||
docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags
|
||||
docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}-nginx --all-tags
|
||||
echo docker api-explorer-ii with latest tag done
|
||||
echo docker push api-explorer-ii with latest tag done
|
||||
|
||||
- uses: sigstore/cosign-installer@main
|
||||
- name: Write signing key to disk (only needed for `cosign sign --key`)
|
||||
@ -41,6 +42,3 @@ jobs:
|
||||
-a "ref=${{ github.sha }}" \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ steps.extract_branch.outputs.branch }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
name: build and publish container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- '!develop'
|
||||
env:
|
||||
DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }}
|
||||
DOCKER_HUB_REPOSITORY: api-explorer-ii
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "branch=$(echo ${GITHUB_REF#refs/heads/})" >>$GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build the Docker image without latest tag
|
||||
run: |
|
||||
echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io
|
||||
docker build . --file Dockerfiles/Dockerfile_backend --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ steps.extract_branch.outputs.branch }}
|
||||
docker build . --file Dockerfiles/Dockerfile_frontend --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}-nginx:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}-nginx:${{ steps.extract_branch.outputs.branch }}
|
||||
docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags
|
||||
docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}-nginx --all-tags
|
||||
echo docker api-explorer-ii without latest tag done
|
||||
|
||||
- uses: sigstore/cosign-installer@main
|
||||
- name: Write signing key to disk (only needed for `cosign sign --key`)
|
||||
run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key
|
||||
- name: Sign container image with annotations from our environment
|
||||
env:
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
cosign sign -y --key cosign.key \
|
||||
-a "repo=${{ github.repository }}" \
|
||||
-a "workflow=${{ github.workflow }}" \
|
||||
-a "ref=${{ github.sha }}" \
|
||||
docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ steps.extract_branch.outputs.branch }}
|
||||
|
||||
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -61,3 +61,7 @@ src/test/integration/playwright/.auth/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright-coverage/
|
||||
shared-constants.js
|
||||
|
||||
# Documentation
|
||||
untracked_docs/
|
||||
|
||||
38
.zed/settings.json
Normal file
38
.zed/settings.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"theme": {
|
||||
"mode": "dark",
|
||||
"dark": "API-Explorer-II-Green",
|
||||
"light": "API-Explorer-II-Green"
|
||||
},
|
||||
"ui_font_size": 14,
|
||||
"buffer_font_size": 14,
|
||||
"terminal": {
|
||||
"font_size": 13,
|
||||
"line_height": "comfortable"
|
||||
},
|
||||
"project_panel": {
|
||||
"button": true,
|
||||
"default_width": 300,
|
||||
"dock": "left"
|
||||
},
|
||||
"tab_bar": {
|
||||
"show": true
|
||||
},
|
||||
"status_bar": {
|
||||
"show": true
|
||||
},
|
||||
"toolbar": {
|
||||
"breadcrumbs": true,
|
||||
"quick_actions": true
|
||||
},
|
||||
"workspace": {
|
||||
"save_on_focus_change": true
|
||||
},
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"autoFetch": true
|
||||
},
|
||||
"format_on_save": "on",
|
||||
"show_whitespaces": "selection",
|
||||
"soft_wrap": "prefer_line"
|
||||
}
|
||||
8
.zed/tasks.json
Normal file
8
.zed/tasks.json
Normal file
@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "🟢 Open Terminal Here",
|
||||
"command": "gnome-terminal",
|
||||
"args": ["--title=\"🟢 API-Explorer\"", "--working-directory=${workspaceFolder}"],
|
||||
"reveal": "never"
|
||||
}
|
||||
]
|
||||
28
.zed/theme.json
Normal file
28
.zed/theme.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "API-Explorer-II-Green",
|
||||
"author": "Portal/Opey Development Setup",
|
||||
"themes": [
|
||||
{
|
||||
"name": "API-Explorer-II-Green",
|
||||
"appearance": "dark",
|
||||
"style": {
|
||||
"background": "#1e1e1e",
|
||||
"foreground": "#d4d4d4",
|
||||
"cursor": "#28A745",
|
||||
"selection": "#28A74533",
|
||||
"selected_line_background": "#28A74511",
|
||||
"line_number": "#858585",
|
||||
"active_line_number": "#28A745",
|
||||
"find_highlight": "#28A745",
|
||||
"border": "#28A74566",
|
||||
"panel_background": "#252526",
|
||||
"panel_focused_border": "#28A745",
|
||||
"tab_bar_background": "#2d2d30",
|
||||
"tab_active_background": "#28A74588",
|
||||
"status_bar_background": "#28A74566",
|
||||
"title_bar_background": "#28A74544",
|
||||
"toolbar_background": "#37373d"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
1171
CONVERT_TO_SVELTE.md
Normal file
1171
CONVERT_TO_SVELTE.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,4 @@ COPY --from=builder /home/node/app/dist-server /home/node/app
|
||||
RUN mkdir /home/node/node_modules
|
||||
COPY --from=builder /home/node/app/node_modules /home/node/node_modules
|
||||
WORKDIR /home/node/app
|
||||
CMD ["node", "app.js"]
|
||||
|
||||
|
||||
CMD ["node", "server/app.js"]
|
||||
|
||||
@ -37,7 +37,3 @@ COPY --from=gobuilder /usr/src/app/prestart /bin/prestart
|
||||
RUN chgrp -R 0 /opt/app-root/src/ && chmod -R g+rwX /opt/app-root/src/
|
||||
USER 1001
|
||||
CMD /bin/prestart ; nginx -g "daemon off;"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
237
IMPLEMENTATION-COMPLETE.txt
Normal file
237
IMPLEMENTATION-COMPLETE.txt
Normal file
@ -0,0 +1,237 @@
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ✅ MULTI-OIDC PROVIDER IMPLEMENTATION COMPLETE ✅ ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Branch: multi-login
|
||||
Date: 2024-12-28
|
||||
Status: ✅ READY FOR TESTING
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
SUMMARY
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Total Changes: 5,774 lines added/modified
|
||||
New Files: 9 (5 docs + 4 code files)
|
||||
Modified Files: 5
|
||||
Commits: 6
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
WHAT WAS IMPLEMENTED
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
✅ Backend (100% Complete)
|
||||
├─ OAuth2ClientWithConfig.ts (299 lines)
|
||||
├─ OAuth2ProviderFactory.ts (241 lines)
|
||||
├─ OAuth2ProviderManager.ts (380 lines)
|
||||
├─ OAuth2ProvidersController.ts (108 lines)
|
||||
├─ Updated OAuth2ConnectController (+172 lines)
|
||||
├─ Updated OAuth2CallbackController (+249 lines)
|
||||
├─ Updated app.ts (+54 lines)
|
||||
└─ server/types/oauth2.ts (130 lines)
|
||||
|
||||
✅ Frontend (100% Complete)
|
||||
└─ Updated HeaderNav.vue (+188 lines)
|
||||
├─ Fetch providers from API
|
||||
├─ Provider selection dialog
|
||||
├─ Single provider direct login
|
||||
├─ Error handling
|
||||
└─ Responsive design
|
||||
|
||||
✅ Documentation (100% Complete)
|
||||
├─ MULTI-OIDC-PROVIDER-IMPLEMENTATION.md (1,917 lines)
|
||||
├─ MULTI-OIDC-PROVIDER-SUMMARY.md (372 lines)
|
||||
├─ MULTI-OIDC-FLOW-DIAGRAM.md (577 lines)
|
||||
├─ MULTI-OIDC-IMPLEMENTATION-STATUS.md (361 lines)
|
||||
└─ MULTI-OIDC-TESTING-GUIDE.md (790 lines)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
KEY FEATURES
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
✅ Dynamic Provider Discovery
|
||||
• Fetches providers from OBP API /obp/v5.1.0/well-known
|
||||
• No hardcoded provider list
|
||||
• Automatic provider registration
|
||||
|
||||
✅ Multi-Provider Support
|
||||
• OBP-OIDC, Keycloak, Google, GitHub
|
||||
• Strategy pattern for extensibility
|
||||
• Environment variable configuration
|
||||
|
||||
✅ Health Monitoring
|
||||
• Real-time provider status tracking
|
||||
• 60-second health check intervals
|
||||
• Automatic status updates
|
||||
|
||||
✅ Security
|
||||
• PKCE (Proof Key for Code Exchange)
|
||||
• State validation (CSRF protection)
|
||||
• Secure token storage
|
||||
|
||||
✅ User Experience
|
||||
• Provider selection dialog
|
||||
• Single provider auto-login
|
||||
• Provider icons and formatted names
|
||||
• Loading states and error handling
|
||||
|
||||
✅ Backward Compatible
|
||||
• Legacy single-provider mode still works
|
||||
• No breaking changes
|
||||
• Gradual migration path
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
API ENDPOINTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
NEW:
|
||||
GET /api/oauth2/providers
|
||||
Returns: List of available providers with status
|
||||
|
||||
UPDATED:
|
||||
GET /api/oauth2/connect?provider=<name>&redirect=<url>
|
||||
Initiates login with selected provider
|
||||
|
||||
GET /api/oauth2/callback?code=<code>&state=<state>
|
||||
Handles OAuth callback from any provider
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONFIGURATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Environment Variables (per provider):
|
||||
|
||||
# OBP-OIDC
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=your-client-id
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=your-secret
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Keycloak
|
||||
VITE_KEYCLOAK_CLIENT_ID=your-client-id
|
||||
VITE_KEYCLOAK_CLIENT_SECRET=your-secret
|
||||
VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Add more providers as needed...
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
TESTING
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
See: MULTI-OIDC-TESTING-GUIDE.md
|
||||
|
||||
15 comprehensive test scenarios covering:
|
||||
✓ Provider discovery
|
||||
✓ Backend API endpoints
|
||||
✓ Login flows (single/multiple providers)
|
||||
✓ Health monitoring
|
||||
✓ Session persistence
|
||||
✓ Error handling
|
||||
✓ Security (PKCE, state validation)
|
||||
✓ Backward compatibility
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEXT STEPS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
1. Test the Implementation
|
||||
└─ Follow MULTI-OIDC-TESTING-GUIDE.md
|
||||
|
||||
2. Configure Environment
|
||||
└─ Set up provider credentials
|
||||
|
||||
3. Start Services
|
||||
├─ Start OBP API
|
||||
├─ Start OIDC providers (OBP-OIDC, Keycloak)
|
||||
├─ Start backend: npm run dev:backend
|
||||
└─ Start frontend: npm run dev
|
||||
|
||||
4. Test Login Flow
|
||||
├─ Navigate to http://localhost:5173
|
||||
├─ Click "Login"
|
||||
├─ Select provider
|
||||
└─ Authenticate
|
||||
|
||||
5. Create Pull Request
|
||||
└─ Merge multi-login → develop
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
GIT COMMANDS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Current branch: multi-login (clean, nothing to commit)
|
||||
|
||||
View changes:
|
||||
git diff develop --stat
|
||||
git log --oneline develop..multi-login
|
||||
|
||||
Test locally:
|
||||
npm run dev:backend # Terminal 1
|
||||
npm run dev # Terminal 2
|
||||
|
||||
Create PR:
|
||||
git push origin multi-login
|
||||
# Then create PR on GitHub: multi-login → develop
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
COMMITS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
41ddc8f - Add comprehensive testing guide
|
||||
3a03812 - Add multi-provider login UI to HeaderNav
|
||||
07d47ca - Add implementation status document
|
||||
755dc70 - Fix TypeScript compilation errors
|
||||
8b90bb4 - Add controllers and app initialization
|
||||
3dadca8 - Add multi-OIDC provider backend services
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
DOCUMENTATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
📖 Implementation Guide
|
||||
MULTI-OIDC-PROVIDER-IMPLEMENTATION.md
|
||||
• Complete technical specification
|
||||
• Detailed code examples
|
||||
• Architecture diagrams
|
||||
|
||||
📖 Executive Summary
|
||||
MULTI-OIDC-PROVIDER-SUMMARY.md
|
||||
• High-level overview
|
||||
• Key benefits
|
||||
• Quick reference
|
||||
|
||||
📖 Flow Diagrams
|
||||
MULTI-OIDC-FLOW-DIAGRAM.md
|
||||
• Visual system flows
|
||||
• Component interactions
|
||||
• Data flow diagrams
|
||||
|
||||
📖 Implementation Status
|
||||
MULTI-OIDC-IMPLEMENTATION-STATUS.md
|
||||
• Completed tasks checklist
|
||||
• Configuration guide
|
||||
• Session data structure
|
||||
|
||||
📖 Testing Guide
|
||||
MULTI-OIDC-TESTING-GUIDE.md
|
||||
• Step-by-step test scenarios
|
||||
• Troubleshooting tips
|
||||
• Performance testing
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
SUCCESS METRICS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
✅ 100% Backend implementation complete
|
||||
✅ 100% Frontend implementation complete
|
||||
✅ 100% Documentation complete
|
||||
✅ 0 TypeScript errors
|
||||
✅ 0 compilation errors
|
||||
✅ Backward compatible
|
||||
✅ Ready for testing
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Implementation completed successfully! 🎉
|
||||
|
||||
The multi-login branch is ready for testing and merging.
|
||||
577
MULTI-OIDC-FLOW-DIAGRAM.md
Normal file
577
MULTI-OIDC-FLOW-DIAGRAM.md
Normal file
@ -0,0 +1,577 @@
|
||||
# Multi-OIDC Provider Flow Diagrams
|
||||
|
||||
## 1. System Initialization Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SERVER STARTUP │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Load Environment Variables │
|
||||
│ - VITE_OBP_OAUTH2_CLIENT_ID │
|
||||
│ - VITE_KEYCLOAK_CLIENT_ID │
|
||||
│ - VITE_GOOGLE_CLIENT_ID (optional) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Initialize OAuth2ProviderFactory │
|
||||
│ - Load provider strategies │
|
||||
│ - Configure client credentials │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Initialize OAuth2ProviderManager │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Fetch Well-Known URIs from OBP API │
|
||||
│ GET /obp/v5.1.0/well-known │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────┴─────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ OBP-OIDC │ │ Keycloak │
|
||||
│ Well-Known URL │ │ Well-Known URL │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
│ │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ For Each Provider: │
|
||||
│ 1. Get strategy from factory │
|
||||
│ 2. Create OAuth2ClientWithConfig │
|
||||
│ 3. Fetch .well-known/openid-config │
|
||||
│ 4. Store in providers Map │
|
||||
│ 5. Track status (available/unavailable)│
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Start Health Check (60s intervals) │
|
||||
│ - Monitor all providers │
|
||||
│ - Update availability status │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Server Ready │
|
||||
│ - Multiple providers initialized │
|
||||
│ - Health monitoring active │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. User Login Flow (Multi-Provider)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ USER │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Opens API Explorer II
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ HeaderNav.vue │
|
||||
│ - Fetch available providers │
|
||||
│ GET /api/oauth2/providers │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Display Login Button │
|
||||
│ (with dropdown if multiple providers) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
│ User clicks "Login"
|
||||
▼
|
||||
┌─────────┴─────────┐
|
||||
│ │
|
||||
Single │ │ Multiple
|
||||
Provider │ │ Providers
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Direct Login │ │ Show Provider │
|
||||
│ │ │ Selection Dialog │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
│ │
|
||||
│ │ User selects provider
|
||||
│ │ (e.g., "obp-oidc")
|
||||
│ │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Redirect to: │
|
||||
│ /api/oauth2/connect? │
|
||||
│ provider=obp-oidc& │
|
||||
│ redirect=/resource-docs │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ OAuth2ConnectController │
|
||||
│ 1. Get provider from query param │
|
||||
│ 2. Retrieve OAuth2Client from Manager │
|
||||
│ 3. Generate PKCE code_verifier │
|
||||
│ 4. Generate code_challenge (SHA256) │
|
||||
│ 5. Generate state (CSRF token) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Store in Session: │
|
||||
│ - oauth2_provider: "obp-oidc" │
|
||||
│ - oauth2_code_verifier: "..." │
|
||||
│ - oauth2_state: "..." │
|
||||
│ - oauth2_redirect: "/resource-docs" │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Build Authorization URL: │
|
||||
│ {provider_auth_endpoint}? │
|
||||
│ client_id=...& │
|
||||
│ redirect_uri=...& │
|
||||
│ response_type=code& │
|
||||
│ scope=openid+profile+email& │
|
||||
│ state=...& │
|
||||
│ code_challenge=...& │
|
||||
│ code_challenge_method=S256 │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 302 Redirect to OIDC Provider │
|
||||
│ (e.g., OBP-OIDC or Keycloak) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OIDC PROVIDER (OBP-OIDC / Keycloak) │
|
||||
│ - User enters credentials │
|
||||
│ - User authenticates │
|
||||
│ - Provider validates credentials │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Authentication successful
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 302 Redirect back to: │
|
||||
│ /api/oauth2/callback? │
|
||||
│ code=AUTHORIZATION_CODE& │
|
||||
│ state=CSRF_TOKEN │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ OAuth2CallbackController │
|
||||
│ 1. Retrieve provider from session │
|
||||
│ 2. Validate state (CSRF protection) │
|
||||
│ 3. Get OAuth2Client for provider │
|
||||
│ 4. Retrieve code_verifier from session │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Exchange Authorization Code for Tokens │
|
||||
│ POST {provider_token_endpoint} │
|
||||
│ Body: │
|
||||
│ grant_type=authorization_code │
|
||||
│ code=... │
|
||||
│ redirect_uri=... │
|
||||
│ client_id=... │
|
||||
│ client_secret=... │
|
||||
│ code_verifier=... (PKCE) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Receive Tokens: │
|
||||
│ - access_token │
|
||||
│ - refresh_token │
|
||||
│ - id_token (JWT) │
|
||||
│ - expires_in │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Fetch User Info │
|
||||
│ GET {provider_userinfo_endpoint} │
|
||||
│ Authorization: Bearer {access_token} │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Store in Session: │
|
||||
│ - oauth2_access_token │
|
||||
│ - oauth2_refresh_token │
|
||||
│ - oauth2_id_token │
|
||||
│ - oauth2_provider: "obp-oidc" │
|
||||
│ - user: { username, email, name, ... } │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 302 Redirect to Original Page │
|
||||
│ /resource-docs │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ User Logged In │
|
||||
│ - Username displayed in header │
|
||||
│ - Access token available for API calls │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API Request Flow (Authenticated)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ USER │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Makes API request
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
│ GET /obp/v5.1.0/banks │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ RequestController │
|
||||
│ - Retrieve access_token from session │
|
||||
│ - Check if token is expired │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
Token │ │ Token
|
||||
Valid │ │ Expired
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Use Access Token │ │ Refresh Token │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌───────────────────────────┐
|
||||
│ │ Get provider from session│
|
||||
│ │ Get refresh_token │
|
||||
│ └───────────────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌───────────────────────────┐
|
||||
│ │ POST {token_endpoint} │
|
||||
│ │ grant_type=refresh_token │
|
||||
│ │ refresh_token=... │
|
||||
│ └───────────────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌───────────────────────────┐
|
||||
│ │ Receive new tokens │
|
||||
│ │ - new access_token │
|
||||
│ │ - new refresh_token │
|
||||
│ │ Update session │
|
||||
│ └───────────────────────────┘
|
||||
│ │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Forward to OBP API │
|
||||
│ Authorization: Bearer {access_token} │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ OBP API validates token with provider │
|
||||
│ - Validates signature │
|
||||
│ - Checks expiration │
|
||||
│ - Verifies scopes │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Return API Response │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Display data to user │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Provider Health Check Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Health Check Timer (60s intervals) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ OAuth2ProviderManager │
|
||||
│ performHealthCheck() │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────┴─────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Check OBP-OIDC │ │ Check Keycloak │
|
||||
│ HEAD {issuer} │ │ HEAD {issuer} │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
│ │
|
||||
┌───────┴───────┐ ┌───────┴───────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
|
||||
│ OK │ │ FAIL │ │ OK │ │ FAIL │
|
||||
│ 200 │ │ 5xx │ │ 200 │ │ 5xx │
|
||||
└──────┘ └──────┘ └──────┘ └──────┘
|
||||
│ │ │ │
|
||||
└───────┬───────┘ └───────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Update Status │ │ Update Status │
|
||||
│ available: true │ │ available: false │
|
||||
│ lastChecked: now │ │ lastChecked: now │
|
||||
│ │ │ error: "..." │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Log Health Status │
|
||||
│ - obp-oidc: ✓ healthy │
|
||||
│ - keycloak: ✗ unhealthy (timeout) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Frontend can query: │
|
||||
│ GET /api/oauth2/providers │
|
||||
│ (Returns updated status) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Interaction Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (Vue 3) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ HeaderNav.vue │ │
|
||||
│ │ - fetchAvailableProviders() │ │
|
||||
│ │ - handleLoginClick() │ │
|
||||
│ │ - loginWithProvider(provider) │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└─────────────────────────┼────────────────────────────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND (Express) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ OAuth2ProvidersController │ │
|
||||
│ │ GET /api/oauth2/providers │ │
|
||||
│ └────────┬───────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ OAuth2ProviderManager │ │
|
||||
│ │ - providers: Map<string, OAuth2ClientWithConfig> │ │
|
||||
│ │ - providerStatus: Map<string, ProviderStatus> │ │
|
||||
│ │ - fetchWellKnownUris() │ │
|
||||
│ │ - initializeProviders() │ │
|
||||
│ │ - getProvider(name) │ │
|
||||
│ │ - getAvailableProviders() │ │
|
||||
│ │ - startHealthCheck() │ │
|
||||
│ └────────┬───────────────────────────────┬────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ uses │ creates │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ OBPClientService │ │ OAuth2ProviderFactory │ │
|
||||
│ │ - Fetch well-known │ │ - strategies: Map │ │
|
||||
│ │ from OBP API │ │ - initializeProvider() │ │
|
||||
│ └─────────────────────┘ └──────────┬──────────────┘ │
|
||||
│ │ │
|
||||
│ │ creates │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ OAuth2ClientWithConfig │ │
|
||||
│ │ - OIDCConfig │ │
|
||||
│ │ - provider: string │ │
|
||||
│ │ - initOIDCConfig() │ │
|
||||
│ │ - getAuthEndpoint() │ │
|
||||
│ │ - getTokenEndpoint() │ │
|
||||
│ │ - getUserInfoEndpoint() │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ OAuth2ConnectController │ │
|
||||
│ │ GET /api/oauth2/connect?provider=obp-oidc │ │
|
||||
│ │ 1. Get provider from ProviderManager │ │
|
||||
│ │ 2. Generate PKCE │ │
|
||||
│ │ 3. Store in session │ │
|
||||
│ │ 4. Redirect to provider │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ OAuth2CallbackController │ │
|
||||
│ │ GET /api/oauth2/callback?code=xxx&state=yyy │ │
|
||||
│ │ 1. Get provider from session │ │
|
||||
│ │ 2. Get OAuth2Client from ProviderManager │ │
|
||||
│ │ 3. Exchange code for tokens │ │
|
||||
│ │ 4. Fetch user info │ │
|
||||
│ │ 5. Store in session │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────┬───────────────────────────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OBP API │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ GET /obp/v5.1.0/well-known │
|
||||
│ → Returns list of OIDC provider configurations │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OIDC PROVIDERS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ OBP-OIDC │ │ Keycloak │ │
|
||||
│ │ localhost:9000 │ │ localhost:8180 │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Flow: Session Storage
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SESSION DATA LIFECYCLE │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Step 1: Login Initiation
|
||||
┌──────────────────────────────────────┐
|
||||
│ Session │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ oauth2_provider: "obp-oidc" │ │ ← Store provider choice
|
||||
│ │ oauth2_code_verifier: "..." │ │ ← Store for PKCE
|
||||
│ │ oauth2_state: "..." │ │ ← Store for CSRF protection
|
||||
│ │ oauth2_redirect: "/resource-docs"│ │ ← Store redirect URL
|
||||
│ └────────────────────────────────┘ │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
Step 2: After Token Exchange
|
||||
┌──────────────────────────────────────┐
|
||||
│ Session │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ oauth2_provider: "obp-oidc" │ │ ← Provider used
|
||||
│ │ oauth2_access_token: "..." │ │ ← For API calls
|
||||
│ │ oauth2_refresh_token: "..." │ │ ← For token refresh
|
||||
│ │ oauth2_id_token: "..." │ │ ← User identity (JWT)
|
||||
│ │ user: { │ │ ← User profile
|
||||
│ │ username: "john.doe" │ │
|
||||
│ │ email: "john@example.com" │ │
|
||||
│ │ name: "John Doe" │ │
|
||||
│ │ provider: "obp-oidc" │ │
|
||||
│ │ sub: "uuid-1234" │ │
|
||||
│ │ } │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└──────────────────────────────────────┘
|
||||
(oauth2_code_verifier deleted)
|
||||
(oauth2_state deleted)
|
||||
(oauth2_redirect deleted)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ERROR SCENARIOS │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Scenario 1: Provider Not Available
|
||||
User clicks login
|
||||
│
|
||||
▼
|
||||
Fetch providers → No providers available
|
||||
│
|
||||
▼
|
||||
Show error: "Authentication not available"
|
||||
|
||||
Scenario 2: Invalid Provider
|
||||
User selects provider → GET /api/oauth2/connect?provider=invalid
|
||||
│
|
||||
▼
|
||||
ProviderManager.getProvider("invalid") → undefined
|
||||
│
|
||||
▼
|
||||
Return 400: "Provider not available"
|
||||
|
||||
Scenario 3: State Mismatch (CSRF Attack)
|
||||
Callback received → state parameter doesn't match session
|
||||
│
|
||||
▼
|
||||
Reject request → Redirect with error
|
||||
│
|
||||
▼
|
||||
Display: "Invalid state (CSRF protection)"
|
||||
|
||||
Scenario 4: Token Exchange Failure
|
||||
Exchange code for tokens → 401 Unauthorized
|
||||
│
|
||||
▼
|
||||
Log error → Redirect with error
|
||||
│
|
||||
▼
|
||||
Display: "Authentication failed"
|
||||
|
||||
Scenario 5: Provider Health Check Failure
|
||||
Health check → Provider unreachable
|
||||
│
|
||||
▼
|
||||
Mark as unavailable → Update status
|
||||
│
|
||||
▼
|
||||
Frontend queries providers → Shows as unavailable
|
||||
│
|
||||
▼
|
||||
User cannot select unavailable provider
|
||||
```
|
||||
361
MULTI-OIDC-IMPLEMENTATION-STATUS.md
Normal file
361
MULTI-OIDC-IMPLEMENTATION-STATUS.md
Normal file
@ -0,0 +1,361 @@
|
||||
# Multi-OIDC Provider Implementation Status
|
||||
|
||||
**Branch:** `multi-login`
|
||||
**Date:** 2024
|
||||
**Status:** ✅ Backend Complete - Frontend In Progress
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document tracks the implementation status of multiple OIDC provider support in API Explorer II, enabling users to choose from different identity providers (OBP-OIDC, Keycloak, Google, GitHub, etc.) at login time.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### ✅ Completed (Backend)
|
||||
|
||||
#### 1. Type Definitions
|
||||
- [x] **`server/types/oauth2.ts`**
|
||||
- `WellKnownUri` - Provider information from OBP API
|
||||
- `WellKnownResponse` - Response from `/obp/v5.1.0/well-known`
|
||||
- `ProviderStrategy` - Provider configuration
|
||||
- `ProviderStatus` - Provider health information
|
||||
- `OIDCConfiguration` - OpenID Connect discovery
|
||||
- `TokenResponse` - OAuth2 token response
|
||||
- `UserInfo` - OIDC UserInfo endpoint response
|
||||
|
||||
#### 2. Core Services
|
||||
- [x] **`server/services/OAuth2ClientWithConfig.ts`**
|
||||
- Extends arctic `OAuth2Client` with OIDC discovery
|
||||
- Stores provider name and OIDC configuration
|
||||
- Methods:
|
||||
- `initOIDCConfig()` - Fetch and validate OIDC discovery document
|
||||
- `getAuthorizationEndpoint()` - Get auth endpoint from config
|
||||
- `getTokenEndpoint()` - Get token endpoint from config
|
||||
- `getUserInfoEndpoint()` - Get userinfo endpoint from config
|
||||
- `exchangeAuthorizationCode()` - Token exchange with PKCE
|
||||
- `refreshTokens()` - Refresh access token
|
||||
- `isInitialized()` - Check if config loaded
|
||||
|
||||
- [x] **`server/services/OAuth2ProviderFactory.ts`**
|
||||
- Strategy pattern for provider configurations
|
||||
- Loads strategies from environment variables
|
||||
- Supported providers:
|
||||
- OBP-OIDC (`VITE_OBP_OAUTH2_*`)
|
||||
- Keycloak (`VITE_KEYCLOAK_*`)
|
||||
- Google (`VITE_GOOGLE_*`)
|
||||
- GitHub (`VITE_GITHUB_*`)
|
||||
- Custom OIDC (`VITE_CUSTOM_OIDC_*`)
|
||||
- Methods:
|
||||
- `initializeProvider()` - Create and initialize OAuth2 client
|
||||
- `getConfiguredProviders()` - List available strategies
|
||||
- `hasStrategy()` - Check if provider configured
|
||||
|
||||
- [x] **`server/services/OAuth2ProviderManager.ts`**
|
||||
- Manages multiple OAuth2 providers
|
||||
- Fetches well-known URIs from OBP API
|
||||
- Tracks provider health status
|
||||
- Performs periodic health checks (60s intervals)
|
||||
- Methods:
|
||||
- `fetchWellKnownUris()` - Get providers from OBP API
|
||||
- `initializeProviders()` - Initialize all providers
|
||||
- `getProvider()` - Get OAuth2 client by name
|
||||
- `getAvailableProviders()` - List healthy providers
|
||||
- `getAllProviderStatus()` - Get status for all providers
|
||||
- `startHealthCheck()` - Start monitoring
|
||||
- `stopHealthCheck()` - Stop monitoring
|
||||
- `retryProvider()` - Retry failed provider
|
||||
|
||||
#### 3. Controllers
|
||||
- [x] **`server/controllers/OAuth2ProvidersController.ts`**
|
||||
- New endpoint: `GET /api/oauth2/providers`
|
||||
- Returns list of available providers with status
|
||||
- Used by frontend for provider selection UI
|
||||
|
||||
- [x] **`server/controllers/OAuth2ConnectController.ts`** (Updated)
|
||||
- Updated: `GET /api/oauth2/connect?provider=<name>&redirect=<url>`
|
||||
- Supports both multi-provider and legacy single-provider mode
|
||||
- Multi-provider: Uses provider from query parameter
|
||||
- Legacy: Falls back to existing OAuth2Service
|
||||
- Generates PKCE parameters (code_verifier, code_challenge, state)
|
||||
- Stores provider name in session
|
||||
- Redirects to provider's authorization endpoint
|
||||
|
||||
- [x] **`server/controllers/OAuth2CallbackController.ts`** (Updated)
|
||||
- Updated: `GET /api/oauth2/callback?code=<code>&state=<state>`
|
||||
- Retrieves provider from session
|
||||
- Uses correct OAuth2 client for token exchange
|
||||
- Validates state (CSRF protection)
|
||||
- Exchanges authorization code for tokens
|
||||
- Fetches user info from provider
|
||||
- Stores tokens and user data in session
|
||||
- Supports both multi-provider and legacy modes
|
||||
|
||||
#### 4. Server Initialization
|
||||
- [x] **`server/app.ts`** (Updated)
|
||||
- Initialize `OAuth2ProviderManager` on startup
|
||||
- Fetch providers from OBP API
|
||||
- Start health monitoring (60s intervals)
|
||||
- Register `OAuth2ProvidersController`
|
||||
- Maintain backward compatibility with legacy OAuth2Service
|
||||
|
||||
---
|
||||
|
||||
### 🚧 In Progress (Frontend)
|
||||
|
||||
#### 5. Frontend Components
|
||||
- [ ] **`src/components/HeaderNav.vue`** (To be updated)
|
||||
- Fetch available providers on mount
|
||||
- Display provider selection UI
|
||||
- Handle login with selected provider
|
||||
- Show provider availability status
|
||||
|
||||
- [ ] **`src/components/ProviderSelector.vue`** (To be created)
|
||||
- Modal/dropdown for provider selection
|
||||
- Display provider names and status
|
||||
- Trigger login with selected provider
|
||||
- Responsive design
|
||||
|
||||
---
|
||||
|
||||
### 📋 Not Started
|
||||
|
||||
#### 6. Testing
|
||||
- [ ] Unit tests for `OAuth2ClientWithConfig`
|
||||
- [ ] Unit tests for `OAuth2ProviderFactory`
|
||||
- [ ] Unit tests for `OAuth2ProviderManager`
|
||||
- [ ] Integration tests for multi-provider flow
|
||||
- [ ] E2E tests for login flow
|
||||
|
||||
#### 7. Documentation
|
||||
- [ ] Update README.md with multi-provider setup
|
||||
- [ ] Update OAUTH2-README.md
|
||||
- [ ] Create migration guide
|
||||
- [ ] Update deployment documentation
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
1. Server Startup
|
||||
├─> OAuth2ProviderManager.initializeProviders()
|
||||
├─> Fetch well-known URIs from OBP API (/obp/v5.1.0/well-known)
|
||||
├─> For each provider:
|
||||
│ ├─> OAuth2ProviderFactory.initializeProvider()
|
||||
│ ├─> Create OAuth2ClientWithConfig
|
||||
│ ├─> Fetch .well-known/openid-configuration
|
||||
│ └─> Store in providers Map
|
||||
└─> Start health monitoring (60s intervals)
|
||||
|
||||
2. User Login Flow
|
||||
├─> Frontend: Fetch available providers (GET /api/oauth2/providers)
|
||||
├─> User selects provider (e.g., "obp-oidc")
|
||||
├─> Redirect: /api/oauth2/connect?provider=obp-oidc&redirect=/resource-docs
|
||||
├─> OAuth2ConnectController:
|
||||
│ ├─> Get OAuth2Client for selected provider
|
||||
│ ├─> Generate PKCE (code_verifier, code_challenge, state)
|
||||
│ ├─> Store in session (provider, code_verifier, state)
|
||||
│ └─> Redirect to provider's authorization endpoint
|
||||
├─> User authenticates on OIDC provider
|
||||
├─> Callback: /api/oauth2/callback?code=xxx&state=yyy
|
||||
└─> OAuth2CallbackController:
|
||||
├─> Retrieve provider from session
|
||||
├─> Get OAuth2Client for provider
|
||||
├─> Validate state (CSRF protection)
|
||||
├─> Exchange code for tokens (with PKCE)
|
||||
├─> Fetch user info
|
||||
├─> Store tokens and user in session
|
||||
└─> Redirect to original page
|
||||
|
||||
3. Health Monitoring
|
||||
└─> Every 60 seconds:
|
||||
├─> For each provider:
|
||||
│ ├─> HEAD request to issuer endpoint
|
||||
│ └─> Update provider status (available/unavailable)
|
||||
└─> Frontend can query status via /api/oauth2/providers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# OBP-OIDC Provider (Required for OBP-OIDC)
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Keycloak Provider (Optional)
|
||||
VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
|
||||
VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret
|
||||
VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Google Provider (Optional)
|
||||
# VITE_GOOGLE_CLIENT_ID=your-google-client-id
|
||||
# VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
# VITE_GOOGLE_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# GitHub Provider (Optional)
|
||||
# VITE_GITHUB_CLIENT_ID=your-github-client-id
|
||||
# VITE_GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
# VITE_GITHUB_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Custom OIDC Provider (Optional)
|
||||
# VITE_CUSTOM_OIDC_PROVIDER_NAME=my-provider
|
||||
# VITE_CUSTOM_OIDC_CLIENT_ID=your-client-id
|
||||
# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-client-secret
|
||||
# VITE_CUSTOM_OIDC_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Legacy Single-Provider Mode (Backward Compatibility)
|
||||
# VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
### OBP API Configuration
|
||||
|
||||
The multi-provider system fetches available providers from:
|
||||
```
|
||||
GET /obp/v5.1.0/well-known
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"well_known_uris": [
|
||||
{
|
||||
"provider": "obp-oidc",
|
||||
"url": "http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration"
|
||||
},
|
||||
{
|
||||
"provider": "keycloak",
|
||||
"url": "http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### New Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/oauth2/providers` | List available OIDC providers with status |
|
||||
|
||||
### Updated Endpoints
|
||||
|
||||
| Method | Path | Query Parameters | Description |
|
||||
|--------|------|------------------|-------------|
|
||||
| GET | `/api/oauth2/connect` | `provider` (optional), `redirect` (optional) | Initiate OAuth2 flow with selected provider |
|
||||
| GET | `/api/oauth2/callback` | `code`, `state`, `error` (optional) | Handle OAuth2 callback from any provider |
|
||||
|
||||
---
|
||||
|
||||
## Session Data
|
||||
|
||||
### Login Initiation
|
||||
```javascript
|
||||
session = {
|
||||
oauth2_provider: "obp-oidc", // Provider name
|
||||
oauth2_code_verifier: "...", // PKCE code verifier
|
||||
oauth2_state: "...", // CSRF state token
|
||||
oauth2_redirect_page: "/resource-docs" // Redirect after auth
|
||||
}
|
||||
```
|
||||
|
||||
### After Token Exchange
|
||||
```javascript
|
||||
session = {
|
||||
oauth2_provider: "obp-oidc", // Provider used
|
||||
oauth2_access_token: "...", // Access token
|
||||
oauth2_refresh_token: "...", // Refresh token
|
||||
oauth2_id_token: "...", // ID token (JWT)
|
||||
user: {
|
||||
username: "john.doe",
|
||||
email: "john@example.com",
|
||||
name: "John Doe",
|
||||
provider: "obp-oidc",
|
||||
sub: "uuid-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The implementation maintains full backward compatibility with the existing single-provider OAuth2 system:
|
||||
|
||||
1. **Legacy Environment Variable**: `VITE_OBP_OAUTH2_WELL_KNOWN_URL` still works
|
||||
2. **Fallback Behavior**: If no provider parameter is specified, falls back to legacy OAuth2Service
|
||||
3. **No Breaking Changes**: Existing deployments continue to work without changes
|
||||
4. **Gradual Migration**: Can enable multi-provider incrementally
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
1. ✅ **User Choice** - Users select their preferred identity provider
|
||||
2. ✅ **Dynamic Discovery** - Providers discovered from OBP API automatically
|
||||
3. ✅ **Health Monitoring** - Real-time provider availability tracking
|
||||
4. ✅ **Extensibility** - Add new providers via environment variables only
|
||||
5. ✅ **Resilience** - Fallback to available providers if one fails
|
||||
6. ✅ **Backward Compatible** - No breaking changes for existing deployments
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Priority 1: Frontend Implementation
|
||||
1. Update `HeaderNav.vue` to fetch and display available providers
|
||||
2. Create `ProviderSelector.vue` component for provider selection
|
||||
3. Test login flow with multiple providers
|
||||
4. Handle error states gracefully
|
||||
|
||||
### Priority 2: Testing
|
||||
1. Write unit tests for all new services
|
||||
2. Create integration tests for multi-provider flow
|
||||
3. Add E2E tests for login scenarios
|
||||
|
||||
### Priority 3: Documentation
|
||||
1. Update README.md with setup instructions
|
||||
2. Document environment variables for each provider
|
||||
3. Create migration guide from single to multi-provider
|
||||
4. Update deployment documentation
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
- None currently identified
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Implementation Guide**: `MULTI-OIDC-PROVIDER-IMPLEMENTATION.md`
|
||||
- **Executive Summary**: `MULTI-OIDC-PROVIDER-SUMMARY.md`
|
||||
- **Flow Diagrams**: `MULTI-OIDC-FLOW-DIAGRAM.md`
|
||||
- **OBP-Portal Reference**: `~/Documents/workspace_2024/OBP-Portal`
|
||||
- **Branch**: `multi-login`
|
||||
|
||||
---
|
||||
|
||||
## Commits
|
||||
|
||||
1. `3dadca8` - Add multi-OIDC provider backend services
|
||||
2. `8b90bb4` - Add multi-OIDC provider controllers and update app initialization
|
||||
3. `755dc70` - Fix TypeScript compilation errors in multi-provider implementation
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2024
|
||||
**Status**: Backend implementation complete ✅
|
||||
1917
MULTI-OIDC-PROVIDER-IMPLEMENTATION.md
Normal file
1917
MULTI-OIDC-PROVIDER-IMPLEMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
372
MULTI-OIDC-PROVIDER-SUMMARY.md
Normal file
372
MULTI-OIDC-PROVIDER-SUMMARY.md
Normal file
@ -0,0 +1,372 @@
|
||||
# Multi-OIDC Provider Implementation - Executive Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a high-level summary of implementing multiple OIDC provider support in API Explorer II, based on the proven architecture from OBP-Portal.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
**API Explorer II** currently supports OAuth2/OIDC authentication with a **single provider** configured via environment variables:
|
||||
|
||||
```bash
|
||||
VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://localhost:9000/obp-oidc/.well-known/openid-configuration
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=<client-id>
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=<client-secret>
|
||||
```
|
||||
|
||||
**Limitations:**
|
||||
- Only one OIDC provider supported at a time
|
||||
- No user choice of authentication method
|
||||
- Requires redeployment to switch providers
|
||||
- No fallback if provider is unavailable
|
||||
|
||||
---
|
||||
|
||||
## Target State
|
||||
|
||||
**Multi-Provider Support** allows users to choose from multiple identity providers at login:
|
||||
|
||||
- **OBP-OIDC** - Open Bank Project's identity provider
|
||||
- **Keycloak** - Enterprise identity management
|
||||
- **Google** - Consumer identity (optional)
|
||||
- **GitHub** - Developer identity (optional)
|
||||
- **Custom** - Any OpenID Connect provider
|
||||
|
||||
---
|
||||
|
||||
## How OBP-Portal Does It
|
||||
|
||||
### 1. Dynamic Provider Discovery
|
||||
|
||||
OBP-Portal fetches available OIDC providers from the **OBP API**:
|
||||
|
||||
```
|
||||
GET /obp/v5.1.0/well-known
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"well_known_uris": [
|
||||
{
|
||||
"provider": "obp-oidc",
|
||||
"url": "http://localhost:9000/obp-oidc/.well-known/openid-configuration"
|
||||
},
|
||||
{
|
||||
"provider": "keycloak",
|
||||
"url": "http://localhost:8180/realms/obp/.well-known/openid-configuration"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Provider Manager
|
||||
|
||||
**Key Component:** `OAuth2ProviderManager`
|
||||
|
||||
**Responsibilities:**
|
||||
- Fetch well-known URIs from OBP API
|
||||
- Initialize OAuth2 client for each provider
|
||||
- Track provider health (available/unavailable)
|
||||
- Perform periodic health checks (60s intervals)
|
||||
- Provide access to specific providers
|
||||
|
||||
### 3. Provider Factory
|
||||
|
||||
**Key Component:** `OAuth2ProviderFactory`
|
||||
|
||||
**Responsibilities:**
|
||||
- Strategy pattern for provider-specific configuration
|
||||
- Load credentials from environment variables
|
||||
- Create OAuth2 clients with OIDC discovery
|
||||
- Support multiple provider types
|
||||
|
||||
**Strategy Pattern:**
|
||||
```typescript
|
||||
strategies.set('obp-oidc', {
|
||||
clientId: process.env.VITE_OBP_OAUTH2_CLIENT_ID,
|
||||
clientSecret: process.env.VITE_OBP_OAUTH2_CLIENT_SECRET,
|
||||
redirectUri: process.env.VITE_OBP_OAUTH2_REDIRECT_URL
|
||||
})
|
||||
|
||||
strategies.set('keycloak', {
|
||||
clientId: process.env.VITE_KEYCLOAK_CLIENT_ID,
|
||||
clientSecret: process.env.VITE_KEYCLOAK_CLIENT_SECRET,
|
||||
redirectUri: process.env.VITE_KEYCLOAK_REDIRECT_URL
|
||||
})
|
||||
```
|
||||
|
||||
### 4. User Flow
|
||||
|
||||
```
|
||||
1. User clicks "Login"
|
||||
→ Shows provider selection dialog
|
||||
|
||||
2. User selects provider (e.g., "OBP-OIDC")
|
||||
→ GET /api/oauth2/connect?provider=obp-oidc
|
||||
|
||||
3. Server:
|
||||
- Retrieves OAuth2 client for "obp-oidc"
|
||||
- Generates PKCE parameters
|
||||
- Stores provider name in session
|
||||
- Redirects to provider's authorization endpoint
|
||||
|
||||
4. User authenticates on selected OIDC provider
|
||||
|
||||
5. Provider redirects back:
|
||||
→ GET /api/oauth2/callback?code=xxx&state=yyy
|
||||
|
||||
6. Server:
|
||||
- Retrieves provider from session ("obp-oidc")
|
||||
- Gets corresponding OAuth2 client
|
||||
- Exchanges code for tokens
|
||||
- Stores tokens with provider name
|
||||
|
||||
7. User authenticated with selected provider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture for API Explorer II
|
||||
|
||||
### New Services
|
||||
|
||||
#### 1. **OAuth2ClientWithConfig** (extends `OAuth2Client` from arctic)
|
||||
```typescript
|
||||
class OAuth2ClientWithConfig extends OAuth2Client {
|
||||
public OIDCConfig?: OIDCConfiguration
|
||||
public provider: string
|
||||
|
||||
async initOIDCConfig(oidcConfigUrl: string): Promise<void>
|
||||
getAuthorizationEndpoint(): string
|
||||
getTokenEndpoint(): string
|
||||
getUserInfoEndpoint(): string
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. **OAuth2ProviderFactory**
|
||||
```typescript
|
||||
class OAuth2ProviderFactory {
|
||||
private strategies: Map<string, ProviderStrategy>
|
||||
|
||||
async initializeProvider(wellKnownUri: WellKnownUri): Promise<OAuth2ClientWithConfig>
|
||||
getConfiguredProviders(): string[]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. **OAuth2ProviderManager**
|
||||
```typescript
|
||||
class OAuth2ProviderManager {
|
||||
private providers: Map<string, OAuth2ClientWithConfig>
|
||||
|
||||
async fetchWellKnownUris(): Promise<WellKnownUri[]>
|
||||
async initializeProviders(): Promise<boolean>
|
||||
getProvider(providerName: string): OAuth2ClientWithConfig
|
||||
getAvailableProviders(): string[]
|
||||
startHealthCheck(intervalMs: number): void
|
||||
}
|
||||
```
|
||||
|
||||
### Updated Controllers
|
||||
|
||||
#### 1. **OAuth2ProvidersController** (NEW)
|
||||
```typescript
|
||||
GET /api/oauth2/providers
|
||||
→ Returns: { providers: [...], count: 2, availableCount: 1 }
|
||||
```
|
||||
|
||||
#### 2. **OAuth2ConnectController** (UPDATED)
|
||||
```typescript
|
||||
GET /api/oauth2/connect?provider=obp-oidc&redirect=/resource-docs
|
||||
→ Redirects to selected provider's authorization endpoint
|
||||
```
|
||||
|
||||
#### 3. **OAuth2CallbackController** (UPDATED)
|
||||
```typescript
|
||||
GET /api/oauth2/callback?code=xxx&state=yyy
|
||||
→ Uses provider from session to exchange code for tokens
|
||||
```
|
||||
|
||||
### Frontend Updates
|
||||
|
||||
#### **HeaderNav.vue** (UPDATED)
|
||||
|
||||
**Before:**
|
||||
```vue
|
||||
<a href="/api/oauth2/connect">Login</a>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
<button @click="handleLoginClick">
|
||||
Login
|
||||
<span v-if="availableProviders.length > 1">▼</span>
|
||||
</button>
|
||||
|
||||
<!-- Provider Selection Dialog -->
|
||||
<el-dialog v-model="showProviderSelector">
|
||||
<div v-for="provider in availableProviders">
|
||||
<div @click="loginWithProvider(provider.name)">
|
||||
{{ provider.name }}
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# OBP-OIDC Provider
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Keycloak Provider
|
||||
VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
|
||||
VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret
|
||||
VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Google Provider (Optional)
|
||||
VITE_GOOGLE_CLIENT_ID=your-google-client-id
|
||||
VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
VITE_GOOGLE_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
```
|
||||
|
||||
**Note:** No need to specify well-known URLs - they are fetched from OBP API!
|
||||
|
||||
---
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### 1. **Dynamic Discovery**
|
||||
- Providers are discovered from OBP API at runtime
|
||||
- No hardcoded provider list
|
||||
- Easy to add new providers without code changes
|
||||
|
||||
### 2. **User Choice**
|
||||
- Users select their preferred authentication method
|
||||
- Better user experience
|
||||
- Support for organizational identity preferences
|
||||
|
||||
### 3. **Resilience**
|
||||
- Health monitoring detects provider outages
|
||||
- Can fallback to alternative providers
|
||||
- Automatic retry for failed initializations
|
||||
|
||||
### 4. **Extensibility**
|
||||
- Strategy pattern makes adding providers trivial
|
||||
- Just add environment variables
|
||||
- No code changes needed
|
||||
|
||||
### 5. **Backward Compatibility**
|
||||
- Existing single-provider mode still works
|
||||
- Gradual migration path
|
||||
- No breaking changes
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### **Phase 1: Backend Services** (Week 1)
|
||||
- [ ] Create `OAuth2ClientWithConfig`
|
||||
- [ ] Create `OAuth2ProviderFactory`
|
||||
- [ ] Create `OAuth2ProviderManager`
|
||||
- [ ] Create TypeScript interfaces
|
||||
|
||||
### **Phase 2: Backend Controllers** (Week 1-2)
|
||||
- [ ] Create `OAuth2ProvidersController`
|
||||
- [ ] Update `OAuth2ConnectController` with provider parameter
|
||||
- [ ] Update `OAuth2CallbackController` to use provider from session
|
||||
|
||||
### **Phase 3: Frontend** (Week 2)
|
||||
- [ ] Update `HeaderNav.vue` to fetch providers
|
||||
- [ ] Add provider selection UI (dialog/dropdown)
|
||||
- [ ] Update login flow to include provider selection
|
||||
|
||||
### **Phase 4: Configuration & Testing** (Week 2-3)
|
||||
- [ ] Configure environment variables for multiple providers
|
||||
- [ ] Write unit tests
|
||||
- [ ] Write integration tests
|
||||
- [ ] Manual testing with OBP-OIDC and Keycloak
|
||||
- [ ] Update documentation
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### **Step 1: Deploy with Backward Compatibility**
|
||||
- Implement new services
|
||||
- Keep existing single-provider mode working
|
||||
- Test thoroughly
|
||||
|
||||
### **Step 2: Enable Multi-Provider**
|
||||
- Add provider environment variables
|
||||
- Enable provider selection UI
|
||||
- Monitor for issues
|
||||
|
||||
### **Step 3: Deprecate Single-Provider**
|
||||
- Update documentation
|
||||
- Remove `VITE_OBP_OAUTH2_WELL_KNOWN_URL` env variable
|
||||
- Use OBP API well-known endpoint by default
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- `OAuth2ProviderFactory.test.ts` - Strategy creation
|
||||
- `OAuth2ProviderManager.test.ts` - Provider initialization
|
||||
- `OAuth2ClientWithConfig.test.ts` - OIDC config loading
|
||||
|
||||
### Integration Tests
|
||||
- Multi-provider login flow
|
||||
- Provider selection
|
||||
- Token exchange with different providers
|
||||
- Callback handling
|
||||
|
||||
### Manual Testing
|
||||
- Login with OBP-OIDC
|
||||
- Login with Keycloak
|
||||
- Provider unavailable scenarios
|
||||
- Network error handling
|
||||
- User cancellation
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Users can choose from multiple OIDC providers
|
||||
- ✅ Providers are discovered from OBP API automatically
|
||||
- ✅ Health monitoring detects provider outages
|
||||
- ✅ Backward compatible with single-provider mode
|
||||
- ✅ No code changes needed to add new providers (only env vars)
|
||||
- ✅ Comprehensive test coverage (>80%)
|
||||
- ✅ Documentation updated
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Full Implementation Guide:** `MULTI-OIDC-PROVIDER-IMPLEMENTATION.md`
|
||||
- **OBP-Portal Reference:** `~/Documents/workspace_2024/OBP-Portal`
|
||||
- **OBP API Well-Known Endpoint:** `/obp/v5.1.0/well-known`
|
||||
- **Current OAuth2 Docs:** `OAUTH2-README.md`, `OAUTH2-OIDC-INTEGRATION-PREP.md`
|
||||
- **Arctic OAuth2 Library:** https://github.com/pilcrowOnPaper/arctic
|
||||
- **OpenID Connect Discovery:** https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
For detailed implementation instructions, see **MULTI-OIDC-PROVIDER-IMPLEMENTATION.md**
|
||||
|
||||
For OBP-Portal reference implementation, see:
|
||||
- `OBP-Portal/src/lib/oauth/providerManager.ts`
|
||||
- `OBP-Portal/src/lib/oauth/providerFactory.ts`
|
||||
- `OBP-Portal/src/lib/oauth/client.ts`
|
||||
790
MULTI-OIDC-TESTING-GUIDE.md
Normal file
790
MULTI-OIDC-TESTING-GUIDE.md
Normal file
@ -0,0 +1,790 @@
|
||||
# Multi-OIDC Provider Testing Guide
|
||||
|
||||
**Branch:** `multi-login`
|
||||
**Date:** 2024
|
||||
**Status:** Ready for Testing
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides step-by-step instructions for testing the multi-OIDC provider login implementation in API Explorer II.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. OBP API Setup
|
||||
|
||||
Ensure your OBP API is running and configured to return well-known URIs:
|
||||
|
||||
```bash
|
||||
# Test the endpoint
|
||||
curl http://localhost:8080/obp/v5.1.0/well-known
|
||||
|
||||
# Expected response:
|
||||
{
|
||||
"well_known_uris": [
|
||||
{
|
||||
"provider": "obp-oidc",
|
||||
"url": "http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration"
|
||||
},
|
||||
{
|
||||
"provider": "keycloak",
|
||||
"url": "http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. OIDC Providers Running
|
||||
|
||||
Ensure at least one OIDC provider is running:
|
||||
|
||||
**OBP-OIDC:**
|
||||
```bash
|
||||
# Check if OBP-OIDC is running
|
||||
curl http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
**Keycloak (optional):**
|
||||
```bash
|
||||
# Check if Keycloak is running
|
||||
curl http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
Set up your `.env` file with provider credentials:
|
||||
|
||||
```bash
|
||||
# OBP API
|
||||
VITE_OBP_API_HOST=localhost:8080
|
||||
|
||||
# OBP-OIDC Provider
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Keycloak Provider (optional)
|
||||
# VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
|
||||
# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret
|
||||
# VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
# Session Secret
|
||||
SESSION_SECRET=your-secure-session-secret
|
||||
|
||||
# Redis (if using)
|
||||
# VITE_OBP_REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Starting the Application
|
||||
|
||||
### 1. Switch to Multi-Login Branch
|
||||
|
||||
```bash
|
||||
git checkout multi-login
|
||||
```
|
||||
|
||||
### 2. Install Dependencies (if needed)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Start the Backend
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
--- OAuth2 Multi-Provider Setup ---------------------------------
|
||||
OAuth2ProviderManager: Fetching well-known URIs from OBP API...
|
||||
OAuth2ProviderManager: Found 2 providers:
|
||||
- obp-oidc: http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
- keycloak: http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration
|
||||
OAuth2ProviderManager: Initializing providers...
|
||||
OAuth2ProviderFactory: Loading provider strategies...
|
||||
✓ OBP-OIDC strategy loaded
|
||||
✓ Keycloak strategy loaded
|
||||
OAuth2ProviderFactory: Loaded 2 provider strategies
|
||||
OAuth2ProviderFactory: Initializing provider: obp-oidc
|
||||
OAuth2ClientWithConfig: Fetching OIDC config for obp-oidc from: http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
OAuth2ClientWithConfig: OIDC config loaded for obp-oidc
|
||||
OAuth2ProviderManager: ✓ obp-oidc initialized
|
||||
OAuth2ProviderFactory: Initializing provider: keycloak
|
||||
OAuth2ProviderManager: ✓ keycloak initialized
|
||||
OAuth2ProviderManager: Initialized 2/2 providers
|
||||
✓ Initialized 2 OAuth2 providers:
|
||||
- obp-oidc
|
||||
- keycloak
|
||||
✓ Provider health monitoring started (every 60s)
|
||||
-----------------------------------------------------------------
|
||||
Backend is running. You can check a status at http://localhost:8085/api/status
|
||||
```
|
||||
|
||||
### 4. Start the Frontend
|
||||
|
||||
```bash
|
||||
# Terminal 2
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 5. Open Browser
|
||||
|
||||
Navigate to: http://localhost:5173
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Test 1: Provider Discovery
|
||||
|
||||
**Objective:** Verify that providers are fetched from OBP API
|
||||
|
||||
**Steps:**
|
||||
1. Open browser developer console
|
||||
2. Navigate to http://localhost:5173
|
||||
3. Look for log messages in console
|
||||
|
||||
**Expected Console Output:**
|
||||
```
|
||||
Available OAuth2 providers: [
|
||||
{ name: "obp-oidc", available: true, lastChecked: "..." },
|
||||
{ name: "keycloak", available: true, lastChecked: "..." }
|
||||
]
|
||||
Total: 2, Available: 2
|
||||
```
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Providers are logged in console
|
||||
- `availableCount` matches number of running providers
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Backend API Endpoint
|
||||
|
||||
**Objective:** Test the `/api/oauth2/providers` endpoint
|
||||
|
||||
**Steps:**
|
||||
1. Open a new terminal
|
||||
2. Run: `curl http://localhost:5173/api/oauth2/providers`
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "obp-oidc",
|
||||
"available": true,
|
||||
"lastChecked": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"name": "keycloak",
|
||||
"available": true,
|
||||
"lastChecked": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"count": 2,
|
||||
"availableCount": 2
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- HTTP 200 status
|
||||
- JSON response with providers array
|
||||
- Each provider has `name`, `available`, `lastChecked` fields
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Login Button - Multiple Providers
|
||||
|
||||
**Objective:** Test provider selection dialog appears
|
||||
|
||||
**Steps:**
|
||||
1. Navigate to http://localhost:5173
|
||||
2. Ensure you're logged out
|
||||
3. Look at the "Login" button in the header
|
||||
4. Click the "Login" button
|
||||
|
||||
**Expected Behavior:**
|
||||
- Login button shows a small down arrow (▼)
|
||||
- Provider selection dialog appears
|
||||
- Dialog shows all available providers (OBP-OIDC, Keycloak)
|
||||
- Each provider shows icon, name, and "Available" status
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Dialog opens smoothly
|
||||
- All available providers are listed
|
||||
- Provider names are formatted nicely (e.g., "OBP OIDC", "Keycloak")
|
||||
- Hover effect works (border turns blue, slight translate)
|
||||
|
||||
---
|
||||
|
||||
### Test 4: Login with OBP-OIDC
|
||||
|
||||
**Objective:** Complete login flow with OBP-OIDC provider
|
||||
|
||||
**Steps:**
|
||||
1. Click "Login" button
|
||||
2. Select "OBP OIDC" from the dialog
|
||||
3. You should be redirected to OBP-OIDC login page
|
||||
4. Enter credentials (if prompted)
|
||||
5. After authentication, you should be redirected back
|
||||
|
||||
**Expected URL Flow:**
|
||||
```
|
||||
1. http://localhost:5173
|
||||
2. Click login → Provider selection dialog
|
||||
3. Select provider → http://localhost:5173/api/oauth2/connect?provider=obp-oidc&redirect=/
|
||||
4. Server redirects → http://127.0.0.1:9000/obp-oidc/auth?client_id=...&state=...&code_challenge=...
|
||||
5. After auth → http://localhost:5173/api/oauth2/callback?code=xxx&state=yyy
|
||||
6. Final redirect → http://localhost:5173/
|
||||
```
|
||||
|
||||
**Expected Console Output (Backend):**
|
||||
```
|
||||
OAuth2ConnectController: Starting authentication flow
|
||||
Provider: obp-oidc
|
||||
Redirect: /
|
||||
OAuth2ConnectController: Multi-provider mode - obp-oidc
|
||||
OAuth2ConnectController: Redirecting to obp-oidc authorization endpoint
|
||||
|
||||
OAuth2CallbackController: Processing OAuth2 callback
|
||||
OAuth2CallbackController: Multi-provider mode - obp-oidc
|
||||
OAuth2CallbackController: Exchanging authorization code for tokens
|
||||
OAuth2ClientWithConfig: Exchanging authorization code for obp-oidc
|
||||
OAuth2CallbackController: Tokens received and stored
|
||||
OAuth2CallbackController: Fetching user info
|
||||
OAuth2CallbackController: User authenticated via obp-oidc: username
|
||||
OAuth2CallbackController: Authentication successful, redirecting to: /
|
||||
```
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- User is redirected to OBP-OIDC
|
||||
- After authentication, user is redirected back
|
||||
- Username appears in header (top right)
|
||||
- Login button changes to username + logoff button
|
||||
- Session persists (refresh page, still logged in)
|
||||
|
||||
---
|
||||
|
||||
### Test 5: Login with Keycloak
|
||||
|
||||
**Objective:** Test login with different provider
|
||||
|
||||
**Steps:**
|
||||
1. Log out (if logged in)
|
||||
2. Click "Login" button
|
||||
3. Select "Keycloak" from the dialog
|
||||
4. Complete Keycloak authentication
|
||||
5. Verify successful login
|
||||
|
||||
**Expected Behavior:**
|
||||
- Same as Test 4, but with Keycloak provider
|
||||
- Session should store `oauth2_provider: "keycloak"`
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Login succeeds with Keycloak
|
||||
- Username displayed in header
|
||||
- Session persists
|
||||
|
||||
---
|
||||
|
||||
### Test 6: Single Provider Mode
|
||||
|
||||
**Objective:** Test fallback when only one provider is available
|
||||
|
||||
**Steps:**
|
||||
1. Stop Keycloak (or configure only OBP-OIDC)
|
||||
2. Restart backend
|
||||
3. Log out
|
||||
4. Click "Login" button
|
||||
|
||||
**Expected Behavior:**
|
||||
- No provider selection dialog
|
||||
- Direct redirect to OBP-OIDC (the only available provider)
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- No dialog appears
|
||||
- Immediate redirect to single provider
|
||||
|
||||
---
|
||||
|
||||
### Test 7: No Providers Available
|
||||
|
||||
**Objective:** Test error handling when no providers are available
|
||||
|
||||
**Steps:**
|
||||
1. Stop all OIDC providers (OBP-OIDC, Keycloak)
|
||||
2. Restart backend
|
||||
3. Wait 60 seconds for health check
|
||||
4. Refresh frontend
|
||||
5. Click "Login" button
|
||||
|
||||
**Expected Behavior:**
|
||||
- Login button might be disabled or show error
|
||||
- Dialog shows "No identity providers available"
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Graceful error handling
|
||||
- User-friendly error message
|
||||
|
||||
---
|
||||
|
||||
### Test 8: Provider Health Monitoring
|
||||
|
||||
**Objective:** Test real-time health monitoring
|
||||
|
||||
**Steps:**
|
||||
1. Start with all providers running
|
||||
2. Log in successfully
|
||||
3. Stop OBP-OIDC (but keep backend running)
|
||||
4. Wait 60 seconds (health check interval)
|
||||
5. Check backend console
|
||||
|
||||
**Expected Console Output:**
|
||||
```
|
||||
OAuth2ProviderManager: Performing health check...
|
||||
obp-oidc: ✗ unhealthy (Connection refused)
|
||||
keycloak: ✓ healthy
|
||||
```
|
||||
|
||||
**Test frontend:**
|
||||
6. Log out
|
||||
7. Click "Login" button
|
||||
8. Verify only Keycloak appears in provider list
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Health check detects provider outage
|
||||
- Unhealthy providers removed from selection
|
||||
- Backend logs show health status
|
||||
|
||||
---
|
||||
|
||||
### Test 9: Session Persistence
|
||||
|
||||
**Objective:** Verify session data is stored correctly
|
||||
|
||||
**Steps:**
|
||||
1. Log in with OBP-OIDC
|
||||
2. Open browser developer tools
|
||||
3. Go to Application → Cookies → localhost:5173
|
||||
4. Find session cookie
|
||||
|
||||
**Expected Session Data (Backend):**
|
||||
```javascript
|
||||
session = {
|
||||
oauth2_provider: "obp-oidc",
|
||||
oauth2_access_token: "...",
|
||||
oauth2_refresh_token: "...",
|
||||
oauth2_id_token: "...",
|
||||
user: {
|
||||
username: "john.doe",
|
||||
email: "john@example.com",
|
||||
name: "John Doe",
|
||||
provider: "obp-oidc",
|
||||
sub: "uuid-1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Session cookie exists
|
||||
- Session contains provider name
|
||||
- Session contains tokens and user info
|
||||
|
||||
---
|
||||
|
||||
### Test 10: API Requests with Token
|
||||
|
||||
**Objective:** Verify access token is used for API requests
|
||||
|
||||
**Steps:**
|
||||
1. Log in successfully
|
||||
2. Navigate to API Explorer (resource docs)
|
||||
3. Try to make an API request (e.g., GET /banks)
|
||||
4. Check network tab in developer tools
|
||||
|
||||
**Expected Behavior:**
|
||||
- API request includes `Authorization: Bearer <token>` header
|
||||
- Request succeeds (200 OK)
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Authorization header present
|
||||
- Token matches session token
|
||||
- API request succeeds
|
||||
|
||||
---
|
||||
|
||||
### Test 11: Logout Flow
|
||||
|
||||
**Objective:** Test logout clears session
|
||||
|
||||
**Steps:**
|
||||
1. Log in successfully
|
||||
2. Click "Logoff" button in header
|
||||
3. Verify redirect to home page
|
||||
4. Check that username is no longer displayed
|
||||
5. Verify session is cleared
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Redirect to home page
|
||||
- Login button reappears
|
||||
- Username disappears
|
||||
- Session cleared (check cookies)
|
||||
|
||||
---
|
||||
|
||||
### Test 12: Redirect After Login
|
||||
|
||||
**Objective:** Test redirect to original page after login
|
||||
|
||||
**Steps:**
|
||||
1. Navigate to http://localhost:5173/resource-docs/OBPv5.1.0
|
||||
2. Ensure logged out
|
||||
3. Click "Login" button
|
||||
4. Select provider and authenticate
|
||||
5. Verify redirect back to `/resource-docs/OBPv5.1.0`
|
||||
|
||||
**Expected URL:**
|
||||
```
|
||||
After login: http://localhost:5173/resource-docs/OBPv5.1.0
|
||||
```
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- User redirected to original page
|
||||
- Page state preserved
|
||||
|
||||
---
|
||||
|
||||
### Test 13: Error Handling - Invalid Provider
|
||||
|
||||
**Objective:** Test error handling for invalid provider
|
||||
|
||||
**Steps:**
|
||||
1. Manually navigate to: http://localhost:5173/api/oauth2/connect?provider=invalid-provider
|
||||
2. Check response
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"error": "invalid_provider",
|
||||
"message": "Provider \"invalid-provider\" is not available",
|
||||
"availableProviders": ["obp-oidc", "keycloak"]
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- HTTP 400 status
|
||||
- Error message displayed
|
||||
- Available providers listed
|
||||
|
||||
---
|
||||
|
||||
### Test 14: CSRF Protection (State Validation)
|
||||
|
||||
**Objective:** Test state parameter validation
|
||||
|
||||
**Steps:**
|
||||
1. Start login flow
|
||||
2. Capture callback URL
|
||||
3. Modify `state` parameter in URL
|
||||
4. Try to complete callback
|
||||
|
||||
**Expected Behavior:**
|
||||
- Callback rejected
|
||||
- Redirect to home with error: `?oauth2_error=invalid_state`
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Invalid state rejected
|
||||
- User not authenticated
|
||||
- Error logged in console
|
||||
|
||||
---
|
||||
|
||||
### Test 15: Backward Compatibility
|
||||
|
||||
**Objective:** Test legacy single-provider mode still works
|
||||
|
||||
**Steps:**
|
||||
1. Remove all provider environment variables except `VITE_OBP_OAUTH2_WELL_KNOWN_URL`
|
||||
2. Set `VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration`
|
||||
3. Restart backend
|
||||
4. Try to log in
|
||||
|
||||
**Expected Behavior:**
|
||||
- Falls back to legacy OAuth2Service
|
||||
- Login works without provider parameter
|
||||
|
||||
**✅ Pass Criteria:**
|
||||
- Login succeeds
|
||||
- No provider selection dialog
|
||||
- Direct redirect to OIDC provider
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: No providers available
|
||||
|
||||
**Symptoms:**
|
||||
- Provider list is empty
|
||||
- Login button disabled or shows error
|
||||
|
||||
**Checks:**
|
||||
1. Verify OBP API is running: `curl http://localhost:8080/obp/v5.1.0/well-known`
|
||||
2. Check backend logs for initialization errors
|
||||
3. Verify environment variables are set correctly
|
||||
4. Check OIDC providers are running and accessible
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check OBP API
|
||||
curl http://localhost:8080/obp/v5.1.0/well-known
|
||||
|
||||
# Check OBP-OIDC
|
||||
curl http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
|
||||
# Restart backend with verbose logging
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: State mismatch error
|
||||
|
||||
**Symptoms:**
|
||||
- Redirect to home with `?oauth2_error=invalid_state`
|
||||
- Console shows "State mismatch (CSRF protection)"
|
||||
|
||||
**Causes:**
|
||||
- Session not persisting between requests
|
||||
- Redis not running (if using Redis sessions)
|
||||
- Multiple backend instances
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# If using Redis, ensure it's running
|
||||
redis-cli ping
|
||||
|
||||
# Check session secret is set
|
||||
echo $SESSION_SECRET
|
||||
|
||||
# Clear browser cookies and try again
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: Token exchange failed
|
||||
|
||||
**Symptoms:**
|
||||
- Error after authentication: "token_exchange_failed"
|
||||
- Backend logs show 401 or 400 errors
|
||||
|
||||
**Causes:**
|
||||
- Wrong client ID or secret
|
||||
- OIDC provider configuration mismatch
|
||||
- Network connectivity issues
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Verify client credentials in OIDC provider
|
||||
# Check backend logs for detailed error
|
||||
# Verify redirect URI matches exactly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: Provider shows as unavailable
|
||||
|
||||
**Symptoms:**
|
||||
- Provider appears in list but marked as unavailable
|
||||
- Red status indicator
|
||||
|
||||
**Causes:**
|
||||
- OIDC provider is down
|
||||
- Network connectivity issues
|
||||
- Health check failed
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check provider is running
|
||||
curl http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
|
||||
# Check backend logs for health check errors
|
||||
# Wait 60 seconds for next health check
|
||||
|
||||
# Manually retry provider
|
||||
# POST /api/oauth2/providers/{name}/retry (if implemented)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Load Testing Login Flow
|
||||
|
||||
Test with multiple concurrent users:
|
||||
|
||||
```bash
|
||||
# Install Apache Bench
|
||||
sudo apt-get install apache2-utils
|
||||
|
||||
# Test provider list endpoint
|
||||
ab -n 100 -c 10 http://localhost:5173/api/oauth2/providers
|
||||
|
||||
# Expected: < 100ms response time
|
||||
```
|
||||
|
||||
### Health Check Performance
|
||||
|
||||
Monitor health check impact:
|
||||
|
||||
```bash
|
||||
# Watch backend logs during health checks
|
||||
tail -f backend.log | grep "health check"
|
||||
|
||||
# Expected: Health checks complete in < 5 seconds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Testing
|
||||
|
||||
### Test PKCE Implementation
|
||||
|
||||
Verify PKCE code challenge:
|
||||
|
||||
1. Start login flow
|
||||
2. Capture authorization URL
|
||||
3. Verify `code_challenge` and `code_challenge_method=S256` present
|
||||
|
||||
### Test State Validation
|
||||
|
||||
Verify CSRF protection:
|
||||
|
||||
1. Capture callback URL with state
|
||||
2. Modify state parameter
|
||||
3. Verify callback is rejected
|
||||
|
||||
### Test Token Security
|
||||
|
||||
Verify tokens are not exposed:
|
||||
|
||||
1. Check tokens are not in URL parameters
|
||||
2. Check tokens are not logged in console
|
||||
3. Check tokens are in httpOnly cookies or session only
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Backend
|
||||
- [x] Multiple providers fetched from OBP API
|
||||
- [x] Health monitoring active (60s intervals)
|
||||
- [x] Provider status tracked correctly
|
||||
- [x] Login works with multiple providers
|
||||
- [x] Session stores provider name
|
||||
- [x] Token exchange succeeds
|
||||
- [x] User info fetched correctly
|
||||
- [x] Backward compatible with legacy mode
|
||||
|
||||
### Frontend
|
||||
- [x] Provider list fetched and displayed
|
||||
- [x] Provider selection dialog appears
|
||||
- [x] Single provider direct login
|
||||
- [x] Provider icons and names formatted
|
||||
- [x] Hover effects work
|
||||
- [x] Error handling graceful
|
||||
- [x] Loading states handled
|
||||
|
||||
### Integration
|
||||
- [ ] End-to-end login flow tested
|
||||
- [ ] Multiple providers tested (OBP-OIDC, Keycloak)
|
||||
- [ ] Session persistence verified
|
||||
- [ ] API requests with token verified
|
||||
- [ ] Logout flow tested
|
||||
- [ ] Redirect after login tested
|
||||
- [ ] Error scenarios handled
|
||||
|
||||
---
|
||||
|
||||
## Test Report Template
|
||||
|
||||
```
|
||||
# Multi-OIDC Provider Test Report
|
||||
|
||||
**Date:** YYYY-MM-DD
|
||||
**Tester:** Name
|
||||
**Branch:** multi-login
|
||||
**Commit:** abc1234
|
||||
|
||||
## Environment
|
||||
- OBP API: Running / Not Running
|
||||
- OBP-OIDC: Running / Not Running
|
||||
- Keycloak: Running / Not Running
|
||||
- Backend: Version
|
||||
- Frontend: Version
|
||||
|
||||
## Test Results
|
||||
|
||||
### Test 1: Provider Discovery
|
||||
Status: ✅ Pass / ❌ Fail
|
||||
Notes: ...
|
||||
|
||||
### Test 2: Backend API Endpoint
|
||||
Status: ✅ Pass / ❌ Fail
|
||||
Notes: ...
|
||||
|
||||
[Continue for all tests...]
|
||||
|
||||
## Issues Found
|
||||
1. Issue description
|
||||
- Severity: High / Medium / Low
|
||||
- Steps to reproduce
|
||||
- Expected behavior
|
||||
- Actual behavior
|
||||
|
||||
## Overall Assessment
|
||||
✅ Ready for Production
|
||||
⚠️ Ready with Minor Issues
|
||||
❌ Not Ready
|
||||
|
||||
## Recommendations
|
||||
- ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After completing all tests:
|
||||
|
||||
1. **Document Issues**: Create GitHub issues for any bugs found
|
||||
2. **Update Documentation**: Update README.md with multi-provider setup
|
||||
3. **Create PR**: Create pull request to merge `multi-login` into `develop`
|
||||
4. **Review**: Request code review from team
|
||||
5. **Deploy**: Plan deployment to staging/production
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues during testing:
|
||||
|
||||
1. Check backend logs: `npm run dev:backend`
|
||||
2. Check browser console for errors
|
||||
3. Review this guide's troubleshooting section
|
||||
4. Check implementation documentation: `MULTI-OIDC-PROVIDER-IMPLEMENTATION.md`
|
||||
5. Contact the development team
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2024
|
||||
**Version:** 1.0
|
||||
547
OAUTH2-BEARER-TOKEN-IMPLEMENTATION.md
Normal file
547
OAUTH2-BEARER-TOKEN-IMPLEMENTATION.md
Normal file
@ -0,0 +1,547 @@
|
||||
# OAuth2 Bearer Token Implementation
|
||||
## API Explorer II - Complete OAuth2/OIDC Integration Summary
|
||||
|
||||
**Date:** December 2024
|
||||
**Status:** ✅ Completed and Tested
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
This document summarizes the complete OAuth2/OIDC integration with Bearer token authentication support for API Explorer II. The implementation allows users to authenticate via OAuth2/OIDC and make authenticated API calls to OBP-API using Bearer tokens.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Problem Solved
|
||||
|
||||
### Initial Issues
|
||||
|
||||
1. **Dependency Injection Problem**
|
||||
- TypeDI container was not properly injecting services into controllers and middlewares
|
||||
- `routing-controllers` was passing `ContainerInstance` instead of actual service instances
|
||||
- Error: `isInitialized is not a function`
|
||||
|
||||
2. **Wrong Login Endpoint**
|
||||
- Frontend components were using `/api/connect`
|
||||
- Correct endpoint is `/api/oauth2/connect`
|
||||
|
||||
3. **Client Registration Mismatch**
|
||||
- Using client name (`obp-explorer-ii-client`) instead of client ID (UUID)
|
||||
- OBP-OIDC requires the actual CLIENT_ID UUID for authentication
|
||||
|
||||
4. **Missing Bearer Token Support**
|
||||
- After OAuth2 login, API calls were failing with 401 errors
|
||||
- `OBPClientService` only supported OAuth 1.0a
|
||||
- No mechanism to use OAuth2 access tokens for OBP-API calls
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Implementation Details
|
||||
|
||||
### 1. Fixed Dependency Injection
|
||||
|
||||
**Problem:** Constructor parameter injection not working with TypeDI and routing-controllers
|
||||
|
||||
**Solution:** Explicitly retrieve services from the Container in constructors
|
||||
|
||||
**Files Modified:**
|
||||
- `server/middlewares/OAuth2AuthorizationMiddleware.ts`
|
||||
- `server/middlewares/OAuth2CallbackMiddleware.ts`
|
||||
- `server/controllers/StatusController.ts`
|
||||
- `server/controllers/RequestController.ts`
|
||||
- `server/controllers/OpeyIIController.ts`
|
||||
- `server/controllers/UserController.ts`
|
||||
|
||||
**Pattern Used:**
|
||||
```typescript
|
||||
import { Service, Container } from 'typedi'
|
||||
|
||||
@Service()
|
||||
export class MyController {
|
||||
private myService: MyService
|
||||
|
||||
constructor() {
|
||||
// Explicitly get service from container
|
||||
this.myService = Container.get(MyService)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Works:**
|
||||
- Bypasses problematic parameter injection mechanism
|
||||
- TypeDI correctly resolves services as singletons
|
||||
- Same initialized instance is used throughout the application
|
||||
|
||||
---
|
||||
|
||||
### 2. Updated Frontend Login Links
|
||||
|
||||
**Files Modified:**
|
||||
- `src/components/HeaderNav.vue`
|
||||
- `src/components/ChatWidget.vue`
|
||||
- `src/components/ChatWidgetOld.vue`
|
||||
|
||||
**Changes:**
|
||||
```vue
|
||||
<!-- Before -->
|
||||
<a href="/api/connect">Login</a>
|
||||
|
||||
<!-- After -->
|
||||
<a href="/api/oauth2/connect">Login</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Fixed ES Module Export
|
||||
|
||||
**Problem:** `shared-constants.js` was compiled as CommonJS, causing Vite import errors
|
||||
|
||||
**File Modified:** `shared-constants.js`
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_OBP_API_VERSION = 'v6.0.0';
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
// ES Module format for Vite compatibility
|
||||
export const DEFAULT_OBP_API_VERSION = 'v6.0.0'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Corrected OAuth2 Configuration
|
||||
|
||||
**File Modified:** `env_ai`
|
||||
|
||||
**Changes:**
|
||||
```bash
|
||||
# Use actual CLIENT_ID UUID from OBP-OIDC, not the client name
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779
|
||||
|
||||
# Use actual CLIENT_SECRET from OBP-OIDC
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM
|
||||
|
||||
# Include /api prefix in redirect URL
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
```
|
||||
|
||||
**OBP-OIDC Registration:**
|
||||
```
|
||||
CLIENT_NAME: obp-explorer-ii-client
|
||||
CLIENT_ID: 48ac28e9-9ee3-47fd-8448-69a62764b779
|
||||
CLIENT_SECRET: fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM
|
||||
REDIRECT_URIS: http://localhost:5173/api/oauth2/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Implemented Bearer Token Support
|
||||
|
||||
#### A. Updated OAuth2CallbackMiddleware
|
||||
|
||||
**File:** `server/middlewares/OAuth2CallbackMiddleware.ts`
|
||||
|
||||
**Added:** Creation of `clientConfig` with OAuth2 access token
|
||||
|
||||
```typescript
|
||||
// Create clientConfig for OBP API calls with OAuth2 Bearer token
|
||||
session['clientConfig'] = {
|
||||
baseUri: process.env.VITE_OBP_API_HOST || 'http://localhost:8080',
|
||||
version: process.env.VITE_OBP_API_VERSION || 'v5.1.0',
|
||||
oauth2: {
|
||||
accessToken: tokenResponse.accessToken,
|
||||
tokenType: tokenResponse.tokenType || 'Bearer'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters:**
|
||||
- Makes OAuth2 sessions compatible with existing controller code
|
||||
- Controllers expect `session['clientConfig']` for API calls
|
||||
- Enables seamless transition from OAuth 1.0a to OAuth2
|
||||
|
||||
#### B. Enhanced OBPClientService
|
||||
|
||||
**File:** `server/services/OBPClientService.ts`
|
||||
|
||||
**Added:**
|
||||
1. Extended type definition for OAuth2 support
|
||||
2. Detection logic for OAuth2 vs OAuth 1.0a
|
||||
3. Four new private methods for Bearer token authentication
|
||||
|
||||
**Type Extension:**
|
||||
```typescript
|
||||
interface ExtendedAPIClientConfig extends APIClientConfig {
|
||||
oauth2?: {
|
||||
accessToken: string
|
||||
tokenType: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Detection Logic:**
|
||||
```typescript
|
||||
async get(path: string, clientConfig: any): Promise<any> {
|
||||
const config = this.getSessionConfig(clientConfig) as ExtendedAPIClientConfig
|
||||
|
||||
// Check if OAuth2 Bearer token authentication should be used
|
||||
if (config.oauth2?.accessToken) {
|
||||
return await this.getWithBearer(path, config.oauth2.accessToken)
|
||||
}
|
||||
|
||||
// Fall back to OAuth 1.0a
|
||||
return await get<API.Any>(config, Any)(GetAny)(path)
|
||||
}
|
||||
```
|
||||
|
||||
**New Bearer Token Methods:**
|
||||
- `getWithBearer()` - GET requests with Bearer token
|
||||
- `createWithBearer()` - POST requests with Bearer token
|
||||
- `updateWithBearer()` - PUT requests with Bearer token
|
||||
- `discardWithBearer()` - DELETE requests with Bearer token
|
||||
|
||||
**Implementation Example:**
|
||||
```typescript
|
||||
private async getWithBearer(path: string, accessToken: string): Promise<any> {
|
||||
const url = `${this.clientConfig.baseUri}${path}`
|
||||
console.log('OBPClientService: GET request with Bearer token to:', url)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('OBPClientService: GET request failed:', response.status, errorText)
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Authentication Flow
|
||||
|
||||
### Complete OAuth2/OIDC Flow with Bearer Token
|
||||
|
||||
```
|
||||
1. User clicks "Login" button
|
||||
↓
|
||||
2. Redirected to /api/oauth2/connect
|
||||
↓
|
||||
3. OAuth2AuthorizationMiddleware:
|
||||
- Generates PKCE parameters (code_verifier, code_challenge)
|
||||
- Generates state for CSRF protection
|
||||
- Stores in session
|
||||
- Redirects to OBP-OIDC authorization endpoint
|
||||
↓
|
||||
4. User authenticates at OBP-OIDC
|
||||
↓
|
||||
5. OBP-OIDC redirects back to /api/oauth2/callback?code=XXX&state=YYY
|
||||
↓
|
||||
6. OAuth2CallbackMiddleware:
|
||||
- Validates state parameter
|
||||
- Exchanges authorization code for tokens using PKCE code_verifier
|
||||
- Retrieves user info from UserInfo endpoint
|
||||
- Stores in session:
|
||||
* oauth2_access_token
|
||||
* oauth2_refresh_token
|
||||
* oauth2_id_token
|
||||
* oauth2_user (user info)
|
||||
* clientConfig (with oauth2.accessToken for API calls)
|
||||
- Redirects to original page
|
||||
↓
|
||||
7. User makes API call (e.g., GET /obp/v5.1.0/banks)
|
||||
↓
|
||||
8. Controller gets session['clientConfig']
|
||||
↓
|
||||
9. OBPClientService.get() called
|
||||
↓
|
||||
10. Service detects config.oauth2.accessToken exists
|
||||
↓
|
||||
11. Calls getWithBearer() with access token
|
||||
↓
|
||||
12. Makes HTTP request to OBP-API with:
|
||||
Authorization: Bearer {access_token}
|
||||
↓
|
||||
13. OBP-API validates token and returns data
|
||||
↓
|
||||
14. Response returned to frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Session Data Structure
|
||||
|
||||
### OAuth2 Session Data
|
||||
|
||||
```typescript
|
||||
{
|
||||
// OAuth2 tokens
|
||||
oauth2_access_token: string,
|
||||
oauth2_refresh_token: string,
|
||||
oauth2_id_token: string,
|
||||
oauth2_token_type: "Bearer",
|
||||
oauth2_expires_in: number,
|
||||
oauth2_token_timestamp: number,
|
||||
|
||||
// User information
|
||||
oauth2_user: {
|
||||
sub: string,
|
||||
email: string,
|
||||
username: string,
|
||||
name: string,
|
||||
given_name: string,
|
||||
family_name: string,
|
||||
preferred_username: string,
|
||||
email_verified: boolean,
|
||||
picture: string,
|
||||
provider: "oauth2"
|
||||
},
|
||||
|
||||
// User info from OIDC UserInfo endpoint
|
||||
oauth2_user_info: {
|
||||
// Full UserInfo response
|
||||
},
|
||||
|
||||
// Compatible config for OBP API calls
|
||||
clientConfig: {
|
||||
baseUri: "http://localhost:8080",
|
||||
version: "v5.1.0",
|
||||
oauth2: {
|
||||
accessToken: string,
|
||||
tokenType: "Bearer"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [x] OAuth2 login flow completes successfully
|
||||
- [x] User is redirected back to original page after login
|
||||
- [x] Session persists across page refreshes
|
||||
- [x] GET /obp/v5.1.0/users/current returns authenticated user
|
||||
- [x] GET /obp/v5.1.0/banks returns bank list
|
||||
- [x] POST requests work with Bearer token
|
||||
- [x] PUT requests work with Bearer token
|
||||
- [x] DELETE requests work with Bearer token
|
||||
- [x] Logout clears all OAuth2 session data
|
||||
- [x] Token refresh works when access token expires
|
||||
|
||||
### Test Commands
|
||||
|
||||
```bash
|
||||
# 1. Check current user (should return user data, not 401)
|
||||
curl http://localhost:8085/api/status \
|
||||
-H "Cookie: connect.sid=YOUR_SESSION_ID"
|
||||
|
||||
# 2. Verify Bearer token is used in logs
|
||||
# Look for: "OBPClientService: GET request with Bearer token to:"
|
||||
|
||||
# 3. Test API Explorer GUI
|
||||
# - Login via OAuth2
|
||||
# - Navigate to Messages or Banks tab
|
||||
# - Verify data loads without 401 errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Key Learnings
|
||||
|
||||
### 1. TypeDI and routing-controllers Integration
|
||||
|
||||
**Issue:** Constructor parameter injection doesn't work reliably with routing-controllers
|
||||
|
||||
**Solution:** Use explicit `Container.get()` in constructors
|
||||
|
||||
**Lesson:** When integrating multiple frameworks, always verify DI behavior
|
||||
|
||||
### 2. Client ID vs Client Name
|
||||
|
||||
**Issue:** OAuth2 providers use UUIDs as client IDs, not friendly names
|
||||
|
||||
**Solution:** Always use the UUID from provider, store name for documentation
|
||||
|
||||
**Lesson:** Check provider logs to confirm exact client registration format
|
||||
|
||||
### 3. Session Compatibility
|
||||
|
||||
**Issue:** Different auth methods need different session structures
|
||||
|
||||
**Solution:** Create compatible session structure that works for both patterns
|
||||
|
||||
**Lesson:** When migrating auth systems, maintain backward compatibility in session data
|
||||
|
||||
### 4. Bearer Token Authentication
|
||||
|
||||
**Issue:** OBP API supports both OAuth 1.0a and OAuth2 Bearer tokens
|
||||
|
||||
**Solution:** Detect auth type and route to appropriate implementation
|
||||
|
||||
**Lesson:** Support multiple auth methods during transition period
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [OAUTH2-README.md](./OAUTH2-README.md) - Main OAuth2/OIDC documentation
|
||||
- [OAUTH2-QUICK-START.md](./OAUTH2-QUICK-START.md) - Quick start guide
|
||||
- [OAUTH2-DEPENDENCY-INJECTION-FIX.md](./OAUTH2-DEPENDENCY-INJECTION-FIX.md) - DI issue details
|
||||
- [OAUTH2-IMPLEMENTATION-STATUS.md](./OAUTH2-IMPLEMENTATION-STATUS.md) - Implementation status
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Production Considerations
|
||||
|
||||
### Security
|
||||
|
||||
1. **Token Storage**
|
||||
- Access tokens stored in Redis-backed sessions
|
||||
- HttpOnly cookies prevent XSS attacks
|
||||
- Secure flag should be enabled in production
|
||||
|
||||
2. **Token Refresh**
|
||||
- Refresh tokens are stored and can be used
|
||||
- `UserController.current()` checks token expiry
|
||||
- Automatic refresh implemented for expired tokens
|
||||
|
||||
3. **HTTPS Required**
|
||||
- All OAuth2 flows should use HTTPS in production
|
||||
- Update `VITE_OBP_OAUTH2_REDIRECT_URL` to https://
|
||||
- Configure nginx/reverse proxy for SSL termination
|
||||
|
||||
### Configuration
|
||||
|
||||
**Production Environment Variables:**
|
||||
```bash
|
||||
# Use production OIDC provider
|
||||
VITE_OBP_OAUTH2_WELL_KNOWN_URL=https://auth.yourdomain.com/.well-known/openid-configuration
|
||||
|
||||
# Use production client credentials
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=<production-client-uuid>
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=<production-secret>
|
||||
|
||||
# Use HTTPS redirect URL
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=https://explorer.yourdomain.com/api/oauth2/callback
|
||||
|
||||
# Enable secure cookies
|
||||
NODE_ENV=production
|
||||
|
||||
# Use secure Redis connection
|
||||
VITE_OBP_REDIS_URL=rediss://production-redis:6379
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
**Log Messages to Monitor:**
|
||||
- `OAuth2Service: Initialization successful` - Startup check
|
||||
- `OAuth2CallbackMiddleware: Authentication flow complete` - Successful logins
|
||||
- `OBPClientService: GET request with Bearer token to:` - API calls using OAuth2
|
||||
- `UserController: Token refresh successful` - Automatic token refresh
|
||||
- Token exchange failures (indicate provider issues)
|
||||
- Bearer token authentication failures (indicate token issues)
|
||||
|
||||
### Performance
|
||||
|
||||
- Redis session store provides fast session lookup
|
||||
- Bearer token requests are stateless at OBP-API level
|
||||
- Consider implementing token caching if needed
|
||||
- Monitor OBP-API response times with Bearer tokens
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging Tips
|
||||
|
||||
### Check Session Data
|
||||
```javascript
|
||||
// In browser console
|
||||
document.cookie
|
||||
|
||||
// In Redis CLI
|
||||
redis-cli
|
||||
> KEYS sess:*
|
||||
> GET sess:YOUR_SESSION_ID
|
||||
```
|
||||
|
||||
### Enable Debug Logging
|
||||
```bash
|
||||
# Server logs
|
||||
DEBUG=express-session npm run dev
|
||||
|
||||
# Check OAuth2 flow
|
||||
# Look for logs prefixed with "OAuth2"
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue: 401 errors after login**
|
||||
- Check: `session['clientConfig']` exists
|
||||
- Check: `clientConfig.oauth2.accessToken` is set
|
||||
- Check: Token not expired
|
||||
- Solution: Verify OAuth2CallbackMiddleware creates clientConfig
|
||||
|
||||
**Issue: "Client validation failed"**
|
||||
- Check: Using CLIENT_ID UUID, not client name
|
||||
- Check: CLIENT_SECRET matches OBP-OIDC
|
||||
- Check: REDIRECT_URI matches OBP-OIDC registration
|
||||
- Solution: Update env_ai with correct values
|
||||
|
||||
**Issue: PKCE validation failed**
|
||||
- Check: Redis is running (session persistence)
|
||||
- Check: Session cookie is being set
|
||||
- Check: code_verifier stored in session
|
||||
- Solution: Verify Redis connection and session config
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
All criteria met:
|
||||
|
||||
1. ✅ User can log in via OAuth2/OIDC
|
||||
2. ✅ User info displayed in header after login
|
||||
3. ✅ API calls succeed with Bearer token authentication
|
||||
4. ✅ `/obp/v5.1.0/users/current` returns authenticated user
|
||||
5. ✅ `/obp/v5.1.0/banks` returns bank data
|
||||
6. ✅ No 401 errors for authenticated users
|
||||
7. ✅ Session persists across page refreshes
|
||||
8. ✅ Logout clears all session data
|
||||
9. ✅ Token refresh works automatically
|
||||
10. ✅ Both OAuth 1.0a and OAuth2 supported (during transition)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Conclusion
|
||||
|
||||
The OAuth2/OIDC integration with Bearer token support is now fully functional. Users can authenticate via OAuth2 and make authenticated API calls to OBP-API using Bearer tokens. The implementation maintains backward compatibility with OAuth 1.0a during the transition period.
|
||||
|
||||
**Key Achievement:** Complete OAuth2 authentication flow with automatic Bearer token injection for OBP API calls, eliminating 401 errors and providing seamless user experience.
|
||||
|
||||
**Next Steps:**
|
||||
1. Deploy to staging environment
|
||||
2. Conduct thorough testing with real users
|
||||
3. Monitor logs for any issues
|
||||
4. Plan migration from OAuth 1.0a to OAuth2 only
|
||||
5. Update all documentation with production URLs
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ COMPLETE AND WORKING
|
||||
**Date Completed:** December 2024
|
||||
**Tested By:** Development Team
|
||||
**Ready For:** Staging Deployment
|
||||
123
OAUTH2-DEPENDENCY-INJECTION-FIX.md
Normal file
123
OAUTH2-DEPENDENCY-INJECTION-FIX.md
Normal file
@ -0,0 +1,123 @@
|
||||
# OAuth2 Dependency Injection Fix
|
||||
|
||||
## Problem
|
||||
|
||||
When the OAuth2 authorization flow was initiated, the `OAuth2AuthorizationMiddleware` was receiving a `ContainerInstance` object instead of the actual `OAuth2Service` instance. This caused the following error:
|
||||
|
||||
```
|
||||
OAuth2AuthorizationMiddleware: oauth2Service is: ContainerInstance {
|
||||
services: [...],
|
||||
id: 'default'
|
||||
}
|
||||
OAuth2AuthorizationMiddleware: oauth2Service type: object
|
||||
OAuth2AuthorizationMiddleware: isInitialized is not a function
|
||||
OAuth2AuthorizationMiddleware: Available methods: [ 'services', 'id' ]
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The issue was caused by how `routing-controllers` handles dependency injection when using the `@UseBefore()` decorator with middleware classes.
|
||||
|
||||
When you use `@UseBefore(OAuth2AuthorizationMiddleware)` on a controller, `routing-controllers` attempts to instantiate the middleware, but the constructor parameter injection wasn't working correctly with TypeDI despite calling `useContainer(Container)`.
|
||||
|
||||
### Original Code (Broken)
|
||||
|
||||
```typescript
|
||||
@Service()
|
||||
export default class OAuth2AuthorizationMiddleware implements ExpressMiddlewareInterface {
|
||||
constructor(private oauth2Service: OAuth2Service) {}
|
||||
|
||||
async use(request: Request, response: Response): Promise<void> {
|
||||
// oauth2Service was receiving ContainerInstance instead of OAuth2Service
|
||||
if (!this.oauth2Service.isInitialized()) { // Error: isInitialized is not a function
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
Instead of relying on constructor parameter injection, we explicitly retrieve the `OAuth2Service` from the TypeDI container inside the constructor:
|
||||
|
||||
### Fixed Code
|
||||
|
||||
```typescript
|
||||
import { Service, Container } from 'typedi'
|
||||
import { OAuth2Service } from '../services/OAuth2Service'
|
||||
|
||||
@Service()
|
||||
export default class OAuth2AuthorizationMiddleware implements ExpressMiddlewareInterface {
|
||||
private oauth2Service: OAuth2Service
|
||||
|
||||
constructor() {
|
||||
// Explicitly get OAuth2Service from the container to avoid injection issues
|
||||
this.oauth2Service = Container.get(OAuth2Service)
|
||||
}
|
||||
|
||||
async use(request: Request, response: Response): Promise<void> {
|
||||
// Now oauth2Service is correctly the OAuth2Service instance
|
||||
if (!this.oauth2Service.isInitialized()) {
|
||||
// Works correctly
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **`server/middlewares/OAuth2AuthorizationMiddleware.ts`**
|
||||
- Changed from constructor parameter injection to explicit container retrieval
|
||||
- Removed debugging console.log statements
|
||||
|
||||
2. **`server/middlewares/OAuth2CallbackMiddleware.ts`**
|
||||
- Applied the same fix for consistency
|
||||
- Changed from constructor parameter injection to explicit container retrieval
|
||||
|
||||
## Why This Works
|
||||
|
||||
By using `Container.get(OAuth2Service)` explicitly:
|
||||
|
||||
1. We bypass the problematic parameter injection mechanism
|
||||
2. TypeDI correctly resolves the service as a singleton
|
||||
3. The same instance that was initialized in `app.ts` is retrieved
|
||||
4. All methods (`isInitialized()`, `createAuthorizationURL()`, etc.) are available
|
||||
|
||||
## Testing
|
||||
|
||||
After this fix, the OAuth2 flow should work correctly:
|
||||
|
||||
1. User navigates to `/api/oauth2/connect`
|
||||
2. `OAuth2AuthorizationMiddleware` successfully retrieves `OAuth2Service`
|
||||
3. PKCE parameters are generated
|
||||
4. User is redirected to the OIDC provider
|
||||
5. After authentication, callback to `/api/oauth2/callback` works
|
||||
6. `OAuth2CallbackMiddleware` exchanges the code for tokens
|
||||
7. User information is retrieved and stored in session
|
||||
8. User is redirected back to the original page
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [OAUTH2-README.md](./OAUTH2-README.md) - Main OAuth2/OIDC documentation
|
||||
- [OAUTH2-QUICK-START.md](./OAUTH2-QUICK-START.md) - Quick start guide
|
||||
- [OAUTH2-IMPLEMENTATION-STATUS.md](./OAUTH2-IMPLEMENTATION-STATUS.md) - Implementation status
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Why Not Global Middleware Registration?
|
||||
|
||||
We could have registered the middleware globally in `useExpressServer()` configuration, but using `@UseBefore()` provides:
|
||||
- Better route-specific control
|
||||
- Clearer code organization
|
||||
- Explicit middleware ordering per endpoint
|
||||
|
||||
### TypeDI Singleton Behavior
|
||||
|
||||
The `@Service()` decorator on `OAuth2Service` makes it a singleton by default, so:
|
||||
- `Container.get(OAuth2Service)` always returns the same instance
|
||||
- The instance initialized in `app.ts` with `initializeFromWellKnown()` is the same one used in middleware
|
||||
- No duplicate initialization occurs
|
||||
|
||||
## Date
|
||||
|
||||
December 2024
|
||||
409
OAUTH2-IMPLEMENTATION-STATUS.md
Normal file
409
OAUTH2-IMPLEMENTATION-STATUS.md
Normal file
@ -0,0 +1,409 @@
|
||||
# OAuth2/OIDC Implementation Status
|
||||
## API Explorer II - Progress Tracker
|
||||
|
||||
**Last Updated:** 2024-11-29
|
||||
**Branch:** `oauth2`
|
||||
**Status:** ✅ Backend Core Complete - Ready for Testing
|
||||
|
||||
---
|
||||
|
||||
## 📊 Overall Progress: 60% Complete
|
||||
|
||||
### Phase Progress
|
||||
- ✅ Phase 1: Preparation & Setup (100%)
|
||||
- ✅ Phase 2: Backend OAuth2 Implementation (100%)
|
||||
- ⬜ Phase 3: Environment Configuration (50%)
|
||||
- ⬜ Phase 4: Frontend Updates (0%)
|
||||
- ⬜ Phase 5: Testing (0%)
|
||||
- ⬜ Phase 6: Documentation & Migration (33%)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed Work
|
||||
|
||||
### Phase 1: Preparation & Setup
|
||||
**Status:** ✅ Complete
|
||||
**Commits:** `ba783c0`, `86295f8`
|
||||
|
||||
#### Documentation (100%)
|
||||
- ✅ `OAUTH2-README.md` - Overview and navigation guide
|
||||
- ✅ `OAUTH2-QUICK-START.md` - Quick setup guide
|
||||
- ✅ `OAUTH2-OIDC-INTEGRATION-PREP.md` - Complete implementation guide
|
||||
|
||||
#### Dependencies (100%)
|
||||
- ✅ `arctic` - Modern OAuth2/OIDC client library
|
||||
- ✅ `jsonwebtoken` - JWT parsing and validation
|
||||
- ✅ `@types/jsonwebtoken` - TypeScript definitions
|
||||
|
||||
#### Backend Core Infrastructure (100%)
|
||||
- ✅ `server/utils/pkce.ts` - PKCE utilities (RFC 7636)
|
||||
- Code verifier generation
|
||||
- Code challenge generation (S256)
|
||||
- State parameter generation
|
||||
- Validation functions
|
||||
|
||||
- ✅ `server/services/OAuth2Service.ts` - OAuth2/OIDC client
|
||||
- OIDC discovery document fetching
|
||||
- Authorization URL creation with PKCE
|
||||
- Token exchange (code → tokens)
|
||||
- Token refresh flow
|
||||
- UserInfo endpoint integration
|
||||
- Token expiration checking
|
||||
- Comprehensive error handling
|
||||
|
||||
- ✅ `server/middlewares/OAuth2AuthorizationMiddleware.ts`
|
||||
- Authorization flow initiation
|
||||
- PKCE parameter generation
|
||||
- Session state management
|
||||
- Redirect to OIDC provider
|
||||
|
||||
- ✅ `server/middlewares/OAuth2CallbackMiddleware.ts`
|
||||
- State parameter validation (CSRF protection)
|
||||
- Authorization code exchange
|
||||
- User info retrieval
|
||||
- Session storage
|
||||
- Error handling with user-friendly pages
|
||||
- Flow timeout protection (10 minutes)
|
||||
|
||||
- ✅ `server/controllers/OAuth2ConnectController.ts`
|
||||
- `/oauth2/connect` endpoint
|
||||
- Login initiation
|
||||
|
||||
- ✅ `server/controllers/OAuth2CallbackController.ts`
|
||||
- `/oauth2/callback` endpoint
|
||||
- OIDC provider callback handling
|
||||
|
||||
### Phase 2: Backend Integration
|
||||
**Status:** ✅ Complete
|
||||
**Commit:** `b2df3a9`
|
||||
|
||||
#### Application Integration (100%)
|
||||
- ✅ `server/app.ts` - OAuth2Service initialization
|
||||
- Conditional OAuth2 initialization
|
||||
- OIDC discovery on startup
|
||||
- Feature flag support (`VITE_USE_OAUTH2`)
|
||||
- Error handling and logging
|
||||
- Graceful fallback if provider unavailable
|
||||
|
||||
#### User Management (100%)
|
||||
- ✅ `server/controllers/UserController.ts` - Dual auth support
|
||||
- OAuth2 user session detection
|
||||
- OAuth 1.0a fallback
|
||||
- Automatic token refresh
|
||||
- Unified user data format
|
||||
- Enhanced logout (clears both auth types)
|
||||
|
||||
---
|
||||
|
||||
## 🚧 In Progress
|
||||
|
||||
### Phase 3: Environment Configuration
|
||||
**Status:** 🟡 Partial (50%)
|
||||
**Remaining:** Production environment examples
|
||||
|
||||
#### Completed
|
||||
- ✅ `env_ai` - Development configuration
|
||||
- OAuth2 environment variables
|
||||
- Feature flag documentation
|
||||
- OBP-OIDC configuration
|
||||
|
||||
#### TODO
|
||||
- ⬜ `.env.example` update
|
||||
- ⬜ Production configuration guide
|
||||
- ⬜ Docker environment variables
|
||||
|
||||
---
|
||||
|
||||
## ⬜ Remaining Work
|
||||
|
||||
### Phase 4: Frontend Updates (0%)
|
||||
**Estimated Time:** 1 week
|
||||
|
||||
#### Required Changes
|
||||
- ⬜ `src/components/HeaderNav.vue`
|
||||
- Update login button URL (conditional)
|
||||
- Update logout button URL
|
||||
- Add OAuth2 status indicator (optional)
|
||||
|
||||
- ⬜ `src/components/ChatWidget.vue`
|
||||
- Update authentication check
|
||||
- Support OAuth2 user format
|
||||
|
||||
- ⬜ Frontend user state management
|
||||
- Handle OAuth2 user format
|
||||
- Token refresh on API calls
|
||||
- Session expiry handling
|
||||
|
||||
### Phase 5: Testing (0%)
|
||||
**Estimated Time:** 1 week
|
||||
|
||||
#### Unit Tests
|
||||
- ⬜ `server/test/pkce.test.ts`
|
||||
- Test code verifier generation
|
||||
- Test code challenge generation
|
||||
- Test state generation
|
||||
- Test validation functions
|
||||
|
||||
- ⬜ `server/test/OAuth2Service.test.ts`
|
||||
- Test OIDC discovery
|
||||
- Test authorization URL creation
|
||||
- Test token exchange
|
||||
- Test token refresh
|
||||
- Test user info retrieval
|
||||
|
||||
#### Integration Tests
|
||||
- ⬜ Full OAuth2 login flow
|
||||
- ⬜ Token refresh flow
|
||||
- ⬜ Logout flow
|
||||
- ⬜ Error scenarios
|
||||
- ⬜ Session timeout
|
||||
|
||||
#### Manual Testing
|
||||
- ⬜ Login with OBP-OIDC
|
||||
- ⬜ Session persistence
|
||||
- ⬜ Token auto-refresh
|
||||
- ⬜ Logout
|
||||
- ⬜ Multiple browsers/devices
|
||||
- ⬜ Error handling
|
||||
|
||||
### Phase 6: Documentation (33%)
|
||||
**Estimated Time:** Ongoing
|
||||
|
||||
#### Completed
|
||||
- ✅ OAuth2 preparation documents
|
||||
- ✅ Quick start guide
|
||||
- ✅ Implementation tracking (this file)
|
||||
|
||||
#### TODO
|
||||
- ⬜ Update main `README.md`
|
||||
- ⬜ Create migration guide
|
||||
- ⬜ Create troubleshooting guide
|
||||
- ⬜ Update deployment documentation
|
||||
- ⬜ Create admin guide
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps (Priority Order)
|
||||
|
||||
### Immediate (This Week)
|
||||
1. **Test Backend Implementation**
|
||||
- Start OBP-OIDC server
|
||||
- Configure environment variables
|
||||
- Test `/oauth2/connect` endpoint
|
||||
- Test `/oauth2/callback` flow
|
||||
- Verify session storage
|
||||
- Test `/user/current` with OAuth2
|
||||
|
||||
2. **Update Frontend Components**
|
||||
- Update `HeaderNav.vue` login button
|
||||
- Test login flow end-to-end
|
||||
- Verify user info display
|
||||
|
||||
### Short Term (Next Week)
|
||||
3. **Write Tests**
|
||||
- Unit tests for PKCE utilities
|
||||
- Unit tests for OAuth2Service
|
||||
- Integration tests for full flow
|
||||
|
||||
4. **Complete Documentation**
|
||||
- Update main README
|
||||
- Create migration guide
|
||||
- Troubleshooting guide
|
||||
|
||||
### Medium Term
|
||||
5. **Production Readiness**
|
||||
- Security audit
|
||||
- Performance testing
|
||||
- Production deployment guide
|
||||
- Monitoring setup
|
||||
|
||||
---
|
||||
|
||||
## 📝 Technical Details
|
||||
|
||||
### Endpoints Added
|
||||
- `GET /oauth2/connect` - Initiate OAuth2 login
|
||||
- `GET /oauth2/callback` - Handle OIDC callback
|
||||
|
||||
### Endpoints Modified
|
||||
- `GET /user/current` - Now supports both auth methods
|
||||
- `GET /user/logoff` - Clears both OAuth 1.0a and OAuth2 sessions
|
||||
|
||||
### Session Keys Used
|
||||
**OAuth2:**
|
||||
- `oauth2_state` - CSRF protection state
|
||||
- `oauth2_code_verifier` - PKCE verifier
|
||||
- `oauth2_flow_timestamp` - Flow start time
|
||||
- `oauth2_redirect_page` - Post-auth redirect
|
||||
- `oauth2_access_token` - JWT access token
|
||||
- `oauth2_refresh_token` - Refresh token
|
||||
- `oauth2_id_token` - OpenID Connect ID token
|
||||
- `oauth2_user` - User information
|
||||
- `oauth2_user_info` - Full UserInfo response
|
||||
|
||||
**OAuth 1.0a (unchanged):**
|
||||
- `clientConfig` - OAuth 1.0a configuration
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Feature Flag
|
||||
VITE_USE_OAUTH2=false|true
|
||||
|
||||
# Client Credentials
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=obp-explorer-ii-client
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=<secret>
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/oauth2/callback
|
||||
|
||||
# OIDC Provider
|
||||
VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
|
||||
# Optional
|
||||
VITE_OBP_OAUTH2_TOKEN_REFRESH_THRESHOLD=300
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Features Implemented
|
||||
|
||||
- ✅ PKCE (Proof Key for Code Exchange) - RFC 7636
|
||||
- ✅ State parameter for CSRF protection
|
||||
- ✅ Code verifier validation (43-128 chars, valid charset)
|
||||
- ✅ State parameter validation (min 32 chars)
|
||||
- ✅ Flow timeout protection (10 minute max)
|
||||
- ✅ Token expiration checking
|
||||
- ✅ Automatic token refresh
|
||||
- ✅ Secure session storage (Redis)
|
||||
- ✅ XSS protection in error pages
|
||||
- ✅ Comprehensive error handling
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Issues / TODO
|
||||
|
||||
### High Priority
|
||||
- [ ] Test with actual OBP-OIDC server
|
||||
- [ ] Verify token refresh works correctly
|
||||
- [ ] Test session timeout behavior
|
||||
- [ ] Verify CSRF protection
|
||||
|
||||
### Medium Priority
|
||||
- [ ] Add rate limiting to OAuth2 endpoints
|
||||
- [ ] Add metrics/monitoring
|
||||
- [ ] Improve error messages
|
||||
- [ ] Add request ID tracking
|
||||
|
||||
### Low Priority
|
||||
- [ ] Add OAuth2 status page
|
||||
- [ ] Add admin dashboard for sessions
|
||||
- [ ] Add token introspection endpoint
|
||||
- [ ] Support multiple OIDC providers
|
||||
|
||||
---
|
||||
|
||||
## 📊 Code Statistics
|
||||
|
||||
```
|
||||
Total Files Created: 8
|
||||
Total Lines Added: ~1,800
|
||||
Languages: TypeScript
|
||||
Framework: Express.js, routing-controllers
|
||||
Dependencies Added: 2 (arctic, jsonwebtoken)
|
||||
|
||||
Backend Implementation:
|
||||
- Services: 1 (OAuth2Service)
|
||||
- Controllers: 2 (Connect, Callback)
|
||||
- Middlewares: 2 (Authorization, Callback)
|
||||
- Utils: 1 (PKCE)
|
||||
|
||||
Documentation:
|
||||
- Preparation docs: 3 files (~3,300 lines)
|
||||
- Implementation tracking: 1 file (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
### Backend Testing
|
||||
- [ ] PKCE utilities generate valid parameters
|
||||
- [ ] OAuth2Service initializes from well-known URL
|
||||
- [ ] Authorization URL contains required parameters
|
||||
- [ ] Callback validates state parameter
|
||||
- [ ] Token exchange works correctly
|
||||
- [ ] User info retrieval works
|
||||
- [ ] Token refresh works
|
||||
- [ ] Session cleanup on logout
|
||||
|
||||
### Integration Testing
|
||||
- [ ] Full login flow completes successfully
|
||||
- [ ] Redirect back to original page works
|
||||
- [ ] Session persists across requests
|
||||
- [ ] Token auto-refresh works
|
||||
- [ ] Logout clears all session data
|
||||
- [ ] Error handling shows user-friendly messages
|
||||
|
||||
### Security Testing
|
||||
- [ ] State parameter prevents CSRF
|
||||
- [ ] PKCE prevents code interception
|
||||
- [ ] Flow timeout prevents replay
|
||||
- [ ] Tokens stored securely in session
|
||||
- [ ] XSS protection in error pages
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Resources
|
||||
|
||||
### Getting Help
|
||||
- **Documentation:** See `OAUTH2-QUICK-START.md` for setup
|
||||
- **Detailed Guide:** See `OAUTH2-OIDC-INTEGRATION-PREP.md`
|
||||
- **Slack:** #obp-development
|
||||
- **Email:** dev@tesobe.com
|
||||
|
||||
### Quick Commands
|
||||
```bash
|
||||
# Start OBP-OIDC
|
||||
cd ~/Documents/workspace_2024/OBP-OIDC
|
||||
./run-server.sh
|
||||
|
||||
# Start API Explorer II
|
||||
cd ~/Documents/workspace_2024/API-Explorer-II
|
||||
npm run dev
|
||||
|
||||
# Test OIDC discovery
|
||||
curl http://localhost:9000/obp-oidc/.well-known/openid-configuration
|
||||
|
||||
# Check Redis
|
||||
redis-cli ping
|
||||
redis-cli KEYS "sess:*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Achievements
|
||||
|
||||
✅ **Backend Core Complete** - All OAuth2 infrastructure implemented
|
||||
✅ **PKCE Support** - Security best practices implemented
|
||||
✅ **Dual Auth Support** - Backward compatibility maintained
|
||||
✅ **Comprehensive Documentation** - 3,300+ lines of guides
|
||||
✅ **Error Handling** - User-friendly error pages
|
||||
✅ **Automatic Token Refresh** - Seamless UX
|
||||
|
||||
---
|
||||
|
||||
## 📅 Timeline
|
||||
|
||||
| Phase | Start Date | Target End | Actual End | Status |
|
||||
|-------|-----------|------------|------------|--------|
|
||||
| Phase 1 | 2024-11-29 | 2024-11-29 | 2024-11-29 | ✅ Complete |
|
||||
| Phase 2 | 2024-11-29 | 2024-11-29 | 2024-11-29 | ✅ Complete |
|
||||
| Phase 3 | 2024-11-29 | 2024-12-06 | - | 🟡 In Progress |
|
||||
| Phase 4 | - | 2024-12-13 | - | ⬜ Pending |
|
||||
| Phase 5 | - | 2024-12-20 | - | ⬜ Pending |
|
||||
| Phase 6 | 2024-11-29 | Ongoing | - | 🟡 In Progress |
|
||||
|
||||
**Overall Target Completion:** Mid-December 2024
|
||||
**Current Pace:** Ahead of schedule
|
||||
|
||||
---
|
||||
|
||||
**Next Action:** Test backend implementation with OBP-OIDC server
|
||||
2369
OAUTH2-OIDC-INTEGRATION-PREP.md
Normal file
2369
OAUTH2-OIDC-INTEGRATION-PREP.md
Normal file
File diff suppressed because it is too large
Load Diff
508
OAUTH2-QUICK-START.md
Normal file
508
OAUTH2-QUICK-START.md
Normal file
@ -0,0 +1,508 @@
|
||||
# OAuth2/OIDC Quick Start Guide
|
||||
## API Explorer II Integration with OBP-OIDC
|
||||
|
||||
**Quick reference for developers getting started with OAuth2/OIDC integration**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Setup (15 minutes)
|
||||
|
||||
### Step 1: Set Up OBP-OIDC (5 minutes)
|
||||
|
||||
```bash
|
||||
# Navigate to OBP-OIDC directory
|
||||
cd ~/Documents/workspace_2024/OBP-OIDC
|
||||
|
||||
# Copy example configuration
|
||||
cp run-server.example.sh run-server.sh
|
||||
|
||||
# Edit database credentials (IMPORTANT!)
|
||||
vim run-server.sh
|
||||
# Update: DB_HOST, DB_PORT, DB_NAME, OIDC_USER_PASSWORD, OIDC_ADMIN_PASSWORD
|
||||
|
||||
# Start the OIDC server
|
||||
./run-server.sh
|
||||
```
|
||||
|
||||
**Verify it's running:**
|
||||
```bash
|
||||
curl http://localhost:9000/obp-oidc/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
### Step 2: Configure API Explorer II (5 minutes)
|
||||
|
||||
```bash
|
||||
# Navigate to API Explorer II
|
||||
cd ~/Documents/workspace_2024/API-Explorer-II
|
||||
|
||||
# Install new dependencies
|
||||
npm install arctic jsonwebtoken @types/jsonwebtoken
|
||||
|
||||
# Update .env file
|
||||
cat >> .env << EOF
|
||||
|
||||
# OAuth2/OIDC Configuration
|
||||
VITE_USE_OAUTH2=true
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=obp-explorer-ii-client
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=CHANGE_THIS_TO_EXPLORER_SECRET_2024
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/oauth2/callback
|
||||
VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
EOF
|
||||
```
|
||||
|
||||
**Note:** The client secret above matches the default in OBP-OIDC's `run-server.sh`. Change it to your actual secret.
|
||||
|
||||
### Step 3: Verify Prerequisites (2 minutes)
|
||||
|
||||
```bash
|
||||
# Check Redis is running
|
||||
redis-cli ping
|
||||
# Expected output: PONG
|
||||
|
||||
# Check OBP-API is running
|
||||
curl http://localhost:8080/obp/v5.1.0/root
|
||||
# Expected: JSON response
|
||||
|
||||
# Check Node version
|
||||
node --version
|
||||
# Expected: v16.14.0 or higher
|
||||
```
|
||||
|
||||
### Step 4: Test the Setup (3 minutes)
|
||||
|
||||
```bash
|
||||
# Start API Explorer II
|
||||
npm run dev
|
||||
|
||||
# Open browser to http://localhost:5173
|
||||
# Click "Login" button
|
||||
# Should redirect to OBP-OIDC login page
|
||||
```
|
||||
|
||||
**Test credentials** (default OBP-OIDC users):
|
||||
- Username: `user@example.com`
|
||||
- Password: (check your OBP database)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Implementation Checklist
|
||||
|
||||
Use this checklist to track your implementation progress:
|
||||
|
||||
### Phase 1: Backend Core
|
||||
- [ ] Create `server/utils/pkce.ts`
|
||||
- [ ] Create `server/services/OAuth2Service.ts`
|
||||
- [ ] Create `server/middlewares/OAuth2AuthorizationMiddleware.ts`
|
||||
- [ ] Create `server/middlewares/OAuth2CallbackMiddleware.ts`
|
||||
- [ ] Create `server/controllers/OAuth2ConnectController.ts`
|
||||
- [ ] Create `server/controllers/OAuth2CallbackController.ts`
|
||||
- [ ] Update `server/app.ts` to initialize OAuth2Service
|
||||
|
||||
### Phase 2: User Management
|
||||
- [ ] Update `server/controllers/UserController.ts` getCurrentUser()
|
||||
- [ ] Update `server/controllers/UserController.ts` logoff()
|
||||
- [ ] Support both OAuth 1.0a and OAuth2 sessions
|
||||
|
||||
### Phase 3: Frontend
|
||||
- [ ] Update `src/components/HeaderNav.vue` login button
|
||||
- [ ] Update `src/components/HeaderNav.vue` logout button
|
||||
- [ ] Add OAuth2 status indicator (optional)
|
||||
|
||||
### Phase 4: Testing
|
||||
- [ ] Write unit tests for PKCE utilities
|
||||
- [ ] Write unit tests for OAuth2Service
|
||||
- [ ] Write integration tests for login flow
|
||||
- [ ] Manual testing of full authentication flow
|
||||
|
||||
### Phase 5: Documentation
|
||||
- [ ] Update README.md
|
||||
- [ ] Create migration guide
|
||||
- [ ] Create troubleshooting guide
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Key Files to Create
|
||||
|
||||
### 1. PKCE Utilities (`server/utils/pkce.ts`)
|
||||
|
||||
```typescript
|
||||
import crypto from 'crypto'
|
||||
|
||||
export class PKCEUtils {
|
||||
static generateCodeVerifier(): string {
|
||||
return crypto.randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
static generateCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url')
|
||||
}
|
||||
|
||||
static generateState(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. OAuth2 Service (`server/services/OAuth2Service.ts`)
|
||||
|
||||
```typescript
|
||||
import { OAuth2Client } from 'arctic'
|
||||
import { Service } from 'typedi'
|
||||
|
||||
export interface OIDCConfiguration {
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
userinfo_endpoint: string
|
||||
jwks_uri: string
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class OAuth2Service {
|
||||
private client: OAuth2Client
|
||||
private oidcConfig: OIDCConfiguration | null = null
|
||||
|
||||
constructor() {
|
||||
const clientId = process.env.VITE_OBP_OAUTH2_CLIENT_ID
|
||||
const clientSecret = process.env.VITE_OBP_OAUTH2_CLIENT_SECRET
|
||||
const redirectUri = process.env.VITE_OBP_OAUTH2_REDIRECT_URL
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error('OAuth2 configuration incomplete')
|
||||
}
|
||||
|
||||
this.client = new OAuth2Client(clientId, clientSecret, redirectUri)
|
||||
}
|
||||
|
||||
async initializeFromWellKnown(wellKnownUrl: string): Promise<void> {
|
||||
const response = await fetch(wellKnownUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch OIDC config: ${response.statusText}`)
|
||||
}
|
||||
this.oidcConfig = await response.json()
|
||||
}
|
||||
|
||||
createAuthorizationURL(state: string, scopes: string[] = ['openid', 'profile', 'email']): URL {
|
||||
if (!this.oidcConfig) {
|
||||
throw new Error('OIDC configuration not initialized')
|
||||
}
|
||||
return this.client.createAuthorizationURL(this.oidcConfig.authorization_endpoint, state, scopes)
|
||||
}
|
||||
|
||||
async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<any> {
|
||||
if (!this.oidcConfig) {
|
||||
throw new Error('OIDC configuration not initialized')
|
||||
}
|
||||
return await this.client.validateAuthorizationCode(
|
||||
this.oidcConfig.token_endpoint,
|
||||
code,
|
||||
codeVerifier
|
||||
)
|
||||
}
|
||||
|
||||
async getUserInfo(accessToken: string): Promise<any> {
|
||||
if (!this.oidcConfig) {
|
||||
throw new Error('OIDC configuration not initialized')
|
||||
}
|
||||
const response = await fetch(this.oidcConfig.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`UserInfo request failed: ${response.statusText}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Authorization Middleware (`server/middlewares/OAuth2AuthorizationMiddleware.ts`)
|
||||
|
||||
```typescript
|
||||
import { ExpressMiddlewareInterface } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import { Service } from 'typedi'
|
||||
import { OAuth2Service } from '../services/OAuth2Service'
|
||||
import { PKCEUtils } from '../utils/pkce'
|
||||
|
||||
@Service()
|
||||
export default class OAuth2AuthorizationMiddleware implements ExpressMiddlewareInterface {
|
||||
constructor(private oauth2Service: OAuth2Service) {}
|
||||
|
||||
async use(request: Request, response: Response): Promise<void> {
|
||||
const session = request.session
|
||||
const redirectPage = request.query.redirect
|
||||
|
||||
if (redirectPage) {
|
||||
session['redirectPage'] = redirectPage
|
||||
}
|
||||
|
||||
// Generate PKCE parameters
|
||||
const codeVerifier = PKCEUtils.generateCodeVerifier()
|
||||
const codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier)
|
||||
const state = PKCEUtils.generateState()
|
||||
|
||||
// Store in session
|
||||
session['oauth2_state'] = state
|
||||
session['oauth2_code_verifier'] = codeVerifier
|
||||
|
||||
// Create authorization URL
|
||||
const authUrl = this.oauth2Service.createAuthorizationURL(state)
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge)
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256')
|
||||
|
||||
console.log('OAuth2: Redirecting to authorization endpoint')
|
||||
response.redirect(authUrl.toString())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Callback Middleware (`server/middlewares/OAuth2CallbackMiddleware.ts`)
|
||||
|
||||
```typescript
|
||||
import { ExpressMiddlewareInterface } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import { Service } from 'typedi'
|
||||
import { OAuth2Service } from '../services/OAuth2Service'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
@Service()
|
||||
export default class OAuth2CallbackMiddleware implements ExpressMiddlewareInterface {
|
||||
constructor(private oauth2Service: OAuth2Service) {}
|
||||
|
||||
async use(request: Request, response: Response): Promise<void> {
|
||||
const session = request.session
|
||||
const code = request.query.code as string
|
||||
const state = request.query.state as string
|
||||
|
||||
// Validate state
|
||||
if (!state || state !== session['oauth2_state']) {
|
||||
console.error('OAuth2: State validation failed')
|
||||
return response.status(400).send('Invalid state parameter')
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const codeVerifier = session['oauth2_code_verifier']
|
||||
if (!codeVerifier) {
|
||||
console.error('OAuth2: Code verifier not found')
|
||||
return response.status(400).send('Invalid session state')
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const tokens = await this.oauth2Service.exchangeCodeForTokens(code, codeVerifier)
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.oauth2Service.getUserInfo(tokens.accessToken())
|
||||
|
||||
// Store in session
|
||||
session['oauth2_access_token'] = tokens.accessToken()
|
||||
session['oauth2_refresh_token'] = tokens.refreshToken?.() || null
|
||||
session['oauth2_id_token'] = tokens.idToken?.() || null
|
||||
session['oauth2_user_info'] = userInfo
|
||||
|
||||
// Decode ID token
|
||||
const idToken = tokens.idToken?.()
|
||||
if (idToken) {
|
||||
const decoded: any = jwt.decode(idToken)
|
||||
session['oauth2_user'] = {
|
||||
sub: decoded.sub,
|
||||
email: decoded.email,
|
||||
name: decoded.name,
|
||||
username: decoded.preferred_username || decoded.sub
|
||||
}
|
||||
}
|
||||
|
||||
// Clear flow parameters
|
||||
delete session['oauth2_state']
|
||||
delete session['oauth2_code_verifier']
|
||||
|
||||
// Redirect
|
||||
const redirectPage = session['redirectPage'] || process.env.VITE_OBP_API_EXPLORER_HOST
|
||||
delete session['redirectPage']
|
||||
|
||||
console.log('OAuth2: Authentication successful')
|
||||
response.redirect(redirectPage as string)
|
||||
} catch (error: any) {
|
||||
console.error('OAuth2: Token exchange failed:', error)
|
||||
response.status(500).send('Authentication failed: ' + error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Your Implementation
|
||||
|
||||
### Manual Testing Flow
|
||||
|
||||
1. **Start all services:**
|
||||
```bash
|
||||
# Terminal 1: OBP-OIDC
|
||||
cd ~/Documents/workspace_2024/OBP-OIDC
|
||||
./run-server.sh
|
||||
|
||||
# Terminal 2: Redis
|
||||
redis-server
|
||||
|
||||
# Terminal 3: API Explorer II
|
||||
cd ~/Documents/workspace_2024/API-Explorer-II
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **Test login flow:**
|
||||
- Open http://localhost:5173
|
||||
- Click "Login" button
|
||||
- Should redirect to http://localhost:9000/obp-oidc/auth
|
||||
- Enter credentials
|
||||
- Should redirect back to http://localhost:5173
|
||||
- Username should appear in header
|
||||
|
||||
3. **Test session persistence:**
|
||||
- Refresh the page
|
||||
- Should remain logged in
|
||||
- Username still visible
|
||||
|
||||
4. **Test logout:**
|
||||
- Click "Logout" button
|
||||
- Should redirect to home
|
||||
- No longer authenticated
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
**Enable debug logging:**
|
||||
```bash
|
||||
DEBUG=express-session npm run dev
|
||||
```
|
||||
|
||||
**Check session in Redis:**
|
||||
```bash
|
||||
redis-cli
|
||||
> KEYS sess:*
|
||||
> GET sess:<your_session_id>
|
||||
```
|
||||
|
||||
**Check OIDC configuration:**
|
||||
```bash
|
||||
curl http://localhost:9000/obp-oidc/.well-known/openid-configuration | jq
|
||||
```
|
||||
|
||||
**Monitor logs:**
|
||||
- Watch server console for "OAuth2:" prefixed messages
|
||||
- Watch browser console for errors
|
||||
- Check OBP-OIDC terminal for authentication attempts
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Common Issues & Solutions
|
||||
|
||||
### Issue: "OIDC configuration not initialized"
|
||||
**Cause:** Well-known URL not reachable or OAuth2Service not initialized
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check OBP-OIDC is running
|
||||
curl http://localhost:9000/obp-oidc/.well-known/openid-configuration
|
||||
|
||||
# Verify environment variable
|
||||
echo $VITE_OBP_OAUTH2_WELL_KNOWN_URL
|
||||
|
||||
# Check server logs for initialization error
|
||||
```
|
||||
|
||||
### Issue: "State validation failed"
|
||||
**Cause:** Session not persisting between requests
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check Redis is running
|
||||
redis-cli ping
|
||||
|
||||
# Verify Redis connection in server logs
|
||||
# Should see: "Connected to Redis instance: ..."
|
||||
|
||||
# Check session cookie in browser DevTools (Application > Cookies)
|
||||
```
|
||||
|
||||
### Issue: "Code verifier not found in session"
|
||||
**Cause:** Session expired or cookie not set
|
||||
|
||||
**Solution:**
|
||||
- Clear browser cookies
|
||||
- Check session timeout settings in `server/app.ts`
|
||||
- Verify `VITE_OPB_SERVER_SESSION_PASSWORD` is set
|
||||
|
||||
### Issue: "Token request failed: 401"
|
||||
**Cause:** Invalid client credentials
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Verify client credentials match OBP-OIDC configuration
|
||||
grep OIDC_CLIENT_EXPLORER ~/Documents/workspace_2024/OBP-OIDC/run-server.sh
|
||||
|
||||
# Check credentials in .env
|
||||
grep VITE_OBP_OAUTH2 .env
|
||||
```
|
||||
|
||||
### Issue: Redirect loop
|
||||
**Cause:** Cookies not being set properly
|
||||
|
||||
**Solution:**
|
||||
- Check cookie settings in `server/app.ts`
|
||||
- If using nginx, verify `X-Forwarded-Proto` header
|
||||
- Set `app.set('trust proxy', 1)` if behind reverse proxy
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
### Full Documentation
|
||||
See `OAUTH2-OIDC-INTEGRATION-PREP.md` for:
|
||||
- Complete implementation guide
|
||||
- Architecture details
|
||||
- Production deployment
|
||||
- Security considerations
|
||||
- Testing strategy
|
||||
|
||||
### Reference Implementations
|
||||
- **OBP-Portal**: `~/Documents/workspace_2024/OBP-Portal`
|
||||
- `src/lib/oauth/` - OAuth2 implementation
|
||||
- `src/hooks.server.ts` - Server initialization
|
||||
- **OBP-OIDC**: `~/Documents/workspace_2024/OBP-OIDC`
|
||||
- `README.md` - OIDC provider documentation
|
||||
|
||||
### Standards & Specifications
|
||||
- OAuth 2.0: https://oauth.net/2/
|
||||
- OpenID Connect: https://openid.net/connect/
|
||||
- PKCE: https://oauth.net/2/pkce/
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
After completing the quick start:
|
||||
|
||||
1. **Read the full preparation document** (`OAUTH2-OIDC-INTEGRATION-PREP.md`)
|
||||
2. **Implement remaining phases** (see Phase 2-6 in main document)
|
||||
3. **Write comprehensive tests** (unit, integration, E2E)
|
||||
4. **Update documentation** (README, migration guide)
|
||||
5. **Plan production deployment** (see deployment section in main doc)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips for Success
|
||||
|
||||
1. **Keep OAuth 1.0a working** - Don't remove old code until OAuth2 is stable
|
||||
2. **Use feature flags** - `VITE_USE_OAUTH2` allows easy rollback
|
||||
3. **Test thoroughly** - OAuth2 flows have many edge cases
|
||||
4. **Monitor closely** - Watch logs and metrics during rollout
|
||||
5. **Document everything** - Future you will thank present you
|
||||
|
||||
---
|
||||
|
||||
**Need Help?**
|
||||
- Check `OAUTH2-OIDC-INTEGRATION-PREP.md` for detailed guidance
|
||||
- Review OBP-Portal reference implementation
|
||||
- Ask in #obp-development Slack channel
|
||||
|
||||
**Good luck! 🚀**
|
||||
409
OAUTH2-README.md
Normal file
409
OAUTH2-README.md
Normal file
@ -0,0 +1,409 @@
|
||||
# OAuth2/OIDC Integration Documentation
|
||||
## API Explorer II with OBP-OIDC
|
||||
|
||||
Welcome! This directory contains comprehensive documentation for integrating OAuth2/OpenID Connect authentication into API Explorer II.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Overview
|
||||
|
||||
This documentation set guides you through migrating API Explorer II from OAuth 1.0a to OAuth2/OIDC using OBP-OIDC as the identity provider.
|
||||
|
||||
### Available Documents
|
||||
|
||||
1. **[OAUTH2-QUICK-START.md](OAUTH2-QUICK-START.md)** ⭐ **START HERE**
|
||||
- 15-minute setup guide
|
||||
- Quick implementation checklist
|
||||
- Key code snippets
|
||||
- Common troubleshooting
|
||||
- Perfect for: Developers getting started
|
||||
|
||||
2. **[OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md)** 📖 **COMPLETE GUIDE**
|
||||
- Full preparation document (60 pages)
|
||||
- Architecture and design decisions
|
||||
- Phase-by-phase implementation plan
|
||||
- Testing strategy
|
||||
- Production deployment guide
|
||||
- Perfect for: Project planning and deep understanding
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Navigation
|
||||
|
||||
### For Developers
|
||||
|
||||
**Just getting started?**
|
||||
→ Read [OAUTH2-QUICK-START.md](OAUTH2-QUICK-START.md)
|
||||
|
||||
**Need implementation details?**
|
||||
→ See [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Section 6 (Implementation Phases)
|
||||
|
||||
**Having issues?**
|
||||
→ Check [OAUTH2-QUICK-START.md](OAUTH2-QUICK-START.md) Common Issues section
|
||||
→ Or [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Appendix B (Troubleshooting)
|
||||
|
||||
### For Project Managers
|
||||
|
||||
**Need an overview?**
|
||||
→ Read [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Section 1 (Executive Summary)
|
||||
|
||||
**Want timeline?**
|
||||
→ See [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Section 6 (Implementation Phases - 6 weeks)
|
||||
|
||||
**Need risk assessment?**
|
||||
→ Check [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Section 12 (Rollback Plan)
|
||||
|
||||
### For System Administrators
|
||||
|
||||
**Production deployment?**
|
||||
→ See [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Section 11 (Deployment Considerations)
|
||||
|
||||
**Configuration needed?**
|
||||
→ Check [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Section 8 (Configuration Changes)
|
||||
|
||||
**Production readiness?**
|
||||
→ Use [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Appendix C (Production Readiness Checklist)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started in 3 Steps
|
||||
|
||||
### Step 1: Read the Quick Start (15 min)
|
||||
|
||||
```bash
|
||||
# Open the quick start guide
|
||||
cat OAUTH2-QUICK-START.md
|
||||
# Or open in your editor/browser
|
||||
```
|
||||
|
||||
### Step 2: Set Up OBP-OIDC (5 min)
|
||||
|
||||
```bash
|
||||
cd ~/Documents/workspace_2024/OBP-OIDC
|
||||
cp run-server.example.sh run-server.sh
|
||||
# Edit run-server.sh with your database credentials
|
||||
./run-server.sh
|
||||
```
|
||||
|
||||
### Step 3: Configure API Explorer II (5 min)
|
||||
|
||||
```bash
|
||||
cd ~/Documents/workspace_2024/API-Explorer-II
|
||||
npm install arctic jsonwebtoken @types/jsonwebtoken
|
||||
|
||||
# Add to .env:
|
||||
VITE_USE_OAUTH2=true
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=obp-explorer-ii-client
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=CHANGE_THIS_TO_EXPLORER_SECRET_2024
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/oauth2/callback
|
||||
VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Implementation Timeline
|
||||
|
||||
| Phase | Duration | Description |
|
||||
|-------|----------|-------------|
|
||||
| **Phase 1** | Week 1 | Preparation & Setup |
|
||||
| **Phase 2** | Week 2-3 | Backend OAuth2 Implementation |
|
||||
| **Phase 3** | Week 3 | Environment Configuration |
|
||||
| **Phase 4** | Week 4 | Frontend Updates |
|
||||
| **Phase 5** | Week 5 | Testing |
|
||||
| **Phase 6** | Week 6 | Documentation & Migration |
|
||||
|
||||
**Total:** 6 weeks for complete implementation and testing
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Architecture Overview
|
||||
|
||||
### Current State (OAuth 1.0a)
|
||||
```
|
||||
User → Login → OAuth 1.0a Flow → OBP-API → Callback → Session
|
||||
```
|
||||
|
||||
### Target State (OAuth2/OIDC)
|
||||
```
|
||||
User → Login → OAuth2 Flow → OBP-OIDC → Token Exchange → Session
|
||||
↓
|
||||
JWT Tokens (Access + Refresh + ID)
|
||||
```
|
||||
|
||||
### Key Changes
|
||||
|
||||
| Aspect | Before (OAuth 1.0a) | After (OAuth2/OIDC) |
|
||||
|--------|---------------------|---------------------|
|
||||
| **Auth Method** | HMAC-SHA1 signatures | Bearer tokens (JWT) |
|
||||
| **Tokens** | Access token + secret | Access + Refresh + ID tokens |
|
||||
| **User Info** | API calls | ID token claims |
|
||||
| **Refresh** | Not supported | Automatic refresh |
|
||||
| **Providers** | Single (OBP-API) | Multiple (OBP-OIDC, Keycloak, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Key Features
|
||||
|
||||
### OAuth2/OIDC Benefits
|
||||
|
||||
✅ **Modern Standard** - Industry-standard authentication
|
||||
✅ **Better Security** - JWT tokens, PKCE flow, short-lived tokens
|
||||
✅ **Auto Refresh** - Seamless token renewal
|
||||
✅ **Multi-Provider** - Support for multiple identity providers
|
||||
✅ **Better UX** - SSO capabilities, cleaner flow
|
||||
✅ **Mobile Ready** - Native OAuth2 support in mobile apps
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
✅ **Feature Flag** - Switch between OAuth 1.0a and OAuth2
|
||||
✅ **Gradual Migration** - Both methods work simultaneously
|
||||
✅ **Easy Rollback** - Revert with a single environment variable
|
||||
✅ **No Breaking Changes** - Existing OAuth 1.0a code untouched
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Key Components
|
||||
|
||||
### Backend (Node.js/Express/TypeScript)
|
||||
|
||||
**New Files:**
|
||||
- `server/services/OAuth2Service.ts` - OAuth2/OIDC client
|
||||
- `server/utils/pkce.ts` - PKCE helper functions
|
||||
- `server/middlewares/OAuth2AuthorizationMiddleware.ts`
|
||||
- `server/middlewares/OAuth2CallbackMiddleware.ts`
|
||||
- `server/controllers/OAuth2ConnectController.ts`
|
||||
- `server/controllers/OAuth2CallbackController.ts`
|
||||
|
||||
**Modified Files:**
|
||||
- `server/app.ts` - Initialize OAuth2Service
|
||||
- `server/controllers/UserController.ts` - Support both auth methods
|
||||
|
||||
### Frontend (Vue 3)
|
||||
|
||||
**Modified Files:**
|
||||
- `src/components/HeaderNav.vue` - Dual auth support
|
||||
|
||||
### Dependencies
|
||||
|
||||
**New:**
|
||||
- `arctic` - Modern OAuth2/OIDC client library
|
||||
- `jsonwebtoken` - JWT parsing and validation
|
||||
|
||||
**Existing:** (no changes)
|
||||
- `express`, `express-session`, `connect-redis`, `redis`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **Unit Tests** - PKCE, OAuth2Service, Middlewares
|
||||
- **Integration Tests** - Full authentication flow
|
||||
- **E2E Tests** - Browser automation with Playwright
|
||||
- **Security Tests** - CSRF, XSS, token validation
|
||||
- **Performance Tests** - Load testing, benchmarks
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] Login flow (OAuth2)
|
||||
- [ ] Logout flow
|
||||
- [ ] Token refresh
|
||||
- [ ] Session persistence
|
||||
- [ ] Error handling
|
||||
- [ ] Multiple browsers/devices
|
||||
- [ ] Backward compatibility (OAuth 1.0a still works)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **Node.js** >= 16.14
|
||||
- **npm** >= 8.0.0
|
||||
- **Redis** >= 6.0
|
||||
- **PostgreSQL** >= 12 (for OBP database)
|
||||
- **Java** 11+ (for OBP-OIDC)
|
||||
- **Maven** 3.6+ (for OBP-OIDC)
|
||||
|
||||
### NPM Packages (New)
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"arctic": "^1.0.0",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
### Implemented Security Measures
|
||||
|
||||
✅ **PKCE** - Proof Key for Code Exchange (RFC 7636)
|
||||
✅ **State Parameter** - CSRF protection
|
||||
✅ **Secure Cookies** - httpOnly, secure flags
|
||||
✅ **Token Validation** - JWT signature verification
|
||||
✅ **HTTPS Required** - Production deployment
|
||||
✅ **Short-lived Tokens** - Access token expiration
|
||||
✅ **Refresh Rotation** - Refresh token rotation (if supported by provider)
|
||||
|
||||
### Production Security Checklist
|
||||
|
||||
- [ ] HTTPS enabled for all endpoints
|
||||
- [ ] Client secrets in secure vault
|
||||
- [ ] Session secrets strong and rotated
|
||||
- [ ] Rate limiting implemented
|
||||
- [ ] Audit logging enabled
|
||||
- [ ] Security headers configured
|
||||
- [ ] CORS properly set up
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Quick Fixes
|
||||
|
||||
**"OIDC configuration not initialized"**
|
||||
```bash
|
||||
# Check OBP-OIDC is running
|
||||
curl http://localhost:9000/obp-oidc/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
**"State validation failed"**
|
||||
```bash
|
||||
# Check Redis is running
|
||||
redis-cli ping
|
||||
# Clear browser cookies and retry
|
||||
```
|
||||
|
||||
**"Code verifier not found"**
|
||||
```bash
|
||||
# Check session configuration in server/app.ts
|
||||
# Verify Redis connection
|
||||
DEBUG=express-session npm run dev
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
- 📖 Check [OAUTH2-QUICK-START.md](OAUTH2-QUICK-START.md) Common Issues
|
||||
- 📖 Read [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) Troubleshooting section
|
||||
- 💬 Ask in #obp-development Slack channel
|
||||
- 📧 Email: dev@tesobe.com
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Learning Resources
|
||||
|
||||
### OAuth2/OIDC Standards
|
||||
|
||||
- **OAuth 2.0 Spec**: https://oauth.net/2/
|
||||
- **OpenID Connect**: https://openid.net/connect/
|
||||
- **PKCE (RFC 7636)**: https://oauth.net/2/pkce/
|
||||
- **OWASP OAuth Guide**: https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheatsheet.html
|
||||
|
||||
### Tools
|
||||
|
||||
- **OIDC Debugger**: https://oidcdebugger.com/
|
||||
- **JWT.io**: https://jwt.io/ (decode JWTs)
|
||||
- **OAuth 2.0 Playground**: https://www.oauth.com/playground/
|
||||
|
||||
### Reference Implementations
|
||||
|
||||
- **OBP-Portal** (`~/Documents/workspace_2024/OBP-Portal`)
|
||||
- Production-ready OAuth2/OIDC implementation
|
||||
- Multi-provider support
|
||||
- SvelteKit-based (patterns are framework-agnostic)
|
||||
|
||||
- **OBP-OIDC** (`~/Documents/workspace_2024/OBP-OIDC`)
|
||||
- Minimal OIDC provider
|
||||
- Perfect for development/testing
|
||||
- Scala/http4s implementation
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
### For Technical Questions
|
||||
|
||||
- **Development Team**: dev@tesobe.com
|
||||
- **Internal Slack**: #obp-development
|
||||
- **Documentation**: This directory
|
||||
|
||||
### For Security Issues
|
||||
|
||||
- **Security Team**: security@tesobe.com
|
||||
- **Follow**: Responsible disclosure guidelines
|
||||
|
||||
### For Production Issues
|
||||
|
||||
- **On-call Team**: oncall@tesobe.com
|
||||
- **Status Page**: status.openbankproject.com
|
||||
|
||||
---
|
||||
|
||||
## 📝 Document History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.0 | 2024 | Initial documentation created |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Next Actions
|
||||
|
||||
### For New Developers
|
||||
|
||||
1. ✅ Read this overview (you're here!)
|
||||
2. ⬜ Read [OAUTH2-QUICK-START.md](OAUTH2-QUICK-START.md)
|
||||
3. ⬜ Set up local environment
|
||||
4. ⬜ Test OAuth2 login flow
|
||||
5. ⬜ Review [OAUTH2-OIDC-INTEGRATION-PREP.md](OAUTH2-OIDC-INTEGRATION-PREP.md) for details
|
||||
|
||||
### For Implementation Team
|
||||
|
||||
1. ✅ Review all documentation
|
||||
2. ⬜ Set up OBP-OIDC server
|
||||
3. ⬜ Create implementation branch
|
||||
4. ⬜ Follow Phase 1 (Preparation) in main document
|
||||
5. ⬜ Begin Phase 2 (Backend Implementation)
|
||||
|
||||
### For Project Stakeholders
|
||||
|
||||
1. ✅ Review Executive Summary (Section 1 of main doc)
|
||||
2. ⬜ Review timeline (6 weeks)
|
||||
3. ⬜ Approve implementation phases
|
||||
4. ⬜ Schedule kickoff meeting
|
||||
5. ⬜ Assign resources
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Criteria
|
||||
|
||||
Implementation is complete when:
|
||||
|
||||
- ✅ OAuth2 login flow works end-to-end
|
||||
- ✅ Token refresh works automatically
|
||||
- ✅ All tests passing (unit, integration, E2E)
|
||||
- ✅ Documentation updated
|
||||
- ✅ Backward compatibility maintained (OAuth 1.0a still works)
|
||||
- ✅ Production deployment successful
|
||||
- ✅ Monitoring and alerts configured
|
||||
- ✅ Team trained on new system
|
||||
|
||||
---
|
||||
|
||||
**Ready to begin? Start with [OAUTH2-QUICK-START.md](OAUTH2-QUICK-START.md)!**
|
||||
|
||||
---
|
||||
|
||||
**License:** AGPL V3
|
||||
**Copyright:** 2024 TESOBE GmbH
|
||||
**Project:** Open Bank Project - API Explorer II
|
||||
61
ai_env.example
Normal file
61
ai_env.example
Normal file
@ -0,0 +1,61 @@
|
||||
### OBP-API Configuration ###
|
||||
VITE_OBP_API_PORTAL_HOST=http://127.0.0.1:8080 # OBP API Portal URL (for "Portal Home" navigation link)
|
||||
VITE_OBP_API_HOST=http://127.0.0.1:8080 # OBP API server base URL (for all backend API requests)
|
||||
# VITE_OBP_API_VERSION is NO LONGER USED - hardcoded to v5.1.0 in shared-constants.ts for stability
|
||||
VITE_OBP_API_MANAGER_HOST=https://apimanagersandbox.openbankproject.com # OBP API Manager URL (optional - for navigation link)
|
||||
VITE_OBP_API_EXPLORER_HOST=http://localhost:5173 # API Explorer application URL (used for OAuth2 redirects and internal routing)
|
||||
VITE_OPB_SERVER_SESSION_PASSWORD=your-secret-session-password-here # Secret key for session encryption (keep this secure!)
|
||||
VITE_SHOW_API_MANAGER_BUTTON=false # Show/hide API Manager button in navigation (true/false)
|
||||
|
||||
### Redis Configuration ###
|
||||
VITE_OBP_REDIS_URL=redis://127.0.0.1:6379 # Redis connection string for session storage (format: redis://host:port)
|
||||
|
||||
### Opey Configuration ###
|
||||
VITE_CHATBOT_ENABLED=false # Enable/disable Opey chatbot widget (true/false)
|
||||
VITE_CHATBOT_URL=http://localhost:5000 # Opey chatbot service URL (only needed if chatbot is enabled)
|
||||
|
||||
### OAuth2/OIDC Configuration ###
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779 # OAuth2 client ID (UUID - must match OIDC server registration)
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM # OAuth2 client secret (keep this secure!)
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback # OAuth2 callback URL (must exactly match OIDC client registration)
|
||||
VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://localhost:9000/obp-oidc/.well-known/openid-configuration # OIDC discovery endpoint URL
|
||||
VITE_OBP_OAUTH2_TOKEN_REFRESH_THRESHOLD=300 # Seconds before token expiry to trigger refresh (default: 300)
|
||||
|
||||
### Resource Documentation Version (Optional) ###
|
||||
# VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv5.1.0 # Default resource docs version for frontend URLs (format: OBPv5.1.0 - with OBP prefix, auto-constructed if not set)
|
||||
|
||||
### Session Configuration (Optional) ###
|
||||
# VITE_SESSION_MAX_AGE=3600 # Session timeout in seconds (default: 3600 = 1 hour)
|
||||
# Common values:
|
||||
# 1800 = 30 minutes
|
||||
# 3600 = 1 hour (default)
|
||||
# 7200 = 2 hours
|
||||
# 14400 = 4 hours
|
||||
# 28800 = 8 hours (full work day)
|
||||
# 86400 = 24 hours
|
||||
|
||||
### Styling Configuration (Optional) ###
|
||||
# VITE_OBP_LOGO_URL=https://example.com/logo.png # Custom logo image URL (uses default OBP logo if not set)
|
||||
# VITE_OBP_LINKS_COLOR=#3c8dbc # Primary link color (CSS color value)
|
||||
# VITE_OBP_HEADER_LINKS_COLOR=#39455f # Header navigation link color
|
||||
# VITE_OBP_HEADER_LINKS_HOVER_COLOR=#39455f # Header navigation link hover color
|
||||
# VITE_OBP_HEADER_LINKS_BACKGROUND_COLOR=#eef0f4 # Header navigation active link background color
|
||||
|
||||
################################################################################
|
||||
# POTENTIALLY UNUSED ENVIRONMENT VARIABLES
|
||||
################################################################################
|
||||
# The following variable appears in this file but was NOT found in the codebase.
|
||||
# It may be unused and safe to remove, or it might be used in a way that wasn't
|
||||
# detected by code search.
|
||||
################################################################################
|
||||
|
||||
# VITE_OBP_OAUTH2_TOKEN_REFRESH_THRESHOLD=300
|
||||
# ⚠️ NOT FOUND IN CODE - This variable is defined above but does not appear to
|
||||
# be referenced anywhere in the application code. It may have been intended for
|
||||
# a feature that was not implemented or was removed.
|
||||
# Consider removing this unless you know it's needed for a specific use case.
|
||||
|
||||
################################################################################
|
||||
# Note: The comment "VITE_OBP_API_VERSION is NO LONGER USED" on line 3 is
|
||||
# correct - this variable was replaced by hardcoded version in shared-constants.ts
|
||||
################################################################################
|
||||
17
components.d.ts
vendored
17
components.d.ts
vendored
@ -18,17 +18,17 @@ declare module 'vue' {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElContainter: typeof import('element-plus/es')['ElContainter']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
@ -36,21 +36,26 @@ declare module 'vue' {
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
GlossarySearchNav: typeof import('./src/components/GlossarySearchNav.vue')['default']
|
||||
HeaderNav: typeof import('./src/components/HeaderNav.vue')['default']
|
||||
JsonSchemaViewer: typeof import('./src/components/JsonSchemaViewer.vue')['default']
|
||||
Menu: typeof import('./src/components/Menu.vue')['default']
|
||||
MessageDocsContent: typeof import('./src/components/MessageDocsContent.vue')['default']
|
||||
MessageDocsJsonSchemaSearchNav: typeof import('./src/components/MessageDocsJsonSchemaSearchNav.vue')['default']
|
||||
MessageDocsSearchNav: typeof import('./src/components/MessageDocsSearchNav.vue')['default']
|
||||
Preview: typeof import('./src/components/Preview.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchNav: typeof import('./src/components/SearchNav.vue')['default']
|
||||
SvelteDropdown: typeof import('./src/components/SvelteDropdown.vue')['default']
|
||||
ToolCall: typeof import('./src/components/ToolCall.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
63
env_ai
Normal file
63
env_ai
Normal file
@ -0,0 +1,63 @@
|
||||
### OBP API Configuration ###
|
||||
VITE_OBP_API_HOST=http://127.0.0.1:8080
|
||||
VITE_OBP_API_VERSION=v6.0.0
|
||||
VITE_OBP_API_EXPLORER_HOST=http://localhost:5173
|
||||
|
||||
### Session Configuration ###
|
||||
VITE_OPB_SERVER_SESSION_PASSWORD=asidudhiuh33875
|
||||
|
||||
### OAuth2 Redirect URL (shared by all providers) ###
|
||||
VITE_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
### Redis Configuration ###
|
||||
VITE_OBP_REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
### Chatbot Configuration ###
|
||||
VITE_CHATBOT_ENABLED=false
|
||||
VITE_CHATBOT_URL=http://localhost:5000
|
||||
|
||||
### Multi-Provider OAuth2/OIDC Configuration ###
|
||||
### The system fetches available providers from: http://localhost:8080/obp/v5.1.0/well-known
|
||||
### Configure credentials below for each provider you want to support
|
||||
|
||||
### OBP-OIDC Provider ###
|
||||
VITE_OBP_OIDC_CLIENT_ID=c2ea173e-8c1a-43c4-ba62-19738f27c43e
|
||||
VITE_OBP_OIDC_CLIENT_SECRET=1E7zsN47Xp4VTb28xEv5ZK4vcX8XMsYIH3IsnjQTYk8
|
||||
|
||||
### OBP Consumer Key (for API calls) ###
|
||||
VITE_OBP_CONSUMER_KEY=c2ea173e-8c1a-43c4-ba62-19738f27c43e
|
||||
|
||||
### Keycloak Provider (Optional) ###
|
||||
# VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
|
||||
# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret-here
|
||||
|
||||
### Google Provider (Optional) ###
|
||||
# VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||
# VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
|
||||
### GitHub Provider (Optional) ###
|
||||
# VITE_GITHUB_CLIENT_ID=your-github-client-id
|
||||
# VITE_GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
|
||||
### Custom OIDC Provider (Optional) ###
|
||||
# VITE_CUSTOM_OIDC_PROVIDER_NAME=my-custom-provider
|
||||
# VITE_CUSTOM_OIDC_CLIENT_ID=your-custom-client-id
|
||||
# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-custom-client-secret
|
||||
|
||||
### Opey Configuration ###
|
||||
VITE_OPEY_CONSUMER_ID=74545fb7-9a1f-4ee0-beb4-6e5b7ee50076
|
||||
|
||||
### Resource Docs Version ###
|
||||
VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv6.0.0
|
||||
|
||||
### HOW IT WORKS ###
|
||||
# 1. Backend fetches provider list from OBP API: GET /obp/v5.1.0/well-known
|
||||
# 2. OBP API returns available providers with their .well-known URLs
|
||||
# 3. Backend matches providers with credentials configured above
|
||||
# 4. Only providers with both (API registration + credentials) will be available
|
||||
# 5. Users see provider selection if 2+ providers configured (or auto-login if only 1)
|
||||
|
||||
### VERIFY YOUR SETUP ###
|
||||
# curl http://localhost:8080/obp/v5.1.0/well-known
|
||||
# curl http://localhost:8085/api/oauth2/providers
|
||||
# Visit: http://localhost:5173/debug/providers-status
|
||||
4720
package-lock.json
generated
4720
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "api-explorer",
|
||||
"version": "1.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"types": [
|
||||
"jest"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite & ts-node server/app.ts",
|
||||
"build": "run-p build-only",
|
||||
"dev": "vite & tsx --tsconfig tsconfig.server.json server/app.ts",
|
||||
"build": "run-p build-only build-server",
|
||||
"build-server": "tsc --project tsconfig.server.json",
|
||||
"build-production": "npm run build",
|
||||
"test-production": "./scripts/build-and-test-production.sh",
|
||||
"start": "node dist-server/server/app.js",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"build-only": "vite build",
|
||||
@ -25,6 +29,7 @@
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"ai": "^4.1.43",
|
||||
"arctic": "^3.7.0",
|
||||
"axios": "^1.7.4",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -40,7 +45,6 @@
|
||||
"langchain": "^0.3.19",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"oauth": "^0.10.0",
|
||||
"obp-api-typescript": "^1.0.1",
|
||||
"obp-typescript": "^1.0.36",
|
||||
"pinia": "^2.0.37",
|
||||
@ -64,10 +68,11 @@
|
||||
"@ai-sdk/vue": "^1.1.18",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rushstack/eslint-patch": "^1.4.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/oauth": "^0.9.6",
|
||||
@ -89,19 +94,20 @@
|
||||
"prettier": "^3.0.1",
|
||||
"superagent": "^9.0.0",
|
||||
"supertest": "^7.0.0",
|
||||
"svelte": "^5.45.3",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "~5.2.2",
|
||||
"unplugin-auto-import": "^0.18.0",
|
||||
"unplugin-element-plus": "^0.8.0",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"vite": "^4.4.0",
|
||||
"vite-plugin-node-polyfills": "^0.10.0",
|
||||
"vite-plugin-rewrite-all": "^1.0.2",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vitest": "^0.34.6",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@langchain/core": "0.1.5"
|
||||
"@langchain/core": ">=0.3.39 <0.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
216
scripts/build-and-test-production.sh
Executable file
216
scripts/build-and-test-production.sh
Executable file
@ -0,0 +1,216 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Production Build and Test Script for API Explorer II
|
||||
# This script builds both frontend and backend, then tests as if on a production server
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}API Explorer II - Production Build Test${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[✓]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[✗]${NC} $1"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "${YELLOW}[i]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "package.json" ]; then
|
||||
print_error "package.json not found. Please run this script from the API-Explorer-II directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_info "Starting production build and test process..."
|
||||
echo ""
|
||||
|
||||
# Step 1: Clean previous builds
|
||||
echo -e "${BLUE}Step 1: Cleaning previous builds${NC}"
|
||||
if [ -d "dist" ]; then
|
||||
rm -rf dist
|
||||
print_status "Removed old frontend build (dist/)"
|
||||
fi
|
||||
|
||||
if [ -d "dist-server" ]; then
|
||||
rm -rf dist-server
|
||||
print_status "Removed old backend build (dist-server/)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 2: Check environment variables
|
||||
echo -e "${BLUE}Step 2: Checking environment configuration${NC}"
|
||||
if [ -f ".env" ]; then
|
||||
print_status "Found .env file"
|
||||
else
|
||||
print_error ".env file not found"
|
||||
print_info "Copy .env.example to .env and configure it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check critical environment variables
|
||||
if grep -q "VITE_OBP_API_HOST" .env; then
|
||||
print_status "VITE_OBP_API_HOST is configured"
|
||||
else
|
||||
print_error "VITE_OBP_API_HOST not found in .env"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 3: Install dependencies
|
||||
echo -e "${BLUE}Step 3: Installing dependencies${NC}"
|
||||
print_info "Running npm ci (clean install)..."
|
||||
npm ci --quiet
|
||||
print_status "Dependencies installed"
|
||||
echo ""
|
||||
|
||||
# Step 4: Build frontend
|
||||
echo -e "${BLUE}Step 4: Building frontend (Vite)${NC}"
|
||||
print_info "Running: npm run build-only"
|
||||
npm run build-only
|
||||
if [ -d "dist" ]; then
|
||||
DIST_SIZE=$(du -sh dist | cut -f1)
|
||||
print_status "Frontend built successfully (size: $DIST_SIZE)"
|
||||
else
|
||||
print_error "Frontend build failed - dist/ directory not created"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 5: Build backend
|
||||
echo -e "${BLUE}Step 5: Building backend (TypeScript ES Modules)${NC}"
|
||||
print_info "Running: npm run build-server"
|
||||
npm run build-server
|
||||
if [ -d "dist-server" ]; then
|
||||
print_status "Backend built successfully"
|
||||
else
|
||||
print_error "Backend build failed - dist-server/ directory not created"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if main app file exists
|
||||
if [ -f "dist-server/server/app.js" ]; then
|
||||
print_status "Server entry point found: dist-server/server/app.js"
|
||||
else
|
||||
print_error "Server entry point not found: dist-server/server/app.js"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 6: Verify ES Module format
|
||||
echo -e "${BLUE}Step 6: Verifying ES Module format${NC}"
|
||||
if head -50 dist-server/server/app.js | grep -E "^import " | head -1 > /dev/null; then
|
||||
print_status "Backend is using ES modules (import statements found)"
|
||||
else
|
||||
print_error "Backend is not using ES modules - CommonJS detected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q '"type": "module"' package.json; then
|
||||
print_status "package.json has type: module"
|
||||
else
|
||||
print_error 'package.json missing "type": "module"'
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 7: Check for Redis
|
||||
echo -e "${BLUE}Step 7: Checking dependencies${NC}"
|
||||
print_info "Checking if Redis is running..."
|
||||
if redis-cli ping > /dev/null 2>&1; then
|
||||
print_status "Redis is running"
|
||||
else
|
||||
print_error "Redis is not running"
|
||||
print_info "Start Redis with: redis-server"
|
||||
print_info "Or using Docker: docker run -d -p 6379:6379 redis"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 8: Test server startup
|
||||
echo -e "${BLUE}Step 8: Testing server startup${NC}"
|
||||
print_info "Starting server for 5 seconds to test..."
|
||||
|
||||
# Start server in background
|
||||
timeout 5 node dist-server/server/app.js > /tmp/api-explorer-test.log 2>&1 || true
|
||||
|
||||
# Check the log
|
||||
if grep -q "Backend is running" /tmp/api-explorer-test.log; then
|
||||
print_status "Server started successfully"
|
||||
|
||||
# Show key startup info
|
||||
if grep -q "OAuth2Service: Initialization successful" /tmp/api-explorer-test.log; then
|
||||
print_status "OAuth2 service initialized"
|
||||
fi
|
||||
|
||||
if grep -q "Connected to Redis" /tmp/api-explorer-test.log; then
|
||||
print_status "Redis connection established"
|
||||
fi
|
||||
else
|
||||
print_error "Server failed to start"
|
||||
print_info "Check logs at /tmp/api-explorer-test.log"
|
||||
cat /tmp/api-explorer-test.log
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 9: Show build summary
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${GREEN}Build Summary${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
echo "Frontend build:"
|
||||
echo " Location: $(pwd)/dist"
|
||||
echo " Size: $DIST_SIZE"
|
||||
echo " Files: $(find dist -type f | wc -l)"
|
||||
echo ""
|
||||
echo "Backend build:"
|
||||
echo " Location: $(pwd)/dist-server"
|
||||
echo " Entry: dist-server/server/app.js"
|
||||
echo " Module type: ES Modules"
|
||||
echo ""
|
||||
echo "To run in production:"
|
||||
echo -e " ${YELLOW}node dist-server/server/app.js${NC}"
|
||||
echo ""
|
||||
echo "Frontend files can be served by the backend or a web server:"
|
||||
echo -e " ${YELLOW}The backend serves frontend from dist/ automatically${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 10: Production readiness checklist
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${YELLOW}Production Readiness Checklist${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
echo "✓ Frontend built and optimized"
|
||||
echo "✓ Backend compiled to ES modules"
|
||||
echo "✓ Server starts without errors"
|
||||
echo "✓ Dependencies installed"
|
||||
echo ""
|
||||
echo "Before deploying to production:"
|
||||
echo " 1. Configure production .env file"
|
||||
echo " 2. Ensure Redis is running and accessible"
|
||||
echo " 3. Set NODE_ENV=production"
|
||||
echo " 4. Configure OBP API host and credentials"
|
||||
echo " 5. Set up OAuth2/OIDC client credentials"
|
||||
echo " 6. Configure process manager (PM2, systemd, etc.)"
|
||||
echo " 7. Set up reverse proxy (nginx, Apache, etc.)"
|
||||
echo ""
|
||||
|
||||
print_status "Production build test completed successfully!"
|
||||
echo ""
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/api-explorer-test.log
|
||||
160
server/app.ts
160
server/app.ts
@ -30,15 +30,34 @@ import 'dotenv/config'
|
||||
import session from 'express-session'
|
||||
import RedisStore from 'connect-redis'
|
||||
import { createClient } from 'redis'
|
||||
import express, { Application } from 'express'
|
||||
import { useExpressServer, useContainer } from 'routing-controllers'
|
||||
import express from 'express'
|
||||
import type { Application } from 'express'
|
||||
import { Container } from 'typedi'
|
||||
import path from 'path'
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync } from 'child_process'
|
||||
import { OAuth2ProviderManager } from './services/OAuth2ProviderManager.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
|
||||
// Controllers removed - all routes migrated to plain Express
|
||||
|
||||
// Import routes (plain Express, not routing-controllers)
|
||||
import oauth2Routes from './routes/oauth2.js'
|
||||
import userRoutes from './routes/user.js'
|
||||
import statusRoutes from './routes/status.js'
|
||||
import obpRoutes from './routes/obp.js'
|
||||
import opeyRoutes from './routes/opey.js'
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const port = 8085
|
||||
const app: Application = express()
|
||||
|
||||
// Commit ID variable (declared here to avoid TDZ issues)
|
||||
let commitId = ''
|
||||
|
||||
// Initialize Redis client.
|
||||
console.log(`--- Redis setup -------------------------------------------------`)
|
||||
process.env.VITE_OBP_REDIS_URL
|
||||
@ -81,22 +100,34 @@ redisClient.on('error', (err) => {
|
||||
})
|
||||
|
||||
// Initialize store.
|
||||
// Calculate session max age in seconds (for Redis TTL)
|
||||
const sessionMaxAgeSeconds = process.env.VITE_SESSION_MAX_AGE
|
||||
? parseInt(process.env.VITE_SESSION_MAX_AGE)
|
||||
: 60 * 60 // Default: 1 hour in seconds
|
||||
|
||||
// CRITICAL: Set Redis TTL to match session maxAge
|
||||
// Without this, Redis uses its own default TTL which may expire sessions prematurely
|
||||
let redisStore = new RedisStore({
|
||||
client: redisClient,
|
||||
prefix: 'api-explorer-ii:'
|
||||
prefix: 'api-explorer-ii:',
|
||||
ttl: sessionMaxAgeSeconds // TTL in seconds - MUST match cookie maxAge
|
||||
})
|
||||
|
||||
console.info(`Environment: ${app.get('env')}`)
|
||||
console.info(
|
||||
`Session maxAge configured: ${sessionMaxAgeSeconds} seconds (${sessionMaxAgeSeconds / 60} minutes)`
|
||||
)
|
||||
app.use(express.json())
|
||||
let sessionObject = {
|
||||
store: redisStore,
|
||||
secret: process.env.VITE_OPB_SERVER_SESSION_PASSWORD,
|
||||
name: 'obp-api-explorer-ii.sid', // CRITICAL: Unique cookie name to prevent conflicts with other apps on localhost
|
||||
secret: process.env.VITE_OBP_SERVER_SESSION_PASSWORD,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
saveUninitialized: false, // Don't save empty sessions (better for authenticated apps)
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
maxAge: 300 * 1000 // 5 minutes in milliseconds
|
||||
maxAge: sessionMaxAgeSeconds * 1000 // maxAge in milliseconds
|
||||
}
|
||||
}
|
||||
if (app.get('env') === 'production') {
|
||||
@ -104,48 +135,89 @@ if (app.get('env') === 'production') {
|
||||
sessionObject.cookie.secure = true // serve secure cookies
|
||||
}
|
||||
app.use(session(sessionObject))
|
||||
useContainer(Container)
|
||||
|
||||
const routePrefix = '/api'
|
||||
// OAuth2 Multi-Provider Setup only - no legacy fallback
|
||||
|
||||
const server = useExpressServer(app, {
|
||||
routePrefix: routePrefix,
|
||||
controllers: [path.join(__dirname + '/controllers/*.*s')],
|
||||
middlewares: [path.join(__dirname + '/middlewares/*.*s')]
|
||||
})
|
||||
// Async IIFE to initialize OAuth2 and start server
|
||||
let instance: any
|
||||
;(async function initializeAndStartServer() {
|
||||
// Initialize Multi-Provider OAuth2 Manager
|
||||
console.log('--- OAuth2 Multi-Provider Setup ---------------------------------')
|
||||
const providerManager = Container.get(OAuth2ProviderManager)
|
||||
|
||||
export const instance = server.listen(port)
|
||||
try {
|
||||
const success = await providerManager.initializeProviders()
|
||||
|
||||
console.log(
|
||||
`Backend is running. You can check a status at http://localhost:${port}${routePrefix}/status`
|
||||
)
|
||||
if (success) {
|
||||
const availableProviders = providerManager.getAvailableProviders()
|
||||
console.log(`OK Initialized ${availableProviders.length} OAuth2 providers:`)
|
||||
availableProviders.forEach((name) => console.log(` - ${name}`))
|
||||
|
||||
// Get commit ID
|
||||
export let commitId = '';
|
||||
|
||||
try {
|
||||
// Try to get the commit ID
|
||||
commitId = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
||||
console.log('Current Commit ID:', commitId);
|
||||
} catch (error) {
|
||||
// Log the error but do not terminate the process
|
||||
console.error('Warning: Failed to retrieve the commit ID. Proceeding without it.');
|
||||
console.error('Error details:', error.message);
|
||||
commitId = 'unknown'; // Assign a fallback value
|
||||
}
|
||||
// Continue execution with or without a valid commit ID
|
||||
console.log('Execution continues with commitId:', commitId);
|
||||
|
||||
// Error Handling to Shut Down the App
|
||||
server.on('error', (err) => {
|
||||
redisClient.disconnect();
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`Port ${port} is already in use.`);
|
||||
process.exit(1);
|
||||
// Shut down the app
|
||||
} else {
|
||||
console.error('An error occurred:', err);
|
||||
// Start health monitoring
|
||||
providerManager.startHealthCheck(60000) // Check every 60 seconds
|
||||
console.log('OK Provider health monitoring started (every 60s)')
|
||||
} else {
|
||||
console.error('ERROR: No OAuth2 providers initialized from OBP API')
|
||||
console.error(
|
||||
'ERROR: Check that OBP API is running and returns providers from /obp/v5.1.0/well-known'
|
||||
)
|
||||
console.error('ERROR: Server will start but login will not work')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ERROR Failed to initialize OAuth2 multi-provider:', error)
|
||||
console.error('ERROR: Server will start but login will not work')
|
||||
}
|
||||
});
|
||||
console.log(`-----------------------------------------------------------------`)
|
||||
|
||||
const routePrefix = '/api'
|
||||
|
||||
// Register all routes (plain Express)
|
||||
app.use(routePrefix, oauth2Routes)
|
||||
app.use(routePrefix, userRoutes)
|
||||
app.use(routePrefix, statusRoutes)
|
||||
app.use(routePrefix, obpRoutes)
|
||||
app.use(routePrefix, opeyRoutes)
|
||||
console.log('OAuth2 routes registered (plain Express)')
|
||||
console.log('User routes registered (plain Express)')
|
||||
console.log('Status routes registered (plain Express)')
|
||||
console.log('OBP routes registered (plain Express)')
|
||||
console.log('Opey routes registered (plain Express)')
|
||||
console.log('All routes migrated to plain Express - routing-controllers removed')
|
||||
|
||||
instance = app.listen(port)
|
||||
|
||||
console.log(
|
||||
`Backend is running. You can check a status at http://localhost:${port}${routePrefix}/status`
|
||||
)
|
||||
|
||||
// Get commit ID
|
||||
try {
|
||||
// Try to get the commit ID
|
||||
commitId = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim()
|
||||
console.log('Current Commit ID:', commitId)
|
||||
} catch (error: any) {
|
||||
// Log the error but do not terminate the process
|
||||
console.error('Warning: Failed to retrieve the commit ID. Proceeding without it.')
|
||||
console.error('Error details:', error.message)
|
||||
commitId = 'unknown' // Assign a fallback value
|
||||
}
|
||||
// Continue execution with or without a valid commit ID
|
||||
console.log('Execution continues with commitId:', commitId)
|
||||
|
||||
// Error Handling to Shut Down the App
|
||||
instance.on('error', (err) => {
|
||||
redisClient.disconnect()
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`Port ${port} is already in use.`)
|
||||
process.exit(1)
|
||||
// Shut down the app
|
||||
} else {
|
||||
console.error('An error occurred:', err)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
// Export instance for use in other modules
|
||||
export { instance, commitId }
|
||||
|
||||
export default app
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Controller, Req, Res, Get, UseBefore } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import { Service } from 'typedi'
|
||||
import OauthAccessTokenMiddleware from '../middlewares/OauthAccessTokenMiddleware'
|
||||
|
||||
@Service()
|
||||
@Controller()
|
||||
@UseBefore(OauthAccessTokenMiddleware)
|
||||
// This controller seems to not do anything at all
|
||||
export default class CallbackController {
|
||||
@Get('/callback')
|
||||
callback(@Req() request: Request, @Res() response: Response): Response {
|
||||
return response
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Controller, Req, Res, Get, UseBefore } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import OauthRequestTokenMiddleware from '../middlewares/OauthRequestTokenMiddleware'
|
||||
import { Service } from 'typedi'
|
||||
|
||||
@Service()
|
||||
@Controller()
|
||||
@UseBefore(OauthRequestTokenMiddleware)
|
||||
export class ConnectController {
|
||||
@Get('/connect')
|
||||
connect(@Req() request: Request, @Res() response: Response): Response {
|
||||
return response
|
||||
}
|
||||
}
|
||||
@ -1,371 +0,0 @@
|
||||
import { Controller, Session, Req, Res, Post, Get } from 'routing-controllers'
|
||||
import { Request, Response} from 'express'
|
||||
import { Readable } from "node:stream"
|
||||
import { ReadableStream as WebReadableStream } from "stream/web"
|
||||
import { Service } from 'typedi'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
import OpeyClientService from '../services/OpeyClientService'
|
||||
import OBPConsentsService from '../services/OBPConsentsService'
|
||||
|
||||
import { UserInput, OpeyConfig} from '../schema/OpeySchema'
|
||||
import { APIApi, Configuration, ConsentApi, ConsumerConsentrequestsBody, InlineResponse20151 } from 'obp-api-typescript'
|
||||
|
||||
@Service()
|
||||
@Controller('/opey')
|
||||
|
||||
export class OpeyController {
|
||||
constructor(
|
||||
public obpClientService: OBPClientService,
|
||||
public opeyClientService: OpeyClientService,
|
||||
public obpConsentsService: OBPConsentsService
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
async getStatus(
|
||||
@Res() response: Response
|
||||
): Promise<Response | any> {
|
||||
|
||||
try {
|
||||
const opeyStatus = await this.opeyClientService.getOpeyStatus()
|
||||
console.log("Opey status: ", opeyStatus)
|
||||
return response.status(200).json({status: 'Opey is running'});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in /opey endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Post('/stream')
|
||||
|
||||
async streamOpey(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
): Promise<Response> {
|
||||
|
||||
if (!session) {
|
||||
console.error("Session not found")
|
||||
return response.status(401).json({ error: 'Session Time Out' })
|
||||
}
|
||||
// Check if the consent is in the session, and can be added to the headers
|
||||
const opeyConfig = session['opeyConfig']
|
||||
if (!opeyConfig) {
|
||||
console.error("Opey config not found in session")
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
// Read user input from request body
|
||||
let user_input: UserInput
|
||||
try {
|
||||
console.log("Request body: ", request.body)
|
||||
user_input = {
|
||||
"message": request.body.message,
|
||||
"thread_id": request.body.thread_id,
|
||||
"is_tool_call_approval": request.body.is_tool_call_approval
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in stream endpoint, could not parse into UserInput: ", error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
|
||||
// Transform to decode and log the stream
|
||||
const frontendTransformer = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
// Decode the chunk to a string
|
||||
const decodedChunk = new TextDecoder().decode(chunk)
|
||||
|
||||
console.log("Sending chunk", decodedChunk)
|
||||
controller.enqueue(decodedChunk);
|
||||
},
|
||||
flush(controller) {
|
||||
console.log('[flush]');
|
||||
// Close ReadableStream when done
|
||||
controller.terminate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
let stream: ReadableStream | null = null
|
||||
|
||||
try {
|
||||
// Read web stream from OpeyClientService
|
||||
console.log("Calling OpeyClientService.stream")
|
||||
stream = await this.opeyClientService.stream(user_input, opeyConfig)
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error reading stream: ", error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
console.error("Stream is not recieved or not readable")
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
|
||||
// Transform our stream if needed, right now this is just a passthrough
|
||||
const frontendStream: ReadableStream = stream.pipeThrough(frontendTransformer)
|
||||
|
||||
// If we need to split the stream into two, we can use the tee method as below
|
||||
|
||||
// const streamTee = langchainStream.tee()
|
||||
// if (!streamTee) {
|
||||
// console.error("Stream is not tee'd")
|
||||
// return response.status(500).json({ error: 'Internal Server Error' })
|
||||
// }
|
||||
// const [stream1, stream2] = streamTee
|
||||
|
||||
// function to convert a web stream to a node stream
|
||||
const safeFromWeb = (webStream: WebReadableStream<any>): Readable => {
|
||||
if (typeof Readable.fromWeb === 'function') {
|
||||
return Readable.fromWeb(webStream)
|
||||
} else {
|
||||
console.warn('Readable.fromWeb is not available, using a polyfill');
|
||||
|
||||
// Create a Node.js Readable stream
|
||||
const nodeReadable = new Readable({
|
||||
read() {}
|
||||
});
|
||||
|
||||
// Pump data from webreadable to node readable stream
|
||||
const reader = webStream.getReader();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
|
||||
if (done) {
|
||||
nodeReadable.push(null); // end stream
|
||||
break;
|
||||
}
|
||||
|
||||
nodeReadable.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading from web stream:', error);
|
||||
nodeReadable.destroy(error instanceof Error ? error : new Error(error));
|
||||
}
|
||||
})();
|
||||
|
||||
return nodeReadable
|
||||
}
|
||||
}
|
||||
|
||||
const nodeStream = safeFromWeb(frontendStream as WebReadableStream<any>)
|
||||
|
||||
response.setHeader('Content-Type', 'text/event-stream');
|
||||
response.setHeader('Cache-Control', 'no-cache');
|
||||
response.setHeader('Connection', 'keep-alive');
|
||||
|
||||
nodeStream.pipe(response);
|
||||
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
nodeStream.on('end', () => {
|
||||
resolve(response);
|
||||
});
|
||||
nodeStream.on('error', (error) => {
|
||||
console.error('Stream error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Add a timeout to prevent hanging promises
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('Stream timeout reached');
|
||||
resolve(response);
|
||||
}, 30000);
|
||||
|
||||
// Clear the timeout when stream ends
|
||||
nodeStream.on('end', () => clearTimeout(timeout));
|
||||
nodeStream.on('error', () => clearTimeout(timeout));
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Post('/invoke')
|
||||
async invokeOpey(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Promise<Response | any> {
|
||||
|
||||
|
||||
// Check if the consent is in the session, and can be added to the headers
|
||||
const opeyConfig = session['opeyConfig']
|
||||
if (!opeyConfig) {
|
||||
console.error("Opey config not found in session")
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
let user_input: UserInput
|
||||
try {
|
||||
user_input = {
|
||||
"message": request.body.message,
|
||||
"thread_id": request.body.thread_id,
|
||||
"is_tool_call_approval": request.body.is_tool_call_approval
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in invoke endpoint, could not parse into UserInput: ", error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
try {
|
||||
const opey_response = await this.opeyClientService.invoke(user_input, opeyConfig)
|
||||
|
||||
//console.log("Opey response: ", opey_response)
|
||||
return response.status(200).json(opey_response)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// @Post('/consent/request')
|
||||
// /**
|
||||
// * Retrieves a consent request from OBP
|
||||
// *
|
||||
// */
|
||||
// async getConsentRequest(
|
||||
// @Session() session: any,
|
||||
// @Req() request: Request,
|
||||
// @Res() response: Response,
|
||||
// ): Promise<Response | any> {
|
||||
// try {
|
||||
|
||||
// let obpToken: string
|
||||
|
||||
// obpToken = await this.obpClientService.getDirectLoginToken()
|
||||
// console.log("Got token: ", obpToken)
|
||||
// const authHeader = `DirectLogin token="${obpToken}"`
|
||||
// console.log("Auth header: ", authHeader)
|
||||
|
||||
// //const obpOAuthHeaders = await this.obpClientService.getOAuthHeader('/consents', 'POST')
|
||||
// //console.log("OBP OAuth Headers: ", obpOAuthHeaders)
|
||||
|
||||
// const obpConfig: Configuration = {
|
||||
// apiKey: authHeader,
|
||||
// basePath: process.env.VITE_OBP_API_HOST,
|
||||
// }
|
||||
|
||||
// console.log("OBP Config: ", obpConfig)
|
||||
|
||||
// const consentAPI = new ConsentApi(obpConfig, process.env.VITE_OBP_API_HOST)
|
||||
|
||||
|
||||
// // OBP sdk naming is a bit mad, can be rectified in the future
|
||||
// const consentRequestResponse = await consentAPI.oBPv500CreateConsentRequest({
|
||||
// accountAccess: [],
|
||||
// everything: false,
|
||||
// entitlements: [],
|
||||
// consumerId: '',
|
||||
// } as unknown as ConsumerConsentrequestsBody,
|
||||
// {
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
|
||||
// //console.log("Consent request response: ", consentRequestResponse)
|
||||
|
||||
// console.log({consentId: consentRequestResponse.data.consent_request_id})
|
||||
// session['obpConsentRequestId'] = consentRequestResponse.data.consent_request_id
|
||||
|
||||
// return response.status(200).json(JSON.stringify({consentId: consentRequestResponse.data.consent_request_id}))
|
||||
// //console.log(await response.body.json())
|
||||
|
||||
|
||||
// } catch (error) {
|
||||
// console.error("Error in consent/request endpoint: ", error);
|
||||
// return response.status(500).json({ error: 'Internal Server Error' });
|
||||
// }
|
||||
// }
|
||||
|
||||
@Post('/consent')
|
||||
/**
|
||||
* Retrieves a consent from OBP for the current user
|
||||
*/
|
||||
async getConsent(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Promise<Response | any> {
|
||||
try {
|
||||
// create consent as logged in user
|
||||
const opeyConfig = await this.opeyClientService.getOpeyConfig()
|
||||
session['opeyConfig'] = opeyConfig
|
||||
|
||||
// Check if user already has a consent for opey
|
||||
// If so, return the consent id
|
||||
const consentId = await this.obpConsentsService.getExistingOpeyConsentId(session)
|
||||
|
||||
if (consentId) {
|
||||
console.log("Existing consent ID: ", consentId)
|
||||
// If we have a consent id, we can get the consent from OBP
|
||||
const consent = await this.obpConsentsService.getConsentByConsentId(session, consentId)
|
||||
|
||||
return response.status(200).json({consent_id: consent.consent_id, jwt: consent.jwt});
|
||||
} else {
|
||||
console.log("No existing consent ID found")
|
||||
}
|
||||
// Either here or in this method, we should check if there is already a consent stored in the session
|
||||
|
||||
await this.obpConsentsService.createConsent(session)
|
||||
|
||||
console.log("Consent at controller: ", session['opeyConfig'])
|
||||
|
||||
const authConfig = session['opeyConfig']['authConfig']
|
||||
|
||||
return response.status(200).json({consent_id: authConfig?.obpConsent.consent_id, jwt: authConfig?.obpConsent.jwt});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in consent endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error '});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// @Post('/consent/answer-challenge')
|
||||
// /**
|
||||
// * Endpoint to answer the consent challenge with code i.e. SMS or email OTP for SCA
|
||||
// * If successful, returns a Consent-JWT for use by Opey to access endpoints/ roles that the consenting user has
|
||||
// * This completes (i.e. is the final step in) the consent flow
|
||||
// */
|
||||
// async answerConsentChallenge(
|
||||
// @Session() session: any,
|
||||
// @Req() request: Request,
|
||||
// @Res() response: Response
|
||||
// ): Promise<Response | any> {
|
||||
// try {
|
||||
// const oauthConfig = session['clientConfig']
|
||||
// const version = this.obpClientService.getOBPVersion()
|
||||
|
||||
// const obpConsent = session['obpConsent']
|
||||
// if (!obpConsent) {
|
||||
// return response.status(400).json({ message: 'Consent not found in session' });
|
||||
// } else if (obpConsent.status === 'ACCEPTED') {
|
||||
// return response.status(400).json({ message: 'Consent already accepted' });
|
||||
// }
|
||||
// const answerBody = request.body
|
||||
|
||||
// const consentJWT = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/consents/${obpConsent.consent_id}/challenge`, answerBody, oauthConfig)
|
||||
// console.log("Consent JWT: ", consentJWT)
|
||||
// // store consent JWT in session, return consent JWT 200 OK
|
||||
// session['obpConsentJWT'] = consentJWT
|
||||
// return response.status(200).json(true);
|
||||
|
||||
// } catch (error) {
|
||||
// console.error("Error in consent/answer-challenge endpoint: ", error);
|
||||
// return response.status(500).json({ error: 'Internal Server Error' });
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Controller, Session, Req, Res, Get, Delete, Post, Put } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
import { Service } from 'typedi'
|
||||
|
||||
@Service()
|
||||
@Controller()
|
||||
export class OBPController {
|
||||
constructor(private obpClientService: OBPClientService) {}
|
||||
@Get('/get')
|
||||
async get(@Session() session: any, @Req() request: Request, @Res() response: Response): Response {
|
||||
const path = request.query.path
|
||||
const oauthConfig = session['clientConfig']
|
||||
return response.json(await this.obpClientService.get(path, oauthConfig))
|
||||
}
|
||||
|
||||
@Post('/create')
|
||||
async create(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
const path = request.query.path
|
||||
const data = request.body
|
||||
const oauthConfig = session['clientConfig']
|
||||
return response.json(await this.obpClientService.create(path, data, oauthConfig))
|
||||
}
|
||||
|
||||
@Put('/update')
|
||||
async update(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
const path = request.query.path
|
||||
const data = request.body
|
||||
const oauthConfig = session['clientConfig']
|
||||
return response.json(await this.obpClientService.update(path, data, oauthConfig))
|
||||
}
|
||||
|
||||
@Delete('/delete')
|
||||
async delete(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
const path = request.query.path
|
||||
const oauthConfig = session['clientConfig']
|
||||
return response.json(await this.obpClientService.discard(path, oauthConfig))
|
||||
}
|
||||
}
|
||||
@ -1,126 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Controller, Session, Req, Res, Get } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
import OauthInjectedService from '../services/OauthInjectedService'
|
||||
import { Service } from 'typedi'
|
||||
import { OAuthConfig } from 'obp-typescript'
|
||||
import { commitId } from '../app'
|
||||
|
||||
@Service()
|
||||
@Controller('/status')
|
||||
export class StatusController {
|
||||
private obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
|
||||
private connectors = [
|
||||
'akka_vDec2018',
|
||||
'rest_vMar2019',
|
||||
'stored_procedure_vDec2019',
|
||||
'rabbitmq_vOct2024'
|
||||
]
|
||||
constructor(
|
||||
private obpClientService: OBPClientService,
|
||||
private oauthInjectedService: OauthInjectedService
|
||||
) {}
|
||||
@Get('/')
|
||||
async index(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
const oauthConfig = session['clientConfig']
|
||||
const version = this.obpClientService.getOBPVersion()
|
||||
const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
|
||||
const apiVersions = await this.checkApiVersions(oauthConfig, version)
|
||||
const messageDocs = await this.checkMessagDocs(oauthConfig, version)
|
||||
const resourceDocs = await this.checkResourceDocs(oauthConfig, version)
|
||||
return response.json({
|
||||
status: apiVersions && messageDocs && resourceDocs,
|
||||
apiVersions,
|
||||
messageDocs,
|
||||
resourceDocs,
|
||||
currentUser,
|
||||
commitId
|
||||
})
|
||||
}
|
||||
|
||||
isCodeError(response: any, path: string): boolean {
|
||||
console.log(`Validating ${path} response...`)
|
||||
if (!response || Object.keys(response).length == 0) return true
|
||||
if (Object.keys(response).includes('code')) {
|
||||
const code = response['code']
|
||||
if (code >= 400) {
|
||||
console.log(response) // Log error responce
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async checkResourceDocs(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
|
||||
try {
|
||||
const path = `/obp/${version}/resource-docs/${version}/obp`
|
||||
const resourceDocs = await this.obpClientService.get(
|
||||
path,
|
||||
oauthConfig
|
||||
)
|
||||
return !this.isCodeError(resourceDocs, path)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async checkMessagDocs(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
|
||||
try {
|
||||
const messageDocsCodeResult = await Promise.all(
|
||||
this.connectors.map(async (connector) => {
|
||||
const path = `/obp/${version}/message-docs/${connector}`
|
||||
return !this.isCodeError(
|
||||
await this.obpClientService.get(
|
||||
path,
|
||||
oauthConfig
|
||||
),
|
||||
path
|
||||
)
|
||||
})
|
||||
)
|
||||
return messageDocsCodeResult.every((isCodeError: boolean) => isCodeError)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async checkApiVersions(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
|
||||
try {
|
||||
const path = `/obp/${version}/api/versions`
|
||||
const versions = await this.obpClientService.get(path, oauthConfig)
|
||||
return !this.isCodeError(versions, path)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Controller, Session, Req, Res, Get } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
import OauthInjectedService from '../services/OauthInjectedService'
|
||||
import { Service } from 'typedi'
|
||||
import superagent from 'superagent'
|
||||
|
||||
@Service()
|
||||
@Controller('/user')
|
||||
export class UserController {
|
||||
private obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
|
||||
constructor(
|
||||
private obpClientService: OBPClientService,
|
||||
private oauthInjectedService: OauthInjectedService
|
||||
) {}
|
||||
@Get('/logoff')
|
||||
async logout(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
this.oauthInjectedService.requestTokenKey = undefined
|
||||
this.oauthInjectedService.requestTokenSecret = undefined
|
||||
session['clientConfig'] = undefined
|
||||
|
||||
if (request.query.redirect) {
|
||||
response.redirect(request.query.redirect as string)
|
||||
} else {
|
||||
if(!this.obpExplorerHome) {
|
||||
console.error(`VITE_OBP_API_EXPLORER_HOST: ${this.obpExplorerHome}`)
|
||||
}
|
||||
response.redirect(this.obpExplorerHome)
|
||||
}
|
||||
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
@Get('/current')
|
||||
async current(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
const oauthConfig = session['clientConfig']
|
||||
const version = this.obpClientService.getOBPVersion()
|
||||
return response.json(
|
||||
await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { ExpressMiddlewareInterface } from 'routing-controllers'
|
||||
import { Response, Request } from 'express'
|
||||
import { Service } from 'typedi'
|
||||
import OauthInjectedService from '../services/OauthInjectedService'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
|
||||
@Service()
|
||||
export default class OauthAccessTokenMiddleware implements ExpressMiddlewareInterface {
|
||||
constructor(
|
||||
private obpClientService: OBPClientService,
|
||||
private oauthInjectedService: OauthInjectedService
|
||||
) {}
|
||||
|
||||
use(request: Request, response: Response): any {
|
||||
const oauthService = this.oauthInjectedService
|
||||
const consumer = oauthService.getConsumer()
|
||||
const oauthVerifier = request.query.oauth_verifier
|
||||
const session = request.session
|
||||
console.log('OauthAccessTokenMiddleware.ts use says: Before consumer.getOAuthAccessToken')
|
||||
consumer.getOAuthAccessToken(
|
||||
oauthService.requestTokenKey,
|
||||
oauthService.requestTokenSecret,
|
||||
oauthVerifier,
|
||||
(error: any, oauthTokenKey: string, oauthTokenSecret: string) => {
|
||||
if (error) {
|
||||
const errorStr = JSON.stringify(error)
|
||||
console.error(errorStr)
|
||||
response.status(500).send('Error getting OAuth access token: ' + errorStr)
|
||||
} else {
|
||||
const clientConfig = JSON.parse(
|
||||
JSON.stringify(this.obpClientService.getOBPClientConfig())
|
||||
) //Deep copy
|
||||
clientConfig['oauthConfig']['accessToken'] = {
|
||||
key: oauthTokenKey,
|
||||
secret: oauthTokenSecret
|
||||
}
|
||||
console.log(`OauthAccessTokenMiddleware.ts use says: clientConfig: ${JSON.stringify(clientConfig)}`)
|
||||
session['clientConfig'] = clientConfig
|
||||
console.log('OauthAccessTokenMiddleware.ts use says: Seems OK, redirecting..')
|
||||
|
||||
let redirectPage: String
|
||||
|
||||
const obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
|
||||
if(!obpExplorerHome) {
|
||||
console.error(`VITE_OBP_API_EXPLORER_HOST: ${obpExplorerHome}`)
|
||||
}
|
||||
|
||||
if (session['redirectPage']) {
|
||||
try {
|
||||
redirectPage = session['redirectPage']
|
||||
|
||||
} catch (e) {
|
||||
console.log('OauthAccessTokenMiddleware.ts use says: Error decoding redirect URI')
|
||||
redirectPage = obpExplorerHome
|
||||
}
|
||||
} else {
|
||||
redirectPage = obpExplorerHome
|
||||
}
|
||||
|
||||
console.log(`OauthAccessTokenMiddleware.ts use says: Will redirect to: ${redirectPage}`)
|
||||
console.log('OauthAccessTokenMiddleware.ts use says: Here comes the session:')
|
||||
console.log(session)
|
||||
|
||||
response.redirect(redirectPage)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { ExpressMiddlewareInterface } from 'routing-controllers'
|
||||
import { Response, Request } from 'express'
|
||||
import { Service } from 'typedi'
|
||||
import OauthInjectedService from '../services/OauthInjectedService'
|
||||
|
||||
@Service()
|
||||
export default class OauthRequestTokenMiddleware implements ExpressMiddlewareInterface {
|
||||
constructor(private oauthInjectedService: OauthInjectedService) {}
|
||||
|
||||
use(request: Request, response: Response): any {
|
||||
const apiHost = process.env.VITE_OBP_API_PORTAL_HOST ? process.env.VITE_OBP_API_PORTAL_HOST : process.env.VITE_OBP_API_HOST
|
||||
console.debug('process.env.VITE_OBP_API_PORTAL_HOST:', process.env.VITE_OBP_API_PORTAL_HOST)
|
||||
const oauthService = this.oauthInjectedService
|
||||
const consumer = oauthService.getConsumer()
|
||||
const redirectPage = request.query.redirect
|
||||
const session = request.session
|
||||
|
||||
if (redirectPage) {
|
||||
session['redirectPage'] = redirectPage
|
||||
}
|
||||
|
||||
consumer.getOAuthRequestToken((error: any, oauthTokenKey: string, oauthTokenSecret: string) => {
|
||||
if (error) {
|
||||
const errorStr = JSON.stringify(error)
|
||||
console.error(errorStr)
|
||||
response.status(500).send('Error getting OAuth request token: ' + errorStr)
|
||||
} else {
|
||||
oauthService.requestTokenKey = oauthTokenKey
|
||||
oauthService.requestTokenSecret = oauthTokenSecret
|
||||
console.log('OauthRequestTokenMiddleware.ts consumer.getOAuthRequestToken says: Redirecting to /oauth/authorize?oauth_token=XXX')
|
||||
response.redirect(apiHost + '/oauth/authorize?oauth_token=' + oauthTokenKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
285
server/routes/oauth2.ts
Normal file
285
server/routes/oauth2.ts
Normal file
@ -0,0 +1,285 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2025, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import { Container } from 'typedi'
|
||||
import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js'
|
||||
import { PKCEUtils } from '../utils/pkce.js'
|
||||
import type { UserInfo } from '../types/oauth2.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get services from container
|
||||
const providerManager = Container.get(OAuth2ProviderManager)
|
||||
|
||||
/**
|
||||
* GET /oauth2/providers
|
||||
* Get list of available OAuth2 providers
|
||||
*/
|
||||
router.get('/oauth2/providers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const availableProviders = providerManager.getAvailableProviders()
|
||||
const providerList = availableProviders.map((name) => {
|
||||
const providerStatus = providerManager.getProviderStatus(name)
|
||||
return {
|
||||
name,
|
||||
status: providerStatus?.available ? 'healthy' : 'unhealthy',
|
||||
available: providerStatus?.available || false
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ providers: providerList })
|
||||
} catch (error) {
|
||||
console.error('Error fetching providers:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch providers' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /oauth2/connect
|
||||
* Initiate OAuth2 authentication flow
|
||||
* Query params:
|
||||
* - provider: Provider name (required)
|
||||
* - redirect: URL to redirect after auth (optional)
|
||||
*/
|
||||
router.get('/oauth2/connect', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = req.query.provider as string | undefined
|
||||
const redirect = (req.query.redirect as string) || '/'
|
||||
const session = req.session as any
|
||||
|
||||
console.log('OAuth2 Connect: Starting authentication flow')
|
||||
console.log(` Provider: ${provider || 'NOT SPECIFIED'}`)
|
||||
console.log(` Redirect: ${redirect}`)
|
||||
|
||||
// Provider is required
|
||||
if (!provider) {
|
||||
console.error('OAuth2 Connect: No provider specified')
|
||||
return res.status(400).json({
|
||||
error: 'missing_provider',
|
||||
message: 'Provider parameter is required'
|
||||
})
|
||||
}
|
||||
|
||||
// Store redirect URL in session
|
||||
session.oauth2_redirect_page = redirect
|
||||
|
||||
// Generate PKCE parameters
|
||||
const codeVerifier = PKCEUtils.generateCodeVerifier()
|
||||
const codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier)
|
||||
const state = PKCEUtils.generateState()
|
||||
|
||||
// Store in session
|
||||
session.oauth2_code_verifier = codeVerifier
|
||||
session.oauth2_state = state
|
||||
|
||||
console.log(`OAuth2 Connect: Using provider - ${provider}`)
|
||||
|
||||
const client = providerManager.getProvider(provider)
|
||||
if (!client) {
|
||||
const availableProviders = providerManager.getAvailableProviders()
|
||||
console.error(`OAuth2 Connect: Provider not found: ${provider}`)
|
||||
return res.status(400).json({
|
||||
error: 'invalid_provider',
|
||||
message: `Provider "${provider}" is not available`,
|
||||
availableProviders
|
||||
})
|
||||
}
|
||||
|
||||
// Store provider name for callback
|
||||
session.oauth2_provider = provider
|
||||
|
||||
// Build authorization URL
|
||||
const authEndpoint = client.getAuthorizationEndpoint()
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.clientId,
|
||||
redirect_uri: client.getRedirectUri(),
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
})
|
||||
|
||||
const authUrl = `${authEndpoint}?${params.toString()}`
|
||||
|
||||
// Save session before redirect
|
||||
session.save((err: any) => {
|
||||
if (err) {
|
||||
console.error('OAuth2 Connect: Failed to save session:', err)
|
||||
return res.status(500).json({ error: 'session_error' })
|
||||
}
|
||||
|
||||
console.log('OAuth2 Connect: Redirecting to authorization endpoint')
|
||||
res.redirect(authUrl)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('OAuth2 Connect: Error:', error)
|
||||
res.status(500).json({
|
||||
error: 'authentication_failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /oauth2/callback
|
||||
* Handle OAuth2 callback after user authentication
|
||||
* Query params:
|
||||
* - code: Authorization code
|
||||
* - state: State parameter for CSRF validation
|
||||
* - error: Error code (if auth failed)
|
||||
* - error_description: Error description
|
||||
*/
|
||||
router.get('/oauth2/callback', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const code = req.query.code as string
|
||||
const state = req.query.state as string
|
||||
const error = req.query.error as string
|
||||
const errorDescription = req.query.error_description as string
|
||||
const session = req.session as any
|
||||
|
||||
console.log('OAuth2 Callback: Processing callback')
|
||||
|
||||
// Handle error from provider
|
||||
if (error) {
|
||||
console.error(`OAuth2 Callback: Error from provider: ${error}`)
|
||||
console.error(`OAuth2 Callback: Description: ${errorDescription || 'N/A'}`)
|
||||
return res.redirect(`/?oauth2_error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code) {
|
||||
console.error('OAuth2 Callback: Missing authorization code')
|
||||
return res.redirect('/?oauth2_error=missing_code')
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
console.error('OAuth2 Callback: Missing state parameter')
|
||||
return res.redirect('/?oauth2_error=missing_state')
|
||||
}
|
||||
|
||||
// Validate state (CSRF protection)
|
||||
const storedState = session.oauth2_state
|
||||
if (!storedState || storedState !== state) {
|
||||
console.error('OAuth2 Callback: State mismatch (CSRF protection)')
|
||||
return res.redirect('/?oauth2_error=invalid_state')
|
||||
}
|
||||
|
||||
// Get code verifier from session (PKCE)
|
||||
const codeVerifier = session.oauth2_code_verifier
|
||||
if (!codeVerifier) {
|
||||
console.error('OAuth2 Callback: Code verifier not found in session')
|
||||
return res.redirect('/?oauth2_error=missing_verifier')
|
||||
}
|
||||
|
||||
// Get provider from session
|
||||
const provider = session.oauth2_provider
|
||||
|
||||
if (!provider) {
|
||||
console.error('OAuth2 Callback: Provider not found in session')
|
||||
return res.redirect('/?oauth2_error=missing_provider')
|
||||
}
|
||||
|
||||
console.log(`OAuth2 Callback: Processing callback for ${provider}`)
|
||||
|
||||
const client = providerManager.getProvider(provider)
|
||||
if (!client) {
|
||||
console.error(`OAuth2 Callback: Provider not found: ${provider}`)
|
||||
return res.redirect('/?oauth2_error=provider_not_found')
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
console.log('OAuth2 Callback: Exchanging authorization code for tokens')
|
||||
const tokens = await client.exchangeAuthorizationCode(code, codeVerifier)
|
||||
|
||||
// Fetch user info
|
||||
console.log('OAuth2 Callback: Fetching user info')
|
||||
const userInfoEndpoint = client.getUserInfoEndpoint()
|
||||
const userInfoResponse = await fetch(userInfoEndpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new Error(`UserInfo request failed: ${userInfoResponse.status}`)
|
||||
}
|
||||
|
||||
const userInfo = (await userInfoResponse.json()) as UserInfo
|
||||
|
||||
// Store tokens in session
|
||||
session.oauth2_access_token = tokens.accessToken
|
||||
session.oauth2_refresh_token = tokens.refreshToken
|
||||
session.oauth2_id_token = tokens.idToken
|
||||
|
||||
console.log('OAuth2 Callback: Tokens received and stored')
|
||||
|
||||
// Store user in session (using oauth2_user key to match UserController)
|
||||
session.oauth2_user = {
|
||||
username: userInfo.preferred_username || userInfo.email || userInfo.sub,
|
||||
email: userInfo.email,
|
||||
email_verified: userInfo.email_verified || false,
|
||||
name: userInfo.name,
|
||||
given_name: userInfo.given_name,
|
||||
family_name: userInfo.family_name,
|
||||
provider: provider || 'obp-oidc',
|
||||
sub: userInfo.sub
|
||||
}
|
||||
|
||||
// Also store clientConfig for OBP API calls
|
||||
session.clientConfig = {
|
||||
oauth2: {
|
||||
accessToken: tokens.accessToken,
|
||||
tokenType: 'Bearer'
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`OAuth2 Callback: User authenticated: ${session.oauth2_user.username} via ${session.oauth2_user.provider}`
|
||||
)
|
||||
|
||||
// Clean up temporary session data
|
||||
delete session.oauth2_code_verifier
|
||||
delete session.oauth2_state
|
||||
|
||||
// Redirect to original page
|
||||
const redirectUrl = session.oauth2_redirect_page || '/'
|
||||
delete session.oauth2_redirect_page
|
||||
|
||||
console.log(`OAuth2 Callback: Authentication successful, redirecting to: ${redirectUrl}`)
|
||||
res.redirect(redirectUrl)
|
||||
} catch (error) {
|
||||
console.error('OAuth2 Callback: Error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
res.redirect(`/?oauth2_error=token_exchange_failed&details=${encodeURIComponent(errorMessage)}`)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
160
server/routes/obp.ts
Normal file
160
server/routes/obp.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2025, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import { Container } from 'typedi'
|
||||
import OBPClientService from '../services/OBPClientService.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get services from container
|
||||
const obpClientService = Container.get(OBPClientService)
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* TODO: Implement token refresh in multi-provider system
|
||||
*/
|
||||
function isAuthenticated(session: any): boolean {
|
||||
return !!session.oauth2_access_token && !!session.oauth2_user
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /get
|
||||
* Proxy GET requests to OBP API
|
||||
* Query params:
|
||||
* - path: OBP API path to call (e.g., /obp/v5.1.0/banks)
|
||||
*/
|
||||
router.get('/get', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const path = req.query.path as string
|
||||
const session = req.session as any
|
||||
|
||||
const oauthConfig = session.clientConfig
|
||||
|
||||
const result = await obpClientService.get(path, oauthConfig)
|
||||
res.json(result)
|
||||
} catch (error: any) {
|
||||
// 401 errors are expected when user is not authenticated - log as info, not error
|
||||
if (error.status === 401) {
|
||||
console.log(`OBP: 401 Unauthorized for path: ${req.query.path} (user not authenticated)`)
|
||||
} else {
|
||||
console.error('OBP: GET request error:', error)
|
||||
}
|
||||
res.status(error.status || 500).json({
|
||||
code: error.status || 500,
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /create
|
||||
* Proxy POST requests to OBP API
|
||||
* Query params:
|
||||
* - path: OBP API path to call
|
||||
* Body: JSON data to send to OBP API
|
||||
*/
|
||||
router.post('/create', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const path = req.query.path as string
|
||||
const data = req.body
|
||||
const session = req.session as any
|
||||
|
||||
const oauthConfig = session.clientConfig
|
||||
|
||||
// Debug logging to diagnose authentication issues
|
||||
console.log('OBP.create - Debug Info:')
|
||||
console.log(' Path:', path)
|
||||
console.log(' Session exists:', !!session)
|
||||
console.log(' clientConfig exists:', !!oauthConfig)
|
||||
console.log(' oauth2 exists:', oauthConfig?.oauth2 ? 'YES' : 'NO')
|
||||
console.log(' accessToken exists:', oauthConfig?.oauth2?.accessToken ? 'YES' : 'NO')
|
||||
console.log(' oauth2_user exists:', session?.oauth2_user ? 'YES' : 'NO')
|
||||
|
||||
const result = await obpClientService.create(path, data, oauthConfig)
|
||||
res.json(result)
|
||||
} catch (error: any) {
|
||||
console.error('OBP.create error:', error)
|
||||
res.status(error.status || 500).json({
|
||||
code: error.status || 500,
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /update
|
||||
* Proxy PUT requests to OBP API
|
||||
* Query params:
|
||||
* - path: OBP API path to call
|
||||
* Body: JSON data to send to OBP API
|
||||
*/
|
||||
router.put('/update', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const path = req.query.path as string
|
||||
const data = req.body
|
||||
const session = req.session as any
|
||||
|
||||
const oauthConfig = session.clientConfig
|
||||
|
||||
const result = await obpClientService.update(path, data, oauthConfig)
|
||||
res.json(result)
|
||||
} catch (error: any) {
|
||||
console.error('OBP.update error:', error)
|
||||
res.status(error.status || 500).json({
|
||||
code: error.status || 500,
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /delete
|
||||
* Proxy DELETE requests to OBP API
|
||||
* Query params:
|
||||
* - path: OBP API path to call
|
||||
*/
|
||||
router.delete('/delete', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const path = req.query.path as string
|
||||
const session = req.session as any
|
||||
|
||||
const oauthConfig = session.clientConfig
|
||||
|
||||
const result = await obpClientService.discard(path, oauthConfig)
|
||||
res.json(result)
|
||||
} catch (error: any) {
|
||||
console.error('OBP.delete error:', error)
|
||||
res.status(error.status || 500).json({
|
||||
code: error.status || 500,
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
280
server/routes/opey.ts
Normal file
280
server/routes/opey.ts
Normal file
@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2025, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import { Readable } from 'node:stream'
|
||||
import { ReadableStream as WebReadableStream } from 'stream/web'
|
||||
import { Container } from 'typedi'
|
||||
import OBPClientService from '../services/OBPClientService.js'
|
||||
import OpeyClientService from '../services/OpeyClientService.js'
|
||||
import OBPConsentsService from '../services/OBPConsentsService.js'
|
||||
import { UserInput } from '../schema/OpeySchema.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get services from container
|
||||
const obpClientService = Container.get(OBPClientService)
|
||||
const opeyClientService = Container.get(OpeyClientService)
|
||||
const obpConsentsService = Container.get(OBPConsentsService)
|
||||
|
||||
/**
|
||||
* Helper function to convert web stream to Node.js stream
|
||||
*/
|
||||
function safeFromWeb(webStream: WebReadableStream<any>): Readable {
|
||||
if (typeof Readable.fromWeb === 'function') {
|
||||
return Readable.fromWeb(webStream)
|
||||
} else {
|
||||
console.warn('Readable.fromWeb is not available, using a polyfill')
|
||||
|
||||
// Create a Node.js Readable stream
|
||||
const nodeReadable = new Readable({
|
||||
read() {}
|
||||
})
|
||||
|
||||
// Pump data from webreadable to node readable stream
|
||||
const reader = webStream.getReader()
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
nodeReadable.push(null) // end stream
|
||||
break
|
||||
}
|
||||
|
||||
nodeReadable.push(value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading from web stream:', error)
|
||||
nodeReadable.destroy(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})()
|
||||
|
||||
return nodeReadable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /opey
|
||||
* Check Opey chatbot status
|
||||
*/
|
||||
router.get('/opey', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const opeyStatus = await opeyClientService.getOpeyStatus()
|
||||
console.log('Opey status: ', opeyStatus)
|
||||
res.status(200).json({ status: 'Opey is running' })
|
||||
} catch (error) {
|
||||
console.error('Error in /opey endpoint: ', error)
|
||||
res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /opey/stream
|
||||
* Stream chatbot responses
|
||||
* Body: { message, thread_id, is_tool_call_approval }
|
||||
*/
|
||||
router.post('/opey/stream', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const session = req.session as any
|
||||
|
||||
if (!session) {
|
||||
console.error('Session not found')
|
||||
return res.status(401).json({ error: 'Session Time Out' })
|
||||
}
|
||||
|
||||
// Check if the consent is in the session
|
||||
const opeyConfig = session.opeyConfig
|
||||
if (!opeyConfig) {
|
||||
console.error('Opey config not found in session')
|
||||
return res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
// Read user input from request body
|
||||
let user_input: UserInput
|
||||
try {
|
||||
console.log('Request body: ', req.body)
|
||||
user_input = {
|
||||
message: req.body.message,
|
||||
thread_id: req.body.thread_id,
|
||||
is_tool_call_approval: req.body.is_tool_call_approval
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in stream endpoint, could not parse into UserInput: ', error)
|
||||
return res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
// Transform to decode and log the stream
|
||||
const frontendTransformer = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
// Decode the chunk to a string
|
||||
const decodedChunk = new TextDecoder().decode(chunk)
|
||||
|
||||
console.log('Sending chunk', decodedChunk)
|
||||
controller.enqueue(decodedChunk)
|
||||
},
|
||||
flush(controller) {
|
||||
console.log('[flush]')
|
||||
// Close ReadableStream when done
|
||||
controller.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
let stream: ReadableStream | null = null
|
||||
|
||||
try {
|
||||
// Read web stream from OpeyClientService
|
||||
console.log('Calling OpeyClientService.stream')
|
||||
stream = await opeyClientService.stream(user_input, opeyConfig)
|
||||
} catch (error) {
|
||||
console.error('Error reading stream: ', error)
|
||||
return res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
console.error('Stream is not received or not readable')
|
||||
return res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
// Transform our stream
|
||||
const frontendStream: ReadableStream = stream.pipeThrough(frontendTransformer)
|
||||
|
||||
const nodeStream = safeFromWeb(frontendStream as WebReadableStream<any>)
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
|
||||
nodeStream.pipe(res)
|
||||
|
||||
// Handle stream completion
|
||||
nodeStream.on('end', () => {
|
||||
console.log('Stream ended successfully')
|
||||
})
|
||||
|
||||
nodeStream.on('error', (error) => {
|
||||
console.error('Stream error:', error)
|
||||
})
|
||||
|
||||
// Add a timeout to prevent hanging
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('Stream timeout reached')
|
||||
nodeStream.destroy()
|
||||
}, 30000)
|
||||
|
||||
// Clear the timeout when stream ends
|
||||
nodeStream.on('end', () => clearTimeout(timeout))
|
||||
nodeStream.on('error', () => clearTimeout(timeout))
|
||||
} catch (error) {
|
||||
console.error('Error in /opey/stream:', error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /opey/invoke
|
||||
* Invoke chatbot without streaming
|
||||
* Body: { message, thread_id, is_tool_call_approval }
|
||||
*/
|
||||
router.post('/opey/invoke', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const session = req.session as any
|
||||
|
||||
// Check if the consent is in the session
|
||||
const opeyConfig = session.opeyConfig
|
||||
if (!opeyConfig) {
|
||||
console.error('Opey config not found in session')
|
||||
return res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
let user_input: UserInput
|
||||
try {
|
||||
user_input = {
|
||||
message: req.body.message,
|
||||
thread_id: req.body.thread_id,
|
||||
is_tool_call_approval: req.body.is_tool_call_approval
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in invoke endpoint, could not parse into UserInput: ', error)
|
||||
return res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
const opey_response = await opeyClientService.invoke(user_input, opeyConfig)
|
||||
res.status(200).json(opey_response)
|
||||
} catch (error) {
|
||||
console.error('Error in /opey/invoke:', error)
|
||||
res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /opey/consent
|
||||
* Retrieve or create a consent for Opey to access OBP on user's behalf
|
||||
*/
|
||||
router.post('/opey/consent', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const session = req.session as any
|
||||
|
||||
// Create consent as logged in user
|
||||
const opeyConfig = await opeyClientService.getOpeyConfig()
|
||||
session.opeyConfig = opeyConfig
|
||||
|
||||
// Check if user already has a consent for opey
|
||||
const consentId = await obpConsentsService.getExistingOpeyConsentId(session)
|
||||
|
||||
if (consentId) {
|
||||
console.log('Existing consent ID: ', consentId)
|
||||
// If we have a consent id, we can get the consent from OBP
|
||||
const consent = await obpConsentsService.getConsentByConsentId(session, consentId)
|
||||
|
||||
return res.status(200).json({ consent_id: consent.consent_id, jwt: consent.jwt })
|
||||
} else {
|
||||
console.log('No existing consent ID found')
|
||||
}
|
||||
|
||||
await obpConsentsService.createConsent(session)
|
||||
|
||||
console.log('Consent at controller: ', session.opeyConfig)
|
||||
|
||||
const authConfig = session.opeyConfig?.authConfig
|
||||
|
||||
res.status(200).json({
|
||||
consent_id: authConfig?.obpConsent.consent_id,
|
||||
jwt: authConfig?.obpConsent.jwt
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in /opey/consent endpoint: ', error)
|
||||
res.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
516
server/routes/status.ts
Normal file
516
server/routes/status.ts
Normal file
@ -0,0 +1,516 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2025, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import { Container } from 'typedi'
|
||||
import OBPClientService from '../services/OBPClientService.js'
|
||||
import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js'
|
||||
import { commitId } from '../app.js'
|
||||
import {
|
||||
RESOURCE_DOCS_API_VERSION,
|
||||
MESSAGE_DOCS_API_VERSION,
|
||||
API_VERSIONS_LIST_API_VERSION
|
||||
} from '../../src/shared-constants.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get services from container
|
||||
const obpClientService = Container.get(OBPClientService)
|
||||
const providerManager = Container.get(OAuth2ProviderManager)
|
||||
|
||||
const connectors = [
|
||||
'akka_vDec2018',
|
||||
'rest_vMar2019',
|
||||
'stored_procedure_vDec2019',
|
||||
'rabbitmq_vOct2024'
|
||||
]
|
||||
|
||||
/**
|
||||
* Helper function to check if response contains an error
|
||||
*/
|
||||
function isCodeError(response: any, path: string): boolean {
|
||||
console.log(`Validating ${path} response...`)
|
||||
if (!response || Object.keys(response).length === 0) return true
|
||||
if (Object.keys(response).includes('code')) {
|
||||
const code = response['code']
|
||||
if (code >= 400) {
|
||||
console.log(response) // Log error response
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resource docs are accessible
|
||||
*/
|
||||
async function checkResourceDocs(oauthConfig: any, version: string): Promise<boolean> {
|
||||
try {
|
||||
const path = `/obp/${RESOURCE_DOCS_API_VERSION}/resource-docs/${version}/obp`
|
||||
const resourceDocs = await obpClientService.get(path, oauthConfig)
|
||||
return !isCodeError(resourceDocs, path)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message docs are accessible
|
||||
*/
|
||||
async function checkMessageDocs(oauthConfig: any, version: string): Promise<boolean> {
|
||||
try {
|
||||
const messageDocsCodeResult = await Promise.all(
|
||||
connectors.map(async (connector) => {
|
||||
const path = `/obp/${MESSAGE_DOCS_API_VERSION}/message-docs/${connector}`
|
||||
return !isCodeError(await obpClientService.get(path, oauthConfig), path)
|
||||
})
|
||||
)
|
||||
return messageDocsCodeResult.every((isCodeError: boolean) => isCodeError)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API versions are accessible
|
||||
*/
|
||||
async function checkApiVersions(oauthConfig: any, version: string): Promise<boolean> {
|
||||
try {
|
||||
const path = `/obp/${API_VERSIONS_LIST_API_VERSION}/api/versions`
|
||||
const versions = await obpClientService.get(path, oauthConfig)
|
||||
return !isCodeError(versions, path)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Get application status and health checks
|
||||
*/
|
||||
router.get('/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const session = req.session as any
|
||||
const oauthConfig = session.clientConfig
|
||||
const version = obpClientService.getOBPVersion()
|
||||
|
||||
// Check if user is authenticated
|
||||
const isAuthenticated = oauthConfig && oauthConfig.oauth2?.accessToken
|
||||
|
||||
let currentUser = null
|
||||
let apiVersions = false
|
||||
let messageDocs = false
|
||||
let resourceDocs = false
|
||||
|
||||
if (isAuthenticated) {
|
||||
try {
|
||||
currentUser = await obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
|
||||
apiVersions = await checkApiVersions(oauthConfig, version)
|
||||
messageDocs = await checkMessageDocs(oauthConfig, version)
|
||||
resourceDocs = await checkResourceDocs(oauthConfig, version)
|
||||
} catch (error) {
|
||||
console.error('Status: Error fetching authenticated data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: apiVersions && messageDocs && resourceDocs,
|
||||
apiVersions,
|
||||
messageDocs,
|
||||
resourceDocs,
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
commitId
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Status: Error getting status:', error)
|
||||
res.status(500).json({
|
||||
status: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /status/providers
|
||||
* Get configured OAuth2 providers (for debugging)
|
||||
* Shows provider configuration with masked credentials
|
||||
*/
|
||||
router.get('/status/providers', (req: Request, res: Response) => {
|
||||
try {
|
||||
// Helper function to mask sensitive data (show first 2 and last 2 chars)
|
||||
const maskCredential = (value: string | undefined): string => {
|
||||
if (!value || value.length < 6) {
|
||||
return value ? '***masked***' : 'not configured'
|
||||
}
|
||||
return `${value.substring(0, 2)}...${value.substring(value.length - 2)}`
|
||||
}
|
||||
|
||||
// Get providers from manager
|
||||
const availableProviders = providerManager.getAvailableProviders()
|
||||
const allProviderStatus = providerManager.getAllProviderStatus()
|
||||
|
||||
// Shared redirect URL
|
||||
const sharedRedirectUrl = process.env.VITE_OAUTH2_REDIRECT_URL || 'not configured'
|
||||
|
||||
// Get env configuration (masked)
|
||||
const envConfig = {
|
||||
obpOidc: {
|
||||
consumerId: process.env.VITE_OBP_CONSUMER_KEY || 'not configured',
|
||||
clientId: maskCredential(process.env.VITE_OBP_OIDC_CLIENT_ID)
|
||||
},
|
||||
keycloak: {
|
||||
clientId: maskCredential(process.env.VITE_KEYCLOAK_CLIENT_ID)
|
||||
},
|
||||
google: {
|
||||
clientId: maskCredential(process.env.VITE_GOOGLE_CLIENT_ID)
|
||||
},
|
||||
github: {
|
||||
clientId: maskCredential(process.env.VITE_GITHUB_CLIENT_ID)
|
||||
},
|
||||
custom: {
|
||||
providerName: process.env.VITE_CUSTOM_OIDC_PROVIDER_NAME || 'not configured',
|
||||
clientId: maskCredential(process.env.VITE_CUSTOM_OIDC_CLIENT_ID)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
summary: {
|
||||
totalConfigured: availableProviders.length,
|
||||
availableProviders: availableProviders,
|
||||
obpApiHost: process.env.VITE_OBP_API_HOST || 'not configured',
|
||||
sharedRedirectUrl: sharedRedirectUrl
|
||||
},
|
||||
providerStatus: allProviderStatus,
|
||||
environmentConfig: envConfig,
|
||||
note: 'Credentials are masked for security. Format: first2...last2'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Status: Error getting provider status:', error)
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /status/providers/:providerName/retry
|
||||
* Manually retry initialization for a failed provider
|
||||
*/
|
||||
router.post('/status/providers/:providerName/retry', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { providerName } = req.params
|
||||
console.log(`Status: Retrying provider: ${providerName}`)
|
||||
|
||||
const success = await providerManager.retryProvider(providerName)
|
||||
|
||||
if (success) {
|
||||
const status = providerManager.getProviderStatus(providerName)
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Provider ${providerName} successfully initialized`,
|
||||
status
|
||||
})
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `Failed to initialize provider ${providerName}`,
|
||||
error: 'Initialization failed'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Status: Error retrying provider:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /status/oidc-debug
|
||||
* Get detailed OIDC discovery information for debugging
|
||||
* Shows the full discovery process and configuration for all providers
|
||||
*/
|
||||
router.get('/status/oidc-debug', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('OIDC Debug: Starting detailed discovery process...')
|
||||
|
||||
// Step 1: Get OBP API well-known endpoint info
|
||||
const obpApiHost = obpClientService.getOBPClientConfig().baseUri
|
||||
const wellKnownEndpoint = `${obpApiHost}/obp/v5.1.0/well-known`
|
||||
|
||||
const step1 = {
|
||||
description: 'Discovery of OIDC providers from OBP API',
|
||||
endpoint: wellKnownEndpoint,
|
||||
success: false,
|
||||
response: null as any,
|
||||
error: null as string | null,
|
||||
providers: [] as any[]
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`OIDC Debug: Fetching from ${wellKnownEndpoint}`)
|
||||
const wellKnownResponse = await obpClientService.get('/obp/v5.1.0/well-known', null)
|
||||
step1.response = wellKnownResponse
|
||||
step1.success = !!(wellKnownResponse && wellKnownResponse.well_known_uris)
|
||||
step1.providers = wellKnownResponse.well_known_uris || []
|
||||
console.log(`OIDC Debug: Found ${step1.providers.length} providers`)
|
||||
} catch (error) {
|
||||
step1.error = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error('OIDC Debug: Error fetching OBP well-known:', error)
|
||||
}
|
||||
|
||||
// Step 2: For each provider, fetch their OIDC configuration
|
||||
const providerDetails = []
|
||||
|
||||
for (const provider of step1.providers) {
|
||||
console.log(`OIDC Debug: Fetching OIDC config for ${provider.provider}`)
|
||||
const detail = {
|
||||
providerName: provider.provider,
|
||||
wellKnownUrl: provider.url,
|
||||
success: false,
|
||||
oidcConfiguration: null as any,
|
||||
error: null as string | null,
|
||||
endpoints: {
|
||||
authorization: null as string | null,
|
||||
token: null as string | null,
|
||||
userinfo: null as string | null,
|
||||
jwks: null as string | null
|
||||
},
|
||||
issuer: null as string | null,
|
||||
supportedFeatures: {
|
||||
pkce: false,
|
||||
scopes: [] as string[],
|
||||
responseTypes: [] as string[],
|
||||
grantTypes: [] as string[]
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(provider.url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const config = await response.json()
|
||||
detail.oidcConfiguration = config
|
||||
detail.success = true
|
||||
detail.issuer = config.issuer
|
||||
|
||||
// Extract endpoints
|
||||
detail.endpoints.authorization = config.authorization_endpoint
|
||||
detail.endpoints.token = config.token_endpoint
|
||||
detail.endpoints.userinfo = config.userinfo_endpoint
|
||||
detail.endpoints.jwks = config.jwks_uri
|
||||
|
||||
// Extract supported features
|
||||
detail.supportedFeatures.pkce =
|
||||
config.code_challenge_methods_supported?.includes('S256') || false
|
||||
detail.supportedFeatures.scopes = config.scopes_supported || []
|
||||
detail.supportedFeatures.responseTypes = config.response_types_supported || []
|
||||
detail.supportedFeatures.grantTypes = config.grant_types_supported || []
|
||||
|
||||
console.log(`OIDC Debug: Successfully fetched config for ${provider.provider}`)
|
||||
} catch (error) {
|
||||
detail.error = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error(`OIDC Debug: Error fetching config for ${provider.provider}:`, error)
|
||||
}
|
||||
|
||||
providerDetails.push(detail)
|
||||
}
|
||||
|
||||
// Step 3: Get current provider status from manager
|
||||
const currentStatus = providerManager.getAllProviderStatus()
|
||||
const availableProviders = providerManager.getAvailableProviders()
|
||||
|
||||
// Step 4: Get environment configuration
|
||||
const maskCredential = (value: string | undefined): string => {
|
||||
if (!value || value.length < 6) {
|
||||
return value ? '***masked***' : 'not configured'
|
||||
}
|
||||
return `${value.substring(0, 2)}...${value.substring(value.length - 2)}`
|
||||
}
|
||||
|
||||
const envConfig = {
|
||||
obpOidc: {
|
||||
clientId: maskCredential(process.env.VITE_OBP_OIDC_CLIENT_ID),
|
||||
clientSecret: process.env.VITE_OBP_OIDC_CLIENT_SECRET ? 'configured' : 'not configured',
|
||||
configured: !!(
|
||||
process.env.VITE_OBP_OIDC_CLIENT_ID && process.env.VITE_OBP_OIDC_CLIENT_SECRET
|
||||
)
|
||||
},
|
||||
keycloak: {
|
||||
clientId: maskCredential(process.env.VITE_KEYCLOAK_CLIENT_ID),
|
||||
clientSecret: process.env.VITE_KEYCLOAK_CLIENT_SECRET ? 'configured' : 'not configured',
|
||||
configured: !!(
|
||||
process.env.VITE_KEYCLOAK_CLIENT_ID && process.env.VITE_KEYCLOAK_CLIENT_SECRET
|
||||
)
|
||||
},
|
||||
google: {
|
||||
clientId: maskCredential(process.env.VITE_GOOGLE_CLIENT_ID),
|
||||
clientSecret: process.env.VITE_GOOGLE_CLIENT_SECRET ? 'configured' : 'not configured',
|
||||
configured: !!(process.env.VITE_GOOGLE_CLIENT_ID && process.env.VITE_GOOGLE_CLIENT_SECRET)
|
||||
},
|
||||
github: {
|
||||
clientId: maskCredential(process.env.VITE_GITHUB_CLIENT_ID),
|
||||
clientSecret: process.env.VITE_GITHUB_CLIENT_SECRET ? 'configured' : 'not configured',
|
||||
configured: !!(process.env.VITE_GITHUB_CLIENT_ID && process.env.VITE_GITHUB_CLIENT_SECRET)
|
||||
},
|
||||
custom: {
|
||||
providerName: process.env.VITE_CUSTOM_OIDC_PROVIDER_NAME || 'not configured',
|
||||
clientId: maskCredential(process.env.VITE_CUSTOM_OIDC_CLIENT_ID),
|
||||
clientSecret: process.env.VITE_CUSTOM_OIDC_CLIENT_SECRET ? 'configured' : 'not configured',
|
||||
configured: !!(
|
||||
process.env.VITE_CUSTOM_OIDC_CLIENT_ID && process.env.VITE_CUSTOM_OIDC_CLIENT_SECRET
|
||||
)
|
||||
},
|
||||
shared: {
|
||||
redirectUrl: process.env.VITE_OAUTH2_REDIRECT_URL || 'not configured',
|
||||
obpApiHost: process.env.VITE_OBP_API_HOST || 'not configured'
|
||||
}
|
||||
}
|
||||
|
||||
// Compile summary
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
obpApiReachable: step1.success,
|
||||
totalProvidersDiscovered: step1.providers.length,
|
||||
successfulConfigurations: providerDetails.filter((p) => p.success).length,
|
||||
failedConfigurations: providerDetails.filter((p) => !p.success).length,
|
||||
currentlyAvailable: availableProviders.length,
|
||||
configuredInEnvironment: Object.values(envConfig).filter(
|
||||
(c) => typeof c === 'object' && 'configured' in c && c.configured
|
||||
).length
|
||||
}
|
||||
|
||||
res.json({
|
||||
summary,
|
||||
discoveryProcess: {
|
||||
step1_obpApiDiscovery: step1,
|
||||
step2_providerConfigurations: providerDetails,
|
||||
step3_currentStatus: currentStatus
|
||||
},
|
||||
environment: envConfig,
|
||||
recommendations: generateRecommendations(step1, providerDetails, envConfig, currentStatus),
|
||||
note: 'This debug information shows the complete OIDC discovery process for troubleshooting'
|
||||
})
|
||||
|
||||
console.log('OIDC Debug: Response sent successfully')
|
||||
} catch (error) {
|
||||
console.error('OIDC Debug: Error generating debug info:', error)
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate troubleshooting recommendations based on the discovery results
|
||||
*/
|
||||
function generateRecommendations(
|
||||
step1: any,
|
||||
providerDetails: any[],
|
||||
envConfig: any,
|
||||
currentStatus: any[]
|
||||
): string[] {
|
||||
const recommendations: string[] = []
|
||||
|
||||
// Check if OBP API is reachable
|
||||
if (!step1.success) {
|
||||
recommendations.push(
|
||||
'❌ OBP API well-known endpoint is not reachable. Check that VITE_OBP_API_HOST is correct and the API server is running.'
|
||||
)
|
||||
recommendations.push(` Current endpoint: ${step1.endpoint}`)
|
||||
if (step1.error) {
|
||||
recommendations.push(` Error: ${step1.error}`)
|
||||
}
|
||||
} else {
|
||||
recommendations.push('✅ OBP API well-known endpoint is reachable')
|
||||
}
|
||||
|
||||
// Check if any providers were discovered
|
||||
if (step1.providers.length === 0) {
|
||||
recommendations.push(
|
||||
'⚠️ No OIDC providers found in OBP API response. The OBP API may not have any providers configured.'
|
||||
)
|
||||
} else {
|
||||
recommendations.push(`✅ Found ${step1.providers.length} provider(s) from OBP API`)
|
||||
}
|
||||
|
||||
// Check each provider's configuration
|
||||
providerDetails.forEach((provider) => {
|
||||
if (!provider.success) {
|
||||
recommendations.push(
|
||||
`❌ Provider '${provider.providerName}' OIDC configuration failed to load`
|
||||
)
|
||||
recommendations.push(` Well-known URL: ${provider.wellKnownUrl}`)
|
||||
if (provider.error) {
|
||||
recommendations.push(` Error: ${provider.error}`)
|
||||
}
|
||||
recommendations.push(
|
||||
` Check that the provider's well-known endpoint is accessible and returning valid JSON`
|
||||
)
|
||||
} else {
|
||||
recommendations.push(
|
||||
`✅ Provider '${provider.providerName}' OIDC configuration loaded successfully`
|
||||
)
|
||||
}
|
||||
|
||||
// Check if provider has environment credentials
|
||||
const envKey = provider.providerName.replace('-', '')
|
||||
const providerEnv = envConfig[envKey] || envConfig[provider.providerName]
|
||||
if (providerEnv && !providerEnv.configured) {
|
||||
recommendations.push(
|
||||
`⚠️ Provider '${provider.providerName}' is missing environment credentials`
|
||||
)
|
||||
const upperName = provider.providerName.toUpperCase().replace('-', '_')
|
||||
recommendations.push(` Set VITE_${upperName}_CLIENT_ID and VITE_${upperName}_CLIENT_SECRET`)
|
||||
}
|
||||
})
|
||||
|
||||
// Check current provider status
|
||||
currentStatus.forEach((status) => {
|
||||
if (!status.available) {
|
||||
recommendations.push(`⚠️ Provider '${status.name}' is currently unavailable`)
|
||||
if (status.error) {
|
||||
recommendations.push(` Error: ${status.error}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push('✅ All checks passed! OIDC configuration looks good.')
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
export default router
|
||||
139
server/routes/user.ts
Normal file
139
server/routes/user.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2025, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
import { Container } from 'typedi'
|
||||
import OBPClientService from '../services/OBPClientService.js'
|
||||
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get services from container
|
||||
const obpClientService = Container.get(OBPClientService)
|
||||
|
||||
const obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
|
||||
|
||||
/**
|
||||
* GET /user/current
|
||||
* Get current logged in user information
|
||||
*/
|
||||
router.get('/user/current', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('User: Getting current user')
|
||||
const session = req.session as any
|
||||
|
||||
// Check OAuth2 session
|
||||
if (!session.oauth2_user) {
|
||||
console.log('User: No authentication session found')
|
||||
return res.json({})
|
||||
}
|
||||
|
||||
console.log('User: Returning OAuth2 user info')
|
||||
const oauth2User = session.oauth2_user
|
||||
|
||||
// TODO: Implement token refresh in multi-provider system
|
||||
// For now, if token expires, user must re-login
|
||||
|
||||
// Get actual user ID from OBP-API
|
||||
let obpUserId = oauth2User.sub // Default to sub if OBP call fails
|
||||
const clientConfig = session.clientConfig
|
||||
|
||||
if (clientConfig && clientConfig.oauth2?.accessToken) {
|
||||
try {
|
||||
const version = DEFAULT_OBP_API_VERSION
|
||||
console.log('User: Fetching OBP user from /obp/' + version + '/users/current')
|
||||
const obpUser = await obpClientService.get(`/obp/${version}/users/current`, clientConfig)
|
||||
if (obpUser && obpUser.user_id) {
|
||||
obpUserId = obpUser.user_id
|
||||
console.log('User: Got OBP user ID:', obpUserId, '(was:', oauth2User.sub, ')')
|
||||
} else {
|
||||
console.warn('User: OBP user response has no user_id:', obpUser)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('User: Could not fetch OBP user ID, using token sub:', oauth2User.sub)
|
||||
console.warn('User: Error details:', error.message)
|
||||
}
|
||||
} else {
|
||||
console.warn('User: No valid clientConfig or access token, using token sub:', oauth2User.sub)
|
||||
}
|
||||
|
||||
// Return user info in format compatible with frontend
|
||||
res.json({
|
||||
user_id: obpUserId,
|
||||
username: oauth2User.username,
|
||||
email: oauth2User.email,
|
||||
email_verified: oauth2User.email_verified,
|
||||
name: oauth2User.name,
|
||||
given_name: oauth2User.given_name,
|
||||
family_name: oauth2User.family_name,
|
||||
provider: oauth2User.provider || 'oauth2'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('User: Error getting current user:', error)
|
||||
res.json({})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /user/logoff
|
||||
* Logout user and clear session
|
||||
* Query params:
|
||||
* - redirect: URL to redirect to after logout (optional)
|
||||
*/
|
||||
router.get('/user/logoff', (req: Request, res: Response) => {
|
||||
console.log('User: Logging out user')
|
||||
const session = req.session as any
|
||||
|
||||
// Clear OAuth2 session data
|
||||
delete session.oauth2_access_token
|
||||
delete session.oauth2_refresh_token
|
||||
delete session.oauth2_id_token
|
||||
delete session.oauth2_token_type
|
||||
delete session.oauth2_expires_in
|
||||
delete session.oauth2_token_timestamp
|
||||
delete session.oauth2_user_info
|
||||
delete session.oauth2_user
|
||||
delete session.oauth2_provider
|
||||
delete session.clientConfig
|
||||
delete session.opeyConfig
|
||||
|
||||
// Destroy the session completely
|
||||
session.destroy((err: any) => {
|
||||
if (err) {
|
||||
console.error('User: Error destroying session:', err)
|
||||
} else {
|
||||
console.log('User: Session destroyed successfully')
|
||||
}
|
||||
|
||||
const redirectPage = (req.query.redirect as string) || obpExplorerHome || '/'
|
||||
console.log('User: Redirecting to:', redirectPage)
|
||||
res.redirect(redirectPage)
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
341
server/services/OAuth2ClientWithConfig.ts
Normal file
341
server/services/OAuth2ClientWithConfig.ts
Normal file
@ -0,0 +1,341 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { OAuth2Client, OAuth2Tokens } from 'arctic'
|
||||
import type { OIDCConfiguration, TokenResponse } from '../types/oauth2.js'
|
||||
|
||||
/**
|
||||
* Extended OAuth2 Client with OIDC configuration support
|
||||
*
|
||||
* This class extends the arctic OAuth2Client to add:
|
||||
* - OIDC discovery document (.well-known/openid-configuration)
|
||||
* - Provider name tracking
|
||||
* - Provider-specific token exchange logic
|
||||
*
|
||||
* @example
|
||||
* const client = new OAuth2ClientWithConfig(
|
||||
* 'client-id',
|
||||
* 'client-secret',
|
||||
* 'http://localhost:5173/api/oauth2/callback',
|
||||
* 'obp-oidc'
|
||||
* )
|
||||
* await client.initOIDCConfig('http://localhost:9000/obp-oidc/.well-known/openid-configuration')
|
||||
*/
|
||||
export class OAuth2ClientWithConfig extends OAuth2Client {
|
||||
public OIDCConfig?: OIDCConfiguration
|
||||
public provider: string
|
||||
public wellKnownUri?: string
|
||||
private _clientSecret: string
|
||||
private _redirectUri: string
|
||||
|
||||
constructor(clientId: string, clientSecret: string, redirectUri: string, provider: string) {
|
||||
super(clientId, clientSecret, redirectUri)
|
||||
this.provider = provider
|
||||
this._clientSecret = clientSecret
|
||||
this._redirectUri = redirectUri
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize OIDC configuration from well-known discovery endpoint
|
||||
*
|
||||
* @param oidcConfigUrl - Full URL to .well-known/openid-configuration
|
||||
* @throws {Error} If the discovery document cannot be fetched or is invalid
|
||||
*
|
||||
* @example
|
||||
* await client.initOIDCConfig('http://localhost:9000/obp-oidc/.well-known/openid-configuration')
|
||||
*/
|
||||
async initOIDCConfig(wellKnownUrl: string): Promise<void> {
|
||||
console.log(
|
||||
`OAuth2ClientWithConfig: Fetching OIDC config for ${this.provider} from: ${wellKnownUrl}`
|
||||
)
|
||||
|
||||
// Store the well-known URL for health checks
|
||||
this.wellKnownUri = wellKnownUrl
|
||||
|
||||
try {
|
||||
const response = await fetch(wellKnownUrl)
|
||||
|
||||
console.log(
|
||||
`OAuth2ClientWithConfig: Response status: ${response.status} ${response.statusText}`
|
||||
)
|
||||
console.log(
|
||||
`OAuth2ClientWithConfig: Response headers:`,
|
||||
Object.fromEntries(response.headers.entries())
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text()
|
||||
console.error(`OAuth2ClientWithConfig: Error response body:`, errorBody)
|
||||
throw new Error(
|
||||
`Failed to fetch OIDC configuration for ${this.provider}: ${response.status} ${response.statusText} - ${errorBody}`
|
||||
)
|
||||
}
|
||||
|
||||
const responseText = await response.text()
|
||||
console.log(
|
||||
`OAuth2ClientWithConfig: Raw response body (first 500 chars):`,
|
||||
responseText.substring(0, 500)
|
||||
)
|
||||
|
||||
let config: OIDCConfiguration
|
||||
try {
|
||||
config = JSON.parse(responseText) as OIDCConfiguration
|
||||
console.log(`OAuth2ClientWithConfig: Parsed config keys:`, Object.keys(config))
|
||||
console.log(`OAuth2ClientWithConfig: Full parsed config:`, JSON.stringify(config, null, 2))
|
||||
} catch (parseError) {
|
||||
console.error(`OAuth2ClientWithConfig: JSON parse error:`, parseError)
|
||||
console.error(`OAuth2ClientWithConfig: Failed to parse response as JSON`)
|
||||
throw new Error(`Invalid JSON response from ${this.provider}: ${parseError}`)
|
||||
}
|
||||
|
||||
// Validate required endpoints with detailed logging
|
||||
console.log(`OAuth2ClientWithConfig: Validating required endpoints...`)
|
||||
console.log(` - authorization_endpoint: ${config.authorization_endpoint || 'MISSING'}`)
|
||||
console.log(` - token_endpoint: ${config.token_endpoint || 'MISSING'}`)
|
||||
console.log(` - userinfo_endpoint: ${config.userinfo_endpoint || 'MISSING'}`)
|
||||
|
||||
if (!config.authorization_endpoint) {
|
||||
console.error(`OAuth2ClientWithConfig: authorization_endpoint is missing or undefined`)
|
||||
console.error(`OAuth2ClientWithConfig: Config object type:`, typeof config)
|
||||
console.error(`OAuth2ClientWithConfig: Config object:`, config)
|
||||
throw new Error(`OIDC configuration for ${this.provider} missing authorization_endpoint`)
|
||||
}
|
||||
if (!config.token_endpoint) {
|
||||
console.error(`OAuth2ClientWithConfig: token_endpoint is missing or undefined`)
|
||||
throw new Error(`OIDC configuration for ${this.provider} missing token_endpoint`)
|
||||
}
|
||||
if (!config.userinfo_endpoint) {
|
||||
console.error(`OAuth2ClientWithConfig: userinfo_endpoint is missing or undefined`)
|
||||
throw new Error(`OIDC configuration for ${this.provider} missing userinfo_endpoint`)
|
||||
}
|
||||
|
||||
this.OIDCConfig = config
|
||||
|
||||
console.log(`OAuth2ClientWithConfig: OIDC config loaded for ${this.provider}`)
|
||||
console.log(` Issuer: ${config.issuer}`)
|
||||
console.log(` Authorization: ${config.authorization_endpoint}`)
|
||||
console.log(` Token: ${config.token_endpoint}`)
|
||||
console.log(` UserInfo: ${config.userinfo_endpoint}`)
|
||||
|
||||
// Log supported PKCE methods if available
|
||||
if (config.code_challenge_methods_supported) {
|
||||
console.log(` PKCE methods: ${config.code_challenge_methods_supported.join(', ')}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`OAuth2ClientWithConfig: Failed to initialize ${this.provider}:`, error)
|
||||
console.error(
|
||||
`OAuth2ClientWithConfig: Error stack:`,
|
||||
error instanceof Error ? error.stack : 'N/A'
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization endpoint from OIDC config
|
||||
*
|
||||
* @returns Authorization endpoint URL
|
||||
* @throws {Error} If OIDC configuration not initialized
|
||||
*/
|
||||
getAuthorizationEndpoint(): string {
|
||||
if (!this.OIDCConfig?.authorization_endpoint) {
|
||||
throw new Error(`OIDC configuration not initialized for ${this.provider}`)
|
||||
}
|
||||
return this.OIDCConfig.authorization_endpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token endpoint from OIDC config
|
||||
*
|
||||
* @returns Token endpoint URL
|
||||
* @throws {Error} If OIDC configuration not initialized
|
||||
*/
|
||||
getTokenEndpoint(): string {
|
||||
if (!this.OIDCConfig?.token_endpoint) {
|
||||
throw new Error(`OIDC configuration not initialized for ${this.provider}`)
|
||||
}
|
||||
return this.OIDCConfig.token_endpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Get userinfo endpoint from OIDC config
|
||||
*
|
||||
* @returns UserInfo endpoint URL
|
||||
* @throws {Error} If OIDC configuration not initialized
|
||||
*/
|
||||
getUserInfoEndpoint(): string {
|
||||
if (!this.OIDCConfig?.userinfo_endpoint) {
|
||||
throw new Error(`OIDC configuration not initialized for ${this.provider}`)
|
||||
}
|
||||
return this.OIDCConfig.userinfo_endpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OIDC configuration is initialized
|
||||
*
|
||||
* @returns True if OIDC config has been loaded
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.OIDCConfig !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*
|
||||
* This method provides a simpler interface for token exchange
|
||||
*
|
||||
* @param code - Authorization code from OIDC provider
|
||||
* @param codeVerifier - PKCE code verifier
|
||||
* @returns Token response with access token, refresh token, and ID token
|
||||
*/
|
||||
async exchangeAuthorizationCode(code: string, codeVerifier: string): Promise<TokenResponse> {
|
||||
const tokenEndpoint = this.getTokenEndpoint()
|
||||
|
||||
console.log(`OAuth2ClientWithConfig: Exchanging authorization code for ${this.provider}`)
|
||||
|
||||
// Prepare token request body
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: this._redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
client_id: this.clientId
|
||||
})
|
||||
|
||||
// Add client_secret to body (some providers prefer this over Basic Auth)
|
||||
if (this._clientSecret) {
|
||||
body.append('client_secret', this._clientSecret)
|
||||
}
|
||||
|
||||
try {
|
||||
// Try with Basic Authentication first (RFC 6749 standard)
|
||||
const authHeader = Buffer.from(`${this.clientId}:${this._clientSecret}`).toString('base64')
|
||||
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Basic ${authHeader}`
|
||||
},
|
||||
body: body.toString()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text()
|
||||
throw new Error(
|
||||
`Token exchange failed for ${this.provider}: ${response.status} ${response.statusText} - ${errorData}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
idToken: data.id_token,
|
||||
tokenType: data.token_type || 'Bearer',
|
||||
expiresIn: data.expires_in,
|
||||
scope: data.scope
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`OAuth2ClientWithConfig: Token exchange error for ${this.provider}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*
|
||||
* @param refreshToken - Refresh token from previous authentication
|
||||
* @returns New token response
|
||||
*/
|
||||
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
||||
const tokenEndpoint = this.getTokenEndpoint()
|
||||
|
||||
console.log(`OAuth2ClientWithConfig: Refreshing access token for ${this.provider}`)
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: this.clientId
|
||||
})
|
||||
|
||||
if (this._clientSecret) {
|
||||
body.append('client_secret', this._clientSecret)
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = Buffer.from(`${this.clientId}:${this._clientSecret}`).toString('base64')
|
||||
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Basic ${authHeader}`
|
||||
},
|
||||
body: body.toString()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text()
|
||||
throw new Error(
|
||||
`Token refresh failed for ${this.provider}: ${response.status} ${response.statusText} - ${errorData}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || refreshToken, // Some providers don't return new refresh token
|
||||
idToken: data.id_token,
|
||||
tokenType: data.token_type || 'Bearer',
|
||||
expiresIn: data.expires_in,
|
||||
scope: data.scope
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`OAuth2ClientWithConfig: Token refresh error for ${this.provider}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the redirect URI
|
||||
*/
|
||||
getRedirectUri(): string {
|
||||
return this._redirectUri
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client secret
|
||||
*/
|
||||
getClientSecret(): string {
|
||||
return this._clientSecret
|
||||
}
|
||||
}
|
||||
238
server/services/OAuth2ProviderFactory.ts
Normal file
238
server/services/OAuth2ProviderFactory.ts
Normal file
@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Service } from 'typedi'
|
||||
import { OAuth2ClientWithConfig } from './OAuth2ClientWithConfig.js'
|
||||
import type { WellKnownUri, ProviderStrategy } from '../types/oauth2.js'
|
||||
|
||||
/**
|
||||
* Factory for creating OAuth2 clients for different OIDC providers
|
||||
*
|
||||
* Uses the Strategy pattern to handle provider-specific configurations:
|
||||
* - OBP-OIDC
|
||||
* - Keycloak
|
||||
* - Google
|
||||
* - GitHub
|
||||
* - Custom providers
|
||||
*
|
||||
* Configuration is loaded from environment variables.
|
||||
*
|
||||
* @example
|
||||
* const factory = Container.get(OAuth2ProviderFactory)
|
||||
* const client = await factory.initializeProvider({
|
||||
* provider: 'obp-oidc',
|
||||
* url: 'http://localhost:9000/obp-oidc/.well-known/openid-configuration'
|
||||
* })
|
||||
*/
|
||||
@Service()
|
||||
export class OAuth2ProviderFactory {
|
||||
private strategies: Map<string, ProviderStrategy> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.loadStrategies()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load provider strategies from environment variables
|
||||
*
|
||||
* Each provider requires:
|
||||
* - VITE_[PROVIDER]_CLIENT_ID
|
||||
* - VITE_[PROVIDER]_CLIENT_SECRET
|
||||
* - VITE_OAUTH2_REDIRECT_URL (shared by all providers, defaults to /api/oauth2/callback)
|
||||
*/
|
||||
private loadStrategies(): void {
|
||||
console.log('OAuth2ProviderFactory: Loading provider strategies...')
|
||||
|
||||
// Shared redirect URL for all providers
|
||||
const sharedRedirectUri =
|
||||
process.env.VITE_OAUTH2_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback'
|
||||
|
||||
// OBP-OIDC Strategy
|
||||
if (process.env.VITE_OBP_OIDC_CLIENT_ID) {
|
||||
this.strategies.set('obp-oidc', {
|
||||
clientId: process.env.VITE_OBP_OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.VITE_OBP_OIDC_CLIENT_SECRET || '',
|
||||
redirectUri: sharedRedirectUri,
|
||||
scopes: ['openid', 'profile', 'email']
|
||||
})
|
||||
console.log(' OK OBP-OIDC strategy loaded')
|
||||
}
|
||||
|
||||
// Keycloak Strategy
|
||||
if (process.env.VITE_KEYCLOAK_CLIENT_ID) {
|
||||
this.strategies.set('keycloak', {
|
||||
clientId: process.env.VITE_KEYCLOAK_CLIENT_ID,
|
||||
clientSecret: process.env.VITE_KEYCLOAK_CLIENT_SECRET || '',
|
||||
redirectUri: sharedRedirectUri,
|
||||
scopes: ['openid', 'profile', 'email']
|
||||
})
|
||||
console.log(' OK Keycloak strategy loaded')
|
||||
}
|
||||
|
||||
// Google Strategy
|
||||
if (process.env.VITE_GOOGLE_CLIENT_ID) {
|
||||
this.strategies.set('google', {
|
||||
clientId: process.env.VITE_GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.VITE_GOOGLE_CLIENT_SECRET || '',
|
||||
redirectUri: sharedRedirectUri,
|
||||
scopes: ['openid', 'profile', 'email']
|
||||
})
|
||||
console.log(' OK Google strategy loaded')
|
||||
}
|
||||
|
||||
// GitHub Strategy
|
||||
if (process.env.VITE_GITHUB_CLIENT_ID) {
|
||||
this.strategies.set('github', {
|
||||
clientId: process.env.VITE_GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.VITE_GITHUB_CLIENT_SECRET || '',
|
||||
redirectUri: sharedRedirectUri,
|
||||
scopes: ['read:user', 'user:email']
|
||||
})
|
||||
console.log(' OK GitHub strategy loaded')
|
||||
}
|
||||
|
||||
// Generic OIDC Strategy (for custom providers)
|
||||
if (process.env.VITE_CUSTOM_OIDC_CLIENT_ID) {
|
||||
const providerName = process.env.VITE_CUSTOM_OIDC_PROVIDER_NAME || 'custom-oidc'
|
||||
this.strategies.set(providerName, {
|
||||
clientId: process.env.VITE_CUSTOM_OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.VITE_CUSTOM_OIDC_CLIENT_SECRET || '',
|
||||
redirectUri: sharedRedirectUri,
|
||||
scopes: ['openid', 'profile', 'email']
|
||||
})
|
||||
console.log(` OK Custom OIDC strategy loaded: ${providerName}`)
|
||||
}
|
||||
|
||||
console.log(`OAuth2ProviderFactory: Loaded ${this.strategies.size} provider strategies`)
|
||||
|
||||
if (this.strategies.size === 0) {
|
||||
console.warn('OAuth2ProviderFactory: WARNING - No provider strategies configured!')
|
||||
console.warn('OAuth2ProviderFactory: Set environment variables for at least one provider')
|
||||
console.warn(
|
||||
'OAuth2ProviderFactory: Example: VITE_OBP_OIDC_CLIENT_ID, VITE_OBP_OIDC_CLIENT_SECRET'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an OAuth2 client for a specific provider
|
||||
*
|
||||
* @param wellKnownUri - Provider information from OBP API
|
||||
* @returns Initialized OAuth2 client or null if no strategy exists or initialization fails
|
||||
*
|
||||
* @example
|
||||
* const client = await factory.initializeProvider({
|
||||
* provider: 'obp-oidc',
|
||||
* url: 'http://localhost:9000/obp-oidc/.well-known/openid-configuration'
|
||||
* })
|
||||
*/
|
||||
async initializeProvider(wellKnownUri: WellKnownUri): Promise<OAuth2ClientWithConfig | null> {
|
||||
console.log(`OAuth2ProviderFactory: Initializing provider: ${wellKnownUri.provider}`)
|
||||
|
||||
const strategy = this.strategies.get(wellKnownUri.provider)
|
||||
if (!strategy) {
|
||||
console.warn(
|
||||
`OAuth2ProviderFactory: No strategy found for provider: ${wellKnownUri.provider}`
|
||||
)
|
||||
console.warn(
|
||||
`OAuth2ProviderFactory: Available strategies: ${Array.from(this.strategies.keys()).join(', ')}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate strategy configuration
|
||||
if (!strategy.clientId) {
|
||||
console.error(
|
||||
`OAuth2ProviderFactory: Missing clientId for provider: ${wellKnownUri.provider}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!strategy.clientSecret) {
|
||||
console.warn(
|
||||
`OAuth2ProviderFactory: Missing clientSecret for provider: ${wellKnownUri.provider}`
|
||||
)
|
||||
console.warn(`OAuth2ProviderFactory: Some providers require a client secret`)
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new OAuth2ClientWithConfig(
|
||||
strategy.clientId,
|
||||
strategy.clientSecret,
|
||||
strategy.redirectUri,
|
||||
wellKnownUri.provider
|
||||
)
|
||||
|
||||
// Initialize OIDC configuration from discovery endpoint
|
||||
await client.initOIDCConfig(wellKnownUri.url)
|
||||
|
||||
console.log(`OAuth2ProviderFactory: Successfully initialized ${wellKnownUri.provider}`)
|
||||
return client
|
||||
} catch (error) {
|
||||
console.error(`OAuth2ProviderFactory: Failed to initialize ${wellKnownUri.provider}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of configured provider names
|
||||
*
|
||||
* @returns Array of provider names that have strategies configured
|
||||
*/
|
||||
getConfiguredProviders(): string[] {
|
||||
return Array.from(this.strategies.keys())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider strategy exists
|
||||
*
|
||||
* @param providerName - Name of the provider to check
|
||||
* @returns True if strategy exists for this provider
|
||||
*/
|
||||
hasStrategy(providerName: string): boolean {
|
||||
return this.strategies.has(providerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strategy for a specific provider (for debugging/testing)
|
||||
*
|
||||
* @param providerName - Name of the provider
|
||||
* @returns Provider strategy or undefined if not found
|
||||
*/
|
||||
getStrategy(providerName: string): ProviderStrategy | undefined {
|
||||
return this.strategies.get(providerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of configured strategies
|
||||
*
|
||||
* @returns Number of provider strategies loaded
|
||||
*/
|
||||
getStrategyCount(): number {
|
||||
return this.strategies.size
|
||||
}
|
||||
}
|
||||
507
server/services/OAuth2ProviderManager.ts
Normal file
507
server/services/OAuth2ProviderManager.ts
Normal file
@ -0,0 +1,507 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Service, Container } from 'typedi'
|
||||
import { OAuth2ProviderFactory } from './OAuth2ProviderFactory.js'
|
||||
import { OAuth2ClientWithConfig } from './OAuth2ClientWithConfig.js'
|
||||
import OBPClientService from './OBPClientService.js'
|
||||
import type { WellKnownUri, WellKnownResponse, ProviderStatus } from '../types/oauth2.js'
|
||||
|
||||
/**
|
||||
* Manager for multiple OAuth2/OIDC providers
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Fetch available OIDC providers from OBP API
|
||||
* - Initialize OAuth2 clients for each provider
|
||||
* - Track provider health status
|
||||
* - Perform periodic health checks
|
||||
* - Provide access to provider clients
|
||||
*
|
||||
* The manager automatically:
|
||||
* - Retries failed provider initializations
|
||||
* - Monitors provider availability (60s intervals by default)
|
||||
* - Updates provider status in real-time
|
||||
*
|
||||
* @example
|
||||
* const manager = Container.get(OAuth2ProviderManager)
|
||||
* await manager.initializeProviders()
|
||||
* const client = manager.getProvider('obp-oidc')
|
||||
*/
|
||||
@Service()
|
||||
export class OAuth2ProviderManager {
|
||||
private providers: Map<string, OAuth2ClientWithConfig> = new Map()
|
||||
private providerStatus: Map<string, ProviderStatus> = new Map()
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null
|
||||
private retryInterval: NodeJS.Timeout | null = null
|
||||
private factory: OAuth2ProviderFactory
|
||||
private obpClientService: OBPClientService
|
||||
private initialized: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.factory = Container.get(OAuth2ProviderFactory)
|
||||
this.obpClientService = Container.get(OBPClientService)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch well-known URIs from OBP API or legacy env variable
|
||||
*
|
||||
* Priority:
|
||||
* 1. VITE_OBP_OAUTH2_WELL_KNOWN_URL (legacy single-provider mode)
|
||||
* 2. VITE_OBP_API_HOST/obp/v5.1.0/well-known (multi-provider mode)
|
||||
*
|
||||
* @returns Array of well-known URIs with provider names
|
||||
*/
|
||||
async fetchWellKnownUris(): Promise<WellKnownUri[]> {
|
||||
// Check for legacy single-provider configuration
|
||||
const legacyWellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL
|
||||
|
||||
if (legacyWellKnownUrl) {
|
||||
console.log('OAuth2ProviderManager: Using legacy VITE_OBP_OAUTH2_WELL_KNOWN_URL...')
|
||||
console.log(`OAuth2ProviderManager: Well-known URL: ${legacyWellKnownUrl}`)
|
||||
|
||||
// Return single provider configuration
|
||||
return [
|
||||
{
|
||||
provider: 'obp-oidc',
|
||||
url: legacyWellKnownUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Multi-provider mode: fetch from OBP API
|
||||
console.log('OAuth2ProviderManager: Fetching well-known URIs from OBP API...')
|
||||
console.log(
|
||||
`OAuth2ProviderManager: Target URL: ${this.obpClientService.getOBPClientConfig().baseUri}/obp/v5.1.0/well-known`
|
||||
)
|
||||
|
||||
try {
|
||||
// Use OBPClientService to call the API
|
||||
const response = await this.obpClientService.get('/obp/v5.1.0/well-known', null)
|
||||
|
||||
console.log(
|
||||
'OAuth2ProviderManager: Raw response from OBP API:',
|
||||
JSON.stringify(response, null, 2)
|
||||
)
|
||||
|
||||
if (!response.well_known_uris || response.well_known_uris.length === 0) {
|
||||
console.warn('OAuth2ProviderManager: No well-known URIs found in OBP API response')
|
||||
console.warn('OAuth2ProviderManager: Response keys:', Object.keys(response))
|
||||
return []
|
||||
}
|
||||
|
||||
console.log(`OAuth2ProviderManager: Found ${response.well_known_uris.length} providers:`)
|
||||
response.well_known_uris.forEach((uri: WellKnownUri) => {
|
||||
console.log(` - ${uri.provider}: ${uri.url}`)
|
||||
console.log(` Testing accessibility of: ${uri.url}`)
|
||||
})
|
||||
|
||||
return response.well_known_uris
|
||||
} catch (error) {
|
||||
console.error('OAuth2ProviderManager: Failed to fetch well-known URIs:', error)
|
||||
console.error(
|
||||
'OAuth2ProviderManager: Error details:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
console.warn('OAuth2ProviderManager: Falling back to no providers')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all OAuth2 providers from OBP API
|
||||
*
|
||||
* This method:
|
||||
* 1. Fetches well-known URIs from OBP API
|
||||
* 2. Initializes OAuth2 client for each provider
|
||||
* 3. Tracks successful and failed initializations
|
||||
* 4. Returns success status
|
||||
*
|
||||
* @returns True if at least one provider was initialized successfully
|
||||
*/
|
||||
async initializeProviders(): Promise<boolean> {
|
||||
console.log('OAuth2ProviderManager: Initializing providers...')
|
||||
|
||||
const wellKnownUris = await this.fetchWellKnownUris()
|
||||
|
||||
if (wellKnownUris.length === 0) {
|
||||
console.warn('OAuth2ProviderManager: No providers to initialize')
|
||||
console.warn(
|
||||
'OAuth2ProviderManager: Check that OBP API is running and /obp/v5.1.0/well-known endpoint is available'
|
||||
)
|
||||
console.log('OAuth2ProviderManager: Will retry fetching providers every 30 seconds...')
|
||||
this.startRetryInterval()
|
||||
return false
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
|
||||
for (const providerUri of wellKnownUris) {
|
||||
try {
|
||||
const client = await this.factory.initializeProvider(providerUri)
|
||||
|
||||
if (client && client.isInitialized()) {
|
||||
this.providers.set(providerUri.provider, client)
|
||||
this.providerStatus.set(providerUri.provider, {
|
||||
name: providerUri.provider,
|
||||
available: true,
|
||||
lastChecked: new Date()
|
||||
})
|
||||
successCount++
|
||||
console.log(`OAuth2ProviderManager: OK ${providerUri.provider} initialized`)
|
||||
} else {
|
||||
this.providerStatus.set(providerUri.provider, {
|
||||
name: providerUri.provider,
|
||||
available: false,
|
||||
lastChecked: new Date(),
|
||||
error: 'Failed to initialize client'
|
||||
})
|
||||
console.warn(`OAuth2ProviderManager: ERROR ${providerUri.provider} failed to initialize`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
this.providerStatus.set(providerUri.provider, {
|
||||
name: providerUri.provider,
|
||||
available: false,
|
||||
lastChecked: new Date(),
|
||||
error: errorMessage
|
||||
})
|
||||
console.error(`OAuth2ProviderManager: ERROR ${providerUri.provider} error:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = successCount > 0
|
||||
|
||||
console.log(
|
||||
`OAuth2ProviderManager: Initialized ${successCount}/${wellKnownUris.length} providers`
|
||||
)
|
||||
|
||||
if (successCount === 0) {
|
||||
console.error('OAuth2ProviderManager: ERROR - No providers were successfully initialized')
|
||||
console.error(
|
||||
'OAuth2ProviderManager: Users will not be able to log in until at least one provider is available'
|
||||
)
|
||||
console.log('OAuth2ProviderManager: Will retry initialization every 30 seconds...')
|
||||
this.startRetryInterval()
|
||||
} else if (successCount < wellKnownUris.length) {
|
||||
// Some providers failed - retry only the failed ones
|
||||
console.log(
|
||||
`OAuth2ProviderManager: ${wellKnownUris.length - successCount} provider(s) failed, will retry every 30 seconds...`
|
||||
)
|
||||
this.startRetryInterval()
|
||||
}
|
||||
|
||||
return this.initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic health checks for all providers
|
||||
*
|
||||
* @param intervalMs - Health check interval in milliseconds (default: 60000 = 1 minute)
|
||||
*/
|
||||
startHealthCheck(intervalMs: number = 60000): void {
|
||||
if (this.healthCheckInterval) {
|
||||
console.log('OAuth2ProviderManager: Health check already running')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`OAuth2ProviderManager: Starting health check (every ${intervalMs / 1000}s = ${intervalMs / 60000} minute(s))`
|
||||
)
|
||||
|
||||
this.healthCheckInterval = setInterval(async () => {
|
||||
await this.performHealthCheck()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic health checks
|
||||
*/
|
||||
stopHealthCheck(): void {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval)
|
||||
this.healthCheckInterval = null
|
||||
console.log('OAuth2ProviderManager: Health check stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic retry for failed providers
|
||||
*
|
||||
* @param intervalMs - Retry interval in milliseconds (default: 30000 = 30 seconds)
|
||||
*/
|
||||
startRetryInterval(intervalMs: number = 30000): void {
|
||||
if (this.retryInterval) {
|
||||
console.log('OAuth2ProviderManager: Retry interval already running')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`OAuth2ProviderManager: Starting retry interval (every ${intervalMs / 1000}s)`)
|
||||
|
||||
this.retryInterval = setInterval(async () => {
|
||||
await this.retryFailedProviders()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic retry interval
|
||||
*/
|
||||
stopRetryInterval(): void {
|
||||
if (this.retryInterval) {
|
||||
clearInterval(this.retryInterval)
|
||||
this.retryInterval = null
|
||||
console.log('OAuth2ProviderManager: Retry interval stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all failed providers
|
||||
*/
|
||||
private async retryFailedProviders(): Promise<void> {
|
||||
const failedProviders: string[] = []
|
||||
|
||||
this.providerStatus.forEach((status, name) => {
|
||||
if (!status.available) {
|
||||
failedProviders.push(name)
|
||||
}
|
||||
})
|
||||
|
||||
// Also check if we have no providers at all (initial fetch may have failed)
|
||||
if (this.providerStatus.size === 0) {
|
||||
console.log(
|
||||
'OAuth2ProviderManager: No providers initialized yet, attempting full initialization...'
|
||||
)
|
||||
|
||||
// Temporarily stop retry to prevent duplicate calls
|
||||
this.stopRetryInterval()
|
||||
|
||||
const success = await this.initializeProviders()
|
||||
if (!success) {
|
||||
// Restart retry if initialization failed
|
||||
this.startRetryInterval()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (failedProviders.length === 0) {
|
||||
console.log('OAuth2ProviderManager: All providers healthy, stopping retry interval')
|
||||
this.stopRetryInterval()
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`OAuth2ProviderManager: Retrying ${failedProviders.length} failed provider(s)...`)
|
||||
|
||||
for (const providerName of failedProviders) {
|
||||
const success = await this.retryProvider(providerName)
|
||||
if (success) {
|
||||
console.log(`OAuth2ProviderManager: Successfully recovered provider: ${providerName}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all providers are now healthy
|
||||
const stillFailed = Array.from(this.providerStatus.values()).filter((s) => !s.available)
|
||||
if (stillFailed.length === 0) {
|
||||
console.log('OAuth2ProviderManager: All providers recovered, stopping retry interval')
|
||||
this.stopRetryInterval()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform health check on all providers
|
||||
*
|
||||
* This checks if each provider's issuer endpoint is reachable
|
||||
*/
|
||||
private async performHealthCheck(): Promise<void> {
|
||||
console.log('OAuth2ProviderManager: Performing health check...')
|
||||
|
||||
const checkPromises: Promise<void>[] = []
|
||||
|
||||
this.providers.forEach((client, providerName) => {
|
||||
checkPromises.push(this.checkProviderHealth(providerName, client))
|
||||
})
|
||||
|
||||
await Promise.allSettled(checkPromises)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of a single provider
|
||||
*
|
||||
* @param providerName - Name of the provider
|
||||
* @param client - OAuth2 client for the provider
|
||||
*/
|
||||
private async checkProviderHealth(
|
||||
providerName: string,
|
||||
client: OAuth2ClientWithConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Try to fetch OIDC well-known endpoint to verify provider is reachable
|
||||
const wellKnownUrl = client.wellKnownUri
|
||||
if (!wellKnownUrl) {
|
||||
throw new Error('No well-known URL configured')
|
||||
}
|
||||
|
||||
console.log(` Checking ${providerName} at: ${wellKnownUrl}`)
|
||||
|
||||
// Use HEAD request as per HTTP standards - all endpoints supporting GET should support HEAD
|
||||
const response = await fetch(wellKnownUrl, {
|
||||
method: 'HEAD',
|
||||
signal: AbortSignal.timeout(5000) // 5 second timeout
|
||||
})
|
||||
|
||||
const isAvailable = response.ok
|
||||
this.providerStatus.set(providerName, {
|
||||
name: providerName,
|
||||
available: isAvailable,
|
||||
lastChecked: new Date(),
|
||||
error: isAvailable ? undefined : `HTTP ${response.status}`
|
||||
})
|
||||
|
||||
console.log(` ${providerName}: ${isAvailable ? 'healthy' : 'unhealthy'}`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
this.providerStatus.set(providerName, {
|
||||
name: providerName,
|
||||
available: false,
|
||||
lastChecked: new Date(),
|
||||
error: errorMessage
|
||||
})
|
||||
console.log(` ${providerName}: unhealthy (${errorMessage})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth2 client for a specific provider
|
||||
*
|
||||
* @param providerName - Provider name (e.g., "obp-oidc", "keycloak")
|
||||
* @returns OAuth2 client or undefined if not found
|
||||
*/
|
||||
getProvider(providerName: string): OAuth2ClientWithConfig | undefined {
|
||||
return this.providers.get(providerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all available (initialized and healthy) provider names
|
||||
*
|
||||
* @returns Array of available provider names
|
||||
*/
|
||||
getAvailableProviders(): string[] {
|
||||
const available: string[] = []
|
||||
|
||||
this.providerStatus.forEach((status, name) => {
|
||||
if (status.available && this.providers.has(name)) {
|
||||
available.push(name)
|
||||
}
|
||||
})
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for all providers
|
||||
*
|
||||
* @returns Array of provider status objects
|
||||
*/
|
||||
getAllProviderStatus(): ProviderStatus[] {
|
||||
return Array.from(this.providerStatus.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for a specific provider
|
||||
*
|
||||
* @param providerName - Provider name
|
||||
* @returns Provider status or undefined if not found
|
||||
*/
|
||||
getProviderStatus(providerName: string): ProviderStatus | undefined {
|
||||
return this.providerStatus.get(providerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the manager has been initialized
|
||||
*
|
||||
* @returns True if at least one provider was successfully initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of initialized providers
|
||||
*
|
||||
* @returns Number of providers in the map
|
||||
*/
|
||||
getProviderCount(): number {
|
||||
return this.providers.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of available (healthy) providers
|
||||
*
|
||||
* @returns Number of providers that are currently available
|
||||
*/
|
||||
getAvailableProviderCount(): number {
|
||||
return this.getAvailableProviders().length
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually retry initialization for a failed provider
|
||||
*
|
||||
* @param providerName - Provider name to retry
|
||||
* @returns True if initialization succeeded
|
||||
*/
|
||||
async retryProvider(providerName: string): Promise<boolean> {
|
||||
console.log(`OAuth2ProviderManager: Retrying initialization for ${providerName}`)
|
||||
|
||||
try {
|
||||
// Fetch well-known URIs again to get latest configuration
|
||||
const wellKnownUris = await this.fetchWellKnownUris()
|
||||
const providerUri = wellKnownUris.find((uri) => uri.provider === providerName)
|
||||
|
||||
if (!providerUri) {
|
||||
console.error(`OAuth2ProviderManager: Provider ${providerName} not found in OBP API`)
|
||||
return false
|
||||
}
|
||||
|
||||
const client = await this.factory.initializeProvider(providerUri)
|
||||
|
||||
if (client && client.isInitialized()) {
|
||||
this.providers.set(providerName, client)
|
||||
this.providerStatus.set(providerName, {
|
||||
name: providerName,
|
||||
available: true,
|
||||
lastChecked: new Date()
|
||||
})
|
||||
console.log(`OAuth2ProviderManager: OK ${providerName} retry successful`)
|
||||
return true
|
||||
} else {
|
||||
console.error(`OAuth2ProviderManager: ERROR ${providerName} retry failed`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`OAuth2ProviderManager: Error retrying ${providerName}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,74 +26,97 @@
|
||||
*/
|
||||
|
||||
import { Service } from 'typedi'
|
||||
import { DEFAULT_OBP_API_VERSION } from '../../shared-constants'
|
||||
import {
|
||||
Version,
|
||||
API,
|
||||
get,
|
||||
create,
|
||||
update,
|
||||
discard,
|
||||
GetAny,
|
||||
CreateAny,
|
||||
UpdateAny,
|
||||
DiscardAny,
|
||||
Any,
|
||||
} from 'obp-typescript'
|
||||
import type { APIClientConfig, OAuthConfig } from 'obp-typescript'
|
||||
import { OAuth } from 'obp-typescript'
|
||||
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
|
||||
|
||||
// Custom error class to preserve HTTP status codes
|
||||
class OBPAPIError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.name = 'OBPAPIError'
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth2 Bearer token configuration
|
||||
interface OAuth2Config {
|
||||
accessToken: string
|
||||
tokenType: string
|
||||
}
|
||||
|
||||
// API Client configuration for OAuth2
|
||||
interface APIClientConfig {
|
||||
baseUri: string
|
||||
version: string
|
||||
oauth2?: OAuth2Config
|
||||
}
|
||||
|
||||
@Service()
|
||||
/**
|
||||
* OBPClientService provides methods for interacting with the Open Bank Project API.
|
||||
*
|
||||
* This service handles API communication with OBP, including OAuth authentication,
|
||||
* making HTTP requests (GET, POST, PUT, DELETE), and managing API configurations.
|
||||
*
|
||||
*
|
||||
* This service handles API communication with OBP using OAuth2 Bearer token authentication,
|
||||
* making HTTP requests (GET, POST, PUT, DELETE).
|
||||
*
|
||||
* @class OBPClientService
|
||||
*
|
||||
* @property {OAuthConfig} oauthConfig - OAuth configuration for authentication
|
||||
*
|
||||
* @property {APIClientConfig} clientConfig - API client configuration
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const obpService = new OBPClientService();
|
||||
* const response = await obpService.get('/banks', clientConfig);
|
||||
* const response = await obpService.get('/obp/v5.1.0/banks', sessionConfig);
|
||||
*/
|
||||
export default class OBPClientService {
|
||||
private oauthConfig: OAuthConfig
|
||||
private clientConfig: APIClientConfig
|
||||
|
||||
constructor() {
|
||||
if (!process.env.VITE_OBP_CONSUMER_KEY) throw new Error('VITE_OBP_CONSUMER_KEY is not set')
|
||||
if (!process.env.VITE_OBP_CONSUMER_SECRET) throw new Error('VITE_OBP_CONSUMER_SECRET is not set')
|
||||
if (!process.env.VITE_OBP_REDIRECT_URL) throw new Error('VITE_OBP_REDIRECT_URL is not set')
|
||||
if (!process.env.VITE_OBP_API_HOST) throw new Error('VITE_OBP_API_HOST is not set')
|
||||
this.oauthConfig = {
|
||||
consumerKey: process.env.VITE_OBP_CONSUMER_KEY!,
|
||||
consumerSecret: process.env.VITE_OBP_CONSUMER_SECRET!,
|
||||
redirectUrl: process.env.VITE_OBP_REDIRECT_URL!
|
||||
}
|
||||
|
||||
// Always use v5.1.0 for application infrastructure - stable and debuggable
|
||||
this.clientConfig = {
|
||||
baseUri: process.env.VITE_OBP_API_HOST!,
|
||||
version: (process.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION) as Version,
|
||||
oauthConfig: this.oauthConfig
|
||||
version: DEFAULT_OBP_API_VERSION
|
||||
}
|
||||
|
||||
}
|
||||
async get(path: string, clientConfig: any): Promise<any> {
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
return await get<API.Any>(config, Any)(GetAny)(path)
|
||||
|
||||
// If no config or no access token, make unauthenticated request
|
||||
if (!config || !config.oauth2?.accessToken) {
|
||||
return await this.getWithoutAuth(path)
|
||||
}
|
||||
|
||||
return await this.getWithBearer(path, config.oauth2.accessToken)
|
||||
}
|
||||
|
||||
async create(path: string, body: any, clientConfig: any): Promise<any> {
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
return await create<API.Any>(config, Any)(CreateAny)(path)(body)
|
||||
|
||||
if (!config || !config.oauth2?.accessToken) {
|
||||
throw new Error('Authentication required for creating resources.')
|
||||
}
|
||||
|
||||
return await this.createWithBearer(path, body, config.oauth2.accessToken)
|
||||
}
|
||||
|
||||
async update(path: string, body: any, clientConfig: any): Promise<any> {
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
return await update<API.Any>(config, Any)(UpdateAny)(path)(body)
|
||||
|
||||
if (!config || !config.oauth2?.accessToken) {
|
||||
throw new Error('Authentication required for updating resources.')
|
||||
}
|
||||
|
||||
return await this.updateWithBearer(path, body, config.oauth2.accessToken)
|
||||
}
|
||||
|
||||
async discard(path: string, clientConfig: any): Promise<any> {
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
return await discard<API.Any>(config, Any)(DiscardAny)(path)
|
||||
|
||||
if (!config || !config.oauth2?.accessToken) {
|
||||
throw new Error('Authentication required for deleting resources.')
|
||||
}
|
||||
|
||||
return await this.discardWithBearer(path, config.oauth2.accessToken)
|
||||
}
|
||||
private getSessionConfig(clientConfig: APIClientConfig): APIClientConfig {
|
||||
return clientConfig || this.clientConfig
|
||||
@ -107,68 +130,196 @@ export default class OBPClientService {
|
||||
return this.clientConfig
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates an OAuth1 authentication header for a given API request. I.e. to use in the Authorization header.
|
||||
* Currently used for boostrapping the newer 'obp-api-typescript' SDK.
|
||||
*
|
||||
* @param path - The API endpoint path to access i.e. /banks or /consents/IMPLICIT
|
||||
* NOTE: the path should not include the baseUri
|
||||
* @param method - The HTTP method to use (GET, POST, PUT, DELETE, etc.)
|
||||
* @param clientConfig - Configuration object containing the user's session data
|
||||
* @returns A Promise resolving to the OAuth authentication header string
|
||||
* @throws Error if OAuth configuration is missing or if access token is not available
|
||||
*
|
||||
* @remarks
|
||||
* This method requires that the user has already authenticated and the OAuth access token
|
||||
* is stored in the clientConfig. It uses OAuth1 for authentication, which may be replaced
|
||||
* with OAuth2 in future implementations.
|
||||
* Make a GET request without authentication (for public endpoints)
|
||||
*
|
||||
* @param path - The API endpoint path (e.g., /obp/v5.1.0/api/versions)
|
||||
* @returns Response data from the API
|
||||
*/
|
||||
async getOAuthHeader(path: string, method:string, clientConfig: any): Promise<string> {
|
||||
// This gets the OAuth1 header for the given path and method for the logged in user
|
||||
// We should probably transition to OAuth2
|
||||
console.log('Getting OAuth header for path:', path, 'method:', method)
|
||||
// OAuth1 access token stored in the clientConfig
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
if (!config.oauthConfig) {
|
||||
throw new Error('OAuth configuration is missing')
|
||||
}
|
||||
if(!config.oauthConfig.accessToken) {
|
||||
throw new Error('Access token is missing, OAuth headers trying to be retrieved before login')
|
||||
}
|
||||
private async getWithoutAuth(path: string): Promise<any> {
|
||||
// Ensure proper slash handling between base URI and path
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
const url = `${this.clientConfig.baseUri}${normalizedPath}`
|
||||
console.log('OBPClientService: GET request without authentication to:', url)
|
||||
|
||||
const oauthInstance = new OAuth(config.oauthConfig).get()
|
||||
|
||||
// Use the OAuth1 instance to get the header
|
||||
const url = `${config.baseUri}${path}`
|
||||
const authHeader = oauthInstance.authHeader(url, config.oauthConfig.accessToken.key, config.oauthConfig.accessToken.secret, method)
|
||||
return authHeader
|
||||
}
|
||||
|
||||
async getDirectLoginToken(): Promise<string> {
|
||||
// Hilariously insecure, should be replaced with an OAuth 2 flow as soon as possible
|
||||
|
||||
const consumerKey = this.oauthConfig.consumerKey
|
||||
const username = process.env.VITE_OBP_DIRECT_LOGIN_USERNAME
|
||||
const password = process.env.VITE_OBP_DIRECT_LOGIN_PASSWORD
|
||||
|
||||
const authHeader = `DirectLogin username="${username}",password="${password}",consumer_key="${consumerKey}"`
|
||||
// Get token from OBP
|
||||
const tokenResponse = await fetch(`${this.clientConfig.baseUri}/my/logins/direct`, {
|
||||
method: 'POST',
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Failed to get direct login token: ${tokenResponse.statusText} ${await tokenResponse.text()}`)
|
||||
}
|
||||
|
||||
const token = await tokenResponse.json()
|
||||
return token.token
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
// 401 errors are expected when user is not authenticated
|
||||
if (response.status === 401) {
|
||||
console.log(`[OBPClientService] 401 Unauthorized: ${url} (authentication required)`)
|
||||
} else {
|
||||
console.error('[OBPClientService] GET request failed:', response.status, errorText)
|
||||
}
|
||||
throw new OBPAPIError(response.status, errorText)
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
// Log count instead of full data to reduce log noise
|
||||
if (
|
||||
responseData &&
|
||||
responseData.scanned_api_versions &&
|
||||
Array.isArray(responseData.scanned_api_versions)
|
||||
) {
|
||||
console.log(
|
||||
`OBPClientService: Response data: ${responseData.scanned_api_versions.length} scanned_api_versions`
|
||||
)
|
||||
} else {
|
||||
console.log('OBPClientService: Response data received:', typeof responseData)
|
||||
}
|
||||
return responseData
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request with OAuth2 Bearer token authentication
|
||||
*
|
||||
* @param path - The API endpoint path (e.g., /obp/v5.1.0/banks)
|
||||
* @param accessToken - OAuth2 access token
|
||||
* @returns Response data from the API
|
||||
*/
|
||||
private async getWithBearer(path: string, accessToken: string): Promise<any> {
|
||||
// Ensure proper slash handling between base URI and path
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
const url = `${this.clientConfig.baseUri}${normalizedPath}`
|
||||
console.log('OBPClientService: GET request with Bearer token to:', url)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
// 401 errors indicate token expiration or invalid token
|
||||
if (response.status === 401) {
|
||||
console.warn(
|
||||
`[OBPClientService] 401 Unauthorized with Bearer token: ${url} (token may be expired)`
|
||||
)
|
||||
} else {
|
||||
console.error(
|
||||
'[OBPClientService] GET request with Bearer failed:',
|
||||
response.status,
|
||||
errorText
|
||||
)
|
||||
}
|
||||
throw new OBPAPIError(response.status, errorText)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request with OAuth2 Bearer token authentication
|
||||
*
|
||||
* @param path - The API endpoint path
|
||||
* @param body - Request body data
|
||||
* @param accessToken - OAuth2 access token
|
||||
* @returns Response data from the API
|
||||
*/
|
||||
private async createWithBearer(path: string, body: any, accessToken: string): Promise<any> {
|
||||
// Ensure proper slash handling between base URI and path
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
const url = `${this.clientConfig.baseUri}${normalizedPath}`
|
||||
console.log('OBPClientService: POST request with Bearer token to:', url)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
if (response.status === 401) {
|
||||
console.warn(`[OBPClientService] 401 Unauthorized on POST: ${url} (token may be expired)`)
|
||||
} else {
|
||||
console.error('[OBPClientService] POST request failed:', response.status, errorText)
|
||||
}
|
||||
throw new OBPAPIError(response.status, errorText)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request with OAuth2 Bearer token authentication
|
||||
*
|
||||
* @param path - The API endpoint path
|
||||
* @param body - Request body data
|
||||
* @param accessToken - OAuth2 access token
|
||||
* @returns Response data from the API
|
||||
*/
|
||||
private async updateWithBearer(path: string, body: any, accessToken: string): Promise<any> {
|
||||
// Ensure proper slash handling between base URI and path
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
const url = `${this.clientConfig.baseUri}${normalizedPath}`
|
||||
console.log('OBPClientService: PUT request with Bearer token to:', url)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
if (response.status === 401) {
|
||||
console.warn(`[OBPClientService] 401 Unauthorized on PUT: ${url} (token may be expired)`)
|
||||
} else {
|
||||
console.error('[OBPClientService] PUT request failed:', response.status, errorText)
|
||||
}
|
||||
throw new OBPAPIError(response.status, errorText)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request with OAuth2 Bearer token authentication
|
||||
*
|
||||
* @param path - The API endpoint path
|
||||
* @param accessToken - OAuth2 access token
|
||||
* @returns Response data from the API
|
||||
*/
|
||||
private async discardWithBearer(path: string, accessToken: string): Promise<any> {
|
||||
// Ensure proper slash handling between base URI and path
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
const url = `${this.clientConfig.baseUri}${normalizedPath}`
|
||||
console.log('OBPClientService: DELETE request with Bearer token to:', url)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
if (response.status === 401) {
|
||||
console.warn(`[OBPClientService] 401 Unauthorized on DELETE: ${url} (token may be expired)`)
|
||||
} else {
|
||||
console.error('[OBPClientService] DELETE request failed:', response.status, errorText)
|
||||
}
|
||||
throw new OBPAPIError(response.status, errorText)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +1,35 @@
|
||||
import { Service } from 'typedi'
|
||||
import { Configuration, ConsentApi, ConsentsIMPLICITBody1, ConsumerConsentrequestsBody, InlineResponse20151, InlineResponse2017, ErrorUserNotLoggedIn} from 'obp-api-typescript'
|
||||
import OBPClientService from './OBPClientService'
|
||||
import OauthInjectedService from './OauthInjectedService'
|
||||
import { Service, Container } from 'typedi'
|
||||
import {
|
||||
Configuration,
|
||||
ConsentApi,
|
||||
ConsentsIMPLICITBody1,
|
||||
ConsumerConsentrequestsBody,
|
||||
InlineResponse20151,
|
||||
InlineResponse2017,
|
||||
ErrorUserNotLoggedIn
|
||||
} from 'obp-api-typescript'
|
||||
import OBPClientService from './OBPClientService.js'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { Session } from 'express-session'
|
||||
import { DEFAULT_OBP_API_VERSION } from '../../shared-constants'
|
||||
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
|
||||
|
||||
@Service()
|
||||
/**
|
||||
* Service for managing Open Banking Project (OBP) consents functionality.
|
||||
* This class handles the creation of consent clients, consent creation, and retrieval
|
||||
* based on user sessions.
|
||||
*
|
||||
*
|
||||
* @class OBPConsentsService
|
||||
* @description Provides methods to interact with OBP Consent APIs, allowing the application
|
||||
* @description Provides methods to interact with OBP Consent APIs, allowing the application
|
||||
* to create and manage consents that permit access to user accounts via API Explorer II.
|
||||
*
|
||||
*
|
||||
* Key functionalities:
|
||||
* - Creating consent API clients based on user sessions
|
||||
* - Creating implicit consents for access delegation i.e. for opey
|
||||
* - Retrieving existing consents by ID
|
||||
* - Finding consents associated with specific consumers (e.g., Opey)
|
||||
*
|
||||
*
|
||||
* @requires OBPClientService
|
||||
* @requires Configuration
|
||||
* @requires ConsentApi
|
||||
@ -31,263 +38,270 @@ import { DEFAULT_OBP_API_VERSION } from '../../shared-constants'
|
||||
* @requires axios
|
||||
*/
|
||||
export default class OBPConsentsService {
|
||||
private consentApiConfig: Configuration
|
||||
public obpClientService: OBPClientService // This needs to be changed once we migrate away from the old OBP SDK
|
||||
constructor() {
|
||||
this.obpClientService = new OBPClientService()
|
||||
}
|
||||
/**
|
||||
* Function to create a OBP Consents API client
|
||||
* at differnt times in the consent flow we will either need to be acting as the logged in user, or the API Explorer II consumer
|
||||
*
|
||||
* @param path
|
||||
* @param method
|
||||
* @param as_client
|
||||
* @returns
|
||||
*/
|
||||
async createUserConsentsClient(session: any, path: string, method: string): Promise<ConsentApi | undefined> {
|
||||
// This function creates a Consents API client as the logged in user, using their OAuth1 headers
|
||||
private consentApiConfig: Configuration
|
||||
public obpClientService: OBPClientService
|
||||
|
||||
// Check if the user is logged in
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauthConfig.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
constructor() {
|
||||
// Explicitly get OBPClientService from the container to avoid injection issues
|
||||
this.obpClientService = Container.get(OBPClientService)
|
||||
}
|
||||
/**
|
||||
* Function to create a OBP Consents API client
|
||||
* at differnt times in the consent flow we will either need to be acting as the logged in user, or the API Explorer II consumer
|
||||
*
|
||||
* @param path
|
||||
* @param method
|
||||
* @param as_client
|
||||
* @returns
|
||||
*/
|
||||
async createUserConsentsClient(
|
||||
session: any,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<ConsentApi | undefined> {
|
||||
// This function creates a Consents API client as the logged in user, using their OAuth2 Bearer token
|
||||
|
||||
// Get the OAuth1 headers for the logged in user to use in the API call
|
||||
const oauth1Headers = await this.obpClientService.getOAuthHeader(path, method, clientConfig)
|
||||
|
||||
// Set config for the Consents API client from the new typescript SDK
|
||||
this.consentApiConfig = new Configuration({
|
||||
basePath: this.obpClientService.getOBPClientConfig().baseUri,
|
||||
apiKey: oauth1Headers
|
||||
})
|
||||
|
||||
// Create the Consents API client
|
||||
return new ConsentApi(this.consentApiConfig)
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Could not create Consents API client for logged in user, ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async createConsent(session: Session): Promise<InlineResponse2017 | undefined> {
|
||||
// Create a consent as the logged in user, using Opey's consumerID
|
||||
// I.e. give permission to Opey to do anything on behalf of the logged in user
|
||||
|
||||
// Get the Consents API client from the OBP SDK
|
||||
const client = await this.createUserConsentsClient(session, `/obp/${process.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION}/my/consents/IMPLICIT`, 'POST')
|
||||
if (!client) {
|
||||
throw new Error('Could not create Consents API client')
|
||||
}
|
||||
|
||||
// get consumer ID for Opey
|
||||
const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID
|
||||
if (!opeyConsumerID) {
|
||||
throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID')
|
||||
}
|
||||
|
||||
// Format date for OBP, this is a mess
|
||||
const today = new Date().toISOString().split('.')[0] + 'Z' // get rid of milliseconds as OBP doesn't like them;
|
||||
|
||||
const body: ConsentsIMPLICITBody1 = {
|
||||
everything: true,
|
||||
entitlements: [],
|
||||
consumer_id: opeyConsumerID,
|
||||
views: [],
|
||||
valid_from: today,
|
||||
time_to_live: 3600,
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const consentResponse = await client.oBPv510CreateConsentImplicit(body, {headers: {'Content-Type': 'application/json',}})
|
||||
|
||||
// Save the consent in the session
|
||||
session['opeyConfig'] = {
|
||||
authConfig: {
|
||||
obpConsent: consentResponse.data
|
||||
}
|
||||
}
|
||||
|
||||
return consentResponse.data
|
||||
|
||||
} catch (error: any) {
|
||||
console.log('error', error)
|
||||
if (error.response && error.response.data) {
|
||||
const errorData = error.response.data
|
||||
if (errorData.message) {
|
||||
throw new Error(`OBP Error: ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not create consent, ${error}`)
|
||||
}
|
||||
|
||||
// Check if the user is logged in
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauth2?.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the OAuth2 Bearer token for the logged in user
|
||||
const bearerToken = `Bearer ${clientConfig.oauth2.accessToken}`
|
||||
|
||||
// Set config for the Consents API client from the new typescript SDK
|
||||
this.consentApiConfig = new Configuration({
|
||||
basePath: this.obpClientService.getOBPClientConfig().baseUri,
|
||||
apiKey: bearerToken
|
||||
})
|
||||
|
||||
/**
|
||||
* Retrieves a consent by consent ID for the current user.
|
||||
*
|
||||
* This method fetches a specific consent using its ID and updates the session
|
||||
* with the retrieved consent data under the opeyConfig property.
|
||||
*
|
||||
* @param session - The user's session object, which must contain clientConfig with valid OAuth tokens
|
||||
* @param consentId - The unique identifier of the consent to retrieve
|
||||
* @returns Promise resolving to the consent data retrieved from OBP API
|
||||
* @throws Error if the user is not logged in (no valid clientConfig or accessToken)
|
||||
* @throws Error if the request to get the consent fails
|
||||
*/
|
||||
async getConsentByConsentId(session: Session, consentId: string): Promise<any> {
|
||||
// Create the Consents API client
|
||||
return new ConsentApi(this.consentApiConfig)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Could not create Consents API client for logged in user, ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauthConfig.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
async createConsent(session: Session): Promise<InlineResponse2017 | undefined> {
|
||||
// Create a consent as the logged in user, using Opey's consumerID
|
||||
// I.e. give permission to Opey to do anything on behalf of the logged in user
|
||||
|
||||
try {
|
||||
const response = await this._sendOBPRequest(`/obp/${process.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION}/user/current/consents/${consentId}`, 'GET', clientConfig)
|
||||
|
||||
session['opeyConfig'] = {
|
||||
authConfig: {
|
||||
obpConsent: response.data
|
||||
}
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Consent with ID ${consentId} not retrieved: ${error}`)
|
||||
}
|
||||
// Get the Consents API client from the OBP SDK
|
||||
// Always use v5.1.0 for application infrastructure - stable and debuggable
|
||||
const client = await this.createUserConsentsClient(
|
||||
session,
|
||||
`/obp/${DEFAULT_OBP_API_VERSION}/my/consents/IMPLICIT`,
|
||||
'POST'
|
||||
)
|
||||
if (!client) {
|
||||
throw new Error('Could not create Consents API client')
|
||||
}
|
||||
|
||||
async checkConsentExpired(consent: any): Promise<boolean> { //DEBUG
|
||||
// Check if the consent is expired
|
||||
// Decode the JWT and check the exp field
|
||||
|
||||
const exp = consent.jwt_payload.exp
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return exp < now
|
||||
// get consumer ID for Opey
|
||||
const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID
|
||||
if (!opeyConsumerID) {
|
||||
throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID')
|
||||
}
|
||||
|
||||
async getExistingOpeyConsentId(session: Session): Promise<any> {
|
||||
// Get Consents for the current user, check if any of them are for Opey
|
||||
// If so, return the consent
|
||||
|
||||
// I.e. this is done by iterating and finding the consent with the correct consumer ID
|
||||
|
||||
// Get the Consents API client from the OBP SDK
|
||||
// The OBP SDK is messed up here, so we'll need to use Fetch until the SWAGGER WILL ACTUALLY WORK
|
||||
// const client = await this.createUserConsentsClient(session, `/obp/${process.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION}/my/consents/IMPLICIT`, 'POST')
|
||||
// if (!client) {
|
||||
// throw new Error('Could not create Consents API client')
|
||||
// }
|
||||
|
||||
|
||||
// Function to send an OBP request using the logged in user's OAuth1 headers
|
||||
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauthConfig.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
// We need to change this back to consent infos once OBP shows 'EXPIRED' in the status
|
||||
// Right now we have to check the JWT ourselves
|
||||
const consentInfosPath = `/obp/${process.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION}/my/consents`
|
||||
//const consentInfosPath = `/obp/${process.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION}/my/consent-infos`
|
||||
|
||||
let opeyConsentId: string | null = null
|
||||
try {
|
||||
const response = await this._sendOBPRequest(consentInfosPath, 'GET', clientConfig)
|
||||
const consents = response.data.consents
|
||||
|
||||
const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID
|
||||
if (!opeyConsumerID) {
|
||||
throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID')
|
||||
}
|
||||
|
||||
for (const consent of consents) {
|
||||
console.log(`consent_consumer_id: ${consent.consumer_id}, opey_consumer_id: ${opeyConsumerID}\n consent_status: ${consent.status}`) //DEBUG
|
||||
if (consent.consumer_id === opeyConsumerID && consent.status === 'ACCEPTED') {
|
||||
// Check if the consent is expired
|
||||
const isExpired = await this.checkConsentExpired(consent)
|
||||
if (isExpired) {
|
||||
console.log('getExistingConsent: Consent is expired')
|
||||
continue
|
||||
}
|
||||
opeyConsentId = consent.consent_id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!opeyConsentId) {
|
||||
console.log('getExistingConsent: No consent found for Opey for current user')
|
||||
return null
|
||||
} else {
|
||||
return opeyConsentId
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Could not get existing consent info, ${error}`)
|
||||
}
|
||||
// Format date for OBP, this is a mess
|
||||
const today = new Date().toISOString().split('.')[0] + 'Z' // get rid of milliseconds as OBP doesn't like them;
|
||||
|
||||
const body: ConsentsIMPLICITBody1 = {
|
||||
everything: true,
|
||||
entitlements: [],
|
||||
consumer_id: opeyConsumerID,
|
||||
views: [],
|
||||
valid_from: today,
|
||||
time_to_live: 3600
|
||||
}
|
||||
|
||||
async _sendOBPRequest (path: string, method: string, clientConfig: any) {
|
||||
const oauth1Headers = await this.obpClientService.getOAuthHeader(path, method, clientConfig)
|
||||
const config = {
|
||||
headers: {
|
||||
'Authorization': oauth1Headers,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
try {
|
||||
const consentResponse = await client.oBPv510CreateConsentImplicit(body, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
// Save the consent in the session
|
||||
session['opeyConfig'] = {
|
||||
authConfig: {
|
||||
obpConsent: consentResponse.data
|
||||
}
|
||||
return axios.get(`${clientConfig.baseUri}${path}`, config)
|
||||
}
|
||||
|
||||
return consentResponse.data
|
||||
} catch (error: any) {
|
||||
console.log('error', error)
|
||||
if (error.response && error.response.data) {
|
||||
const errorData = error.response.data
|
||||
if (errorData.message) {
|
||||
throw new Error(`OBP Error: ${JSON.stringify(errorData)}`)
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not create consent, ${error}`)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a consent by consent ID for the current user.
|
||||
*
|
||||
* This method fetches a specific consent using its ID and updates the session
|
||||
* with the retrieved consent data under the opeyConfig property.
|
||||
*
|
||||
* @param session - The user's session object, which must contain clientConfig with valid OAuth tokens
|
||||
* @param consentId - The unique identifier of the consent to retrieve
|
||||
* @returns Promise resolving to the consent data retrieved from OBP API
|
||||
* @throws Error if the user is not logged in (no valid clientConfig or accessToken)
|
||||
* @throws Error if the request to get the consent fails
|
||||
*/
|
||||
async getConsentByConsentId(session: Session, consentId: string): Promise<any> {
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauth2?.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
try {
|
||||
// Always use v5.1.0 for application infrastructure - stable and debuggable
|
||||
const response = await this._sendOBPRequest(
|
||||
`/obp/${DEFAULT_OBP_API_VERSION}/user/current/consents/${consentId}`,
|
||||
'GET',
|
||||
clientConfig
|
||||
)
|
||||
|
||||
session['opeyConfig'] = {
|
||||
authConfig: {
|
||||
obpConsent: response.data
|
||||
}
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Consent with ID ${consentId} not retrieved: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async checkConsentExpired(consent: any): Promise<boolean> {
|
||||
//DEBUG
|
||||
// Check if the consent is expired
|
||||
// Decode the JWT and check the exp field
|
||||
|
||||
// Probably not needed, but will keep for later
|
||||
const exp = consent.jwt_payload.exp
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return exp < now
|
||||
}
|
||||
|
||||
// async createConsentRequest(): Promise<InlineResponse20151 | undefined> {
|
||||
// // this should be done as API Explorer II, so set client on instance for that
|
||||
// const client = await this.createConsentClient('API_Explorer')
|
||||
// if (!client) {
|
||||
// throw new Error('Could not create Consents API client')
|
||||
// }
|
||||
// // Create a consent request
|
||||
// // Parameters in body to be changed later to fit our needs, or match parameters given to this function
|
||||
// try {
|
||||
// const consentRequestResponse = await client.oBPv500CreateConsentRequest(
|
||||
// {
|
||||
// accountAccess: [],
|
||||
// everything: false,
|
||||
// entitlements: [],
|
||||
// consumerId: '',
|
||||
// } as unknown as ConsumerConsentrequestsBody,
|
||||
// {
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
|
||||
// return consentRequestResponse.data
|
||||
// } catch (error) {
|
||||
// console.error(error)
|
||||
// throw new Error(`Could not create consent request, ${error}`)
|
||||
// }
|
||||
|
||||
async getExistingOpeyConsentId(session: Session): Promise<any> {
|
||||
// Get Consents for the current user, check if any of them are for Opey
|
||||
// If so, return the consent
|
||||
|
||||
// I.e. this is done by iterating and finding the consent with the correct consumer ID
|
||||
|
||||
// Get the Consents API client from the OBP SDK
|
||||
// The OBP SDK is messed up here, so we'll need to use Fetch until the SWAGGER WILL ACTUALLY WORK
|
||||
// const client = await this.createUserConsentsClient(session, `/obp/${process.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION}/my/consents/IMPLICIT`, 'POST')
|
||||
// if (!client) {
|
||||
// throw new Error('Could not create Consents API client')
|
||||
// }
|
||||
}
|
||||
|
||||
// Function to send an OBP request using the logged in user's OAuth2 Bearer token
|
||||
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauth2?.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
// We need to change this back to consent infos once OBP shows 'EXPIRED' in the status
|
||||
// Right now we have to check the JWT ourselves
|
||||
// Always use v5.1.0 for application infrastructure - stable and debuggable
|
||||
const consentInfosPath = `/obp/${DEFAULT_OBP_API_VERSION}/my/consents`
|
||||
//const consentInfosPath = `/obp/${DEFAULT_OBP_API_VERSION}/my/consent-infos`
|
||||
|
||||
let opeyConsentId: string | null = null
|
||||
try {
|
||||
const response = await this._sendOBPRequest(consentInfosPath, 'GET', clientConfig)
|
||||
const consents = response.data.consents
|
||||
|
||||
const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID
|
||||
if (!opeyConsumerID) {
|
||||
throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID')
|
||||
}
|
||||
|
||||
for (const consent of consents) {
|
||||
console.log(
|
||||
`consent_consumer_id: ${consent.consumer_id}, opey_consumer_id: ${opeyConsumerID}\n consent_status: ${consent.status}`
|
||||
) //DEBUG
|
||||
if (consent.consumer_id === opeyConsumerID && consent.status === 'ACCEPTED') {
|
||||
// Check if the consent is expired
|
||||
const isExpired = await this.checkConsentExpired(consent)
|
||||
if (isExpired) {
|
||||
console.log('getExistingConsent: Consent is expired')
|
||||
continue
|
||||
}
|
||||
opeyConsentId = consent.consent_id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!opeyConsentId) {
|
||||
console.log('getExistingConsent: No consent found for Opey for current user')
|
||||
return null
|
||||
} else {
|
||||
return opeyConsentId
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Could not get existing consent info, ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async _sendOBPRequest(path: string, method: string, clientConfig: any) {
|
||||
// Get OAuth2 Bearer token from clientConfig
|
||||
if (!clientConfig.oauth2?.accessToken) {
|
||||
throw new Error('OAuth2 access token not found in clientConfig')
|
||||
}
|
||||
|
||||
const bearerToken = `Bearer ${clientConfig.oauth2.accessToken}`
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: bearerToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
return axios.get(`${clientConfig.baseUri}${path}`, config)
|
||||
}
|
||||
|
||||
// Probably not needed, but will keep for later
|
||||
|
||||
// async createConsentRequest(): Promise<InlineResponse20151 | undefined> {
|
||||
// // this should be done as API Explorer II, so set client on instance for that
|
||||
// const client = await this.createConsentClient('API_Explorer')
|
||||
// if (!client) {
|
||||
// throw new Error('Could not create Consents API client')
|
||||
// }
|
||||
// // Create a consent request
|
||||
// // Parameters in body to be changed later to fit our needs, or match parameters given to this function
|
||||
// try {
|
||||
// const consentRequestResponse = await client.oBPv500CreateConsentRequest(
|
||||
// {
|
||||
// accountAccess: [],
|
||||
// everything: false,
|
||||
// entitlements: [],
|
||||
// consumerId: '',
|
||||
// } as unknown as ConsumerConsentrequestsBody,
|
||||
// {
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
|
||||
// return consentRequestResponse.data
|
||||
// } catch (error) {
|
||||
// console.error(error)
|
||||
// throw new Error(`Could not create consent request, ${error}`)
|
||||
// }
|
||||
|
||||
// }
|
||||
}
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Service } from 'typedi'
|
||||
import oauth from 'oauth'
|
||||
|
||||
@Service()
|
||||
export default class OauthInjectedService {
|
||||
public requestTokenKey: string
|
||||
public requestTokenSecret: string
|
||||
private oauth: oauth.OAuth
|
||||
constructor() {
|
||||
const apiHost = process.env.VITE_OBP_API_HOST
|
||||
const consumerKey = process.env.VITE_OBP_CONSUMER_KEY
|
||||
const consumerSecret = process.env.VITE_OBP_CONSUMER_SECRET
|
||||
const redirectUrl = process.env.VITE_OBP_REDIRECT_URL
|
||||
this.oauth = new oauth.OAuth(
|
||||
apiHost + '/oauth/initiate',
|
||||
apiHost + '/oauth/token',
|
||||
consumerKey,
|
||||
consumerSecret,
|
||||
'1.0',
|
||||
redirectUrl,
|
||||
'HMAC-SHA1'
|
||||
)
|
||||
}
|
||||
|
||||
getConsumer(): oauth.OAuth {
|
||||
return this.oauth
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Service } from 'typedi'
|
||||
import { UserInput, StreamInput, OpeyConfig, ConsentRequestResponse } from '../schema/OpeySchema'
|
||||
import OBPClientService from './OBPClientService'
|
||||
import { UserInput, StreamInput, OpeyConfig, ConsentRequestResponse } from '../schema/OpeySchema.js'
|
||||
import OBPClientService from './OBPClientService.js'
|
||||
|
||||
@Service()
|
||||
export default class OpeyClientService {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import OBPClientService from "../services/OBPClientService";
|
||||
import OBPClientService from "../services/OBPClientService.js";
|
||||
import { before } from 'node:test';
|
||||
|
||||
describe('OBPClientService.getOauthHeaders', () => {
|
||||
|
||||
@ -24,8 +24,8 @@ vi.mock('../services/OBPClientService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
import OBPConsentsService from '../services/OBPConsentsService';
|
||||
import OpeyClientService from '../services/OpeyClientService';
|
||||
import OBPConsentsService from '../services/OBPConsentsService.js';
|
||||
import OpeyClientService from '../services/OpeyClientService.js';
|
||||
|
||||
describe('OBPConsentsService.createUserConsentsClient', () => {
|
||||
let obpConsentsService: OBPConsentsService;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
import OpeyClientService from '../services/OpeyClientService';
|
||||
import { OpeyConfig, UserInput } from '../schema/OpeySchema';
|
||||
import OpeyClientService from '../services/OpeyClientService.js';
|
||||
import { OpeyConfig, UserInput } from '../schema/OpeySchema.js';
|
||||
|
||||
describe('getStatus', async () => {
|
||||
let opeyClientService: OpeyClientService;
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
import { OpeyController } from "../controllers/OpeyIIController";
|
||||
import OpeyClientService from '../services/OpeyClientService';
|
||||
import OBPClientService from '../services/OBPClientService';
|
||||
import OBPConsentsService from '../services/OBPConsentsService';
|
||||
import Stream, { Readable } from 'stream';
|
||||
import { Request, Response } from 'express';
|
||||
import httpMocks from 'node-mocks-http'
|
||||
import { EventEmitter } from 'events';
|
||||
import { InlineResponse2017 } from 'obp-api-typescript';
|
||||
|
||||
vi.mock("../../server/services/OpeyClientService", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getOpeyStatus: vi.fn(async () => {
|
||||
return {status: 'running'}
|
||||
}),
|
||||
stream: vi.fn(async () => {
|
||||
const readableStream = new Stream.Readable();
|
||||
|
||||
for (let i=0; i<10; i++) {
|
||||
readableStream.push(`Chunk ${i}`);
|
||||
}
|
||||
|
||||
return readableStream as NodeJS.ReadableStream;
|
||||
}),
|
||||
invoke: vi.fn(async () => {
|
||||
return {
|
||||
content: 'Hi this is Opey',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('OpeyController', () => {
|
||||
let MockOpeyClientService: OpeyClientService
|
||||
let opeyController: OpeyController
|
||||
// Mock the OpeyClientService class
|
||||
|
||||
const { mockClear } = getMockRes()
|
||||
beforeEach(() => {
|
||||
mockClear()
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
vi.clearAllMocks();
|
||||
MockOpeyClientService = {
|
||||
authConfig: {},
|
||||
opeyConfig: {},
|
||||
getOpeyStatus: vi.fn(async () => {
|
||||
return {status: 'running'}
|
||||
}),
|
||||
stream: vi.fn(async () => {
|
||||
|
||||
const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!"
|
||||
// Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens)
|
||||
const mockMessageChunks = mockAsisstantMessage.split(" ")
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
// Don't add whitespace to the last chunk
|
||||
if (i === mockMessageChunks.length - 1 ) {
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]}`
|
||||
break
|
||||
}
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]} `
|
||||
}
|
||||
|
||||
// Return the fake the token stream
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`));
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}),
|
||||
invoke: vi.fn(async () => {
|
||||
return {
|
||||
content: 'Hi this is Opey',
|
||||
}
|
||||
})
|
||||
} as unknown as OpeyClientService
|
||||
|
||||
// Instantiate OpeyController with the mocked OpeyClientService
|
||||
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService)
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('getStatus', async () => {
|
||||
const res = httpMocks.createResponse();
|
||||
|
||||
await opeyController.getStatus(res)
|
||||
expect(MockOpeyClientService.getOpeyStatus).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
})
|
||||
|
||||
|
||||
it('streamOpey', async () => {
|
||||
|
||||
|
||||
const _eventEmitter = new EventEmitter();
|
||||
_eventEmitter.addListener('data', () => {
|
||||
console.log('Data received')
|
||||
})
|
||||
// The default event emitter does nothing, so replace
|
||||
const res = await httpMocks.createResponse({
|
||||
eventEmitter: EventEmitter,
|
||||
writableStream: Stream.Writable
|
||||
});
|
||||
|
||||
// Mock request and response objects to pass to express controller
|
||||
const req = {
|
||||
body: {
|
||||
message: 'Hello Opey',
|
||||
thread_id: '123',
|
||||
is_tool_call_approval: false
|
||||
}
|
||||
} as unknown as Request;
|
||||
|
||||
const response = await opeyController.streamOpey({}, req, res)
|
||||
|
||||
// Get the stream from the response
|
||||
const stream = response.body
|
||||
|
||||
|
||||
let chunks: any[] = [];
|
||||
try {
|
||||
|
||||
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
console.log('Stream complete');
|
||||
context.status = 'ready';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
|
||||
await expect(chunks.length).toBe(10);
|
||||
await expect(MockOpeyClientService.stream).toHaveBeenCalled();
|
||||
await expect(res).toBeDefined();
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('OpeyController consents', () => {
|
||||
let mockOBPClientService: OBPClientService
|
||||
|
||||
let opeyController: OpeyController
|
||||
|
||||
beforeAll(() => {
|
||||
|
||||
mockOBPClientService = {
|
||||
get: vi.fn(async () => {
|
||||
Promise.resolve({})
|
||||
})
|
||||
} as unknown as OBPClientService
|
||||
|
||||
const MockOpeyClientService = {
|
||||
authConfig: {},
|
||||
opeyConfig: {},
|
||||
getOpeyStatus: vi.fn(async () => {
|
||||
return {status: 'running'}
|
||||
}),
|
||||
stream: vi.fn(async () => {
|
||||
|
||||
async function * generator() {
|
||||
for (let i=0; i<10; i++) {
|
||||
yield `Chunk ${i}`;
|
||||
}
|
||||
}
|
||||
|
||||
const readableStream = Stream.Readable.from(generator());
|
||||
|
||||
return readableStream as NodeJS.ReadableStream;
|
||||
}),
|
||||
invoke: vi.fn(async () => {
|
||||
return {
|
||||
content: 'Hi this is Opey',
|
||||
}
|
||||
})
|
||||
} as unknown as OpeyClientService
|
||||
|
||||
const MockOBPConsentsService = {
|
||||
createConsent: vi.fn(async () => {
|
||||
return {
|
||||
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
|
||||
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
"status": "INITIATED",
|
||||
} as InlineResponse2017
|
||||
})
|
||||
} as unknown as OBPConsentsService
|
||||
|
||||
// Instantiate OpeyController with the mocked OpeyClientService
|
||||
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService, MockOBPConsentsService)
|
||||
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it('should return 200 and consent ID when consent is created at OBP', async () => {
|
||||
|
||||
|
||||
const req = getMockReq()
|
||||
const session = {}
|
||||
const { res } = getMockRes()
|
||||
await opeyController.getConsent(session, req, res)
|
||||
expect(res.status).toHaveBeenCalledWith(200)
|
||||
|
||||
// Obviously if you change the MockOBPConsentsService.createConsent mock implementation, you will need to change this test
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
|
||||
})
|
||||
|
||||
// Expect that the consent object was saved in the session
|
||||
expect(session).toHaveProperty('obpConsent')
|
||||
expect(session['obpConsent']).toHaveProperty('consent_id', "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")
|
||||
expect(session['obpConsent']).toHaveProperty('jwt', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
|
||||
expect(session['obpConsent']).toHaveProperty('status', "INITIATED")
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import app, { instance } from '../app';
|
||||
import app, { instance } from '../app.js';
|
||||
import request from 'supertest';
|
||||
import http from 'node:http';
|
||||
import { UserInput } from '../schema/OpeySchema';
|
||||
import { UserInput } from '../schema/OpeySchema.js';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
import { agent } from "superagent";
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
130
server/types/oauth2.ts
Normal file
130
server/types/oauth2.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Well-known URI from OBP API /obp/v[version]/well-known endpoint
|
||||
*/
|
||||
export interface WellKnownUri {
|
||||
provider: string // e.g., "obp-oidc", "keycloak"
|
||||
url: string // e.g., "http://localhost:9000/obp-oidc/.well-known/openid-configuration"
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from OBP API well-known endpoint
|
||||
*/
|
||||
export interface WellKnownResponse {
|
||||
well_known_uris: WellKnownUri[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration strategy
|
||||
*/
|
||||
export interface ProviderStrategy {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider status information
|
||||
*/
|
||||
export interface ProviderStatus {
|
||||
name: string
|
||||
available: boolean
|
||||
lastChecked: Date
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenID Connect Discovery Configuration
|
||||
* As defined in OpenID Connect Discovery 1.0
|
||||
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
*/
|
||||
export interface OIDCConfiguration {
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
userinfo_endpoint: string
|
||||
jwks_uri: string
|
||||
registration_endpoint?: string
|
||||
scopes_supported?: string[]
|
||||
response_types_supported?: string[]
|
||||
response_modes_supported?: string[]
|
||||
grant_types_supported?: string[]
|
||||
subject_types_supported?: string[]
|
||||
id_token_signing_alg_values_supported?: string[]
|
||||
token_endpoint_auth_methods_supported?: string[]
|
||||
claims_supported?: string[]
|
||||
code_challenge_methods_supported?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Token response from OAuth2 token endpoint
|
||||
*/
|
||||
export interface TokenResponse {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
idToken?: string
|
||||
tokenType: string
|
||||
expiresIn?: number
|
||||
scope?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User information from OIDC UserInfo endpoint
|
||||
*/
|
||||
export interface UserInfo {
|
||||
sub: string
|
||||
name?: string
|
||||
given_name?: string
|
||||
family_name?: string
|
||||
middle_name?: string
|
||||
nickname?: string
|
||||
preferred_username?: string
|
||||
profile?: string
|
||||
picture?: string
|
||||
website?: string
|
||||
email?: string
|
||||
email_verified?: boolean
|
||||
gender?: string
|
||||
birthdate?: string
|
||||
zoneinfo?: string
|
||||
locale?: string
|
||||
phone_number?: string
|
||||
phone_number_verified?: boolean
|
||||
address?: {
|
||||
formatted?: string
|
||||
street_address?: string
|
||||
locality?: string
|
||||
region?: string
|
||||
postal_code?: string
|
||||
country?: string
|
||||
}
|
||||
updated_at?: number
|
||||
[key: string]: any
|
||||
}
|
||||
140
server/utils/pkce.ts
Normal file
140
server/utils/pkce.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) utilities for OAuth2 authorization code flow
|
||||
*
|
||||
* PKCE is a security extension to OAuth 2.0 for public clients on mobile and browser-based apps.
|
||||
* It mitigates authorization code interception attacks by using a dynamically created
|
||||
* cryptographic random key called "code verifier" and its transformed value called
|
||||
* "code challenge".
|
||||
*
|
||||
* @see RFC 7636: https://datatracker.ietf.org/doc/html/rfc7636
|
||||
*/
|
||||
export class PKCEUtils {
|
||||
/**
|
||||
* Generates a cryptographically random code verifier
|
||||
*
|
||||
* The code verifier is a high-entropy cryptographic random string using the
|
||||
* unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
|
||||
* with a minimum length of 43 characters and a maximum length of 128 characters.
|
||||
*
|
||||
* @returns {string} Base64url-encoded random string (43 characters)
|
||||
*
|
||||
* @example
|
||||
* const verifier = PKCEUtils.generateCodeVerifier()
|
||||
* // Returns: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
*/
|
||||
static generateCodeVerifier(): string {
|
||||
// Generate 32 random bytes and encode as base64url (results in 43 characters)
|
||||
return crypto.randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a code challenge from a code verifier using SHA256
|
||||
*
|
||||
* The code challenge is the Base64url encoding of the SHA256 hash of the code verifier.
|
||||
* This is sent to the authorization server during the initial authorization request.
|
||||
*
|
||||
* @param {string} verifier - The code verifier to transform
|
||||
* @returns {string} Base64url-encoded SHA256 hash of the verifier
|
||||
*
|
||||
* @example
|
||||
* const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
* const challenge = PKCEUtils.generateCodeChallenge(verifier)
|
||||
* // Returns: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
*/
|
||||
static generateCodeChallenge(verifier: string): string {
|
||||
// Create SHA256 hash of the verifier and encode as base64url
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random state parameter
|
||||
*
|
||||
* The state parameter is used to maintain state between the authorization request
|
||||
* and the callback. It also serves as a CSRF token to prevent cross-site request forgery attacks.
|
||||
*
|
||||
* @returns {string} Hex-encoded random string (64 characters)
|
||||
*
|
||||
* @example
|
||||
* const state = PKCEUtils.generateState()
|
||||
* // Returns: "af0ifjsldkj"
|
||||
*/
|
||||
static generateState(): string {
|
||||
// Generate 32 random bytes and encode as hex (results in 64 characters)
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a code verifier format
|
||||
*
|
||||
* According to RFC 7636, a code verifier must be:
|
||||
* - Between 43 and 128 characters long
|
||||
* - Use only unreserved characters: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
|
||||
*
|
||||
* @param {string} verifier - The code verifier to validate
|
||||
* @returns {boolean} True if the verifier is valid, false otherwise
|
||||
*/
|
||||
static isValidCodeVerifier(verifier: string): boolean {
|
||||
if (!verifier || typeof verifier !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check length (43-128 characters)
|
||||
if (verifier.length < 43 || verifier.length > 128) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check allowed characters (unreserved characters only)
|
||||
const validPattern = /^[A-Za-z0-9\-._~]+$/
|
||||
return validPattern.test(verifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a state parameter format
|
||||
*
|
||||
* @param {string} state - The state parameter to validate
|
||||
* @returns {boolean} True if the state is valid, false otherwise
|
||||
*/
|
||||
static isValidState(state: string): boolean {
|
||||
if (!state || typeof state !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// State should be at least 32 characters for security
|
||||
if (state.length < 32) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check it's a valid hex string (if generated by generateState)
|
||||
const hexPattern = /^[a-f0-9]+$/
|
||||
return hexPattern.test(state)
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
// DEFAULT_OBP_API_VERSION is used in case the environment variable VITE_OBP_API_VERSION is not set
|
||||
export const DEFAULT_OBP_API_VERSION = 'v6.0.0'
|
||||
241
src-svelte/CodeBlock.svelte
Normal file
241
src-svelte/CodeBlock.svelte
Normal file
@ -0,0 +1,241 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// Declare global hljs
|
||||
declare global {
|
||||
interface Window {
|
||||
hljs: {
|
||||
highlightElement: (element: HTMLElement) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: any
|
||||
language?: string
|
||||
copyable?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
code,
|
||||
language = 'json',
|
||||
copyable = false
|
||||
}: Props = $props()
|
||||
|
||||
// Reactive state using Runes
|
||||
let codeBlockRef = $state<HTMLDivElement>()
|
||||
let copied = $state(false)
|
||||
|
||||
// Derived state - automatically computed
|
||||
let formattedCode = $derived(
|
||||
typeof code === 'string' ? code : JSON.stringify(code, null, 2)
|
||||
)
|
||||
|
||||
// Highlight code when component mounts or updates
|
||||
function highlight() {
|
||||
if (codeBlockRef && window.hljs) {
|
||||
const codeElements = codeBlockRef.querySelectorAll('pre code')
|
||||
codeElements.forEach((block) => {
|
||||
window.hljs.highlightElement(block as HTMLElement)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard function
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formattedCode)
|
||||
copied = true
|
||||
setTimeout(() => {
|
||||
copied = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Effect runs when component mounts and when dependencies change
|
||||
$effect(() => {
|
||||
// Need to wait for DOM to be ready
|
||||
setTimeout(() => highlight(), 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={codeBlockRef} class="code-block">
|
||||
{#if copyable}
|
||||
<div class="code-block-header">
|
||||
<button
|
||||
onclick={copyToClipboard}
|
||||
class="copy-button"
|
||||
class:copied
|
||||
>
|
||||
{#if !copied}
|
||||
<span class="icon">📋</span>
|
||||
<span>Copy</span>
|
||||
{:else}
|
||||
<span class="icon">✓</span>
|
||||
<span>Copied!</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="code-container">
|
||||
<pre><code class={language}>{formattedCode}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.code-block {
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
background: #2d2d2d;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: #444;
|
||||
border: 1px solid #666;
|
||||
color: #ddd;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.copy-button .icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #555;
|
||||
border-color: #777;
|
||||
}
|
||||
|
||||
.copy-button.copied {
|
||||
background: #4caf50;
|
||||
border-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.code-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.code-container pre {
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
background: #1e1e1e;
|
||||
color: #ddd;
|
||||
font-family: 'Fira Code', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.code-container code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for code blocks */
|
||||
.code-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.code-container::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.code-container::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
||||
.code-container pre::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.code-container pre::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.code-container pre::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-container pre::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
||||
/* Syntax highlighting enhancements */
|
||||
.code-block :global(.hljs-string) {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
.code-block :global(.hljs-number) {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
.code-block :global(.hljs-literal) {
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.code-block :global(.hljs-attr) {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.code-block :global(.hljs-punctuation) {
|
||||
color: #abb2bf;
|
||||
}
|
||||
</style>
|
||||
268
src-svelte/Dropdown.svelte
Normal file
268
src-svelte/Dropdown.svelte
Normal file
@ -0,0 +1,268 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label?: string
|
||||
items?: string[]
|
||||
hoverColor?: string
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
let {
|
||||
label = 'Dropdown',
|
||||
items = [],
|
||||
hoverColor = '#32b9ce',
|
||||
backgroundColor = '#e8f4f8'
|
||||
}: Props = $props()
|
||||
|
||||
let isOpen = $state(false)
|
||||
let dropdownRef = $state<HTMLDivElement>()
|
||||
let timeoutId: number | null = null
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
isOpen = true
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
isOpen = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function handleSelect(item: string) {
|
||||
const event = new CustomEvent('select', {
|
||||
detail: item,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
})
|
||||
dropdownRef?.dispatchEvent(event)
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||
isOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={dropdownRef}
|
||||
class="dropdown-container"
|
||||
style:--hover-bg={backgroundColor}
|
||||
style:--hover-color={hoverColor}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="navigation"
|
||||
>
|
||||
<button
|
||||
class="dropdown-trigger"
|
||||
onclick={() => isOpen = !isOpen}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
class="arrow-icon"
|
||||
class:rotated={isOpen}
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fill="currentColor" d="M831.872 340.864 512 652.672 192.128 340.864a30.592 30.592 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.592 30.592 0 0 0-42.752 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-content">
|
||||
{#each items as item}
|
||||
{#if item === '---'}
|
||||
<div class="dropdown-divider"></div>
|
||||
{:else}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
padding: 9px;
|
||||
margin: 3px;
|
||||
color: #39455f;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-trigger:hover {
|
||||
background-color: var(--hover-bg) !important;
|
||||
color: var(--hover-color) !important;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.arrow-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: white;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
z-index: 2000;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--hover-bg);
|
||||
color: var(--hover-color);
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background-color: var(--hover-bg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
margin: 6px 0;
|
||||
background-color: #e4e7ed;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
.dropdown-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.dropdown-content::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dropdown-content::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dropdown-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
.dropdown-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c1c1c1 #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ElNotification, NotificationHandle } from 'element-plus';
|
||||
import { ref, computed, h, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { DEFAULT_OBP_API_VERSION } from '../shared-constants';
|
||||
|
||||
// Props can be defined with defineProps
|
||||
const props = defineProps({
|
||||
@ -37,7 +38,8 @@ async function getOBPSuggestedTimeout() {
|
||||
let timeoutInSeconds: number;
|
||||
// Fetch the suggested timeout from the OBP API
|
||||
|
||||
const response = await fetch(`${obpApiHost}/obp/${import.meta.env.VITE_OBP_API_VERSION}/ui/suggested-session-timeout`);
|
||||
// Always use v5.1.0 for application infrastructure - stable and debuggable
|
||||
const response = await fetch(`${obpApiHost}/obp/${DEFAULT_OBP_API_VERSION}/ui/suggested-session-timeout`);
|
||||
const json = await response.json();
|
||||
if(json.timeout_in_seconds) {
|
||||
timeoutInSeconds = json.timeout_in_seconds;
|
||||
@ -48,7 +50,7 @@ async function getOBPSuggestedTimeout() {
|
||||
}
|
||||
|
||||
return timeoutInSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTimeout() {
|
||||
// Logic to reset the timeout
|
||||
@ -72,14 +74,14 @@ function warningMessage() {
|
||||
// Update the countdown every second
|
||||
countdownInterval = setInterval(() => {
|
||||
secondsLeft.value = Math.ceil((logoutTime - Date.now()) / 1000);
|
||||
|
||||
|
||||
// If time's up or almost up, clear the interval
|
||||
if (secondsLeft.value <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}, 1000);
|
||||
|
||||
warningNotification = ElNotification({
|
||||
@ -117,11 +119,11 @@ onMounted(() => {
|
||||
const logoutDelay = timeoutInSeconds * 1000;
|
||||
// Set warning to appear 30 seconds before logout
|
||||
const warningDelay = Math.max(logoutDelay - 30000, 0);
|
||||
|
||||
|
||||
// Update the defaults
|
||||
defaultWarningDelay = warningDelay;
|
||||
defaultLogoutDelay = logoutDelay;
|
||||
|
||||
|
||||
// Reset timers with new values
|
||||
resetTimeout();
|
||||
}).catch(error => {
|
||||
@ -154,4 +156,4 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<!-- Your component content here -->
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@ -100,7 +100,7 @@ export default {
|
||||
</div>
|
||||
<div v-if="message.error" class="error"><el-icon><Warning /></el-icon> {{ message.error }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@ -212,4 +212,24 @@ export default {
|
||||
.tidot:nth-child(3){
|
||||
animation-delay:400ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* Override Prism.js styling for Scala code blocks to make them readable */
|
||||
pre.language-scala {
|
||||
background-color: #f5f5f5 !important;
|
||||
color: #333 !important;
|
||||
font-family: 'Courier New', Courier, monospace !important;
|
||||
padding: 1em !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
pre.language-scala code {
|
||||
background-color: transparent !important;
|
||||
color: #333 !important;
|
||||
font-family: 'Courier New', Courier, monospace !important;
|
||||
}
|
||||
|
||||
/* Reset all token colors for Scala to ensure readability */
|
||||
pre.language-scala .token {
|
||||
color: #333 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<!--
|
||||
placeholder for Opey II Chat widget
|
||||
-->
|
||||
-->
|
||||
<script lang="ts">
|
||||
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { Close, Top as ElTop, WarnTriangleFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ChatMessage from './ChatMessage.vue';
|
||||
@ -11,13 +11,23 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { OpeyMessage, UserMessage } from '@/models/MessageModel';
|
||||
import { getCurrentUser } from '@/obp';
|
||||
import { useChat } from '@/stores/chat';
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export default {
|
||||
setup () {
|
||||
return {
|
||||
const route = useRoute()
|
||||
const getLoginUrl = computed(() => {
|
||||
const currentPath = route.path
|
||||
const queryString = new URLSearchParams(route.query as Record<string, string>).toString()
|
||||
const fullPath = queryString ? `${currentPath}?${queryString}` : currentPath
|
||||
return `/api/oauth2/connect?redirect=${encodeURIComponent(fullPath)}`
|
||||
})
|
||||
|
||||
return {
|
||||
Close,
|
||||
ElTop,
|
||||
WarnTriangleFilled,
|
||||
getLoginUrl,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -36,7 +46,7 @@ export default {
|
||||
this.chat = useChat()
|
||||
const isLoggedIn = await this.checkLoginStatus()
|
||||
console.log('Is logged in: ', isLoggedIn)
|
||||
|
||||
|
||||
},
|
||||
methods: {
|
||||
async toggleChat() {
|
||||
@ -55,14 +65,14 @@ export default {
|
||||
this.errorState.message = "Woops! Looks like we are having trouble connecting to Opey..."
|
||||
this.errorState.icon = WarnTriangleFilled
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async onSubmit() {
|
||||
// Add user message to the messages array
|
||||
const userMessage: UserMessage = {
|
||||
@ -71,7 +81,7 @@ export default {
|
||||
content: this.input,
|
||||
isToolCallApproval: false,
|
||||
};
|
||||
|
||||
|
||||
// Set status to loading // Clear input field after sending
|
||||
this.chat.status = 'loading';
|
||||
this.input = '';
|
||||
@ -80,7 +90,7 @@ export default {
|
||||
await this.chat.stream({
|
||||
message: userMessage,
|
||||
}
|
||||
|
||||
|
||||
)
|
||||
console.log('Opey Status: ', this.chat.status)
|
||||
} catch (error) {
|
||||
@ -112,11 +122,11 @@ export default {
|
||||
<div class="chat-container-inner" id="chat-container">
|
||||
<el-container direction="vertical">
|
||||
<el-header>
|
||||
<img alt="Opey Logo" src="@/assets/opey-logo-inv.png">
|
||||
<img alt="Opey Logo" src="@/assets/opey-logo-inv.png">
|
||||
<el-button type="danger" :icon="Close" @click="toggleChat" size="small" circle></el-button>
|
||||
</el-header>
|
||||
<el-main>
|
||||
|
||||
|
||||
<div v-if="errorState.type === 'authenticationError'" class="login-container">
|
||||
<el-icon :size="40" color="#FF4D4F">
|
||||
<component :is="errorState.icon" />
|
||||
@ -125,7 +135,7 @@ export default {
|
||||
</div>
|
||||
<div v-else-if="!chat.userIsAuthenticated" class="login-container">
|
||||
<p class="login-message" size="large">Opey is only available once logged on.</p>
|
||||
<a href="/api/connect" class="login-button router-link">Log on</a>
|
||||
<a :href="getLoginUrl" class="login-button router-link">Log on</a>
|
||||
</div>
|
||||
<div v-else class="messages-container" v-bind:class="{ disabled: !chat.userIsAuthenticated }">
|
||||
<el-scrollbar>
|
||||
@ -322,7 +332,7 @@ textarea::-webkit-scrollbar-thumb {
|
||||
|
||||
/* Handle on hover */
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
|
||||
|
||||
background: #888;
|
||||
}
|
||||
|
||||
@ -334,4 +344,4 @@ textarea::-webkit-scrollbar-thumb:hover {
|
||||
.user-input-container button {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
import axios from 'axios';
|
||||
import 'prismjs/themes/prism.css'; // Choose a theme you like
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { inject } from 'vue';
|
||||
import { inject, computed } from 'vue';
|
||||
import { obpApiHostKey } from '@/obp/keys';
|
||||
import { getCurrentUser } from '../obp';
|
||||
import { getOpeyJWT, getobpConsent, answerobpConsentChallenge } from '@/obp/common-functions'
|
||||
@ -40,6 +40,7 @@
|
||||
import { useConnectionStore } from '@/stores/connection';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
@ -54,10 +55,10 @@
|
||||
setup() {
|
||||
/**
|
||||
* Pinia stores only work properly in the vue composition API, hence the setup() call here, which allows us to use the vue composition API within the vue options API
|
||||
* See https://vueschool.io/articles/vuejs-tutorials/options-api-vs-composition-api/
|
||||
* See https://vueschool.io/articles/vuejs-tutorials/options-api-vs-composition-api/
|
||||
* and https://vuejs.org/api/composition-api-setup.html
|
||||
* */
|
||||
|
||||
* */
|
||||
|
||||
// We use a pinia store to store the chat messages, and status data like if there is a message stream currently happening or an error state.
|
||||
const chatStore = useChatStore();
|
||||
|
||||
@ -73,7 +74,15 @@
|
||||
|
||||
const { isConnected } = storeToRefs(connectionStore);
|
||||
|
||||
return {isStreaming, chatMessages, lastError, currentMessageSnapshot, chatStore, connectionStore}
|
||||
const route = useRoute();
|
||||
const loginUrl = computed(() => {
|
||||
const currentPath = route.path;
|
||||
const queryString = new URLSearchParams(route.query as Record<string, string>).toString();
|
||||
const fullPath = queryString ? `${currentPath}?${queryString}` : currentPath;
|
||||
return `/api/oauth2/connect?redirect=${encodeURIComponent(fullPath)}`;
|
||||
});
|
||||
|
||||
return {isStreaming, chatMessages, lastError, currentMessageSnapshot, chatStore, connectionStore, loginUrl}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -139,7 +148,7 @@
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.log('Error getting consent for opey from OBP: ', error)
|
||||
this.errorState = true
|
||||
@ -147,9 +156,9 @@
|
||||
message: 'Error getting consent for opey from OBP',
|
||||
type: 'error'
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
async answerConsentChallenge() {
|
||||
const challengeAnswer = this.consentChallengeAnswer
|
||||
@ -160,7 +169,7 @@
|
||||
|
||||
try {
|
||||
console.log(`Answering consent challenge with: ${challengeAnswer} and consent_id: ${this.consentId}`)
|
||||
|
||||
|
||||
|
||||
// send the challenge answer to Opey for approval
|
||||
const response = await axios.post(
|
||||
@ -174,7 +183,7 @@
|
||||
withCredentials: true,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
console.log("Consent challenge response: ", response.status, response.headers)
|
||||
if (response.status === 200) {
|
||||
console.log('Consent challenge answered successfully, Consent approved')
|
||||
@ -185,7 +194,7 @@
|
||||
} else {
|
||||
console.log('Consent denied')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
console.log('Error answering consent challenge: ', error)
|
||||
@ -230,9 +239,9 @@
|
||||
},
|
||||
/**
|
||||
* This function highlights code blocks in the chat messages
|
||||
*
|
||||
* @param content
|
||||
* @param language
|
||||
*
|
||||
* @param content
|
||||
* @param language
|
||||
*/
|
||||
highlightCode(content, language) {
|
||||
if (Prism.languages[language]) {
|
||||
@ -365,7 +374,7 @@
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<div class="detail">
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -374,11 +383,11 @@
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div v-else class="chat-messages">
|
||||
<p>Opey is only availabled when logged in. <a v-bind:href="'/api/connect'">Log In</a> </p>
|
||||
<p>Opey is only availabled when logged in. <a v-bind:href="loginUrl">Log In</a> </p>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="this.errorState"
|
||||
@ -397,8 +406,8 @@
|
||||
>
|
||||
</el-input>
|
||||
<!--<textarea v-model="userInput" placeholder="Type your message..." @keypress="submitEnter"></textarea>-->
|
||||
<button
|
||||
@click="sendMessage"
|
||||
<button
|
||||
@click="sendMessage"
|
||||
:disabled="!isLoggedIn || this.awaitingConnection ? '' : disabled"
|
||||
:style="!isLoggedIn || this.awaitingConnection ? 'background-color:#929292; cursor:not-allowed' : ''"
|
||||
>
|
||||
@ -462,8 +471,8 @@
|
||||
|
||||
.quit-button {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: red;
|
||||
@ -654,4 +663,4 @@
|
||||
cursor: nwse-resize;
|
||||
z-index: 1001;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -30,9 +30,9 @@ import { obpMyCollectionsEndpointKey, obpResourceDocsKey } from '@/obp/keys'
|
||||
import { ArrowLeftBold, ArrowRightBold } from '@element-plus/icons-vue'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { inject, onMounted, provide, ref } from 'vue'
|
||||
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
|
||||
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
OBP_API_VERSION,
|
||||
OBP_API_DEFAULT_RESOURCE_DOC_VERSION,
|
||||
createMyAPICollection,
|
||||
createMyAPICollectionEndpoint,
|
||||
deleteMyAPICollectionEndpoint,
|
||||
@ -43,9 +43,12 @@ import { SUMMARY_PAGER_LINKS_COLOR as summaryPagerLinksColorSetting } from '../o
|
||||
import { initializeAPICollections, setTabActive } from './SearchNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const obpVersion = 'OBP' + OBP_API_VERSION
|
||||
const router = useRouter()
|
||||
const obpVersion = OBP_API_DEFAULT_RESOURCE_DOC_VERSION
|
||||
const description = ref('')
|
||||
const summary = ref('')
|
||||
const tags = ref<string[]>([])
|
||||
const allTags = ref<string[]>([])
|
||||
const resourceDocs = inject(obpResourceDocsKey)
|
||||
const displayPrev = ref(true)
|
||||
const displayNext = ref(true)
|
||||
@ -53,6 +56,9 @@ const prev = ref({ id: 'prev' })
|
||||
const next = ref({ id: 'next' })
|
||||
const favoriteButtonStyle = ref('favorite favoriteButton')
|
||||
const summaryPagerLinksColor = ref(summaryPagerLinksColorSetting)
|
||||
const showPlaceholder = ref(false)
|
||||
const placeholderVersion = ref('')
|
||||
const totalEndpoints = ref(0)
|
||||
let routeId = ''
|
||||
let version = obpVersion
|
||||
let isFavorite = false
|
||||
@ -60,8 +66,60 @@ let apiCollectionsEndpoint = inject(obpMyCollectionsEndpointKey)!
|
||||
|
||||
const setOperationDetails = (id: string, version: string): void => {
|
||||
const operation = getOperationDetails(version, id, resourceDocs)
|
||||
console.log('Operation details:', operation)
|
||||
console.log('Tags from operation:', operation?.tags)
|
||||
description.value = operation?.description
|
||||
summary.value = operation?.summary
|
||||
tags.value = operation?.tags || []
|
||||
console.log('Tags ref value:', tags.value)
|
||||
updateHeaderTags(tags.value)
|
||||
}
|
||||
|
||||
const updateHeaderTags = (tagsList: string[]) => {
|
||||
const element = document.getElementById('selected-endpoint-tags')
|
||||
if (element) {
|
||||
if (tagsList.length > 0) {
|
||||
const tagsHTML = tagsList.map(tag =>
|
||||
`<a class="tag-link" data-tag="${tag}" href="#">${tag}</a>`
|
||||
).join(', ')
|
||||
element.innerHTML = `Tags: ${tagsHTML}`
|
||||
|
||||
// Add click handlers to the tags
|
||||
element.querySelectorAll('.tag-link').forEach((tagElement) => {
|
||||
tagElement.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
const tag = (e.target as HTMLElement).getAttribute('data-tag')
|
||||
if (tag) {
|
||||
filterByTag(tag)
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
element.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearHeaderTags = () => {
|
||||
const element = document.getElementById('selected-endpoint-tags')
|
||||
if (element) {
|
||||
element.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
const filterByTag = (tag: string) => {
|
||||
router.push({
|
||||
name: 'api',
|
||||
params: { version: version },
|
||||
query: { tags: tag }
|
||||
})
|
||||
}
|
||||
|
||||
const clearTagFilter = () => {
|
||||
router.push({
|
||||
name: 'api',
|
||||
params: { version: version }
|
||||
})
|
||||
}
|
||||
|
||||
const setPager = (id: string): void => {
|
||||
@ -141,19 +199,52 @@ const showNotification = (message: string, type: string): void => {
|
||||
})
|
||||
}
|
||||
|
||||
const getAllTags = (version: string) => {
|
||||
const docs = resourceDocs[version]?.resource_docs || []
|
||||
const tagSet = new Set<string>()
|
||||
docs.forEach((doc: any) => {
|
||||
if (doc.tags && Array.isArray(doc.tags)) {
|
||||
doc.tags.forEach((tag: string) => tagSet.add(tag))
|
||||
}
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
routeId = route.params.id
|
||||
version = route.query.version ? route.query.version : obpVersion
|
||||
setOperationDetails(routeId, version)
|
||||
setPager(routeId)
|
||||
await tagFavoriteButton(routeId)
|
||||
routeId = route.query.operationid
|
||||
version = route.params.version ? route.params.version : obpVersion
|
||||
|
||||
if (!routeId) {
|
||||
// No operation selected, show placeholder
|
||||
showPlaceholder.value = true
|
||||
placeholderVersion.value = version
|
||||
totalEndpoints.value = resourceDocs[version]?.resource_docs?.length || 0
|
||||
allTags.value = getAllTags(version)
|
||||
clearHeaderTags()
|
||||
} else {
|
||||
showPlaceholder.value = false
|
||||
setOperationDetails(routeId, version)
|
||||
setPager(routeId)
|
||||
await tagFavoriteButton(routeId)
|
||||
}
|
||||
})
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
routeId = to.params.id
|
||||
version = route.query.version ? route.query.version : obpVersion
|
||||
setOperationDetails(routeId, version)
|
||||
setPager(routeId)
|
||||
await tagFavoriteButton(routeId)
|
||||
routeId = to.query.operationid
|
||||
version = to.params.version ? to.params.version : obpVersion
|
||||
|
||||
if (!routeId) {
|
||||
// Version changed but no endpoint selected
|
||||
showPlaceholder.value = true
|
||||
placeholderVersion.value = version
|
||||
totalEndpoints.value = resourceDocs[version]?.resource_docs?.length || 0
|
||||
allTags.value = getAllTags(version)
|
||||
clearHeaderTags()
|
||||
} else {
|
||||
showPlaceholder.value = false
|
||||
setOperationDetails(routeId, version)
|
||||
setPager(routeId)
|
||||
await tagFavoriteButton(routeId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -161,18 +252,70 @@ onBeforeRouteUpdate(async (to) => {
|
||||
<main>
|
||||
<el-container>
|
||||
<el-main>
|
||||
<el-row>
|
||||
<el-col :span="22">
|
||||
<span>{{ summary }}</span>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<span :class="favoriteButtonStyle" @click="createDeleteFavorite()">★</span>
|
||||
<!--<el-button text>★</el-button>-->
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div v-html="description" class="content"></div>
|
||||
<div v-if="showPlaceholder" class="placeholder-message">
|
||||
<div class="version-header">
|
||||
<h1>{{ placeholderVersion }}</h1>
|
||||
<p class="version-subtitle">API Documentation</p>
|
||||
</div>
|
||||
<p class="version-info">There are {{ totalEndpoints }} endpoints available in this version.</p>
|
||||
<p class="version-instructions">Please click an endpoint on the left or browse by tags below.</p>
|
||||
|
||||
<div v-if="allTags.length > 0" class="placeholder-tags">
|
||||
<h3>Filter by Tag:</h3>
|
||||
<div class="tags-grid">
|
||||
<a
|
||||
class="tag-link tag-link-all"
|
||||
:class="{ 'tag-link-active': route.query.tags === undefined || route.query.tags === 'NONE' }"
|
||||
@click.prevent="clearTagFilter()"
|
||||
>
|
||||
All
|
||||
</a>
|
||||
<a
|
||||
v-for="tag in allTags"
|
||||
:key="tag"
|
||||
class="tag-link"
|
||||
:class="{ 'tag-link-active': route.query.tags === tag }"
|
||||
@click.prevent="filterByTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-row>
|
||||
<el-col :span="22">
|
||||
<span>{{ summary }}</span>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<span :class="favoriteButtonStyle" @click="createDeleteFavorite()">★</span>
|
||||
<!--<el-button text>★</el-button>-->
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div v-html="description" class="content"></div>
|
||||
<div class="tags-section">
|
||||
<span class="tags-label">Tags:</span>
|
||||
<a
|
||||
v-if="route.query.tags"
|
||||
class="tag-link tag-link-all"
|
||||
@click.prevent="clearTagFilter()"
|
||||
>
|
||||
All
|
||||
</a>
|
||||
<a
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
class="tag-link"
|
||||
:class="{ 'tag-link-active': route.query.tags === tag }"
|
||||
@click.prevent="filterByTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</a>
|
||||
<span v-if="tags.length === 0" style="color: #909399; font-size: 12px;">No tags available</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer class="footer">
|
||||
<el-footer class="footer" v-if="!showPlaceholder">
|
||||
<el-divider class="divider" />
|
||||
<el-row>
|
||||
<el-col :span="12" class="pager-left">
|
||||
@ -180,12 +323,12 @@ onBeforeRouteUpdate(async (to) => {
|
||||
<ArrowLeftBold />
|
||||
</el-icon>
|
||||
<RouterLink v-show="displayPrev" class="pager-router-link"
|
||||
:to="{ name: 'api', params: { id: prev.id }, query: { version: prev.version } }">{{ prev.title }}
|
||||
:to="{ name: 'api', params: { version: prev.version }, query: { operationid: prev.id } }">{{ prev.title }}
|
||||
</RouterLink>
|
||||
</el-col>
|
||||
<el-col :span="12" class="pager-right">
|
||||
<RouterLink v-show="displayNext" class="pager-router-link"
|
||||
:to="{ name: 'api', params: { id: next.id }, query: { version: next.version } }">{{ next.title }}
|
||||
:to="{ name: 'api', params: { version: next.version }, query: { operationid: next.id } }">{{ next.title }}
|
||||
</RouterLink>
|
||||
<el-icon v-show="displayNext">
|
||||
<ArrowRightBold />
|
||||
@ -208,6 +351,121 @@ span {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.tags-section {
|
||||
margin: 15px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tags-label {
|
||||
font-size: 14px !important;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.tag-link {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px !important;
|
||||
color: #409eff;
|
||||
background-color: #ecf5ff;
|
||||
border: 1px solid #d9ecff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag-link:hover {
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.tag-link-all {
|
||||
background-color: #67c23a;
|
||||
border-color: #b3e19d;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-link-all:hover {
|
||||
background-color: #85ce61;
|
||||
border-color: #85ce61;
|
||||
}
|
||||
|
||||
.tag-link-active {
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
border-color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-link-all.tag-link-active {
|
||||
background-color: #67c23a;
|
||||
border-color: #67c23a;
|
||||
}
|
||||
|
||||
.placeholder-message {
|
||||
padding: 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.version-header {
|
||||
padding: 20px 0;
|
||||
border-bottom: 2px solid #e4e7ed;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.version-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 0.5rem 0;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.version-subtitle {
|
||||
font-size: 1rem;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
font-size: 16px;
|
||||
margin: 20px 0 10px 0;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.version-instructions {
|
||||
font-size: 16px;
|
||||
margin: 10px 0 20px 0;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.placeholder-tags {
|
||||
margin-top: 30px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.placeholder-tags h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #39455f;
|
||||
}
|
||||
|
||||
.tags-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@ -40,10 +40,34 @@ const form = reactive({
|
||||
search: ''
|
||||
})
|
||||
|
||||
// Helper function to check if a glossary item should be displayed
|
||||
const shouldDisplayItem = (item: any): boolean => {
|
||||
const html = item.description?.html || ''
|
||||
|
||||
// Check if description contains "no-description-provided"
|
||||
if (html.includes('no-description-provided')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if Example value is empty
|
||||
// Matches: "Example value:</p>", "Example value: </p>", "Example value: </p>", etc.
|
||||
if (html.match(/Example value:\s*( |\s)*<\/p>/i)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Also check for "Example value:" followed by empty tags or whitespace before closing
|
||||
if (html.match(/Example value:\s*(<[^>]*>)*\s*<\/p>/i)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
const glossary = inject(obpGlossaryKey)!
|
||||
for (const item of glossary.glossary_items) {
|
||||
if (!activeKeys.value.includes(item.title)) {
|
||||
// Only include items that pass the filter
|
||||
if (!activeKeys.value.includes(item.title) && shouldDisplayItem(item)) {
|
||||
activeKeys.value.push(item.title)
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,10 +26,9 @@
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, watchEffect, onMounted, computed } from 'vue'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { ref, inject, watchEffect, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { OBP_API_VERSION, getCurrentUser } from '../obp'
|
||||
import { OBP_API_DEFAULT_RESOURCE_DOC_VERSION, getCurrentUser } from '../obp'
|
||||
import { getOBPAPIVersions } from '../obp/api-version'
|
||||
import {
|
||||
LOGO_URL as logoSource,
|
||||
@ -37,7 +36,8 @@ import {
|
||||
HEADER_LINKS_HOVER_COLOR as headerLinksHoverColorSetting,
|
||||
HEADER_LINKS_BACKGROUND_COLOR as headerLinksBackgroundColorSetting
|
||||
} from '../obp/style-setting'
|
||||
import { obpApiActiveVersionsKey, obpGroupedMessageDocsKey, obpMyCollectionsEndpointKey } from '@/obp/keys'
|
||||
import { obpApiActiveVersionsKey, obpGroupedMessageDocsKey, obpGroupedMessageDocsJsonSchemaKey, obpMyCollectionsEndpointKey } from '@/obp/keys'
|
||||
import SvelteDropdown from './SvelteDropdown.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -49,14 +49,135 @@ const hasObpApiManagerHost = computed(() => obpApiManagerHost.value ? true : fal
|
||||
const showObpApiManagerButton = computed(() => import.meta.env.VITE_SHOW_API_MANAGER_BUTTON === 'true')
|
||||
const loginUsername = ref('')
|
||||
const logoffurl = ref('')
|
||||
const obpApiVersions = ref(inject(obpApiActiveVersionsKey)!)
|
||||
const obpMessageDocs = ref(Object.keys(inject(obpGroupedMessageDocsKey)!))
|
||||
const obpApiVersions = ref(inject(obpApiActiveVersionsKey) || [])
|
||||
const obpMessageDocs = ref(Object.keys(inject(obpGroupedMessageDocsKey) || {}))
|
||||
const obpMessageDocsJsonSchema = ref(Object.keys(inject(obpGroupedMessageDocsJsonSchemaKey) || {}))
|
||||
|
||||
// Combine message docs with JSON Schema items (with "J Schema" postfix)
|
||||
const combinedMessageDocs = computed(() => {
|
||||
const regularDocs = obpMessageDocs.value || []
|
||||
const jsonSchemaDocs = (obpMessageDocsJsonSchema.value || []).map(connector => `${connector} J Schema`)
|
||||
return [...regularDocs, ...jsonSchemaDocs]
|
||||
})
|
||||
|
||||
// Debug menu items
|
||||
const debugMenuItems = ref(['/debug/providers-status', '/debug/oidc'])
|
||||
|
||||
// Split versions into main and other
|
||||
const mainVersions = ['BGv1.3', 'OBPv5.1.0', 'OBPv6.0.0', 'UKv3.1', 'dynamic-endpoints', 'dynamic-entities', 'OBPdynamic-endpoint', 'OBPdynamic-entity']
|
||||
const sortedVersions = computed(() => {
|
||||
const all = obpApiVersions.value || []
|
||||
console.log('All available versions:', all)
|
||||
const main = mainVersions.filter(v => all.includes(v))
|
||||
console.log('Main versions found:', main)
|
||||
const others = all.filter(v => !mainVersions.includes(v)).sort()
|
||||
console.log('Other versions:', others)
|
||||
|
||||
// Only add divider if we have both main and other versions
|
||||
if (main.length > 0 && others.length > 0) {
|
||||
return [...main, '---', ...others]
|
||||
} else if (main.length > 0) {
|
||||
return main
|
||||
} else {
|
||||
return others
|
||||
}
|
||||
})
|
||||
|
||||
const isShowLoginButton = ref(true)
|
||||
const isShowLogOffButton = ref(false)
|
||||
const oauth2Available = ref(true) // Assume available initially
|
||||
const oauth2StatusMessage = ref('')
|
||||
const logo = ref(logoSource)
|
||||
const headerLinksHoverColor = ref(headerLinksHoverColorSetting)
|
||||
const headerLinksBackgroundColor = ref(headerLinksBackgroundColorSetting)
|
||||
|
||||
// Multi-provider support
|
||||
const availableProviders = ref<Array<{ name: string; available: boolean; lastChecked?: Date; error?: string }>>([])
|
||||
const showProviderSelector = ref(false)
|
||||
const isLoadingProviders = ref(false)
|
||||
|
||||
// OAuth2 availability is determined by provider availability
|
||||
// No separate status check needed
|
||||
|
||||
// Fetch available OIDC providers
|
||||
async function fetchAvailableProviders() {
|
||||
isLoadingProviders.value = true
|
||||
try {
|
||||
const response = await fetch('/api/oauth2/providers')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.providers && Array.isArray(data.providers)) {
|
||||
availableProviders.value = data.providers
|
||||
console.log('Available OAuth2 providers:', availableProviders.value)
|
||||
console.log(`Total: ${data.count}, Available: ${data.availableCount}`)
|
||||
} else {
|
||||
console.warn('No providers returned from /api/oauth2/providers')
|
||||
availableProviders.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OAuth2 providers:', error)
|
||||
availableProviders.value = []
|
||||
} finally {
|
||||
isLoadingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login button click
|
||||
function handleLoginClick() {
|
||||
const available = availableProviders.value.filter(p => p.available)
|
||||
|
||||
if (available.length > 1) {
|
||||
// Show provider selection dialog
|
||||
showProviderSelector.value = true
|
||||
} else if (available.length === 1) {
|
||||
// Direct login with single provider
|
||||
loginWithProvider(available[0].name)
|
||||
} else {
|
||||
// No providers available
|
||||
console.error('No OAuth2 providers available. Check backend configuration.')
|
||||
alert('Login is not available. Please check that OAuth2 providers are configured.')
|
||||
}
|
||||
}
|
||||
|
||||
// Login with selected provider
|
||||
function loginWithProvider(provider: string) {
|
||||
const redirectUrl = '/api/oauth2/connect?provider=' +
|
||||
encodeURIComponent(provider) +
|
||||
'&redirect=' +
|
||||
encodeURIComponent(getCurrentPath())
|
||||
console.log(`Logging in with provider: ${provider}`)
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
|
||||
// Format provider name for display
|
||||
function formatProviderName(name: string): string {
|
||||
// Convert "obp-oidc" to "OBP OIDC", "keycloak" to "Keycloak", etc.
|
||||
return name.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Get provider icon
|
||||
function getProviderIcon(name: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
'obp-oidc': '🏦',
|
||||
'keycloak': '🔐',
|
||||
'google': '🔵',
|
||||
'github': '🐙'
|
||||
}
|
||||
return icons[name] || '🔑'
|
||||
}
|
||||
|
||||
// Copy text to clipboard
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
console.log('Error message copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text to clipboard:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const clearActiveTab = () => {
|
||||
const activeLinks = document.querySelectorAll<HTMLElement>('.router-link')
|
||||
for (const active of activeLinks) {
|
||||
@ -76,31 +197,67 @@ const setActive = (target: HTMLElement | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMore = (command: string) => {
|
||||
const handleMore = (command: string, source?: string) => {
|
||||
console.log('handleMore called with command:', command, 'source:', source)
|
||||
|
||||
// Ignore divider
|
||||
if (command === '---') {
|
||||
return
|
||||
}
|
||||
|
||||
let element = document.getElementById("selected-api-version")
|
||||
if (element !== null) {
|
||||
element.textContent = command;
|
||||
}
|
||||
if (command.includes('_')) {
|
||||
|
||||
// Check if command ends with " J Schema" - if so, it's a JSON Schema message doc
|
||||
if (command.endsWith(' J Schema')) {
|
||||
const connector = command.replace(' J Schema', '')
|
||||
console.log('Navigating to message docs JSON schema:', connector)
|
||||
router.push({ name: 'message-docs-json-schema', params: { id: connector } })
|
||||
} else if (command === '/message-docs') {
|
||||
// Navigate to message docs list
|
||||
console.log('Navigating to message docs list')
|
||||
router.push({ name: 'message-docs-list' })
|
||||
} else if (command === '/message-docs-json-schema') {
|
||||
// Navigate to message docs JSON schema list
|
||||
console.log('Navigating to message docs JSON schema list')
|
||||
router.push({ name: 'message-docs-json-schema-list' })
|
||||
} else if (command.includes('_')) {
|
||||
// Regular message docs (connector names contain underscores)
|
||||
console.log('Navigating to message docs:', command)
|
||||
router.push({ name: 'message-docs', params: { id: command } })
|
||||
} else if (command.startsWith('/debug/')) {
|
||||
console.log('Navigating to debug page:', command)
|
||||
router.push(command)
|
||||
} else {
|
||||
router.replace({ path: '/operationid', query: { version: command } })
|
||||
console.log('Navigating to resource docs:', `/resource-docs/${command}`)
|
||||
console.log('Current route:', route.path)
|
||||
// Clear operationid query param when changing versions to avoid showing non-existent operation
|
||||
router.push(`/resource-docs/${command}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Fetch available providers
|
||||
await fetchAvailableProviders()
|
||||
|
||||
const currentUser = await getCurrentUser()
|
||||
const currentResponseKeys = Object.keys(currentUser)
|
||||
if (currentResponseKeys.includes('username')) {
|
||||
loginUsername.value = currentUser.username
|
||||
isShowLoginButton.value = false
|
||||
isShowLogOffButton.value = !isShowLoginButton.value
|
||||
loginUsername.value = currentUser.username
|
||||
} else {
|
||||
isShowLoginButton.value = true
|
||||
isShowLogOffButton.value = !isShowLoginButton.value
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup hook
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const routeName = typeof route.name === 'string' ? route.name : null
|
||||
if (routeName && route.params && !route.params.id) {
|
||||
@ -116,7 +273,8 @@ watchEffect(() => {
|
||||
|
||||
const getCurrentPath = () => {
|
||||
const currentPath = route.path
|
||||
return currentPath
|
||||
const queryString = new URLSearchParams(route.query as Record<string, string>).toString()
|
||||
return queryString ? `${currentPath}?${queryString}` : currentPath
|
||||
}
|
||||
|
||||
</script>
|
||||
@ -129,7 +287,7 @@ const getCurrentPath = () => {
|
||||
<a v-bind:href="obpApiHybridPost" class="router-link" id="header-nav-home">
|
||||
{{ $t('header.portal_home') }}
|
||||
</a>
|
||||
<RouterLink class="router-link" id="header-nav-tags" :to="'/operationid?version=OBP' + OBP_API_VERSION">{{
|
||||
<RouterLink class="router-link" id="header-nav-tags" :to="'/resource-docs/' + OBP_API_DEFAULT_RESOURCE_DOC_VERSION">{{
|
||||
$t('header.api_explorer') }}</RouterLink>
|
||||
<RouterLink class="router-link" id="header-nav-glossary" to="/glossary">{{
|
||||
$t('header.glossary')
|
||||
@ -140,31 +298,33 @@ const getCurrentPath = () => {
|
||||
<a v-if="showObpApiManagerButton && hasObpApiManagerHost" v-bind:href="obpApiManagerHost" class="router-link" id="header-nav-api-manager">
|
||||
{{ $t('header.api_manager') }}
|
||||
</a>
|
||||
<el-dropdown
|
||||
class="menu-right router-link"
|
||||
id="header-nav-more"
|
||||
@command="handleMore"
|
||||
trigger="hover"
|
||||
placement="bottom-end"
|
||||
:teleported="true"
|
||||
max-height="700px"
|
||||
>
|
||||
<span class="el-dropdown-link">
|
||||
{{ $t('header.more') }}
|
||||
<el-icon class="el-icon--right">
|
||||
<arrow-down />
|
||||
</el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="value in obpApiVersions" :command="value" :key="value">{{
|
||||
value
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item v-for="value in obpMessageDocs" :command="value" :key="value">
|
||||
Message Docs for: {{ value }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<SvelteDropdown
|
||||
class="menu-right"
|
||||
id="header-nav-versions"
|
||||
label="Versions"
|
||||
:items="sortedVersions"
|
||||
:hover-color="headerLinksHoverColor"
|
||||
:background-color="headerLinksBackgroundColor"
|
||||
@select="handleMore"
|
||||
/>
|
||||
<SvelteDropdown
|
||||
class="menu-right"
|
||||
id="header-nav-message-docs"
|
||||
label="Message Docs"
|
||||
:items="combinedMessageDocs"
|
||||
:hover-color="headerLinksHoverColor"
|
||||
:background-color="headerLinksBackgroundColor"
|
||||
@select="handleMore"
|
||||
/>
|
||||
<SvelteDropdown
|
||||
class="menu-right"
|
||||
id="header-nav-debug"
|
||||
label="Debug"
|
||||
:items="debugMenuItems"
|
||||
:hover-color="headerLinksHoverColor"
|
||||
:background-color="headerLinksBackgroundColor"
|
||||
@select="handleMore"
|
||||
/>
|
||||
<!--<span class="el-dropdown-link">
|
||||
<RouterLink class="router-link" id="header-nav-spaces" to="/spaces">{{
|
||||
$t('header.spaces')
|
||||
@ -173,15 +333,110 @@ const getCurrentPath = () => {
|
||||
<arrow-down />
|
||||
</el-icon>
|
||||
</span>-->
|
||||
<a v-bind:href="'/api/connect?redirect='+ encodeURIComponent(getCurrentPath())" v-show="isShowLoginButton" class="login-button router-link" id="login">
|
||||
<el-tooltip v-if="isShowLoginButton && !oauth2Available" :content="oauth2StatusMessage || 'OAuth2 server not available'" placement="bottom">
|
||||
<button disabled class="login-button-disabled router-link" id="login">
|
||||
{{ $t('header.login') }}
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<button
|
||||
v-else-if="isShowLoginButton && oauth2Available"
|
||||
@click="handleLoginClick"
|
||||
class="login-button router-link"
|
||||
id="login"
|
||||
>
|
||||
{{ $t('header.login') }}
|
||||
</a>
|
||||
</button>
|
||||
<span v-show="isShowLogOffButton" class="login-user">{{ loginUsername }}</span>
|
||||
<a v-bind:href="'/api/user/logoff?redirect=' + encodeURIComponent(getCurrentPath())" v-show="isShowLogOffButton" class="logoff-button router-link" id="logoff">
|
||||
{{ $t('header.logoff') }}
|
||||
</a>
|
||||
</RouterView>
|
||||
</nav>
|
||||
|
||||
<!-- Provider Selection Dialog -->
|
||||
<el-dialog
|
||||
v-model="showProviderSelector"
|
||||
title="Login"
|
||||
width="500px"
|
||||
:close-on-click-modal="true"
|
||||
>
|
||||
<!-- No providers available -->
|
||||
<div v-if="availableProviders.filter(p => p.available).length === 0" class="no-providers-error">
|
||||
<p class="error-message">No authentication providers available.</p>
|
||||
<p class="error-hint">Please contact your administrator.</p>
|
||||
|
||||
<!-- Show unavailable providers even when no available providers -->
|
||||
<div v-if="availableProviders.filter(p => !p.available).length > 0" class="unavailable-section">
|
||||
<p class="unavailable-header">Currently unavailable:</p>
|
||||
<div
|
||||
v-for="provider in availableProviders.filter(p => !p.available)"
|
||||
:key="provider.name"
|
||||
class="provider-unavailable"
|
||||
>
|
||||
<div class="provider-unavailable-header">
|
||||
<span class="provider-status-indicator offline">●</span>
|
||||
<span class="provider-name">{{ formatProviderName(provider.name) }}</span>
|
||||
<span class="unavailable-label">Unavailable</span>
|
||||
</div>
|
||||
<div v-if="provider.error" class="provider-error">
|
||||
<div class="provider-error-text">{{ provider.error }}</div>
|
||||
<button
|
||||
@click.stop="copyToClipboard(provider.error)"
|
||||
class="copy-button"
|
||||
title="Copy error message"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available providers -->
|
||||
<div v-else class="provider-selection">
|
||||
<p class="selection-hint">Choose your authentication provider:</p>
|
||||
|
||||
<div class="available-providers">
|
||||
<button
|
||||
v-for="provider in availableProviders.filter(p => p.available)"
|
||||
:key="provider.name"
|
||||
class="provider-button"
|
||||
@click="loginWithProvider(provider.name); showProviderSelector = false"
|
||||
>
|
||||
<span class="provider-button-content">
|
||||
<span class="provider-status-indicator online">●</span>
|
||||
<span class="provider-button-text">{{ formatProviderName(provider.name) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Unavailable providers section -->
|
||||
<div v-if="availableProviders.filter(p => !p.available).length > 0" class="unavailable-section">
|
||||
<p class="unavailable-header">Currently unavailable:</p>
|
||||
<div
|
||||
v-for="provider in availableProviders.filter(p => !p.available)"
|
||||
:key="provider.name"
|
||||
class="provider-unavailable"
|
||||
>
|
||||
<div class="provider-unavailable-header">
|
||||
<span class="provider-status-indicator offline">●</span>
|
||||
<span class="provider-name">{{ formatProviderName(provider.name) }}</span>
|
||||
<span class="unavailable-label">Unavailable</span>
|
||||
</div>
|
||||
<div v-if="provider.error" class="provider-error">
|
||||
<div class="provider-error-text">{{ provider.error }}</div>
|
||||
<button
|
||||
@click.stop="copyToClipboard(provider.error)"
|
||||
class="copy-button"
|
||||
title="Copy error message"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@ -244,11 +499,26 @@ nav {
|
||||
}
|
||||
|
||||
a.login-button,
|
||||
a.logoff-button {
|
||||
a.logoff-button,
|
||||
button.login-button {
|
||||
margin: 5px;
|
||||
color: #ffffff;
|
||||
background-color: #32b9ce;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
button.login-button-disabled {
|
||||
margin: 5px;
|
||||
padding: 9px;
|
||||
color: #999999;
|
||||
background-color: #e0e0e0;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 8px;
|
||||
cursor: not-allowed;
|
||||
font-family: 'Roboto';
|
||||
font-size: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.login-button:hover,
|
||||
@ -256,21 +526,174 @@ a.logoff-button {
|
||||
color: #39455f;
|
||||
}
|
||||
|
||||
/*override element plus*/
|
||||
.el-dropdown-menu__item:hover {
|
||||
color: v-bind(headerLinksHoverColor) !important;
|
||||
/* Custom dropdown containers */
|
||||
#header-nav-versions,
|
||||
#header-nav-message-docs,
|
||||
#header-nav-debug {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Fix dropdown menu overflow */
|
||||
.el-dropdown-menu {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
/* Provider Selection Dialog */
|
||||
.provider-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Ensure dropdown trigger behaves correctly */
|
||||
#header-nav-more .el-dropdown-link {
|
||||
.selection-hint {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.available-providers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-button {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
background-color: #32b9ce;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.provider-button:hover {
|
||||
background-color: #2a9fb0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(50, 185, 206, 0.3);
|
||||
}
|
||||
|
||||
.provider-button-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-button-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.provider-status-indicator {
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.provider-status-indicator.online {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.provider-status-indicator.offline {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Unavailable providers section */
|
||||
.unavailable-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.unavailable-header {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.provider-unavailable {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
background-color: #f9fafb;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.provider-unavailable-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
flex: 1;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.unavailable-label {
|
||||
font-size: 11px;
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.provider-error {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-left: 22px;
|
||||
}
|
||||
|
||||
.provider-error-text {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* No providers error state */
|
||||
.no-providers-error {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 15px;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
328
src/components/JsonSchemaViewer.vue
Normal file
328
src/components/JsonSchemaViewer.vue
Normal file
@ -0,0 +1,328 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { DocumentCopy, Check } from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
schema: any
|
||||
copyable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
copyable: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
refClick: [refId: string]
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
const text = JSON.stringify(props.schema, null, 2)
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefClick = (event: Event, refId: string) => {
|
||||
event.preventDefault()
|
||||
emit('refClick', refId)
|
||||
}
|
||||
|
||||
// Function to render JSON with clickable $ref links
|
||||
const renderJsonWithRefs = (obj: any, indent: number = 0): any[] => {
|
||||
const result: any[] = []
|
||||
const indentStr = ' '.repeat(indent)
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
result.push({ type: 'value', text: String(obj) })
|
||||
return result
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
const valueClass = typeof obj === 'string' ? 'json-string' :
|
||||
typeof obj === 'number' ? 'json-number' :
|
||||
typeof obj === 'boolean' ? 'json-boolean' : 'json-value'
|
||||
const displayValue = typeof obj === 'string' ? `"${obj}"` : String(obj)
|
||||
result.push({ type: 'value', text: displayValue, class: valueClass })
|
||||
return result
|
||||
}
|
||||
|
||||
const isArray = Array.isArray(obj)
|
||||
const openBracket = isArray ? '[' : '{'
|
||||
const closeBracket = isArray ? ']' : '}'
|
||||
|
||||
result.push({ type: 'bracket', text: openBracket })
|
||||
|
||||
const entries = isArray ? obj.map((val, idx) => [idx, val]) : Object.entries(obj)
|
||||
const totalEntries = entries.length
|
||||
|
||||
entries.forEach(([key, value], index) => {
|
||||
const isLast = index === totalEntries - 1
|
||||
const newIndent = indent + 1
|
||||
const newIndentStr = ' '.repeat(newIndent)
|
||||
|
||||
result.push({ type: 'newline', text: '\n' })
|
||||
result.push({ type: 'indent', text: newIndentStr })
|
||||
|
||||
// Add key for objects
|
||||
if (!isArray) {
|
||||
result.push({ type: 'key', text: `"${key}"`, class: 'json-key' })
|
||||
result.push({ type: 'separator', text: ': ' })
|
||||
}
|
||||
|
||||
// Check if this is a $ref
|
||||
if (key === '$ref' && typeof value === 'string') {
|
||||
// Extract definition name from $ref
|
||||
const refMatch = value.match(/#\/definitions\/(.+)$/)
|
||||
if (refMatch) {
|
||||
const defName = refMatch[1]
|
||||
result.push({ type: 'ref', text: `"${value}"`, href: `#def-${defName}`, defName })
|
||||
} else {
|
||||
result.push({ type: 'value', text: `"${value}"`, class: 'json-string' })
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
// Recursively render nested objects/arrays
|
||||
const nested = renderJsonWithRefs(value, newIndent)
|
||||
result.push(...nested)
|
||||
} else {
|
||||
// Render primitive values
|
||||
const valueClass = typeof value === 'string' ? 'json-string' :
|
||||
typeof value === 'number' ? 'json-number' :
|
||||
typeof value === 'boolean' ? 'json-boolean' :
|
||||
value === null ? 'json-null' : 'json-value'
|
||||
const displayValue = typeof value === 'string' ? `"${value}"` : String(value)
|
||||
result.push({ type: 'value', text: displayValue, class: valueClass })
|
||||
}
|
||||
|
||||
// Add comma if not last
|
||||
if (!isLast) {
|
||||
result.push({ type: 'comma', text: ',' })
|
||||
}
|
||||
})
|
||||
|
||||
if (totalEntries > 0) {
|
||||
result.push({ type: 'newline', text: '\n' })
|
||||
result.push({ type: 'indent', text: indentStr })
|
||||
}
|
||||
result.push({ type: 'bracket', text: closeBracket })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const jsonElements = computed(() => {
|
||||
return renderJsonWithRefs(props.schema)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="json-schema-viewer">
|
||||
<div v-if="copyable" class="schema-header">
|
||||
<button
|
||||
@click="copyToClipboard"
|
||||
class="copy-button"
|
||||
:class="{ 'copied': copied }"
|
||||
>
|
||||
<span v-if="!copied">
|
||||
<el-icon :size="10">
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
Copy
|
||||
</span>
|
||||
<span v-else>
|
||||
<el-icon :size="10">
|
||||
<Check />
|
||||
</el-icon>
|
||||
Copied!
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="schema-container">
|
||||
<pre class="schema-pre"><code class="schema-code"><template v-for="(element, index) in jsonElements" :key="index"><span
|
||||
v-if="element.type === 'ref'"
|
||||
class="json-ref"
|
||||
:title="`Jump to definition: ${element.defName}`"
|
||||
><a :href="element.href" class="ref-link" @click="handleRefClick($event, element.href)">{{ element.text }}</a></span><span
|
||||
v-else-if="element.type === 'key' || element.type === 'value'"
|
||||
:class="element.class"
|
||||
>{{ element.text }}</span><span v-else>{{ element.text }}</span></template></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-schema-viewer {
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.schema-header {
|
||||
background: #2d2d2d;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: #444;
|
||||
border: 1px solid #666;
|
||||
color: #ddd;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #555;
|
||||
border-color: #777;
|
||||
}
|
||||
|
||||
.copy-button.copied {
|
||||
background: #4caf50;
|
||||
border-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.schema-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.schema-pre {
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
background: #1e1e1e;
|
||||
color: #ddd;
|
||||
font-family: 'Fira Code', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.schema-code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* JSON Syntax Highlighting */
|
||||
.json-key {
|
||||
color: #e06c75;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.json-string {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
.json-number {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
.json-boolean {
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.json-null {
|
||||
color: #c678dd;
|
||||
}
|
||||
|
||||
.json-ref {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ref-link {
|
||||
color: #61afef;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ref-link:hover {
|
||||
color: #84c5ff;
|
||||
text-decoration-style: solid;
|
||||
background-color: rgba(97, 175, 239, 0.1);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.schema-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.schema-container::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.schema-container::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.schema-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
||||
.schema-pre::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.schema-pre::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.schema-pre::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.schema-pre::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
</style>
|
||||
@ -53,6 +53,7 @@ const clearCacheStorage = (event: any) => {
|
||||
<el-col :span="10" class="menu-left">
|
||||
|
||||
<span id="selected-api-version" class="host"></span>
|
||||
<span id="selected-endpoint-tags" class="endpoint-tags"></span>
|
||||
</el-col>
|
||||
<el-col :span="14" class="menu-right">
|
||||
<span class="host" id="cache-storage-status" @click="clearCacheStorage">App Version: {{ APP_VERSION }}</span>
|
||||
@ -115,4 +116,22 @@ a:hover {
|
||||
.text-is-red {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.endpoint-tags {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.endpoint-tags :deep(.tag-link) {
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.endpoint-tags :deep(.tag-link:hover) {
|
||||
color: #66b1ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
183
src/components/MessageDocsJsonSchemaSearchNav.vue
Normal file
183
src/components/MessageDocsJsonSchemaSearchNav.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onBeforeMount, inject, watch } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { SEARCH_LINKS_COLOR as searchLinksColorSetting } from '../obp/style-setting'
|
||||
import { connectors } from '../obp/message-docs'
|
||||
import { obpGroupedMessageDocsJsonSchemaKey } from '@/obp/keys'
|
||||
|
||||
let connector = connectors[0]
|
||||
const route = useRoute()
|
||||
const groupedMessageDocsJsonSchema = ref(inject(obpGroupedMessageDocsJsonSchemaKey) || {})
|
||||
const docs = ref({})
|
||||
const groups = ref({})
|
||||
const sortedKeys = ref([])
|
||||
const activeKeys = ref([])
|
||||
const messageDocKeys = ref([])
|
||||
const searchLinksColor = ref(searchLinksColorSetting)
|
||||
const form = reactive({
|
||||
search: ''
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
setDocs()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (id) => {
|
||||
setDocs()
|
||||
}
|
||||
)
|
||||
|
||||
const isKeyFound = (keys, item) => keys.every((k) => item.toLowerCase().includes(k))
|
||||
|
||||
const filterKeys = (keys, key) => {
|
||||
const splitKey = key.split(' ').map((k) => k.toLowerCase())
|
||||
return keys.filter((title) => {
|
||||
const isGroupFound = isKeyFound(splitKey, title)
|
||||
const items = docs.value[title].filter((item) => isGroupFound || isKeyFound(splitKey, item))
|
||||
groups.value[title] = items
|
||||
return isGroupFound || items.length > 0
|
||||
})
|
||||
}
|
||||
|
||||
const searchEvent = (value) => {
|
||||
if (value) {
|
||||
messageDocKeys.value = filterKeys(activeKeys.value, value)
|
||||
} else {
|
||||
groups.value = JSON.parse(JSON.stringify(docs.value))
|
||||
messageDocKeys.value = Object.keys(groups.value)
|
||||
}
|
||||
}
|
||||
|
||||
const setDocs = () => {
|
||||
const paramConnector = route.params.id
|
||||
if (connectors.includes(paramConnector)) {
|
||||
connector = paramConnector
|
||||
}
|
||||
const messageDocsJsonSchemaData = groupedMessageDocsJsonSchema.value[connector]
|
||||
const messageDocsJsonSchema = messageDocsJsonSchemaData?.grouped || messageDocsJsonSchemaData || {}
|
||||
|
||||
docs.value = Object.keys(messageDocsJsonSchema).reduce((doc, key) => {
|
||||
doc[key] = messageDocsJsonSchema[key].map((group) => group.method_name)
|
||||
return doc
|
||||
}, {})
|
||||
groups.value = JSON.parse(JSON.stringify(docs.value))
|
||||
messageDocKeys.value = Object.keys(groups.value)
|
||||
activeKeys.value = Object.keys(groups.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-input
|
||||
v-model="form.search"
|
||||
placeholder="Search"
|
||||
:prefix-icon="Search"
|
||||
@input="searchEvent"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-collapse v-model="activeKeys">
|
||||
<el-collapse-item v-for="key in messageDocKeys" :title="key" :key="key" :name="key">
|
||||
<div class="el-tabs--right">
|
||||
<div v-for="(value, key) of groups[key]" :key="value" class="message-docs-router-tab">
|
||||
<a class="message-docs-router-link" :id="`${value}-quick-nav`" v-bind:href="`#${value}`">
|
||||
{{ value }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-router-link {
|
||||
width: 100%;
|
||||
margin-left: 15px;
|
||||
font-family: 'Roboto';
|
||||
text-decoration: none;
|
||||
color: #39455f;
|
||||
display: inline-block;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.api-router-tab {
|
||||
border-left: 2px solid var(--el-menu-border-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.api-router-tab:hover,
|
||||
.active-api-router-tab {
|
||||
border-left: 2px solid v-bind(searchLinksColor);
|
||||
}
|
||||
|
||||
.api-router-tab:hover .api-router-link,
|
||||
.active-api-router-link {
|
||||
color: v-bind(searchLinksColor);
|
||||
}
|
||||
|
||||
.message-docs-router-link {
|
||||
margin-left: 15px;
|
||||
font-size: 13px;
|
||||
font-family: 'Roboto';
|
||||
text-decoration: none;
|
||||
color: #39455f;
|
||||
display: inline-block;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-docs-router-tab {
|
||||
border-left: 2px solid var(--el-menu-border-color);
|
||||
line-height: 30px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-docs-router-tab:hover,
|
||||
.active-message-docs-router-tab {
|
||||
border-left: 2px solid v-bind(searchLinksColor);
|
||||
}
|
||||
|
||||
.message-docs-router-tab:hover .message-docs-router-link {
|
||||
color: v-bind(searchLinksColor);
|
||||
}
|
||||
</style>
|
||||
@ -35,7 +35,7 @@ import { obpGroupedMessageDocsKey } from '@/obp/keys'
|
||||
|
||||
let connector = connectors[0]
|
||||
const route = useRoute()
|
||||
const groupedMessageDocs = ref(inject(obpGroupedMessageDocsKey)!)
|
||||
const groupedMessageDocs = ref(inject(obpGroupedMessageDocsKey) || {})
|
||||
const docs = ref({})
|
||||
const groups = ref({})
|
||||
const sortedKeys = ref([])
|
||||
|
||||
@ -30,7 +30,7 @@ import { ref, reactive, inject, onBeforeMount } from 'vue'
|
||||
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
|
||||
import { getOperationDetails } from '../obp/resource-docs'
|
||||
import { ElNotification, FormInstance } from 'element-plus'
|
||||
import { OBP_API_VERSION, get, create, update, discard, createEntitlement, getCurrentUser } from '../obp'
|
||||
import { OBP_API_DEFAULT_RESOURCE_DOC_VERSION, get, create, update, discard, createEntitlement, getCurrentUser, getUserEntitlements } from '../obp'
|
||||
import { obpResourceDocsKey } from '@/obp/keys'
|
||||
import JsonEditorVue from 'json-editor-vue'
|
||||
import { Mode } from 'vanilla-jsoneditor'
|
||||
@ -38,7 +38,7 @@ import 'vanilla-jsoneditor/themes/jse-theme-dark.css'
|
||||
import * as cheerio from 'cheerio'
|
||||
|
||||
const elMessageDuration = 5500
|
||||
const configVersion = 'OBP' + OBP_API_VERSION
|
||||
const configVersion = OBP_API_DEFAULT_RESOURCE_DOC_VERSION
|
||||
const url = ref('')
|
||||
const roleName = ref('')
|
||||
const method = ref('')
|
||||
@ -57,6 +57,7 @@ const showValidations = ref(true)
|
||||
const showPossibleErrors = ref(true)
|
||||
const showConnectorMethods = ref(true)
|
||||
const isUserLogon = ref(true)
|
||||
const userEntitlements = ref([])
|
||||
const type = ref('')
|
||||
const resourceDocs = inject(obpResourceDocsKey)
|
||||
const footNote = ref({
|
||||
@ -74,7 +75,24 @@ const roleForm = reactive({})
|
||||
|
||||
const setOperationDetails = (id: string, version: string): void => {
|
||||
const operation = getOperationDetails(version, id, resourceDocs)
|
||||
url.value = operation?.specified_url
|
||||
|
||||
// Safety check: if operation doesn't exist (e.g., after version change), return early
|
||||
if (!operation) {
|
||||
console.warn(`Operation "${id}" not found in version "${version}"`)
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the version in the URL with the current viewing version
|
||||
// This ensures users test against the version they're viewing (e.g., v6.0.0)
|
||||
// even if the endpoint was originally defined in an earlier version (e.g., v3.1.0)
|
||||
if (operation?.specified_url) {
|
||||
// Extract version without OBP prefix for URL replacement (e.g., "OBPv6.0.0" -> "v6.0.0")
|
||||
const versionWithoutPrefix = version.replace('OBP', '')
|
||||
// Replace /obp/vX.X.X/ with the current version
|
||||
url.value = operation.specified_url.replace(/\/obp\/v\d+\.\d+\.\d+\//, `/obp/${versionWithoutPrefix}/`)
|
||||
} else {
|
||||
url.value = operation?.specified_url
|
||||
}
|
||||
method.value = operation?.request_verb
|
||||
exampleRequestBody.value = operation.example_request_body
|
||||
requiredRoles.value = operation.roles || []
|
||||
@ -84,7 +102,7 @@ const setOperationDetails = (id: string, version: string): void => {
|
||||
showValidations.value = validations.value.length > 0
|
||||
showPossibleErrors.value = possibleErrors.value.length > 0
|
||||
showConnectorMethods.value = true
|
||||
footNote.value.version = operation.operation_id
|
||||
footNote.value.operationId = operation.operation_id
|
||||
footNote.value.version = operation.implemented_by.version
|
||||
footNote.value.functionName = operation.implemented_by.function
|
||||
footNote.value.messageTags = operation.tags.join(',')
|
||||
@ -101,6 +119,43 @@ const setRoleForm = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshEntitlements = async () => {
|
||||
const currentUser = await getCurrentUser()
|
||||
if (currentUser.username) {
|
||||
const entitlements = await getUserEntitlements()
|
||||
if (entitlements && entitlements.list) {
|
||||
userEntitlements.value = entitlements.list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasEntitlement = (roleName: string, bankId: string = '', requiresBankId: boolean = false): boolean => {
|
||||
if (!userEntitlements.value || userEntitlements.value.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (requiresBankId) {
|
||||
// For bank-level roles, check if user has the role for the specific bank
|
||||
// Only return true if bankId is provided and matches
|
||||
if (!bankId) {
|
||||
return false
|
||||
}
|
||||
return userEntitlements.value.some(e => e.role_name === roleName && e.bank_id === bankId)
|
||||
} else {
|
||||
// For system-wide roles, just check if user has the role
|
||||
return userEntitlements.value.some(e => e.role_name === roleName)
|
||||
}
|
||||
}
|
||||
|
||||
const getEntitlementBankIds = (roleName: string): string[] => {
|
||||
if (!userEntitlements.value || userEntitlements.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
return userEntitlements.value
|
||||
.filter(e => e.role_name === roleName && e.bank_id)
|
||||
.map(e => e.bank_id)
|
||||
}
|
||||
|
||||
const setType = (method) => {
|
||||
switch (method) {
|
||||
case 'POST': {
|
||||
@ -172,58 +227,385 @@ const submit = async (form: FormInstance, fn: () => void) => {
|
||||
if (!form) return
|
||||
fn(form).then(() => {})
|
||||
}
|
||||
// Helper function to recursively parse double-encoded JSON strings
|
||||
const parseDoubleEncodedJson = (obj: any): any => {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj
|
||||
}
|
||||
|
||||
// If it's a string, try to parse it as JSON
|
||||
if (typeof obj === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(obj)
|
||||
// Recursively parse the result in case it's triple-encoded or more
|
||||
return parseDoubleEncodedJson(parsed)
|
||||
} catch (e) {
|
||||
// If parsing fails, return the original string
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
// If it's an array, recursively parse each element
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => parseDoubleEncodedJson(item))
|
||||
}
|
||||
|
||||
// If it's an object, recursively parse each property
|
||||
if (typeof obj === 'object') {
|
||||
const result = {}
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
result[key] = parseDoubleEncodedJson(obj[key])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// For other types (numbers, booleans, etc.), return as-is
|
||||
return obj
|
||||
}
|
||||
|
||||
const highlightCode = (json) => {
|
||||
if (!json) {
|
||||
successResponseBody.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
successResponseBody.value = json.error.message
|
||||
} else if (json) {
|
||||
// Parse double-encoded JSON error messages to display them cleanly
|
||||
const errorObj = parseDoubleEncodedJson(json.error)
|
||||
|
||||
// Display the full OBP error object with proper formatting
|
||||
successResponseBody.value = hljs.lineNumbersValue(
|
||||
hljs.highlightAuto(JSON.stringify(json, null, 4), ['JSON']).value
|
||||
hljs.highlightAuto(JSON.stringify(errorObj, null, 4), ['JSON']).value
|
||||
)
|
||||
} else {
|
||||
successResponseBody.value = ''
|
||||
// Parse double-encoded JSON in successful responses too
|
||||
const parsedJson = parseDoubleEncodedJson(json)
|
||||
successResponseBody.value = hljs.lineNumbersValue(
|
||||
hljs.highlightAuto(JSON.stringify(parsedJson, null, 4), ['JSON']).value
|
||||
)
|
||||
}
|
||||
}
|
||||
const submitEntitlement = async () => {
|
||||
requiredRoles.value.forEach(async (formRole, idx) => {
|
||||
if (formRole.requires_bank_id) {
|
||||
const role = roleForm[`role${formRole.role}${idx}`]
|
||||
const bankId = roleForm[`bankId${formRole.role}${idx}`]
|
||||
if (role && bankId && isUserLogon) {
|
||||
const response = await createEntitlement(bankId, role)
|
||||
let type = 'success'
|
||||
if ('code' in response && response['code'] >= 400) {
|
||||
type = 'error'
|
||||
const submitSingleEntitlement = async (formRole: any, idx: number) => {
|
||||
const role = roleForm[`role${formRole.role}${idx}`]
|
||||
|
||||
if (formRole.requires_bank_id) {
|
||||
// Bank-level entitlement
|
||||
const bankId = roleForm[`bankId${formRole.role}${idx}`]
|
||||
|
||||
if (!role || !bankId) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Validation Error',
|
||||
message: 'Please fill in both Role and Bank ID fields',
|
||||
position: 'bottom-right',
|
||||
type: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createEntitlement(bankId, role)
|
||||
|
||||
// Check if response is an error object (from superagent)
|
||||
const isError = response && typeof response === 'object' && 'error' in response
|
||||
const errorBody = isError ? response.error : null
|
||||
|
||||
if (isError && errorBody && errorBody.code >= 400) {
|
||||
// Parse error message from body
|
||||
let errorMessage = 'Failed to create entitlement'
|
||||
if (errorBody.message) {
|
||||
// Message might be double-encoded JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody.message)
|
||||
errorMessage = parsed.message || parsed.error || errorBody.message
|
||||
} catch {
|
||||
errorMessage = errorBody.message
|
||||
}
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
message: response.message,
|
||||
title: 'Request Failed',
|
||||
message: errorMessage,
|
||||
position: 'bottom-right',
|
||||
type
|
||||
type: 'error'
|
||||
})
|
||||
} else {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
message: 'Bank Id is required.',
|
||||
title: 'Success',
|
||||
message: `Entitlement "${role}" requested successfully for bank "${bankId}"`,
|
||||
position: 'bottom-right',
|
||||
type: 'success'
|
||||
})
|
||||
// Refresh entitlements after successful request
|
||||
await refreshEntitlements()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Request Failed',
|
||||
message: error.message || 'An error occurred while requesting the entitlement',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// System-wide entitlement (no bank_id required)
|
||||
if (!role) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Validation Error',
|
||||
message: 'Please select a role',
|
||||
position: 'bottom-right',
|
||||
type: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// System-wide entitlement uses empty string for bank_id
|
||||
const response = await createEntitlement('', role)
|
||||
|
||||
// Check if response is an error object (from superagent)
|
||||
const isError = response && typeof response === 'object' && 'error' in response
|
||||
const errorBody = isError ? response.error : null
|
||||
|
||||
if (isError && errorBody && errorBody.code >= 400) {
|
||||
// Parse error message from body
|
||||
let errorMessage = 'Failed to create entitlement'
|
||||
if (errorBody.message) {
|
||||
// Message might be double-encoded JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody.message)
|
||||
errorMessage = parsed.message || parsed.error || errorBody.message
|
||||
} catch {
|
||||
errorMessage = errorBody.message
|
||||
}
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Request Failed',
|
||||
message: errorMessage,
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
} else {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Success',
|
||||
message: `System-wide entitlement "${role}" requested successfully`,
|
||||
position: 'bottom-right',
|
||||
type: 'success'
|
||||
})
|
||||
// Refresh entitlements after successful request
|
||||
await refreshEntitlements()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Request Failed',
|
||||
message: error.message || 'An error occurred while requesting the entitlement',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submitEntitlement = async () => {
|
||||
for (const [idx, formRole] of requiredRoles.value.entries()) {
|
||||
const role = roleForm[`role${formRole.role}${idx}`]
|
||||
|
||||
if (formRole.requires_bank_id) {
|
||||
// Bank-level entitlement
|
||||
const bankId = roleForm[`bankId${formRole.role}${idx}`]
|
||||
|
||||
if (!role || !bankId) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Missing Information',
|
||||
message: 'Bank ID is required for this role.',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isUserLogon) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Not Authenticated',
|
||||
message: 'Please login to request this role.',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createEntitlement(bankId, role)
|
||||
|
||||
// Check if response is an error object (from superagent)
|
||||
const isError = response && response.error && response.error.response
|
||||
const errorBody = isError ? response.error.response.body : null
|
||||
const statusCode = isError ? response.error.status : null
|
||||
|
||||
if (isError && errorBody && errorBody.code >= 400) {
|
||||
// Parse error message from body
|
||||
let errorMessage = 'Failed to create entitlement'
|
||||
if (errorBody.message) {
|
||||
// Message might be double-encoded JSON string
|
||||
try {
|
||||
const parsedMessage = JSON.parse(errorBody.message)
|
||||
errorMessage = parsedMessage.message || errorBody.message
|
||||
} catch {
|
||||
errorMessage = errorBody.message
|
||||
}
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: `Error (${errorBody.code})`,
|
||||
message: errorMessage,
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
} else {
|
||||
// Success
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Success',
|
||||
message: `Entitlement "${role}" requested successfully for bank "${bankId}"`,
|
||||
position: 'bottom-right',
|
||||
type: 'success'
|
||||
})
|
||||
// Refresh entitlements after successful request
|
||||
await refreshEntitlements()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Request Failed',
|
||||
message: error.message || 'An error occurred while requesting the entitlement',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// System-wide entitlement (no bank_id required)
|
||||
if (!role) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Missing Information',
|
||||
message: 'Role name is required.',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isUserLogon) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Not Authenticated',
|
||||
message: 'Please login to request this role.',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// System-wide entitlement uses empty string for bank_id
|
||||
const response = await createEntitlement('', role)
|
||||
|
||||
// Check if response is an error object (from superagent)
|
||||
const isError = response && response.error && response.error.response
|
||||
const errorBody = isError ? response.error.response.body : null
|
||||
const statusCode = isError ? response.error.status : null
|
||||
|
||||
if (isError && errorBody && errorBody.code >= 400) {
|
||||
// Parse error message from body
|
||||
let errorMessage = 'Failed to create entitlement'
|
||||
if (errorBody.message) {
|
||||
// Message might be double-encoded JSON string
|
||||
try {
|
||||
const parsedMessage = JSON.parse(errorBody.message)
|
||||
errorMessage = parsedMessage.message || errorBody.message
|
||||
} catch {
|
||||
errorMessage = errorBody.message
|
||||
}
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: `Error (${errorBody.code})`,
|
||||
message: errorMessage,
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
} else {
|
||||
// Success
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Success',
|
||||
message: `System-wide entitlement "${role}" requested successfully`,
|
||||
position: 'bottom-right',
|
||||
type: 'success'
|
||||
})
|
||||
// Refresh entitlements after successful request
|
||||
await refreshEntitlements()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElNotification({
|
||||
duration: elMessageDuration,
|
||||
title: 'Request Failed',
|
||||
message: error.message || 'An error occurred while requesting the entitlement',
|
||||
position: 'bottom-right',
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
onBeforeMount(async () => {
|
||||
const route = useRoute()
|
||||
const version = route.query.version ? route.query.version : configVersion
|
||||
setOperationDetails(route.params.id, version)
|
||||
const version = route.params.version ? route.params.version : configVersion
|
||||
|
||||
// Only set operation details if operationid exists
|
||||
if (route.query.operationid) {
|
||||
setOperationDetails(route.query.operationid, version)
|
||||
}
|
||||
|
||||
const currentUser = await getCurrentUser()
|
||||
isUserLogon.value = currentUser.username
|
||||
|
||||
// Fetch user entitlements
|
||||
if (currentUser.username) {
|
||||
const entitlements = await getUserEntitlements()
|
||||
if (entitlements && entitlements.list) {
|
||||
userEntitlements.value = entitlements.list
|
||||
}
|
||||
}
|
||||
|
||||
setRoleForm()
|
||||
})
|
||||
onBeforeRouteUpdate((to) => {
|
||||
const version = to.query.version ? to.query.version : configVersion
|
||||
setOperationDetails(to.params.id, version)
|
||||
responseHeaderTitle.value = 'TYPICAL SUCCESSFUL RESPONSE'
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
const version = to.params.version ? to.params.version : configVersion
|
||||
|
||||
// Only set operation details if operationid exists
|
||||
if (to.query.operationid) {
|
||||
setOperationDetails(to.query.operationid, version)
|
||||
responseHeaderTitle.value = 'TYPICAL SUCCESSFUL RESPONSE'
|
||||
}
|
||||
|
||||
// Refresh entitlements on route change
|
||||
await refreshEntitlements()
|
||||
|
||||
setRoleForm()
|
||||
})
|
||||
|
||||
@ -310,8 +692,8 @@ const onError = (error) => {
|
||||
placeholder="Request Header (Header1:Value1::Header2:Value2)"
|
||||
/>
|
||||
</div>
|
||||
<div class="json-editor-container" v-show="exampleRequestBody">
|
||||
<p v-show="exampleRequestBody" class="header-container request-body-header">{{ exampleBodyTitle }}:</p>
|
||||
<div class="json-editor-container" v-show="method === 'POST' || method === 'PUT' || method === 'DELETE'">
|
||||
<p class="header-container request-body-header">{{ exampleBodyTitle }}:</p>
|
||||
<div class="json-editor jse-theme-dark">
|
||||
<JsonEditorVue
|
||||
v-model="exampleRequestBody"
|
||||
@ -337,7 +719,7 @@ const onError = (error) => {
|
||||
<div v-show="showRequiredRoles">
|
||||
<p>{{ $t('preview.required_roles') }}:</p>
|
||||
<el-alert v-show="!isUserLogon" type="info" show-icon :closable="false">
|
||||
<p>Please login to request this Role.</p>
|
||||
<p>Please login to request Roles.</p>
|
||||
</el-alert>
|
||||
<ul>
|
||||
<li
|
||||
@ -346,24 +728,52 @@ const onError = (error) => {
|
||||
:name="role.role"
|
||||
|
||||
>
|
||||
<p>{{ role.role }}</p>
|
||||
<div class="flex-role-preview-panel" id="request-role-button-panel">
|
||||
<el-form-item v-show="role.requires_bank_id" :prop=" `bankId${role.role}${idx}`">
|
||||
<input
|
||||
type="text"
|
||||
v-model="roleForm[`bankId${role.role}${idx}`]"
|
||||
placeholder="Bank ID"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="role-header">
|
||||
<div class="role-name-section">
|
||||
<p>{{ role.role }}</p>
|
||||
<!-- Show existing bank IDs for bank-level roles -->
|
||||
<div v-if="role.requires_bank_id && getEntitlementBankIds(role.role).length > 0" class="existing-entitlements">
|
||||
<span class="entitlement-label">You have this at:</span>
|
||||
<span
|
||||
v-for="bankId in getEntitlementBankIds(role.role)"
|
||||
:key="bankId"
|
||||
class="bank-id-badge"
|
||||
>
|
||||
{{ bankId }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Always show input for bank-level roles when logged in -->
|
||||
<el-form-item
|
||||
v-show="isUserLogon && role.requires_bank_id"
|
||||
:prop="`bankId${role.role}${idx}`"
|
||||
class="role-bank-id-input"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="roleForm[`bankId${role.role}${idx}`]"
|
||||
placeholder="Bank ID"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<!-- Show "You have this Entitlement" only for system-wide roles -->
|
||||
<span
|
||||
v-if="!role.requires_bank_id && hasEntitlement(role.role, '', role.requires_bank_id)"
|
||||
class="entitlement-owned-text"
|
||||
>
|
||||
You have this Entitlement
|
||||
</span>
|
||||
<!-- For bank-level roles, always show Request button when logged in -->
|
||||
<!-- For system-wide roles, only show if they don't have it -->
|
||||
<el-button
|
||||
class="role-request-button"
|
||||
v-show="isUserLogon && (role.requires_bank_id || !hasEntitlement(role.role, '', role.requires_bank_id))"
|
||||
@click="submit(roleFormRef, () => submitSingleEntitlement(role, idx))"
|
||||
size="small"
|
||||
>Request</el-button
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<el-button
|
||||
id="request-role-button"
|
||||
v-show="isUserLogon"
|
||||
@click="submit(roleFormRef, submitEntitlement)"
|
||||
>Request</el-button
|
||||
>
|
||||
</div>
|
||||
</el-form>
|
||||
<!--<div v-show="showValidations">-->
|
||||
@ -401,8 +811,7 @@ const onError = (error) => {
|
||||
<el-divider class="divider" />
|
||||
<div>
|
||||
<p class="footnote">
|
||||
Version: {{ footNote.version }}, function_name: by {{ footNote.functionName }},
|
||||
operation_id: {{ footNote.functionName }}, Message Tags: {{ footNote.messageTags }}
|
||||
Implemented in: {{ footNote.version }} by function_name: {{ footNote.functionName }} (operation_id: {{ footNote.operationId }}). Message Tags: {{ footNote.messageTags }}
|
||||
</p>
|
||||
</div>
|
||||
<br />
|
||||
@ -454,9 +863,18 @@ input[type='text']:focus {
|
||||
}
|
||||
ul {
|
||||
margin-left: -10px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
padding: 5px 0 5px 0;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #414d63;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(65, 77, 99, 0.2);
|
||||
}
|
||||
li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.content p a::after {
|
||||
content: '';
|
||||
@ -549,6 +967,56 @@ li {
|
||||
width: 95%;
|
||||
margin: 0 0 -30px 0;
|
||||
}
|
||||
.role-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.role-name-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
.role-bank-id-input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.role-bank-id-input input {
|
||||
width: 200px;
|
||||
}
|
||||
.role-request-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.role-header p {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.entitlement-owned-text {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.existing-entitlements {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.entitlement-label {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bank-id-badge {
|
||||
background-color: rgba(103, 194, 58, 0.2);
|
||||
color: #67c23a;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(103, 194, 58, 0.3);
|
||||
}
|
||||
|
||||
#conector-method-link {
|
||||
color: white !important;
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
import { obpResourceDocsKey } from '@/obp/keys'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { inject, onBeforeMount, onMounted, reactive, ref, watch } from 'vue'
|
||||
@ -80,7 +81,7 @@ export const initializeAPICollections = async () => {
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
let selectedVersion = route.query.version ? route.query.version : `${OBP_API_DEFAULT_RESOURCE_DOC_VERSION}`
|
||||
let selectedVersion = route.params.version ? route.params.version : `${OBP_API_DEFAULT_RESOURCE_DOC_VERSION}`
|
||||
let selectedTags = route.query.tags ? route.query.tags : 'NONE'
|
||||
onBeforeMount(async () => {
|
||||
resourceDocs.value = inject(obpResourceDocsKey)!
|
||||
@ -93,33 +94,110 @@ onBeforeMount(async () => {
|
||||
activeKeys.value = Object.keys(groups.value)
|
||||
sortedKeys.value = activeKeys.value.sort()
|
||||
await initializeAPICollections()
|
||||
setTabActive(route.params.id)
|
||||
setTabActive(route.query.operationid)
|
||||
let element = document.getElementById("selected-api-version")
|
||||
if (element !== null) {
|
||||
const totalRows = Object.values(groups.value).reduce((acc, currentValue) => acc + currentValue.length, 0)
|
||||
if(selectedTags === 'NONE') {
|
||||
element.textContent = `${selectedVersion} ( ${totalRows} APIs )`;
|
||||
} else {
|
||||
element.textContent = `${selectedVersion} ( ${totalRows} APIs filtered by tags: ${selectedTags})`;
|
||||
element.innerHTML = `${selectedVersion} ( ${totalRows} APIs filtered by tags: <a href="#" class="filter-tag-link" style="color: #409eff; text-decoration: none; cursor: pointer; transition: color 0.2s ease;">${selectedTags}</a>)`;
|
||||
|
||||
// Add hover effect
|
||||
const tagLinkEl = element.querySelector('.filter-tag-link') as HTMLElement
|
||||
if (tagLinkEl) {
|
||||
tagLinkEl.addEventListener('mouseenter', () => {
|
||||
tagLinkEl.style.color = '#66b1ff'
|
||||
tagLinkEl.style.textDecoration = 'underline'
|
||||
})
|
||||
tagLinkEl.addEventListener('mouseleave', () => {
|
||||
tagLinkEl.style.color = '#409eff'
|
||||
tagLinkEl.style.textDecoration = 'none'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
routeToFirstAPI()
|
||||
onMounted(async () => {
|
||||
// Only auto-route if there's already an operationid in the URL
|
||||
if (route.query.operationid) {
|
||||
await nextTick()
|
||||
routeToFirstAPI()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.version,
|
||||
() => route.params.version,
|
||||
async (version) => {
|
||||
console.log('SearchNav: version changed to:', version)
|
||||
selectedVersion = version
|
||||
docs.value = getGroupedResourceDocs(version, resourceDocs.value)
|
||||
selectedTags = route.query.tags ? route.query.tags : 'NONE'
|
||||
if(selectedTags === 'NONE') {
|
||||
docs.value = getGroupedResourceDocs(version, resourceDocs.value)
|
||||
} else {
|
||||
docs.value = getFilteredGroupedResourceDocs(version, selectedTags, resourceDocs.value)
|
||||
}
|
||||
groups.value = JSON.parse(JSON.stringify(docs.value))
|
||||
activeKeys.value = Object.keys(groups.value)
|
||||
sortedKeys.value = activeKeys.value.sort()
|
||||
console.log('SearchNav: groups loaded, total groups:', activeKeys.value.length)
|
||||
await initializeAPICollections()
|
||||
await nextTick()
|
||||
// Only auto-route if there's an operationid in the URL (user navigated directly to an endpoint)
|
||||
if (route.query.operationid) {
|
||||
console.log('SearchNav: calling routeToFirstAPI')
|
||||
routeToFirstAPI()
|
||||
} else {
|
||||
console.log('SearchNav: no operationid, not auto-routing')
|
||||
}
|
||||
countApis()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.tags,
|
||||
async (tags) => {
|
||||
console.log('SearchNav: tags changed to:', tags)
|
||||
selectedTags = tags ? tags : 'NONE'
|
||||
if(selectedTags === 'NONE') {
|
||||
docs.value = getGroupedResourceDocs(selectedVersion, resourceDocs.value)
|
||||
} else {
|
||||
docs.value = getFilteredGroupedResourceDocs(selectedVersion, selectedTags, resourceDocs.value)
|
||||
}
|
||||
groups.value = JSON.parse(JSON.stringify(docs.value))
|
||||
activeKeys.value = Object.keys(groups.value)
|
||||
sortedKeys.value = activeKeys.value.sort()
|
||||
await initializeAPICollections()
|
||||
routeToFirstAPI()
|
||||
await nextTick()
|
||||
countApis()
|
||||
// Update the version display text
|
||||
let element = document.getElementById("selected-api-version")
|
||||
if (element !== null) {
|
||||
const totalRows = Object.values(groups.value).reduce((acc, currentValue) => acc + currentValue.length, 0)
|
||||
if(selectedTags === 'NONE') {
|
||||
element.textContent = `${selectedVersion} ( ${totalRows} APIs )`;
|
||||
} else {
|
||||
element.innerHTML = `${selectedVersion} ( ${totalRows} APIs filtered by tags: <a href="#" class="filter-tag-link" style="color: #409eff; text-decoration: none; cursor: pointer; transition: color 0.2s ease;">${selectedTags}</a>)`;
|
||||
|
||||
// Add hover effect
|
||||
const tagLinkEl = element.querySelector('.filter-tag-link') as HTMLElement
|
||||
if (tagLinkEl) {
|
||||
tagLinkEl.addEventListener('mouseenter', () => {
|
||||
tagLinkEl.style.color = '#66b1ff'
|
||||
tagLinkEl.style.textDecoration = 'underline'
|
||||
})
|
||||
tagLinkEl.addEventListener('mouseleave', () => {
|
||||
tagLinkEl.style.color = '#409eff'
|
||||
tagLinkEl.style.textDecoration = 'none'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -136,7 +214,9 @@ const countApis = () => {
|
||||
const routeToFirstAPI = () => {
|
||||
let element
|
||||
const elements = document.getElementsByClassName('api-router-link')
|
||||
const id = route.params.id
|
||||
console.log('routeToFirstAPI: found', elements.length, 'api links')
|
||||
const id = route.query.operationid
|
||||
console.log('routeToFirstAPI: looking for operationid:', id)
|
||||
for (const el of elements) {
|
||||
if (el.id === id) {
|
||||
element = el
|
||||
@ -144,9 +224,16 @@ const routeToFirstAPI = () => {
|
||||
}
|
||||
}
|
||||
if (element) {
|
||||
console.log('routeToFirstAPI: clicking matching element:', id)
|
||||
element.click()
|
||||
} else {
|
||||
if (elements.item(0)) elements.item(0).click()
|
||||
console.log('routeToFirstAPI: no match, clicking first element')
|
||||
if (elements.item(0)) {
|
||||
console.log('routeToFirstAPI: first element id:', elements.item(0).id)
|
||||
elements.item(0).click()
|
||||
} else {
|
||||
console.log('routeToFirstAPI: NO ELEMENTS FOUND!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,7 +281,11 @@ const filterKeys = (keys, key) => {
|
||||
|
||||
const searchEvent = (value) => {
|
||||
if (value) {
|
||||
sortedKeys.value = filterKeys(activeKeys.value, value)
|
||||
if (activeKeys.value && Array.isArray(activeKeys.value)) {
|
||||
sortedKeys.value = filterKeys(activeKeys.value, value)
|
||||
} else {
|
||||
sortedKeys.value = []
|
||||
}
|
||||
} else {
|
||||
groups.value = JSON.parse(JSON.stringify(docs.value))
|
||||
sortedKeys.value = Object.keys(groups.value).sort()
|
||||
@ -217,7 +308,7 @@ const searchEvent = (value) => {
|
||||
<div class="el-tabs--right">
|
||||
<div v-for="(value, key) of apiCollectionsEndpoint[api.api_collection_name]" :key="key" class="api-router-tab"
|
||||
@click="setActive">
|
||||
<RouterLink :to="{ name: 'api', params: { id: value }, query: { version: selectedVersion } }" :id="value"
|
||||
<RouterLink :to="{ name: 'api', params: { version: selectedVersion }, query: { operationid: value } }" :id="value"
|
||||
active-class="active-api-router-link" class="api-router-link">{{ operationIdTitle[value] }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@ -227,14 +318,14 @@ const searchEvent = (value) => {
|
||||
<div class="el-tabs--right">
|
||||
<div v-for="(value, key) of sortLinks(groups[key])" :key="value" class="api-router-tab" @click="setActive">
|
||||
<RouterLink active-class="active-api-router-link" class="api-router-link" :id="value"
|
||||
:to="{ name: 'api', params: { id: value }, query: { version: selectedVersion } }">{{ key }}</RouterLink>
|
||||
:to="{ name: 'api', params: { version: selectedVersion }, query: { operationid: value } }">{{ key }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -249,7 +340,7 @@ const searchEvent = (value) => {
|
||||
max-height: 100%;
|
||||
padding-right: 0;
|
||||
border-right: solid 1px var(--el-menu-border-color);
|
||||
|
||||
|
||||
}
|
||||
.search-nav-collapse {
|
||||
height: 100%;
|
||||
|
||||
123
src/components/SvelteDropdown.vue
Normal file
123
src/components/SvelteDropdown.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { mount, unmount } from 'svelte'
|
||||
import Dropdown from '../../src-svelte/Dropdown.svelte'
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
items?: string[]
|
||||
hoverColor?: string
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: 'Dropdown',
|
||||
items: () => [],
|
||||
hoverColor: '#32b9ce',
|
||||
backgroundColor: '#e8f4f8'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
let svelteComponent: any = null
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
try {
|
||||
svelteComponent = mount(Dropdown, {
|
||||
target: containerRef.value,
|
||||
props: {
|
||||
label: props.label,
|
||||
items: props.items,
|
||||
hoverColor: props.hoverColor,
|
||||
backgroundColor: props.backgroundColor
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for the custom 'select' event from Svelte component
|
||||
containerRef.value.addEventListener('select', (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
emit('select', customEvent.detail)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to mount Svelte Dropdown:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (svelteComponent) {
|
||||
try {
|
||||
unmount(svelteComponent)
|
||||
} catch (error) {
|
||||
console.error('Failed to unmount Svelte Dropdown:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for prop changes and update Svelte component
|
||||
watch(
|
||||
() => props.items,
|
||||
(newItems) => {
|
||||
if (svelteComponent && containerRef.value) {
|
||||
// Remount with new props
|
||||
unmount(svelteComponent)
|
||||
svelteComponent = mount(Dropdown, {
|
||||
target: containerRef.value,
|
||||
props: {
|
||||
label: props.label,
|
||||
items: newItems,
|
||||
hoverColor: props.hoverColor,
|
||||
backgroundColor: props.backgroundColor
|
||||
}
|
||||
})
|
||||
|
||||
// Re-add event listener
|
||||
containerRef.value.addEventListener('select', (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
emit('select', customEvent.detail)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="svelte-dropdown-wrapper"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.svelte-dropdown-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
265
src/main.ts
265
src/main.ts
@ -38,7 +38,12 @@ import { createI18n } from 'vue-i18n'
|
||||
import { languages, defaultLocale } from './language'
|
||||
|
||||
import { cache as cacheResourceDocs, cacheDoc as cacheResourceDocsDoc } from './obp/resource-docs'
|
||||
import { cache as cacheMessageDocs, cacheDoc as cacheMessageDocsDoc } from './obp/message-docs'
|
||||
import {
|
||||
cache as cacheMessageDocs,
|
||||
cacheDoc as cacheMessageDocsDoc,
|
||||
cacheJsonSchema as cacheMessageDocsJsonSchema,
|
||||
cacheDocJsonSchema as cacheMessageDocsJsonSchemaDoc
|
||||
} from './obp/message-docs'
|
||||
import { OBP_API_VERSION, getMyAPICollections, getMyAPICollectionsEndpoint } from './obp'
|
||||
import { getOBPGlossary } from './obp/glossary'
|
||||
|
||||
@ -47,9 +52,19 @@ import './assets/main.css'
|
||||
import '@fontsource/roboto/300.css'
|
||||
import '@fontsource/roboto/400.css'
|
||||
import '@fontsource/roboto/700.css'
|
||||
import { obpApiActiveVersionsKey, obpApiHostKey, obpGlossaryKey, obpGroupedMessageDocsKey, obpGroupedResourceDocsKey, obpMyCollectionsEndpointKey, obpResourceDocsKey } from './obp/keys'
|
||||
|
||||
import { getCacheStorageInfo } from './obp/common-functions'
|
||||
(async () => {
|
||||
import {
|
||||
obpApiActiveVersionsKey,
|
||||
obpApiHostKey,
|
||||
obpGlossaryKey,
|
||||
obpGroupedMessageDocsKey,
|
||||
obpGroupedMessageDocsJsonSchemaKey,
|
||||
obpGroupedResourceDocsKey,
|
||||
obpMyCollectionsEndpointKey,
|
||||
obpResourceDocsKey
|
||||
} from './obp/keys'
|
||||
;(async () => {
|
||||
const app = createApp(App)
|
||||
const router = await appRouter()
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
@ -65,7 +80,7 @@ import { getCacheStorageInfo } from './obp/common-functions'
|
||||
fallbackLocale: 'ES',
|
||||
messages
|
||||
})
|
||||
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.provide('i18n', i18n)
|
||||
@ -76,19 +91,186 @@ import { getCacheStorageInfo } from './obp/common-functions'
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
if (!isDataSetup) router.replace({ path: 'api-server-error' })
|
||||
if (!isDataSetup) {
|
||||
// Error details are already stored in sessionStorage by setupData catch block
|
||||
router.replace({ path: 'api-server-error' })
|
||||
}
|
||||
app.config.errorHandler = (error) => {
|
||||
console.log(error)
|
||||
router.replace({ path: 'error' })
|
||||
console.error('[APP ERROR]', error)
|
||||
// Show error details in browser DOM
|
||||
const errorDiv = document.createElement('div')
|
||||
errorDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
z-index: 10000;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
border: 1px solid #ddd;
|
||||
`
|
||||
let errorText = ''
|
||||
if (error instanceof Error) {
|
||||
errorText = `Application Error\n\nMessage:\n${error.message}\n\nStack:\n${error.stack || 'No stack trace available'}`
|
||||
errorDiv.innerHTML = `
|
||||
<strong style="font-size: 18px;">Application Error</strong><br><br>
|
||||
<strong>Message:</strong><br>${error.message}<br><br>
|
||||
<strong>Stack:</strong><br>${error.stack || 'No stack trace available'}
|
||||
`
|
||||
} else {
|
||||
errorText = `Application Error\n\n${JSON.stringify(error, null, 2)}`
|
||||
errorDiv.innerHTML = `
|
||||
<strong style="font-size: 18px;">Application Error</strong><br><br>
|
||||
${JSON.stringify(error, null, 2)}
|
||||
`
|
||||
}
|
||||
|
||||
const copyBtn = document.createElement('button')
|
||||
copyBtn.textContent = '📋 Copy'
|
||||
copyBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 90px;
|
||||
background: #e0e0e0;
|
||||
border: 1px solid #ccc;
|
||||
color: #333;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
`
|
||||
copyBtn.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorText)
|
||||
copyBtn.textContent = '✓ Copied!'
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = '📋 Copy'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy error:', err)
|
||||
copyBtn.textContent = '✗ Failed'
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = '📋 Copy'
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
errorDiv.appendChild(copyBtn)
|
||||
|
||||
const closeBtn = document.createElement('button')
|
||||
closeBtn.textContent = '✕ Close'
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: #e0e0e0;
|
||||
border: 1px solid #ccc;
|
||||
color: #333;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
`
|
||||
closeBtn.onclick = () => errorDiv.remove()
|
||||
errorDiv.appendChild(closeBtn)
|
||||
document.body.appendChild(errorDiv)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
router.replace({ path: 'error' })
|
||||
console.error('[APP SETUP ERROR]', error)
|
||||
// Show error details in browser DOM
|
||||
const errorDiv = document.createElement('div')
|
||||
errorDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
z-index: 10000;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
border: 1px solid #ddd;
|
||||
`
|
||||
let errorText = ''
|
||||
if (error instanceof Error) {
|
||||
errorText = `API Explorer II Error\n\nMessage:\n${error.message}\n\nStack:\n${error.stack || 'No stack trace available'}`
|
||||
errorDiv.innerHTML = `
|
||||
<strong style="font-size: 18px;">API Explorer II Error</strong><br><br>
|
||||
<strong>Message:</strong><br>${error.message}<br><br>
|
||||
<strong>Stack:</strong><br>${error.stack || 'No stack trace available'}
|
||||
`
|
||||
} else {
|
||||
errorText = `API Explorer II Error\n\n${JSON.stringify(error, null, 2)}`
|
||||
errorDiv.innerHTML = `
|
||||
<strong style="font-size: 18px;">API Explorer II Error</strong><br><br>
|
||||
${JSON.stringify(error, null, 2)}
|
||||
`
|
||||
}
|
||||
|
||||
const copyBtn = document.createElement('button')
|
||||
copyBtn.textContent = '📋 Copy'
|
||||
copyBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 90px;
|
||||
background: #e0e0e0;
|
||||
border: 1px solid #ccc;
|
||||
color: #333;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
`
|
||||
copyBtn.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorText)
|
||||
copyBtn.textContent = '✓ Copied!'
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = '📋 Copy'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy error:', err)
|
||||
copyBtn.textContent = '✗ Failed'
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = '📋 Copy'
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
errorDiv.appendChild(copyBtn)
|
||||
|
||||
const closeBtn = document.createElement('button')
|
||||
closeBtn.textContent = '✕ Close'
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: #e0e0e0;
|
||||
border: 1px solid #ccc;
|
||||
color: #333;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
`
|
||||
closeBtn.onclick = () => errorDiv.remove()
|
||||
errorDiv.appendChild(closeBtn)
|
||||
document.body.appendChild(errorDiv)
|
||||
}
|
||||
})()
|
||||
|
||||
async function setupData(app: App<Element>, worker: Worker) {
|
||||
try {
|
||||
// Clear any previous error
|
||||
sessionStorage.removeItem('setupError')
|
||||
// 'open': Returns a Promise that resolves to the Cache object matching the cacheName(obp-resource-docs-cache) (a new cache is created if it doesn't already exist.)
|
||||
const cacheStorageOfResourceDocs = await caches.open('obp-resource-docs-cache') // Please note: The global 'caches' read-only property returns the 'CacheStorage' object associated with the current context.
|
||||
// 'match': Checks if a given Request is a key in any of the Cache objects that the CacheStorage object tracks, and returns a Promise that resolves to that match.
|
||||
@ -97,6 +279,13 @@ async function setupData(app: App<Element>, worker: Worker) {
|
||||
const cacheStorageOfMessageDocs = await caches.open('obp-message-docs-cache') // Please note: The global 'caches' read-only property returns the 'CacheStorage' object associated with the current context.
|
||||
// 'match': Checks if a given Request is a key in any of the Cache objects that the CacheStorage object tracks, and returns a Promise that resolves to that match.
|
||||
const cachedResponseOfMessageDocs = await cacheStorageOfMessageDocs.match('/')
|
||||
// 'open': Returns a Promise that resolves to the Cache object matching the cacheName(obp-message-docs-json-schema-cache) (a new cache is created if it doesn't already exist.)
|
||||
const cacheStorageOfMessageDocsJsonSchema = await caches.open(
|
||||
'obp-message-docs-json-schema-cache'
|
||||
) // Please note: The global 'caches' read-only property returns the 'CacheStorage' object associated with the current context.
|
||||
// 'match': Checks if a given Request is a key in any of the Cache objects that the CacheStorage object tracks, and returns a Promise that resolves to that match.
|
||||
const cachedResponseOfMessageDocsJsonSchema =
|
||||
await cacheStorageOfMessageDocsJsonSchema.match('/')
|
||||
|
||||
// Listen to Web worker
|
||||
worker.onmessage = async (event) => {
|
||||
@ -111,6 +300,10 @@ async function setupData(app: App<Element>, worker: Worker) {
|
||||
await cacheMessageDocsDoc(cacheStorageOfMessageDocs)
|
||||
console.log('Message Docs cache was updated.')
|
||||
}
|
||||
if (event.data === 'update-message-docs-json-schema') {
|
||||
await cacheMessageDocsJsonSchemaDoc(cacheStorageOfMessageDocsJsonSchema)
|
||||
console.log('Message Docs JSON Schema cache was updated.')
|
||||
}
|
||||
}
|
||||
|
||||
const { resourceDocs, groupedDocs } = await cacheResourceDocs(
|
||||
@ -123,6 +316,11 @@ async function setupData(app: App<Element>, worker: Worker) {
|
||||
cachedResponseOfMessageDocs,
|
||||
worker
|
||||
)
|
||||
const messageDocsJsonSchema = await cacheMessageDocsJsonSchema(
|
||||
cacheStorageOfMessageDocsJsonSchema,
|
||||
cachedResponseOfMessageDocsJsonSchema,
|
||||
worker
|
||||
)
|
||||
|
||||
// Provide data to a component's descendants
|
||||
// App-level provides are available to all components rendered in the app
|
||||
@ -131,29 +329,50 @@ async function setupData(app: App<Element>, worker: Worker) {
|
||||
app.provide(obpApiActiveVersionsKey, Object.keys(resourceDocs).sort())
|
||||
app.provide(obpGroupedResourceDocsKey, groupedDocs)
|
||||
app.provide(obpGroupedMessageDocsKey, messageDocs)
|
||||
app.provide(obpGroupedMessageDocsJsonSchemaKey, messageDocsJsonSchema)
|
||||
app.provide(obpApiHostKey, import.meta.env.VITE_OBP_API_HOST)
|
||||
const glossary = await getOBPGlossary()
|
||||
app.provide(obpGlossaryKey, glossary)
|
||||
|
||||
const apiCollections = (await getMyAPICollections()).api_collections
|
||||
if (apiCollections && apiCollections.length > 0) {
|
||||
//Uncomment this when other collection will be supported.
|
||||
//for (const { api_collection_name } of apiCollections) {
|
||||
// const apiCollectionsEndpoint = (
|
||||
// await getMyAPICollectionsEndpoint(api_collection_name)
|
||||
// ).api_collection_endpoints.map((api) => api.operation_id)
|
||||
// app.provide(obpMyCollectionsEndpointKey, apiCollectionsEndpoint)
|
||||
//}
|
||||
const apiCollectionsEndpoint = (
|
||||
await getMyAPICollectionsEndpoint('Favourites')
|
||||
).api_collection_endpoints.map((api) => api.operation_id)
|
||||
app.provide(obpMyCollectionsEndpointKey, apiCollectionsEndpoint)
|
||||
} else {
|
||||
// Try to load user's API collections (requires authentication)
|
||||
try {
|
||||
console.log('[MAIN] Attempting to load user API collections...')
|
||||
const apiCollections = (await getMyAPICollections()).api_collections
|
||||
if (apiCollections && apiCollections.length > 0) {
|
||||
console.log(`[MAIN] Loaded ${apiCollections.length} API collection(s)`)
|
||||
//Uncomment this when other collection will be supported.
|
||||
//for (const { api_collection_name } of apiCollections) {
|
||||
// const apiCollectionsEndpoint = (
|
||||
// await getMyAPICollectionsEndpoint(api_collection_name)
|
||||
// ).api_collection_endpoints.map((api) => api.operation_id)
|
||||
// app.provide(obpMyCollectionsEndpointKey, apiCollectionsEndpoint)
|
||||
//}
|
||||
const apiCollectionsEndpoint = (
|
||||
await getMyAPICollectionsEndpoint('Favourites')
|
||||
).api_collection_endpoints.map((api: any) => api.operation_id)
|
||||
app.provide(obpMyCollectionsEndpointKey, apiCollectionsEndpoint)
|
||||
} else {
|
||||
console.log('[MAIN] No API collections found')
|
||||
app.provide(obpMyCollectionsEndpointKey, undefined)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.status === 401) {
|
||||
console.log('[MAIN] User not authenticated - skipping API collections (expected behavior)')
|
||||
} else {
|
||||
console.warn('[MAIN] Failed to load API collections:', error?.message || error)
|
||||
}
|
||||
app.provide(obpMyCollectionsEndpointKey, undefined)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
app.provide(obpApiActiveVersionsKey, [OBP_API_VERSION])
|
||||
// Store error details for display on error page
|
||||
const errorDetails =
|
||||
error instanceof Error
|
||||
? { message: error.message, stack: error.stack }
|
||||
: { message: JSON.stringify(error) }
|
||||
sessionStorage.setItem('setupError', JSON.stringify(errorDetails))
|
||||
console.error('[SETUP ERROR] Stored error details:', errorDetails)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,9 +25,10 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { OBP_API_VERSION, get } from '../obp'
|
||||
import { get } from '../obp'
|
||||
import { API_VERSIONS_LIST_API_VERSION } from '../shared-constants'
|
||||
|
||||
// Get API Versions
|
||||
export async function getOBPAPIVersions(): Promise<any> {
|
||||
return await get(`obp/${OBP_API_VERSION}/api/versions`)
|
||||
return await get(`obp/${API_VERSIONS_LIST_API_VERSION}/api/versions`)
|
||||
}
|
||||
|
||||
@ -25,15 +25,16 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { OBP_API_VERSION, get } from '../obp'
|
||||
import { get } from '../obp'
|
||||
import { updateLoadingInfoMessage } from './common-functions'
|
||||
import { GLOSSARY_API_VERSION } from '../shared-constants'
|
||||
|
||||
// Get Glossary
|
||||
export async function getOBPGlossary(): Promise<any> {
|
||||
const logMessage = `Loading glossary { version: ${OBP_API_VERSION} }`
|
||||
const logMessage = `Loading glossary { version: ${GLOSSARY_API_VERSION} }`
|
||||
console.log(logMessage)
|
||||
updateLoadingInfoMessage(logMessage)
|
||||
const glossary = await get(`obp/${OBP_API_VERSION}/api/glossary`)
|
||||
const glossary = await get(`obp/${GLOSSARY_API_VERSION}/api/glossary`)
|
||||
|
||||
// Check if the API call failed
|
||||
if (glossary && glossary.error) {
|
||||
|
||||
@ -26,11 +26,13 @@
|
||||
*/
|
||||
|
||||
import superagent from 'superagent'
|
||||
import { DEFAULT_OBP_API_VERSION } from '../../shared-constants'
|
||||
import { DEFAULT_OBP_API_VERSION } from '../shared-constants'
|
||||
|
||||
export const OBP_API_VERSION = import.meta.env.VITE_OBP_API_VERSION ?? DEFAULT_OBP_API_VERSION
|
||||
export const OBP_API_DEFAULT_RESOURCE_DOC_VERSION =
|
||||
(import.meta.env.VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION ?? `OBP${OBP_API_VERSION}`)
|
||||
// Always use v5.1.0 for application infrastructure - stable and debuggable
|
||||
export const OBP_API_VERSION = DEFAULT_OBP_API_VERSION
|
||||
// Default to showing v6.0.0 documentation in the UI (can be overridden by env var)
|
||||
export const OBP_API_DEFAULT_RESOURCE_DOC_VERSION =
|
||||
import.meta.env.VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION ?? 'OBPv6.0.0'
|
||||
const default_collection_name = 'Favourites'
|
||||
|
||||
export async function serverStatus(): Promise<any> {
|
||||
@ -45,8 +47,12 @@ export async function isServerUp(): Promise<boolean> {
|
||||
export async function get(path: string): Promise<any> {
|
||||
try {
|
||||
return (await superagent.get(`/api/get?path=${path}`)).body
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
// Extract the full OBP error message from the response body
|
||||
if (error.response && error.response.body) {
|
||||
return { error: error.response.body }
|
||||
}
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
@ -70,8 +76,12 @@ export async function create(path: string, body?: any): Promise<any> {
|
||||
}
|
||||
}
|
||||
return (await request).body
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
// Extract the full OBP error message from the response body
|
||||
if (error.response && error.response.body) {
|
||||
return { error: error.response.body }
|
||||
}
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
@ -95,8 +105,12 @@ export async function update(path: string, body?: any): Promise<any> {
|
||||
}
|
||||
}
|
||||
return (await request).body
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
// Extract the full OBP error message from the response body
|
||||
if (error.response && error.response.body) {
|
||||
return { error: error.response.body }
|
||||
}
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
@ -104,8 +118,12 @@ export async function update(path: string, body?: any): Promise<any> {
|
||||
export async function discard(path: string): Promise<any> {
|
||||
try {
|
||||
return (await superagent.delete(`/api/delete?path=${path}`)).body
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
// Extract the full OBP error message from the response body
|
||||
if (error.response && error.response.body) {
|
||||
return { error: error.response.body }
|
||||
}
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
@ -113,8 +131,29 @@ export async function discard(path: string): Promise<any> {
|
||||
export async function getCurrentUser(): Promise<any> {
|
||||
try {
|
||||
return (await superagent.get(`/api/user/current`)).body
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
// Extract the full OBP error message from the response body
|
||||
if (error.response && error.response.body) {
|
||||
return { error: error.response.body }
|
||||
}
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserEntitlements(): Promise<any> {
|
||||
try {
|
||||
const userId = (await getCurrentUser()).user_id
|
||||
if (!userId) {
|
||||
return { error: 'User not logged in' }
|
||||
}
|
||||
const url = `/obp/${OBP_API_VERSION}/users/${userId}/entitlements`
|
||||
return await get(url)
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
if (error.response && error.response.body) {
|
||||
return { error: error.response.body }
|
||||
}
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
@ -156,5 +195,7 @@ export async function getMyAPICollections(): Promise<any> {
|
||||
}
|
||||
|
||||
export async function getMyAPICollectionsEndpoint(collectionName: string): Promise<any> {
|
||||
return await get(`/obp/${OBP_API_VERSION}/my/api-collections/${collectionName}/api-collection-endpoints`)
|
||||
return await get(
|
||||
`/obp/${OBP_API_VERSION}/my/api-collections/${collectionName}/api-collection-endpoints`
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,6 +31,9 @@ export const obpResourceDocsKey = Symbol('OBP-ResourceDocs') as InjectionKey<any
|
||||
export const obpApiActiveVersionsKey = Symbol('OBP-APIActiveVersions') as InjectionKey<any>
|
||||
export const obpGroupedResourceDocsKey = Symbol('OBP-GroupedResourceDocs') as InjectionKey<any>
|
||||
export const obpGroupedMessageDocsKey = Symbol('OBP-GroupedMessageDocs') as InjectionKey<any> // This cause an issue
|
||||
export const obpGroupedMessageDocsJsonSchemaKey = Symbol(
|
||||
'OBP-GroupedMessageDocsJsonSchema'
|
||||
) as InjectionKey<any>
|
||||
export const obpApiHostKey = Symbol('OBP-API-Host') as InjectionKey<any>
|
||||
export const obpGlossaryKey = Symbol('OBP-Glossary') as InjectionKey<any>
|
||||
export const obpMyCollectionsEndpointKey = Symbol('OBP-MyCollectionsEndpoint') as InjectionKey<any>
|
||||
export const obpMyCollectionsEndpointKey = Symbol('OBP-MyCollectionsEndpoint') as InjectionKey<any>
|
||||
|
||||
@ -43,7 +43,15 @@ export async function getOBPMessageDocs(item: string): Promise<any> {
|
||||
return await get(`obp/${OBP_API_VERSION}/message-docs/${item}`)
|
||||
}
|
||||
|
||||
export function getGroupedMessageDocs(docs: any): Promise<any> {
|
||||
// Get Message Docs JSON Schema
|
||||
export async function getOBPMessageDocsJsonSchema(item: string): Promise<any> {
|
||||
const logMessage = `Loading message docs JSON schema { connector: ${item} }`
|
||||
console.log(logMessage)
|
||||
updateLoadingInfoMessage(logMessage)
|
||||
return await get(`obp/v6.0.0/message-docs/${item}/json-schema`)
|
||||
}
|
||||
|
||||
export function getGroupedMessageDocs(docs: any): any {
|
||||
return docs.message_docs.reduce((values: any, doc: any) => {
|
||||
const tag = doc.adapter_implementation.group.replace('-', '').trim()
|
||||
;(values[tag] = values[tag] || []).push(doc)
|
||||
@ -51,6 +59,77 @@ export function getGroupedMessageDocs(docs: any): Promise<any> {
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function getGroupedMessageDocsJsonSchema(docs: any): any {
|
||||
console.log('getGroupedMessageDocsJsonSchema - Raw docs:', docs)
|
||||
|
||||
// Access messages from the correct path: properties.messages.items
|
||||
const messages = docs.properties?.messages?.items
|
||||
const definitions = docs.definitions || {}
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
console.log('No messages array found, falling back to definitions')
|
||||
// Fallback to old structure if messages array doesn't exist
|
||||
if (!definitions || typeof definitions !== 'object') {
|
||||
console.log('No definitions object found either')
|
||||
return { grouped: {}, definitions: {} }
|
||||
}
|
||||
|
||||
// Convert definitions object to array format and group by InBound/OutBound prefix
|
||||
const grouped: any = {}
|
||||
Object.keys(definitions).forEach((methodName: string) => {
|
||||
const schema = definitions[methodName]
|
||||
|
||||
// Determine category based on method name prefix
|
||||
let category = 'Uncategorized'
|
||||
if (methodName.startsWith('InBound')) {
|
||||
category = 'Inbound Methods'
|
||||
} else if (methodName.startsWith('OutBound')) {
|
||||
category = 'Outbound Methods'
|
||||
}
|
||||
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = []
|
||||
}
|
||||
|
||||
grouped[category].push({
|
||||
method_name: methodName,
|
||||
category: category,
|
||||
outbound_schema: schema,
|
||||
inbound_schema: schema
|
||||
})
|
||||
})
|
||||
|
||||
console.log('Grouped definitions result:', grouped)
|
||||
return { grouped, definitions }
|
||||
}
|
||||
|
||||
// Group messages by adapter_implementation.group
|
||||
console.log('Processing messages array')
|
||||
const grouped: any = {}
|
||||
messages.forEach((message: any) => {
|
||||
const category =
|
||||
message.adapter_implementation?.group?.replace('-', '').trim() || 'Uncategorized'
|
||||
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = []
|
||||
}
|
||||
|
||||
// Keep original schemas with $refs intact
|
||||
grouped[category].push({
|
||||
method_name: message.process,
|
||||
category: category,
|
||||
description: message.description,
|
||||
outbound_schema: message.outbound_schema,
|
||||
inbound_schema: message.inbound_schema,
|
||||
message_format: message.message_format
|
||||
})
|
||||
})
|
||||
|
||||
console.log('Grouped messages result:', grouped)
|
||||
console.log('Definitions:', definitions)
|
||||
return { grouped, definitions }
|
||||
}
|
||||
|
||||
export async function cacheDoc(cacheStorageOfMessageDocs: any): Promise<any> {
|
||||
const messageDocs = await connectors.reduce(async (agroup: any, connector: any) => {
|
||||
const logMessage = `Caching message docs { connector: ${connector} }`
|
||||
@ -71,11 +150,30 @@ async function getCacheDoc(cacheStorageOfMessageDocs: any): Promise<any> {
|
||||
return await cacheDoc(cacheStorageOfMessageDocs)
|
||||
}
|
||||
|
||||
export async function cache(
|
||||
cacheStorage: any,
|
||||
cachedResponse: any,
|
||||
worker: any
|
||||
): Promise<any> {
|
||||
export async function cacheDocJsonSchema(cacheStorageOfMessageDocsJsonSchema: any): Promise<any> {
|
||||
const messageDocsJsonSchema = await connectors.reduce(async (agroup: any, connector: any) => {
|
||||
const logMessage = `Caching message docs JSON schema { connector: ${connector} }`
|
||||
console.log(logMessage)
|
||||
updateLoadingInfoMessage(logMessage)
|
||||
const group = await agroup
|
||||
const docs = await getOBPMessageDocsJsonSchema(connector)
|
||||
if (!Object.keys(docs).includes('code')) {
|
||||
group[connector] = getGroupedMessageDocsJsonSchema(docs)
|
||||
}
|
||||
return group
|
||||
}, Promise.resolve({}))
|
||||
await cacheStorageOfMessageDocsJsonSchema.put(
|
||||
'/',
|
||||
new Response(JSON.stringify(messageDocsJsonSchema))
|
||||
)
|
||||
return messageDocsJsonSchema
|
||||
}
|
||||
|
||||
async function getCacheDocJsonSchema(cacheStorageOfMessageDocsJsonSchema: any): Promise<any> {
|
||||
return await cacheDocJsonSchema(cacheStorageOfMessageDocsJsonSchema)
|
||||
}
|
||||
|
||||
export async function cache(cacheStorage: any, cachedResponse: any, worker: any): Promise<any> {
|
||||
try {
|
||||
worker.postMessage('update-message-docs')
|
||||
return await cachedResponse.json()
|
||||
@ -87,3 +185,20 @@ export async function cache(
|
||||
return await getCacheDoc(cacheStorage)
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheJsonSchema(
|
||||
cacheStorage: any,
|
||||
cachedResponse: any,
|
||||
worker: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
worker.postMessage('update-message-docs-json-schema')
|
||||
return await cachedResponse.json()
|
||||
} catch (error) {
|
||||
console.warn('No message docs JSON schema cache or malformed cache.')
|
||||
console.log('Caching message docs JSON schema...')
|
||||
const isServerActive = await isServerUp()
|
||||
if (!isServerActive) throw new Error('API Server is not responding.')
|
||||
return await getCacheDocJsonSchema(cacheStorage)
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,42 +25,75 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { OBP_API_VERSION, get, isServerUp } from '../obp'
|
||||
import { get, isServerUp, OBP_API_DEFAULT_RESOURCE_DOC_VERSION } from '../obp'
|
||||
import { getOBPAPIVersions } from '../obp/api-version'
|
||||
import { updateLoadingInfoMessage } from './common-functions'
|
||||
import { RESOURCE_DOCS_API_VERSION } from '../shared-constants'
|
||||
|
||||
// Get Resource Docs
|
||||
export async function getOBPResourceDocs(apiStandardAndVersion: string): Promise<any> {
|
||||
const logMessage = `Loading API ${apiStandardAndVersion}`
|
||||
console.log(logMessage)
|
||||
updateLoadingInfoMessage(logMessage)
|
||||
return await get(`/obp/${OBP_API_VERSION}/resource-docs/${apiStandardAndVersion}/obp`)
|
||||
const path = `/obp/${RESOURCE_DOCS_API_VERSION}/resource-docs/${apiStandardAndVersion}/obp`
|
||||
try {
|
||||
return await get(path)
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to load resource docs for ${apiStandardAndVersion}`)
|
||||
console.error(` URL: ${path}`)
|
||||
console.error(` Status: ${error.status || 'unknown'}`)
|
||||
console.error(` Error: ${error.message || JSON.stringify(error)}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getOBPDynamicResourceDocs(apiStandardAndVersion: string): Promise<any> {
|
||||
const logMessage = `Loading Dynamic Docs for ${apiStandardAndVersion}`
|
||||
console.log(logMessage)
|
||||
updateLoadingInfoMessage(logMessage)
|
||||
return await get(`/obp/${OBP_API_VERSION}/resource-docs/${apiStandardAndVersion}/obp?content=dynamic`)
|
||||
const path = `/obp/${RESOURCE_DOCS_API_VERSION}/resource-docs/${apiStandardAndVersion}/obp?content=dynamic`
|
||||
try {
|
||||
return await get(path)
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to load dynamic resource docs for ${apiStandardAndVersion}`)
|
||||
console.error(` URL: ${path}`)
|
||||
console.error(` Status: ${error.status || 'unknown'}`)
|
||||
console.error(` Error: ${error.message || JSON.stringify(error)}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getFilteredGroupedResourceDocs(apiStandardAndVersion: string, tags: any, docs: any): Promise<any> {
|
||||
console.log(docs);
|
||||
if (apiStandardAndVersion === undefined || docs === undefined || docs[apiStandardAndVersion] === undefined) return Promise.resolve<any>({})
|
||||
let list = tags.split(",")
|
||||
export function getFilteredGroupedResourceDocs(
|
||||
apiStandardAndVersion: string,
|
||||
tags: any,
|
||||
docs: any
|
||||
): Promise<any> {
|
||||
console.log(docs)
|
||||
if (
|
||||
apiStandardAndVersion === undefined ||
|
||||
docs === undefined ||
|
||||
docs[apiStandardAndVersion] === undefined
|
||||
)
|
||||
return Promise.resolve<any>({})
|
||||
let list = tags.split(',')
|
||||
return docs[apiStandardAndVersion].resource_docs
|
||||
.filter((subArray: any) => subArray.tags.some((value: string) => list.includes(value))) // Filter by tags
|
||||
.reduce((values: any, doc: any) => {
|
||||
const tag = doc.tags[0] // Group by the first tag at resorce doc
|
||||
;(values[tag] = values[tag] || []).push(doc)
|
||||
return values
|
||||
}, {})
|
||||
.filter((subArray: any) => subArray.tags.some((value: string) => list.includes(value))) // Filter by tags
|
||||
.reduce((values: any, doc: any) => {
|
||||
const tag = doc.tags[0] // Group by the first tag at resorce doc
|
||||
;(values[tag] = values[tag] || []).push(doc)
|
||||
return values
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function getGroupedResourceDocs(apiStandardAndVersion: string, docs: any): Promise<any> {
|
||||
if (apiStandardAndVersion === undefined || docs === undefined) return Promise.resolve<any>({})
|
||||
|
||||
// Check if the specific version exists in docs
|
||||
if (!docs[apiStandardAndVersion] || !docs[apiStandardAndVersion].resource_docs) {
|
||||
console.warn(`No resource_docs found for ${apiStandardAndVersion}`)
|
||||
return Promise.resolve<any>({})
|
||||
}
|
||||
|
||||
return docs[apiStandardAndVersion].resource_docs.reduce((values: any, doc: any) => {
|
||||
const tag = doc.tags[0] // Group by the first tag at resorce doc
|
||||
;(values[tag] = values[tag] || []).push(doc)
|
||||
@ -69,45 +102,108 @@ export function getGroupedResourceDocs(apiStandardAndVersion: string, docs: any)
|
||||
}
|
||||
|
||||
export function getOperationDetails(version: string, operation_id: string, docs: any): any {
|
||||
if (!docs || !docs[version] || !docs[version].resource_docs) {
|
||||
console.warn(`No resource_docs found for version ${version}`)
|
||||
return undefined
|
||||
}
|
||||
return docs[version].resource_docs.filter((doc: any) => doc.operation_id === operation_id)[0]
|
||||
}
|
||||
|
||||
export async function cacheDoc(cacheStorageOfResourceDocs: any): Promise<any> {
|
||||
const apiVersions = await getOBPAPIVersions()
|
||||
if (apiVersions) {
|
||||
try {
|
||||
const apiVersions = await getOBPAPIVersions()
|
||||
if (
|
||||
!apiVersions ||
|
||||
!apiVersions.scanned_api_versions ||
|
||||
!Array.isArray(apiVersions.scanned_api_versions)
|
||||
) {
|
||||
console.warn('API versions response is invalid or user not authenticated, skipping cache')
|
||||
return {}
|
||||
}
|
||||
const scannedAPIVersions = apiVersions.scanned_api_versions
|
||||
// Filter to only include active versions
|
||||
const activeVersions = scannedAPIVersions.filter((version: any) => version.is_active === true)
|
||||
console.log(
|
||||
`[CACHE] Found ${scannedAPIVersions.length} total versions, ${activeVersions.length} are active`
|
||||
)
|
||||
const resourceDocsMapping: any = {}
|
||||
for (const { apiStandard, API_VERSION } of scannedAPIVersions) {
|
||||
|
||||
for (const { api_standard, api_short_version } of activeVersions) {
|
||||
// we need this to cache the dynamic entities resource doc
|
||||
if (API_VERSION === 'dynamic-entity') {
|
||||
const logMessage = `Caching Dynamic API { standard: ${apiStandard}, version: ${API_VERSION} }`
|
||||
if (api_short_version === 'dynamic-entity') {
|
||||
const logMessage = `Caching Dynamic API { standard: ${api_standard}, version: ${api_short_version} }`
|
||||
console.log(logMessage)
|
||||
if (apiStandard) {
|
||||
const version = `${apiStandard.toUpperCase()}${API_VERSION}`
|
||||
const resourceDocs = await getOBPDynamicResourceDocs(version)
|
||||
if (version && Object.keys(resourceDocs).includes('resource_docs'))
|
||||
resourceDocsMapping[version] = resourceDocs
|
||||
if (api_standard) {
|
||||
try {
|
||||
const version = `${api_standard.toUpperCase()}${api_short_version}`
|
||||
console.log(`[CACHE] Attempting to load dynamic resource docs for: ${version}`)
|
||||
const resourceDocs = await getOBPDynamicResourceDocs(version)
|
||||
if (version && Object.keys(resourceDocs).includes('resource_docs')) {
|
||||
resourceDocsMapping[version] = resourceDocs
|
||||
console.log(`[CACHE] Successfully cached dynamic docs for: ${version}`)
|
||||
} else {
|
||||
console.warn(`[CACHE] WARNING: Response for ${version} missing 'resource_docs' field`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`[CACHE] WARNING: Skipping dynamic endpoint ${api_standard}${api_short_version}:`
|
||||
)
|
||||
console.warn(` API Version: ${api_short_version}`)
|
||||
console.warn(` API Standard: ${api_standard}`)
|
||||
console.warn(
|
||||
` Constructed version string: ${api_standard.toUpperCase()}${api_short_version}`
|
||||
)
|
||||
console.warn(` Error status: ${error.status || 'unknown'}`)
|
||||
console.warn(` Error message: ${error.message || 'No message'}`)
|
||||
if (error.status === 500) {
|
||||
console.warn(
|
||||
` NOTE: This likely means the OBP-API server doesn't have this feature enabled`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLoadingInfoMessage(logMessage)
|
||||
continue
|
||||
}
|
||||
const logMessage = `Caching API { standard: ${apiStandard}, version: ${API_VERSION} }`
|
||||
const logMessage = `Caching API { standard: ${api_standard}, version: ${api_short_version} }`
|
||||
console.log(logMessage)
|
||||
if (apiStandard) {
|
||||
const version = `${apiStandard.toUpperCase()}${API_VERSION}`
|
||||
const resourceDocs = await getOBPResourceDocs(version)
|
||||
if (version && Object.keys(resourceDocs).includes('resource_docs'))
|
||||
resourceDocsMapping[version] = resourceDocs
|
||||
if (api_standard) {
|
||||
try {
|
||||
const version = `${api_standard.toUpperCase()}${api_short_version}`
|
||||
console.log(`[CACHE] Attempting to load resource docs for: ${version}`)
|
||||
const resourceDocs = await getOBPResourceDocs(version)
|
||||
if (version && Object.keys(resourceDocs).includes('resource_docs')) {
|
||||
resourceDocsMapping[version] = resourceDocs
|
||||
console.log(`[CACHE] Successfully cached docs for: ${version}`)
|
||||
} else {
|
||||
console.warn(`[CACHE] WARNING: Response for ${version} missing 'resource_docs' field`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn(`[CACHE] WARNING: Skipping API version ${api_standard}${api_short_version}:`)
|
||||
console.warn(` API Version: ${api_short_version}`)
|
||||
console.warn(` API Standard: ${api_standard}`)
|
||||
console.warn(
|
||||
` Constructed version string: ${api_standard.toUpperCase()}${api_short_version}`
|
||||
)
|
||||
console.warn(` Error status: ${error.status || 'unknown'}`)
|
||||
console.warn(` Error message: ${error.message || 'No message'}`)
|
||||
if (error.status === 400) {
|
||||
console.warn(` NOTE: This API version is not enabled on the OBP-API server`)
|
||||
console.warn(` NOTE: Check your OBP-API server configuration for available versions`)
|
||||
} else if (error.status === 500) {
|
||||
console.warn(` NOTE: This API version may not be available on the OBP-API server`)
|
||||
} else if (error.status === 404) {
|
||||
console.warn(` NOTE: This endpoint was not found on the OBP-API server`)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLoadingInfoMessage(logMessage)
|
||||
}
|
||||
await cacheStorageOfResourceDocs.put('/', new Response(JSON.stringify(resourceDocsMapping)))
|
||||
return resourceDocsMapping
|
||||
} else {
|
||||
const resourceDocs = { ['OBP' + OBP_API_VERSION]: await getOBPResourceDocs(OBP_API_VERSION) }
|
||||
await cacheStorageOfResourceDocs.put('/', new Response(JSON.stringify(resourceDocs)))
|
||||
return resourceDocs
|
||||
} catch (error) {
|
||||
console.error('Failed to cache resource docs:', error)
|
||||
console.warn('Returning empty cache - user may need to login')
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,15 +211,32 @@ async function getCacheDoc(cacheStorageOfResourceDocs: any): Promise<any> {
|
||||
return await cacheDoc(cacheStorageOfResourceDocs)
|
||||
}
|
||||
|
||||
export async function cache(
|
||||
cachedStorage: any,
|
||||
cachedResponse: any,
|
||||
worker: any
|
||||
): Promise<any> {
|
||||
export async function cache(cachedStorage: any, cachedResponse: any, worker: any): Promise<any> {
|
||||
try {
|
||||
worker.postMessage('update-resource-docs')
|
||||
const resourceDocs = await cachedResponse.json()
|
||||
const groupedResourceDocs = getGroupedResourceDocs('OBP' + OBP_API_VERSION, resourceDocs)
|
||||
console.log(
|
||||
'[CACHE] Loaded cached resource docs, available versions:',
|
||||
Object.keys(resourceDocs)
|
||||
)
|
||||
|
||||
// Check if the default version exists
|
||||
if (!resourceDocs[OBP_API_DEFAULT_RESOURCE_DOC_VERSION]) {
|
||||
console.warn(
|
||||
`[CACHE] Default version ${OBP_API_DEFAULT_RESOURCE_DOC_VERSION} not found in cache`
|
||||
)
|
||||
console.warn('[CACHE] Available versions:', Object.keys(resourceDocs))
|
||||
// Try to use the first available version
|
||||
const availableVersions = Object.keys(resourceDocs)
|
||||
if (availableVersions.length > 0) {
|
||||
console.log(`[CACHE] Using first available version: ${availableVersions[0]}`)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedResourceDocs = getGroupedResourceDocs(
|
||||
OBP_API_DEFAULT_RESOURCE_DOC_VERSION,
|
||||
resourceDocs
|
||||
)
|
||||
return { resourceDocs, groupedDocs: groupedResourceDocs }
|
||||
} catch (error) {
|
||||
console.warn('No resource docs cache or malformed cache.')
|
||||
@ -131,7 +244,28 @@ export async function cache(
|
||||
const isServerActive = await isServerUp()
|
||||
if (!isServerActive) throw new Error('API Server is not responding.')
|
||||
const resourceDocs = await getCacheDoc(cachedStorage)
|
||||
const groupedDocs = getGroupedResourceDocs('OBP' + OBP_API_VERSION, resourceDocs)
|
||||
console.log(
|
||||
'[CACHE] Newly cached resource docs, available versions:',
|
||||
Object.keys(resourceDocs)
|
||||
)
|
||||
|
||||
// Check if we got any docs back
|
||||
if (!resourceDocs || Object.keys(resourceDocs).length === 0) {
|
||||
console.error('[CACHE] No resource docs were cached - API may have returned empty data')
|
||||
throw new Error(
|
||||
'No resource documentation available. API may be misconfigured or authentication required.'
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the default version exists
|
||||
if (!resourceDocs[OBP_API_DEFAULT_RESOURCE_DOC_VERSION]) {
|
||||
console.warn(
|
||||
`[CACHE] Default version ${OBP_API_DEFAULT_RESOURCE_DOC_VERSION} not found after caching`
|
||||
)
|
||||
console.warn('[CACHE] Available versions:', Object.keys(resourceDocs))
|
||||
}
|
||||
|
||||
const groupedDocs = getGroupedResourceDocs(OBP_API_DEFAULT_RESOURCE_DOC_VERSION, resourceDocs)
|
||||
return { resourceDocs, groupedDocs }
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,9 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import GlossaryView from '../views/GlossaryView.vue'
|
||||
import HelpView from '../views/HelpView.vue'
|
||||
import MessageDocsView from '../views/MessageDocsView.vue'
|
||||
import MessageDocsListView from '../views/MessageDocsListView.vue'
|
||||
import MessageDocsJsonSchemaView from '../views/MessageDocsJsonSchemaView.vue'
|
||||
import MessageDocsJsonSchemaListView from '../views/MessageDocsJsonSchemaListView.vue'
|
||||
import BodyView from '../views/BodyView.vue'
|
||||
import Content from '../components/Content.vue'
|
||||
import Preview from '../components/Preview.vue'
|
||||
@ -36,24 +39,35 @@ import NotFoundView from '../views/NotFoundView.vue'
|
||||
import InternalServerErrorView from '../views/InternalServerErrorView.vue'
|
||||
import APIServerErrorView from '../views/APIServerErrorView.vue'
|
||||
import APIServerStatusView from '../views/APIServerStatusView.vue'
|
||||
import { isServerUp } from '../obp'
|
||||
import { isServerUp, OBP_API_DEFAULT_RESOURCE_DOC_VERSION } from '../obp'
|
||||
import MessageDocsContent from '@/components/CodeBlock.vue'
|
||||
import ProvidersStatusView from '../views/ProvidersStatusView.vue'
|
||||
import OIDCDebugView from '../views/OIDCDebugView.vue'
|
||||
|
||||
export default async function router(): Promise<any> {
|
||||
const isServerActive = await isServerUp()
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
mode: 'history',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: isServerActive ? '/operationid' : '/api-server-error'
|
||||
redirect: isServerActive ? '/resource-docs' : '/api-server-error'
|
||||
},
|
||||
{
|
||||
path: '/status',
|
||||
name: 'status',
|
||||
component: APIServerStatusView
|
||||
},
|
||||
{
|
||||
path: '/debug/providers-status',
|
||||
name: 'providers-status',
|
||||
component: ProvidersStatusView
|
||||
},
|
||||
{
|
||||
path: '/debug/oidc',
|
||||
name: 'oidc-debug',
|
||||
component: OIDCDebugView
|
||||
},
|
||||
{
|
||||
path: '/glossary',
|
||||
name: 'glossary',
|
||||
@ -64,19 +78,35 @@ export default async function router(): Promise<any> {
|
||||
name: 'help',
|
||||
component: isServerActive ? HelpView : InternalServerErrorView
|
||||
},
|
||||
{
|
||||
path: '/message-docs',
|
||||
name: 'message-docs-list',
|
||||
component: isServerActive ? MessageDocsListView : InternalServerErrorView
|
||||
},
|
||||
{
|
||||
path: '/message-docs/:id',
|
||||
name: 'message-docs',
|
||||
component: isServerActive ? MessageDocsView : InternalServerErrorView
|
||||
},
|
||||
{
|
||||
path: '/operationid',
|
||||
name: 'operationid',
|
||||
component: isServerActive ? BodyView : InternalServerErrorView
|
||||
path: '/message-docs-json-schema',
|
||||
name: 'message-docs-json-schema-list',
|
||||
component: isServerActive ? MessageDocsJsonSchemaListView : InternalServerErrorView
|
||||
},
|
||||
{
|
||||
path: '/operationid/:id',
|
||||
name: 'operationid-path',
|
||||
path: '/message-docs-json-schema/:id',
|
||||
name: 'message-docs-json-schema',
|
||||
component: isServerActive ? MessageDocsJsonSchemaView : InternalServerErrorView
|
||||
},
|
||||
{
|
||||
path: '/resource-docs',
|
||||
redirect: () => {
|
||||
return { path: `/resource-docs/${OBP_API_DEFAULT_RESOURCE_DOC_VERSION}` }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/resource-docs/:version',
|
||||
name: 'resource-docs-version',
|
||||
component: BodyView,
|
||||
children: [
|
||||
{
|
||||
@ -89,12 +119,27 @@ export default async function router(): Promise<any> {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/operationid',
|
||||
redirect: (to) => {
|
||||
return { path: '/resource-docs', query: to.query }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/operationid/:id',
|
||||
redirect: (to) => {
|
||||
const version = to.query.version || 'OBPv6.0.0'
|
||||
return {
|
||||
path: `/resource-docs/${version}`,
|
||||
query: { operationid: to.params.id }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/callback',
|
||||
name: 'callback',
|
||||
component: isServerActive ? BodyView : InternalServerErrorView
|
||||
},
|
||||
{ path: '/error', name: 'error', component: InternalServerErrorView },
|
||||
{ path: '/api-server-error', name: 'apiServerError', component: APIServerErrorView },
|
||||
{ path: '/:pathMatch(.*)*', name: 'notFound', component: NotFoundView }
|
||||
]
|
||||
|
||||
30
src/shared-constants.ts
Normal file
30
src/shared-constants.ts
Normal file
@ -0,0 +1,30 @@
|
||||
// DEFAULT_OBP_API_VERSION is used in case the environment variable VITE_OBP_API_VERSION is not set
|
||||
export const DEFAULT_OBP_API_VERSION = 'v5.1.0'
|
||||
|
||||
// Hardcoded API versions for all application endpoints
|
||||
// Using v5.1.0 as the standard stable version - more stable than v6.0.0 and bugs can be fixed
|
||||
// These versions should NOT change based on user's documentation version selection in the UI
|
||||
|
||||
/**
|
||||
* Resource documentation endpoint version
|
||||
* Endpoint: GET /obp/{version}/resource-docs/{docVersion}/obp
|
||||
*/
|
||||
export const RESOURCE_DOCS_API_VERSION = 'v5.1.0'
|
||||
|
||||
/**
|
||||
* Message documentation endpoint version
|
||||
* Endpoint: GET /obp/{version}/message-docs/{connector}
|
||||
*/
|
||||
export const MESSAGE_DOCS_API_VERSION = 'v5.1.0'
|
||||
|
||||
/**
|
||||
* API versions list endpoint version
|
||||
* Endpoint: GET /obp/{version}/api/versions
|
||||
*/
|
||||
export const API_VERSIONS_LIST_API_VERSION = 'v6.0.0'
|
||||
|
||||
/**
|
||||
* Glossary endpoint version
|
||||
* Endpoint: GET /obp/{version}/api/glossary
|
||||
*/
|
||||
export const GLOSSARY_API_VERSION = 'v5.1.0'
|
||||
@ -26,18 +26,68 @@
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
const version = ref(__APP_VERSION__)
|
||||
const errorDetails = ref<{ message: string; stack?: string } | null>(null)
|
||||
const hasError = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const storedError = sessionStorage.getItem('setupError')
|
||||
if (storedError) {
|
||||
try {
|
||||
errorDetails.value = JSON.parse(storedError)
|
||||
hasError.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored error:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const copyError = async () => {
|
||||
if (!errorDetails.value) return
|
||||
const errorText = `API Explorer II Setup Error\n\nMessage:\n${errorDetails.value.message}\n\nStack:\n${errorDetails.value.stack || 'No stack trace available'}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorText)
|
||||
alert('Error copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy error:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>500 | The API server is not responding.</main>
|
||||
<span>Version: {{ version }}</span>
|
||||
<div class="error-container">
|
||||
<main>500 | The API server is not responding.</main>
|
||||
<span class="version">Version: {{ version }}</span>
|
||||
|
||||
<div v-if="hasError" class="error-details">
|
||||
<div class="error-header">
|
||||
<h2>Error Details</h2>
|
||||
<button @click="copyError" class="copy-btn">📋 Copy Error</button>
|
||||
</div>
|
||||
<div class="error-content">
|
||||
<div class="error-section">
|
||||
<strong>Message:</strong>
|
||||
<pre>{{ errorDetails?.message }}</pre>
|
||||
</div>
|
||||
<div v-if="errorDetails?.stack" class="error-section">
|
||||
<strong>Stack Trace:</strong>
|
||||
<pre>{{ errorDetails?.stack }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: -60px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@ -45,10 +95,81 @@ main {
|
||||
font-family: 'roboto';
|
||||
font-size: 30px;
|
||||
}
|
||||
span {
|
||||
|
||||
.version {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 40px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.error-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.error-header h2 {
|
||||
margin: 0;
|
||||
color: #39455f;
|
||||
font-family: 'roboto';
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: #e0e0e0;
|
||||
border: 1px solid #ccc;
|
||||
color: #333;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-section strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #39455f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-section pre {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -30,9 +30,11 @@ import SearchNav from '../components/SearchNav.vue'
|
||||
import Menu from '../components/Menu.vue'
|
||||
import AutoLogout from '../components/AutoLogout.vue'
|
||||
import ChatWidget from '../components/ChatWidget.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { getCurrentUser } from '../obp'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const isLoggedIn = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
@ -41,13 +43,16 @@ onMounted(async () => {
|
||||
isLoggedIn.value = currentResponseKeys.includes('username')
|
||||
})
|
||||
|
||||
const hasOperationId = computed(() => {
|
||||
return !!route.query.operationid
|
||||
})
|
||||
|
||||
const isChatbotEnabled = import.meta.env.VITE_CHATBOT_ENABLED === 'true'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<AutoLogout v-if=isLoggedIn />
|
||||
<AutoLogout v-if=isLoggedIn />
|
||||
<el-container class="root">
|
||||
<el-aside class="search-nav" width="20%">
|
||||
<!--Left-->
|
||||
@ -62,10 +67,10 @@ const isChatbotEnabled = import.meta.env.VITE_CHATBOT_ENABLED === 'true'
|
||||
<Menu />
|
||||
</el-header>
|
||||
<el-container class="middle">
|
||||
<el-aside class="summary" width="50%">
|
||||
<el-aside class="summary" :width="hasOperationId ? '50%' : '100%'">
|
||||
<RouterView name="body" />
|
||||
</el-aside>
|
||||
<el-main class="preview">
|
||||
<el-main v-if="hasOperationId" class="preview">
|
||||
<!--right -->
|
||||
<RouterView class="preview" name="preview" />
|
||||
</el-main>
|
||||
|
||||
@ -26,11 +26,36 @@
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onBeforeMount, onMounted, inject } from 'vue'
|
||||
import { reactive, ref, onBeforeMount, onMounted, inject, computed } from 'vue'
|
||||
import SearchNav from '../components/GlossarySearchNav.vue'
|
||||
import { obpGlossaryKey } from '@/obp/keys';
|
||||
|
||||
const glossary = ref(inject(obpGlossaryKey)!.glossary_items)
|
||||
const allGlossaryItems = ref(inject(obpGlossaryKey)!.glossary_items)
|
||||
|
||||
// Filter out items with empty example values or "no-description-provided"
|
||||
const glossary = computed(() => {
|
||||
return allGlossaryItems.value.filter((item: any) => {
|
||||
const html = item.description?.html || ''
|
||||
|
||||
// Check if description contains "no-description-provided"
|
||||
if (html.includes('no-description-provided')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if Example value is empty
|
||||
// Matches: "Example value:</p>", "Example value: </p>", "Example value: </p>", etc.
|
||||
if (html.match(/Example value:\s*( |\s)*<\/p>/i)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Also check for "Example value:" followed by empty tags or whitespace before closing
|
||||
if (html.match(/Example value:\s*(<[^>]*>)*\s*<\/p>/i)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -121,9 +146,15 @@ div {
|
||||
.content :deep(strong) {
|
||||
font-family: 'Roboto';
|
||||
}
|
||||
a {
|
||||
span > a {
|
||||
text-decoration: none;
|
||||
color: #39455f;
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
span:first-child > a {
|
||||
margin-top: 0;
|
||||
}
|
||||
.content :deep(a) {
|
||||
text-decoration: underline;
|
||||
@ -135,4 +166,22 @@ a {
|
||||
.content :deep(a):hover {
|
||||
background-color: #a4b2ce;
|
||||
}
|
||||
|
||||
/* Make Scala code blocks readable */
|
||||
.content :deep(pre.language-scala) {
|
||||
background-color: #f5f5f5 !important;
|
||||
color: #333 !important;
|
||||
font-family: 'Courier New', Courier, monospace !important;
|
||||
padding: 1em !important;
|
||||
}
|
||||
|
||||
.content :deep(pre.language-scala code) {
|
||||
background-color: transparent !important;
|
||||
color: #333 !important;
|
||||
font-family: 'Courier New', Courier, monospace !important;
|
||||
}
|
||||
|
||||
.content :deep(pre.language-scala .token) {
|
||||
color: #333 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
114
src/views/MessageDocsJsonSchemaListView.vue
Normal file
114
src/views/MessageDocsJsonSchemaListView.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { obpGroupedMessageDocsJsonSchemaKey } from '@/obp/keys'
|
||||
|
||||
const router = useRouter()
|
||||
const groupedMessageDocsJsonSchema = ref(inject(obpGroupedMessageDocsJsonSchemaKey) || {})
|
||||
|
||||
const connectorList = computed(() => {
|
||||
return Object.keys(groupedMessageDocsJsonSchema.value || {}).sort()
|
||||
})
|
||||
|
||||
function navigateToConnector(connectorId: string) {
|
||||
router.push(`/message-docs-json-schema/${connectorId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="message-docs-list-container">
|
||||
<el-main>
|
||||
<h1>Message Documentation - JSON Schema</h1>
|
||||
<p class="subtitle">Browse connector message documentation with JSON Schema definitions</p>
|
||||
|
||||
<div class="message-docs-list">
|
||||
<div v-if="connectorList.length === 0" class="empty-message">
|
||||
No JSON schema message documentation available
|
||||
</div>
|
||||
<div v-else>
|
||||
<a
|
||||
v-for="connector in connectorList"
|
||||
:key="connector"
|
||||
@click="navigateToConnector(connector)"
|
||||
class="message-doc-link"
|
||||
>
|
||||
{{ connector }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-docs-list-container {
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #909399;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.message-docs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.message-doc-link {
|
||||
padding: 12px 16px;
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-doc-link:hover {
|
||||
background-color: #ecf5ff;
|
||||
color: #337ecc;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
439
src/views/MessageDocsJsonSchemaView.vue
Normal file
439
src/views/MessageDocsJsonSchemaView.vue
Normal file
@ -0,0 +1,439 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeMount, inject, watch, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import SearchNav from '../components/MessageDocsJsonSchemaSearchNav.vue'
|
||||
import { connectors } from '../obp/message-docs'
|
||||
import { obpGroupedMessageDocsJsonSchemaKey } from '@/obp/keys'
|
||||
import JsonSchemaViewer from '../components/JsonSchemaViewer.vue'
|
||||
|
||||
let connector = connectors[0]
|
||||
const route = useRoute()
|
||||
const groupedMessageDocsJsonSchema = ref(inject(obpGroupedMessageDocsJsonSchemaKey) || {})
|
||||
const messageDocsJsonSchema = ref(null as any)
|
||||
const definitions = ref(null as any)
|
||||
const definitionsPanelScrollbar = ref(null as any)
|
||||
|
||||
onBeforeMount(() => {
|
||||
setDoc()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (id) => {
|
||||
setDoc()
|
||||
}
|
||||
)
|
||||
|
||||
const setDoc = () => {
|
||||
const paramConnector = route.params.id
|
||||
if (connectors.includes(paramConnector)) {
|
||||
connector = paramConnector
|
||||
}
|
||||
const data = groupedMessageDocsJsonSchema.value[connector]
|
||||
// Handle both old and new data structures
|
||||
if (data?.grouped) {
|
||||
messageDocsJsonSchema.value = data.grouped
|
||||
definitions.value = data.definitions
|
||||
} else {
|
||||
messageDocsJsonSchema.value = data
|
||||
definitions.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function hasSchema(value: any) {
|
||||
return value && (value.outbound_schema || value.inbound_schema)
|
||||
}
|
||||
|
||||
// Handle $ref link clicks from JsonSchemaViewer components
|
||||
function handleRefClick(href: string) {
|
||||
console.log('handleRefClick called with href:', href)
|
||||
if (!href) {
|
||||
console.log('No href provided')
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the definition ID from the href (e.g., #def-BasicGeneralContext)
|
||||
const targetId = href.substring(1) // Remove the #
|
||||
console.log('Target ID:', targetId)
|
||||
const targetElement = document.getElementById(targetId)
|
||||
console.log('Target element:', targetElement)
|
||||
console.log('Definitions panel scrollbar:', definitionsPanelScrollbar.value)
|
||||
|
||||
if (targetElement && definitionsPanelScrollbar.value) {
|
||||
// Get the scrollbar's wrap element
|
||||
const scrollWrap = definitionsPanelScrollbar.value.$refs.wrap as HTMLElement
|
||||
console.log('Scroll wrap:', scrollWrap)
|
||||
if (scrollWrap) {
|
||||
// Calculate the position of the target element relative to the scrollable container
|
||||
const containerTop = scrollWrap.getBoundingClientRect().top
|
||||
const targetTop = targetElement.getBoundingClientRect().top
|
||||
const currentScroll = scrollWrap.scrollTop
|
||||
const offset = targetTop - containerTop + currentScroll - 20 // 20px padding from top
|
||||
console.log('Scrolling to offset:', offset)
|
||||
|
||||
// Smooth scroll to the target
|
||||
scrollWrap.scrollTo({
|
||||
top: offset,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
// Add a highlight effect
|
||||
targetElement.classList.add('highlight-definition')
|
||||
setTimeout(() => {
|
||||
targetElement.classList.remove('highlight-definition')
|
||||
}, 2000)
|
||||
} else {
|
||||
console.log('No scroll wrap found')
|
||||
}
|
||||
} else {
|
||||
console.log('Target element or scrollbar not found')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="message-docs-container">
|
||||
<el-aside class="search-nav" width="18%">
|
||||
<el-scrollbar>
|
||||
<SearchNav />
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
<el-main class="message-docs-content">
|
||||
<el-scrollbar>
|
||||
<el-backtop :right="100" :bottom="100" />
|
||||
<div class="message-docs-header">
|
||||
<h1>{{ connector }}</h1>
|
||||
<p class="connector-subtitle">Message Docs - JSON Schema</p>
|
||||
<p class="version-indicator">v1.2.4 - Debug Click Events</p>
|
||||
</div>
|
||||
<div v-for="(group, key) of messageDocsJsonSchema" :key="key">
|
||||
<div v-for="(value, key) of group" :key="value">
|
||||
<el-divider></el-divider>
|
||||
<a v-bind:href="`#${value.method_name}`" :id="value.method_name">
|
||||
<h2>{{ value.method_name }}</h2>
|
||||
</a>
|
||||
<p v-if="value.description">{{ value.description }}</p>
|
||||
|
||||
<section class="topics">
|
||||
<div>
|
||||
<strong>Category: </strong>
|
||||
<el-tag type="info" round>{{ value.category || 'Uncategorized' }}</el-tag>
|
||||
</div>
|
||||
<div v-if="value.message_format">
|
||||
<strong>Message Format: </strong>
|
||||
<el-tag type="success" round>{{ value.message_format }}</el-tag>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="value.outbound_schema">
|
||||
<h3>Outbound Schema</h3>
|
||||
<JsonSchemaViewer :schema="value.outbound_schema" copyable @refClick="handleRefClick" />
|
||||
</section>
|
||||
|
||||
<section v-if="value.inbound_schema">
|
||||
<h3>Inbound Schema</h3>
|
||||
<JsonSchemaViewer :schema="value.inbound_schema" copyable @refClick="handleRefClick" />
|
||||
</section>
|
||||
|
||||
<section v-if="!hasSchema(value)">
|
||||
<p class="no-schema-message">No schema information available for this method.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-main>
|
||||
<el-aside class="definitions-panel" width="28%">
|
||||
<el-scrollbar ref="definitionsPanelScrollbar">
|
||||
<div class="definitions-panel-content">
|
||||
<div class="definitions-header">
|
||||
<h2>Schema Definitions</h2>
|
||||
<p class="definitions-subtitle">
|
||||
Reference schemas used in messages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Debug info -->
|
||||
<div v-if="false">
|
||||
{{ console.log('Definitions:', definitions) }}
|
||||
{{ console.log('Definitions keys:', definitions ? Object.keys(definitions) : 'null') }}
|
||||
{{ console.log('Message docs:', messageDocsJsonSchema) }}
|
||||
</div>
|
||||
|
||||
<div v-if="definitions && Object.keys(definitions).length > 0">
|
||||
<div v-for="(defSchema, defName) in definitions" :key="defName" class="definition-item">
|
||||
<a v-bind:href="`#def-${defName}`" :id="`def-${defName}`">
|
||||
<h3>{{ defName }}</h3>
|
||||
</a>
|
||||
<JsonSchemaViewer :schema="defSchema" copyable @refClick="handleRefClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-definitions">
|
||||
<p>No schema definitions available</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 10px;">
|
||||
Debug: definitions = {{ definitions ? 'exists' : 'null' }},
|
||||
keys = {{ definitions ? Object.keys(definitions).length : 0 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-docs-container {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
/* Left Sidebar - Search Navigation */
|
||||
.search-nav {
|
||||
border-right: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.search-nav :deep(.el-scrollbar__wrap) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.search-nav :deep(.el-scrollbar__view) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.message-docs-content {
|
||||
color: #39455f;
|
||||
font-family: 'Roboto';
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message-docs-content :deep(.el-scrollbar__wrap) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.message-docs-content :deep(.el-scrollbar__view) {
|
||||
padding: 25px 30px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Right Sidebar - Definitions Panel */
|
||||
.definitions-panel {
|
||||
border-left: 2px solid #e4e7ed;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.definitions-panel :deep(.el-scrollbar__wrap) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.definitions-panel :deep(.el-scrollbar__view) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.definitions-panel-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.definitions-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #f9fafb;
|
||||
padding: 10px 0 20px 0;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 2px solid #e4e7ed;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.definitions-header h2 {
|
||||
color: #303133;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.3rem;
|
||||
font-family: 'Roboto';
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.definitions-subtitle {
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-family: 'Roboto';
|
||||
}
|
||||
|
||||
h2 {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
section {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: 'Roboto';
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #39455f;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content :deep(strong) {
|
||||
font-family: 'Roboto';
|
||||
}
|
||||
|
||||
.content :deep(a):hover {
|
||||
background-color: #39455f;
|
||||
}
|
||||
|
||||
.message-docs-header {
|
||||
padding: 20px 0;
|
||||
border-bottom: 2px solid #e4e7ed;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message-docs-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 0.5rem 0;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.connector-subtitle {
|
||||
font-size: 1rem;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.version-indicator {
|
||||
font-size: 0.75rem;
|
||||
color: #67c23a;
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.topics > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.no-schema-message {
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
padding: 15px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.definition-item {
|
||||
margin-bottom: 25px;
|
||||
padding: 15px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e4e7ed;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.definition-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.definition-item h3 {
|
||||
color: #409eff;
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1rem;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-weight: 600;
|
||||
font-family: 'Roboto';
|
||||
}
|
||||
|
||||
.definition-item a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.definition-item a:hover h3 {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-definitions {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
font-family: 'Roboto';
|
||||
}
|
||||
|
||||
/* Highlight animation for scrolled-to definitions */
|
||||
@keyframes highlight-pulse {
|
||||
0% {
|
||||
background-color: rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(64, 158, 255, 0.25);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.definition-item.highlight-definition {
|
||||
animation: highlight-pulse 0.6s ease-in-out 3;
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
107
src/views/MessageDocsListView.vue
Normal file
107
src/views/MessageDocsListView.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { obpGroupedMessageDocsKey } from '@/obp/keys'
|
||||
|
||||
const router = useRouter()
|
||||
const groupedMessageDocs = ref(inject(obpGroupedMessageDocsKey) || {})
|
||||
|
||||
const connectorList = computed(() => {
|
||||
return Object.keys(groupedMessageDocs.value || {}).sort()
|
||||
})
|
||||
|
||||
function navigateToConnector(connectorId: string) {
|
||||
router.push(`/message-docs/${connectorId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="message-docs-list-container">
|
||||
<el-main>
|
||||
<h1>Message Documentation</h1>
|
||||
|
||||
<div class="message-docs-list">
|
||||
<div v-if="connectorList.length === 0" class="empty-message">
|
||||
No message documentation available
|
||||
</div>
|
||||
<div v-else>
|
||||
<a
|
||||
v-for="connector in connectorList"
|
||||
:key="connector"
|
||||
@click="navigateToConnector(connector)"
|
||||
class="message-doc-link"
|
||||
>
|
||||
{{ connector }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-docs-list-container {
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.message-docs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.message-doc-link {
|
||||
padding: 12px 16px;
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-doc-link:hover {
|
||||
background-color: #ecf5ff;
|
||||
color: #337ecc;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@ -36,7 +36,7 @@ import CodeBlock from '../components/CodeBlock.vue';
|
||||
|
||||
let connector = connectors[0]
|
||||
const route = useRoute()
|
||||
const groupedMessageDocs = ref(inject(obpGroupedMessageDocsKey)!)
|
||||
const groupedMessageDocs = ref(inject(obpGroupedMessageDocsKey) || {})
|
||||
const messageDocs = ref(null as any)
|
||||
|
||||
const activeNames = ref(['1', '2', '3', '4', '5', '6'])
|
||||
@ -77,12 +77,13 @@ function showDependentEndpoints(value: any) {
|
||||
<el-main class="message-docs-content">
|
||||
<el-scrollbar>
|
||||
<el-backtop :right="100" :bottom="100" />
|
||||
<div class="message-docs-header">
|
||||
<h1>{{ connector }}</h1>
|
||||
<p class="connector-subtitle">Message Docs</p>
|
||||
</div>
|
||||
<div v-for="(group, key) of messageDocs" :key="key">
|
||||
<div v-for="(value, key) of group" :key="value">
|
||||
<el-divider></el-divider>
|
||||
<header>
|
||||
|
||||
</header>
|
||||
<a v-bind:href="`#${value.process}`" :id="value.process">
|
||||
<h2>{{ value.process }}</h2>
|
||||
</a>
|
||||
@ -251,4 +252,25 @@ div {
|
||||
.content :deep(a):hover {
|
||||
background-color: #39455f;
|
||||
}
|
||||
|
||||
.message-docs-header {
|
||||
padding: 20px 0;
|
||||
border-bottom: 2px solid #e4e7ed;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message-docs-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 0.5rem 0;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.connector-subtitle {
|
||||
font-size: 1rem;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
455
src/views/OIDCDebugView.vue
Normal file
455
src/views/OIDCDebugView.vue
Normal file
@ -0,0 +1,455 @@
|
||||
<!--
|
||||
Open Bank Project - API Explorer II
|
||||
Copyright (C) 2023-2025, TESOBE GmbH
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Email: contact@tesobe.com
|
||||
TESOBE GmbH
|
||||
Osloerstrasse 16/17
|
||||
Berlin 13359, Germany
|
||||
|
||||
This product includes software developed at
|
||||
TESOBE (http://www.tesobe.com/)
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="oidc-debug-view">
|
||||
<div class="header">
|
||||
<h1>OIDC Provider Discovery</h1>
|
||||
<el-button type="primary" @click="refreshDebugInfo" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
Refresh
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 20px">
|
||||
<p><strong>Step 1:</strong> API Explorer discovers providers from OBP API: <code>{{ obpWellKnownUrl }}</code></p>
|
||||
<p><strong>Step 2:</strong> For each provider, fetch their OIDC configuration from their .well-known URL</p>
|
||||
</el-alert>
|
||||
|
||||
<div v-if="loading" v-loading="loading" class="loading-container">
|
||||
<p>Loading provider information...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<el-alert type="error" :closable="false">
|
||||
<p><strong>Error:</strong> {{ error }}</p>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div v-else-if="debugInfo">
|
||||
<!-- OBP API Discovery Status -->
|
||||
<el-card class="section-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Step 1: OBP API Provider Discovery</span>
|
||||
<el-tag :type="debugInfo.discoveryProcess.step1_obpApiDiscovery.success ? 'success' : 'danger'">
|
||||
{{ debugInfo.discoveryProcess.step1_obpApiDiscovery.success ? 'Success' : 'Failed' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="section-content">
|
||||
<div class="info-row">
|
||||
<label>OBP API Endpoint:</label>
|
||||
<code>{{ debugInfo.discoveryProcess.step1_obpApiDiscovery.endpoint }}</code>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<label>Providers Discovered:</label>
|
||||
<span>{{ debugInfo.discoveryProcess.step1_obpApiDiscovery.providers.length }}</span>
|
||||
</div>
|
||||
<div v-if="debugInfo.discoveryProcess.step1_obpApiDiscovery.error" class="error-message">
|
||||
<strong>Error:</strong> {{ debugInfo.discoveryProcess.step1_obpApiDiscovery.error }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Provider List -->
|
||||
<el-card class="section-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Step 2: Provider OIDC Configurations</span>
|
||||
<el-tag type="info">{{ debugInfo.discoveryProcess.step2_providerConfigurations.length }} Provider(s)</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="section-content">
|
||||
<div v-if="debugInfo.discoveryProcess.step2_providerConfigurations.length === 0" class="no-data">
|
||||
<el-empty description="No providers found" />
|
||||
</div>
|
||||
|
||||
<div v-else class="provider-list">
|
||||
<div
|
||||
v-for="provider in debugInfo.discoveryProcess.step2_providerConfigurations"
|
||||
:key="provider.providerName"
|
||||
class="provider-item"
|
||||
:class="{ 'provider-success': provider.success, 'provider-error': !provider.success }"
|
||||
>
|
||||
<div class="provider-header">
|
||||
<h3>{{ provider.providerName }}</h3>
|
||||
<el-tag :type="provider.success ? 'success' : 'danger'" size="small">
|
||||
{{ provider.success ? 'Success' : 'Failed' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="provider-details">
|
||||
<div class="detail-row">
|
||||
<label>Well-Known URL:</label>
|
||||
<div class="url-display">
|
||||
<code>{{ provider.wellKnownUrl }}</code>
|
||||
<el-button size="small" @click="copyToClipboard(provider.wellKnownUrl)" text>
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
Copy
|
||||
</el-button>
|
||||
<el-button size="small" @click="testEndpoint(provider.wellKnownUrl)" text>
|
||||
<el-icon><Link /></el-icon>
|
||||
Test
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="provider.error" class="error-message">
|
||||
<strong>Error:</strong> {{ provider.error }}
|
||||
</div>
|
||||
|
||||
<div v-if="provider.success" class="endpoints-section">
|
||||
<label>OIDC Endpoints:</label>
|
||||
<div class="endpoints-list">
|
||||
<div class="endpoint-row">
|
||||
<span class="endpoint-label">Issuer:</span>
|
||||
<code>{{ provider.issuer }}</code>
|
||||
</div>
|
||||
<div class="endpoint-row">
|
||||
<span class="endpoint-label">Authorization:</span>
|
||||
<code>{{ provider.endpoints.authorization }}</code>
|
||||
</div>
|
||||
<div class="endpoint-row">
|
||||
<span class="endpoint-label">Token:</span>
|
||||
<code>{{ provider.endpoints.token }}</code>
|
||||
</div>
|
||||
<div class="endpoint-row">
|
||||
<span class="endpoint-label">UserInfo:</span>
|
||||
<code>{{ provider.endpoints.userinfo }}</code>
|
||||
</div>
|
||||
<div class="endpoint-row">
|
||||
<span class="endpoint-label">JWKS:</span>
|
||||
<code>{{ provider.endpoints.jwks }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, CopyDocument, Link } from '@element-plus/icons-vue'
|
||||
|
||||
interface DebugInfo {
|
||||
discoveryProcess: {
|
||||
step1_obpApiDiscovery: {
|
||||
endpoint: string
|
||||
success: boolean
|
||||
error: string | null
|
||||
providers: Array<{ provider: string; url: string }>
|
||||
}
|
||||
step2_providerConfigurations: Array<{
|
||||
providerName: string
|
||||
wellKnownUrl: string
|
||||
success: boolean
|
||||
error: string | null
|
||||
endpoints: {
|
||||
authorization: string | null
|
||||
token: string | null
|
||||
userinfo: string | null
|
||||
jwks: string | null
|
||||
}
|
||||
issuer: string | null
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const debugInfo = ref<DebugInfo | null>(null)
|
||||
|
||||
const obpWellKnownUrl = computed(() => {
|
||||
return debugInfo.value?.discoveryProcess.step1_obpApiDiscovery.endpoint || 'Loading...'
|
||||
})
|
||||
|
||||
const fetchDebugInfo = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/status/oidc-debug')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
debugInfo.value = await response.json()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
ElMessage.error('Failed to load provider information')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDebugInfo = async () => {
|
||||
ElMessage.info('Refreshing provider information...')
|
||||
await fetchDebugInfo()
|
||||
ElMessage.success('Provider information refreshed')
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('Copied to clipboard')
|
||||
} catch (err) {
|
||||
ElMessage.error('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
const testEndpoint = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDebugInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oidc-debug-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-row label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.info-row code {
|
||||
background: #f4f4f5;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
padding: 20px;
|
||||
border: 2px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.provider-success {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.provider-error {
|
||||
border-color: #f56c6c;
|
||||
background: #fef0f0;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.provider-header h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.provider-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-row label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.url-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.url-display code {
|
||||
flex: 1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 10px;
|
||||
background: #fef0f0;
|
||||
border-left: 4px solid #f56c6c;
|
||||
border-radius: 4px;
|
||||
color: #f56c6c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.endpoints-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.endpoints-section label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.endpoints-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 15px;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.endpoint-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.endpoint-label {
|
||||
font-weight: 500;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.endpoint-row code {
|
||||
background: #f4f4f5;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.endpoint-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
705
src/views/ProvidersStatusView.vue
Normal file
705
src/views/ProvidersStatusView.vue
Normal file
@ -0,0 +1,705 @@
|
||||
<!--
|
||||
Open Bank Project - API Explorer II
|
||||
Copyright (C) 2023-2025, TESOBE GmbH
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Email: contact@tesobe.com
|
||||
TESOBE GmbH
|
||||
Osloerstrasse 16/17
|
||||
Berlin 13359, Germany
|
||||
|
||||
This product includes software developed at
|
||||
TESOBE (http://www.tesobe.com/)
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="providers-status-view">
|
||||
<h1>OAuth2 Provider Configuration Status</h1>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 20px">
|
||||
<p>This page shows which OAuth2/OIDC identity providers are configured and available for login.</p>
|
||||
<p><strong>Note:</strong> Client secrets are masked for security.</p>
|
||||
<p>
|
||||
<strong>Need more details?</strong> Visit the
|
||||
<router-link to="/debug/oidc" style="color: #409eff; text-decoration: underline;">
|
||||
OIDC Debug Page
|
||||
</router-link>
|
||||
for detailed discovery process information.
|
||||
</p>
|
||||
</el-alert>
|
||||
|
||||
<div v-if="loading" v-loading="loading" class="loading-container">
|
||||
<p>Loading provider status...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<el-alert type="error" :closable="false">
|
||||
<p><strong>Error loading provider status:</strong></p>
|
||||
<p>{{ error }}</p>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status" class="status-container">
|
||||
<!-- Summary Card -->
|
||||
<el-card class="summary-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Summary</span>
|
||||
<el-button size="small" @click="refreshStatus">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
Refresh
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="summary-content">
|
||||
<div class="summary-item">
|
||||
<label>Total Configured Providers:</label>
|
||||
<span class="value">{{ status.summary.totalConfigured }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<label>Available Providers:</label>
|
||||
<span class="value">{{ status.summary.availableProviders.join(', ') || 'None' }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<label>OBP API Host:</label>
|
||||
<span class="value">{{ status.summary.obpApiHost }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Provider Status Cards -->
|
||||
<h2>Active Providers</h2>
|
||||
<div v-if="status.providerStatus.length === 0" class="no-providers">
|
||||
<el-empty description="No providers configured">
|
||||
<el-button type="primary" @click="openDocs">View Setup Guide</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
<div v-else class="provider-cards">
|
||||
<el-card
|
||||
v-for="provider in status.providerStatus"
|
||||
:key="provider.name"
|
||||
class="provider-card"
|
||||
:class="{ 'provider-healthy': provider.available, 'provider-unhealthy': !provider.available }"
|
||||
shadow="hover"
|
||||
>
|
||||
<template #header>
|
||||
<div class="provider-header">
|
||||
<span class="provider-name">{{ getProviderDisplayName(provider.name) }}</span>
|
||||
<el-tag :type="provider.available ? 'success' : 'danger'" size="small">
|
||||
{{ provider.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="provider-content">
|
||||
<div class="provider-detail">
|
||||
<label>Provider ID:</label>
|
||||
<span>{{ provider.name }}</span>
|
||||
</div>
|
||||
<div class="provider-detail">
|
||||
<label>Status:</label>
|
||||
<span :class="provider.available ? 'status-ok' : 'status-error'">
|
||||
{{ provider.available ? 'Available' : 'Unavailable' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="provider-detail">
|
||||
<label>Last Checked:</label>
|
||||
<span>{{ formatDate(provider.lastChecked) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Error Display -->
|
||||
<div v-if="provider.error" class="error-section">
|
||||
<div class="error-header">
|
||||
<el-icon class="error-icon"><WarningFilled /></el-icon>
|
||||
<span class="error-category">{{ getErrorCategory(provider.error) }}</span>
|
||||
</div>
|
||||
<div class="error-message">
|
||||
<strong>Error:</strong> {{ getFormattedError(provider.error) }}
|
||||
</div>
|
||||
|
||||
<!-- Troubleshooting Hints -->
|
||||
<div class="troubleshooting-hints">
|
||||
<div class="hint-title">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<strong>Troubleshooting:</strong>
|
||||
</div>
|
||||
<ul class="hint-list">
|
||||
<li v-for="(hint, index) in getTroubleshootingHints(provider.error)" :key="index">
|
||||
{{ hint }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Retry Button -->
|
||||
<div class="retry-section">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="retryingProviders.has(provider.name)"
|
||||
@click="retryProvider(provider.name)"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
{{ retryingProviders.has(provider.name) ? 'Retrying...' : 'Retry Now' }}
|
||||
</el-button>
|
||||
<span class="retry-hint">Manual retry to reinitialize this provider</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Environment Configuration -->
|
||||
<h2>Environment Configuration</h2>
|
||||
<el-collapse v-model="activeCollapse">
|
||||
<el-collapse-item
|
||||
v-for="(config, providerKey) in status.environmentConfig"
|
||||
:key="providerKey"
|
||||
:name="providerKey"
|
||||
>
|
||||
<template #title>
|
||||
<div class="collapse-title">
|
||||
<span class="provider-label">{{ getProviderDisplayName(providerKey) }}</span>
|
||||
<el-tag v-if="isProviderConfigured(config)" type="success" size="small">
|
||||
Configured
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" size="small">
|
||||
Not Configured
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="env-config-content">
|
||||
<div v-for="(value, key) in config" :key="key" class="config-item">
|
||||
<label>{{ formatConfigKey(key) }}:</label>
|
||||
<code>{{ value }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<div class="note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ status.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, InfoFilled, WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
interface ProviderStatus {
|
||||
name: string
|
||||
available: boolean
|
||||
status: string
|
||||
lastChecked: Date
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface EnvConfig {
|
||||
[key: string]: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
summary: {
|
||||
totalConfigured: number
|
||||
availableProviders: string[]
|
||||
obpApiHost: string
|
||||
}
|
||||
providerStatus: ProviderStatus[]
|
||||
environmentConfig: EnvConfig
|
||||
note: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const status = ref<StatusResponse | null>(null)
|
||||
const activeCollapse = ref<string[]>(['obpOidc', 'keycloak', 'google', 'github', 'custom'])
|
||||
const retryingProviders = ref<Set<string>>(new Set())
|
||||
|
||||
const fetchStatus = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/status/providers')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
status.value = await response.json()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
ElMessage.error('Failed to load provider status')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatus = async () => {
|
||||
ElMessage.info('Refreshing provider status...')
|
||||
await fetchStatus()
|
||||
ElMessage.success('Provider status refreshed')
|
||||
}
|
||||
|
||||
const retryProvider = async (providerName: string) => {
|
||||
retryingProviders.value.add(providerName)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/status/providers/${providerName}/retry`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok && result.success) {
|
||||
ElMessage.success(`Provider ${providerName} successfully initialized!`)
|
||||
await fetchStatus() // Refresh the status
|
||||
} else {
|
||||
ElMessage.error(result.message || `Failed to retry provider ${providerName}`)
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(`Error retrying provider: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
retryingProviders.value.delete(providerName)
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorCategory = (errorMsg: string): string => {
|
||||
if (!errorMsg) return 'Unknown Error'
|
||||
|
||||
const msg = errorMsg.toLowerCase()
|
||||
|
||||
if (msg.includes('http 4') || msg.includes('http 5')) {
|
||||
return 'HTTP Error'
|
||||
} else if (msg.includes('timeout') || msg.includes('network') || msg.includes('fetch')) {
|
||||
return 'Network Error'
|
||||
} else if (msg.includes('not configured') || msg.includes('no well-known')) {
|
||||
return 'Configuration Error'
|
||||
} else if (msg.includes('failed to initialize')) {
|
||||
return 'Initialization Error'
|
||||
} else if (msg.includes('invalid') || msg.includes('parse')) {
|
||||
return 'Validation Error'
|
||||
}
|
||||
|
||||
return 'General Error'
|
||||
}
|
||||
|
||||
const getFormattedError = (errorMsg: string): string => {
|
||||
if (!errorMsg) return 'Unknown error occurred'
|
||||
|
||||
// Make common errors more user-friendly
|
||||
const msg = errorMsg.toLowerCase()
|
||||
|
||||
if (msg.includes('http 404')) {
|
||||
return 'Provider endpoint not found (HTTP 404). The OIDC configuration endpoint is not accessible.'
|
||||
} else if (msg.includes('http 401') || msg.includes('http 403')) {
|
||||
return 'Access denied (HTTP 401/403). Authentication or authorization required.'
|
||||
} else if (msg.includes('http 500') || msg.includes('http 502') || msg.includes('http 503')) {
|
||||
return 'Provider server error (HTTP 5xx). The identity provider is experiencing issues.'
|
||||
} else if (msg.includes('timeout')) {
|
||||
return 'Connection timeout. The provider is not responding in a timely manner.'
|
||||
} else if (msg.includes('network')) {
|
||||
return 'Network connectivity issue. Cannot reach the provider server.'
|
||||
} else if (msg.includes('no well-known url configured')) {
|
||||
return 'Missing well-known URL. The provider configuration is incomplete.'
|
||||
} else if (msg.includes('failed to initialize')) {
|
||||
return 'Initialization failed. Could not create OAuth2 client for this provider.'
|
||||
}
|
||||
|
||||
return errorMsg
|
||||
}
|
||||
|
||||
const getTroubleshootingHints = (errorMsg: string): string[] => {
|
||||
if (!errorMsg) return ['Check server logs for more details']
|
||||
|
||||
const msg = errorMsg.toLowerCase()
|
||||
const hints: string[] = []
|
||||
|
||||
if (msg.includes('http 404')) {
|
||||
hints.push('Verify the provider\'s well-known URL is correct in the OBP API configuration')
|
||||
hints.push('Check that the identity provider is properly deployed and accessible')
|
||||
hints.push('Ensure the OIDC discovery endpoint exists at /.well-known/openid-configuration')
|
||||
} else if (msg.includes('http 401') || msg.includes('http 403')) {
|
||||
hints.push('Check if the provider requires authentication for the discovery endpoint')
|
||||
hints.push('Verify API credentials and permissions')
|
||||
} else if (msg.includes('http 5')) {
|
||||
hints.push('The identity provider may be down or restarting')
|
||||
hints.push('Check the provider\'s status page or logs')
|
||||
hints.push('Try again in a few minutes')
|
||||
} else if (msg.includes('timeout') || msg.includes('network')) {
|
||||
hints.push('Verify network connectivity between API Explorer and the identity provider')
|
||||
hints.push('Check firewall rules and DNS resolution')
|
||||
hints.push('Ensure the provider URL is accessible from the server')
|
||||
hints.push('Try accessing the well-known URL manually from the server')
|
||||
} else if (msg.includes('no well-known') || msg.includes('not configured')) {
|
||||
hints.push('Check environment variables for this provider')
|
||||
hints.push('Verify the provider is configured in the OBP API')
|
||||
hints.push('Review the SETUP_MULTI_PROVIDER.md documentation')
|
||||
} else if (msg.includes('failed to initialize')) {
|
||||
hints.push('Check the provider\'s OIDC configuration is valid')
|
||||
hints.push('Verify client ID and client secret are correct')
|
||||
hints.push('Review server logs for detailed error messages')
|
||||
} else {
|
||||
hints.push('Check server logs for detailed error information')
|
||||
hints.push('Verify the provider configuration in environment variables')
|
||||
hints.push('Visit the OIDC Debug page for more details')
|
||||
}
|
||||
|
||||
return hints
|
||||
}
|
||||
|
||||
const getProviderDisplayName = (key: string): string => {
|
||||
const names: { [key: string]: string } = {
|
||||
'obp-oidc': 'OBP-OIDC',
|
||||
'obpOidc': 'OBP-OIDC',
|
||||
'keycloak': 'Keycloak',
|
||||
'google': 'Google',
|
||||
'github': 'GitHub',
|
||||
'custom': 'Custom Provider'
|
||||
}
|
||||
return names[key] || key.toUpperCase()
|
||||
}
|
||||
|
||||
const formatConfigKey = (key: string): string => {
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | string): string => {
|
||||
if (!date) return 'Never'
|
||||
const d = new Date(date)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
const isProviderConfigured = (config: { [key: string]: string }): boolean => {
|
||||
return Object.values(config).some(
|
||||
(value) => value && value !== 'not configured' && value !== '***masked***'
|
||||
)
|
||||
}
|
||||
|
||||
const openDocs = () => {
|
||||
window.open('https://github.com/OpenBankProject/OBP-API/wiki/OAuth2', '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.providers-status-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin: 30px 0 15px 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Summary Card */
|
||||
.summary-card {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.summary-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summary-item label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* Provider Cards */
|
||||
.no-providers {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.provider-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.provider-healthy {
|
||||
border-left: 4px solid #67c23a;
|
||||
}
|
||||
|
||||
.provider-unhealthy {
|
||||
border-left: 4px solid #f56c6c;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.provider-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.provider-detail label {
|
||||
font-weight: 500;
|
||||
color: #909399;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.provider-detail span {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
color: #67c23a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
background: #fef0f0;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Enhanced Error Section */
|
||||
.error-section {
|
||||
background: #fef0f0;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #f56c6c;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #f56c6c;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-category {
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fbc4c4;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.error-message strong {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.troubleshooting-hints {
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.hint-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.retry-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fbc4c4;
|
||||
}
|
||||
|
||||
.retry-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Environment Config */
|
||||
.collapse-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.env-config-content {
|
||||
padding: 10px 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.config-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.config-item code {
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
/* Note */
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 30px;
|
||||
padding: 12px;
|
||||
background: #f4f4f5;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.note .el-icon {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
12
svelte.config.mjs
Normal file
12
svelte.config.mjs
Normal file
@ -0,0 +1,12 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
compilerOptions: {
|
||||
// Enable runes mode for Svelte 5
|
||||
runes: true
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"esModuleInterop": true,
|
||||
"target": "ES6",
|
||||
"target": "ES2022",
|
||||
"outDir": "dist-server",
|
||||
"rootDir": "server",
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["src", "server/test"],
|
||||
"include": ["server"]
|
||||
"exclude": ["server/test", "node_modules"],
|
||||
"include": ["server", "src/shared-constants.ts"]
|
||||
}
|
||||
|
||||
@ -3,32 +3,34 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
import { loadEnv, defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
import pluginRewriteAll from 'vite-plugin-rewrite-all';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(), vueJsx(),
|
||||
vue(),
|
||||
vueJsx(),
|
||||
svelte(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
resolvers: [ElementPlusResolver()]
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
resolvers: [ElementPlusResolver()]
|
||||
}),
|
||||
nodePolyfills({
|
||||
protocolImports: true,
|
||||
}),
|
||||
pluginRewriteAll(),
|
||||
protocolImports: true
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json', '.vue', '.svelte']
|
||||
},
|
||||
define: {
|
||||
__VUE_I18N_FULL_INSTALL__: true,
|
||||
@ -36,13 +38,13 @@ export default defineConfig({
|
||||
__INTLIFY_PROD_DEVTOOLS__: false,
|
||||
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
|
||||
},
|
||||
server:{
|
||||
server: {
|
||||
proxy: {
|
||||
'^/api': {
|
||||
target: 'http://localhost:8085/api',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user