@lwtw/identity
v0.4.2
Published
A simple library for managing users, user groups, and sessions
Downloads
17
Maintainers
Readme
README
A simple library for managing users, user groups, and sessions
Note: This package is still in alpha. API stability is not guaranteed, and it would be unwise to use it in production.
Introduction
This package helps you to quickly add user accounts, user groups, and sessions in your APIs/WebApps.
Change log
v0.4.1
- Added feature: User names can be listed with paging functionality
- Added feature: Group names can be listed with paging functionality
- Added feature: Users can be searched for by their indexed data, with paging functionality
v0.4.0
- Breaking change: Moved all static methods from the Group, Session, and User classes to their new respective *Admin classes
- Breaking change: New table schema & setup incompatible with previous one
- Introduced new Identity class that allows for multiple operations within one transaction
v0.3.1
- Fixed issue:
group.removeMember()
now only removes the user from the specified group instead of all groups
v0.3.0
- Fixed inconsistency:
group.removeMember()
now throws aGroupNotAMemberError
instead ofGroupHasMemberError
v0.2.2
- Added middleware:
requireAnyGroup()
- Added middleware:
requireAllGroups()
- Added middleware:
requireCondition()
- Fixed issue: Exceptions in the middleware are now passed on to express error handlers, instead of causing a crash
v0.2.1
- Added feature: User groups
v0.2.0
- Added feature: Expired sessions can now be renewed using renewal tokens
- Introduced breaking change to signature of method
session.updateLifetime()
- Introduced breaking change to interface
RequireGuardOptions
v0.1.4
- Added feature: Middleware can now automatically update the lifetime of valid sessions
- Added feature: Session lifetime can be updated
- Cleaned up leaking sessions from tests
v0.1.3
- Added tests for route options
v0.1.2
- Added feature: Data in the from of key-value pairs can be added to users
v0.1.1
- Added this README file.
Installation
npm install @lwtw/identity
Usage
Connect to your database, create schema
import knex from "knex"
import { Identity } from "@lwtw/identity"
const db = knex(/* Your database connection parameters go here */)
const identity = new Identity(db) // Create a new instance of the Identity class
await identity.user.schema.build() // Create the required user tables
await identity.group.schema.build() // Create the required group tables - these reference the user_accounts table, so always build the user tables first
await identity.session.schema.build() // Create the required session tables - these reference the user_accounts table, so always build the user tables first
Create a new session and get the session token
const session = await identity.session.create() // Create new session that expires after 30 days
const sessionToken = session.getToken() // Get session token
const longLivedSession = await identity.session.create({ months: 3, days: 15 }) // Create a new session that expires after 3 months and 15 days
Open an existing session
const session = await identity.session.open(sessionToken) // Open an existing session - This will throw if the session token is invalid, or the session has expired
Renew a session
const renewedSession = await identity.session.renew(expiredSessionToken, renewalTokenForExpiredSession) // Renew a session - this will generate a new session- and renewal token
Set a timer to purge sessions with expired renewal periods from the database once a day
setInterval(() => {
identity.session.purge() // Purges all sessions with expired renewal periods
}, 24 * 60 * 60 * 1000 /* One day in milliseconds */)
Destroy a session
const session = await identity.session.open(sessionToken) // Open an existing session
await session.destroy() // Destroy the session you just opened
Create a new user and set their password
const user = await identity.user.create("my-user") // Create a new user
await user.setPassword("my secret password") // Set the password for the user you just created
Get a user by username
const user = await identity.user.get("my-user") // Get a user by their username
Check if a user exists
await userExists = await identity.user.exists("my-user") // true if the user exists, false otherwise
Get user data
const user = await identity.user.get("my-user") // Get a user
const username = user.getUsername() // Get their username
const authenticated = user.isAuthenticated() // Check whether they are currently authenticated
Check if a password is correct
const user = await identity.user.get("my-user") // Get a user
const passwordIsCorrect = await user.verifyPassword("my secret password") // true if the password is correct, false otherwise
Remove a user
await identity.user.remove("my-user")
Authenticate a user
const user = await identity.user.get("my-user") // Get a user
await user.authenticate("my secret password") // Throws an error if the password is wrong
const userIsAutheticated = user.isAuthenticated() // Will be true if authentication succeeded
Log in a user to a session
const session = await identity.session.create() // Create a new session (you could also open an existing one)
const user = await identity.user.get("my-user") // Get the user
user.login(session, "my secret password") // Log in the user - This will throw if the password is wrong
const userIsAutheticated = user.isAuthenticated() // Will be true if login succeeded
Get a user from a session
const session = await identity.session.open(sessionToken) // Open an existing session
const user = await identity.user.fromSession(session) // Get the session user - this will throw if the session has no user logged in
Log out a user from a session
const session = await identity.session.open(sessionToken) // Open an existing session
const user = await identity.user.fromSession(session) // Get the session user
await user.logout(session) // Log out the user from this session
Deal with user data
const user = await identity.user.get("my-user") // Get a user
await user.setItems({ firstName: "Jane", lastName: "Doe", age: 28 }) // set values for custom keys 'firstName', 'lastName', and 'age'
const { firstName, age } = await user.getItems(["firstName", "age"]) // Get values for keys 'firstName' and 'age'
await user.removeItems(["firstName", "lastName"]) // Remove items 'firstName' and 'lastName'
List usernames
const firstTenUsernames = await identity.user.list(0, 10) // get the first 10 usernames in alphabetical order
const secondTenUsernames = await identity.user.list(10, 10) // get the next 10 usernames in alphabetical order
Index user data so it can be matched in a search
const user = await identity.user.get("my-user") // Get a user
await user.setItems(
{ firstName: "Jane", lastName: "Doe", age: 28 }, // set values for custom keys 'firstName', 'lastName', and 'age'
true // Tell the backend to create an index of all 3 items, so they can be matched during a search operation
)
Search for users
Note that the search only looks at user data that was added using the setItems() method with indexing explicitly enabled.
const firstTenUsernames = await identity.user.list(0, 10, "jane doe") // get the first 10 usernames with indexed data matching the search string, in order of relevance
const secondTenUsernames = await identity.user.list(10, 10, "[email protected]") // get the next 10 usernames with indexed data matching the search string, in order of relevance
Get names of groups the user is a member of
const user = await identity.user.get("my-user") // Get an existing user
const groupNames = user.listGroups() // Get a list of all group names the user is a member of
Create a group
const newGroup = await identity.group.create("my-group") // Create a new group
const groupName = newGroup.getName() // Get the name of the newly created group
Check whether a group exists
await groupExists = await identity.group.exists("my-group") // Resolves to true if the group exists, to false otherwise
Get an existing group
const existingGroup = await identity.group.get("my-group") // Get an existing group named 'my-group'
Add a user to a group
const group = await identity.group.get("my-group") // Get an existing group
const user = await identity.user.get("my-user") // Get an existing user
await group.addMember(user) // Add the user to the group
Get usernames of group members
const group = await identity.group.get("my-group") // Get an existing group
const usernames = await group.listMembers() // Gets a list of all usernames of users that are a member of the group
Check if user is a member of a group
const group = await identity.group.get("my-group") // Get an existing group
const user = await identity.user.get("my-user") // Get an existing user
const isMember = await group.hasMember(user) // Resolves to true if the user is a member of the group, to false otherwise
Remove a user from a group
const group = await identity.group.get("my-group") // Get an existing group
const user = await identity.user.get("my-user") // Get an existing user
await group.removeMember(user) // Removes the user from the group
List group names
const firstTenGroupNames = await identity.group.list(0, 10) // Get the first ten group names in alphabetical order
const secondTenGroupNames = await identity.group.list(10, 10) // Get the next ten group names in alphabetical order
Remove a group
await identity.group.remove("my-group") // Remove a group
Perform multiple operations as one atomic operation
await identity.atomicOperation(async (atomic) => {
const user = await atomic.user.create("my-user")
await user.setPassword("123")
await user.setItems({
foo: "bar",
lorem: "ipsum",
})
const group = await atomic.group.create(
"my-group"
)
await group.addMember(user)
const session = await atomic.session.create()
await user.login(session, "123")
}) // Uses the same database transaction for all operations inside the callback
// If an error is thrown inside the callback, all the previous operations in the callback are rolled back
Express middleware
For the middleware to do its job, the session token needs to be provided as Bearer token in the Authorization header.
Make a valid session required for a route
import express, { Request, Response } from "express"
import { Identity, requireSession, RequestWithIdentity } from "@lwtw/identity"
import knex from "knex"
const db = knex(/* Your database connection parameters go here */)
const identity = new Identity(db) // Create a new instance of the Identity class
app = express()
app.use("/session-required/", requireSession(identity)) // This will respond with an error if the request includes no valid session token
app.get("/session-required", (req: Request, res: Response) => {
const { session } = req as RequestWithIdentity // Get the session from the request
})
Make a login required for a route
import express, { Request, Response } from "express"
import { Identity, requireLogin, RequestWithIdentity } from "@lwtw/identity"
import knex from "knex"
const db = knex(/* Your database connection parameters go here */)
const identity = new Identity(db) // Create a new instance of the Identity class
app = express()
app.use("/login-required/", requireLogin(identity)) // This will respond with an error if the request includes no valid session token with a logged in user
app.get("/login-required", (req: Request, res: Response) => {
const { session, user } = req as RequestWithIdentity // Get the session and user from the request
})
Make it a requirement for the user to be in at least one group in a list
import express, { Request, Response } from "express"
import { Identity, requireAnyGroup, RequestWithIdentity } from "@lwtw/identity"
import knex from "knex"
const db = knex(/* Your database connection parameters go here */)
const identity = new Identity(db) // Create a new instance of the Identity class
app = express()
app.use(
"/require-any-group",
requireAnyGroup(identity, ["this-group", "or-that-group", "or-even-this-group"])
) // Responds with an error if the request comes from a user who is not in at least one of these groups
app.get("/require-any-group", (req: Request, res: Response) => {
const { session, user } = req as RequestWithIdentity // Get the session and user from the request
})
Make it a requirement for the user to be in all groups in a list
import express, { Request, Response } from "express"
import { Identity, requireAllGroups, RequestWithIdentity } from "@lwtw/identity"
import knex from "knex"
const db = knex(/* Your database connection parameters go here */)
const identity = new Identity(db) // Create a new instance of the Identity class
app = express()
app.use(
"/require-all-groups",
requireAllGroups(identity, ["this-group", "and-this-group", "this-group-as-well"])
) // Responds with an error if the request comes from a user who is not in all of these groups
app.get("/require-all-groups", (req: Request, res: Response) => {
const { session, user } = req as RequestWithIdentity // Get the session and user from the request
})
Make a custom requirement
import express, { Request, Response } from "express"
import { Identity, User, Session, requireCondition, RequestWithIdentity, UserInvalidError } from "@lwtw/identity"
import knex from "knex"
const db = knex(/* Your database connection parameters go here */)
const identity = new Identity(db) // Create a new instance of the Identity class
app = express()
app.use(
"/require-condition",
requireCondition(identity, (req: Request, res: Response) => {
const { session, user } = req as RequestWithIdentity // Get the session and user from the request, if they are available
const username = user instanceof User ? user.getUsername() : ""
const sessionId = session instanceof Session ? session.getId() : ""
if (username !== "admin") {
console.warn(`User '${username}' tried to access the admin area (sessionId: ${sessionId})!`)
throw new UserInvalidError("Only the administrator is allowed here!") // Prevents access
}
})
) // Responds with an error if the callback throws or rejects with a Group*, User* or Session* error - all other errors are forwarded to express
// You can handle other kinds of errors as well by providing a responseCallback (see 'Middleware options' below)
app.get("/require-condition", (req: Request, res: Response) => {
const { session, user } = req as RequestWithIdentity // Get the session and user from the request
})
Middleware options
You can pass an object of type RequireGuardOptions
as the last argument to any require*
middleware function to change its behavior.
export interface RequireGuardOptions {
responseCode?: number // The HTTP-response code to send on error
responseData?: { [key: string]: any } | string | Buffer // The response data to send on error
headers?: { [key: string]: string } // The response headers to send on error
responseCallback?: (
req: Request,
res: Response,
error: Error
) => Promise<void> | void // A callback responsible for sending a response on error - if provided, the above options will have no effect
update?:{
lifetime: Lifetime
renewalPeriod: Lifetime
} // The new lifetime and renewal period for the session if a valid token was provided - by default, lifetime and renewal period remain unchanged
}
Notes on caching and access control security
When you get or create a user, or open or create a session, some data is cached in the resulting object. You can assume
that any synchronous method returning data for a session or user (e.g. user.getUsername()
, session.getExpirationDate()
)
may return stale information. Consider the following two examples:
const originalSession = await identity.session.create({ days: 1 })
const sessionCopy = await identity.session.open(originalSession.getToken())
await sessionCopy.updateLifetime({ days: 10 })
originalSession.getExpirationDate() // Will return a (stale) expiration date one day in the future
sessionCopy.getExpirationDate() // Will return an accurate expiration date 10 days in the future
const user = await identity.user.create("my-user") // Create a new user
const group = await identity.group.create("my-group") // Create a new, empty group
await group.addMember(user) // Add user to group
const groupList = user.listGroups() // will be a stale (and thus empty) array
const memberList = await identity.group.listMembers() // will correctly contain "my-user"
For this reason, it is recommended to keep session and user objects alive for as short as possible. Short lifespans tend
to be the natural case for REST endpoints using the middleware provided, since the middleware checks access against a fresh instance of
a session and/or user, and adds the session and user to the Request
object express passes to route handlers. Once the route handler
returns, the objects will go out of scope, and there is no risk of accessing stale data at some much later point.
WebSockets however may keep connections alive for long periods of time. In this case, it is recommended to create a new User
or Session
instance for each request coming in on the WebSocket, instead of keeping them around from when the connection was first
initiated. You can however cache the session token at the beginning of the connection, and reopen the session for each request, so the
client does not have to re-authenticate with every single request after the initial connection.
If you need to make sure your data is as recent as possible, you can simply re-get a user or group, or re-open a session:
let user = await identity.user.create("my-user") // Create a new user
const group = await identity.group.create("my-group") // Create a new, empty group
await group.addMember(user) // Add user to group
user = await identity.user.get(user.getUsername()) // Get a fresh instance of the user
const groupList = user.listGroups() // will correctly contain 'my-group'
const memberList = await identity.group.listMembers() // will still correctly contain 'my-user'
ToDos
- Add tests to all methods that update or delete from the database to make sure only the desired row is affected