Initial commit

This commit is contained in:
Oliver Traber 2023-02-19 02:02:36 +01:00
commit ff5a958e26
Signed by: Bluemedia
GPG key ID: C0674B105057136C
28 changed files with 7460 additions and 0 deletions

View file

@ -0,0 +1,15 @@
<template>
<div
class="flex flex-row justify-center mt-6 w-full bg-red-100 border border-red-400 text-red-700 p-2 rounded">
<p class="text-center">
<slot></slot>
</p>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "ErrorBox",
});
</script>

25
src/components/Layout.vue Normal file
View file

@ -0,0 +1,25 @@
<template>
<div
:class="
'flex flex-col min-h-screen w-full md:w-1/2 lg:w-1/3 max-w-none md:max-w-2xl bg-gray-50 md:rounded-r-3xl shadow-2xl bg-[url(\'' +
context.url.resourcesPath +
'/img/background.jpg\')] bg-no-repeat bg-center bg-cover font-sans overflow-hidden'
">
<div class="flex flex-grow flex-col justify-center w-full px-8 md:px-20">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { KcContextBase } from "~/types/context";
export default defineComponent({
name: "Layout",
data() {
return {
context: (window as any).kcContext as KcContextBase.Common,
};
},
});
</script>

5
src/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module "*" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View file

@ -0,0 +1,8 @@
{
"themes": [
{
"name": "modern-login",
"types": ["login"]
}
]
}

View file

