diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..c2cdfb8
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,21 @@
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+
+root = true
+
+
+[*]
+
+# Change these settings to your own preference
+indent_style = space
+indent_size = 2
+
+# We recommend you to keep these unchanged
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..c536a34
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,61 @@
+#
+
+name: E2E
+
+# Automatically cancel in-progress actions on the same branch
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+on:
+ push:
+ workflow_dispatch:
+
+jobs:
+ e2e:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+
+ - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 #v4.0.4
+ with:
+ node-version: "node"
+
+ - name: 🌭 Install bun
+ uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2
+
+ - run: bun install --frozen-lockfile
+
+ - name: cypress-io/github-action needs package-lock.json
+ run: |
+ touch package-lock.json
+
+ - name: Cypress run
+ uses: cypress-io/github-action@0da3c06ed8217b912deea9d8ee69630baed1737e # v6.7.6
+ with:
+ build: bun run build
+ install-command: bun install --frozen-lockfile
+ spec: apps/${{ matrix.e2e_test }}/features
+ start: bun run start
+
+ - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
+ if: failure()
+ with:
+ compression-level: 9
+ name: cypress-${{ matrix.e2e_test }}-screenshots
+ path: e2e/cypress/screenshots
+ spec: features/${{ matrix.e2e_test }}
+
+ - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
+ if: failure()
+ with:
+ compression-level: 9
+ name: cypress-${{ matrix.e2e_test }}-videos
+ path: e2e/cypress/videos
+
+ strategy:
+ matrix:
+ e2e_test:
+ - spa_pkce_proconnect
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8828c63
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+#
+
+dist
+node_modules
diff --git a/apps/spa_pkce_proconnect/README.md b/apps/spa_pkce_proconnect/README.md
new file mode 100644
index 0000000..3526371
--- /dev/null
+++ b/apps/spa_pkce_proconnect/README.md
@@ -0,0 +1,15 @@
+# spa_pkce_proconnect
+
+To install dependencies:
+
+```bash
+bun install
+```
+
+To run:
+
+```bash
+bun run index.ts
+```
+
+This project was created using `bun init` in bun v1.1.29. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
diff --git a/apps/spa_pkce_proconnect/features/main.feature b/apps/spa_pkce_proconnect/features/main.feature
new file mode 100644
index 0000000..2970213
--- /dev/null
+++ b/apps/spa_pkce_proconnect/features/main.feature
@@ -0,0 +1,10 @@
+#language: fr
+Fonctionnalité: Spa PKCE Test
+
+ Scénario: Connexion avec succès
+ Soit la page de démarrage
+ * je clique sur "SPA PKCE"
+ Quand je clique sur "Cliquez pour vous connecter"
+ * je remplis le formulaire de connexion
+ * je clique sur "Connexion"
+ Alors je vois le message "Connexion réussie"
diff --git a/apps/spa_pkce_proconnect/index.html b/apps/spa_pkce_proconnect/index.html
new file mode 100644
index 0000000..301d2a3
--- /dev/null
+++ b/apps/spa_pkce_proconnect/index.html
@@ -0,0 +1,72 @@
+
+
OAuth Authorization Code + PKCE in Vanilla JS
+
+
+
+
+
+
+
+
+
diff --git a/apps/spa_pkce_proconnect/package.json b/apps/spa_pkce_proconnect/package.json
new file mode 100644
index 0000000..b7fa9f9
--- /dev/null
+++ b/apps/spa_pkce_proconnect/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "spa_pkce_proconnect",
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^5.4.8"
+ }
+}
diff --git a/apps/spa_pkce_proconnect/src/main.ts b/apps/spa_pkce_proconnect/src/main.ts
new file mode 100644
index 0000000..a438ead
--- /dev/null
+++ b/apps/spa_pkce_proconnect/src/main.ts
@@ -0,0 +1,174 @@
+//////////////////////////////////////////////////////////////////////
+// OAUTH REQUEST
+
+// Initiate the PKCE Auth Code flow when the link is clicked
+document.getElementById("start")!.addEventListener("click", async function (e) {
+ e.preventDefault();
+
+ // Create and store a random "state" value
+ var state = generateRandomString();
+ localStorage.setItem("pkce_state", state);
+
+ // Create and store a new PKCE code_verifier (the plaintext random secret)
+ var code_verifier = generateRandomString();
+ localStorage.setItem("pkce_code_verifier", code_verifier);
+
+ // Hash and base64-urlencode the secret to use as the challenge
+ var code_challenge = await pkceChallengeFromVerifier(code_verifier);
+
+ // Build the authorization URL
+ var url =
+ config.authorization_endpoint +
+ "?response_type=code" +
+ "&client_id=" +
+ encodeURIComponent(config.client_id) +
+ "&state=" +
+ encodeURIComponent(state) +
+ "&scope=" +
+ encodeURIComponent(config.requested_scopes) +
+ "&redirect_uri=" +
+ encodeURIComponent(config.redirect_uri) +
+ "&code_challenge=" +
+ encodeURIComponent(code_challenge) +
+ "&code_challenge_method=S256";
+ // Redirect to the authorization server
+ window.location = url;
+});
+
+//////////////////////////////////////////////////////////////////////
+// OAUTH REDIRECT HANDLING
+
+// Handle the redirect back from the authorization server and
+// get an access token from the token endpoint
+
+var q = parseQueryString(window.location.search.substring(1));
+
+// Check if the server returned an error string
+if (q.error) {
+ alert("Error returned from authorization server: " + q.error);
+ document.getElementById("error_details").innerText =
+ q.error + "\n\n" + q.error_description;
+ document.getElementById("error").classList = "";
+}
+
+// If the server returned an authorization code, attempt to exchange it for an access token
+if (q.code) {
+ // Verify state matches what we set at the beginning
+ if (localStorage.getItem("pkce_state") != q.state) {
+ alert("Invalid state");
+ } else {
+ // Exchange the authorization code for an access token
+ sendPostRequest(
+ config.token_endpoint,
+ {
+ grant_type: "authorization_code",
+ code: q.code,
+ client_id: config.client_id,
+ redirect_uri: config.redirect_uri,
+ code_verifier: localStorage.getItem("pkce_code_verifier"),
+ },
+ function (request, body) {
+ // Initialize your application now that you have an access token.
+ // Here we just display it in the browser.
+ document.getElementById("access_token").innerText = body.access_token;
+ document.getElementById("start").classList = "hidden";
+ document.getElementById("token").classList = "";
+
+ // Replace the history entry to remove the auth code from the browser address bar
+ window.history.replaceState({}, null, "/");
+ },
+ function (request, error) {
+ // This could be an error response from the OAuth server, or an error because the
+ // request failed such as if the OAuth server doesn't allow CORS requests
+ document.getElementById("error_details").innerText =
+ error.error + "\n\n" + error.error_description;
+ document.getElementById("error").classList = "";
+ }
+ );
+ }
+
+ // Clean these up since we don't need them anymore
+ localStorage.removeItem("pkce_state");
+ localStorage.removeItem("pkce_code_verifier");
+}
+
+//////////////////////////////////////////////////////////////////////
+// GENERAL HELPER FUNCTIONS
+
+// Make a POST request and parse the response as JSON
+function sendPostRequest(url, params, success, error) {
+ var request = new XMLHttpRequest();
+ request.open("POST", url, true);
+ request.setRequestHeader(
+ "Content-Type",
+ "application/x-www-form-urlencoded; charset=UTF-8"
+ );
+ request.onload = function () {
+ var body = {};
+ try {
+ body = JSON.parse(request.response);
+ } catch (e) {}
+
+ if (request.status == 200) {
+ success(request, body);
+ } else {
+ error(request, body);
+ }
+ };
+ request.onerror = function () {
+ error(request, {});
+ };
+ var body = Object.keys(params)
+ .map((key) => key + "=" + params[key])
+ .join("&");
+ request.send(body);
+}
+
+// Parse a query string into an object
+function parseQueryString(string) {
+ if (string == "") {
+ return {};
+ }
+ var segments = string.split("&").map((s) => s.split("="));
+ var queryString = {};
+ segments.forEach((s) => (queryString[s[0]] = s[1]));
+ return queryString;
+}
+
+//////////////////////////////////////////////////////////////////////
+// PKCE HELPER FUNCTIONS
+
+// Generate a secure random string using the browser crypto functions
+function generateRandomString() {
+ var array = new Uint32Array(28);
+ window.crypto.getRandomValues(array);
+ return Array.from(array, (dec) => ("0" + dec.toString(16)).substr(-2)).join(
+ ""
+ );
+}
+
+// Calculate the SHA256 hash of the input text.
+// Returns a promise that resolves to an ArrayBuffer
+function sha256(plain) {
+ const encoder = new TextEncoder();
+ const data = encoder.encode(plain);
+ return window.crypto.subtle.digest("SHA-256", data);
+}
+
+// Base64-urlencodes the input string
+function base64urlencode(str) {
+ // Convert the ArrayBuffer to string using Uint8 array to conver to what btoa accepts.
+ // btoa accepts chars only within ascii 0-255 and base64 encodes them.
+ // Then convert the base64 encoded to base64url encoded
+ // (replace + with -, replace / with _, trim trailing =)
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+// Return the base64-urlencoded sha256 hash for the PKCE challenge
+async function pkceChallengeFromVerifier(v) {
+ hashed = await sha256(v);
+ return base64urlencode(hashed);
+}
diff --git a/apps/spa_pkce_proconnect/tsconfig.json b/apps/spa_pkce_proconnect/tsconfig.json
new file mode 100644
index 0000000..8d1211d
--- /dev/null
+++ b/apps/spa_pkce_proconnect/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "compilerOptions": {},
+ "extends": "@tsconfig/bun/tsconfig.json"
+}
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..6d30e5a
Binary files /dev/null and b/bun.lockb differ
diff --git a/cypress.config.ts b/cypress.config.ts
new file mode 100644
index 0000000..2be5de1
--- /dev/null
+++ b/cypress.config.ts
@@ -0,0 +1,42 @@
+//
+
+import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
+import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild";
+import createBundler from "@bahmutov/cypress-esbuild-preprocessor";
+import { defineConfig } from "cypress";
+import { fileURLToPath } from "node:url";
+
+//
+
+export default defineConfig({
+ e2e: {
+ baseUrl: "http://localhost:3000/",
+ reporter: fileURLToPath(
+ await import.meta.resolve(
+ "@badeball/cypress-cucumber-preprocessor/pretty-reporter",
+ ),
+ ),
+ setupNodeEvents,
+ specPattern: "**/*.feature",
+ supportFile: false,
+ },
+ video: true,
+});
+
+//
+
+async function setupNodeEvents(
+ on: Cypress.PluginEvents,
+ config: Cypress.PluginConfigOptions,
+) {
+ await addCucumberPreprocessorPlugin(on, config);
+
+ on(
+ "file:preprocessor",
+ createBundler({
+ plugins: [createEsbuildPlugin(config)],
+ }),
+ );
+
+ return config;
+}
diff --git a/cypress/support/step_definitions/general.ts b/cypress/support/step_definitions/general.ts
new file mode 100644
index 0000000..776fdf5
--- /dev/null
+++ b/cypress/support/step_definitions/general.ts
@@ -0,0 +1,21 @@
+//
+
+import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
+
+//
+
+Given("la page de démarrage", () => {
+ cy.visit("/");
+});
+
+When("je clique sur {string}", function (text: string) {
+ cy.contains(text).click();
+});
+
+When("je remplis le formulaire de connexion", function (string: string) {
+ return "pending";
+});
+
+Then("je vois {string}", function (text: string) {
+ cy.contains(text).should("be.visible");
+});
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..314f326
--- /dev/null
+++ b/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "proconnect-kitchen-sink",
+ "private": true,
+ "type": "module",
+ "module": "index.ts",
+ "workspaces": [
+ "apps/*"
+ ],
+ "scripts": {
+ "build": "bun run --filter=* build",
+ "start": "bun run scripts/start.ts",
+ "studio": "cypress open -b electron --e2e",
+ "test": "cypress run -e filterSpecs=true"
+ },
+ "prettier": {
+ "plugins": [
+ "prettier-plugin-organize-imports"
+ ]
+ },
+ "devDependencies": {
+ "@badeball/cypress-cucumber-preprocessor": "20.1.2",
+ "@bahmutov/cypress-esbuild-preprocessor": "2.2.3",
+ "@tsconfig/bun": "1.0.7",
+ "@types/bun": "1.1.10",
+ "cypress": "13.14.2",
+ "prettier": "3.3.3",
+ "prettier-plugin-organize-imports": "4.1.0",
+ "typescript": "5.6.2"
+ },
+ "trustedDependencies": [
+ "cypress"
+ ]
+}
diff --git a/scripts/index.html b/scripts/index.html
new file mode 100644
index 0000000..cc8c707
--- /dev/null
+++ b/scripts/index.html
@@ -0,0 +1,3 @@
+
diff --git a/scripts/start.ts b/scripts/start.ts
new file mode 100644
index 0000000..c8319f1
--- /dev/null
+++ b/scripts/start.ts
@@ -0,0 +1,15 @@
+Bun.spawn(`bun run preview --port 3100`.split(" "), {
+ cwd: "./apps/spa_pkce_proconnect",
+});
+
+Bun.serve({
+ fetch() {
+ return new Response("404!");
+ },
+ static: {
+ "/": new Response(await Bun.file("./scripts/index.html").bytes(), {
+ headers: { "Content-Type": "text/html" },
+ }),
+ },
+ port: 3000,
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..eba46e7
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@tsconfig/bun/tsconfig.json",
+ "compilerOptions": {}
+}