An authentication micro-service for web applications or web APIs.
auth-service offers protection of one or more instances of web applications/APIs along with the following features:
- Protection of one or more instances of web applications or web APIs
- Local accounts (in the form of user names and passwords stored in a database)
- Account roles
- Password resets via e-mail
- External account creation
- SMS-based two-factor authentication for local accounts (via Twilio)
- Single sign-on via SAML
All features in the list above are opt-in.
auth-service consists of an NGINX-based proxy (“Authentication Proxy”) and a service for authenticating requests (“Authentication Backend”). auth-service stores its state in a PostgreSQL database (“Authentication Database”).
In order to use auth-service to protect your web application or web API (“Your Application Server”), you will need to configure an outer proxy (“Frontend Proxy”) that is exposed externally (e.g. the Internet). An example using Docker Compose and NGINX is provided.
auth-service provides Docker images for the yellow blocks (“Authentication Proxy” and “Authentication Backend”). The official postgres image can be used for auth-service's database (“Authentication Database”).
Let's say we have a web API that we want to protect using auth-service.
backend/index.js
:
const http = require('http')
const app = http.createServer((_request, response) => {
response.setHeader('Content-Type', 'application/json')
response.write(JSON.stringify({ message: 'Hello World!' }))
response.end()
})
app.listen({ port: 80 }, () => {
console.log('The backend is running!')
})
backend/Dockerfile
:
FROM node
EXPOSE 3000
CMD ["node", "index.js"]
WORKDIR /app
COPY index.js .
We can then use Docker Compose to protect this backend, using a
docker-compose.yml
file such as this one:
networks:
auth-service:
proxy:
services:
auth-service-backend:
depends_on:
auth-service-database:
condition: service_healthy
environment:
- DB_HOST=auth-service-database
image: nejla/auth-service-backend
networks:
- auth-service
- proxy
restart: unless-stopped
auth-service-database:
environment:
- POSTGRES_HOST_AUTH_METHOD=trust
healthcheck:
interval: 1s
test: 'pg_isready -U postgres'
image: postgres
networks:
- auth-service
restart: unless-stopped
volumes:
- ./database:/var/lib/postgresql/data
auth-service-proxy:
depends_on:
auth-service-backend:
condition: service_healthy
healthcheck:
interval: 1s
test: service nginx status || exit 1
image: nejla/auth-service-proxy
links:
- auth-service-backend:auth-service
- backend:0d6a2251-e82c-47a9-9895-dbc22c7b6852
networks:
- proxy
restart: unless-stopped
backend:
build: backend
healthcheck:
interval: 1s
test: 'curl -f http://localhost/'
networks:
- proxy
restart: unless-stopped
proxy:
depends_on:
auth-service-proxy:
condition: service_healthy
backend:
condition: service_healthy
healthcheck:
interval: 1s
test: service nginx status || exit 1
image: nginx
networks:
- proxy
ports:
- '8080:80'
restart: unless-stopped
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
As mentioned above, auth-service can protect of one or more instances of web
applications or web APIs. Each instance must have a UUID that uniquely
identifies it. 0d6a2251-e82c-47a9-9895-dbc22c7b685
is used above, but that's
just an example.
nginx.conf
contains the “Frontend Proxy” configuration. In a case like this,
it could look like this:
events {}
http {
server {
listen 80;
location /api {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Instance 0d6a2251-e82c-47a9-9895-dbc22c7b6852;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://auth-service-proxy/;
}
}
}
The X-Instance
header must be set to the UUID value for the instance. Note
that the same UUID value is present in the Docker Compose configuration –
auth-service-proxy uses this UUID value as the hostname to forward requests to.
Start the Docker Compose services:
$ docker-compose up -d
Check that the Docker Compose services are up and running:
$ docker-compose ps
NAME COMMAND SERVICE STATUS PORTS
auth-service-example-auth-service-backend-1 "auth-service run" auth-service-backend running (healthy) 80/tcp
auth-service-example-auth-service-database-1 "docker-entrypoint.s…" auth-service-database running (healthy) 5432/tcp
auth-service-example-auth-service-proxy-1 "/docker-entrypoint.…" auth-service-proxy running (healthy) 80/tcp, 443/tcp
auth-service-example-backend-1 "docker-entrypoint.s…" backend running (healthy) 3000/tcp
auth-service-example-proxy-1 "/docker-entrypoint.…" proxy running (healthy) 0.0.0.0:8080->80/tcp, :::8080->80/tcp
auth-service is now protecting the backend service. Accessing the API without authenticating won't work:
$ curl http://localhost:8080/api
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.23.2</center>
</body>
</html>
In order to try authenticating, add a user like so:
$ docker-compose exec auth-service-backend auth-service \
adduser [email protected] secret "Example User"
Make auth-service aware of the instance (backend) using the same UUID again:
$ docker-compose exec auth-service-backend auth-service \
newinstance example 0d6a2251-e82c-47a9-9895-dbc22c7b6852
Give the example user access to the instance:
$ docker-compose exec auth-service-backend auth-service \
addinstance [email protected] 0d6a2251-e82c-47a9-9895-dbc22c7b6852
Sign in the user to get an access token:
$ curl \
-d '{ "password": "secret", "user": "[email protected]" }' \
-H "Content-Type: application/json" \
-w "\n" \
http://localhost:8080/api/login
{"token":"YQhZ7OVedqVAM2IXdsonua","instances":[{"name":"example","id":"0d6a2251-e82c-47a9-9895-dbc22c7b6852"}]}
We can now access the backend using the token like so:
$ curl -H "X-Token: YQhZ7OVedqVAM2IXdsonua" -w "\n" http://localhost:8080/api
{"message":"Hello World!"}
Instead of using the X-Token header
, we can also use a cookie (with the same
token value). Cookies can be useful for signing in using web frontends.
$ curl \
-H "Cookie: token=YQhZ7OVedqVAM2IXdsonua" \
-w "\n" \
http://localhost:8080/api
{"message":"Hello World!"}
User can log out (deactivate the current token) by making a POST
request to
/api/logout
.
A user can deactivate all tokens belonging to the account except the current
token by making a POST
request to /api/disable-sessions
.
Finally, users can get information about their accounts (including ID, name,
e-mail, phone number, instances and roles) by making GET
requests to
/api/user-info
.
We are now up and running with auth-service! 🚀
auth-service can be used with Single Page Application (SPA) frameworks and
libraries like React and Vue.js. In that scenario, a static login page is not
necessary. The UI can itself manage requests to /api/login
and the resulting
authentication sessions.
However, for web applications that are not SPAs, or in order to get started more quickly, auth-service can offer a static login page.
In order to use a static login page, put the following configuration into your frontend proxy.
location /auth/ {
index index.html;
proxy_pass http://auth-service-proxy/authentication/;
proxy_pass_request_body off;
proxy_redirect http://auth-service-proxy/ /;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
}
Also, if you want unauthenticated requests to /index.html to be translated into the authentication page of this component, add the following:
location /index.html {
auth_request /api/auth;
error_page 403 =303 /auth.html;
}
The auth-service-backend service can be configured by setting environment
variables, e.g. by adding -E "option=value"
to docker
calls or by setting
them under environment
in your docker-compose.yml
files.
Option | Required | Type | Default | Description |
---|---|---|---|---|
TOKEN_TIMEOUT |
No | Integer | Unset | Time in seconds before a given token expire after the token was created. Only affects newly created tokens. Tokens don't expire if unset, unless the tokens expire because of TOKEN_UNUSED_TIMEOUT (see below). |
TOKEN_UNUSED_TIMEOUT |
No | Integer | Unset | Time in seconds before a given token expire after the token was last used. Making a request that causes a token to be checked counts as using the token. Affects all tokens. If unset, tokens don't expire from being unused. |
auth-service can offer password reset e-mails containing a password reset link. By default, the e-mail functionality is disabled.
If the EMAIL_FROM
option is set, the e-mail functionality is enabled and a number of other options become required. If the EMAIL_FROM
option is unset, the e-mail functionality is disabled and all other e-mail-related options are ignored.
Option | Required | Type | Default | Description |
---|---|---|---|---|
EMAIL_FROM |
No | String | Unset | E-mail address to send e-mail from. |
EMAIL_PASSWORD |
If EMAIL_FROM is set |
String | Unset | SMTP password to use for sending e-mail. |
EMAIL_SMTP |
If EMAIL_FROM is set |
String | Unset | SMTP server to use for sending e-mail. |
EMAIL_USER |
If EMAIL_FROM is set |
String | Unset | SMTP user name to use for sending e-mail. |
EMAIL_AUTH |
No | Boolean (true or false ) |
true |
Whether to authenticate to the SMTP server. |
EMAIL_FROM_NAME |
No | String | Unset | Human-visible name of the sender. |
EMAIL_PORT |
No | Integer | 25 |
SMTP server port to use for sending e-mail. |
EMAIL_TLS |
No | Boolean (true or false ) |
true |
Whether to use TLS to connect to the SMTP server. |
RESET_LINK_EXPIRATION_TIME |
No | Integer | 24 |
Time in hours before password reset links expire. |
SENDMAIL_PROGRAM |
No | String | /usr/sbin/sendmail |
Path to the sendmail executable to use, followed by the arguments, separated by spaces. |
A password reset e-mail request will always succeed, even if the e-mail address does not belong to an auth-service user, in order to prevent oracle attacks.
auth-service will look for the following two e-mail templates:
/app/password-reset-email.html.mustache.mustache
/app/password-reset-unknown-email.html.mustache
You must provide these files (if EMAIL_FROM
is set) by voluming them into the /app
directory in the auth-server-backend container. Using Docker Compose that can be done like so:
volumes:
- ./password-reset-email.html.mustache.mustache:/app/password-reset-email.html.mustache:ro
- ./password-reset-unknown-email.html.mustache:/app/password-reset-unknown-email.html.mustache:ro
Here is an example of password-reset-email.html.mustache.mustache
(%s
is
replaced with the token):
<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8">
<title>Your Password Reset</title>
<meta content="initial-scale=1, width=device-width" name="viewport">
</head>
<body>
<h1>Your Password Reset</h1>
<p>
You requested a password reset for <b>Example App</b>. To complete the
reset please follow this link:
</p>
<p>
<a href="https://my-app.example.com/reset-password?token={{token}}">
https://my-app.example.com/reset-password?token={{token}}
</a>
</p>
<p>The link will be valid for {{expirationTime}} hours.</p>
<p>
If you didn't request a password reset you can safely ignore this message.
</p>
</body>
</html>
Here is an example of password-reset-unknown-email.html.mustache
:
<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8">
<title>Password Reset Attempt</title>
<meta content="initial-scale=1, width=device-width" name="viewport">
</head>
<body>
<h1>Password Reset Attempt</h1>
<p>
You requested a password reset for <b>Example App</b> but this e-mail
address is not known to our system. Please try a different e-mail address
or contact support.
</p>
<p>
If you didn't request a password reset you can safely ignore this message.
</p>
</body>
</html>
There are three endpoints that can be used for password resets:
POST
requests to/api/request-password-reset
will request a password reset e-mails to be sent. The body should contain anemail
property, like so:{ "email": "[email protected]" }
.GET
requests to/api/reset-password-info
with atoken
query parameter (like/api/reset-password-info?token=...
) can be used to check the validity of tokens (whether or not they exist, have not expired and have not been used).POST
requests to/api/reset-password
will use a valid token to set a new password. The body should containtoken
andnewPassword
properties, like so:{ "newPassword": "...", "token": "..." }
.
auth-service can use Twilio, a mobile messaging service, for two-factor authentication in the form of one-time SMS codes. By default, the two-factor authentication functionality is disabled.
If the TFA_REQUIRED
option is set to true
, the two-factor functionality is enabled and the “TWILIO” options become required. If TFA_REQUIRED
is unset, or set to false
, the two-factor functionality is disabled.
Option | Required | Type | Default | Description |
---|---|---|---|---|
TFA_REQUIRED |
No | Boolean (true or false ) |
false |
Whether two-factor authentication is required for all users in order to create new tokens. |
TWILIO_ACCOUNT |
If TFA_REQUIRED is set |
String | Unset | Twilio account to use when sending SMS messages. |
TWILIO_TOKEN |
If TFA_REQUIRED is set |
String | Unset | Twilio authentication token to use when sending SMS messages. |
TWILIO_SOURCE |
If TFA_REQUIRED is set |
String | Unset | Twilio phone number to send messages from. |
OTP_LENGTH |
No | Integer | 4 | Number of characters to use in one time passwords. |
OTP_TIMEOUT |
No | Integer | 300 | Time in seconds before one time passwords expire. |
When two-factor authentication is used, users sign in using POST
requests to /api/login
, just like when two-factor authentication is not used.
The difference is that the first POST
request to /api/login
will (if the credentials are correct) respond with 499 Client Closed Request
.
The user then has to make another POST
request to /api/login
, this time with a body containing an otp
property, in order to complete the login flow:
{
"otp":"..."
"password": "...",
"user": "...",
}
If the ACCOUNT_CREATION
option is set to true
, auth-service will allow for
accounts to be created using POST
requests to /api/create-account
.
Option | Required | Type | Default | Description |
---|---|---|---|---|
ACCOUNT_CREATION |
No | Boolean (true or false ) |
false |
Whether new accounts can be created using the auth-service web API. |
DEFAULT_INSTACE |
No | UUID | Unset | Default instance ID to grant new accounts created using the auth-service web API access to. If the X-Instance header is set by the proxy, the header value will overrides this option. |
The request body should look like this:
{
"email": "...",
"name": "...",
"password": "...",
"phone": "+..."
}
email
, name
and password
are required. phone
is optional.
For an extra layer of security, auth-service can be configured to provide a signed header that your application server can use to verify that the incoming request has indeed passed through auth-service. This could be useful in a situation where the proxy server become compromised, or if some other container manages to reach the container running your application server.
Option | Required | Type | Default | Description |
---|---|---|---|---|
SIGNED_HEADERS |
No | Boolean (true or false ) |
true |
Whether or not the signed header functionality is enabled. If the functionality is enabled, an Ed25519 private key can be provided (see below). |
A Ed25519 key pair has to be generated in order to sign and verify headers.
auth-service-backend only needs a private key, since auth-service-backend only
generates signed headers (not verifies them). The private key has to be in a
Base64-DER-encoded format. The private key should be mounted to
/run/secrets/header_signing_private_key
in the auth-service-backend container.
The key has to be passed as a file and not an environment variable to prevent it
from leaking.
When a private key has been generated and mounted, the application server can then verify the signatures using the public key. The application server should not have access to the private key. The public key does not have to be secured and can be passed to the application server as an environment variable.
If a private key is not provided, auth-service-backend sets a random (and temporary) private key. In this scenario, the application server would just ignore the signed headers (since it doesn't have the public key to verify them).
Instructions for generating the key pair follows.
Start by using openssl
to generate the private key, and encoding it with
base64
:
$ openssl genpkey -algorithm ed25519 -outform der | base64 > ed25519.priv.der
Make sure to set the right permissions on the private key:
$ chmod 0600 ed25519.priv.der
The public key can then be created like this:
$ base64 -d ed25519.priv.der \
| openssl pkey -inform der -pubout -outform der \
| base64 > ed25519.pub.der
For more information about verifying headers, see Haskell Library.
The auth-service-proxy service can be configured in the same way as the auth-service-backend service can, that is with environment variables.
Only one option is supported.
Option | Required | Type | Default | Description | Example |
---|---|---|---|---|---|
SESSION_COOKIES |
No | Boolean (true or false ) |
false |
Whether or not cookies should be session cookies (as opposed to “permanent” cookies). | SESSION_COOKIES=false |
AUTH_SERVICE |
No | Text | auth-service-backend:80 | Host and port of the auth service backend | AUTH_SERVICE=auth-service-backend:8080 |
NORATELIMIT |
No | Boolean (true or false ) |
false | Disable rate limiting | NORATELIMIT=true |
INSTANCE_PORTS |
No | List of string=integer pairs | 80 for instances that aren't mentioned |
Port of upstream web services as a semicolon-separated list of instance=port pairs | INSTANCE_PORTS=e01e95a7-c2b9-40ca-bd31-cd602f62ad84=8000;676be2b5-380b-4cc2-93fb-d3fa70aa7e51=8081 |
DEBUG |
No | Boolean (true or false ) |
false | Use nginx-debug and enable debug output | DEBUG=true |
ACCESS_LOG |
No | path or false |
false | Path to write access log | ACCESS_LOG=/var/log/nginx.access.log |
ERROR_LOG |
No | path or false |
false | Path to write error log | ERROR_LOG=/var/log/nginx.error.log |
Cookies set when calls to /api/login
are made are “permanent” by default. “Permanent” cookies expires Fri, 01-Jan-2038 00:00:01 GMT
.
Like demonstrated above, users can be added by running the adduser
command in
the auth-service-backend
container:
$ docker-compose exec auth-service-backend auth-service \
adduser [email protected] secret "Example User"
Additionally, users with the admin
role can create new users via a POST
request to /api/users
with a request body like the following:
{
"email": "...",
"instances": ["de872e04-290a-40fc-a09b-855e078f94c9"],
"name": "...",
"password": "...",
"phone": "+...",
"roles": ["some-role"]
"uuid": "b7bf312a-b589-429d-987c-8ac61c2b6eb4",
}
uuid
and phone
are optional. uuid
will be filled in automatically if
unset.
Response:
{
"user": "b7bf312a-b589-429d-987c-8ac61c2b6eb4",
"roles": ["some-role"]
}
Login passwords can be changed via a POST
request to /api/change-password
with a request body like the following:
{
"oldPasword": "...",
"newPassword": "..."
}
Additionally, passwords can be changed by running the chpasswd
command in
the auth-service-backend
container:
$ docker-compose exec auth-service-backend auth-service \
chpass [email protected] some-password
Using the above command, “some-password” would become the new password.
Users can have roles set. The roles will be passed to the backend container in
the X-Roles
header.
You can modify a user's roles by using the addrole
and removerole
commands:
$ docker-compose exec auth-service-backend auth-service \
addrole [email protected] some-role
$ docker-compose exec auth-service-backend auth-service \
rmrole [email protected] some-role
Using the above commands, the user was first added to, and then removed from, the “some-role” role.
auth-service supports single sign-on via SAML 2.0. SP-initiated and IdP-initiated login flows are supported.
SP-initiated login flows are supported via redirect request bindings (to
/api/sso/login
) and POST response bindings (to /api/sso/assert
).
IdP-initiated login flows are supported via POST response bindings
(again to /api/sso/assert
) if allow_unsolicited_responses
is set to true
.
Three attributes are supported, of which two are required. The name and email attributes are required. The role attribute is optional. Multiple role attributes may be specified; however, joined values are not accepted.
The IdP will need a client ID, a root URL, and a certificate for encrypting assertions.
The SP will need a request URL for authentication requests and a realm signing certificate.
Please note that when a local account and an SSO account will be considered
distinct (even if the same e-mail address is used). That means that a
/api/disable-sessions
request using an SSO token will not expire tokens for
local accounts, and vice versa.
For more information, see the SAML documentation.
We provide a Haskell library for working with signed headers and the internal API. For more information, see the library documentation.
Copyright © 2015-2023 Nejla AB. All rights reserved.
Twilio and Twiml are registered trademarks of Twilio and/or its affiliates. Other names may be trademarks of their respective owners.