Managing Sessions
Learn how to use can/session to manage user session state in CanJS apps.
Introduction
A "session" refers to the period of time when a user has logged into an application. To begin sessions users submit login requests to access restricted data or features. The response from a login request contains a piece of information that is passed in future requests to identify them as being from a particular user. This piece of information is called a session token and is typically a string, either held directly by the application or in a cookie held by the browser. The can/session behavior assists in managing the lifecycle of user sessions and accessing any session-related data.
This guide will explain how:
- the can/session behavior makes session management easier
- can/session expects your backend to work by default
- to use can/session with cookies
- to use can/session with application held tokens
- to initialize can/session manually if you know you have an active session
If you prefer a video introduction to this material, the basics of this guide are also covered in a presentation from our monthly online meetup:
Benefits Of The can/session Behavior
can/session makes it easy to access and load the current session. It adds the following properties to the connection model constructor to allow developers to access the current session and any pending request for the current session:
// a reference to the current session or undefined if no session is available
// yet
Session.current;
// a Promise representing the request for the current session. resolves when a
// session is available or rejects if there is no ongoing session.
Session.currentPromise;
The first time either current
or currentPromise
is accessed in the application, a request is made to verify if there is an ongoing session. If there is no ongoing session current
will remain undefined and currentPromise
will reject. The user will need to log in to start a session.
can/session adds methods to the connection model to make starting sessions & ending sessions easy:
// logging in to start a session
Session.save({ username: 'nils', password: 'foobar' });
// logging out to end a session
Session.current.destroy();
The following sections demonstrate how to configure the behavior and apply these benefits to your application.
Expected Backend Structure
The can/session behavior expects that your backend will have a "session" endpoint (e.g /api/session
) that responds to three different HTTP request types:
- A
GET
request is made to the session endpoint to see if the user still has an active session. This is done when first loading the application. The server should validate any session token provided, which may be a cookie or an HTTP header. If the session is still valid, the server should respond with a 200 response and any session metadata as the response body. If the session is not valid, or no token is provided, the server should respond with a 401 response, indicating to the UI that the user must login & start a new session.
- A
POST
request is made to the session endpoint when a user logs in and begins a new session. This request will include whatever login info a user has provided, typically a username & password. The backend should validate that login info, and if successful, respond with a 200 response that includes a new session token (either as a cookie or a property in the response body), along with any session metadata in the response body. If the login information is invalid, the server should respond with a 401 response, indicating to the UI that the login information is not valid and the user should try again.
- A
DELETE
request is made to the session endpoint when a user logs out and ends their session. This request will include a session token, which should be invalidated by the backend to end the session. How this is done will vary between the sort of tokens implemented by your backend architecture. If the logout is successful, the server should respond with a 200 response that removes the session cookie (if cookies are being used). If the logout is unsuccessful the server should respond with a 4xx response, indicating to the UI that something has gone wrong during the logout and the user's session is still active.
If your backend uses multiple endpoints or different HTTP request types to implement these three scenarios, you can configure the behavior to accommodate that. This is explained in the following section.
Configuring The Behavior
The connection that includes the can/session behavior should always include the following configuration parameters:
Map
configures what DefineMap constructor will represent the session and will have thecurrent
¤tPromise
properties added to it.
url
configures the path of the session endpoint used during verification (getData
), login (createData
) and logout (destroyData
). Even if you use the same endpoint for those three cases you will need to explicitly set the path fordestroyData
andgetData
. Unless these are set, the data/url behavior tries to include an id as part of the url. Since sessions, unlike other models, don't typically have an id this will cause an error.
A typical example of configuration:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session', // endpoint
getData: '/api/session', // endpoint when validating
destroyData: '/api/session', // endpoint when logging out
}
});
Session.connection.init();
class TodoLogin extends StacheElement {
static view =`
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
width: 93%;
padding: 3%;
margin-bottom: 20px;
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
</style>
An example of a configuration that uses multiple session endpoints:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
getData: 'GET /api/validate_session',
createData: 'POST /api/login',
destroyData: 'POST /api/logout',
}
});
Session.connection.init();
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
function setupFixtures() {
fixture("GET /api/validate_session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/login", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("POST /api/logout", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
width: 93%;
padding: 3%;
margin-bottom: 20px;
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
</style>
Sessions Via Cookies
Cookies are an excellent way to store session tokens since when properly configured, they're very secure and require little application-level code to utilize. Storing tokens as cookies is generally regarded as a best practice.
Initializing The App & Depending On An Active Session
Usually, when storing tokens in cookies, they're stored as an "httponly" cookie, which prevents JavaScript from accessing it. This is a useful security feature in preventing XSS (Cross Site Scripting) and similar attacks. Due to this, when your application initially loads it won't immediately know if there is an ongoing user session since your app JS can't tell if the browser currently has a session token or not. The page will need to make a request to see if a session is active or not.
Thus to verify if there's an ongoing user session, the app should access Session.currentPromise
, and see if it resolves successfully. There are several places in your application you might need an active session and choose to do this:
- in your view before rendering components that make requests to restricted services or uses the session metadata
- in the view model of a component that makes requests to restricted services or uses the session metadata
- in the
beforeSend
callback of a connection to restricted services
Here, we'll show examples of all three dependencies. The third one is of particular importance to the application held tokens section since it's necessary for that case.
Depending On Session In A View
In a component's view you could depend on Session.currentPromise
directly like this:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session', // endpoint
getData: '/api/session', // endpoint when validating
destroyData: '/api/session', // endpoint when logging out
}
});
Session.connection.init();
class TodoLogin extends StacheElement {
static view =`
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
width: 93%;
padding: 3%;
margin-bottom: 20px;
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
</style>
This is a good option when you can make this dependency high in the component hierarchy, toggling several session-dependant components at once. In cases where a rendered component should determine for itself if a session is active, rather than depending on a parent component to check, one of the following two techniques should be used.
Depending On Session In A View Model
In a components view model, you may use currentPromise
in computed properties like this:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
restModel,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session',
getData: '/api/session',
destroyData: '/api/session',
}
});
Session.connection.init();
const Todo = DefineMap.extend('Todo', {
body: 'string',
completed: 'boolean'
});
Todo.connection = restModel({
Map: Todo,
url: '/api/todo'
});
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
<todo-list></todo-list>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
class TodoList extends StacheElement {
static view = `
<h3>Todos:</h3>
<ol>
{{#each (this.todos)}}
<li>
<input type="checkbox" checked:bind="this.completed" />
{{ this.body }}
</li>
{{/each}}
</ol>
`;
static props = {
todos: {
async(resolve) {
Session.currentPromise.then(() => {
Todo.getList({}).then(resolve);
});
return [];
}
},
};
}
customElements.define("todo-list", TodoList);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
fixture("GET /api/todo", function () {
const session = getSession();
if (session) {
return todos;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
const todos = [
{body: 'buy milk', completed: true},
{body: 'pick up dry cleaning', completed: false},
{body: 'call mom', completed: false},
{body: 'clean basement', completed: false},
];
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
padding: 3%;
margin-bottom: 20px;
&:not([type=checkbox]) {
width: 93%;
}
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
todo-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
This is a good option for making a single request dependant on an active session. However, if you use this model (e.g Todo
) in many places, making this dependency in each place is a lot of extra code. Additionally, application held token scenarios need a way to add the token from the session to the request. In those cases, you should depend on the session as part of the connection.
Depending On Session In A Connection
In the beforeSend
callback for a restricted resource you may depend on currentPromise
like this:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
restModel,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session',
getData: '/api/session',
destroyData: '/api/session',
}
});
Session.connection.init();
const Todo = DefineMap.extend('Todo', {
body: 'string',
completed: 'boolean'
});
Todo.connection = restModel({
Map: Todo,
url: {
resource: '/api/todo',
beforeSend: () => {
return Session.currentPromise;
}
}
});
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
<todo-list></todo-list>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
class TodoList extends StacheElement {
static view = `
<h3>Todos:</h3>
<ol>
{{#each (this.todos)}}
<li>
<input type="checkbox" checked:bind="this.completed" />
{{ this.body }}
</li>
{{/each}}
</ol>
`;
static props = {
todos: {
async(resolve) {
Todo.getList({}).then(resolve);
return [];
}
},
};
}
customElements.define("todo-list", TodoList);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
fixture("GET /api/todo", function () {
const session = getSession();
if (session) {
return todos;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
const todos = [
{body: 'buy milk', completed: true},
{body: 'pick up dry cleaning', completed: false},
{body: 'call mom', completed: false},
{body: 'clean basement', completed: false},
];
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
padding: 3%;
margin-bottom: 20px;
&:not([type=checkbox]) {
width: 93%;
}
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
todo-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
One advantage of this option is that it keeps the dependency on the session contained to the definition of the connection. This is cleaner than depending on the session in the view model where the connection is used, or in the view before a component making a request is rendered. In app-held token scenarios, this option must be used since beforeSend
is where the token is added to the request headers.
Note: Since
Session.currentPromise
only makes a request the first time it's accessed, all the components that make requests for restricted data can use it without worrying about multiple requests happening unintentionally.
In all the above examples, after the currentPromise
resolves, properties like isResolved
& todos
will recalculate (and emit updates), rendering new components or making requests. If currentPromise
rejects, the request to verify the active session has failed, typically because there is no active session. At this point, the user should be prompted by the app to log in.
Logging In
The can/session
behavior makes it easy to log in and start a session. All you have to do is save a new instance of the session model when the user has provided their login details:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
restModel,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session',
getData: '/api/session',
destroyData: '/api/session',
}
});
Session.connection.init();
const Todo = DefineMap.extend('Todo', {
body: 'string',
completed: 'boolean'
});
Todo.connection = restModel({
Map: Todo,
url: '/api/todo'
});
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
<todo-list></todo-list>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
class TodoList extends StacheElement {
static view = `
<h3>Todos:</h3>
<ol>
{{#each (this.todos)}}
<li>
<input type="checkbox" checked:bind="this.completed" />
{{ this.body }}
</li>
{{/each}}
</ol>
`;
static props = {
todos: {
async(resolve) {
Session.currentPromise.then(() => {
Todo.getList({}).then(resolve);
});
return [];
}
},
};
}
customElements.define("todo-list", TodoList);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
fixture("GET /api/todo", function () {
const session = getSession();
if (session) {
return todos;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
const todos = [
{body: 'buy milk', completed: true},
{body: 'pick up dry cleaning', completed: false},
{body: 'call mom', completed: false},
{body: 'clean basement', completed: false},
];
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
padding: 3%;
margin-bottom: 20px;
&:not([type=checkbox]) {
width: 93%;
}
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
todo-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
After the Promise returned by the .save
method completes Session.current
is set to the new instance session
and Session.currentPromise
is set to a resolved Promise that returns session
. Any components using current
or currentPromise
will notice this change and update their view or make requests for restricted services.
Making Requests
In the cookie scenario making requests on restricted services requires no special effort. The browser is responsible for sending the cookie containing the token as part of appropriate requests, so requests are made as if it were for any other endpoint:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
restModel,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session',
getData: '/api/session',
destroyData: '/api/session',
}
});
Session.connection.init();
const Todo = DefineMap.extend('Todo', {
body: 'string',
completed: 'boolean'
});
Todo.connection = restModel({
Map: Todo,
url: '/api/todo'
});
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
<todo-list></todo-list>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
class TodoList extends StacheElement {
static view = `
<h3>Todos:</h3>
<ol>
{{#each (this.todos)}}
<li>
<input type="checkbox" checked:bind="this.completed" />
{{ this.body }}
</li>
{{/each}}
</ol>
`;
static props = {
todos: {
async(resolve) {
Session.currentPromise.then(() => {
Todo.getList({}).then(resolve);
});
return [];
}
},
};
}
customElements.define("todo-list", TodoList);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
fixture("GET /api/todo", function () {
const session = getSession();
if (session) {
return todos;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
const todos = [
{body: 'buy milk', completed: true},
{body: 'pick up dry cleaning', completed: false},
{body: 'call mom', completed: false},
{body: 'clean basement', completed: false},
];
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
padding: 3%;
margin-bottom: 20px;
&:not([type=checkbox]) {
width: 93%;
}
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
todo-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
Logging Out
Eventually, a user will want to stop making requests and end their session, this is quite easy as well. When a user initiates the logout code like the following must be run:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
restModel,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session',
getData: '/api/session',
destroyData: '/api/session',
}
});
Session.connection.init();
const Todo = DefineMap.extend('Todo', {
body: 'string',
completed: 'boolean'
});
Todo.connection = restModel({
Map: Todo,
url: '/api/todo'
});
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
<todo-list></todo-list>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
class TodoList extends StacheElement {
static view = `
<h3>Todos:</h3>
<ol>
{{#each (this.todos)}}
<li>
<input type="checkbox" checked:bind="this.completed" />
{{ this.body }}
</li>
{{/each}}
</ol>
`;
static props = {
todos: {
async(resolve) {
Session.currentPromise.then(() => {
Todo.getList({}).then(resolve);
});
return [];
}
},
};
}
customElements.define("todo-list", TodoList);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
fixture("GET /api/todo", function () {
const session = getSession();
if (session) {
return todos;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
const todos = [
{body: 'buy milk', completed: true},
{body: 'pick up dry cleaning', completed: false},
{body: 'call mom', completed: false},
{body: 'clean basement', completed: false},
];
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
padding: 3%;
margin-bottom: 20px;
&:not([type=checkbox]) {
width: 93%;
}
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
todo-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
After the logout completes Session.current
will be set to undefined and Session.currentPromise
will be set to a rejected promise. Due to this change, properties dependent on the session will recalculate and return to a logged out state. A user must then login anew to update current
& currentPromise
and resume using the application.
Example
The following is the full example of using cookie-based sessions:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
restModel,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session',
getData: '/api/session',
destroyData: '/api/session',
}
});
Session.connection.init();
const Todo = DefineMap.extend('Todo', {
body: 'string',
completed: 'boolean'
});
Todo.connection = restModel({
Map: Todo,
url: '/api/todo'
});
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
<todo-list></todo-list>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
class TodoList extends StacheElement {
static view = `
<h3>Todos:</h3>
<ol>
{{#each (this.todos)}}
<li>
<input type="checkbox" checked:bind="this.completed" />
{{ this.body }}
</li>
{{/each}}
</ol>
`;
static props = {
todos: {
async(resolve) {
Session.currentPromise.then(() => {
Todo.getList({}).then(resolve);
});
return [];
}
},
};
}
customElements.define("todo-list", TodoList);
function setupFixtures() {
fixture("GET /api/session", function (request, response) {
const session = getSession();
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
document.cookie = `SESSION-TOKEN=${btoa(JSON.stringify({email, password}))}`;
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
fixture("GET /api/todo", function () {
const session = getSession();
if (session) {
return todos;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
}
// get browser cookies as a map from key to value
function getCookieMap() {
return document.cookie.split(';').map(cookie => cookie.split('=')).reduce((map, [key, value]) => {
map[key] = value;
return map;
}, {});
}
// get the decoded session cookie
function getSession() {
try {
const token = getCookieMap()['SESSION-TOKEN'];
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
const todos = [
{body: 'buy milk', completed: true},
{body: 'pick up dry cleaning', completed: false},
{body: 'call mom', completed: false},
{body: 'clean basement', completed: false},
];
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
padding: 3%;
margin-bottom: 20px;
&:not([type=checkbox]) {
width: 93%;
}
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
todo-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
Sessions Via Application Held Tokens
Sessions are easiest to implement with cookies since the browser takes care of securely storing the session token and sending the token with requests. However you may be working with an API that requires the session token to be sent as an Authorization
header in which case your application needs to hold the session token and add it to requests.
The Dangers Of Application Held Tokens
The use of application held tokens, in general, is considered a hazardous practice since your token is accessible by any script on the page. A malicious script may steal the token and impersonate the user. If you must use application held tokens, avoid the temptation to persist the token by storing it in browser LocalStorage (or SessionStorage) for reuse during the next visit to the application by a user. Storing tokens in a widely used location like this may increase the likelihood that they're stolen by a malicious script.
Difference From Cookie Held Tokens
From a code perspective, the difference from a cookie-based scenario is the requirement to add the token to requests manually, rather than letting the browser do it for you. When initializing an app using app-held tokens you'll typically use the third scenario described above. You'll access Session.currentPromise
in the beforeSend
handler of requests for restricted data, which looks something like this:
<todo-login></todo-login>
<script type="module">
import {
connect,
connectCanSession,
fixture,
restModel,
type,
DefineMap,
StacheElement,
} from "//unpkg.com/can@5/ecosystem.mjs";
setupFixtures();
const Session = DefineMap.extend('Session', {});
Session.connection = [
connect.base,
connect.dataUrl,
connect.constructor,
connect.canMap,
connectCanSession
].reduce((conn, behavior) => behavior(conn), {
Map: Session,
url: {
resource: '/api/session',
getData: '/api/session',
destroyData: '/api/session',
}
});
Session.connection.init();
const Todo = DefineMap.extend('Todo', {
body: 'string',
completed: 'boolean'
});
Todo.connection = restModel({
Map: Todo,
url: {
resource: '/api/todo',
beforeSend: (xhr) => {
return Session.currentPromise.then(() => {
xhr.setRequestHeader('Authorization', `Bearer ${Session.current.token}`);
});
}
}
});
class TodoLogin extends StacheElement {
static view = `
{{# if (this.Session.currentPromise.isResolved) }}
<p class="welcome-message">
Welcome {{ Session.current.email }}.
<a href="javascript://" on:click="this.logOut()">Log out</a>
</p>
<todo-list></todo-list>
{{/ if }}
{{# if (this.Session.currentPromise.isPending) }}
Loading User...
{{/ if }}
{{# if (this.Session.currentPromise.isRejected) }}
<form on:submit="this.logIn(scope.event)">
<h2>Log In</h2>
<input placeholder="email" value:to="this.email" />
<input type="password" placeholder="password"
value:to="this.password" />
<button>Log In</button>
{{# if (this.logInError) }}
<div class="error">{{ this.logInError.message }}</div>
{{/ if }}
<aside>
Login with the following account details:<br/>
Email: nils@bitovi.com<br/>
Password: abc123<br/>
</aside>
</form>
{{/ if }}
`;
static props = {
email: String,
password: String,
logInError: type.Any,
};
get Session() {
return Session;
}
logOut() {
Session.current.destroy();
}
logIn(event) {
event.preventDefault();
this.logInError = null;
const session = new Session({
email: this.email,
password: this.password
});
session.save().catch((error) => {
this.logInError = error;
});
}
}
customElements.define("todo-login", TodoLogin);
class TodoList extends StacheElement {
static view = `
<h3>Todos:</h3>
<ol>
{{#each (this.todos)}}
<li>
<input type="checkbox" checked:bind="this.completed" />
{{ this.body }}
</li>
{{/each}}
</ol>
`;
static props = {
todos: {
async(resolve) {
Todo.getList({}).then(resolve);
return [];
}
},
};
}
customElements.define("todo-list", TodoList);
function setupFixtures() {
fixture("GET /api/session", function (request, response, headers) {
const session = getSession(headers);
if (session) {
response(session);
} else {
response(404, {message: "No session"}, {}, "unauthorized");
}
});
fixture("POST /api/session", function (request, response) {
const email = request.data.email;
const password = request.data.password;
if (
email === 'nils@bitovi.com' &&
password === 'abc123'
) {
request.data.token = btoa(JSON.stringify({email, password}));
return request.data;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
fixture("DELETE /api/session", function () {
document.cookie = 'SESSION-TOKEN=';
return {};
});
fixture("GET /api/todo", function (request, response, headers) {
const session = getSession(headers);
if (session) {
return todos;
} else {
response(401, {message: "Unauthorized"}, {}, "unauthorized");
}
});
}
// get the decoded session header
function getSession(headers) {
try {
const token = headers['Authorization'].replace('Bearer ', '');
// a real backend would verify the token by checking a database or by decrypting the token
return JSON.parse(atob(token));
} catch (e) {
return null;
}
}
const todos = [
{body: 'buy milk', completed: true},
{body: 'pick up dry cleaning', completed: false},
{body: 'call mom', completed: false},
{body: 'clean basement', completed: false},
];
</script>
<style type="text/less">
@font-family: 'Raleway', "Helvetica Neue", Arial, sans-serif;
@font-size: 1em;
@color-dark: #54599c;
@color-light: #fff;
@color-light-gray: #d3d3d3;
@color-light-blue: #e2f5ff;
@color-error: #ff000e;
@color-error-light: #fde5ec;
@link-color: #2196F3;
body,
input,
button {
font-family: @font-family;
font-size: @font-size;
}
body {
background-color: @color-dark;
padding: 5%;
}
form {
background-color: @color-light;
padding: 30px 40px 0 40px;
border-radius: 6px;
}
input {
border: 1px solid @color-light-gray;
border-radius: 4px;
padding: 3%;
margin-bottom: 20px;
&:not([type=checkbox]) {
width: 93%;
}
&:focus {
background-color: @color-light-blue;
outline: 0;
border-color: #a1c7e8;
}
}
button {
background-color: #3ec967;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 20px;
border: 0;
color: White;
padding: 10px;
width: 100%;
}
a {
color: @link-color;
}
h2 {
color: #b027a1;
text-align: center;
font-size: 2em;
}
aside {
background-color: #f1f0ff;
margin: 40px -40px;
padding: 15px;
border-radius: 0px 0px 6px 6px;
text-align: center;
color: @color-dark;
}
.welcome-message {
color: white;
text-align: center;
}
.error {
padding: 20px;
margin-top: 20px;
text-align: center;
color: @color-error;
background-color: @color-error-light;
}
todo-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
With the above configuration every request made via Todo.connection
(e.g Todo.getList()
, newTodo.save()
, etc.) will wait for a session to be available before attempting the request. Once the session is available it will use the token held by the application (as part of the Session instance) and add it via an HTTP header to the outgoing request.
Initializing The Session Manually
As we've described above, in most apps a request must be made upon loading to tell if there's an active session. However, an app using cookie tokens could be written so that the server indicates to the app JS that there's already an active session, preventing the need for an initialization request. This is done by including session metadata as part of the initial page load.
For example, when the browser requests the page at www.myapp.com/store
, the browser includes any cookies for that domain. If I have a session cookie for www.myapp.com
, the server can detect this and add a script tag including session metadata to the HTML in the response:
<script>
window.sessionMetadata = {
sessionTimeout: 1763078400,
}
</script>
Then when my application JS initializes, it can check window.sessionMetadata
to see if the server has indicated that there's already an active session. If there is, it can manually set Session.current
:
// upon starting the application js, check for session data included with page
if (window.sessionMetadata) {
const session = new Session(window.sessionMetadata);
Session.current = session;
// after setting Session.current, Session.currentPromise is also automatically set:
// Session.currentPromise === Promise.resolve(session);
}
Any requests that use Session.current
or Session.currentPromise
can now be made immediately, without the need for a request to see if there's an active session.