From c15e342bb8a7313c905d534df0163d2f023a106d Mon Sep 17 00:00:00 2001 From: Girish Ramakrishnan Date: Sat, 14 Mar 2026 22:06:17 +0530 Subject: [PATCH] webadmin: remove the implicit flow we now use pkce . main advantage is that we don't see the access token in the url anymore. in pkce, the auth code by itself is useless. need the verifier. fixes #844 --- dashboard/authcallback.html | 62 +++++++++++++++++++---- dashboard/src/Index.vue | 6 +-- dashboard/src/components/SetupAccount.vue | 3 +- dashboard/src/utils.js | 35 ++++++++++++- dashboard/src/views/ActivationView.vue | 4 +- src/oidcclients.js | 10 ++-- src/oidcserver.js | 9 ++-- 7 files changed, 101 insertions(+), 28 deletions(-) diff --git a/dashboard/authcallback.html b/dashboard/authcallback.html index f023edaea..23b4a5556 100644 --- a/dashboard/authcallback.html +++ b/dashboard/authcallback.html @@ -2,19 +2,59 @@ diff --git a/dashboard/src/Index.vue b/dashboard/src/Index.vue index 3d7007466..4deabdc63 100644 --- a/dashboard/src/Index.vue +++ b/dashboard/src/Index.vue @@ -8,7 +8,7 @@ import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue'; import { Notification, InputDialog, fetcher } from '@cloudron/pankow'; import { setLanguage } from './i18n.js'; import { API_ORIGIN, TOKEN_TYPES } from './constants.js'; -import { redirectIfNeeded } from './utils.js'; +import { redirectIfNeeded, startAuthFlow } from './utils.js'; import ProfileModel from './models/ProfileModel.js'; import ProvisionModel from './models/ProvisionModel.js'; import NotificationsModel from './models/NotificationsModel.js'; @@ -436,8 +436,8 @@ onMounted(async () => { if (!localStorage.token) { localStorage.setItem('redirectToHash', window.location.hash); - // start oidc flow - window.location.href = `${API_ORIGIN}/openid/auth?client_id=` + (API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN) + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html'; + const clientId = API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN; + window.location.href = await startAuthFlow(clientId, API_ORIGIN); return; } diff --git a/dashboard/src/components/SetupAccount.vue b/dashboard/src/components/SetupAccount.vue index fc90c1fbf..370b792fe 100644 --- a/dashboard/src/components/SetupAccount.vue +++ b/dashboard/src/components/SetupAccount.vue @@ -5,6 +5,7 @@ import { marked } from 'marked'; import { Button, PasswordInput, FormGroup, TextInput } from '@cloudron/pankow'; import PublicPageLayout from '../components/PublicPageLayout.vue'; import ProfileModel from '../models/ProfileModel.js'; +import { startAuthFlow } from '../utils.js'; const profileModel = ProfileModel.create(); @@ -89,7 +90,7 @@ async function onSubmit() { // set token to autologin on first oidc flow localStorage.cloudronFirstTimeToken = result.accessToken; - dashboardUrl.value = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html'; + dashboardUrl.value = await startAuthFlow('cid-webadmin', ''); busy.value = false; mode.value = MODE.DONE; diff --git a/dashboard/src/utils.js b/dashboard/src/utils.js index 605a1e504..7bc69d7c0 100644 --- a/dashboard/src/utils.js +++ b/dashboard/src/utils.js @@ -660,6 +660,35 @@ function parseFullBackupPath(fullPath) { return { prefix, remotePath }; } +function base64urlEncode(buffer) { + return btoa(String.fromCharCode(...buffer)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function generateCodeVerifier() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64urlEncode(array); +} + +async function computeCodeChallenge(verifier) { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return base64urlEncode(new Uint8Array(hash)); +} + +async function startAuthFlow(clientId, apiOrigin) { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await computeCodeChallenge(codeVerifier); + + sessionStorage.setItem('pkce_code_verifier', codeVerifier); + sessionStorage.setItem('pkce_client_id', clientId); + sessionStorage.setItem('pkce_api_origin', apiOrigin || ''); + + const redirectUri = window.location.origin + '/authcallback.html'; + const base = apiOrigin || ''; + return `${base}/openid/auth?client_id=${clientId}&scope=openid email profile&response_type=code&redirect_uri=${redirectUri}&code_challenge=${codeChallenge}&code_challenge_method=S256`; +} + // named exports export { renderSafeMarkdown, @@ -679,7 +708,8 @@ export { getColor, prettySchedule, parseSchedule, - parseFullBackupPath + parseFullBackupPath, + startAuthFlow }; // default export @@ -701,5 +731,6 @@ export default { getColor, prettySchedule, parseSchedule, - parseFullBackupPath + parseFullBackupPath, + startAuthFlow }; diff --git a/dashboard/src/views/ActivationView.vue b/dashboard/src/views/ActivationView.vue index f0bb5093e..fd4d1b685 100644 --- a/dashboard/src/views/ActivationView.vue +++ b/dashboard/src/views/ActivationView.vue @@ -3,7 +3,7 @@ import { ref, useTemplateRef, onMounted } from 'vue'; import { Button, Checkbox, FormGroup, TextInput, PasswordInput, EmailInput } from '@cloudron/pankow'; import ProvisionModel from '../models/ProvisionModel.js'; -import { redirectIfNeeded } from '../utils.js'; +import { redirectIfNeeded, startAuthFlow } from '../utils.js'; const provisionModel = ProvisionModel.create(); @@ -59,7 +59,7 @@ async function onOwnerSubmit() { // set token to autologin on first oidc flow localStorage.cloudronFirstTimeToken = result; - window.location.href = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html'; + window.location.href = await startAuthFlow('cid-webadmin', ''); } onMounted(async () => { diff --git a/src/oidcclients.js b/src/oidcclients.js index 28d0f149c..c2bb951f1 100644 --- a/src/oidcclients.js +++ b/src/oidcclients.js @@ -61,8 +61,9 @@ async function get(id) { id: ID_WEBADMIN, secret: 'notused', application_type: 'web', - response_types: ['code', 'code token'], - grant_types: ['authorization_code', 'implicit'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + grant_types: ['authorization_code'], loginRedirectUri: `https://${dashboardFqdn}/authcallback.html` }; } else if (id === ID_DEVELOPMENT) { @@ -70,8 +71,9 @@ async function get(id) { id: ID_DEVELOPMENT, secret: 'notused', application_type: 'native', // have to use native here to support plaintext http on localhost - response_types: ['code', 'code token'], - grant_types: ['authorization_code', 'implicit'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + grant_types: ['authorization_code'], loginRedirectUri: 'http://localhost:4000/authcallback.html' }; } else if (id === ID_CLI) { diff --git a/src/oidcserver.js b/src/oidcserver.js index 2a428fab7..b6f249a7a 100644 --- a/src/oidcserver.js +++ b/src/oidcserver.js @@ -36,15 +36,14 @@ import mailpasswords from './mailpasswords.js'; const { log, trace } = logger('oidcserver'); -// 1. Index.vue starts the OIDC flow by navigating to /openid/auth. Webadmin sets callback url to authcallback.html + implicit flow +// 1. Index.vue starts the OIDC flow by navigating to /openid/auth. Webadmin uses authorization code flow with PKCE // 2. oidcserver starts an interaction and redirects to oidc_login.html // 3. oidc_login.html is rendered by renderInteractionPage() with the form submit url /interaction/:uid/login // 4. When form is submitted, it invokes interactionLogin(). This validates user creds // 5. We enter the scopes confirmation flow which is oidc_interaction_confirm.html rendered by renderInteractionPage() // 6. We have no concept of confirmation. The page auto-submits the form immediately without user interaction // 7. oidcserver calls interactionConfirm() which finishes it via interactionFinished(). - -// FIXME: webadmin's implicit flow (response_type=code token) results in authcallback.html being called with access_token query param. We should remove this +// 8. authcallback.html exchanges the authorization code for an access token via POST to /openid/token with code_verifier const ROUTE_PREFIX = '/openid'; @@ -719,8 +718,8 @@ async function start() { keys: [ cookieSecret ] }, pkce: { - required: function pkceRequired(/*ctx, client*/) { - return false; + required: function pkceRequired(ctx, client) { + return client.clientId === 'cid-webadmin' || client.clientId === 'cid-development'; } }, clientBasedCORS(ctx, origin, client) {