@canva/cli 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +2 -0
- package/cli.js +596 -574
- package/lib/cjs/index.cjs +2 -2
- package/lib/esm/index.mjs +2 -2
- package/lib/index.d.ts +2 -0
- package/package.json +7 -2
- package/templates/base/package.json +9 -8
- package/templates/base/styles/components.css +18 -0
- package/templates/common/.env.template +1 -1
- package/templates/common/jest.config.mjs +1 -1
- package/templates/dam/backend/server.ts +8 -0
- package/templates/dam/canva-app.json +9 -0
- package/templates/dam/package.json +10 -8
- package/templates/dam/src/index.tsx +3 -21
- package/templates/dam/src/intents/design_editor/index.tsx +25 -0
- package/templates/data_connector/README.md +1 -1
- package/templates/data_connector/package.json +9 -8
- package/templates/data_connector/src/api/data_sources/designs.tsx +1 -1
- package/templates/data_connector/src/api/data_sources/templates.tsx +1 -1
- package/templates/data_connector/src/components/header.tsx +1 -1
- package/templates/data_connector/src/index.tsx +2 -66
- package/templates/data_connector/src/{app.tsx → intents/data_connector/app.tsx} +3 -3
- package/templates/data_connector/src/{entrypoint.tsx → intents/data_connector/entrypoint.tsx} +5 -5
- package/templates/data_connector/src/{home.tsx → intents/data_connector/home.tsx} +1 -1
- package/templates/data_connector/src/intents/data_connector/index.tsx +56 -0
- package/templates/data_connector/src/pages/error.tsx +1 -1
- package/templates/data_connector/src/pages/login.tsx +1 -1
- package/templates/data_connector/src/routes/protected_route.tsx +1 -1
- package/templates/data_connector/src/routes/routes.tsx +3 -3
- package/templates/data_connector/styles/components.css +18 -0
- package/templates/gen_ai/backend/server.ts +17 -0
- package/templates/gen_ai/canva-app.json +5 -0
- package/templates/gen_ai/package.json +10 -8
- package/templates/gen_ai/src/api/api.ts +4 -0
- package/templates/gen_ai/src/components/footer.tsx +1 -1
- package/templates/gen_ai/src/components/loading_results.tsx +1 -1
- package/templates/gen_ai/src/components/prompt_input.tsx +1 -1
- package/templates/gen_ai/src/index.tsx +3 -14
- package/templates/gen_ai/src/{app.tsx → intents/design_editor/app.tsx} +3 -3
- package/templates/gen_ai/src/{home.tsx → intents/design_editor/home.tsx} +1 -1
- package/templates/gen_ai/src/intents/design_editor/index.tsx +17 -0
- package/templates/gen_ai/src/pages/error.tsx +1 -1
- package/templates/gen_ai/src/routes/routes.tsx +2 -2
- package/templates/gen_ai/styles/components.css +18 -0
- package/templates/hello_world/canva-app.json +5 -0
- package/templates/hello_world/package.json +10 -8
- package/templates/hello_world/src/index.tsx +3 -21
- package/templates/hello_world/src/{app.tsx → intents/design_editor/app.tsx} +26 -3
- package/templates/hello_world/src/intents/design_editor/index.tsx +25 -0
- package/templates/hello_world/src/{tests → intents/design_editor/tests}/app.tests.tsx +19 -13
- package/templates/hello_world/styles/components.css +18 -0
- package/templates/mls/README.md +81 -0
- package/templates/mls/canva-app.json +25 -0
- package/templates/mls/declarations/declarations.d.ts +29 -0
- package/templates/mls/eslint.config.mjs +14 -0
- package/templates/mls/jest.config.mjs +36 -0
- package/templates/mls/jest.setup.ts +37 -0
- package/templates/mls/package.json +117 -0
- package/templates/mls/scripts/copy_env.ts +13 -0
- package/templates/mls/scripts/ssl/ssl.ts +131 -0
- package/templates/mls/scripts/start/app_runner.ts +223 -0
- package/templates/mls/scripts/start/context.ts +171 -0
- package/templates/mls/scripts/start/start.ts +46 -0
- package/templates/mls/src/__tests__/app.tests.tsx +11 -0
- package/templates/mls/src/__tests__/office_selection_page.tests.tsx +72 -0
- package/templates/mls/src/__tests__/utils.tsx +19 -0
- package/templates/mls/src/adapter.ts +126 -0
- package/templates/mls/src/components/agent/agent_card.tsx +57 -0
- package/templates/mls/src/components/agent/agent_grid.tsx +37 -0
- package/templates/mls/src/components/agent/agent_list.tsx +17 -0
- package/templates/mls/src/components/agent/agent_search_filters.tsx +88 -0
- package/templates/mls/src/components/breadcrumb/breadcrumb.tsx +40 -0
- package/templates/mls/src/components/listing/listing_card.tsx +64 -0
- package/templates/mls/src/components/listing/listing_grid.tsx +37 -0
- package/templates/mls/src/components/listing/listing_list.tsx +21 -0
- package/templates/mls/src/components/listing/listing_search_filters.tsx +145 -0
- package/templates/mls/src/components/placeholders/placeholders.tsx +65 -0
- package/templates/mls/src/data.ts +359 -0
- package/templates/mls/src/index.tsx +4 -0
- package/templates/mls/src/intents/design_editor/app.tsx +44 -0
- package/templates/mls/src/intents/design_editor/index.tsx +25 -0
- package/templates/mls/src/pages/agent_details_page/agent_details_page.tsx +175 -0
- package/templates/mls/src/pages/list_page/agent_tab_panel.tsx +126 -0
- package/templates/mls/src/pages/list_page/list_page.tsx +67 -0
- package/templates/mls/src/pages/list_page/listing_tab_panel.tsx +135 -0
- package/templates/mls/src/pages/listing_details_page/listing_details_page.tsx +418 -0
- package/templates/mls/src/pages/loading_page/loading_page.tsx +152 -0
- package/templates/mls/src/pages/office_selection_page/office_selection_page.tsx +144 -0
- package/templates/mls/src/real_estate.type.ts +44 -0
- package/templates/mls/src/util/use_add_element.tsx +62 -0
- package/templates/mls/src/util/use_drag_element.tsx +68 -0
- package/templates/mls/styles/components.css +56 -0
- package/templates/mls/tsconfig.json +55 -0
- package/templates/mls/webpack.config.ts +254 -0
- package/templates/optional/AGENTS.md +80 -2
- package/templates/optional/CLAUDE.md +80 -2
- package/templates/base/backend/routers/oauth.ts +0 -393
- package/templates/base/utils/backend/bearer_middleware/bearer_middleware.ts +0 -99
- package/templates/base/utils/backend/bearer_middleware/index.ts +0 -1
- package/templates/base/utils/backend/bearer_middleware/tests/bearer_middleware.tests.ts +0 -192
- package/templates/base/utils/use_add_element.ts +0 -58
- package/templates/base/utils/use_feature_support.ts +0 -28
- package/templates/common/utils/backend/base_backend/create.ts +0 -104
- package/templates/common/utils/table_wrapper.ts +0 -520
- package/templates/common/utils/use_add_element.ts +0 -58
- package/templates/common/utils/use_feature_support.ts +0 -28
- package/templates/common/utils/use_overlay_hook.ts +0 -76
- package/templates/common/utils/use_selection_hook.ts +0 -37
- package/templates/gen_ai/backend/database/database.ts +0 -42
- package/templates/gen_ai/utils/backend/bearer_middleware/bearer_middleware.ts +0 -99
- package/templates/gen_ai/utils/backend/bearer_middleware/index.ts +0 -1
- package/templates/hello_world/utils/use_add_element.ts +0 -58
- package/templates/hello_world/utils/use_feature_support.ts +0 -28
- /package/templates/base/{utils/backend → backend}/base_backend/create.ts +0 -0
- /package/templates/base/{utils/backend → backend}/jwt_middleware/index.ts +0 -0
- /package/templates/base/{utils/backend → backend}/jwt_middleware/jwt_middleware.ts +0 -0
- /package/templates/dam/src/{adapter.ts → intents/design_editor/adapter.ts} +0 -0
- /package/templates/dam/src/{app.tsx → intents/design_editor/app.tsx} +0 -0
- /package/templates/dam/src/{config.ts → intents/design_editor/config.ts} +0 -0
- /package/templates/dam/src/{index.css → intents/design_editor/index.css} +0 -0
- /package/templates/data_connector/src/{paths.ts → routes/paths.ts} +0 -0
- /package/templates/gen_ai/src/{paths.ts → routes/paths.ts} +0 -0
- /package/templates/{common → gen_ai}/utils/backend/jwt_middleware/index.ts +0 -0
- /package/templates/{common → gen_ai}/utils/backend/jwt_middleware/jwt_middleware.ts +0 -0
- /package/templates/hello_world/src/{tests → intents/design_editor/tests}/__snapshots__/app.tests.tsx.snap +0 -0
|
@@ -29,8 +29,9 @@ The app, once submitted to Canva, will be rendered in a sandboxed iframe within
|
|
|
29
29
|
## Architecture
|
|
30
30
|
|
|
31
31
|
- **Main App**:
|
|
32
|
-
- `src/index.tsx` - Main application
|
|
33
|
-
- `src/
|
|
32
|
+
- `src/index.tsx` - Main application React application entry point.
|
|
33
|
+
- `src/intents/[intent]/index.tsx` - Every intent that the app implements should be contained in the intents path.
|
|
34
|
+
- `src/intents/[intent]/app.tsx` - Main application component for each intent, which should be split into smaller components as needed, following React best practices.
|
|
34
35
|
- `src/styles/component.css` - Main application styles.
|
|
35
36
|
- `utils/` - Utility functions and helpers, which can be used across the app.
|
|
36
37
|
- `scripts/` - Scripts for building, and running the app, should not contain any business logic.
|
|
@@ -74,3 +75,80 @@ The app, once submitted to Canva, will be rendered in a sandboxed iframe within
|
|
|
74
75
|
- **Required**: AI agents should verify and encourage the user to enable Canva Dev MCP server.
|
|
75
76
|
- **Documentation**: <https://www.canva.dev/docs/apps/mcp-server.md>
|
|
76
77
|
- **If not setup**: Read the docs and prompt user to configure the MCP server for enhanced Canva APIs assistance.
|
|
78
|
+
|
|
79
|
+
## Intent-based architecture
|
|
80
|
+
|
|
81
|
+
All apps should follow the intent-based architecture pattern. Each root index file should call the prepare function for each enabled intent. Each intent index file should implement the full intent contract.
|
|
82
|
+
|
|
83
|
+
### File structure
|
|
84
|
+
|
|
85
|
+
Organize code with a dedicated folder for each intent under `src/intents/`:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
src/
|
|
89
|
+
├── intents/
|
|
90
|
+
│ ├── design_editor/
|
|
91
|
+
│ │ ├── index.tsx
|
|
92
|
+
│ │ └── editor_app.tsx
|
|
93
|
+
│ └── content_publisher/
|
|
94
|
+
│ ├── index.tsx
|
|
95
|
+
│ ├── preview_ui.tsx
|
|
96
|
+
│ └── setting_ui.tsx
|
|
97
|
+
└── index.tsx
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Root index file
|
|
101
|
+
|
|
102
|
+
The root `src/index.tsx` should be minimal, containing only:
|
|
103
|
+
|
|
104
|
+
- Importing each prepare function
|
|
105
|
+
- Importing each intent
|
|
106
|
+
- The prepare call for each intent implementation.
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
import { prepareContentPublisher } from "@canva/intents/content";
|
|
110
|
+
import { prepareDesignEditor } from "@canva/intents/design";
|
|
111
|
+
|
|
112
|
+
import contentPublisher from "./intents/content_publisher";
|
|
113
|
+
import designEditor from "./intents/design_editor";
|
|
114
|
+
|
|
115
|
+
prepareContentPublisher(contentPublisher);
|
|
116
|
+
prepareDesignEditor(designEditor);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Intent index files
|
|
120
|
+
|
|
121
|
+
Each intent's `index.tsx` file should:
|
|
122
|
+
|
|
123
|
+
- Import dependencies, including the app ui kit css.
|
|
124
|
+
- Implement the full contract for the intent and use this as the default export from the file.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// src/intents/design_editor/index.tsx
|
|
128
|
+
import "@canva/app-ui-kit/styles.css";
|
|
129
|
+
import type { DesignEditorIntent } from "@canva/intents/design";
|
|
130
|
+
import { prepareDesignEditor } from "@canva/intents/design";
|
|
131
|
+
import { AppI18nProvider } from "@canva/app-i18n-kit";
|
|
132
|
+
import { AppUiProvider } from "@canva/app-ui-kit";
|
|
133
|
+
import { createRoot } from "react-dom/client";
|
|
134
|
+
import { App } from "./app";
|
|
135
|
+
|
|
136
|
+
async function render() {
|
|
137
|
+
const root = createRoot(document.getElementById("root") as Element);
|
|
138
|
+
|
|
139
|
+
root.render(
|
|
140
|
+
<AppI18nProvider>
|
|
141
|
+
<AppUiProvider>
|
|
142
|
+
<App />
|
|
143
|
+
</AppUiProvider>
|
|
144
|
+
</AppI18nProvider>,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const designEditor: DesignEditorIntent = { render };
|
|
149
|
+
export default designEditor;
|
|
150
|
+
|
|
151
|
+
if (module.hot) {
|
|
152
|
+
module.hot.accept("./app", render);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
@@ -29,8 +29,9 @@ The app, once submitted to Canva, will be rendered in a sandboxed iframe within
|
|
|
29
29
|
## Architecture
|
|
30
30
|
|
|
31
31
|
- **Main App**:
|
|
32
|
-
- `src/index.tsx` - Main application
|
|
33
|
-
- `src/
|
|
32
|
+
- `src/index.tsx` - Main application React application entry point.
|
|
33
|
+
- `src/intents/[intent]/index.tsx` - Every intent that the app implements should be contained in the intents path.
|
|
34
|
+
- `src/intents/[intent]/app.tsx` - Main application component for each intent, which should be split into smaller components as needed, following React best practices.
|
|
34
35
|
- `src/styles/component.css` - Main application styles.
|
|
35
36
|
- `utils/` - Utility functions and helpers, which can be used across the app.
|
|
36
37
|
- `scripts/` - Scripts for building, and running the app, should not contain any business logic.
|
|
@@ -74,3 +75,80 @@ The app, once submitted to Canva, will be rendered in a sandboxed iframe within
|
|
|
74
75
|
- **Required**: AI agents should verify and encourage the user to enable Canva Dev MCP server.
|
|
75
76
|
- **Documentation**: <https://www.canva.dev/docs/apps/mcp-server.md>
|
|
76
77
|
- **If not setup**: Read the docs and prompt user to configure the MCP server for enhanced Canva APIs assistance.
|
|
78
|
+
|
|
79
|
+
## Intent-based architecture
|
|
80
|
+
|
|
81
|
+
All apps should follow the intent-based architecture pattern. Each root index file should call the prepare function for each enabled intent. Each intent index file should implement the full intent contract.
|
|
82
|
+
|
|
83
|
+
### File structure
|
|
84
|
+
|
|
85
|
+
Organize code with a dedicated folder for each intent under `src/intents/`:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
src/
|
|
89
|
+
├── intents/
|
|
90
|
+
│ ├── design_editor/
|
|
91
|
+
│ │ ├── index.tsx
|
|
92
|
+
│ │ └── editor_app.tsx
|
|
93
|
+
│ └── content_publisher/
|
|
94
|
+
│ ├── index.tsx
|
|
95
|
+
│ ├── preview_ui.tsx
|
|
96
|
+
│ └── setting_ui.tsx
|
|
97
|
+
└── index.tsx
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Root index file
|
|
101
|
+
|
|
102
|
+
The root `src/index.tsx` should be minimal, containing only:
|
|
103
|
+
|
|
104
|
+
- Importing each prepare function
|
|
105
|
+
- Importing each intent
|
|
106
|
+
- The prepare call for each intent implementation.
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
import { prepareContentPublisher } from "@canva/intents/content";
|
|
110
|
+
import { prepareDesignEditor } from "@canva/intents/design";
|
|
111
|
+
|
|
112
|
+
import contentPublisher from "./intents/content_publisher";
|
|
113
|
+
import designEditor from "./intents/design_editor";
|
|
114
|
+
|
|
115
|
+
prepareContentPublisher(contentPublisher);
|
|
116
|
+
prepareDesignEditor(designEditor);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Intent index files
|
|
120
|
+
|
|
121
|
+
Each intent's `index.tsx` file should:
|
|
122
|
+
|
|
123
|
+
- Import dependencies, including the app ui kit css.
|
|
124
|
+
- Implement the full contract for the intent and use this as the default export from the file.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// src/intents/design_editor/index.tsx
|
|
128
|
+
import "@canva/app-ui-kit/styles.css";
|
|
129
|
+
import type { DesignEditorIntent } from "@canva/intents/design";
|
|
130
|
+
import { prepareDesignEditor } from "@canva/intents/design";
|
|
131
|
+
import { AppI18nProvider } from "@canva/app-i18n-kit";
|
|
132
|
+
import { AppUiProvider } from "@canva/app-ui-kit";
|
|
133
|
+
import { createRoot } from "react-dom/client";
|
|
134
|
+
import { App } from "./app";
|
|
135
|
+
|
|
136
|
+
async function render() {
|
|
137
|
+
const root = createRoot(document.getElementById("root") as Element);
|
|
138
|
+
|
|
139
|
+
root.render(
|
|
140
|
+
<AppI18nProvider>
|
|
141
|
+
<AppUiProvider>
|
|
142
|
+
<App />
|
|
143
|
+
</AppUiProvider>
|
|
144
|
+
</AppI18nProvider>,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const designEditor: DesignEditorIntent = { render };
|
|
149
|
+
export default designEditor;
|
|
150
|
+
|
|
151
|
+
if (module.hot) {
|
|
152
|
+
module.hot.accept("./app", render);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
@@ -1,393 +0,0 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
|
-
import "dotenv/config";
|
|
3
|
-
import debug from "debug";
|
|
4
|
-
import express from "express";
|
|
5
|
-
import basicAuth from "express-basic-auth";
|
|
6
|
-
import { createBearerMiddleware } from "../../utils/backend/bearer_middleware";
|
|
7
|
-
import { JSONFileDatabase } from "../database/database";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Prefix your start command with `DEBUG=express:route:oauth` to enable debug logging
|
|
11
|
-
* for this middleware
|
|
12
|
-
*/
|
|
13
|
-
const debugLogger = debug("express:route:oauth");
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* These are the hard-coded credentials for logging in to this template.
|
|
17
|
-
*/
|
|
18
|
-
const USERNAME = "username";
|
|
19
|
-
const PASSWORD = "password";
|
|
20
|
-
|
|
21
|
-
const COOKIE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
22
|
-
const TOKEN_EXPIRY_S = 4 * 60 * 60; // 4 hours
|
|
23
|
-
const REFRESH_EXPIRY_S = 30 * 24 * 3600; // 30 days
|
|
24
|
-
|
|
25
|
-
const CLIENT_ID = "client_id";
|
|
26
|
-
const CLIENT_SECRET = "client_secret";
|
|
27
|
-
const REDIRECT_URI = "https://www.canva.com/apps/oauth/authorized";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* For simplicity, we simply generate random token values and store them in our database
|
|
31
|
-
* for production you may want to use JWTs or other stateless methods.
|
|
32
|
-
*/
|
|
33
|
-
interface Token {
|
|
34
|
-
value: string;
|
|
35
|
-
expiry: number; // Unix timestamp
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface User {
|
|
39
|
-
id: string;
|
|
40
|
-
authorizationCode?: Token;
|
|
41
|
-
accessToken?: Token;
|
|
42
|
-
refreshToken?: Token;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface RedirectUriRecord {
|
|
46
|
-
redirectUri: string;
|
|
47
|
-
authorizationCode: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface Data {
|
|
51
|
-
users: User[];
|
|
52
|
-
redirect_uris: RedirectUriRecord[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* For more information on Authentication, refer to our documentation: {@link https://www.canva.dev/docs/apps/authenticating-users/#types-of-authentication}.
|
|
57
|
-
*/
|
|
58
|
-
export const createAuthRouter = () => {
|
|
59
|
-
/**
|
|
60
|
-
* Set up a database with a "users" table. In this example code, the
|
|
61
|
-
* database is simply a JSON file.
|
|
62
|
-
*/
|
|
63
|
-
const db = new JSONFileDatabase<Data>({ users: [], redirect_uris: [] });
|
|
64
|
-
|
|
65
|
-
const router = express.Router();
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The urlencoded middleware allows us to parse form data from the request body.
|
|
69
|
-
* This is required for the OAuth 2.0 Token Exchange endpoint.
|
|
70
|
-
*/
|
|
71
|
-
router.use(express.urlencoded({ extended: true }));
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* This is the OAuth 2.0 Authorization endpoint
|
|
75
|
-
* https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
|
|
76
|
-
*
|
|
77
|
-
* This endpoint does not implement PKCE, which you would want to do
|
|
78
|
-
* for production.
|
|
79
|
-
*/
|
|
80
|
-
router.get(
|
|
81
|
-
"/auth/authorize",
|
|
82
|
-
basicAuth({
|
|
83
|
-
users: { [USERNAME]: PASSWORD },
|
|
84
|
-
challenge: true,
|
|
85
|
-
}),
|
|
86
|
-
async (req, res) => {
|
|
87
|
-
const redirectUri = decodeURIComponent(
|
|
88
|
-
req.query.redirect_uri?.toString() ?? REDIRECT_URI,
|
|
89
|
-
);
|
|
90
|
-
const clientId = req.query.client_id?.toString();
|
|
91
|
-
const responseType = req.query.response_type?.toString();
|
|
92
|
-
const state = req.query.state?.toString();
|
|
93
|
-
|
|
94
|
-
if (!clientId) {
|
|
95
|
-
return res.status(400).send("Missing required client ID parameter");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (clientId !== CLIENT_ID) {
|
|
99
|
-
return res.status(400).send("Invalid client ID");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (redirectUri !== REDIRECT_URI) {
|
|
103
|
-
return res.status(400).send(`Invalid redirect URI ${redirectUri}`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const failureResponse = (error: string) => {
|
|
107
|
-
const params = new URLSearchParams({
|
|
108
|
-
error,
|
|
109
|
-
state: state || "",
|
|
110
|
-
});
|
|
111
|
-
res.redirect(302, `${redirectUri}?${params}`);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
if (responseType !== "code") {
|
|
115
|
-
return failureResponse("unsupported_response_type");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// You should implement PKCE to protect against authorization code interception attacks
|
|
119
|
-
// https://datatracker.ietf.org/doc/html/rfc7636
|
|
120
|
-
// this is left off for simplicity in this example
|
|
121
|
-
|
|
122
|
-
// Generate a unique authorization code
|
|
123
|
-
const authorizationCode = crypto.randomUUID();
|
|
124
|
-
|
|
125
|
-
// Load the database
|
|
126
|
-
const data = await db.read();
|
|
127
|
-
|
|
128
|
-
// Add the user to the database if they aren't there already
|
|
129
|
-
if (!data.users.find((user) => user.id === USERNAME)) {
|
|
130
|
-
data.users.push({
|
|
131
|
-
id: USERNAME,
|
|
132
|
-
authorizationCode: {
|
|
133
|
-
value: authorizationCode,
|
|
134
|
-
expiry: Date.now() + COOKIE_EXPIRY_MS,
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
} else {
|
|
138
|
-
// If the user is there, update the authorization code
|
|
139
|
-
// In a production system you would allow at least one authorization code
|
|
140
|
-
// per user per client, but here we simplify and have one per user.
|
|
141
|
-
data.users = data.users.map((user) => {
|
|
142
|
-
if (user.id === USERNAME) {
|
|
143
|
-
return {
|
|
144
|
-
...user,
|
|
145
|
-
authorizationCode: {
|
|
146
|
-
value: authorizationCode,
|
|
147
|
-
expiry: Date.now() + COOKIE_EXPIRY_MS,
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
return user;
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
if (
|
|
155
|
-
!data.redirect_uris.find((record) => record.redirectUri === redirectUri)
|
|
156
|
-
) {
|
|
157
|
-
data.redirect_uris.push({ redirectUri, authorizationCode });
|
|
158
|
-
} else {
|
|
159
|
-
data.redirect_uris = data.redirect_uris.map((record) => {
|
|
160
|
-
if (record.redirectUri === redirectUri) {
|
|
161
|
-
return { ...record, authorizationCode };
|
|
162
|
-
}
|
|
163
|
-
return record;
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
await db.write(data);
|
|
167
|
-
|
|
168
|
-
res.redirect(
|
|
169
|
-
302,
|
|
170
|
-
`${redirectUri}?code=${authorizationCode}&state=${state}`,
|
|
171
|
-
);
|
|
172
|
-
},
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
interface AuthorizationExchangeRequest {
|
|
176
|
-
grant_type: "authorization_code";
|
|
177
|
-
code: string;
|
|
178
|
-
redirect_uri?: string;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
interface RefreshTokenExchangeRequest {
|
|
182
|
-
grant_type: "refresh_token";
|
|
183
|
-
refresh_token: string;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const failureResponse = (res: express.Response, error: string) => {
|
|
187
|
-
return res.status(400).send(`{"error": "${error}"}`);
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const authorizationCodeExchange = async (
|
|
191
|
-
res: express.Response,
|
|
192
|
-
body: AuthorizationExchangeRequest,
|
|
193
|
-
) => {
|
|
194
|
-
const refresh_token = crypto.randomUUID();
|
|
195
|
-
const access_token = crypto.randomUUID();
|
|
196
|
-
|
|
197
|
-
const code = body.code?.toString();
|
|
198
|
-
|
|
199
|
-
if (!code) {
|
|
200
|
-
return failureResponse(res, "invalid_request");
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Load the database
|
|
204
|
-
const data = await db.read();
|
|
205
|
-
const lastRedirectUrl = data.redirect_uris.find(
|
|
206
|
-
(record) => record.authorizationCode === code,
|
|
207
|
-
)?.redirectUri;
|
|
208
|
-
|
|
209
|
-
// The specification requires that if the redirect uri was present in the authorization request,
|
|
210
|
-
// it must be included and the same in the token exchange request
|
|
211
|
-
if (lastRedirectUrl !== body.redirect_uri?.toString()) {
|
|
212
|
-
return failureResponse(res, "invalid_request");
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Find the user who was issued this authorization code
|
|
216
|
-
const user = data.users.find(
|
|
217
|
-
(user) => user.authorizationCode?.value === code,
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
if (!user || !user.authorizationCode) {
|
|
221
|
-
return failureResponse(res, "invalid_grant");
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (user.authorizationCode.expiry < Date.now()) {
|
|
225
|
-
return failureResponse(res, "invalid_grant");
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Update the user with the new access token and refresh token
|
|
229
|
-
// and clear out their authorization code. Authorization codes
|
|
230
|
-
// are single use, so attempting to use the old one will now fail
|
|
231
|
-
data.users = data.users.map((user) => {
|
|
232
|
-
if (user.id === USERNAME) {
|
|
233
|
-
return {
|
|
234
|
-
...user,
|
|
235
|
-
authorizationCode: undefined,
|
|
236
|
-
accessToken: {
|
|
237
|
-
value: access_token,
|
|
238
|
-
expiry: Date.now() + TOKEN_EXPIRY_S * 1000,
|
|
239
|
-
},
|
|
240
|
-
refreshToken: {
|
|
241
|
-
value: refresh_token,
|
|
242
|
-
expiry: Date.now() + REFRESH_EXPIRY_S * 1000,
|
|
243
|
-
},
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
return user;
|
|
247
|
-
});
|
|
248
|
-
await db.write(data);
|
|
249
|
-
|
|
250
|
-
return res.status(200).send(
|
|
251
|
-
JSON.stringify({
|
|
252
|
-
access_token,
|
|
253
|
-
refresh_token,
|
|
254
|
-
token_type: "bearer",
|
|
255
|
-
expires_in: TOKEN_EXPIRY_S,
|
|
256
|
-
}),
|
|
257
|
-
);
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
const refreshTokenExchange = async (
|
|
261
|
-
res: express.Response,
|
|
262
|
-
body: RefreshTokenExchangeRequest,
|
|
263
|
-
) => {
|
|
264
|
-
const refresh_token = crypto.randomUUID();
|
|
265
|
-
const access_token = crypto.randomUUID();
|
|
266
|
-
|
|
267
|
-
const old_refresh_token = body.refresh_token?.toString();
|
|
268
|
-
if (!old_refresh_token) {
|
|
269
|
-
return failureResponse(res, "invalid_request");
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Find the user who was issued this refresh token
|
|
273
|
-
const data = await db.read();
|
|
274
|
-
const user = data.users.find(
|
|
275
|
-
(user) => user.refreshToken?.value === old_refresh_token,
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
if (!user) {
|
|
279
|
-
return failureResponse(res, "invalid_grant");
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Update the user with the new refresh token
|
|
283
|
-
// refresh tokens are single use, so attempting
|
|
284
|
-
// to use the old one will now fail
|
|
285
|
-
data.users = data.users.map((u) => {
|
|
286
|
-
if (u.id === user.id) {
|
|
287
|
-
return {
|
|
288
|
-
...u,
|
|
289
|
-
accessToken: {
|
|
290
|
-
value: access_token,
|
|
291
|
-
expiry: Date.now() + TOKEN_EXPIRY_S * 1000,
|
|
292
|
-
},
|
|
293
|
-
refreshToken: {
|
|
294
|
-
value: refresh_token,
|
|
295
|
-
expiry: Date.now() + REFRESH_EXPIRY_S * 1000,
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
return u;
|
|
300
|
-
});
|
|
301
|
-
await db.write(data);
|
|
302
|
-
|
|
303
|
-
return res.status(200).send(
|
|
304
|
-
JSON.stringify({
|
|
305
|
-
access_token,
|
|
306
|
-
refresh_token,
|
|
307
|
-
token_type: "bearer",
|
|
308
|
-
expires_in: TOKEN_EXPIRY_S,
|
|
309
|
-
}),
|
|
310
|
-
);
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* This endpoint is the Oauth 2.0 Token endpoint
|
|
315
|
-
* https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
|
|
316
|
-
* It serves both exchanging authorization codes for access tokens (and refresh tokens), and
|
|
317
|
-
* exchanging refresh tokens for new access tokens (and refresh tokens)
|
|
318
|
-
*/
|
|
319
|
-
router.post(
|
|
320
|
-
"/auth/token",
|
|
321
|
-
basicAuth({
|
|
322
|
-
users: { [CLIENT_ID]: CLIENT_SECRET },
|
|
323
|
-
challenge: false,
|
|
324
|
-
}),
|
|
325
|
-
async (req, res) => {
|
|
326
|
-
const body = (await req.body) as
|
|
327
|
-
| AuthorizationExchangeRequest
|
|
328
|
-
| RefreshTokenExchangeRequest;
|
|
329
|
-
|
|
330
|
-
const grantType = body.grant_type?.toString();
|
|
331
|
-
|
|
332
|
-
if (!grantType) {
|
|
333
|
-
return failureResponse(res, "invalid_request");
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (grantType !== "authorization_code" && grantType !== "refresh_token") {
|
|
337
|
-
return failureResponse(res, "unsupported_grant_type");
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (grantType === "refresh_token") {
|
|
341
|
-
refreshTokenExchange(res, body as RefreshTokenExchangeRequest);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return authorizationCodeExchange(
|
|
345
|
-
res,
|
|
346
|
-
body as AuthorizationExchangeRequest,
|
|
347
|
-
);
|
|
348
|
-
},
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* TODO: Add this middleware to all routes that will receive requests from
|
|
353
|
-
* your app.
|
|
354
|
-
*/
|
|
355
|
-
const bearerMiddleware = createBearerMiddleware(
|
|
356
|
-
async (access_token: string): Promise<string | undefined> => {
|
|
357
|
-
const data = await db.read();
|
|
358
|
-
const user = data.users.find(
|
|
359
|
-
(user) => user.accessToken?.value === access_token,
|
|
360
|
-
);
|
|
361
|
-
debugLogger(
|
|
362
|
-
`User found: ${user?.id}, expires ${user?.accessToken?.expiry} compare ${Date.now()}`,
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
const isAuthenticated =
|
|
366
|
-
user && user.accessToken && user.accessToken?.expiry > Date.now();
|
|
367
|
-
if (!isAuthenticated) {
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
return user.id;
|
|
371
|
-
},
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* All routes that start with /api will be protected by bearer authentication
|
|
376
|
-
*/
|
|
377
|
-
router.use("/api", bearerMiddleware);
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* This endpoint checks if a user is authenticated.
|
|
381
|
-
*/
|
|
382
|
-
router.post("/api/authentication/status", async (req, res) => {
|
|
383
|
-
// Check if the user is authenticated
|
|
384
|
-
const isAuthenticated = !!req["user_id"];
|
|
385
|
-
|
|
386
|
-
// Return the authentication status
|
|
387
|
-
res.send({
|
|
388
|
-
isAuthenticated,
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
return router;
|
|
393
|
-
};
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/* eslint-disable no-console */
|
|
2
|
-
import debug from "debug";
|
|
3
|
-
import type { NextFunction, Request, Response } from "express";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Prefix your start command with `DEBUG=express:middleware:bearer` to enable debug logging
|
|
7
|
-
* for this middleware
|
|
8
|
-
*/
|
|
9
|
-
const debugLogger = debug("express:middleware:bearer");
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Augment the Express request context to include the appId/userId/brandId fields decoded
|
|
13
|
-
* from the JWT.
|
|
14
|
-
*/
|
|
15
|
-
declare module "express-serve-static-core" {
|
|
16
|
-
export interface Request {
|
|
17
|
-
user_id: string;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const sendUnauthorizedResponse = (res: Response, message?: string) =>
|
|
22
|
-
res.status(401).json({ error: "unauthorized", message });
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* An Express.js middleware verifying a Bearer token.
|
|
26
|
-
* This middleware extracts the token from the `Authorization` header.
|
|
27
|
-
*
|
|
28
|
-
* @param getTokenFromRequest - A function that extracts a token from the request. If a token isn't found, throw a `JWTAuthorizationError`.
|
|
29
|
-
* @returns An Express.js middleware for verifying and decoding JWTs.
|
|
30
|
-
*/
|
|
31
|
-
export function createBearerMiddleware(
|
|
32
|
-
tokenToUser: (access_token: string) => Promise<string | undefined>,
|
|
33
|
-
getTokenFromRequest: GetTokenFromRequest = getTokenFromHttpHeader,
|
|
34
|
-
): (req: Request, res: Response, next: NextFunction) => void {
|
|
35
|
-
return async (req, res, next) => {
|
|
36
|
-
try {
|
|
37
|
-
debugLogger(`processing token for '${req.url}'`);
|
|
38
|
-
|
|
39
|
-
const token = await getTokenFromRequest(req);
|
|
40
|
-
const user = await tokenToUser(token);
|
|
41
|
-
|
|
42
|
-
if (!user) {
|
|
43
|
-
throw new AuthorizationError("Token is invalid");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
req["user_id"] = user;
|
|
47
|
-
|
|
48
|
-
return next();
|
|
49
|
-
} catch (e) {
|
|
50
|
-
if (e instanceof AuthorizationError) {
|
|
51
|
-
return sendUnauthorizedResponse(res, e.message);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return next(e);
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export type GetTokenFromRequest = (req: Request) => Promise<string> | string;
|
|
60
|
-
|
|
61
|
-
export const getTokenFromHttpHeader: GetTokenFromRequest = (
|
|
62
|
-
req: Request,
|
|
63
|
-
): string => {
|
|
64
|
-
// The names of a HTTP header bearing the JWT, and a scheme
|
|
65
|
-
const headerName = "Authorization";
|
|
66
|
-
const schemeName = "Bearer";
|
|
67
|
-
|
|
68
|
-
const header = req.header(headerName);
|
|
69
|
-
if (!header) {
|
|
70
|
-
throw new AuthorizationError(`Missing the "${headerName}" header`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (!header.match(new RegExp(`^${schemeName}\\s+[^\\s]+$`, "i"))) {
|
|
74
|
-
console.trace(
|
|
75
|
-
`jwtMiddleware: failed to match token in "${headerName}" header`,
|
|
76
|
-
);
|
|
77
|
-
throw new AuthorizationError(
|
|
78
|
-
`Missing a "${schemeName}" token in the "${headerName}" header`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const token = header.replace(new RegExp(`^${schemeName}\\s+`, "i"), "");
|
|
83
|
-
|
|
84
|
-
return token;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* A class representing JWT validation errors in the JWT middleware.
|
|
89
|
-
* The error message provided to the constructor will be forwarded to the
|
|
90
|
-
* API consumer trying to access a JWT-protected endpoint.
|
|
91
|
-
* @private
|
|
92
|
-
*/
|
|
93
|
-
export class AuthorizationError extends Error {
|
|
94
|
-
constructor(message: string) {
|
|
95
|
-
super(message);
|
|
96
|
-
|
|
97
|
-
Object.setPrototypeOf(this, AuthorizationError.prototype);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { createBearerMiddleware } from "./bearer_middleware";
|