diff --git a/CHANGES b/CHANGES index 48cc37f7d..b82d6392c 100644 --- a/CHANGES +++ b/CHANGES @@ -3184,4 +3184,5 @@ * backup sites: identify conflicting site locations * update: add policy to update apps and platform separately * passkey: fix issue where passkeys were lost on restart +* passkey: implement passwordless login diff --git a/dashboard/oidc_login.html b/dashboard/oidc_login.html index 544b2019d..61dc495a4 100644 --- a/dashboard/oidc_login.html +++ b/dashboard/oidc_login.html @@ -9,6 +9,8 @@ name: name, note: note, submitUrl: submitUrl, + passkeyAuthOptionsUrl: passkeyAuthOptionsUrl, + passkeyLoginUrl: passkeyLoginUrl, footer: footer, language: language }) %>; diff --git a/dashboard/public/translation/en.json b/dashboard/public/translation/en.json index fa4f44473..601c0af31 100644 --- a/dashboard/public/translation/en.json +++ b/dashboard/public/translation/en.json @@ -1543,7 +1543,8 @@ "errorInternal": "Internal error, try again later", "loginAction": "Log in", "usePasskeyAction": "Use passkey", - "errorPasskeyFailed": "Failed to login with passkey" + "errorPasskeyFailed": "Failed to login with passkey", + "passkeyAction": "Log in with a passkey" }, "passwordReset": { "title": "Password reset", diff --git a/dashboard/src/views/LoginView.vue b/dashboard/src/views/LoginView.vue index 38001eceb..a0d148374 100644 --- a/dashboard/src/views/LoginView.vue +++ b/dashboard/src/views/LoginView.vue @@ -25,6 +25,8 @@ const note = window.cloudron.note; const iconUrl = window.cloudron.iconUrl; const footer = window.cloudron.footer; const submitUrl = window.cloudron.submitUrl; +const passkeyAuthOptionsUrl = window.cloudron.passkeyAuthOptionsUrl; +const passkeyLoginUrl = window.cloudron.passkeyLoginUrl; async function onSubmit() { busy.value = true; @@ -123,6 +125,41 @@ async function onUsePasskey() { busy.value = false; } +async function onLoginWithPasskey() { + busy.value = true; + passkeyError.value = false; + internalError.value = false; + + try { + const optionsRes = await fetcher.post(passkeyAuthOptionsUrl, {}); + if (optionsRes.status !== 200) { + internalError.value = true; + busy.value = false; + return; + } + + const credential = await startAuthentication({ optionsJSON: optionsRes.body }); + + const res = await fetcher.post(passkeyLoginUrl, { passkeyResponse: credential }); + + if (res.status === 200 && res.body.redirectTo) { + window.location.href = res.body.redirectTo; + return; + } else if (res.status === 401) { + passkeyError.value = true; + } else { + internalError.value = true; + } + } catch (error) { + if (error.name !== 'NotAllowedError') { + console.error('Passkey login failed', error); + passkeyError.value = true; + } + } + + busy.value = false; +} + onMounted(async () => { // placed optionally in local storage by setupaccount.js const autoLoginToken = localStorage.cloudronFirstTimeToken; @@ -171,8 +208,10 @@ onMounted(async () => {
- + + {{ $t('login.passkeyAction') }}
+
{{ $t('login.errorPasskeyFailed') }}
diff --git a/src/oidcserver.js b/src/oidcserver.js index f2c8e7744..a8c22fb5e 100644 --- a/src/oidcserver.js +++ b/src/oidcserver.js @@ -312,6 +312,8 @@ async function renderInteractionPage(req, res) { if (prompt.name === 'login') { const data = { submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/login`, + passkeyAuthOptionsUrl: `${ROUTE_PREFIX}/interaction/${uid}/passkey_auth_options`, + passkeyLoginUrl: `${ROUTE_PREFIX}/interaction/${uid}/passkey_login`, iconUrl: '/api/v1/cloudron/avatar', name: client.name || await branding.getCloudronName(), footer: marked.parse(await branding.renderFooter()), @@ -449,6 +451,47 @@ async function interactionLogin(req, res, next) { res.status(200).send({ redirectTo }); } +async function interactionPasskeyAuthOptions(req, res, next) { + const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res)); + if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description)); + + const { uid } = details; + + const [error, options] = await safe(passkeys.getDiscoverableAuthOptions(uid)); + if (error) return next(new HttpError(500, error)); + + res.status(200).send(options); +} + +async function interactionPasskeyLogin(req, res, next) { + const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res)); + if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description)); + + if (!req.body.passkeyResponse || typeof req.body.passkeyResponse !== 'object') return next(new HttpError(400, 'passkeyResponse must be an object')); + + const { uid } = details; + + const [verifyError, result] = await safe(passkeys.verifyDiscoverableAuth(uid, req.body.passkeyResponse)); + if (verifyError) { + trace(`interactionPasskeyLogin: passkey verification failed: ${verifyError.message}`); + return next(new HttpError(401, 'Passkey verification failed')); + } + + const user = await users.get(result.userId); + if (!user) return next(new HttpError(401, 'User not found')); + + const interactionResult = { + login: { + accountId: user.username, + }, + }; + + const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, interactionResult)); + if (interactionFinishError) return next(new HttpError(500, interactionFinishError)); + + res.status(200).send({ redirectTo }); +} + async function interactionConfirm(req, res, next) { const [detailsError, interactionDetails] = await safe(gOidcProvider.interactionDetails(req, res)); if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description)); @@ -772,9 +815,11 @@ async function start() { } app.get (`${ROUTE_PREFIX}/interaction/:uid`, setNoCache, renderInteractionPage); - app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin); - app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm); - app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort); + app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin); + app.post(`${ROUTE_PREFIX}/interaction/:uid/passkey_auth_options`, setNoCache, json, interactionPasskeyAuthOptions); + app.post(`${ROUTE_PREFIX}/interaction/:uid/passkey_login`, setNoCache, json, interactionPasskeyLogin); + app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm); + app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort); // cloudflare access has a bug that it cannot handle OKP key type. https://github.com/sebadob/rauthy/issues/1229#issuecomment-3610993452 app.get (`${ROUTE_PREFIX}/jwks_rsaonly`, setNoCache, async function (req, res) { diff --git a/src/passkeys.js b/src/passkeys.js index 049139111..c5dd9721b 100644 --- a/src/passkeys.js +++ b/src/passkeys.js @@ -278,6 +278,70 @@ async function verifyAuthentication(user, response) { return { verified: true, passkeyId: passkey.id }; } +async function getDiscoverableAuthOptions(challengeKey) { + assert.strictEqual(typeof challengeKey, 'string'); + + const { rpId } = await getRpInfo(); + + const options = await generateAuthenticationOptions({ + rpID: rpId, + allowCredentials: [], + userVerification: 'preferred' + }); + + storeChallenge(challengeKey, options.challenge); + + log(`getDiscoverableAuthOptions: generated for interaction ${challengeKey}`); + + return options; +} + +async function verifyDiscoverableAuth(challengeKey, response) { + assert.strictEqual(typeof challengeKey, 'string'); + assert.strictEqual(typeof response, 'object'); + + const expectedChallenge = getAndDeleteChallenge(challengeKey); + if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found'); + + const { rpId, origin } = await getRpInfo(); + + const credentialIdBase64url = response.id; + const passkey = await getByCredentialId(credentialIdBase64url); + + if (!passkey) { + log(`verifyDiscoverableAuth: passkey not found for credential ${credentialIdBase64url}`); + throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found'); + } + + const publicKey = new Uint8Array(Buffer.from(passkey.publicKey, 'base64')); + + const [error, verification] = await safe(verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + credential: { + id: passkey.credentialId, + publicKey, + counter: passkey.counter, + transports: passkey.transports + } + })); + + if (error) { + log(`verifyDiscoverableAuth: verification failed:`, error); + throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed'); + } + + if (!verification.verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed'); + + await updateCounter(passkey.id, verification.authenticationInfo.newCounter); + + log(`verifyDiscoverableAuth: passkey verified for user ${passkey.userId}`); + + return { verified: true, passkeyId: passkey.id, userId: passkey.userId }; +} + export default { listByUserId, get, @@ -291,6 +355,8 @@ export default { verifyRegistration, getAuthenticationOptions, verifyAuthentication, + getDiscoverableAuthOptions, + verifyDiscoverableAuth, removePrivateFields };