OpenCompany Authentication Service
I've come to learn there is a virtuous cycle to transparency and a very vicious cycle of obfuscation.
-- Jeff Weiner
Teams struggle to keep everyone on the same page. People are hyper-connected in the moment with chat and email, but it gets noisy as teams grow, and people miss key information. Everyone needs clear and consistent leadership, and the solution is surprisingly simple and effective - great leadership updates that build transparency and alignment.
With that in mind we designed Carrot, a software-as-a-service application powered by the open source OpenCompany platform and a source-available web UI.
With Carrot, important company updates, announcements, stories, and strategic plans create focused, topic-based conversations that keep everyone aligned without interruptions. When information is shared transparently, it inspires trust, new ideas and new levels of stakeholder engagement. Carrot makes it easy for leaders to engage with employees, investors, and customers, creating alignment for everyone.
Transparency expectations are changing. Organizations need to change as well if they are going to attract and retain savvy teams, investors and customers. Just as open source changed the way we build software, transparency changes how we build successful companies with information that is open, interactive, and always accessible. Carrot turns transparency into a competitive advantage.
To get started, head to: Carrot
The OpenCompany Authentication Service handles authenticating users against Slack and email/pass, and creates a JSON Web Token for them, which can then be used with the other OpenCompany services to assert the users identity.
In addition, the Authentication Service handles team management and membership.
Prospective users of Carrot should get started by going to Carrot.io. The following local setup is for developers wanting to work on the OpenCompany Authentication Service.
Most of the dependencies are internal, meaning Leiningen will handle getting them for you. There are a few exceptions:
- Java - a Java 8+ JRE is needed to run Clojure
- Leiningen - Leiningen 2.9.1+ is a Clojure build and dependency management tool
- RethinkDB - RethinkDB v2.3.6+ is a multi-modal (document, key/value, relational) open source NoSQL database
Your system may already have Java 8+ installed. You can verify this with:
java -version
If you do not have Java 8+ download it and follow the installation instructions.
An option we recommend is OpenJDK. There are instructions for Linux and Homebrew can be used to install OpenJDK on a Mac with:
brew update && brew cask install adoptopenjdk8
Leiningen is easy to install:
- Download the latest lein script from the stable branch.
- Place it somewhere that's on your $PATH (
env | grep PATH
)./usr/local/bin
is a good choice if it is on your PATH. - Set it to be executable.
chmod 755 /usr/local/bin/lein
- Run it:
lein
This will finish the installation.
Then let Leiningen install the rest of the dependencies:
git clone https://github.com/open-company/open-company-auth.git
cd open-company-auth
lein deps
RethinkDB is easy to install with official and community supported packages for most operating systems.
Assuming you are running Mac OS X and are a Homebrew user, use brew to install RethinkDB:
brew update && brew install rethinkdb
If you already have RethinkDB installed via brew, check the version:
rethinkdb -v
If it's older, then upgrade it with:
brew update && brew upgrade rethinkdb && brew services restart rethinkdb
Follow the instructions provided by brew to run RethinkDB every time at login:
ln -sfv /usr/local/opt/rethinkdb/*.plist ~/Library/LaunchAgents
And to run RethinkDB now:
launchctl load ~/Library/LaunchAgents/homebrew.mxcl.rethinkdb.plist
Verify you can access the RethinkDB admin console:
open http://localhost:8080/
After installing with brew:
- Your RethinkDB binary will be at
/usr/local/bin/rethinkdb
- Your RethinkDB data directory will be at
/usr/local/var/rethinkdb
- Your RethinkDB log will be at
/usr/local/var/log/rethinkdb/rethinkdb.log
- Your RethinkDB launchd file will be at
~/Library/LaunchAgents/homebrew.mxcl.rethinkdb.plist
If you don't use brew, there is a binary installer package available for Mac OS X from the Mac download page.
After downloading the disk image, mounting it (double click) and running the rethinkdb.pkg installer, you need to manually create the data directory:
sudo mkdir -p /Library/RethinkDB
sudo chown <your-own-user-id> /Library/RethinkDB
mkdir /Library/RethinkDB/data
And you will need to manually create the launchd config file to run RethinkDB every time at login. From within this repo run:
cp ./opt/com.rethinkdb.server.plist ~/Library/LaunchAgents/com.rethinkdb.server.plist
And to run RethinkDB now:
launchctl load ~/Library/LaunchAgents/com.rethinkdb.server.plist
Verify you can access the RethinkDB admin console:
open http://localhost:8080/
After installing with the binary package:
- Your RethinkDB binary will be at
/usr/local/bin/rethinkdb
- Your RethinkDB data directory will be at
/Library/RethinkDB/data
- Your RethinkDB log will be at
/var/log/rethinkdb.log
- Your RethinkDB launchd file will be at
~/Library/LaunchAgents/com.rethinkdb.server.plist
If you run Linux on your development environment (good for you, hardcore!) you can get a package for you distribution or compile from source. Details are on the installation page.
RethinkDB isn't supported on Windows directly. If you are stuck on Windows, you can run Linux in a virtualized environment to host RethinkDB.
A secret is shared between the Storage service and the Auth service for creating and validating JSON Web Tokens.
A Slack App needs to be created for OAuth authentication. For local development, create a Slack app with a Redirect URI of http://localhost:3003/slack-oauth
and get the client ID and secret from the Slack app you create.
An AWS SQS queue is used to pass messages to the OpenCompany Authentication service from the OpenCompany Slack Router service and to pass messages from the OpenCompany Authentication service to the OpenCompany Bot and OpenCompany Email service. Setup SQS Queues and key/secret access to the queue using the AWS Web Console or API.
You will also need to subscribe the SQS aws-sqs-slack-router-auth-queue
queue to the Slack Router service SNS topic. To do this you will need to go to the AWS console and follow these instruction:
Go to the AWS SQS Console and select the SQS queue configured above. From the 'Queue Actions' dropdown, select 'Subscribe Queue to SNS Topic'. Select the SNS topic you've configured your Slack Router service instance to publish to, and click the 'Subscribe' button.
An AWS SNS pub/sub topic is used to push user events to interested listeners, such as the OpenCompany Storage service. To take advantage of this capability, configure the aws-sns-auth-topic-arn
with the ARN (Amazon Resource Name) of an SNS topic you setup in AWS. Then follow the instructions in the other services to subscribe to the SNS topic with an SQS queue.
The aws-sqs-expo-queue
is read by auth directly to consume Expo push notification tickets produced by the notify service. These tickets are then
exchanged for push notification receipts with the Expo server in order to determine if the pushes were successful. In the case that a push notification
failed, it is the responsibility of the auth service to remove the invalid push tokens from the database. This can happen if the user uninstalls the mobile application, for example. Subsequent push notification attempts will fail, because the push token on record is no longer valid. Expo recommends allowing 30 minutes to pass before a ticket is exchanged for a receipt to allow the respective push services time to deal with high volume. Because of this, the SQS queue should be configured with a delay in production scenarios. At the time or writing, the maximum queue delay allowed is 15 minutes, which is okay for our purposes.
For an explanation on configuring aws-lambda-expo-prefix
, please see the notify service documentation.
Make sure you update the CHANGE-ME
items in the section of the project.clj
that looks like this to contain your actual JWT, Slack, and AWS secrets:
;; Dev environment and dependencies
:dev [:qa {
:env ^:replace {
:db-name "open_company_auth_dev"
:liberator-trace "true" ; liberator debug data in HTTP response headers
:open-company-auth-passphrase "this_is_a_dev_secret" ; JWT secret
:hot-reload "true" ; reload code when changed on the file system
:open-company-slack-client-id "CHANGE-ME"
:open-company-slack-client-secret "CHANGE-ME"
:aws-access-key-id "CHANGE-ME"
:aws-secret-access-key "CHANGE-ME"
:aws-sqs-bot-queue "CHANGE-ME"
:aws-sqs-email-queue "CHANGE-ME"
:aws-sqs-slack-router-auth-queue "CHANGE-ME"
:aws-sns-auth-topic-arn "CHANGE-ME"
:aws-sqs-payments-queue "" ; Queue to send team size changes to (optional)
:aws-sqs-expo-queue "CHANGE-ME"
:aws-lambda-expo-prefix "CHANGE-ME"
:aws-sns-auth-topic-arn "" ; SNS topic to publish notifications (optional)
:log-level "debug"
}
You can also override these settings with environmental variables in the form of OPEN_COMPANY_AUTH_PASSPHRASE
and AWS_ACCESS_KEY_ID
, etc. Use environmental variables to provide production secrets when running in production.
Prospective users of Carrot should get started by going to Carrot.io. The following usage is for developers wanting to work on the OpenCompany Authentication Service.
Make sure you've updated project.clj
as described above.
To start a production instance:
lein start!
Or to start a development instance:
lein start
Open your browser to http://localhost:3003/test-token and check that it's working and the JWT works.
To clean all compiled files:
lein clean
To create a production build run:
lein build
To create a sample JSON Web Token for use in development without going through a full auth cycle, create an identity EDN file
formatted like the ones in /opt/identities
or use one of the identity EDN files provided, and run the utility:
lein run -m oc.auth.util.jwtoken -- ./opt/identities/camus.edn
Users can onboard and authenticate using Slack or email/password as an authentication option. Authentication via Slack uses Slack's OAuth provider. Authentication via email uses this OpenCompany Auth Service and the Buddy security library.
A Slack organization provides a built-in "in-group" of users, all the users that have access to the Slack
organization. The Slack organization works as the OpenCompany org-id
that authorizes users to the company. The
OpenCompany org-id
is assigned to the Slack organization ID of the creator of the company at creation time.
Subsequently anyone with that same Slack organization ID in their JWToken can access the company.
Using email for authentication complicates authorization a bit. In some cases for email, the email domain
(e.g. @opencompany.com
) acts as the same sort of "in-group". This is not always sufficient however:
- Sometimes this group will be too narrow (e.g. someone who is in the group, but does not have an email address from the group domain)
- Sometimes this group will be too broad (e.g. use by a department at a large company where thousands of people or more have email in the domain)
- Finally, consider a smaller or looser organization (e.g. a new startup, church group, school group, etc.) still using gmail or other public email addresses as their work email, so there is no "in-group" domain.
Because of these possible limitations, email authorization is based on email domain and/or email invitations from already authorized users.
Based on local settings in the OpenCompany Web application, a GET request is made to this authentication service at
/
to retrieve authentication settings. An unauthenticated response looks like:
{
"slack" : {
"links" : [
{
"rel" : "authenticate",
"method" : "GET",
"href" : "https://slack.com/oauth/authorize?client_id=6895731204.51562819040&redirect_uri=https://auth.carrot.io/slack/auth&state=open-company-auth&scope=bot,users:read",
"type" : "text/plain"
},
{
"rel" : "authenticate-retry",
"method" : "GET",
"href" : "https://slack.com/oauth/authorize?client_id=6895731204.51562819040&redirect_uri=https://auth.carrot.io/slack/auth&state=open-company-auth&scope=identity.basic,identity.email,identity.avatar,identity.team",
"type" : "text/plain"
}
]
},
"email" : {
"links" : [
{
"rel" : "authenticate",
"method" : "GET",
"href" : "/email/auth",
"type" : "text/plain"
},
{
"rel" : "create",
"method" : "POST",
"href": "/email/users",
"type" : "application/vnd.open-company.user.v1+json"
}
]
}
}
A response with an authenticated user is limited to just the URLs appropriate for that user. For a Slack user:
{
"links" :[
{
"rel" : "self",
"method" : "GET",
"href" : "/org/slack-a1b2-c3d4/users/slack-1234-5678",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "refresh",
"method" : "GET",
"href" : "/slack/refresh-token",
"type" : "text/plain"
},
{
"rel" : "invite",
"method" : "POST",
"href": "/org/slack-a1b2-c3d4/users/invite",
"type" : "application/vnd.open-company.invitation.v1+json"
},
{
"rel" : "users",
"method" : "GET",
"href" : "/org/slack-a1b2-c3d4/users",
"type" : "application/vnd.collection+vnd.open-company.user+json;version=1"
}
]
}
or for an email user:
{
"links" :[
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users/email-a1b2-c3d4",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "partial-update",
"method" : "PATCH",
"href" : "/org/email-1234-5678/users/email-a1b2-c3d4",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "refresh",
"method" : "GET",
"href" : "/email/refresh-token",
"type" : "text/plain"
},
{
"rel" : "invite",
"method" : "POST",
"href": "/org/email-1234-5678/users/invite",
"type" : "application/vnd.open-company.invitation.v1+json"
},
{
"rel" : "users",
"method" : "GET",
"href" : "/org/email-1234-5678/users",
"type" : "application/vnd.collection+vnd.open-company.user+json;version=1"
}
]
}
Upon clicking the "Sign in with Slack" button, the user is redirected to the rel
authenticate
URL to authenticate with
Slack. Slack then calls back to this authentication service with an authentication success or failure.
If the authentication was successful, the authentication service creates a JWToken and redirects the user back to the
web application at: /login?jwt=<JWToken>
If the authentication wasn't successful, the authentication service redirects the user back to the web application at
/login?access=denied
. From there, subsequent attempts to authenticate using the rel
authenticate-retry
URL that
does not authorize the bot can occur.
The main implication of a successful Slack authentication is the creation of a trusted JWToken that is then used to authorize all subsequent access to the API.
Upon clicking the "Sign in with Email" link, a GET
request is made to the rel
authenticate
URL with HTTP
Basic authentication (always over HTTPS) to authenticate with an email address and password. If the email/password
authentication succeeds, there is a 200
status with a JWToken returned in the body of the response. If the
email/password authentication fails, there is a 401
status for the response.
Headers:
Authorization: Basic <Email/Password Hash>
Accept: text/plain
The main implication of a successful email/pass authentication is the creation of a trusted JWToken that is then used to authorize all subsequent access to the API.
The JWToken contains an expiration field (24 hours for Slack users w/ an auth'd bot, and 2 hours for everyone else).
An expired JWToken does not authorize access to services. Before each use the JWToken is checked for expiration and if
it's expired, a request is made to rel
refresh
from the /
response. If the JWToken is successfully refreshed,
a 200
status is returned with a new JWToken in the response body.
If the token can't be refreshed (a non-20x
response), this typically means the user is not longer valid for the org,
and the client forgets the expired JWToken, makes a unauth'd request to /
and presents the login UI to the user to
start the authentication process again.
Unlike Slack, which doesn't need to differentiate between initial onboarding and subsequent authorizations, the onboarding of a new email user takes a different path.
To onboard a new email user, POST the following to rel
create
from the /
response:
Headers:
Content-Type: application/vnd.open-company.user.v1+json
Accept: text/plain
Body:
{
"email": "camus@combat.org",
"password": "Ssshhh!#&@!",
"first-name": "Albert",
"last-name": "Camus"
}
If successful, the 20x
response will contain a Location
header with the location of the newly created user,
as well as potentially a JWToken for the user in the body depending on the user sate.
A new user request can be one of 4 cases:
- Unknown user and unknown email domain (brand new)
- Return:
201
Created, JWToken - Email: Email validation sent via email
- Next step: Authenticated user accesses web application with JWToken
- User known by their email domain (e.g.
@opencompany.com
)
- Return status:
204
No content - Email: Email validation sent via email
- Next step: User must validate their email address from the validation email sent
- User known by an open invitation
- Return status:
204
No content - Email: Pending invite resent
- Next step: User must validate their email address from the invite email sent
- Already existing user
- Return status:
409
Conflict - Email: No
- Next step: UI error displayed to user
TBD.
Authenticated users can enumerate the users within the same org-id
with a GET request to rel
users
:
{
"collection" : {
"version" : "1.0",
"href" : "/org/email-1234-5678/users",
"links" : [
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users",
"type" : "application/vnd.collection+vnd.open-company.user+json;version=1"
}],
"users" : [
{
"real-name": "Simone de Beauvoir",
"avatar": "https://en.wikipedia.org/wiki/File:Simone_de_Beauvoir.jpg",
"email": "simone@lyceela.org",
"status": "active",
"links" : [
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users/email-6789-0123",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "delete",
"method" : "DELETE",
"href" : "/org/email-1234-5678/users/email-6789-0123"
}
]
},
{
"real-name": "Albert Camus",
"avatar": "http://www.brentonholmes.com/wp-content/uploads/2010/05/albert-camus1.jpg",
"email": "albert@combat.org",
"status": "pending",
"links" : [
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users/email-abcd-efgh",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "invite",
"method" : "POST",
"href" : "/org/email-1234-5678/users/email-abcd-efgh/invite",
"type" : "application/vnd.open-company.invitation+json;version=1"
},
{
"rel" : "delete",
"method" : "DELETE",
"href" : "/org/email-1234-5678/users/email-abcd-efgh"
}
]
}
]
}
}
Authenticated users can retrieve users within the same org-id
with a GET request to rel
self
.
For their own email user:
{
"email": "simone@lyceela.org",
"avatar": "https://en.wikipedia.org/wiki/File:Simone_de_Beauvoir.jpg",
"name": "Simone",
"first-name": "Simone",
"last-name": "de Beauvoir",
"real-name": "Simone de Beauvoir",
"links" :[
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users/email-a1b2-c3d4",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "partial-update",
"method" : "PATCH",
"href" : "/org/email-1234-5678/users/email-a1b2-c3d4",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "refresh",
"method" : "GET",
"href" : "/email/refresh-token",
"type" : "text/plain"
},
{
"rel" : "delete",
"method" : "DELETE",
"href" : "/org/email-1234-5678/users/email-a1b2-c3d4",
}
]
}
For their own Slack user:
{
"email": "sartre@lyceela.org",
"avatar": "http://existentialismtoday.com/wp-content/uploads/2015/11/sartre_22.jpg",
"name": "Jean-Paul",
"first-name": "Jean-Paul",
"last-name": "Sartre",
"real-name": "Jean-Paul Sartre",
"links" :[
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users/email-b2c3-d4e5",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "refresh",
"method" : "GET",
"href" : "/slack/refresh-token",
"type" : "text/plain"
}
]
}
And for other email users in the org:
{
"email": "Albert Camus",
"avatar": "http://www.brentonholmes.com/wp-content/uploads/2010/05/albert-camus1.jpg",
"name": "Albert",
"first-name": "Albert",
"last-name": "Camus",
"real-name": "Albert Camus",
"status": "pending",
"links" :[
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users/email-c3d4-e5f6",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "refresh",
"method" : "GET",
"href" : "/email/refresh-token",
"type" : "text/plain"
},
{
"rel" : "invite",
"method" : "POST",
"href" : "/org/email-1234-5678/users/email-c3d4-e5f6/invite",
"type" : "application/vnd.open-company.invitation+json;version=1"
},
{
"rel" : "delete",
"method" : "DELETE",
"href" : "/org/email-1234-5678/users/email-c3d4-e5f6",
}
]
}
Email users can update their own user properties, PATCH
the following to rel
partial-update
of the user
or user enumeration response:
Headers:
Authorization: Bearer <JWToken>
Content-Type: application/vnd.open-company.user.v1+json
Accept: text/plain
Body:
{
"first-name": "Albert",
"avatar": "http://www.brentonholmes.com/wp-content/uploads/2010/05/albert-camus1.jpg"
}
Valid properties to update are: email
, name
, first-name
, last-name
, real-name
, avatar
and password
.
If successful, the 200
response will contain an updated JWToken with the new user properties.
To invite a new email user, POST
the following to rel
invite
from the /
or a user response:
Headers:
Authorization: Bearer <JWToken>
Content-Type: application/vnd.open-company.invitation.v1+json
Accept: application/vnd.open-company.user.v1+json
Body:
{
"email": "camus@combat.org",
"company-name": "Combat",
"logo": "https://open-company-assets.s3.amazonaws.com/combat.png"
}
If successful, the 201
response will contain a Location
header with the location of the newly created user,
as well as a JSON body with user properties and links.
{
"user-id": "email-abcd-efgh",
"real-name": "",
"avatar": "",
"email": "albert@combat.org",
"status": "pending",
"links" : [
{
"rel" : "self",
"method" : "GET",
"href" : "/org/email-1234-5678/users/email-abcd-efgh",
"type" : "application/vnd.open-company.user+json;version=1"
},
{
"rel" : "invite",
"method" : "POST",
"href" : "/org/email-1234-5678/users/email-abcd-efgh/invite",
"type" : "application/vnd.open-company.invitation+json;version=1"
},
{
"rel" : "delete",
"method" : "DELETE",
"href" : "/org/email-1234-5678/users/email-abcd-efgh"
}
]
}
Upon receipt, an email invitation sends the user to the OC Web application at /invite?token=<one-time-use-token>
.
The web application GETs the rel
authenticate
link of email
from the unauth'd request to /
, passing the
token as the authorization:
Headers:
Authorization: Bearer <one-time-use-token>
If the token is accepted, the 200
response will contain a Location
header with the Location
of the newly
created user.
At this point, if additional information about the invited user is collected, it can be provided with a PATCH
of the rel
partial-update
link from the user response at the provided Location
.
TBD.
Users are stored as documents in the users
table of the open_company_auth
RethinkDB database.
In the case of a Slack user, owner
and admin
properties have to do with their privileges in the Slack organization. An example stored Slack user:
{
"user-id": "slack:U06SCTYJR",
"org-id": "slack:T07SBMH80",
"name": "camus",
"real-name": "Albert Camus",
"first-name": "Albert",
"last-name": "Camus",
"avatar": "http://...",
"email": "albert@combat.org",
"owner": "false",
"admin": "true",
"auth-source": "slack"
}
In the case of as email user, there are no owner
and admin
properties, but there is a status
property.
{
"user-id": "email:4567-a8f6",
"org-id": "email:bb92-67ga",
"name": "albert",
"real-name": "Albert Camus",
"first-name": "Albert",
"last-name": "Camus",
"avatar": "http://...",
"email": "albert@combat.org",
"status": "active",
"auth-source": "email"
}
THe media type application/jwt
is for JSON Web Tokens or JWTokens.
JWTokens are created as authorization tokens for authorized users. They are encrypted using public key cryptography, meaning anyone can decrypt them, but only services that know the private secret can create them and/or verify that they were created by a service that knows the private secret.
Once decrypted, they consist of the user data (see above) as well as an expiration timestamp (in milliseconds since the epoch) for the token.
In addition to being returned from some authorization API calls, they are expected to be included
in the Authorization
header of all authenticated API calls to any OpenCompany HTTP service.
An example JWToken payload for a Slack user:
{
"user-id": "325d-43ec-ae5d",
"teams": "slack:T07SBMH80",
"name": "Albert Camus",
"first-name": "Albert",
"last-name": "Camus",
"avatar-url": "http://...",
"email": "albert@combat.org",
"expire": 1474975206974,
"auth-source": "slack"
}
and for an email user:
{
"user-id": "325d-43ec-ae5d",
"org-id": "email:bb92-67ga",
"name": "Albert Camus",
"first-name": "Albert",
"last-name": "Camus",
"avatar-url": "http://...",
"email": "albert@combat.org",
"expire": 1474975206974,
"auth-source": "email"
}
Tests are run in continuous integration of the master
and mainline
branches on Travis CI:
To run the tests locally:
lein test!
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
Distributed under the GNU Affero General Public License Version 3.
Copyright © 2015-2022 OpenCompany, LLC
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.