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) {