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:
+51
-11
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user