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
This commit is contained in:
Girish Ramakrishnan
2026-03-14 22:06:17 +05:30
parent dc1449c7b6
commit c15e342bb8
7 changed files with 101 additions and 28 deletions
+51 -11
View File
@@ -2,19 +2,59 @@
<script>
const tmp = window.location.hash.slice(1).split('&');
(async function () {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
// FIXME: implicit flow (response_type=code token) results in access_token query param. this is not secure
tmp.forEach(function (pair) {
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
});
if (!code) {
console.error('No authorization code in callback URL');
window.location.replace('/');
return;
}
let redirectTo = '/';
if (localStorage.getItem('redirectToHash')) {
redirectTo += localStorage.getItem('redirectToHash');
localStorage.removeItem('redirectToHash');
}
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
const clientId = sessionStorage.getItem('pkce_client_id') || 'cid-webadmin';
const apiOrigin = sessionStorage.getItem('pkce_api_origin') || '';
window.location.replace(redirectTo); // this removes us from history
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('pkce_client_id');
sessionStorage.removeItem('pkce_api_origin');
try {
const response = await fetch(apiOrigin + '/openid/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: clientId,
redirect_uri: window.location.origin + '/authcallback.html',
code_verifier: codeVerifier
})
});
const data = await response.json();
if (!response.ok || !data.access_token) {
console.error('Token exchange failed', data);
window.location.replace('/');
return;
}
localStorage.token = data.access_token;
} catch (e) {
console.error('Token exchange error', e);
window.location.replace('/');
return;
}
let redirectTo = '/';
if (localStorage.getItem('redirectToHash')) {
redirectTo += localStorage.getItem('redirectToHash');
localStorage.removeItem('redirectToHash');
}
window.location.replace(redirectTo);
})();
</script>
+3 -3
View File
@@ -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;
}
+2 -1
View File
@@ -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;
+33 -2
View File
@@ -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
};
+2 -2
View File
@@ -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 () => {
+6 -4
View File
@@ -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) {
+4 -5
View File
@@ -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) {