@ -0,0 +1,237 @@
<#function are_same_path path searchedPath>
<#if path?size != searchedPath?size>
<#return false>
</#if>
<#local i=0>
<#list path as property>
<#local searchedProperty=searchedPath[i]>
<#if searchedProperty?is_string && searchedProperty == "*">
<#continue>
</#if>
<#if searchedProperty?is_string && !property?is_string>
<#return false>
</#if>
<#if searchedProperty?is_number && !property?is_number>
<#return false>
</#if>
<#if searchedProperty?string != property?string>
<#return false>
</#if>
<#local i+= 1>
</#list>
<#return true>
</#function>
<#function ftl_object_to_js_code_declaring_an_object pageId object path>
<#local isHash = "">
<#attempt>
<#local isHash = object?is_hash || object?is_hash_ex>
<#recover>
<#return "ABORT: Can't evaluate if " + path?join(".") + " is hash">
</#attempt>
<#if isHash>
<#if path?size gt 10>
<#return "ABORT: Too many recursive calls">
</#if>
<#local keys = "">
<#attempt>
<#local keys = object?keys>
<#recover>
<#return "ABORT: We can't list keys on this object">
</#attempt>
<#local out_seq = []>
<#list keys as key>
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
<#continue>
</#if>
<#if
(
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key) &&
are_same_path(path, ["url"])
) || (
key == "updateProfileCtx" &&
are_same_path(path, [])
) || (
key == "loginAction" &&
are_same_path(path, ["url"]) &&
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&
!(auth?has_content && auth.showTryAnotherWayLink())
) || (
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
are_same_path(path, ["brokerContext"]) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
) || (
key == "identityProviderBrokerCtx" &&
are_same_path(path, []) &&
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
are_same_path(path, ["realm"])
)
>
<#continue>
</#if>
<#if key == "attemptedUsername" && are_same_path(path, ["auth"])>
<#attempt>
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#continue>
</#if>
<#recover>
</#attempt>
</#if>
<#attempt>
<#if !object[key]??>
<#continue>
</#if>
<#recover>
<#local out_seq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
<#continue>
</#attempt>
<#local propertyValue = "">
<#attempt>
<#local propertyValue = object[key]>
<#recover>
<#local out_seq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
<#continue>
</#attempt>
<#local rec_out = ftl_object_to_js_code_declaring_an_object(pageId, propertyValue, path + [ key ])>
<#if rec_out?starts_with("ABORT:")>
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local out_seq += ["/*" + key + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local out_seq += ['"' + key + '": ' + rec_out + ","]>
</#list>
<#return (["{"] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
</#if>
<#local isMethod = "">
<#attempt>
<#local isMethod = object?is_method>
<#recover>
<#return "ABORT: Can't test if it'sa method.">
</#attempt>
<#if isMethod>
<#if are_same_path(path, ["auth", "showUsername"])>
<#attempt>
<#return auth.showUsername()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showUsername()">
</#attempt>
</#if>
<#if are_same_path(path, ["auth", "showResetCredentials"])>
<#attempt>
<#return auth.showResetCredentials()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
</#attempt>
</#if>
<#if are_same_path(path, ["auth", "showTryAnotherWayLink"])>
<#attempt>
<#return auth.showTryAnotherWayLink()?c>
<#recover>
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
</#attempt>
</#if>
<#return "ABORT: It's a method">
</#if>
<#local isBoolean = "">
<#attempt>
<#local isBoolean = object?is_boolean>
<#recover>
<#return "ABORT: Can't test if it's a boolean">
</#attempt>
<#if isBoolean>
<#return object?c>
</#if>
<#local isEnumerable = "">
<#attempt>
<#local isEnumerable = object?is_enumerable>
<#recover>
<#return "ABORT: Can't test if it's an enumerable">
</#attempt>
<#if isEnumerable>
<#local out_seq = []>
<#local i = 0>
<#list object as array_item>
<#if !array_item??>
<#local out_seq += ["null,"]>
<#continue>
</#if>
<#local rec_out = ftl_object_to_js_code_declaring_an_object(pageId, array_item, path + [ i ])>
<#local i = i + 1>
<#if rec_out?starts_with("ABORT:")>
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
<#if errorMessage != " It's a method" >
<#local out_seq += ["/*" + i?string + ": " + errorMessage + "*/"]>
</#if>
<#continue>
</#if>
<#local out_seq += [rec_out + ","]>
</#list>
<#return (["["] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
</#if>
<#attempt>
<#return '"' + object?js_string + '"'>;
<#recover>
</#attempt>
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
</#function>
<#macro baseLayout pageId="template.ftl">
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>${msg("loginTitle",(realm.displayName!''))}</title>
<style>
body {
background-image: url("${url.resourcesPath}/img/background.jpg");
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
</style>
<script>
window.kcContext = ${ftl_object_to_js_code_declaring_an_object(pageId, .data_model, [])?no_esc}
</script>
<#nested "head">
</head>
<body>
<div id="app"></div>
<#nested "scripts">
</body>
</html>
</#macro>

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View file

@ -0,0 +1,2 @@
locales=en,de
parent=keycloak

441
src/types/context.ts Normal file
View file

@ -0,0 +1,441 @@
/* eslint-disable */
/** Take theses type definition with a grain of salt.
* Some values might be undefined on some pages.
* (ex: url.loginAction is undefined on error.ftl)
*/
export type KcContextBase =
| KcContextBase.Login
| KcContextBase.Register
| KcContextBase.RegisterUserProfile
| KcContextBase.Error
| KcContextBase.LoginResetPassword
| KcContextBase.LoginVerifyEmail
| KcContextBase.Terms
| KcContextBase.LoginOtp
| KcContextBase.LoginUsername
| KcContextBase.WebauthnAuthenticate
| KcContextBase.LoginPassword
| KcContextBase.LoginUpdatePassword
| KcContextBase.LoginUpdateProfile
| KcContextBase.LoginIdpLinkConfirm
| KcContextBase.LoginIdpLinkEmail
| KcContextBase.LoginPageExpired
| KcContextBase.LoginConfigTotp
| KcContextBase.LogoutConfirm
| KcContextBase.UpdateUserProfile
| KcContextBase.IdpReviewUserProfile;
export type WebauthnAuthenticator = {
credentialId: string;
label: string;
createdAt: string;
};
export declare namespace KcContextBase {
export type Common = {
url: {
loginAction: string;
resourcesPath: string;
resourcesCommonPath: string;
loginRestartFlowUrl: string;
loginUrl: string;
};
realm: {
name: string;
displayName?: string;
displayNameHtml?: string;
internationalizationEnabled: boolean;
registrationEmailAsUsername: boolean;
};
locale?: {
supported: {
url: string;
label: string;
languageTag: string;
}[];
currentLanguageTag: string;
};
auth?: {
showUsername?: boolean;
showResetCredentials?: boolean;
showTryAnotherWayLink?: boolean;
attemptedUsername?: string;
};
scripts: string[];
message?: {
type: "success" | "warning" | "error" | "info";
summary: string;
};
client: {
clientId: string;
name?: string;
description?: string;
};
isAppInitiatedAction: boolean;
};
export type Login = Common & {
url: {
loginResetCredentialsUrl: string;
registrationUrl: string;
};
realm: {
loginWithEmailAllowed: boolean;
rememberMe: boolean;
password: boolean;
resetPasswordAllowed: boolean;
registrationAllowed: boolean;
};
auth: {
selectedCredential?: string;
};
registrationDisabled: boolean;
login: {
username?: string;
rememberMe?: boolean;
};
usernameEditDisabled: boolean;
social: {
displayInfo: boolean;
providers?: {
loginUrl: string;
alias: string;
providerId: string;
displayName: string;
}[];
};
};
export type RegisterCommon = Common & {
url: {
registrationAction: string;
};
passwordRequired: boolean;
recaptchaRequired: boolean;
recaptchaSiteKey?: string;
social: {
displayInfo: boolean;
providers?: {
loginUrl: string;
alias: string;
providerId: string;
displayName: string;
}[];
};
};
export type Register = RegisterCommon & {
register: {
formData: {
firstName?: string;
displayName?: string;
lastName?: string;
email?: string;
username?: string;
};
};
};
export type RegisterUserProfile = RegisterCommon & {
profile: {
context: "REGISTRATION_PROFILE";
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
export type Error = Common & {
client?: {
baseUrl?: string;
};
message: NonNullable<Common["message"]>;
};
export type LoginResetPassword = Common & {
realm: {
loginWithEmailAllowed: boolean;
};
};
export type LoginVerifyEmail = Common & {
user?: {
email: string;
};
};
export type Terms = Common & {};
export type LoginOtp = Common & {
otpLogin: {
userOtpCredentials: { id: string; userLabel: string }[];
};
};
export type LoginUsername = Common & {
url: {
loginResetCredentialsUrl: string;
registrationUrl: string;
};
realm: {
loginWithEmailAllowed: boolean;
rememberMe: boolean;
password: boolean;
resetPasswordAllowed: boolean;
registrationAllowed: boolean;
};
registrationDisabled: boolean;
login: {
username?: string;
rememberMe?: boolean;
};
usernameHidden?: boolean;
social: {
displayInfo: boolean;
providers?: {
loginUrl: string;
alias: string;
providerId: string;
displayName: string;
}[];
};
};
export type LoginPassword = Common & {
url: {
loginResetCredentialsUrl: string;
registrationUrl: string;
};
realm: {
resetPasswordAllowed: boolean;
};
auth?: {
showUsername?: boolean;
showResetCredentials?: boolean;
showTryAnotherWayLink?: boolean;
attemptedUsername?: string;
};
social: {
displayInfo: boolean;
};
login: {
password?: string;
};
};
export type WebauthnAuthenticate = Common & {
authenticators: {
authenticators: WebauthnAuthenticator[];
};
challenge: string;
userVerification: UserVerificationRequirement | "not specified";
rpId: string;
createTimeout: string;
isUserIdentified: "true" | "false";
shouldDisplayAuthenticators: boolean;
social: {
displayInfo: boolean;
};
login: {};
};
export type LoginUpdatePassword = Common & {
username: string;
};
export type LoginUpdateProfile = Common & {
user: {
editUsernameAllowed: boolean;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
};
};
export type LoginIdpLinkConfirm = Common & {
idpAlias: string;
};
export type LoginIdpLinkEmail = Common & {
brokerContext: {
username: string;
};
idpAlias: string;
};
export type LoginPageExpired = Common & {
pageId: "login-page-expired.ftl";
};
export type LoginConfigTotp = Common & {
pageId: "login-config-totp.ftl";
mode?: "qr" | "manual" | undefined | null;
totp: {
totpSecretEncoded: string;
qrUrl: string;
policy: {
supportedApplications: string[];
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
digits: number;
lookAheadWindow: number;
} & (
| {
type: "totp";
period: number;
}
| {
type: "hotp";
initialCounter: number;
}
);
totpSecretQrCode: string;
manualUrl: string;
totpSecret: string;
otpCredentials: { id: string; userLabel: string }[];
};
};
export type LogoutConfirm = Common & {
pageId: "logout-confirm.ftl";
url: {
logoutConfirmAction: string;
};
client: {
baseUrl?: string;
};
logoutConfirm: {
code: string;
skipLink?: boolean;
};
};
export type UpdateUserProfile = Common & {
pageId: "update-user-profile.ftl";
profile: {
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
export type IdpReviewUserProfile = Common & {
pageId: "idp-review-user-profile.ftl";
profile: {
context: "IDP_REVIEW";
attributes: Attribute[];
attributesByName: Record<string, Attribute>;
};
};
}
export type Attribute = {
name: string;
displayName?: string;
required: boolean;
value?: string;
group?: string;
groupDisplayHeader?: string;
groupDisplayDescription?: string;
readOnly: boolean;
validators: Validators;
annotations: Record<string, string>;
groupAnnotations: Record<string, string>;
autocomplete?:
| "on"
| "off"
| "name"
| "honorific-prefix"
| "given-name"
| "additional-name"
| "family-name"
| "honorific-suffix"
| "nickname"
| "email"
| "username"
| "new-password"
| "current-password"
| "one-time-code"
| "organization-title"
| "organization"
| "street-address"
| "address-line1"
| "address-line2"
| "address-line3"
| "address-level4"
| "address-level3"
| "address-level2"
| "address-level1"
| "country"
| "country-name"
| "postal-code"
| "cc-name"
| "cc-given-name"
| "cc-additional-name"
| "cc-family-name"
| "cc-number"
| "cc-exp"
| "cc-exp-month"
| "cc-exp-year"
| "cc-csc"
| "cc-type"
| "transaction-currency"
| "transaction-amount"
| "language"
| "bday"
| "bday-day"
| "bday-month"
| "bday-year"
| "sex"
| "tel"
| "tel-country-code"
| "tel-national"
| "tel-area-code"
| "tel-local"
| "tel-extension"
| "impp"
| "url"
| "photo";
};
export type Validators = Partial<{
length: Validators.DoIgnoreEmpty & Validators.Range;
double: Validators.DoIgnoreEmpty & Validators.Range;
integer: Validators.DoIgnoreEmpty & Validators.Range;
email: Validators.DoIgnoreEmpty;
"up-immutable-attribute": {};
"up-attribute-required-by-metadata-value": {};
"up-username-has-value": {};
"up-duplicate-username": {};
"up-username-mutation": {};
"up-email-exists-as-username": {};
"up-blank-attribute-value": Validators.ErrorMessage & {
"fail-on-null": boolean;
};
"up-duplicate-email": {};
"local-date": Validators.DoIgnoreEmpty;
pattern: Validators.DoIgnoreEmpty &
Validators.ErrorMessage & { pattern: string };
"person-name-prohibited-characters": Validators.DoIgnoreEmpty &
Validators.ErrorMessage;
uri: Validators.DoIgnoreEmpty;
"username-prohibited-characters": Validators.DoIgnoreEmpty &
Validators.ErrorMessage;
options: Validators.Options;
}>;
export declare namespace Validators {
export type DoIgnoreEmpty = {
"ignore.empty.value"?: boolean;
};
export type ErrorMessage = {
"error-message"?: string;
};
export type Range = {
min?: `${number}`;
max?: `${number}`;
};
export type Options = {
options: string[];
};
}

View file

@ -0,0 +1,6 @@
<#import "baselayout.ftl" as layout>
<@layout.baseLayout pageId="login.ftl" ; section>
<#if section = "scripts">
<script typo="module" src="${url.resourcesPath}/js/login.js"></script>
</#if>
</@layout.baseLayout>

7
src/views/login/index.ts Normal file
View file

@ -0,0 +1,7 @@
import { createApp } from "vue";
import index from "./index.vue";
if ((window as any).kcContext) {
const app = createApp(index);
app.mount("#app");
}

103
src/views/login/index.vue Normal file
View file

@ -0,0 +1,103 @@
<template>
<layout>
<h1 class="mb-8 text-3xl font-semibold text-center text-gray-700">
Login to your account
</h1>
<p
v-if="context.realm.registrationAllowed"
class="mt-4 text-sm font-light text-center text-gray-700">
Don't have an account yet?
<a
:href="context.url.registrationUrl"
class="font-medium text-blue-500 hover:underline"
>Sign up</a
>
</p>
<div v-if="context.message?.type == 'error'">
<ErrorBox>{{ context.message?.summary }}</ErrorBox>
</div>
<form
class="flex flex-col justify-center mt-6"
:action="context.url.loginAction"
method="post">
<div>
<label for="username" class="block text-sm text-gray-800">{{
context.realm.loginWithEmailAllowed ? "Email or Username" : "Username"
}}</label>
<input
name="username"
type="text"
:placeholder="
context.realm.loginWithEmailAllowed
? 'jane.doe@example.com'
: 'JDoe'
"
:value="context.login.username ? context.login.username : ''"
class="block w-full px-4 py-2 mt-2 text-gray-800 bg-white border rounded-md focus:border-blue-500 focus:ring-transparent focus:outline-none focus:ring focus:ring-opacity-40" />
</div>
<div class="mt-4">
<div>
<label for="password" class="block text-sm text-gray-800"
>Password</label
>
<input
type="password"
name="password"
placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
class="block w-full px-4 py-2 mt-2 text-gray-800 bg-white border rounded-md focus:border-blue-500 focus:ring-transparent focus:outline-none focus:ring focus:ring-opacity-40" />
</div>
<a
v-if="context.realm.resetPasswordAllowed"
:href="context.url.loginResetCredentialsUrl"
class="text-xs text-gray-600 hover:underline"
>Forgot Password?</a
>
</div>
<div
v-if="context.realm.rememberMe"
class="mt-5 flex flex-row items-center">
<input
type="checkbox"
name="rememberMe"
class="w-4 h-4 text-blue-500 bg-gray-100 border-gray-400 rounded dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" />
<label for="rememberMe" class="block ml-3 text-sm text-gray-800"
>Remember me</label
>
</div>
<input
type="hidden"
name="credentialId"
:value="
context.auth.selectedCredential ? context.auth.selectedCredential : ''
" />
<button
class="w-full mt-6 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
Login
</button>
</form>
</layout>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Layout from "~/components/Layout.vue";
import ErrorBox from "~/components/ErrorBox.vue";
import type { KcContextBase } from "~/types/context";
export default defineComponent({
name: "Login",
components: {
Layout,
ErrorBox,
},
data() {
return {
context: (window as any).kcContext as KcContextBase.Login,
};
},
});
</script>
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

View file

@ -0,0 +1,6 @@
<#import "baselayout.ftl" as layout>
<@layout.baseLayout pageId="webauthn-authenticate.ftl" ; section>
<#if section = "scripts">
<script typo="module" src="${url.resourcesPath}/js/webauthn-authenticate.js"></script>
</#if>
</@layout.baseLayout>

View file

@ -0,0 +1,7 @@
import { createApp } from "vue";
import index from "./index.vue";
if ((window as any).kcContext) {
const app = createApp(index);
app.mount("#app");
}

View file

@ -0,0 +1,202 @@
<template>
<layout>
<h1
v-if="context.isUserIdentified"
class="text-3xl font-semibold text-center text-gray-700">
Welcome {{ context.auth?.attemptedUsername }}!
</h1>
<h1 v-else class="text-3xl font-semibold text-center text-gray-700">
Welcome!
</h1>
<div v-if="webauthnSupported && !error">
<p class="mt-5 mb-5 text-center">
Please use one of your registered devices to continue:
</p>
<div
v-for="authenticator in context.authenticators.authenticators"
:key="authenticator.credentialId"
class="w-full h-20 mb-5 bg-white shadow-md rounded-md flex flex-row items-center">
<svg
class="w-12 h-12 ml-4 text-gray-700"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16">
<path
d="M6 .5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4H6v-4ZM7 1v1h1V1H7Zm2 0v1h1V1H9ZM5.5 5a.5.5 0 0 0-.5.5V15a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V5.5a.5.5 0 0 0-.5-.5h-6Z" />
</svg>
<div class="w-full ml-5 flex flex-col">
<p>{{ authenticator.label }}</p>
<p class="text-sm">
Registered: {{ new Date(authenticator.createdAt).toLocaleString() }}
</p>
</div>
</div>
<button
:onclick="prepareAuthenticate"
class="w-full px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
Authenticate
</button>
<p
class="mt-5 text-sm text-center"
v-if="context.auth?.showTryAnotherWayLink">
Don't have your device at hand?
<a
:onclick="tryAnotherWay"
class="text-blue-500 hover:underline cursor-pointer"
>Try another way.</a
>
</p>
</div>
<div v-if="!webauthnSupported">
<p class="mt-5 text-center">
It seems that your browser doesn't support WebAuthn. Please try logging
in with a different browser<span
v-if="!context.auth?.showTryAnotherWayLink"
>.</span
><span v-else
>, or try a different login method using the button below.</span
>
</p>
<button
v-if="context.auth?.showTryAnotherWayLink"
:onclick="tryAnotherWay"
class="w-full mt-5 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
Try another method
</button>
</div>
<div v-if="error" class="mt-5 flex flex-col">
<ErrorBox
>Something went wrong during authentication using your device.</ErrorBox
>
<button
:onclick="retryAuth"
class="w-full mt-5 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
Retry
</button>
</div>
</layout>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Layout from "~/components/Layout.vue";
import ErrorBox from "~/components/ErrorBox.vue";
import type { KcContextBase } from "~/types/context";
import { base64url } from "rfc4648";
export default defineComponent({
name: "Login",
components: {
Layout,
ErrorBox,
},
data() {
return {
context: (window as any).kcContext as KcContextBase.WebauthnAuthenticate,
webauthnSupported: true,
error: false,
};
},
mounted: function () {
if (typeof PublicKeyCredential === "undefined") {
this.webauthnSupported = false;
}
},
methods: {
tryAnotherWay(e: Event) {
e.preventDefault();
this.formPost(this.context.url.loginAction, {
tryAnotherWay: "on",
});
},
prepareAuthenticate() {
if (this.context.isUserIdentified) {
const allowedCredentials = new Array<PublicKeyCredentialDescriptor>();
this.context.authenticators.authenticators.forEach(authenticator => {
allowedCredentials.push({
id: base64url.parse(authenticator.credentialId, { loose: true }),
type: "public-key",
});
});
this.authenticate(allowedCredentials);
} else {
this.authenticate([]);
}
},
async authenticate(
allowedAuthenticators: Array<PublicKeyCredentialDescriptor>
) {
const publicKey: PublicKeyCredentialRequestOptions = {
rpId: this.context.rpId,
challenge: base64url.parse(this.context.challenge, { loose: true }),
};
if (this.context.createTimeout !== "0")
publicKey.timeout = Number(this.context.createTimeout) * 1000;
if (allowedAuthenticators.length)
publicKey.allowCredentials = allowedAuthenticators;
if (this.context.userVerification !== "not specified")
publicKey.userVerification = this.context.userVerification;
try {
const resultRaw = await navigator.credentials.get({ publicKey });
if (!resultRaw || resultRaw.type !== "public-key") return;
const result = resultRaw as PublicKeyCredential;
if (!("authenticatorData" in result.response)) return;
const response = result.response as AuthenticatorAssertionResponse;
const clientDataJSON = response.clientDataJSON;
const authenticatorData = response.authenticatorData;
const signature = response.signature;
const postData = {
credentialId: result.id,
clientDataJSON: base64url.stringify(new Uint8Array(clientDataJSON), {
pad: false,
}),
authenticatorData: base64url.stringify(
new Uint8Array(authenticatorData),
{ pad: false }
),
signature: base64url.stringify(new Uint8Array(signature), {
pad: false,
}),
userHandle: base64url.stringify(
new Uint8Array(response.userHandle!),
{ pad: false }
),
};
this.formPost(this.context.url.loginAction, postData);
} catch (err) {
this.error = true;
}
},
retryAuth() {
this.error = false;
},
formPost(url: string, data: object) {
const form = document.createElement("form");
form.method = "post";
form.action = url;
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
const hiddenField = document.createElement("input");
hiddenField.type = "hidden";
hiddenField.name = key;
hiddenField.value = data[key];
form.appendChild(hiddenField);
}
}
document.body.appendChild(form);
form.submit();
},
},
});
</script>
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>