UserIn is an NodeJS Express middleware to build Authorization Servers that support OAuth 2.0. workflows and integrate with Identity Providers (e.g., Google, Facebook, GitHub). Its openid
mode exposes an API that complies to the OpenID Connect specification. With UserIn, the OAuth 2.0/OpenID Connect flows are abstracted so that developers focus only on implementing basic CRUD operations (e.g., get user by ID, insert token’s claims object) using the backend storage of their choice.
To ease testing, UserIn ships with a utility that allows to export a collection.json
to Postman.
UserIn is designed to expose web APIs that support two different flow types:
(1) Other OAuth 2.0 authorization flows that do not require a consent page are the password
, client_credentials
grant type flows. Those flows are generally used for programmatic access.
Creating a UserIn Authorization Server consists in creating an UserInStrategy
class (which must inherit from the Strategy
class) and then registering that class with the UserIn
middleware. That UserInStrategy
class must implement specific methods (based on how many UserIn features must be supported). UserIn removes the burden of implementing business logic in those methods so developer focus only on simple CRUD implementation.
Install UserIn:
npm i userin
If you need to support authentication using Facebook, install the Facebook passport:
npm i passport-facebook
const express = require('express')
const app = express()
const { UserIn, Strategy, Postman } = require('userin')
const Facebook = require('passport-facebook')
class YourStrategy extends Strategy {
constructor(config) {
super(config)
this.name = 'yourstrategyname',
// loginsignup mode
// ================
// Implement those seven methods if you need to support the 'loginsignup'
// mode (i.e., allowing users to login/signup with their username and password only)
this.create_end_user = (root, { user }, context) => { /* Implement your logic here */ }
this.get_end_user = (root, { user }, context) => { /* Implement your logic here */ }
this.generate_access_token = (root, { claims }, context) => { /* Implement your logic here */ }
this.generate_refresh_token = (root, { claims }, context) => { /* Implement your logic here */ }
this.get_refresh_token_claims = (root, { token }, context) => { /* Implement your logic here */ }
this.get_access_token_claims = (root, { token }, context) => { /* Implement your logic here */ }
this.delete_refresh_token = (root, { token }, context) => { /* Implement your logic here */ }
// loginsignupfip mode
// ===================
// Add those four methods to the above five if you also need to support login and signup with Identity
// Providers such as Facebook, Google, ...
this.create_fip_user = (root, { strategy, user }, context) => { /* Implement your logic here */ }
this.get_fip_user = (root, { strategy, user }, context) => { /* Implement your logic here */ }
this.generate_authorization_code = (root, { claims }, context) => { /* Implement your logic here */ }
this.get_authorization_code_claims = (root, { token }, context) => { /* Implement your logic here */ }
// openid mode
// ===================
// Add those eight methods to the following eight if you need to support all the OpenID Connect
// APIs which would allow third-parties to use your APIs:
// 1\. 'generate_access_token',
// 2\. 'generate_authorization_code',
// 3\. 'generate_refresh_token',
// 4\. 'get_end_user',
// 5\. 'get_authorization_code_claims',
// 6\. 'get_refresh_token_claims'
// 7\. 'get_access_token_claims'
// 8\. 'delete_refresh_token'
this.get_identity_claims = (root, { user_id, scopes }, context) => { /* Implement your logic here */ }
this.get_client = (root, { client_id, client_secret }, context) => { /* Implement your logic here */ }
this.get_id_token_claims = (root, { token }, context) => { /* Implement your logic here */ }
this.generate_id_token = (root, { claims }, context) => { /* Implement your logic here */ }
this.get_claims_supported = (root) => { /* Implement your logic here */ }
this.get_scopes_supported = (root) => { /* Implement your logic here */ }
// Those two OpenID event handlers are optional. If they are not implemented, the UserIn middleware uses default
// values instead:
// For 'get_jwks' UserIn uses an empty array.
// For 'get_grant_types_supported' UserIn uses this array: ['password', 'client_credentials', 'authorization_code', 'refresh_token']
this.get_jwks = (root) => { /* Implement your logic here */ }
this.get_grant_types_supported = (root) => { /* Implement your logic here */ }
// IMPORTANT NOTE: The above event handlers support both synchronous and Promises implementations. Both the
// following are correct:
// this.generate_access_token = (root, { claims }, context) => { /* Implement your logic here */ }
// or
// this.generate_access_token = async (root, { claims }, context) => { /* Implement your await logic here */ }
}
}
const userin = new UserIn({
Strategy: YourStrategy,
modes:['loginsignup', 'loginsignupfip', 'openid'], // You have to define at least one of those three values.
config: {
baseUrl: 'http://localhost:3330',
openid: {
tokenExpiry: {
access_token: 3600,
id_token: 3600,
code: 30
}
}
}
})
userIn.use(Facebook, {
scopes: ['public_profile'],
profileFields: ['id', 'displayName', 'photos', 'email', 'first_name', 'middle_name', 'last_name']
})
// Example of how to listen to events and even modify their response.
userIn.on('generate_access_token', (root, payload, context) => {
console.log(`'generate_access_token' event fired. Payload:`)
console.log(payload)
console.log('Previous handler response:')
console.log(root)
console.log('Current context:')
console.log(context)
})
Postman.export({
userIn,
name: 'userin-my-app',
path: './postman-collection.json'
})
app.use(userIn)
app.listen(3330)
All the endpoints that the UserIn middleware exposes are discoverable at the following two endpoints:
GET
http://localhost:3330/v1/.well-known/configuration: This is the non-standard OpenID discovery endpoint. It exposes the exhaustive list of all the UserIn endpoints, including both the OpenID endpoints and the non OpenID OAuth2 endpoints.GET
http://localhost:3330/oauth2/v1/.well-known/openid-configuration: This is the OpenID discovery endpoint. That endpoint is the one that your third-parties are supposed to use.The number of endpoints exposed by UserIn depends on its modes. UserIn supports three modes which can be combined together:
loginsignup
: Non-OAuth 2.0 compliant set of APIs that powers an Authorization Server that can exchange your user’s username and password with an access_token, and a refresh_token. Those tokens allow your Apps to safely access your platform’s API.loginsignupfip
: Same as the loginsignup
mode with the extra ability to use an identity provider (e.g., Facebook) to access the tokens.openid
: OAuth 2.0. and OpenID Connect compliant set of APIs that powers an Authorization Server that support multiple flows to exchange your user’s username and password with various tokens. The difference between this mode and the previous two is that your user is making that exchange request within the context of a third-party system which is uniquely identify by its client_id
. That third-party system must be registered on your platform before your user can use your APIs within that context. Contrary to the first two modes, OAuth 2.0 make it possible to restrict which APIs can be used by combining the client_id
with scopes
. OAuth 2.0 and OpenID are not designed to support creating accounts, which explain why UserIn supports the first two modes above. The purpose of OAuth 2.0 is to let third-party systems registered on your platform with specific scopes to leverage some or all of your APIs to enhance the experience of a subset of their users that also have an account on your platform.By default, UserIn exposes the following web APIs:
Pathname | Mode | Method | Type | Description |
---|---|---|---|---|
/v1/.well-known/configuration |
All |
GET |
Not OAuth 2.0. | Discovery metadata JSON about all web API. |
/v1/postman/collection.json |
All |
GET |
Not OAuth 2.0. | Postman collection 2.0 definition to create a Postman client. This endpoint is not toggled by default. To toggle it, please refer to the Publishing a Postman collection as a web link section. |
/v1/login |
loginsignup & loginsignupfip |
POST |
Not OAuth 2.0. | Lets user log in. |
/v1/signup |
loginsignup & loginsignupfip |
POST |
Not OAuth 2.0. | Lets user sign up. |
/oauth2/v1/token |
All |
POST |
OAuth 2.0. | Gets one or many tokens (e.g., access_token, refresh_token, id_token). |
/oauth2/v1/revoke |
All |
POST |
OAuth 2.0. | Revokes a refresh_token. |
/oauth2/v1/.well-known/openid-configuration |
openid |
GET |
OAuth 2.0. | Discovery metadata JSON about OpenID web API only. |
/oauth2/v1/introspect |
openid |
POST |
OAuth 2.0. | Intraspects a token (e.g., access_token, refresh_token, id_token). |
/oauth2/v1/userinfo |
openid |
GET |
OAuth 2.0. | Returns user’s profile based on the claims associated with the access_token. |
/oauth2/v1/certs |
openid |
GET |
OAuth 2.0. | Array of public JWK keys used to verify id_tokens. |
Additionally, for each identity provider installed on UserIn, the following new endpoint is added (this example uses Facebook):
Pathname | Mode | Method | Type | Description |
---|---|---|---|---|
/v1/facebook/authorize |
loginsignupfip |
GET |
Not OAuth 2.0. | Redirects to Facebook consent page. |
To learn more about setting up identity providers, please refer to the next section.
none
. This endpoint is always available regardless of which modes is selected.GET
none
none
. This endpoint is always available regardless of which modes is selected.client_id
is not required to support login/signup flows where a third-party is not involved.POST
refresh_token
grant typeall
openid
and is the initial scopes contained openid
.grant_type
[required]: refresh_token
refresh_token
[required]: <REFRESH TOKEN VALUE>
client_id
[optional]: Only required for OpenID clients. This means that the modes must contain openid
and that the refresh_token must have been acquired via an OpenID flow (e.g., consent page).client_secret
[optional]: Only required when the client_id is required and that specific client is configured so that the client_secret is required.authorization_code
grant typeloginsignupfip
and openid
openid
and is the initial scopes contained openid
.offline_access
. If the mode contains openid
, then the offline_access
scope must be explicitely supported by the client_id. This is configured on the get_client
event handler. That authorization code is acquired via one of the following two flows:
loginsignupfip
mode) when the user selected an identity provider (e.g., Facebook) rather than the username/password method.openid
mode).grant_type
[required]: authorization_code
code
[required]: <AUTHORIZATION CODE VALUE>
redirect_uri
[required]: This is a security precaution. This redirect uri must be the same as the one that was used by the consent page to redirect to your platform to return the authorization code.client_id
[optional]: Only required for OpenID clients. This means that the modes must contain openid
and that the authorization code must have been acquired via an OpenID flow (e.g., consent page).client_secret
[optional]: Only required when the client_id is required and that specific client is configured so that the client_secret is required.code_verifier
[optional]: This value is only required when the authorization code was acquired with a code_challenge
. This security strategy is called PKCE (Proof Key for Code Exchange).password
grant typeopenid
openid
.grant_type
[required]: password
username
[required]: <USERNAME>
password
[required]: <PASSWORD>
client_id
[required]: <CLIENT_ID>
client_secret
[optional]: Only required when the client_id is required and that specific client is configured so that the client_secret is required.scope
[optional]: <SPACE DELIMITED SCOPES>
client_credentials
grant typeopenid
openid
.grant_type
[required]: client_credentials
client_id
[required]: <CLIENT_ID>
client_secret
[required]: <CLIENT_SECRET>
scope
[optional]: <SPACE DELIMITED SCOPES>
openid
client_id
is not required to support login/signup flows where a third-party is not involved.refresh_token
. In theory, this method should also allow to revoke an access_token
, but in practice this is not always possible. Usually, the access_token is self-signed, which means the only way to revoke it is to wait until it expires and prevent the refresh_token to be used to issue a new one, which is similar to revoke the refresh_token. This is why UserIn does not support revoking access_tokens.POST
Authorization
[required]: Must be the access_token value prefixed with the Bearer
scheme (e.g., Bearer 123
).token
[required]: <TOKEN VALUE>
client_id
[optional]: Only required for OpenID clients. This means that the modes must contain openid
and that the refresh_token must have been acquired via an OpenID flow (e.g., consent page).client_secret
[optional]: Only required when the client_id is required and that specific client is configured so that the client_secret is required.openid
client_id
is not required to support login/signup flows where a third-party is not involved.GET
none
openid
client_id
is not required to support login/signup flows where a third-party is not involved.POST
token
[required]: <TOKEN VALUE>
token_type_hint
[required]: Valid values are: access_token
, id_token
and refresh_token
.client_id
[required]: <CLIENT_ID>
client_secret
[optional]: Only required when the client_id is required and that specific client is configured so that the client_secret is required.openid
GET
Authorization
[required]: Must be the access_token value prefixed with the Bearer
scheme (e.g., Bearer 123
).UserIn supports both Passport strategies and native OpenID providers via their .well-known/openid-configuration
discovery endpoint (e.g., https://accounts.google.com/.well-known/openid-configuration).
The next example uses Facebook:
const { UserIn } = require('userin')
const Facebook = require('passport-facebook')
const YourStrategy = require('./src/YourStrategy.js')
const userin = new UserIn({
Strategy: YourStrategy,
modes:['loginsignupfip', 'openid'], // You have to define at least one of those three values.
config: {
baseUrl: 'http://localhost:3330',
openid: {
tokenExpiry: {
access_token: 3600,
id_token: 3600,
code: 30
}
}
}
})
userIn.use(Facebook, {
clientID: '12234',
clientSecret: '54332432',
scopes: ['public_profile'],
profileFields: ['id', 'displayName', 'photos', 'email', 'first_name', 'middle_name', 'last_name']
})
NOTES:
clientID
and clientSecret
could have been omitted when the following two environment variables are set:FACEBOOK_CLIENT_ID
FACEBOOK_CLIENT_SECRET
The convention to set up environment variables is to prefix _CLIENT_ID
and _CLIENT_SECRET
with the Passport’s name in uppercase.const { UserIn } = require('userin')
const YourStrategy = require('./src/YourStrategy.js')
const userin = new UserIn({
Strategy: YourStrategy,
modes:['loginsignupfip', 'openid'], // You have to define at least one of those three values.
config: {
baseUrl: 'http://localhost:3330',
openid: {
tokenExpiry: {
access_token: 3600,
id_token: 3600,
code: 30
}
}
}
})
userIn.use({
name:'google',
client_id: '12234',
client_secret: '54332432',
discovery: 'https://accounts.google.com/.well-known/openid-configuration',
scopes:['profile', 'email']
})
NOTES:
- Both the
client_id
andclient_secret
could have been omitted when the following two environment variables are set:
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
The convention to set up environment variables is to prefix_CLIENT_ID
and_CLIENT_SECRET
with thename
value.
UserIn supports multiple flows grouped in three modes which can be combined together:
loginsignup
: This is the simplest group of flows to implement. It only supports login and signup with username and password. Generates short-lived access_token, and optionally long-lived refresh_token upon successfull authentication. Use it to let your users login and signup to your platform using a username and password only.loginsignupfip
: Supports login and signup with username/password and Federated Identity Providers (e.g., Facebook, Google). Generates short-lived access_token, short-lived authorization code, and optionally long-lived refresh_token upon successfull authentication. This mode is a superset of the loginsignup
mode. Use it to let your users login and signup to your platform using a username and password as well as one or many FIPs.openid
: Supports login (no signup) using any the OpenID Connect flows (Authorization code, Implicit, Credentials and Password). Generates short-lived access_token, short-lived authorization code, short-lived id_token, and optionally long-lived refresh_token upon successfull authentication. Use it to let others systems access your platform. OpenID Connect and OAuth 2.0 powers the following use cases:
Credentials flow
.Authorization code flow
(recommended) and the Implicit flow
(deprecated).password flow
.NOTE: It is interesting to notice that OpenID Connect and OAuth 2.0. are not designed to let your users directly(1) log in or sign up. That’s why UserIn supports the
loginsignup
mode and theloginsignupfip
mode (though the later is a bit of a hybrid as it connects with FIPs which usually implement OAuth 2.0). If you’re engineering a web API that only needs to power your web app, you only need the first or second mode. When your API needs to be accessed by third-parties, that’s when OpenID Connect becomes useful. The good thing about UserIn is that its implementation lets you upgrade at any time without re-engineering everything from the ground up.
(1) By directly we mean going straight to your middleware/backend without any redirections to let your lambda users log in or create an account using their credentials. Technically, the
password
andclient_credentials
grant types allow a user to acquire tokens in exchange of credentials via the OAuth2/token
API, but those flows are not designed to create new accounts. This is not also in their spirit to support log in. When it comes to identity, the idea behind OpenID is to allow third-parties to request identity information so that they can use them in the way they see fit, including for example, using a custom API to login their users.
loginsignup
modeloginsignup
strategy requirementsConstructor required fields:
baseUrl
tokenExpiry.access_token
Example:
const strategy = new YourStrategy({
modes:['loginsignup'], // this is optional as the default value is ['loginsignup']
tokenExpiry: {
access_token: 3600
}
})
Requires five event handlers:
create_end_user
get_end_user
generate_access_token
generate_refresh_token
get_refresh_token_claims
get_access_token_claims
delete_refresh_token
loginsignupfip
modeloginsignupfip
strategy requirementsThis mode is a superset of loginsignup.
Constructor required fields:
baseUrl
modes
: Must contain 'loginsignupfip'
.tokenExpiry.access_token
tokenExpiry.code
Example:
const strategy = new YourStrategy({
modes:['loginsignupfip'],
tokenExpiry: {
access_token: 3600,
code: 30
}
})
Requires eleven event handlers:
create_end_user
(same as loginsignup)get_end_user
(same as loginsignup)generate_access_token
(same as loginsignup)generate_refresh_token
(same as loginsignup)get_refresh_token_claims
(same as loginsignup)get_access_token_claims
(same as loginsignup)delete_refresh_token
(same as loginsignup)create_fip_user
get_fip_user
generate_authorization_code
get_authorization_code_claims
openid
modeopenid
strategy requirementsConstructor required fields:
baseUrl
modes
: Must contain 'openid'
.openid.iss
openid.tokenExpiry.id_token
openid.tokenExpiry.access_token
openid.tokenExpiry.code
Example:
const strategy = new YourStrategy({
modes:['openid'],
openid: {
iss: 'https://your-authorization-server-domain.com',
tokenExpiry: {
id_token: 3600,
access_token: 3600,
code: 30
}
}
})
Requires sixteen event handlers:
get_end_user
(same as loginsignup and loginsignupfip)generate_access_token
(same as loginsignup and loginsignupfip)generate_refresh_token
(same as loginsignup and loginsignupfip)get_refresh_token_claims
(same as loginsignup and loginsignupfip)get_access_token_claims
(same as loginsignup and loginsignupfip)delete_refresh_token
(same as loginsignup and loginsignupfip)generate_authorization_code
(same as loginsignupfip)get_authorization_code_claims
(same as loginsignupfip)generate_id_token
get_id_token_claims
get_identity_claims
get_client
get_jwks
get_claims_supported
get_scopes_supported
get_grant_types_supported
UserIn behaviors are managed via events and event handlers. Out-of-the-box, UserIn does not define any handlers to respond to those events. As a software engineer, this is your job to implement those event handlers in adequation with your business logic. The following list represents all the events that can be triggered during an authentication or authorization flow, but worry not, you are not forced to implement them all. You only have to implement the event handlers based on the type of authentication and authorization flow you wish to support.
create_end_user
create_fip_user
get_end_user
get_fip_user
generate_access_token
generate_authorization_code
generate_id_token
generate_refresh_token
get_access_token_claims
get_authorization_code_claims
get_id_token_claims
get_refresh_token_claims
get_client
get_identity_claims
get_jwks
get_claims_supported
get_scopes_supported
get_grant_types_supported
delete_refresh_token
get_config
: Automatically implemented.Each of those events trigger a chain of event handlers. By default, only one handler is configured in that chain (the one that you should have implemented in your UserIn Strategy). UserIn exposes an on
API that allows to add more handlers for each event as shown in this example:
userIn.on('generate_access_token', (root, payload, context) => {
console.log(`'generate_access_token' event fired. Payload:`)
console.log(payload)
console.log('Previous handler response:')
console.log(root)
console.log('Current context:')
console.log(context)
})
root
is the response returned by the previous event handler. If your handler does not return anything, root
is passed to the next handler. The code above is similar to this:
userIn.on('generate_access_token', (root, payload, context) => {
console.log(`'generate_access_token' event fired. Payload:`)
console.log(payload)
console.log('Previous handler response:')
console.log(root)
console.log('Current context:')
console.log(context)
return root
})
If, on the other hand, your handler returns a response, that response overrides root
.
create_end_user
Example of that logic encapsulated in a create_end_user.js
:
const { error: { wrapErrors } } = require('puffy')
const services = require('../services')
/**
* Creates new user.
*
* @param {Object} root Previous handler's response. Occurs when there are multiple handlers defined for the same event.
* @param {String} payload.user.username
* @param {String} payload.user.password
* @param {String} payload.user... More properties
* @param {Object} context Strategy's configuration
*
* @return {Object} user This object should always defined the following properties at a minimum.
* @return {Object} user.id String ot number
*/
const handler = async (root, { user }, { repos }) => {
// Note: The following assertions have already been checked by UserIn so this function
// does not need to check these again:
// - 'user' is truthy.
// - 'username' is truthy
// - 'password' is truthy
// - 'username' does not exist already
const errorMsg = 'Failed to create end user'
// 1\. Verify password minimal requirements
const { valid, reason } = services.password.strongEnough(user.password)
if (!valid)
throw new Error(`${errorMsg}. The password is not strong enought. ${reason}`)
const [newUserErrors, newUser] = await repos.user.insert(user)
if (newUserErrors)
throw wrapErrors(errorMsg, newUserErrors)
return newUser
}
module.exports = handler
create_fip_user
generate_access_token
Example of that logic encapsulated in a generate_access_token.js
:
const { error: { wrapErrors } } = require('puffy')
const tokenManager = require('../tokenManager')
/**
* Generates a new access_token.
*
* @param {Object} root Previous handler's response. Occurs when there are multiple handlers defined for the same event.
* @param {Object} payload.claims
* @param {String} payload.state This optional value is not strictly necessary, but it could help set some context based on your own requirements.
* @param {Object} context Strategy's configuration
*
* @return {String} token
*/
const handler = async (root, { claims, state }, { repos }) => {
// Note: The following assertions have already been checked by UserIn so this function
// does not need to check these again:
// - 'claims' is truthy and is an object
//
// This function is expected to behave following the specification described at
// https://github.com/nicolasdao/userin#access_token-requirements
const [errors, token] = await tokenManager(repos)('access_token').create(claims)
if (errors)
throw wrapErrors('Failed to create access_token', errors)
return token
}
module.exports = handler
generate_authorization_code
generate_id_token
generate_refresh_token
Example of that logic encapsulated in a generate_refresh_token.js
:
const { error: { wrapErrors } } = require('puffy')
const tokenManager = require('../tokenManager')
/**
* Generates a new refresh_token.
*
* @param {Object} root Previous handler's response. Occurs when there are multiple handlers defined for the same event.
* @param {Object} payload.claims
* @param {String} payload.state This optional value is not strictly necessary, but it could help set some context based on your own requirements.
* @param {Object} context Strategy's configuration
*
* @return {String} token
*/
const handler = async (root, { claims, state }, { repos }) => {
// Note: The following assertions have already been checked by UserIn so this function
// does not need to check these again:
// - 'claims' is truthy and is an object
//
// This function is expected to behave following the specification described at
// https://github.com/nicolasdao/userin#refresh_token-requirements
const [errors, token] = await tokenManager(repos)('refresh_token').create(claims)
if (errors)
throw wrapErrors('Failed to create refresh_token', errors)
return token
}
module.exports = handler
get_access_token_claims
get_authorization_code_claims
get_client
/**
* Gets the client's audiences, scopes and auth_methods.
*
* @param {Object} root Previous handler's response. Occurs when there are multiple handlers defined for the same event.
* @param {String} payload.client_id
* @param {String} payload.client_secret Optional. If specified, this method should validate the client_secret.
* @param {Object} context Strategy's configuration
*
* @return {[String]} output.audiences Client's audiences.
* @return {[String]} output.scopes Client's scopes.
* @return {[String]} output.auth_methods Client's auth_methods.
*/
const handler = (root, { client_id, client_secret }, context) => {
const client = context.repos.client.find(x => x.client_id == client_id)
if (!client)
return null
if (client_secret && client.client_secret != client_secret)
throw new Error('Unauthorized access')
return {
audiences: client.audiences || [],
scopes: client.scopes || [],
auth_methods: client.auth_methods || []
}
}
get_config
/**
* Gets the strategy's configuration object.
*
* @param {Object} root Previous handler's response. Occurs when there
* are multiple handlers defined for the same event.
* @return {String} output.iss
* @return {Number} output.expiry.id_token
* @return {Number} output.expiry.access_token
* @return {Number} output.expiry.refresh_token
* @return {Number} output.expiry.code
*/
const get_config = (root) => {
console.log('get_config fired')
console.log('Previous handler response:')
console.log(root)
return {
iss: 'https://userin.com',
expiry: {
id_token: 3600,
access_token: 3600,
code: 30
}
}
}
get_end_user
Example of that logic encapsulated in a get_end_user.js
:
const { error:{ InvalidCredentialsError } } = require('userin')
const { error: { wrapErrors } } = require('puffy')
const services = require('../services')
/**
* Gets the user ID and optionnaly its associated client_ids if the 'openid' mode must be supported.
* If the username does not exist, a null value must be returned. However, the 'password' is optional.
* If the 'password' is provided, it must be verified. If the verification fails, an error of type
* InvalidCredentialsError must be thrown (const { error:{ InvalidCredentialsError } } = require('userin'))
*
* @param {Object} root Previous handler's response. Occurs when there are multiple handlers defined for the same event.
* @param {String} payload.user.username
* @param {String} payload.user.password
* @param {String} payload.user... More properties
* @param {String} payload.client_id Optional. Might be useful for logging or other custom business logic.
* @param {String} payload.state Optional. Might be useful for logging or other custom business logic.
* @param {Object} context Strategy's configuration
*
* @return {Object} user This object should always defined the following properties at a minimum.
* @return {Object} user.id String ot number
* @return {[Object]} user.client_ids
*/
const handler = async (root, { user, client_id, state }, { repos }) => {
// Note: The following assertions have already been checked by UserIn so this function
// does not need to check these again:
// - 'user' is truthy.
// - 'username' is truthy
//
// This function is expected to behave as follow:
// - If the 'username' does not exist, a null value must be returned.
// - The 'password' is optional.
// - If the 'password' is provided, it must be verified. If the verification fails, an error of type
// InvalidCredentialsError must be thrown (const { error:{ InvalidCredentialsError } } = require('userin'))
const errorMsg = 'Failed to get end user'
const [confirmedUserErrors, confirmedUser] = await repos.user.find({ where:{ email:user.username } })
if (confirmedUserErrors)
throw wrapErrors(errorMsg, confirmedUserErrors)
if (!confirmedUser)
return null
if (user.password) {
const eMsg = `${errorMsg}. Invalid username or password.`
const saltedPassword = confirmedUser.password
if (!saltedPassword || !confirmedUser.salt)
throw new InvalidCredentialsError(eMsg)
const valid = services.password.verify({
password:user.password,
salt:confirmedUser.salt,
hashedSaltedPassword:confirmedUser.password
})
if (!valid)
throw new InvalidCredentialsError(eMsg)
}
return {
id: confirmedUser.id,
client_ids:[]
}
}
module.exports = handler
get_fip_user
get_id_token_claims
get_identity_claims
get_refresh_token_claims
Example of that logic encapsulated in a get_refresh_token_claims.js
:
const { error: { wrapErrors } } = require('puffy')
const tokenManager = require('../tokenManager')
/**
* Gets the refresh_token's claims
*
* @param {Object} root Previous handler's response. Occurs when there are multiple handlers defined for the same event.
* @param {Object} payload.token
* @param {Object} context Strategy's configuration
*
* @return {Object} claims This object should always defined the following properties at a minimum.
* @return {String} claims.iss
* @return {Object} claims.sub String or number
* @return {String} claims.aud
* @return {Number} claims.exp
* @return {Number} claims.iat
* @return {Object} claims.client_id String or number
* @return {String} claims.scope
*/
const handler = async (root, { token }, { repos }) => {
// Note: The following assertions have already been checked by UserIn so this function
// does not need to check these again:
// - 'token' is truthy and is a string
//
// This function is expected to behave following the specification described at
// https://github.com/nicolasdao/userin#refresh_token-requirements
const [errors, refresh_token] = await tokenManager(repos)('refresh_token').getClaims(token)
if (errors)
throw wrapErrors('Failed to create refresh_token', errors)
return refresh_token
}
module.exports = handler
get_jwks
get_claims_supported
get_scopes_supported
get_grant_types_supported
delete_refresh_token
If you’re implementing a UserIn strategy that supports the openid
mode, then you must generate your tokens and authorization code following strict requirements.
id_token
requirementsaccess_token
requirementsrefresh_token
requirementscode
requirementsUserIn ships with a suite of Mocha unit tests. To test your own strategy:
npm i -D mocha chai
test
folder in your project root directory.test
folder, create a new strategy.js
(or whatever name you see fit), and paste code similar to the following:const { testSuite } = require('userin')
const { YourStrategyClass } = require('../src/yourStrategy.js')
const options = { skip:'' } // Does not skip any test.
// To test a stragegy in 'loginsignup' mode, the following minimum config is required.
const config = {
tokenExpiry: {
access_token: 3600
}
}
// The required stub's properties are (change the values to your own stub):
const stub = {
user: {
username: 'valid@example.com', // Valid username in your own stub data.
password: '123456' // Valid password in your own stub data.
},
newUserPassword: 'd32def32feq' // Add the password that will be used to test new users
}
testSuite.testLoginSignup(YourStrategyClass, config, stub, options)
test
script in your package.json
: "scripts": {
"test": "mocha --exit"
}
npm test
testSuite
APIThe testSuite
API exposes four different test suite, one for each mode + one that combines all the modes, that use the same signature:
The signature is (YourStrategyClass: UserInStrategy, config: Object, stub: Object[, options: Object])
where:
YourStrategyClass
is a custom UserIn Strategy
class (warning: do not use an instance, use the class).config
is the required argument that you would pass to the YourStrategyClass
constructor.stub
is the required fake data used to unit test the YourStrategyClass
flows.options
is the optional object that help skip some tests or show more test results:
options.skip: [String]
: Array of test to skip. To skip all test, use skip: ['all']
.options.only: [String]
: Array of test to run.options.showResults: [String]
: Array of test assertions. When this array is specified, more details about the assertion outcome are displayed. Example: showResults:['login.handler.09,10', 'signup.handler.01']
testLoginSignup
functionRuns the following tests:
strategy
login
signup
const { testSuite } = require('userin')
const { YourStrategyClass } = require('../src/yourStrategy.js')
// Use the 'option' value to control which test is run. By default, all tests are run.
// Valid test names are: 'all', 'strategy', 'login', 'signup'
//
// const options = { skip:'all' } // Skips all tests in this suite.
// const options = { skip:'login' } // Skips the 'login' test in this suite.
// const options = { skip:['login', 'signup'] } // Skips the 'login' and 'signup' tests in this suite.
// const options = { only:'login' } // Only run the 'login' test in this suite.
// const options = { only:['login', 'signup'] } // Only run the 'login' and 'signup' tests in this suite.
const options = { skip:'', showResults:['login.handler.09,10'] } // Does not skip any test and show the results of:
// - Test 'login.handler.09'
// - Test 'login.handler.10'
// To test a stragegy in 'loginsignup' mode, the following minimum config is required.
const config = {
tokenExpiry: {
access_token: 3600
}
}
// The required stub's properties are (change the values to your own stub):
const stub = {
user: {
id: 1,
username: 'valid@example.com', // Valid username in your own stub data.
password: '123456' // Valid password in your own stub data.
},
newUserPassword: 'd32def32feq' // Add the password that will be used to test new users
}
testSuite.testLoginSignup(YourStrategyClass, config, stub, options)
testLoginSignupFIP
functionRuns the following tests:
strategy
login
signup
fiploginsignup
const { testSuite } = require('userin')
const { YourStrategyClass } = require('../src/yourStrategy.js')
// Use the 'option' value to control which test is run. By default, all tests are run.
// Valid test names are: 'all', 'strategy', 'login', 'signup', 'fiploginsignup'
//
// const options = { skip:'all' } // Skips all tests in this suite.
// const options = { skip:'login' } // Skips the 'login' test in this suite.
// const options = { skip:['login', 'signup'] } // Skips the 'login' and 'signup' tests in this suite.
// const options = { only:'login' } // Only run the 'login' test in this suite.
// const options = { only:['login', 'signup'] } // Only run the 'login' and 'signup' tests in this suite.
const options = { skip:'' } // Does not skip any test.
// To test a stragegy in 'loginsignupfip' mode, the following minimum config is required.
const config = {
tokenExpiry: {
access_token: 3600,
code: 30
}
}
// The required stub's properties are (change the values to your own stub):
const stub = {
user: {
id: 1,
username: 'valid@example.com', // Valid username in your own stub data.
password: '123456' // Valid password in your own stub data.
},
newUserPassword: 'd32def32feq', // Add the password that will be used to test new users
fipUser: { // this user should be different from the one above.
id: '1N7fr2yt', // ID of the user in the identity provider plaftform
fipName: 'facebook', // Identity provider's name
userId: 2 // ID of the user on your platform
}
}
testSuite.testLoginSignupFIP(YourStrategyClass, config, stub, options)
testOpenId
functionRuns the following tests:
strategy
introspect
token
userinfo
const { testSuite } = require('userin')
const { YourStrategyClass } = require('../src/yourStrategy.js')
// Use the 'option' value to control which test is run. By default, all tests are run.
// Valid test names are: 'all', 'strategy', 'introspect', 'token', 'userinfo'
//
// const options = { skip:'all' } // Skips all tests in this suite.
// const options = { skip:'introspect' } // Skips the 'introspect' test in this suite.
// const options = { skip:['introspect', 'token'] } // Skips the 'introspect' and 'token' tests in this suite.
// const options = { only:'introspect' } // Only run the 'introspect' test in this suite.
// const options = { only:['introspect', 'token'] } // Only run the 'introspect' and 'token' tests in this suite.
const options = { skip:'' } // Does not skip any test.
// To test a stragegy in 'openid' mode, the following minimum config is required.
const config = {
openid: {
iss: 'https://www.userin.com',
tokenExpiry: {
id_token: 3600,
access_token: 3600,
code: 30
}
}
}
// The required stub's properties are (change the values to your own stub):
const stub = {
client: {
id: 'client_with_at_least_one_user_and_no_auth_methods',
secret: '98765',
aud: 'https://private-api@mycompany.com',
user: {
id: 1,
username: 'valid@example.com', // Valid username in your own stub data.
password: '123456' // Valid password in your own stub data.
claimStubs: [{ // Define the identity claims you want to support here and fill the value for the 'valid@example.com' user.
scope:'profile',
claims: {
given_name: 'Nic',
family_name: 'Dao',
zoneinfo: 'Australia/Sydney'
}
}, {
scope:'email',
claims: {
email: 'nic@cloudlessconsulting.com',
email_verified: true
}
}, {
scope:'phone',
claims: {
phone: '+61432567890',
phone_number_verified: false
}
}, {
scope:'address',
claims: {
address: 'Castle in the shed'
}
}]
}
},
altClient: {
id: 'another_client_with_no_auth_methods',
secret: '3751245'
},
privateClient: {
// this client must have its 'auth_methods' set to ['client_secret_basic'], ['client_secret_post'] or
// ['client_secret_basic', 'client_secret_post']
id: 'yet_another_client_with_auth_methods',
secret: '3751245'
}
}
testSuite.testOpenId(YourStrategyClass, config, stub, options)
testAll
functionThis test function tests all the previous three tests at once. Use it if you have created a UserIn Strategy class that imlements all the event handlers. The signature is the same as for the other tests. Merge all the stubs from the previous tests into a single stub object.
The test suite supports inverson of control via dependency injection. All the event handlers supports the same signature:
(root: Object, payload: Object, context: Object)
.
For example:
YourStrategyClass.prototype.get_end_user = (root, { user }, context) => {
const existingUser = USER_STORE.find(x => x.email == user.username)
if (!existingUser)
return null
if (user.password && existingUser.password != user.password)
throw new Error('Incorrect username or password')
const client_ids = USER_TO_CLIENT_STORE.filter(x => x.user_id == existingUser.id).map(x => x.client_id)
return {
id: existingUser.id,
client_ids
}
}
This example shows that get_end_user
depends on the USER_STORE
and USER_TO_CLIENT_STORE
to function. Those would typically be connectors that can perform IO queries to your backend storage. This code is not properly designed to support unit tests, especially if you are tryng to test inserts. To solve this problem, the best practice is to inject those dependencies from the outside.
This is one the purpose of the context
object. The context
object is the config
object passed to the YourStrategyClass
instance:
const { testSuite } = require('userin')
const { YourStrategyClass } = require('../src/yourStrategy.js')
// To test a stragegy in 'loginsignup' mode, the following minimum config is required.
const config = {
tokenExpiry: {
access_token: 3600
},
repos: {
user: {
find: (userId)
}
}
}
// The required stub's properties are (change the values to your own stub):
const stub = {
user: {
username: 'valid@example.com', // Valid username in your own stub data.
password: '123456' // Valid password in your own stub data.
},
}
testSuite.testLoginSignup(YourStrategyClass, config, stub, options)
In this example, let’s modified the config
as follow:
const config = {
tokenExpiry: {
access_token: 3600
},
repos: {
user: USER_STORE,
userToClient: USER_TO_CLIENT_STORE
}
}
With this change, the get_end_user
can be rewritten as follow:
YourStrategyClass.prototype.get_end_user = (root, { user }, context) => {
const existingUser = context.repos.user.find(x => x.email == user.username)
if (!existingUser)
return null
if (user.password && existingUser.password != user.password)
throw new Error('Incorrect username or password')
const client_ids = context.repos.userToClient.filter(x => x.user_id == existingUser.id).map(x => x.client_id)
return {
id: existingUser.id,
client_ids
}
}
This design pattern is called dependency injection. It allows to replace the behaviors from the outside.
UserIn can publish its API documentation using Postman Collection v2.1. There are two ways to export a Postman collection:
{{YOUR_DOMAIN}}/v1/postman/collection.json
and use that link in Postman to import that collection.Use this API:
userIn.use(new Postman('your-collection-name'))
The full example looks like this:
const express = require('express')
const app = express()
const Facebook = require('passport-facebook')
const { UserIn, Postman } = require('userin')
const YourStrategy = require('./src/YourStrategy')
const userIn = new UserIn({
Strategy: YourStrategy,
modes:['loginsignupfip', 'openid'], // You have to define at least one of those three values.
config: {
baseUrl: 'http://localhost:3330',
openid: {
tokenExpiry: {
access_token: 3600,
id_token: 3600,
code: 30
}
}
}
})
userIn.use(Facebook, {
scopes: ['public_profile'],
profileFields: ['id', 'displayName', 'photos', 'email', 'first_name', 'middle_name', 'last_name']
})
userIn.use({
name:'google',
discovery: 'https://accounts.google.com/.well-known/openid-configuration',
scopes:['profile', 'email']
})
userIn.use(new Postman('userin-my-app'))
app.use(userIn)
app.listen(3330, () => console.log('UserIn listening on https://localhost:3330'))
Once the UserIn instance has been created and configured, use the Postman
utility as follow:
Postman.export({
userIn,
name: 'userin-my-app',
path: './postman-collection.json'
})
The full example looks like this:
const express = require('express')
const app = express()
const Facebook = require('passport-facebook')
const { UserIn, Postman } = require('userin')
const YourStrategy = require('./src/YourStrategy')
const userIn = new UserIn({
Strategy: YourStrategy,
modes:['loginsignupfip', 'openid'], // You have to define at least one of those three values.
config: {
baseUrl: 'http://localhost:3330',
openid: {
tokenExpiry: {
access_token: 3600,
id_token: 3600,
code: 30
}
}
}
})
userIn.use(Facebook, {
scopes: ['public_profile'],
profileFields: ['id', 'displayName', 'photos', 'email', 'first_name', 'middle_name', 'last_name']
})
userIn.use({
name:'google',
discovery: 'https://accounts.google.com/.well-known/openid-configuration',
scopes:['profile', 'email']
})
Postman.export({
userIn,
name: 'userin-my-app',
path: './postman-collection.json'
})
app.use(userIn)
app.listen(3330, () => console.log('UserIn listening on https://localhost:3330'))
When this code is executed, a postman-collection.json
file is autogenerated. Use Postman to import the collection using this file.
Flexible flows are those who leverage the UserIn APIs built to support the OAuth 2.0. flows but do not obey to the strict OAuth 2.0 specification.
Generally speaking, those flows exist to power the non-third-party use cases, i.e., the ones where your API powers your own Apps directly (e.g., signing up with username and password). Those flows do not need any client_id
(which exist to identity a third-party). UserIn’s value proposition is to leverage the existing OAuth 2.0 APIs to support both standard (requires a client_id and maybe a client_secret too) and non-standard flows. To deliver this value, UserIn uses this simple approach. When users are authorized via your platform’s consent page (OAuth 2.0 flow), then tokens (including the authorization code) are linked to a client_id. All subsequent flows involving those tokens require a client_id. On the other hand, When users login or signup via your login/signup page (non-OAuth 2.0 flow), then no client_id is associated with the generated tokens, and therefore the client_id is not required.
This is the case where a user wish to use an identity provider such as Facebook to login or signup to your platform. Behind the scene, UserIn interacts with the identity provider’s OAuth 2.0 authorization code flow to make this happen, but this next web API is not part of the OAuth 2.0 specification.
GET https://YOUR_DOMAIN/v1/google/authorize?
response_type=code&
redirect_uri=https://YOUR_DOMAIN/v1/google/authorizecallback&
scope=profile&
mode=signup
Notice that this HTTP GET is similar to the OAuth 2.0. /authorize
request used in the Authorization Code flow except:
client_id
because you are serving your own users. Client IDs are generally used to identity a third-party accessing your platform.mode
variable helps to determine whether this request aims to create a new user or to log the user in. The supported values are:
login
(default)signup
Coming soon…
logTestErrors
APIAlmost all unit tests use the custom logTestErrors
API. This API’s purpose is to capture explicit error logs to display them when the developer uses the verbose
mode. This API leverages UserIn’s functional error handling style.
const { logTestErrors } = require('./_core')
const verbose = true
const logTest = logTestErrors()
it('Should fail when something bad happens.', done => {
const logE = logTest(done)
logE.run(co(function *() {
// Functional error handling style where the output is always an arrat where the first element is an array of errors
// and the second is the exppected result.
const [errors, result] = yield someFunction()
// Log errors to support the verbose mode.
logE.push(errors)
// Run the usual assertions
assert.isOk(errors, '01')
assert.isOk(errors.length, '02')
assert.isOk(errors.some(e => e.message && e.message.indexOf('Missing \'get_client\' handler') >= 0), '03')
done()
}))
})
Please refer to Exporting the API to Postman.
The easiest solution is to use ngrok
which can expose a web server running on your local machine to the internet via both HTTP and HTTPS. For example, your UserIn server that is locally accessible via http://localhost:3330 will be available publicly via https://2e6c759d16cf.ngrok.io. Add that URL to the allowlist in your Facebook App and then update the {{base_url}}
in Postman to use that new URL.
client_secret
required?https://developer.okta.com/blog/2019/08/22/okta-authjs-pkce https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce
Grant types are labels used in the /token
API to determine how the provided credentials must be exchanged with tokens. OAuth 2.0. supports the following grant types:
password
client_credentials
authorization_code
refresh_token
device_code
(not supported yet by UserIn)Author: nicolasdao
Source Code: https://github.com/nicolasdao/userin
#node #nodejs #javascript