Ory Kratos: Headless Authentication, Identity and User Management¶
Authentication flows are quite common in the modern day software development. What we want from one authentication has a lot of overlapping funcionality with what our other applications need. Even across different industries, you can still see the same patterns apply when it comes to Identity and User Management.
Ory Kratos solves all that user management under one umbrella of identity server, providing a clean headless API that you can ship your own UI with. It empowers you to customize the frontend, while preserving the ever-common backend that is backed by the robust SQL database.
In this blog post, we will cover the introduction and basics of Ory Kratos, as well as the steps and guides to write your integration client.
If you've always wanted to stop reinventing the wheel, reduce code duplication and to follow security best practices, then Ory Kratos and this blog post is for you!
Kratos Auth¶
Over the entire course of my professional career, I have seen countless times where the application needed the same type and pattern of authentication protection.
A user signs up, logs in, logs out, and resets their password. The same idea, the same pattern, the same flow!
I have looked over different solutions and the way they handled their authentication system. Some are good, some are terrible, some are paid, some open-source.
This blog post is not here to tell you which to choose.
It's here to give you an idea of what Ory Kratos is, what problem it solves, and how you can use it to solve your own problem if you so choose to.
You should do your own research and decide for yourself whether or not what Ory Kratos provides fit your need; every problem's context is different and your technology stack may or may not be adaptable to Ory Kratos.
Who is this for?¶
The reality is, if you're reading this, you're likely an engineer of some sort. Perhaps an individual contributor, or maybe a decision maker somewhere down the organization hierarchy.
The guide to follow for the rest of this post is intuitive and easy to follow. If you've read any of the authentication RFCs, or worked with authentication systems before in anyway, you have a good understanding of what's about to come: what is and what isn't authentication that is.
I will mention the highlighting factors when it comes to theories, but this blog post is mainly hands-on with a lot of codes and examples.
Ory Kratos¶
We should set a clear objective of what we're trying to achieve here so that we can best prepare for the journey ahead.
The main goal is to write a frontend client for Ory Kratos so that the users of our application(s) can sign-up, sign-in, etc. The nature of the backend application (the app we are trying to protect behind authentication) doesn't matter here.
The user management and the database records of the identities will be kept on the Ory Kratos side and as such, any user-related authentication flow will be solely handled by Ory Kratos.
In a future post, we will learn how to protect the backend service in a private network and only send authenticated requests to the backend application using a combination of Ory Kratos1 and Ory Oathkeeper2.
Installation¶
This blog post does not make any assumptions on how you want to run Ory Kratos, nor its corresponding frontend code. That is your responsibility to figure out!
However, since for the purpose of demo we had to deploy both somewhere and somehow, the technology stack picked here is as follows. Feel free to adapt to your own stack:
- The Kratos server has been installed as a Helm installation3 on a Kubernetes cluster. The Kratos public endpoints are exposed to the internet using the Gateway API4 and with the help of Cilium. We have guides in our archive for Kubernetes and Cilium installation if you need further help.
- The source code for the frontend5 is written in pure Vanilla JavaScript, bundled with ViteJS6 and built with Bun7. I am by no means a frontender as you shall see shortly for yourself, however, the code is a Single Page Application without any JS framework; cause that's how Maximiliano Firtman8 taught the rest of us possible, among many disbeliefs!
- The CI takes care of the deployment to the GitHub Pages9. Both are free for public repositories.
With that somewhat unconventional stack, let's see how we can create our own custom UI for the Ory Kratos10.
Kratos Configuration¶
The first step when it comes to Ory Kratos, is to have your config file ready. That is the starting point you should worry about before starting anything else.
There are many different attributes you can configure in Ory Kratos. Some of which are required fields, all of which defines the way you want Kratos to work for you.
In this blog post, we can't cover all the attributes and the combination of different values you can assign to them. However, we will cover the essentials to get you going. You won't have a hard time following the rest for yourself (they have a decent documentation11).
To get started, and to have a complete reference of all the available keys, you can copy the entire configuration from the official documentation12 and modify and customize it as you see fit. Below is the screenshot of how to do that.
The basic configuration that can kick things off looks something like this:
identity:
schemas:
- id: users
url: https://gist.githubusercontent.com/developer-friendly-bot/c3ce3c0c1ee6e4e706d3773ad7067132/raw/217a01ef02d78d29dd844461917bc22a616892f4/kratos.identity-schema.json
default_schema_id: users
dsn: PLACEHOLDER
selfservice:
default_browser_return_url: https://ory.developer-friendly.blog
flows:
logout:
after:
default_browser_return_url: https://ory.developer-friendly.blog
registration:
ui_url: https://ory.developer-friendly.blog/register
enabled: true
login:
ui_url: https://ory.developer-friendly.blog/login
verification:
ui_url: https://ory.developer-friendly.blog/verify
use: link
enabled: true
recovery:
ui_url: https://ory.developer-friendly.blog/recovery
use: link
enabled: true
error:
ui_url: https://ory.developer-friendly.blog/
settings:
ui_url: https://ory.developer-friendly.blog/settings
methods:
link:
config:
base_url: https://auth.developer-friendly.blog
enabled: true
code:
enabled: false
password:
enabled: true
allowed_return_urls:
- https://ory.developer-friendly.blog
courier:
smtp:
from_address: no-reply@developer-friendly.blog
from_name: Developer Friendly
connection_uri: PLACEHOLDER
serve:
public:
cors:
allowed_origins:
- https://ory.developer-friendly.blog
enabled: true
base_url: https://auth.developer-friendly.blog
port: 4433
request_log:
disable_for_health: true
admin:
base_url: http://localhost:4434/
port: 4434
request_log:
disable_for_health: true
log:
format: json
level: info
secrets:
cookie:
- ABCDEFGHIJKLMNOP
cipher:
- ABCDEFGHIJKLMNOPQRSTUVWXYZ012345
default:
- ABCDEFGHIJKLMNOP
cookies:
path: /
same_site: None
domain: .developer-friendly.blog
session:
lifespan: 1h
cookie:
path: /
same_site: None
domain: .developer-friendly.blog
There is a lot to uncover in this configuration file, and believe me there are many others trimmed for the sake of brevity.
But, let's explain some of the highlights from this file.
Identity Schema¶
The first section defines what type of schema you want to use. This "schema" is the definition of your users signing up and their attributes saved in the database. This will be an HTML form with all the fields you like filled by your users.
You can have more than one schema definition, which is a perfect use case for having different types of users, e.g., admin, employee, customer, etc.13
For our simple use case, there's only one.
You can pass the identity schema definition from either a file, a remote URL, or even a base64 encoded string. The choice is yours. However, keep in mind that readability matters and you have to be able to make sense of the schema by looking at it and base64 is not it!
In essence, the following identity schema JSON definition will result in the HTML form you see in the next screenshot.
{
"$id": "default",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"traits": {
"additionalProperties": false,
"properties": {
"company_name": {
"default": "default",
"title": "Company Name",
"type": "string"
},
"email": {
"examples": [
"hi@developer-friendly.blog"
],
"format": "email",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"recovery": {
"via": "email"
},
"verification": {
"via": "email"
}
},
"title": "E-Mail",
"type": "string"
},
"first_name": {
"title": "First Name",
"type": "string"
},
"job_title": {
"default": "Not Set",
"title": "Job Title",
"type": "string"
},
"last_name": {
"title": "Last Name",
"type": "string"
}
},
"required": [
"email"
],
"type": "object"
}
},
"title": "User",
"type": "object"
}
UI URLs¶
This part of the configuration file defines the way you want Kratos to redirect requests to your frontend.
The path to your frontend endpoints are custom to your application; pick what's best for you. However, bear in mind that the Kratos server and your frontend application MUST be hosted under the same root domain.
The reason is for the cookies and the constraint around not being able to set a cookie from one domain to another; imagine if anyone could set a cookie on your domain from their own domain!
Cookie and Session¶
This section of the configuration file specifies the domain you want to set cookies for. It is essentially THE key to keeping consistency and correctness between the Ory Kratos server and your frontend. Else, they won't work!
The key point to keep in mind, again, is to host both the frontend and the Kratos server under the same root-level domain. For example:
-
auth.example.com
for Kratos andui.example.com
orexample.com
for the frontend are fine. -
something.com
for Kratos andsomething-else.com
for frontend are not.
We will provide more details on the cookie and the domain later in this post.
Kratos Deployment¶
Skip this section if you have Kratos deployed elsewhere or are using the Ory Network14 with a paid plan.
You don't have to self-host your Ory Kratos server if you don't want to. The Ory team provides a hosted version of Ory Kratos, as well as other Ory products on their Ory Network.
However, as of writing this blog post, they don't allow custom domains on their free version. Not having the same top-level root domain is a big no-no for Kratos and its UI and as such, we'll deploy the opensource version in our Kubernetes deployment.
If you need assistance setting up a Kubernetes cluster, follow one of our earlier guides. The main requirement, however, is that the cluster needs to be internet-facing.
We are using FluxCD CRDs here. If you're new to FluxCD, check out our earlier beginner's guide to get up to speed.
We are also using External Secrets Operator and cert-manager in this setup. We have guides on those as well, so feel free to check them out.
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: ory
spec:
interval: 60m
url: https://k8s.ory.sh/helm/charts
Kratos server is able to read the config file from the specified file, or from the environment variables15. Which is why we are capitalizing all the environments in the following ExternalSecret resource; remember all those values in our kratos/kratos-server-config.yml
where we passed PLACEHOLDER
as value!?
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: kratos-secrets
spec:
data:
- remoteRef:
key: /kratos/dsn
secretKey: DSN
- remoteRef:
key: /kratos/courier/smtp/connection-uri
secretKey: COURIER_SMTP_CONNECTION_URI
- remoteRef:
key: /kratos/secrets/cookie/0
secretKey: SECRETS_COOKIE_0
- remoteRef:
key: /kratos/secrets/cipher/0
secretKey: SECRETS_CIPHER_0
- remoteRef:
key: /kratos/secrets/default/0
secretKey: SECRETS_DEFAULT_0
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: aws-parameter-store
target:
creationPolicy: Owner
deletionPolicy: Retain
immutable: false
template:
mergePolicy: Replace
type: Opaque
The following Kustomization patches applied to the HelmRelease are just because of the lack of flexibility in the Ory Kratos' Helm chart. We have to manually pass some of the otherwise missing values.
apiVersion: helm.toolkit.fluxcd.io/v2beta2
kind: HelmRelease
metadata:
name: kratos
spec:
chart:
spec:
chart: kratos
sourceRef:
kind: HelmRepository
name: ory
version: 0.42.x
interval: 30m
postRenderers:
- kustomize:
patches:
- target:
kind: Deployment
name: kratos
patch: |
- op: add
path: /spec/template/spec/initContainers/0/envFrom
value:
- secretRef:
name: kratos-secrets
- target:
kind: Deployment
name: kratos
patch: |
- op: replace
path: /spec/template/spec/containers/0/args
value:
- serve
- all
- --config
- /var/lib/kratos/config/config.yml
- target:
kind: StatefulSet
name: kratos-courier
patch: |
- op: replace
path: /spec/template/spec/containers/0/args
value:
- courier
- watch
- --config
- /var/lib/kratos/config/config.yml
- --expose-metrics-port
- "4434"
releaseName: kratos
test:
enable: false
timeout: 5m
upgrade:
cleanupOnFail: true
crds: CreateReplace
remediation:
remediateLastFailure: true
values:
deployment:
extraVolumes:
- name: kratos-config
configMap:
name: kratos-config
items:
- key: kratos-server-config.yml
path: config.yml
extraVolumeMounts:
- name: kratos-config
mountPath: /var/lib/kratos/config
readOnly: true
statefulSet:
extraVolumes:
- name: kratos-config
configMap:
name: kratos-config
items:
- key: kratos-server-config.yml
path: config.yml
extraVolumeMounts:
- name: kratos-config
mountPath: /var/lib/kratos/config
readOnly: true
valuesFrom:
- kind: ConfigMap
name: kratos-config
kratos:
automigration:
enabled: true
type: initContainer
customArgs:
- migrate
- sql
- --read-from-env
- --yes
- --config
- /var/lib/kratos/config/config.yml
deployment:
environmentSecretsName: kratos-secrets
automigration:
customArgs:
- migrate
- sql
- --read-from-env
- --yes
- --config
- /var/lib/kratos/config/config.yml
statefulSet:
environmentSecretsName: kratos-secrets
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kratos
spec:
hostnames:
- auth.developer-friendly.blog
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: developer-friendly-blog
namespace: cert-manager
sectionName: https
rules:
- backendRefs:
- kind: Service
name: kratos-public
port: 80
filters:
- responseHeaderModifier:
set:
- name: Strict-Transport-Security
value: max-age=31536000; includeSubDomains; preload
type: ResponseHeaderModifier
matches:
- path:
type: PathPrefix
value: /
---
nameReference:
- kind: ConfigMap
version: v1
fieldSpecs:
- path: spec/valuesFrom/name
kind: HelmRelease
- path: /spec/values/deployment/extraVolumes/configMap/name
kind: HelmRelease
- path: /spec/values/statefulSet/extraVolumes/configMap/name
kind: HelmRelease
configurations:
- kustomizeconfig.yml
configMapGenerator:
- files:
- values.yaml=./helm-values.yml
- ./kratos-server-config.yml
name: kratos-config
resources:
- repository.yml
- release.yml
- externalsecret.yml
- httproute.yml
Finally, we will create this stack as follow:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: kratos
namespace: flux-system
spec:
force: false
interval: 5m
path: ./kratos
prune: true
sourceRef:
kind: GitRepository
name: flux-system
targetNamespace: default
wait: false
This is all the Kubernetes knowledge we will need for this blog post. Promise!
That is to say, if you're not a Kubernetes guy, don't worry. All you need from this step, is an internet-accessible Ory Kratos server hosted under the same top-level domain as your UI frontend.
Moving forward, we will only work on JavaScript, HTML, and CSS.
Frontend Code¶
If you've been waiting for the UI part, this is it!
At this point, we will shift our focus to the frontend code; The custom UI for our Kratos server.
This will be the page that our users will see once they open the application. Everything else, is the communication between this frontend and the Ory Kratos.
To start things off, we will need a couple of unavoidable static files for template and styling.
NOTE: I am by no means a frontender. That is not my strong suit. Yet the following SPA gets the job done. Believe me, I have tested it! And, if you see any flaw with the code style, or something that you don't like, the repository of this website is publicly available and welcomes your pull requests.
HTML¶
The app's starting page is the following index.html
file. It's simple, and that is its superpower.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ory Example - Developer Friendly</title>
<link rel="stylesheet" href="/assets/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="app.js" defer type="module"></script>
</head>
<body>
<header>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/login">Login</a></li>
<li><a href="/register">Register</a></li>
<li><a href="/verify">Verify</a></li>
<li><a href="/recovery">Recovery</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
</nav>
</header>
<main id="app"></main>
<footer>
<p>© <a href="https://developer-friendly.blog" target="_blank">Developer Friendly</a>. All rights reserved.</p>
<span><i class="fa-brands fa-github"></i><a href="https://github.com/developer-friendly/ory" target="_blank">Source Code</span></a>
</footer>
</body>
</html>
CSS¶
Beside the header, footer and the flex container for the form and table, there is nothing special about the styling either.
Click to expand
:root {
--primary-color: rgb(19, 18, 18);
--secondary-color: #6c757d;
--light-color: #f4f4f4;
--danger-color: #dc3545;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
font-family: Arial, sans-serif;
line-height: 1.6;
background-color: var(--light-color);
}
header {
background: var(--primary-color);
color: var(--light-color);
padding: 1rem 0;
text-align: center;
}
header nav ul {
list-style: none;
padding: 0;
display: flex;
justify-content: center;
}
header nav ul li {
margin: 0 1rem;
}
header nav ul li a {
color: var(--light-color);
text-decoration: none;
padding: 0.5rem 1rem;
}
header nav ul li a:hover {
background-color: var(--primary-color);
border-radius: 4px;
}
main {
flex: 1;
padding: 2rem;
text-align: center;
}
h1 {
margin-bottom: 1rem;
color: var(--primary-color);
}
form,
table {
max-width: 400px;
margin: 0 auto;
padding: 2rem;
background: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow-y: auto;
max-height: 80vh;
}
form label {
display: block;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
form input[type="text"],
form input[type="email"],
form input[type="password"] {
width: calc(100% - 22px);
padding: 12px;
margin-bottom: 1rem;
border: 1px solid var(--primary-color);
border-radius: 4px;
font-size: 16px;
}
button {
display: inline-block;
padding: 10px 20px;
font-size: 16px;
font-weight: bold;
color: var(--light-color);
background-color: var(--secondary-color);
border: none;
border-radius: 5px;
width: 100%;
max-width: 300px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
button:hover {
background-color: var(--primary-color);
transform: scale(1.05);
transition: transform 0.2s ease;
}
button:active {
background-color: var(--primary-color);
transform: scale(0.98);
}
button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.5);
}
button:disabled {
background-color: var(--secondary-color);
cursor: not-allowed;
}
footer {
background: var(--primary-color);
color: var(--light-color);
text-align: center;
padding: 1rem 0;
position: fixed;
width: 100%;
bottom: 0;
}
footer p {
margin: 0.5rem 0;
}
footer a {
color: var(--light-color);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
header nav ul {
flex-direction: column;
}
header nav ul li {
margin: 0.5rem 0;
}
}
.required {
color: var(--danger-color);
}
i {
margin-right: 0.3rem;
}
footer img {
margin-right: 0.3rem;
}
Flow 101¶
Now the main business logic of the app we're trying to build.
But, before we jump into code, here's the diagram you should be aware of when it comes to Ory Kratos.
This diagram has all the steps to initiate and complete a flow in Ory Kratos. And be very mindful of the word flow here cause this is the most important thing Kratos understands and responds with.
Anytime you need something from Ory Kratos, it will find its way, one way or another, through a flow.
There are different types of flows in Ory Kratos. Six to be specific:
- Login flow
- Registration flow
- Recovery flow
- Verification flow
- Settings flow
- Logout flow
These flows are something you should be aware of, and implement the frontend counterpart for. The backend is already taken care of by Ory Kratos.
Although, fret not, these flows are quite similar in nature and a general piece of code can handle most, if not all.
Implement One Flow For All¶
Any application will start with registration and that is where we start our journey.
In the registration flow, we are asking Ory Kratos what it takes for us to start and submit a registration request and Ory Kratos will respond with the fields you need to submit by consulting the very same identity schema we passed it earlier.
If you know curl
, this will be the registration flow in a nutshell:
cookie_jar=$(mktemp)
output="$(curl -sS --cookie $cookie_jar --cookie-jar $cookie_jar \
-H "accept: application/json" \
https://auth.developer-friendly.blog/self-service/registration/browser)"
csrf_token="$(echo $output | \
jq -r '.ui.nodes[] |
select(.attributes.name == "csrf_token") |
.attributes.value')"
email=hi@developer-friendly.blog
password=123456
action=$(echo $output | jq -r '.ui.action')
curl -XPOST -sS --cookie $cookie_jar --cookie-jar $cookie_jar \
-H "accept: application/json" \
-H "content-type: application/json" \
-d "{\"traits.email\":\"$email\",
\"password\":\"$password\",
\"csrf_token\":\"$csrf_token\",
\"method\":\"password\"}" \
$action
If that sounds too complicated to comprehend, don't worry. We'll break it down in our upcoming JavaScript code.
Remember the diagram we saw earlier between the frontend and the Ory Kratos? We will start by initiating a flow.
import { kratosHost } from "./config.js";
var fetchOptions = {
credentials: "include",
};
export async function initFlow(flow, extraHeaders = {}) {
return await fetch(
`${kratosHost}/self-service/${flow}/browser`,
{
...fetchOptions,
headers: {
...extraHeaders,
},
}
);
}
export async function getFlowInfo(flowId) {
return await fetch(`${kratosHost}/self-service/browser/flows?id=${flowId}`, {
...fetchOptions,
});
}
Pay close attention that we are explicitly asking the fetch API to include the credentials in our call to the Ory Kratos server. Ignoring that will result in the required cookies not being sent to the server and your flow will never go pass the initial step!
import { initFlow } from "./utils.js";
async function createForm(flowName) {
var flowInfo, flowJson;
var headers = {
accept: "application/json",
};
flowInfo = await initFlow(flowName, headers);
flowJson = await flowInfo.json();
// build the HTML form from the json and return it
return "TODO";
}
export default createForm;
Note the accept
header we pass to the fetch API on line 8. This will make sure that the Kratos server responds with the JSON and won't redirect us to the same URL as we are in right now. Ignore doing so and it will result in double redirection to the current web address, which will nullify the origin
header and you'll face a CORS error16.
At this point, we have the JSON response from the Kratos server. We have to use that information to dynamically create an HTML form to render for the user.
In order to be able to parse the JSON and render an HTML form from it, you have to know what type of JSON response you can expect from the Kratos server.
The JSON response from the registration flow looks something like the following. If you pay close attention, you will notice that the JSON response resembles a lot like the identity schema we passed to the Kratos server earlier.
Click to expand
{
"id": "89b42a09-c821-453b-8207-e298a578a243",
"type": "browser",
"expires_at": "2024-05-19T14:32:09.717435811Z",
"issued_at": "2024-05-19T13:32:09.717435811Z",
"request_url": "https://auth.developer-friendly.blog/self-service/registration/browser",
"ui": {
"action": "https://auth.developer-friendly.blog/self-service/registration?flow=89b42a09-c821-453b-8207-e298a578a243",
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "0eshsqW82u6puCW73HqPFuK/PO0VyCW3ZMcQwmMjMv21MuLCzbADsPx8BpDBuXpnyOFsA0LYzC1Kuk1z4+KFxA==",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.first_name",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070002,
"text": "First Name",
"type": "info",
"context": {
"title": "First Name"
}
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.last_name",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070002,
"text": "Last Name",
"type": "info",
"context": {
"title": "Last Name"
}
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.email",
"type": "email",
"required": true,
"autocomplete": "email",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070002,
"text": "E-Mail",
"type": "info",
"context": {
"title": "E-Mail"
}
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.company_name",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070002,
"text": "Company Name",
"type": "info",
"context": {
"title": "Company Name"
}
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.job_title",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070002,
"text": "Job Title",
"type": "info",
"context": {
"title": "Job Title"
}
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
}
}
]
},
"organization_id": null,
"state": "choose_method"
}
By visualizing the JSON response, we can see that the .ui.nodes
has all the fields we specified in our identity schema. We would only need to use this info to build the HTML form.
This step is a lot subjective and you can get very creative. Yet we simply create a bunch of inputs and labels inside an HTML form.
import { kratosHost } from "./config.js";
var fetchOptions = {
credentials: "include",
};
export async function initFlow(flow, extraHeaders = {}) {
return await fetch(`${kratosHost}/self-service/${flow}/browser`, {
...fetchOptions,
headers: {
...extraHeaders,
},
});
}
export async function getFlowInfo(flowId) {
return await fetch(`${kratosHost}/self-service/browser/flows?id=${flowId}`, {
...fetchOptions,
});
}
export function createFlowForm(flowJson, submitLabel = "Submit") {
var form = document.createElement("form");
form.action = flowJson.ui.action;
form.method = flowJson.ui.method;
var autofocus = false;
var passwordField, passwordLabel;
flowJson.ui.nodes.forEach(function parseNode(node) {
if (node.type == "input") {
var attr = node.attributes;
var isSubmit = attr.type == "submit";
var isPassword = attr.type == "password";
var input = document.createElement("input");
var label = document.createElement("label");
if (isSubmit) {
input = document.createElement("button");
}
if (node.meta && node.meta.label && node.meta.label.text) {
label.innerText = node.meta.label.text;
}
input.type = attr.type;
input.name = attr.name;
input.value = attr.value || "";
if (isSubmit) {
var span = document.createElement("span");
span.innerText = submitLabel;
input.appendChild(span);
}
if (attr.required) {
input.required = true;
if (attr.type != "hidden") {
var required = document.createElement("span");
required.innerText = " *";
required.className = "required";
label.appendChild(required);
}
}
if (attr.disabled) {
input.disabled = true;
}
if (!isSubmit && !isPassword) {
form.appendChild(label);
}
if (!autofocus && input.type != "hidden") {
input.autofocus = true;
autofocus = true;
}
if (!isPassword) {
form.appendChild(input);
} else {
passwordField = input;
passwordLabel = label;
}
}
});
if (passwordField) {
form[form.length - 1].insertAdjacentElement("beforebegin", passwordLabel);
form[form.length - 1].insertAdjacentElement("beforebegin", passwordField);
}
return form;
}
Not much is to say regarding the logic happening here. However, notice that we are intentionally deferring the creation of the password input until the very end. That is just a bit unfortunate since Kratos' JSON response does not send the orders of inputs as we'd like it; I would expect Kratos to do this out of the box!
import { initFlow, getFlowInfo, createFlowForm } from "./utils.js";
async function createForm(flowId, flowName) {
var flowInfo, flowJson;
var headers = {
accept: "application/json",
};
if (flowId) {
flowInfo = await getFlowInfo(flowId);
} else {
flowInfo = await initFlow(flowName, headers);
}
flowJson = await flowInfo.json();
var form = createFlowForm(flowJson);
return form;
}
export default createForm;
We have most of what we need as far as JavaScript goes, yet there is still the entrypoint as well as the Vanilla JS router to take care of.
import CreateForm from "./flow.js";
var Router = {
init: async function init_() {
document.querySelectorAll("a").forEach((a) => {
a.addEventListener("click", function overrideNavlinks(event) {
event.preventDefault();
var href = event.target.getAttribute("href");
Router.go(href);
});
});
window.addEventListener("popstate", (event) => {
Router.go(event.state.route, false);
return;
});
var route = location.pathname + location.search;
await Router.go(route);
},
go: async function go_(route, addToHistory = true) {
console.log("Navigating to", route);
if (addToHistory) {
history.pushState({ route }, "", route);
}
var pageElement;
// In case the browser started the login somewhere else with the Kratos server
var flowId = new URL(location.href).searchParams.get("flow");
switch (true) {
case route.startsWith("/login"):
pageElement = await CreateForm(flowId, "login");
break;
case route.startsWith("/register"):
pageElement = await CreateForm(flowId, "registration");
break;
case route.startsWith("/verify"):
pageElement = await CreateForm(flowId, "verification");
break;
case route.startsWith("/recovery"):
pageElement = await CreateForm(flowId, "recovery");
break;
case route.startsWith("/settings"):
pageElement = await CreateForm(flowId, "settings");
break;
default:
pageElement = document.createElement("h1");
pageElement.textContent = `Default Implementation for Page ${route}`;
}
if (pageElement) {
var app = document.getElementById("app");
app.innerHTML = "";
app.appendChild(pageElement);
}
window.scrollX = 0;
},
};
export default Router;
import Router from "./src/router.js";
window.addEventListener("DOMContentLoaded", async function initRouter() {
console.log("DOM is ready");
await Router.init();
});
document.addEventListener(
"focus",
function (event) {
if (
event.target.tagName === "INPUT" ||
event.target.tagName === "TEXTAREA" ||
event.target.tagName === "SELECT"
) {
event.target.scrollIntoView({ behavior: "smooth", block: "center" });
}
},
true
);
Bundling the Frontend¶
We mentioned that we are using ViteJS6 for bundling our code. We don't do a lot of crazy stuff in this code. Yet one crucial feature we need (not present in VanillaJS) is the ability to override the variables from the environment variables. That is where ViteJS provides a great hand.
export const kratosHost = import.meta.env.VITE_KRATOS_HOST || "http://localhost:4433";
export const baseUrl = import.meta.env.VITE_BASE_URL || "http://localhost:8080";
This way, whenever we want to customize the target Kratos server URL, all we have to do is to pass it as an environment variable as below before building the code:
Building the frontend¶
For this project, we have picked Bun7 as our build tool. It's simple & fast and does the job well.
{
"dependencies": {
"vite": "^5.2.11"
},
"devDependencies": {
"@types/bun": "latest",
"globals": "^15.2.0"
},
"module": "app.js",
"name": "ory-example",
"scripts": {
"build": "vite build",
"dev": "vite",
"lint": "eslint .",
"preview": "vite preview"
},
"type": "module"
}
CI Definition¶
When our project is ready to be published, we will use GitHub Actions to build and deploy the frontend to the GitHub Pages.
name: ci
concurrency:
group: ci-${{ github.event_name }}-${{ github.ref_name }}
cancel-in-progress: true
on:
push:
branches:
- main
permissions:
contents: read
pages: write
id-token: write
env:
VITE_KRATOS_HOST: ${{ vars.KRATOS_HOST }}
jobs:
deploy:
defaults:
run:
working-directory: ./frontend
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build
run: bun run build
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
name: frontend-latest
path: ./frontend/dist
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
with:
artifact_name: frontend-latest
With this workflow, upon every push to the main
branch we will have our application ready to be served on the GitHub Pages. In our case, this repository is public and there is no charge for the CI, as well as for the GitHub Pages.
GitHub Pages SPA Hack¶
As of writing this blog post, GitHub Pages does not natively support Single Page Applications17. This is a blocker for our application since it is a SPA. To get around that, we will get help from the community to come up with something a bit creative18.
The idea is to create a custom 404.html
which will have enough JavaScript code to redirect the page to our SPA's index.html
, having it's URI as query parameter. On the other hand, the index.html
will also include a JavaScript code to parse the query parameter and let our Vanilla JS router take care of the rest.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Developer Friendly</title>
<script type="text/javascript">
var pathSegmentsToKeep = 0;
var l = window.location;
l.replace(
l.protocol +
"//" +
l.hostname +
(l.port ? ":" + l.port : "") +
l.pathname
.split("/")
.slice(0, 1 + pathSegmentsToKeep)
.join("/") +
"/?/" +
l.pathname
.slice(1)
.split("/")
.slice(pathSegmentsToKeep)
.join("/")
.replace(/&/g, "~and~") +
(l.search ? "&" + l.search.slice(1).replace(/&/g, "~and~") : "") +
l.hash
);
</script>
</head>
<body></body>
</html>
NOTE: The parsing of the query parameter in the index.html
has to happen before the our own JS code is loaded. This allows for our router not to worry about this hacky redirection.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ory Example - Developer Friendly</title>
<link rel="stylesheet" href="/assets/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script type="text/javascript">
(function(l) {
if (l.search[1] === '/' ) {
var decoded = l.search.slice(1).split('&').map(function(s) {
return s.replace(/~and~/g, '&')
}).join('?');
window.history.replaceState(null, null,
l.pathname.slice(0, -1) + decoded + l.hash
);
}
}(window.location))
</script>
<script src="app.js" defer type="module"></script>
</head>
<body>
<header>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/login">Login</a></li>
<li><a href="/register">Register</a></li>
<li><a href="/verify">Verify</a></li>
<li><a href="/recovery">Recovery</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
</nav>
</header>
<main id="app"></main>
<footer>
<p>© <a href="https://developer-friendly.blog" target="_blank">Developer Friendly</a>. All rights reserved.</p>
<span><i class="fa-brands fa-github"></i><a href="https://github.com/developer-friendly/ory" target="_blank">Source Code</span></a>
</footer>
</body>
</html>
And now we need to include the new 404.html
as an asset in ViteJS config:
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
build: {
rollupOptions: {
input: {
main: path.resolve(__dirname, "index.html"),
404: path.resolve(__dirname, "404.html"),
},
},
},
});
Logout Flow¶
Among the flows we mentioned earlier, all can be handled by our "general" flow implementation. However, the logout flow is a bit different19. It requires its own implementation as there will no longer be a form. You may want to include a confirmation page for your app but that's out of scope as far as Kratos server is concerned.
import { initFlow } from "./utils.js";
async function createForm() {
var flowInfo;
var extraHeaders = {
accept: "application/json",
};
flowInfo = await initFlow("logout", extraHeaders);
var flowJson = await flowInfo.json();
await fetch(flowJson.logout_url, { credentials: "include" });
window.location.href = "/login";
}
export default createForm;
Bonus: GitHub Pages Custom Domain¶
The application we have deployed in the GitHub Pages so far is accessible with the URL assigned by GitHub to each repository's Pages instance.
https://USERNAME.github.io/REPOSITORY
In our case, that turns out to be the following format:
https://developer-friendly.github.io/ory
There is nothing wrong with this URL. However, in a serious production application, you would want to have your own domain name. This is where the custom domain name comes in; And unlike other service providers, GitHub does not charge you extra for this feature.
The DNS record we want to create should be the following:
Record Type | Record Name | Target Value |
---|---|---|
CNAME | ory.developer-friendly.blog | developer-friendly.github.io |
And since the developer-friendly.blog domain is hosted on Cloudflare, here's how the IaC will look like for such a change.
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.33"
}
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
data "cloudflare_zone" "devfriend_blog" {
name = "developer-friendly.blog"
}
resource "cloudflare_record" "ory" {
zone_id = data.cloudflare_zone.devfriend_blog.id
name = "ory"
proxied = true
ttl = 1
type = "CNAME"
value = "developer-friendly.github.io"
}
Now, let's apply this stack using OpenTofu:
Conclusion¶
That wraps up all we had to say for this blog post. The main objective was to create a custom frontend for the Ory Kratos server.
As you saw in the post, the root domain for both the Kratos server and the frontend should be the same for the cookies to work.
Among many benefits that Kratos brings to the table, many years of development and feedback from the community, following security best practices based on the well-known recommendations and standards20, not reinventing the wheel, and separation of concern are just a few to name.
I honestly rarely think of writing my own authentication and identity management system these days anymore. Cause Kratos does a perfect job at what it was meant to. I invite you to also tip your toes and give it a fair shot if you haven't already.
I know many folks might prefer other alternatives like Keycloak, Auth0, or Firebase. And that's perfectly fine. The choice is yours to make. However, don't let that stop you from exploring what Ory Kratos has to offer.
In a future post, I will explore more of the Ory products and the intersection of them all when it comes to delivering a robust auth solution on top of your application logic.
I wish you have gained something from this post, and I hope you forgive me for the possible awful frontend code an SRE guy has provided before your eyes.
I have learned a lot from Kyle Simpson's21 You Don't Know JS series and most of the code you've seen here are following the patterns he teaches. That is to say that I have no regret not using arrow functions, avoiding the overloaded use of const
keyword, and not using triple equals ===
everywhere.
Until next time , ciao and happy hacking!
If you enjoyed this blog post, consider sharing it with these buttons . Please leave a comment for us at the end, we read & love 'em all.
Share on Share on Share on Share on
-
https://www.ory.sh/docs/kratos/bring-your-own-ui/custom-ui-overview ↩
-
https://www.ory.sh/docs/kratos/manage-identities/identity-schema ↩
-
https://stackoverflow.com/questions/30193851/ajax-call-following-302-redirect-sets-origin-to-null ↩
-
https://www.ory.sh/docs/kratos/self-service/flows/user-logout ↩