- Client App - the application which uses Orion as a backend, responsible for authenticating the user via the Auth API,
- Auth API - Orion's authentication REST API, separate from the Orion GraphQL API
- GraphQL API - Orion's GraphQL API, exposing all the GraphQL queries and mutations, accessible only by authenticated users,
- User - any user of the Client App / Orion, regardless of whether they have a registered Gateway account or not,
- Anonymous user - a user who either doesn't have a Gateway account or is not logged in to a Gateway account and therefore uses anonymous authentication.
- Root user - a special kind of user, typically a gateway operator, with extra privileges to execute certain GraphQL API queries and mutations. It is initially created during database migration step based on the environment variables provided by the gateway administrator.
- Gateway account owner - User that has registered and owns a Gateway account.
- Authenticated request - a request which includes a valid session cookie (as described here) and can therefore be associated with an existing, active session (stored in Orion's database).
- Authentication request - a request to perform the authentication and start a new session (either
POST /login
orPOST /anonymous-auth
). - Gateway account - an account that exists in Orion's database and can be logged in to, not to be confused with Blockchain account or Blockchain membership. Each Gateway account is associated with exactly one Blockchain account (which can be changed via
POST /change-account
endpoint) and exactly one Blockchain membership (which is immutable). - Blockchain account - an account that exists on the Joystream blockchain and can be identified with an address, such as
j4W7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf
for example. A Blockchain account can be associated with exactly one Gateway account. - Blockchain membership - a membership created on a Joystream blockchain, which can be identified with a
handle
. A Blockchain membership can be associated with exactly one Gateway account.
Orion's auth API is a REST API, separate from the GraphQL API (the main Orion API), which is being secured by it.
This approach can also be called out-of-band authenticaiton, to distinguish it from in-band authentication, which would be an authentication implemented as part of the same GraphQL api that is being secured by it.
The Auth API implementation can be found in the src/auth-server
directory.
The implementation is based on the OpenAPI schema which can be found here.
The autogenerated Markdown documentation of the API can be found here. It is generated from the OpenAPI schema via npm run generate:docs:auth-api
command.
The input schema which defines the entities related to user authentication can be found in /schema/auth.graphql file.
User
entity is the most basic representation of a Client App / Orion user, it can be either an anonymous user (have no related Account
) or a gateway account owner.
Each User
has a securely random id
(32-byte string) assigned on creation, which can be stored on user's device (for example, in Browser's local storage) or shared across multiple devices in order to authenticate the user using anonymous authentication and preserve some information about their activity on the platform.
A User
can be associated with activities such as viewing a video, or searching for specific content, which can be later used to provide a personalized experience to the user once they create an account.
Some example functionality that can be enabled for an anonymous User
s (not all of those features are currently implemented):
- Video view history
- Continue watching...
- Search history
- Basic recommendations
We may choose not to provide all of those features to anonymous Users, but it should be possible to at least collect the user activity data, which can later be preserved once the user creates an account (and becomes a gateway account owner), because of the User
<=> Account
association.
Important: id
of a User
that has been associated with an Account
can no longer be used to authenticate as anonymous user (ie. cannot be used for anonymous authentication)!
Session
represents a period of activity of a User
that interacts with the Client App or Orion API directly, during which the user can perform authenticated requests (either as anonymous user or gateway account owner) and access the GraphQL API.
For more information about sessions see Sessions and authenticated requests.
An Account
represents a Gateway account which can be accessed by the Gateway account owner by providing a signed login message. The login message must be signed by the Blockchain account associated with the Gateway account, see: POST /login
endpoint.
The current Blockchain account of a Gateway account is stored in the joystreamAccount
field of the Account
entity. It can be changed via POST /change-account
endpoint.
The Blockchain membership associated with the Gateway account is stored in the membership
field of the Account
entity. It is assigned on account creation and cannot be changed.
EncryptionArtifacts
represents a set of encryption artifacts (cipherIv
and encryptedSeed
) which can be used by the Client app to decrypt the seed
of a Blockchain account based on the account's login credentials (email
and password
). For details, see: Authentication API interactions (specifically Create user account and Login using e-mail and password flows).
The EncryptionArtifacts
can be changed together with the Blockchain account associated with the Gateway account, see Change blockchain account & encryption artifacts associated with the gateway account flow.
SessionEncryptionArtifacts
represents a set of encryption artifacts (cipherIv
and cipherKey
) associated with a given session, allowing the Client app to more securely store Blockchain account's seed throughout the session. For details, see Store encrypted seed for the duration of the session flow.
Token
represents a unique, securely random string generated by the Auth API for a given Account
which can allow executing a specific action on behalf of the user without the need for authentication, Currently the only use-case for tokens is e-mail confirmation.
A token has an expiry date which depends on the Orion configuration (see: Configuration variables).
Those configuration variables can be set as part of the environment, for more details about config variables see Config variables.
OPERATOR_SECRET
- a secret string used as an identifier of the Root user, which is created during the database migration step. Important: Anyone who knows this secret can authenticate as the Root user (Gateway operator) and access the restricted queries and mutations!SESSION_EXPIRY_AFTER_INACTIVITY_MINUTES
- after how many minutes does the session expire in case they are no authenticated requests associated with the session being performed.SESSION_MAX_DURATION_HOURS
- after how many hours does the session expire regardless of whether there were any recent authenticated requests associated with the session performed.SENDGRID_API_KEY
- API key for the Sendgrid API, used for sending e-mails to the Gateway account owners by Orion (currently only for the purpose of e-mail confirmation)SENDGRID_FROM_EMAIL
- e-mail address that will be used as the sender of e-mails sent to the Gateway account owners by Orion.APP_NAME
- the name of the Gateway. It will be used in the e-mails sent to the Gateway account owners. It also has to be specified as part of the payload of some signed messages that need to be provided to the authentication api to make certain actions. For example, the log-in message which has to be provided in order to authenticate as Gateway account owner.EMAIL_CONFIRMATION_ROUTE
- the route in the Client app that will be used to confirm the e-mail address of a Gateway account owner.EMAIL_CONFIRMATION_TOKEN_EXPIRY_TIME_HOURS
- self-explainatoryEMAIL_CONFIRMATION_TOKEN_RATE_LIMIT
- how many requests for a new e-mail confirmation token can be made withinEMAIL_CONFIRMATION_TOKEN_EXPIRY_TIME_HOURS
for a given e-mail addressACCOUNT_OWNERSHIP_PROOF_EXPIRY_TIME_SECONDS
- how many seconds have to pass since the timestamp included in a signed message that proves the ownership of a Blockchain account (ie.ActionExecutionRequestData
) in order for that message to be considered expired.COOKIE_SECRET
- secret used to sign cookies, to make sure they come from Orion and have not been tampered with.
HttpOnly, same-site: strict
session cookie is used as an authentication mechanism (both by the Auth API and the GraphQL API).
This implies that the Client App, the GraphQL API and the Auth API must be hosted on the same domain!
The cookie is called session_id
and stores the unique, randomly generated id
of a Session
entity in the database. It is set upon successful /login
or /anonymous-auth
request.
Upon receiving an authenticated request (ie. a request that contains a valid session_id
cookie), the server reads session information associated with the session identified by the given session_id
, either directly from the database (which is shared between GraphQL API server and the Auth API server) or from a memory session cache.
Each session, besides being associated with a specific user (either an anonymous user or Gateway account owner), includes the following information:
Session.ip
- ip address of the agent that performed the authentication request,Session.browser
- browser that was used to perform the authentication request, as derived from theuser-agent
header,Session.os
- operating system that was used to perform the authentication request, as derived from theuser-agent
header,Session.device
- device that was used to perform the authentication request, as derived from theuser-agent
header,Session.expiry
- the date at which the session should expire or did expire.
This information is then compared with the authenticated request data. It is required that:
Session.ip
matches the IP of the agent that made the authenticated request,Session.browser
,Session.os
andSession.device
match the values derived from theuser-agent
header included in the authenticated request,Session.expiry
is< Date.now()
.
This basically means that ip
, brower
, os
and device
should not change during the course of a given session. In case any of those change, a re-authentication is required.
This solution makes it possible to track the activity of a given User
more accurately and adds additional layer of security, as even a stolen session cookie would be useless unless the attacker can make requests from the user's ip.
A session can expire:
- If it is associated with an account and the account owner performs a
POST /logout
request, - If it is associated with anonymous user and the user creates a Gateway account,
- After
SESSION_EXPIRY_AFTER_INACTIVITY_MINUTES
minutes of inactivity, counted from the last authenticated request, - After
SESSION_MAX_DURATION_HOURS
hours, starting from the time when the session was created.
Different requests may still require different privileges, ie. some mutations like setSupportedCategories
will be only accessible for root user etc., while other mutations may only be accessible for Gateway account owners.
This is the first step required in order to interact with the GraphQL API.
POST /api/v1/anonymous-auth
- Save
userId
from response to local storage and use it for subsequent guest auth requests once the session expires.
POST /api/v1/anonymous-auth
{ "userId": "operator-secret" }
(where operator-secret
must be the value of OPERATOR_SECRET
environment variable)
-
Authenticate as anonymous user first (see Anonymous auth)
-
Generate a random wallet seed and create encryption artifacts using user's credentials (e-mail, password). For reference code see the
prepareEncryptionArtifacts
implementation insidesrc/auth-server/tests/common.ts
, ie.:export async function calculateLookupKey(email: string, password: string): Promise<string> { return (await scryptHash(`lookupKey:${email}:${password}`, 'lookupKeySalt')).toString('hex') } export async function prepareEncryptionArtifacts( seed: string, email: string, password: string ): Promise<EncryptionArtifacts> { // The `encryptionArtifacts.id` is deterministic: // It's an scrypt hash of the combination of user's e-mail and password // salted with some hardcoded `lookupKeySalt` value. const id = await calculateLookupKey(email, password) // The `encryptionArtifacts.cipherIv` is a random 16-byte value. const cipherIv = randomBytes(16) // The `cipherKey` is derived using a combination of user's e-mail and password. // `cipherIv` is used as an scrypt hash salt in this case. const cipherKey = await scryptHash(`cipherKey:${email}:${password}`, cipherIv) // The `seed` should be a random 16/32-byte value. // In this case we're using a string value, but you could also use a Buffer. // `encryptedSeed` is the result of encrypting the `seed` using `cipherKey` and `cipherIv` // with AES-256-CBC algorithm. const encryptedSeed = aes256CbcEncrypt(seed, cipherKey, cipherIv) return { id, cipherIv: cipherIv.toString('hex'), encryptedSeed, } }
-
Create an on-chain membership using the address derived from the seed generated in the previous step as both
controllerAccount
androotAccount
. The easiest way to do that would be to use the Joystream membership faucet service. -
Make request to create a new account:
POST /api/v1/account { "signature": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "payload": { "joystreamAccountId": "j4W7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf", "memberId": "123", "gatewayName": "Gleev", "timestamp": 1682624588376, "action": "createAccount", "email": "[email protected]", "encryptionArtifacts": { "id": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "encryptedSeed": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "cipherIv": "0xffffffffffffffffffffffffffffffff" } } }
Where:
signature
is a signature overJSON.stringify(pyaload)
joystreamAccountId
is the address of the keypair generated fromseed
(see step 2.)memberId
must be the id of the on-chain membership created in step 3. (the membership must be already processed by Orion)gatewayName
must match theAPP_NAME
environment variabletimestamp
must be current timestamp in millisecondsaction
must becreateAccount
email
must be the e-mail provided by the user in step 2.encryptionArtifacts
must be the same as the ones generated in step 2.
-
User provides email and password
-
Compute
id
of the artifacts using the provided e-mail and password (seecalculateLookupKey
implementation insrc/auth-server/tests/common.ts
), ie.:export async function calculateLookupKey(email: string, password: string): Promise<string> { return (await scryptHash(`lookupKey:${email}:${password}`, 'lookupKeySalt')).toString('hex') }
-
Get the artifacts:
GET /api/v1/artifacts?id={id}&email={email}
Where:
id
is theid
of the artifacts computed in step 2.email
is the e-mail provided by the user in step 1.
In response you get the stored
cipherIv
andencryptedSeed
. -
You can now decrypt user's seed using those artifacts, for reference code see
decryptSeed
implementation insrc/auth-server/tests/common.ts
, ie.:export async function decryptSeed( email: string, password: string, // Those are the values provided by the server as a response to `GET /api/v1/artifacts`: { cipherIv, encryptedSeed }: EncryptionArtifacts ): Promise<string> { const cipherIvBuf = Buffer.from(cipherIv, 'hex') // The `cipherKey` can be derived using a combination of user's e-mail, password and `cipherIv`. const cipherKey = await scryptHash(`cipherKey:${email}:${password}`, cipherIvBuf) // The `seed` can be decrypted using `cipherKey` and `cipherIv` with AES-256-CBC algorithm. return aes256CbcDecrypt(encryptedSeed, cipherKey, cipherIvBuf) }
-
Make the login request:
POST /api/v1/login { "signature": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "payload": { "joystreamAccountId": "j4W7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf", "gatewayName": "Gleev", "timestamp": 1682624588376, "action": "login" } }
Where:
signature
is a signature overJSON.stringify(payload)
joystreamAccountId
is the address of the account from the decrypted seedgatewayName
must match theAPP_NAME
environment variabletimestamp
must be current timestamp in millisecondsaction
must belogin
In response you'll get the
accountId
of the logged in account. You can always check the data associated with the logged in account using theaccountData
GraphQL query.
After the user is logged in, you can encrypt their wallet seed to store it more safely (for example: in local storage) for the duration of the session.
In order to do this:
- Generate random
cipherIv
(16 bytes) andcipherKey
(32 bytes) - Encrypt the seed using generated artifacts with AES-256-CBC algorithm
- Save session artifacts on the server:
POST /api/v1/session-artifacts { "cipherKey": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "cipherIv": "0xffffffffffffffffffffffffffffffff" }
- You can retrieve the stored session artifacts later in order to decrypt the locally stored, encrypted seed:
GET /api/v1/session-artifacts
You can change the Blockchain account and remove or update EncryptionArtifacts associated with the Gateway account at the same time using POST /change-account
endpoint.
There are 2 main use cases for this:
- Migrating from password-based authentication to a more secure external signer authentication: In this case you usually change the Blockchain account and remove the EncryptionArtifacts (ie. not provide the
newArtifacts
field in the request) - Changing the account's password: In this case you can either change the EncryptionArtifacts, but keep the old Blockchain account or change both (in which case you need to migrate the assets and the membership to the new account first)
POST /api/v1/change-account
{
"signature": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"payload": {
"joystreamAccountId": "j4UYhDYJ4pz2ihhDDzu69v2JTVeGaGmTebmBdWaX2ANVinXyE",
"gatewayName": "Gleev",
"timestamp": 1682624588376,
"action": "changeAccount",
"gatewayAccountId": "00000001",
"newArtifacts": {
"id": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"encryptedSeed": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"cipherIv": "0xffffffffffffffffffffffffffffffff"
}
}
}
Where:
signature
is a signature overJSON.stringify(payload)
(from the new Blockchain account)joystreamAccountId
is the address of the new Blockchain account (can be the same as the currently used one)gatewayName
must match theAPP_NAME
environment variabletimestamp
must be current timestamp in millisecondsaction
must bechangeAccount
gatewayAccountId
must be theaccountId
of the logged in Gateway account (as provided by the server in response toPOST /api/v1/login
oraccountData
GraphQL query)newArtifacts
optionally, the new EncryptionArtifacts if password-authentication is still being used
Although this is not strictly done through the Auth API, it is worth mentioning that you can always retrieve the data of the currently logged-in Gateway Account by executing the following GraphQL query:
{
accountData {
id
email
joystreamAccount
isEmailConfirmed
membershipId
}
}
POST /api/v1/logout
The central part of the Orion Auth Server is the OpenAPI schema (src/auth-server/openapi.yml
). It defines the API endpoints, their parameters and responses.
The server itself is implemented using Express.js with express-openapi-validator
middleware. The middleware is responsible for validating the requests (and optionally responses) against the OpenAPI schema and provides some other useful features like:
- mapping requests to operation handler functions based on the
x-eov-operation-handler
property specified in the OpenAPI schema, so that individual routes don't have to be defined manually, - applying security handlers based on the
security
property specified in the OpenAPI schema.
npm run generate:types:auth-api
- generates TypeScript types based on the OpenAPI schema. Those types are saved insrc/auth-server/generated/api-types.ts
.npm run generate:docs:auth-api
- generates the markdown documentation based on the OpenAPI schema. The documentation is saved insrc/auth-server/docs
.npm run tests:auth-api
- runs the auth API unit tests.
The process of introducing changes to the API usually involves:
- Making changes to the OpenAPI schema (
src/auth-server/openapi.yml
). - Updating the autogenerated types and documentation by running
npm run generate:types:auth-api
andnpm run generate:docs:auth-api
. - Making changes to the code to reflect the changes in the API. This usually involves adding or modifying the handlers in
src/auth-server/handlers
. The handlers are automatically connected to the API endpoints based on thex-eov-operation-handler
property specified in the OpenAPI schema (ie. the filename of the handler must match the value of this property). - Adding/adjusting the unit tests in
src/auth-server/tests
. - Running the unit tests (
npm run tests:auth-api
) and manually testing the API (see the section below)
The unit tests are located in src/auth-server/tests
. They are written using Mocha.js framework and supertest.
Generally for each API endpoint there should be at least one test case for each of the response codes defined in the OpenAPI schema.
Some common, reusable utilities and fixtures, like functions for doing anonymous auth, creating new accounts, signing in, verifying endpoint rate limits, encrypting and decrypting data etc. are located in src/auth-server/tests/common.ts
.
You can use the OpenAPI playground (generated by swagger-ui-express
package) to test the API locally.
To do that you need to set the OPENAPI_PLAYGROUND
environment variable to true
before starting the server. For example:
# If not already done, install dependencies, run codegen and build the code:
# make prepare
export OPENAPI_PLAYGROUND=true
docker-compose up -d orion_auth-api
# Or `make up` / `make up-squid` to run all the Orion services
By default the Auth API is served at http://localhost:4074/api/v1
and the playground is available at http://localhost:4074/playground
.
When deploying to the staging environment, you can sidestep the same-site: strict
and CORS restrictions in order to be able to test the API with the Client app (like Atlas) deployed under a different domain.
To do that you need to make sure to set those 2 environment variables:
export ORION_ENV=development
export DEV_DISABLE_SAME_SITE=true
Warning: Never use those settings in production! This configuration is much less secure and should only be used for testing purposes.
In order to be able to pass the cookie to Orion's Auth API when making requests from Atlas deployed under different domain, you should specify credentials: 'include'
option in ApolloClient's HttpLink
(see: https://www.apollographql.com/docs/react/networking/authentication/).
Similarly, to include the cookie when making requests to the GraphQL API, you should provide credentials: 'include'
to fetch
: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included