@canva/cli 0.0.1-beta.1 → 0.0.1-beta.3
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/cli.js +134 -134
- package/package.json +1 -1
- package/templates/base/backend/routers/oauth.ts +393 -0
- package/templates/base/eslint.config.mjs +5 -275
- package/templates/base/package.json +1 -1
- package/templates/base/utils/backend/bearer_middleware/bearer_middleware.ts +101 -0
- package/templates/base/utils/backend/bearer_middleware/index.ts +1 -0
- package/templates/base/utils/backend/bearer_middleware/tests/bearer_middleware.tests.ts +192 -0
- package/templates/base/utils/use_add_element.ts +48 -0
- package/templates/base/utils/use_feature_support.ts +28 -0
- package/templates/common/README.md +0 -67
- package/templates/common/conf/eslint-general.mjs +277 -0
- package/templates/common/conf/eslint-i18n.mjs +23 -0
- package/templates/dam/backend/server.ts +0 -7
- package/templates/dam/eslint.config.mjs +6 -275
- package/templates/dam/package.json +8 -7
- package/templates/dam/src/app.tsx +2 -135
- package/templates/gen_ai/README.md +40 -1
- package/templates/gen_ai/backend/routers/oauth.ts +393 -0
- package/templates/gen_ai/backend/server.ts +1 -1
- package/templates/gen_ai/eslint.config.mjs +5 -275
- package/templates/gen_ai/package.json +7 -6
- package/templates/gen_ai/src/api/api.ts +44 -27
- package/templates/gen_ai/src/components/footer.tsx +9 -5
- package/templates/gen_ai/src/components/image_grid.tsx +9 -7
- package/templates/gen_ai/src/components/loading_results.tsx +8 -4
- package/templates/gen_ai/src/components/prompt_input.tsx +2 -0
- package/templates/gen_ai/src/context/app_context.tsx +8 -2
- package/templates/gen_ai/src/services/auth.tsx +5 -10
- package/templates/gen_ai/utils/backend/bearer_middleware/bearer_middleware.ts +101 -0
- package/templates/gen_ai/utils/backend/bearer_middleware/index.ts +1 -0
- package/templates/gen_ai/utils/backend/bearer_middleware/tests/bearer_middleware.tests.ts +192 -0
- package/templates/hello_world/eslint.config.mjs +5 -275
- package/templates/hello_world/package.json +7 -5
- package/templates/hello_world/src/app.tsx +5 -3
- package/templates/hello_world/utils/use_add_element.ts +48 -0
- package/templates/hello_world/utils/use_feature_support.ts +28 -0
- package/templates/dam/backend/database/database.ts +0 -42
- package/templates/dam/backend/routers/auth.ts +0 -285
- package/templates/gen_ai/backend/routers/auth.ts +0 -285
- package/templates/gen_ai/utils/backend/jwt_middleware/index.ts +0 -1
- package/templates/gen_ai/utils/backend/jwt_middleware/jwt_middleware.ts +0 -229
- package/templates/gen_ai/utils/backend/jwt_middleware/tests/jwt_middleware.tests.ts +0 -630
|
@@ -1,147 +1,14 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
1
|
import { SearchableListView } from "@canva/app-components";
|
|
3
|
-
import {
|
|
4
|
-
import type { Authentication } from "@canva/user";
|
|
5
|
-
import { auth } from "@canva/user";
|
|
2
|
+
import { Box } from "@canva/app-ui-kit";
|
|
6
3
|
import { findResources } from "./adapter";
|
|
7
4
|
import { config } from "./config";
|
|
8
5
|
import * as styles from "./index.css";
|
|
9
6
|
import "@canva/app-ui-kit/styles.css";
|
|
10
7
|
|
|
11
|
-
type AuthenticationState =
|
|
12
|
-
| "authenticated"
|
|
13
|
-
| "not_authenticated"
|
|
14
|
-
| "checking"
|
|
15
|
-
| "cancelled"
|
|
16
|
-
| "error";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* This endpoint is defined in the ./backend/server.ts file. You need to
|
|
20
|
-
* register the endpoint in the Developer Portal before sending requests.
|
|
21
|
-
*
|
|
22
|
-
* BACKEND_HOST is configured in the root .env file, for more information,
|
|
23
|
-
* refer to the README.md.
|
|
24
|
-
*/
|
|
25
|
-
const AUTHENTICATION_CHECK_URL = `${BACKEND_HOST}/api/authentication/status`;
|
|
26
|
-
|
|
27
|
-
const checkAuthenticationStatus = async (
|
|
28
|
-
auth: Authentication,
|
|
29
|
-
): Promise<AuthenticationState> => {
|
|
30
|
-
/**
|
|
31
|
-
* Send a request to an endpoint that checks if the user is authenticated.
|
|
32
|
-
* This is example code, intended to convey the basic idea. When implementing this in your app, you might want more advanced checks.
|
|
33
|
-
*
|
|
34
|
-
* Note: You must register the provided endpoint via the Developer Portal.
|
|
35
|
-
*/
|
|
36
|
-
try {
|
|
37
|
-
const token = await auth.getCanvaUserToken();
|
|
38
|
-
const res = await fetch(AUTHENTICATION_CHECK_URL, {
|
|
39
|
-
headers: {
|
|
40
|
-
Authorization: `Bearer ${token}`,
|
|
41
|
-
},
|
|
42
|
-
method: "POST",
|
|
43
|
-
});
|
|
44
|
-
const body = await res.json();
|
|
45
|
-
|
|
46
|
-
if (body?.isAuthenticated) {
|
|
47
|
-
return "authenticated";
|
|
48
|
-
} else {
|
|
49
|
-
return "not_authenticated";
|
|
50
|
-
}
|
|
51
|
-
} catch (error) {
|
|
52
|
-
// eslint-disable-next-line no-console
|
|
53
|
-
console.error(error);
|
|
54
|
-
return "error";
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
|
|
58
8
|
export function App() {
|
|
59
|
-
|
|
60
|
-
const [authState, setAuthState] = useState<AuthenticationState>("checking");
|
|
61
|
-
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
setAuthState("checking");
|
|
64
|
-
checkAuthenticationStatus(auth).then((status) => {
|
|
65
|
-
setAuthState(status);
|
|
66
|
-
});
|
|
67
|
-
}, []);
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
if (authState === "not_authenticated") {
|
|
71
|
-
startAuthenticationFlow();
|
|
72
|
-
}
|
|
73
|
-
}, [authState]);
|
|
74
|
-
|
|
75
|
-
const startAuthenticationFlow = async () => {
|
|
76
|
-
try {
|
|
77
|
-
const response = await auth.requestAuthentication();
|
|
78
|
-
switch (response.status) {
|
|
79
|
-
case "COMPLETED":
|
|
80
|
-
setAuthState("authenticated");
|
|
81
|
-
break;
|
|
82
|
-
case "ABORTED":
|
|
83
|
-
// eslint-disable-next-line no-console
|
|
84
|
-
console.warn("Authentication aborted by user.");
|
|
85
|
-
setAuthState("cancelled");
|
|
86
|
-
break;
|
|
87
|
-
case "DENIED":
|
|
88
|
-
// eslint-disable-next-line no-console
|
|
89
|
-
console.warn("Authentication denied by user", response.details);
|
|
90
|
-
setAuthState("cancelled");
|
|
91
|
-
break;
|
|
92
|
-
default:
|
|
93
|
-
// eslint-disable-next-line no-console
|
|
94
|
-
console.log("Unknown auth state");
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
} catch (e) {
|
|
98
|
-
// eslint-disable-next-line no-console
|
|
99
|
-
console.error(e);
|
|
100
|
-
setAuthState("error");
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
if (authState === "error") {
|
|
105
|
-
// eslint-disable-next-line no-console
|
|
106
|
-
console.warn(
|
|
107
|
-
"Warning: authentication not enabled on this app. Please enable auth with the instructions in README",
|
|
108
|
-
);
|
|
109
|
-
// Comment this next line out for production apps
|
|
110
|
-
setAuthState("authenticated");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// If user has denied or aborted auth flow
|
|
114
|
-
if (authState === "cancelled") {
|
|
115
|
-
return (
|
|
116
|
-
<Box paddingEnd="2u" height="full" className={styles.centerInPage}>
|
|
117
|
-
<Rows spacing="2u" align="center">
|
|
118
|
-
<Alert tone="critical">
|
|
119
|
-
Something went wrong while authenticating
|
|
120
|
-
</Alert>
|
|
121
|
-
<Button
|
|
122
|
-
variant="primary"
|
|
123
|
-
onClick={startAuthenticationFlow}
|
|
124
|
-
stretch={true}
|
|
125
|
-
>
|
|
126
|
-
Start authentication flow
|
|
127
|
-
</Button>
|
|
128
|
-
</Rows>
|
|
129
|
-
</Box>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return authState === "authenticated" ? (
|
|
9
|
+
return (
|
|
134
10
|
<Box className={styles.rootWrapper}>
|
|
135
11
|
<SearchableListView config={config} findResources={findResources} />
|
|
136
12
|
</Box>
|
|
137
|
-
) : (
|
|
138
|
-
<Box
|
|
139
|
-
width="full"
|
|
140
|
-
height="full"
|
|
141
|
-
paddingTop="1u"
|
|
142
|
-
className={styles.centerInPage}
|
|
143
|
-
>
|
|
144
|
-
<LoadingIndicator size="large" />
|
|
145
|
-
</Box>
|
|
146
13
|
);
|
|
147
14
|
}
|
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
## Run the development server with ngrok to use authentication with the app
|
|
2
|
+
|
|
3
|
+
These steps demonstrate how to start the local development server with ngrok and https.
|
|
4
|
+
|
|
5
|
+
From your app's root directory
|
|
6
|
+
|
|
7
|
+
1. Stop any running scripts, and run the following command to launch the backend and frontend development servers. The `--ngrok` parameter exposes the backend server via a publicly accessible URL.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm start --ngrok
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
2. After ngrok is running, copy your ngrok url
|
|
14
|
+
(e.g. `https://0000-0000.ngrok-free.app`) to the clipboard.
|
|
15
|
+
|
|
16
|
+
1. Go to your app in the [Developer Portal](https://www.canva.com/developers/apps).
|
|
17
|
+
2. Navigate to the "Authentication" section of your app.
|
|
18
|
+
3. Click the "Add provider" button under the "OAuth 2.0" tab
|
|
19
|
+
4. Configure the provider as follows:
|
|
20
|
+
- Provider: local (or name of your choice)
|
|
21
|
+
- Client ID: `client_id` (exactly this string, in a real app you'd have some opaque identifier)
|
|
22
|
+
- Client Secret: `client_secret` (same here)
|
|
23
|
+
- Authorization server URL: Your ngrok url followed by `/auth/authorize` e.g. `https://0000-0000.ngrok-free.app/auth/authorize`
|
|
24
|
+
- Token exchange URL: Your ngrok url followed by `/auth/token` e.g. `https://0000-0000.ngrok-free.app/auth/token`
|
|
25
|
+
- Revocation exchange URL: (leave blank)
|
|
26
|
+
- Proof of Key for Code Exchange (PKCE): off
|
|
27
|
+
Note: Your ngrok URL changes each time you restart ngrok. Keep these fields up to
|
|
28
|
+
date to ensure your example authentication step will run.
|
|
29
|
+
|
|
30
|
+
3. Navigate to your app at `https://www.canva.com/developers/apps` and make sure that under Configuration, App source is pointing to your localhost url.
|
|
31
|
+
|
|
32
|
+
4. Navigate to your app at `https://www.canva.com/developers/apps`, and click **Preview** to preview the app.
|
|
33
|
+
1. A new screen will appear asking if you want to authenticate.
|
|
34
|
+
Press **Connect** to start the authentication flow.
|
|
35
|
+
2. A ngrok screen may appear. If it does, select **Visit Site**
|
|
36
|
+
3. An authentication popup will appear. For the username, enter `username`, and
|
|
37
|
+
for the password enter `password`.
|
|
38
|
+
4. If successful, you will be redirected back to your app.
|
|
39
|
+
|
|
1
40
|
## Generative AI Template
|
|
2
41
|
|
|
3
42
|
This template captures best practices for improving user experience in your application.
|
|
@@ -20,7 +59,7 @@ In this template, we've included a basic obscenity filter to stop users from cre
|
|
|
20
59
|
|
|
21
60
|
### Backend
|
|
22
61
|
|
|
23
|
-
This template includes a simple Express server as a sample backend. Please note that this server is not production-ready, and we advise using it solely for instructional purposes to demonstrate API calls.
|
|
62
|
+
This template includes a simple Express server as a sample backend. Please note that this server is not production-ready, and we advise using it solely for instructional purposes to demonstrate API calls. This backend implements an [OAuth 2](https://datatracker.ietf.org/doc/html/rfc6749) Authorization Server to authorize and authenticate users. To use this, you will need to set up authentication above.
|
|
24
63
|
|
|
25
64
|
### Thumbnails
|
|
26
65
|
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import * as express from "express";
|
|
4
|
+
import * as basicAuth from "express-basic-auth";
|
|
5
|
+
import { JSONFileDatabase } from "../database/database";
|
|
6
|
+
import { createBearerMiddleware } from "../../utils/backend/bearer_middleware";
|
|
7
|
+
import * as debug from "debug";
|
|
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
|
+
};
|
|
@@ -2,7 +2,7 @@ import * as express from "express";
|
|
|
2
2
|
import * as cors from "cors";
|
|
3
3
|
import { createBaseServer } from "../utils/backend/base_backend/create";
|
|
4
4
|
import { createImageRouter } from "./routers/image";
|
|
5
|
-
import { createAuthRouter } from "./routers/
|
|
5
|
+
import { createAuthRouter } from "./routers/oauth";
|
|
6
6
|
|
|
7
7
|
async function main() {
|
|
8
8
|
const router = express.Router();
|