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 + + + + +
+
+ Cliquez pour vous connecter + + +
+
+ + + + 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": {} +}