Tags: OAuth2
Client Credential Flow is a widely used best practice to authorize software to access APIs of other services. Another best practice is to not DIY implement cybersecurity but to rely on mature well-tested existing tools. Ory, and its open source Hydra component, can easily be used to any OAuth2 scenario, including Client Credential Flow.
OAuth2 is a widely used standard for authorizing users on websites. For example, a "Log in with Facebook" button uses the OAuth2 protocol to approve someone to use a website based on their Facebook account.
There are several modes of using OAuth2, each of which is a "Flow". Where most OAuth2 flows involve a human being giving approval, the Client Credentials Flow is used for machine-to-machine communications where no human is involved. That is, software on one computer is attempting to access API functions on another computer, and authorization is handled using token exchanges described in the OAuth2 protocol.
An extreme simplification:
- An "OAuth2 Client" is generated which provides a
client_id
andclient_secret
token pair. - During this process a
scope
string is generated describing what kind of API functions the client can access. - The client software is then configured with these tokens.
- Before it tries to invoke API functions on the service, it first sends the
client_id/secret
to an API endpoint which generates anauthToken
. - That
authToken
is what's used in subsequent API calls.
There are many OAuth2 implementations from many vendors and open-source projects.
In this article we'll discuss using one of those - Ory - and its open source OAuth2 component - Hydra. The target is a sample API service implemented in TypeScript, with the API defined using OpenAPI.
Ory is a paid service offering a full suite of authorization and authentication services.
Ory, the company, has fully embraced the open source model. Every component of the hosted Ory platform is available as an open source project. For example, Hydra is the Ory-sponsored open source project which implements OAuth2 and OpenID protocols, and provides those features on the hosted Ory platform.
It's possible to self-host Ory components on your hardware, or to buy access to the hosted Ory service. Your needs and budget will lead you one way or the other.
Terminology: hosted Ory means using the paid Ory service, and self-hosted Hydra means to host the Hydra service on your hardware.
Implement a simple OpenAPI service w/ OAuth2 authorization
You can find source code on GitHub at: https://github.com/robogeek/oauth2-client-credentials-ory
The repository contains these directories
- server - server code
- cli - CLI tool to act as a client
- hydra - Configuration and scripts to self-host Hydra
- spec - separate the openapi definition and other schema definitions
For this article we'll use OpenAPI to describe a simple REST service. The /date
endpoint demonstrates a READ operation, the /echo
endpoint demonstrates a WRITE operation, and the /auth/token
endpoint is where a client program can use OAuth2 Client Credentials Flow to retrieve an access token.
The API is implmented by the server code, which is written on the Node.js platform, coded in TypeScript, and using openapi-backend
to handle most of OpenAPI. This framework easily plugs into ExpressJS or other server packages. It takes care of setting up route functions, data validation, and more. Your task is simply to write handler functions, and any required data persistence or business logic functions.
Behind the server, the OAuth2 functionality uses Ory services. We'll demonstrate using the hosted Ory (commercial) server, as well as self-hosting Hydra, the open source Ory component for OAuth2. OAuth2 client management and OAuth2 token generation/validation will be handled by that service.
Using Hosted ORY for authorization
In the first phase of this article we will use the hosted Ory service before diving into self-hosting Hydra.
Head over to https://www.ory.sh/ and sign up for a Developer account. This is free, but with usage limits we'll talk about in a minute.
Once you've logged in, create a new workspace. Make sure the workspace is in Developer mode.
You'll be guided next to create a project within the workspace. Make sure to use the Development environment.
In the project settings area we need to retrieve some identifiers. I created a shell script, project-ids.sh
, to store them:
ORY_NET_PROJECT_ID=bcbEXAMPLEb-8EXAMPLEx-4EXAMPLEm-9EXAMPLEl-dexample4b
ORY_NET_SLUG=cranky-wu-dexample4b
ORY_NET_API_ENDPOINT=https://cranky-wu-dexample4b.projects.oryapis.com
Next, in the project settings, click on the API Keys tab. There's some discussion saying that the API Keys are used when invoking ORY APIs. This page also has a link to instructions for installing the ORY CLI tool.
Click on the Create API Key link. In the popup window, you enter the key name, and click the Create button to generate the key. Store this key somewhere safe. You'll end up with something like this:
ORY_API_KEY=ory_pat_VmlJhEXAMPLEG63EXAMPLEMs4EXAMPLE06Q
In the OAuth2 tab you'll find a long list of URLs for different endpoints. Every URL is based on $ORY_NET_API_ENDPOINT
.
We need to record two additional variables for endpoints:
ORY_OAUTH2_API_ENDPOINT=$ORY_NET_API_ENDPOINT
ORY_ADMIN_API_ENDPOINT=$ORY_NET_API_ENDPOINT
The REST APIs supported by Ory and Hydra can be split into two. The dashboard lists endpoints defined for OAuth2, which in this list start with $ORY_NET_API_ENDPOINT/oauth2
while the Ory REST API simply starts at $ORY_NET_API_ENDPOINT
. Later, when we use Hydra, the two are on separate ports, and therefore we need two variables.
The next step is to create two OAuth2 "Clients". What ORY means by "client" is an entity for which a client_id
and client_secret
token pair has been generated.
In the OAuth2 tab we can generate clients. But, this can be done using the CLI tool as well as the REST API.
The CLI tool can be used like this:
client=$(ory create oauth2-client \
--endpoint "${ORY_ADMIN_API_ENDPOINT}" \
--project "${ORY_NET_PROJECT_ID}" \
--name "$1" \
--grant-type client_credentials \
--scope "$2" \
--token-endpoint-auth-method client_secret_post \
--format json
)
echo CLIENT ID=$(echo $client | jq -r '.client_id')
echo CLIENT SECRET=$(echo $client | jq -r '.client_secret')
echo $client | jq --indent 4
This is meant to be a shell script, such that $1
is a command-line parameter for the client name, and $2
is a command-line parameter for the OAuth2 scope string.
The client
variable contains the output of ory create oauth2-client
. The grant-type
of client_credentials
is used for machine-to-machine communications.
The --endpoint
parameter identifies which ORY service instance to use.
The --name
parameter gives a name to the client.
The --scope
parameter argument is a space-separated list of named capabilities for the client. In OAuth2, the scope determines which client (as identified by a token) is allowed to access which functions or resources.
The jq
tool (
https://jqlang.github.io/jq/) is used for extracting the client_id
and client_secret
from the returned JSON.
Running the above shell script looks like this:
$ sh mkclient.sh 'read' 'read_date'
Your session has expired or has otherwise become invalid. Please re-authenticate to continue.
Do you want to sign in to an existing Ory Network account? [y/n]: y
Email: your@email.dom
Password:
You are now signed in as: your@email.dom
It's easy to login if required. The script goes on to output the client ID/Secret keys, as well as the entire JSON payload.
The read
client, with the read_date
scope, can invoke the /date
endpoint. Another, write
, will have the post_echo
scope, and be able to invoke the /echo
endpoint.
Having set up these identifiers, we can now start the server.
Create a file named .env
which contains environment variables shown above. In one terminal window run the command npm run watch
, to auto-rebuild the server whenever the code is changed. In another window, run the command npm run monitor
to restart the server whenever the server is rebuilt.
The latter looks like this:
$ npm run monitor
> server@1.0.0 monitor
> npx nodemon --watch ./dist --watch ./../spec/spec.yml --delay 2 --exec 'npm start'
[nodemon] 3.1.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): dist/**/* ../spec/spec.yml
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `npm start`
> server@1.0.0 start
> env-cmd node dist/server.js
api listening at http://localhost:9090
Now that the server is running, and a couple OAuth2 "clients" have been generated, we can start using the client program. It's code is in the cli directory in the workspace, and it's a simple command-line tool.
The first step is to invoke the /auth/token
endpoint, using the client_id
and client_credential
tokens. It is supposed to return a token, and in this case the handler function will invoke an ORY endpoint.
Notice that I've started the server using the env-cmd
tool. This reads a file, .env
, inserting its contents as environment variables. It's an easy way to store process configuration via environment variables.
The node
command also has an --env-file
option:
$ node --help
Usage: node [options] [ script.js ] [arguments]
node inspect [options] [ script.js | host:port ] [arguments]
...
--env-file=... set environment variables from supplied file
...
$ node --env-file .env dist/server.js
This would be equivalent to using env-cmd
.
The .env
file being used is:
USE_ORY=true
ORY_NET_PROJECT_ID=bcbEXAMPLEb-8EXAMPLEx-4EXAMPLEm-9EXAMPLEl-dexample4b
ORY_NET_SLUG=cranky-wu-dexample4b
ORY_NET_API_ENDPOINT=https://cranky-wu-dexample4b.projects.oryapis.com
ORY_OAUTH2_API_ENDPOINT=$ORY_NET_API_ENDPOINT
ORY_ADMIN_API_ENDPOINT=$ORY_NET_API_ENDPOINT
ORY_NET_API_KEY=ory_pat_VmlEXAMPLEG634EXAMPLEzIEXAMPLE6Q
PORT=9090
In the server source, src/auth.ts
contains the functions for interfacing with ORY. At the top of that module is code to inspect the environment variables and initialize the behavior with ORY or Hydra services.
import { Configuration, IntrospectedOAuth2Token, OAuth2Api } from "@ory/client";
// Record URLs for the OAuth2 and Admin API endpoints
let ory_oauth_endpoint: string | undefined;
let ory_admin_endpoint: string | undefined;
if (typeof process.env.ORY_OAUTH2_API_ENDPOINT !== 'string') {
throw new Error(`auth.ts - ORY_OAUTH2_API_ENDPOINT required`);
} else {
ory_oauth_endpoint = process.env.ORY_OAUTH2_API_ENDPOINT;
}
if (typeof process.env.ORY_ADMIN_API_ENDPOINT !== 'string') {
throw new Error(`auth.ts - ORY_ADMIN_API_ENDPOINT required`);
} else {
ory_admin_endpoint = process.env.ORY_ADMIN_API_ENDPOINT;
}
const ory = new OAuth2Api(
new Configuration({
basePath: ory_admin_endpoint,
accessToken: process.env.ORY_NET_API_KEY,
}),
);
This configures a Node.js object for communicating with an ORY server.
Then, the /auth/token
handler:
export async function fetchToken(
c: Context, req: Request, res: Response
) {
const ccrequest
= isClientCredentialRequest(c?.request?.body);
if (ccrequest.error || !ccrequest.value) {
const msg = `fetchToken bad ClientCredentialRequest ${util.inspect(ccrequest.error?.details)} for ${YAML.dump({ ccrequest: ccrequest.value }, { indent: 4 })}`;
return res.status(400).json(reportProblem(msg, 400));
}
const credentials: ClientCredentialRequest
= ccrequest.value;
let clientData;
try {
clientData = await getOAuth2ClientInfo(credentials.client_id);
} catch (err: any) {
return res.status(500).json(reportProblem(
`fetchToken FAIL Did not receive client INFO because ${err.message}`,
500));
}
if (!clientData) {
return res.status(500).json(reportProblem(
`fetchToken FAIL Did not receive client INFO for unknown reason`,
500));
}
if (typeof credentials.scope === 'undefined'
|| (typeof credentials.scope === 'string'
&& credentials.scope.length === 0)
) {
console.warn(`fetchToken empty scope -- token will be generated using default scope`);
credentials.scope = clientData.scope;
}
if (!credentials.scope) {
return res.status(500).json(reportProblem(
`fetchToken FAIL No scope known for client`,
500));
}
const valid = isValidScopeString(credentials.scope);
if (!valid.okay) {
const msg = `fetchToken bad ClientCredentialRequest scope because ${valid?.message}`;
return res.status(400).json(reportProblem(msg, 400));
}
if (typeof process.env.ORY_NET_API_ENDPOINT !== 'string') {
const msg = `fetchToken FAIL InternalServiceError SYSTEM NOT CONFIGURED, NO ORY_NET_API_ENDPOINT`;
return res.status(500).json(reportProblem(msg, 500));
}
let oryServer;
try {
oryServer = new URL(ory_oauth_endpoint as string);
oryServer.pathname = 'oauth2/token';
} catch (err: any) {
const msg = `fetchToken FAIL InternalServiceError could not construct ORY URL from ${ory_oauth_endpoint}`;
return res.status(500).json(reportProblem(msg, 500));
}
let resp;
const options = {
// Got autoconverts this to use
//
// Content-Type
// application/x-www-form-urlencoded
//
// And to use URLSearchParams to
// encode the form field
// which is exactly what's required
form: credentials
};
try {
resp = await got.post(
oryServer.href, options
);
} catch (err: any) {
const statusCode = err.code;
const errorName = err.name;
const msg = `fetchToken FAIL ${errorName} (${statusCode}) ${oryServer.href} with ${err.message} `;
console.error(msg);
return res.status(statusCode).json(reportProblem(msg, statusCode));
}
console.log(resp.body);
if (resp) {
return res.status(200).json(resp.body);
} else {
const msg = `fetchToken unknown problem`;
return res.status(400).json(reportProblem(msg, 400));
}
}
export const scopeNames = [
'post_echo',
'read_date',
];
/**
* Test whether a scope name is legitimate.
* @param nm
* @returns
*/
export function isScopeName(nm: string): boolean {
return scopeNames.includes(nm);
}
export function isValidScopeString(scope: string)
: {
okay: boolean,
message?: string
} {
for (const scopenm of scope.split(' ')) {
if (scopenm === 'None') continue;
if (!isScopeName(scopenm)) {
return {
okay: false,
message: `Bad scope name ${scopenm}`
};
}
}
return {
okay: true
};
}
The length of this code snippet is due to careful error checking.
The isClientCredentialRequest
takes an object, uses a schema implemented with Joi to validate that the object is a ClientCredentialRequest, then if it validates the data is return typed as such. Otherwise the error
field is filled in with an error description.
In OAuth2 client_credential_flow
, the values grant_type
, client_id
, client_secret
, and scope
are encoded in application/x-www-form-urlencoded
format and sent in the body of a request. The response is a JSON object containing the access token and related data.
Our service is not directly implementing the logic to generate an OAuth2 token. Instead, it asks ORY to do so via this function.
In a real application, another server would be invoking the /api/auth/token
endpoint to trigger this function. For this example we will do this using the command-line tool.
The file cli/cli.js
is implemented using Commander to process the command-line.
program.option('-s, --server <url>', 'specify URL for ESX server');
function server() {
const opts = program.opts();
for (const key in opts) {
if (key === 'server') {
const _url = opts[key];
return new URL(_url);
}
}
throw new Error(`server could not generate URL ${util.inspect(opts)}`);
}
program
.command('fetch-token')
.description('Invoke /auth/token to fetch a token')
.option('--clientID <clientID>', 'Client access token string')
.option('--clientSecret <clientSecret>', 'Client access secret token string')
.option('--scope <scope>', 'Client access scope')
.action(async (options, command) => {
const esx = server();
esx.pathname = path.join('api', 'auth', 'token');
let clientID;
if (typeof options?.clientID !== 'undefined') {
clientID = options.clientID;
}
let clientSecret;
if (typeof options?.clientSecret !== 'undefined') {
clientSecret = options.clientSecret;
}
let scope;
if (typeof options?.scope !== 'undefined') {
scope = options.scope;
}
const ccrequest = {
grant_type: "client_credentials",
client_id: clientID,
client_secret: clientSecret,
scope: scope
};
let res;
try {
res = await got.post(
esx.href, { json: ccrequest }
);
if (res.statusCode !== 200) {
console.log(`ERROR ${res.statusCode} ${res.statusMessage} ${res.url} ${res.method} ${util.inspect(res.headers)}`, JSON.parse(res.body).title);
} else {
console.log(JSON.parse(res.body));
}
} catch (err) {
console.log(`fetch-token FAIL ${err?.code} ${err?.response?.statusCode} ${err?.response?.statusMessage} ${err?.message} ${err?.response?.requestUrl} ${err?.response?.body}`);
}
});
Running it looks like this:
$ node cli.js -s http://localhost:9090 fetch-token \
--clientID 23EXAMPLE6-4835-4316-8b6a-59EXAMPLEda465 \
--clientSecret 7bEXAMPLEH~ZKEXAMPLE6y~LpEXAMPLEX3
{
"access_token":"ory_at_qX1WEXAMPLEDOh-NK5EXAMPLEr-0cEXAMPLEx-5M3EXAMPLE3Rw.lcM5zEXAMPLEIMKdAMU3-FeR-imEXAMPLE56Ms",
"expires_in":3599,
"scope":"read_date",
"token_type":"bearer"
}
This gives us an access token with the read_date
scope. In the specification that scope applies to the /date
endpoint. Notice that to create the token we did not supply a scope
string. Instead, the scope
was defined when we registered the "client" with ORY and it returned a client_id/secret
pair.
Remember that we also created a client_id/secret
pair for a client that has post_echo
scope. It will be able to invoke the /echo
endpoint.
The handler functions for these endpoints are:
export async function getDate(
c: Context, req: Request, res: Response
) {
return res.status(200).json({
// Use the dayjs package to create
// an ISO3106 date string
date: dayjs.utc().toDate()
});
}
export async function echoHandler(
c: Context, req: Request, res: Response
) {
const valid = isEchoThing(c?.request?.body);
if (!valid || valid.error) {
return res.status(400).json(reportProblem(
`Bad echoThing -- ${util.inspect(c?.request?.body)} -- ${util.inspect(valid.error)}`,
400
));
}
if (!valid.value) {
return res.status(400).json(reportProblem(
`No echoThing -- ${util.inspect(c?.request?.body)} -- No error given, but no value ${util.inspect(valid.value)}`,
400
));
}
return res.status(200).json(valid.value);
}
The getDate
handler is an example of using GET to read a value from a service.
The echoHandler
handler is an example of using POST to submit some data, and then receive a response. The isEchoThing
checks the supplied data using Joi to ensure it is compatible with the EchoThing
class. If not, appropriate errors are returned, and otherwise the data is returned.
In both cases, these handlers require valid OAuth2 tokens. The OpenAPI specification has been written with this security schema:
securitySchemes:
oAuth2ClientCredentials:
type: oauth2
description: Client credential flow.
flows:
clientCredentials:
tokenUrl: auth/token
scopes:
post_echo: Make an echo request
read_date: Read the current date
bearerAuth:
type: http
scheme: bearer
In the /date
endpoint definition we have this:
security:
- oAuth2ClientCredentials: [read_date]
And, for /echo
we have this:
security:
- oAuth2ClientCredentials: [post_echo]
While initializing the Express app, and OpenAPI-Backend, we make this call:
api.registerSecurityHandler(
"oAuth2ClientCredentials",
oath2ClientHandler);
This says handle Bearer authorization using the named function. In practice this means having an Authorization header where the content is the string Bearer
followed by the bearer token. In this case it is the OAuth2 token generated by ORY earlier.
The oath2ClientHandler
function is:
export async function oath2ClientHandler(
c: Context, req: Request, res: Response
) {
const authHeader = c.request.headers["authorization"];
if (!authHeader) {
logger.error("Missing authorization header");
return false;
}
if (!(typeof authHeader === 'string')) {
logger.error(`Authorization header an array? ${util.inspect(authHeader)}`);
return false;
}
let token = authHeader.replace("Bearer ", "");
let decoded;
let scopes;
try {
decoded = await introspectOAuth2Token(token);
// const decoded = await isOryIntrospectedOAuth2Token(token);
if (!decoded) {
logger.error(`oath2ClientHandler FAIL ${util.inspect(decoded)}`);
return false;
}
let okay = false;
if (!decoded || !decoded.active) {
logger.error(`oath2ClientHandler FAIL inactive token ${token} ${util.inspect(decoded)}`);
return false;
}
scopes = decoded.scope?.split(' ');
} catch (err: any) {
logger.error(`oath2ClientHandler FAIL because introspectOAuth2Token threw ${err.message}`);
throw err;
}
if (Array.isArray(scopes)
&& scopes.length >= 1) {
if (c?.operation
&& c?.operation?.security
&& Array.isArray(c.operation.security)
&& c.operation.security.length >= 1) {
for (const sec of c.operation.security) {
if ('oAuth2ClientCredentials' in sec
&& Array.isArray(sec.oAuth2ClientCredentials)
&& sec.oAuth2ClientCredentials.length >= 1) {
// Intersection of two arrays
// https://medium.com/@alvaro.saburido/set-theory-for-arrays-in-es6-eb2f20a61848
// This compares the scopes (permissions)
// stored in the token against the
// client credentials named in the spec.
// If there is overlap then it's approved.
let found = scopes.filter(x => {
return sec.oAuth2ClientCredentials.includes(x);
});
if (Array.isArray(found)
&& found.length >= 1) {
okay = true;
break;
} else {
// ...
okay = false;
break;
}
} else {
// ...
}
}
} else {
// ...
}
} else {
// ...
}
if (!okay) {
// ...
}
return okay;
}
This is a security handler for OpenAPI-Backend, and therefore is returning either true
or false
to indicate whether the request is authorized.
One step is to validate that the token is valid. We do this by asking ORY to "introspect" the token, which means to decode the token and return an object describing data about the token.
export async function introspectOAuth2Token(accessToken: string)
: Promise<IntrospectedOAuth2Token>
{
try {
if (!ory) {
throw new Error(`auth.ts - No OAuth2Api object constructed`);
}
let data = (await ory.introspectOAuth2Token({
token: accessToken
})).data;
const _data = isOryIntrospectedOAuth2Token(data);
if (_data.error) {
throw new Error(`introspectOAuth2Token retrieved ORY bad introspection ${util.inspect(_data.error)}`);
}
if (!_data.value || typeof _data.value === 'undefined') {
throw new Error(`introspectOAuth2Token retrieved ORY introspection with no data`);
}
return _data.value;
} catch (err: any) {
if (err instanceof Error) {
throw err;
}
const msg = `introspectOAuth2Token ${reportORYError(err)}`;
logger.error(msg);
throw new Error(msg);
}
}
The function ory.introspectOAuth2Token
is from the @ory/client
package and is a wrapper around invoking the corresponding ORY REST endpoint. If the token is okay, we return its introspected value. Otherwise appropriate errors are thrown.
The function isOryIntrospectedOAuth2Token
validates the introspected object, returning an object containing either an error
field or a value
field typed as IntrospectedOAuth2Token
.
Notice that the scope
string is not supplied in the request. Instead, ORY tells us the valid scopes when introspecting the token. Further, the OpenAPI-Backend framework makes available the scopes required to access the endpoint in sec.oAuth2ClientCredentials
. Each scope string is an array of scope names, meaning we're checking the intersection of two arrays.
To invoke the getDate
function, add this to the CLI tool:
We must instruct the CLI tool to look for an auth token command-line parameter, and how to invoke the above REST methods.
// ...
program.option('--authToken <authToken>',
'Authorization token');
function authToken() {
const opts = program.opts();
for (const key in opts) {
if (key === 'authToken') {
return opts[key];
}
}
}
// ...
program
.command('get-date')
.description('Get the Date')
.action(async (options, command) => {
const token = authToken();
const headers = token
? {
Authorization: `Bearer ${token}`
} : undefined;
const service = server();
service.pathname = path.join('api', 'date');
try {
const resp = await got(service.href, {
headers
});
const date = JSON.parse(resp.body);
console.log(date);
} catch (err) {
console.error(`date FAIL ${err?.code} ${err?.response?.statusCode} ${err?.response?.statusMessage} ${err?.message} ${err?.response?.requestUrl} ${err?.response?.body}`);
}
});
This adds a new parameter, --authToken
, for specifying an access token to use.
It forms a URL to access the /api/date
endpoint on the service, and uses the got
package to perform a GET on that endpoint. If this succeeds, it parses the response body into JSON, and prints it out.
A similar function can be added for an echo
command to invoke /echo
.
Running the CLI tool against the two commands using various tokens:
# Invoking getDate with the read token
$ node cli.js -s http://localhost:9090 \
--authToken READ-TOKEN \
get-date
{ date: '2024-09-07T20:58:48.733Z' }
# When passing an incorrect token
$ node cli.js -s http://localhost:9090 \
--authToken BAD-TOKEN \
get-date
date FAIL ERR_NON_2XX_3XX_RESPONSE 403 Forbidden Response code 403 (Forbidden) http://localhost:9090/api/date {"title":"unauthorized","status":403}
# Invoking /echo with the echo token
$ node cli.js -s http://localhost:9090 \
--authToken ECHO-TOKEN \
echo --title 'Here I am JH' --body 'All is all'
{ title: 'Here I am JH', body: 'All is all' }
# Invoking /echo with the read token
$ node cli.js -s http://localhost:9090 \
--authToken READ-TOKEN \
echo --title 'Here I am JH' --body 'All is all'
echo FAIL ERR_NON_2XX_3XX_RESPONSE 403 Forbidden Response code 403 (Forbidden) http://localhost:9090/api/echo {"title":"unauthorized","status":403}
Using the correct token works on both commands. Using the wrong token does not work, nor does it work to use an unknown token.
This result is enforced by introspecting the token. Introspection gives us a value, active
, which directly indicates whether it is a viable token, as well as the scope
string for the token. By comparing that scope string to the method scope string for the API method, we can determine if the provided token is allowed to access that method.
Ory usage limits on the free plan
While working with ORY in a real application, it happened that most ORY requests happened would succeed but some would fail. The access token was still active, in that the active
property on the introspected token was still true
, but there was a failure. After a lot of debugging it was found that ORY returned a 429 status code which refers to "Too many requests".
Under the ORY developer plan there are some usage limits. The issue is that every request on our service turns into a request for ORY to introspect the token, and ran over the limits. Introducing a cache, using the Keyv package, did resolve the problem meaning the 429 status codes were no longer returned. But that's not the correct solution.
In an attempt to replicate that situation consider this shell script:
TOKEN_READ=...TOKEN-for-READ-client...
TOKEN_POST=...TOKEN-for-POST-client...
while true; do
node cli.js -s http://localhost:9090 --authToken $TOKEN_POST echo \
--title 'Here I am JH' --body 'All is all';
node cli.js -s http://localhost:9090 --authToken $TOKEN_READ get-date;
done
Access tokens for both scopes are generated as discussed earlier, and saved into the two variables shown here. Then a shell loop is run to perform both actions. The output should look something like:
{ title: 'Here I am JH', body: 'All is all' }
{ date: '2024-09-08T12:44:02.357Z' }
{ title: 'Here I am JH', body: 'All is all' }
{ date: '2024-09-08T12:44:03.118Z' }
But, this did not cause the 429 status code to be returned. In other words, this script wasn't enough to replicate the problem.
The limits are discussed at
https://www.ory.sh/docs/guides/rate-limits. For example, /admin/oauth2/introspect
is limited to bursts of 10 per second, or 300 per minute. The shell script shown here does not reach that rate. At around 1 sec per loop iteration it might reach 60 queries per minute, well short of 300. A loop that more efficiently makes requests would easily overwhelm a 300 queries/minute limit.
Let's talk a little about caching OAuth2 token responses, however. Every hit on our API requires introspecting the token to ensure the token is real, that the active
field is still true
, and that the token's scope matches the scope of the method being accessed.
Therefore the ORY server will receive a lot of introspection requests. It might seem one can gain a performance boost by caching the introspection of each token to reduce the requests to ORY services.
But, this is a bad idea.
What happens if a bad actor gains access to the OAuth2 token? A token can be revoked using the /oauth2/revoke
endpoint for that purpose. But, if our server has cached the introspected token, it will not know the token has been revoked, and will unwittingly continue thinking the token is valid and active until it reaches its expiration time.
In other words, it is not correct to cache the introspection object. Our server must ask Ory to introspect every token access. The other way to boost performance, and to avoid excess hits on the paid ORY service, is to self-host the Hydra service.
Self-hosting Ory Hydra
Hydra is an Ory-sponsored open source project implementing both OAuth2 Server and OpenID Connect. The OAuth2 portion of the ORY service is powered by Hydra and is what we'll focus on here.
It is relatively easy to self-host Hydra. That improves performance because the Hydra service can be on the same or neighboring server rather than in the Ory cloud however many thousands of miles away. We also avoid the request limit just discussed.
According to the Hydra installation page,
https://www.ory.sh/docs/hydra/self-hosted/install, there is a hydra
CLI tool we can install on our host computer. But, it's easier to use Docker to do so.
$ docker run --rm -it oryd/hydra:v2 --help
...
Run and manage Ory Hydra
Usage:
hydra [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
create Create resources
delete Delete resources
get Get resources
help Help about any command
import Import resources
introspect Introspect resources
janitor This command cleans up stale database rows.
list List resources
migrate Various migration helpers
perform Perform OAuth 2.0 Flows
revoke Revoke resources
serve Parent command for starting public and administrative HTTP/2 APIs
update Update resources
version Display this binary's version, build time and git hash of this build
Flags:
-h, --help help for hydra
Use "hydra [command] --help" for more information about a command.
This has the side effect of downloading the oryd/hydra
image so we can easily instantiate it into a running container. The image tag, oryd/hydra:v2
, is the most recent in the version 2 development train which is the current version as of this writing.
Initializing a database to self-host Ory Hydra
Hydra can be used with several databases. To use Postgres, MySQL, or CockroachDB, we must set up another container to house the database. But, to make a simple demonstration let's instead use SQLITE3.
We first need to create a random secret which Hydra will use in encrypting the database. To do this on Linux or macOS:
(
export LC_CTYPE=C
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
)
This generates some random text like uDCXVpBRnmbtd4dWzDB6hhOLUhBLEWJv
.
For Hydra, this is to be done once and the result saved somewhere for use.
Create a script, env.sh
, containing configuration settings:
# Data Source Name (DSN) is a URL describing the
# database which will be used in persistence.
# DOCS: https://www.ory.sh/docs/self-hosted/deployment
#
# For SQLITE, the URL must be a pathname within
# the container.
DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true
# For SQLITE the database URL must translate to a file
# in the filesystem.
DSN_PATH=/home/david/Projects/docker/hydra/hydra.sqlite
# Names the image-name for Hydra
HYDRA_IMAGE=oryd/hydra:v2.2.0
# Hydra "system secret"
SECRETS_SYSTEM=uDCXVpBRnmbtd4dWzDB6hhOLUhBLEWJv
The DSN_PATH
shown here is on my laptop, where all Docker projects are saved in a directory under $HOME/Projects/docker
.
The variable DSN
is used across all ORY components, and specifies a URL for connecting to a database. In this case, since we're using SQLITE3, the database must appear as a file, and the DSN URL contains the path inside the container. The file at DSN_PATH
must be mounted inside the container at the path named in the DSN
URL.
And, notice SECRETS_SYSTEM
is saved in this file.
Let's create a virtual Docker network within which Hydra and related services will communicate:
$ docker network create hydra
This creates the Docker virtual network, hydra
, which will be used by communication between Hydra and other services.
Next, create a shell script, migrate-sql.sh
, containing:
# When using SQLITE, the SQLITE file must be mounted
# into the container. This volume mount binds the
# local file into the container at a predefined location.
# The same bind mount must be used in the Compose file
# for the Hydra container.
docker run -it --rm \
--network hydra \
-v ${DSN_PATH}:/var/lib/sqlite/db.sqlite:rw \
-e DSN=${DSN} \
-e SECRETS_SYSTEM=${SECRETS_SYSTEM} \
${HYDRA_IMAGE} \
migrate sql --yes ${DSN}
This runs the Hydra container in interactive mode. The file at DSN_PATH
is mounted into the container at the appropriate location.
Hydra, like all ORY components, can be completely configured using environment variables. They have a configuration file format, but they also have a method for mapping environment variables to configuration file keys. The values for DSN
and SECRETS_SYSTEM
are passed as environment variables. Both these variable names are directly recognized by the Hydra CLI.
The migrate sql
command is used for initializing the database.
To correctly initialize the SQLITE3 database file for Hydra use I found it necessary to run these commands in this sequence:
$ sudo rm -rf /home/david/Projects/docker/hydra/hydra.sqlite
$ sudo touch /home/david/Projects/docker/hydra/hydra.sqlite
$ sudo chmod 666 /home/david/Projects/docker/hydra/hydra.sqlite
$ npx env-cmd -f env.sh sh ./migrate-sql.sh
The last command uses the env-cmd
tool from the Node.js ecosystem. It is useful for running a command using environment variables stored in the named file. It has the advantage of automatically interpolating environment variables into the equivalent on Linux, macOS, or Windows, simplifying cross-platform script execution. It's an example of a tool to aid in cross-platform script development as discussed in:
The desired result is is to run migrate-sql
with the desired configuration in env.sh
.
Running the first two commands ensures the database file is correctly setup before running the migration. I found that if the file did not exist, then Docker would create a directory of the same name, then Hydra fail to initialize the database. The chmod
command was required because otherwise an error, attempt to write a readonly database would be printed. That message comes from SQLITE3 and refers to thinking the database file is read-only.
If all is well a long list of messages will print talking about the initialization of data values.
The resulting database file is:
$ ls -l /home/david/Projects/docker/hydra/
total 344
-rw-r--r-- 1 root root 234 Sep 9 14:53 compose.yaml
-rw-rw-rw- 1 root root 348160 Sep 9 17:05 hydra.sqlite
The compose.yaml
file you see is what we'll discuss next, and is in charge of launching the Hydra service.
If you instead wish to use a regular database like Postgres, this command should start a suitable server:
docker run --network hydra \
--name ory-hydra--postgres \
-e POSTGRES_USER=hydra \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=hydra \
-d \
postgres:9.6
I haven't tested this. For example, additional configuration is required to persist the database to the host filesystem.
In such a case the DSN variable is:
DSN=postgres://hydra:secret@ory-hydra--postgres:5432/hydra?sslmode=disable
The command to use in migrate-sql.sh
is instead:
docker run -it --rm \
-e SECRETS_SYSTEM=${SECRETS_SYSTEM} \
--network hydra \
${HYDRA_IMAGE} \
migrate sql --yes ${DSN}
Because this database is not a regular file mounted into the container, we do not have the volume (-v
) mount.
Using Docker Compose to launch ORY Hydra
We've just created a virtual Docker network, hydra
, to which we will connect any service which needs to communicate with the Hydra service. We also initialized an SQLITE3 database we'll use with our Hydra server. Now it's time to launch the Hydra service.
Create a Compose file:
services:
hydra:
restart: unless-stopped
image: ${HYDRA_IMAGE}
ports:
- 4444:4444
- 4445:4445
volumes:
- ${DSN_PATH}:/var/lib/sqlite/db.sqlite:rw
networks:
- hydra
environment:
- SECRETS_SYSTEM=${SECRETS_SYSTEM}
- DSN=${DSN}
networks:
hydra:
external: true
This is written to use the same environment variables as discussed earlier. We'll discuss later injecting environment variables into a Compose file.
Hydra exposes several ports, but we are using only two. The 4444
port is meant for the public to use, and implements the OAuth2 endpoints. The 4445
port serves administrative API requests that should not be available, without administrator intention, to the public internet.
As we mentioned earlier, Hydra uses two separate endpoints for OAuth2 and Admin APIs. Hence, we need to record this as so:
ORY_OAUTH2_API_ENDPOINT=http://localhost:4444
ORY_ADMIN_API_ENDPOINT=http://localhost:4445
The hydra
network is shown as external
which means we must have already created it before running this Compose file. That's why we ran docker network create hydra
earlier.
Normally the command to run a Compose file is: docker compose up
, but we need to inject environment variables.
The official Docker documentation says to do this:
$ docker compose run --env-file /path/to/env.sh up -d
There are other options, but this lets us directly reuse the env.sh
file shown earlier.
So, is the Hydra server running? You can run docker ps
and examine the container list, but there are a few more steps to take.
$ docker compose logs -f
Look at the logging output and see if it any errors are reported.
A common error I experienced is:
unable to open database file: is a directory
Depending on how the service is brought up, the database mount inside the container might look like this:
$ docker exec -it hydra-hydra-1 sh
/ $ ls -l /var/lib/sqlite/
total 4
drwxr-xr-x 2 root root 4096 Sep 9 16:55 db.sqlite
That should not be a directory, but sometimes it is. In such a case run docker compose down
to not only stop the services, but to remove the container. Running docker compose up -d
at the command-line will correctly start Hydra.
$ curl http://localhost:4445/health/ready
{"status":"ok"}
This directly asks the server if it is running.
To execute the Hydra CLI, run:
$ docker exec -it hydra-hydra-1 hydra
Now, run these two commands:
$ ory create oauth2-client --help
...
$ docker exec -it hydra-hydra-1 hydra create oauth2-client --help
...
You should see identical help text printed on your terminal. This is the same command, and we can use it the same way as the ory
command we used earlier.
This means we can create a new version of mkclient.sh
:
client=$(docker exec -it hydra-hydra-1 hydra create oauth2-client \
--endpoint "$ORY_ADMIN_API_ENDPOINT" \
--name "$1" \
--grant-type client_credentials \
--scope "$2" \
--token-endpoint-auth-method client_secret_post \
--format json
)
echo CLIENT ID=$(echo $client | jq -r '.client_id')
echo CLIENT SECRET=$(echo $client | jq -r '.client_secret')
echo $client | jq --indent 4
This mostly looks the same but notice the differences. First, docker exec ... hydra
instead of using the ory
command. As we saw earlier, the help output from the two are identical, and if we explore the help text for various commands they're all the same as well.
Next, we don't need ORY_NET_PROJECT_ID
or the other environment variables. The endpoint is simply localhost
port 4445
, or more precisely the URL in ORY_ADMIN_API_ENDPOINT
.
$ sh mkclient.sh write post_echo
CLIENT ID=d383132d-8b1a-405e-a94f-260d5dc07b3a
CLIENT SECRET=1ymwKKXRPJhGSq5~H7pAwBXcau
# ...
$ sh mkclient.sh 'read' 'read_date'
CLIENT ID=8f0e978d-90ac-4460-bd34-946d84fbb546
CLIENT SECRET=vnlH0~ZysTaYIKt3WIZ-38CgO6
# ...
Otherwise the usage is the same, and we get the same client_id/secret
tokens.
Reconfiguring the sample service to use Hydra for OAuth2
Now that we have a working OAuth2 server, we can integrate the sample service with the self-hosted Hydra instance.
As we noted earlier, Hydra has its own CLI tool separate from the Ory CLI tool. The two are very similar and share many commands, but hydra
is installed separately from ory
.
This extends to the Ory SDK packages as well. The code we showed earlier was written for the @ory/client
package in the NPM repository. The Hydra documentation says to instead install @ory/hydra-client
to interface Node.js/TypeScript code to Hydra, and to make sure the package version matches the Docker image version.
As of this writing the latest Docker image is tagged v2.2.0
, so therefore we would install:
$ npm install @ory/hydra-client@v2.2.0 --save
The usage of the @ory/hydra-client
and @ory/client
packages are very similar, at least to the extent that the sample server requires.
It was necessary to rewrite the auth.ts
module in order to accommodate using either @ory/hydra-client
or @ory/client
. The result is that this sample server can be quickly reconfigured to use either the Ory hosted service or self-hosted Hydra.
First, import both packages:
import ORY from "@ory/client";
import HYDRA from '@ory/hydra-client';
Next, set up global variables for the two endpoints:
// Record URLs for the OAuth2 and Admin API endpoints
let ory_oauth_endpoint: string | undefined;
let ory_admin_endpoint: string | undefined;
if (typeof process.env.ORY_OAUTH2_API_ENDPOINT !== 'string') {
throw new Error(`auth.ts - ORY_OAUTH2_API_ENDPOINT required`);
} else {
ory_oauth_endpoint = process.env.ORY_OAUTH2_API_ENDPOINT;
}
if (typeof process.env.ORY_ADMIN_API_ENDPOINT !== 'string') {
throw new Error(`auth.ts - ORY_ADMIN_API_ENDPOINT required`);
} else {
ory_admin_endpoint = process.env.ORY_ADMIN_API_ENDPOINT;
}
An additional step might be to validate these are URLs.
As we said earlier, there are two set of endpoints we're interested in - OAuth2 and Ory Administrative.
Next, we need a flag to say whether to use Ory or Hydra:
// Check whether we are to use the ORY or HYDRA client packages
let useORY = typeof process.env?.USE_ORY === 'string';
let useHYDRA = typeof process.env?.USE_HYDRA === 'string';
if (useORY && useHYDRA) {
throw new Error(`auth.ts - can only select one of USE_ORY or USE_HYDRA, not both`);
}
if (!useORY && !useHYDRA) {
throw new Error(`auth.ts - Must select one of USE_ORY or USE_HYDRA`);
}
The two environment variables, USE_ORY
and USE_HYDRA
, will serve this purpose. As the code enforces, set one of them, not both, to any value. It only checks that the variable is set, and not its value.
Then, set up a global object, ory
, this will be the OAuth2Api
implementation from the corresponding package. Each of these packages has an implementation of that class. We are simply instructed to use one or the other.
// Configure the ory variable to use either ORY or HYDRA.
// They're both nearly the same or identical API and one
// can be substituted for the other.
let ory: ORY.OAuth2Api | HYDRA.OAuth2Api | undefined;
if (useORY) {
ory = new ORY.OAuth2Api(
new ORY.Configuration({
basePath: ory_admin_endpoint,
accessToken: process.env.ORY_NET_API_KEY,
}),
);
} else if (useHYDRA) {
ory = new HYDRA.OAuth2Api(
new HYDRA.Configuration({
basePath: ory_admin_endpoint
})
);
}
if (!ory) {
throw new Error(`auth.ts - No OAuth2Api object constructed`);
}
The OAuth2Api
object is to configure basePath
to use the ADMIN endpoint.
In the fetchToken
function we make a call on /oauth2/token
to generate an auth token. This is clearly the OAuth2 endpoint, necessitating this small rewrite:
let oryServer;
try {
oryServer = new URL(ory_oauth_endpoint as string);
oryServer.pathname = 'oauth2/token';
} catch (err: any) {
let statusCode = 500;
const msg = `fetchToken FAIL InternalServiceError could not construct ORY URL from ${ory_oauth_endpoint}`;
logger.error(msg);
return res.status(statusCode).json(reportProblem(msg, statusCode));
}
The variable ory_oauth_endpoint
is typed as string | undefined
but the global-scope code ensures that it will always be a string
. Therefore it is safe to cast this to be string
.
In introspectOAuth2Token
we use the ory
object (the OAuth2Api
instance) to introspect the token. This can be rewritten as so:
if (!ory) {
throw new Error(`auth.ts - No OAuth2Api object constructed`);
}
let data = (await ory.introspectOAuth2Token({
token: accessToken
})).data;
While the global-scope code ensures ory
has an OAuth2Api
instance, the compiler thinks it can possibly be undefined
. We test for that case here and throw an error, just in case. This error should never happen (knock on wood).
In getOAuth2ClientInfo
is a similar change:
if (!ory) {
throw new Error(`auth.ts - No OAuth2Api object constructed`);
}
let data = (await ory.getOAuth2Client({
id: client_id
})).data;
With these changes, the sample server can be configured to use either hosted Ory, or self-hosted Hydra, just by changing the environment variables.
To use self-hosted Hydra:
USE_HYDRA=true
ORY_OAUTH2_API_ENDPOINT=http://localhost:4444
ORY_ADMIN_API_ENDPOINT=http://localhost:4445
And, to use hosted Ory:
USE_ORY=true
ORY_NET_API_ENDPOINT=https://cranky-wu-svEXAMPLEgi.projects.oryapis.com
ORY_OAUTH2_API_ENDPOINT=$ORY_NET_API_ENDPOINT
ORY_ADMIN_API_ENDPOINT=$ORY_NET_API_ENDPOINT
The global-scope code in auth.ts
ensures the rules for interpreting these variables to do the right thing.
Using Ory Hydra with the CLI tool
With self-hosted Ory, we are using docker exec
to run the hydra
command. That means the mkclient
script is a little different as shown earlier.
For USE_HYDRA
, the CLI tool is run this way:
docker exec -it hydra-hydra-1 hydra ... HYDRA COMMANDS
It's largely the same commands as with the ory
command that we use with hosted Ory.
To create a client:
$ docker exec -it hydra-hydra-1 hydra create oauth2-client \
--endpoint http://127.0.0.1:4445 --name write \
--grant-type client_credentials --scope post_echo \
--token-endpoint-auth-method client_secret_post \
--format json
{
"client_id":"caEXAMPLEe-b249-4f4e-bd85-13bEXAMPLE9f",
"client_name":"write",
"client_secret":"x8EXAMPLEWV-T_zqEXAMPLEhA",
"client_secret_expires_at":0,
"client_uri":"",
"created_at":"2024-09-10T19:57:44Z",
"grant_types":["client_credentials"],
"jwks":{},
"logo_uri":"",
"metadata":{},
"owner":"",
"policy_uri":"","registration_access_token":"ory_at_1LqD5CzrJEXAMPLEM72A",
"registration_client_uri":"https://localhost:4444/oauth2/register/","request_object_signing_alg":"RS256",
"response_types":["code"],
"scope":"post_echo",
"skip_consent":false,
"skip_logout_consent":false,
"subject_type":"public",
"token_endpoint_auth_method":"client_secret_post",
"tos_uri":"",
"updated_at":"2024-09-10T19:57:44.139116Z",
"userinfo_signed_response_alg":"none"
}
The --endpoint
value to use in these cases is --endpoint "http://localhost:4445"
to connect with the Admin REST service.
Generating an access token is done the same way:
$ node cli.js -s http://localhost:9090 fetch-token \
--clientID caEXAMPLEe-8b1a-405e-a94f-13bEXAMPLE9f \
--clientSecret x8EXAMPLEWV-T_zqEXAMPLEhA
{
"access_token":"ory_at_IZ57ZHEXAMPLE-2DwQ",
"expires_in":3599,
"scope":"post_echo",
"token_type":"bearer"
}
In this case, the implementation inside the sample server invokes the /oauth2/token
endpoint on the OAUTH2 URL. But this user interface remains the same.
The test script we showed earlier likewise remains unchanged:
TOKEN_READ= ... READ TOKEN
TOKEN_POST= ... POST TOKEN
while true; do
node cli.js -s http://localhost:9090 --authToken $TOKEN_POST echo --title 'Here I am JH' --body 'All is all';
node cli.js -s http://localhost:9090 --authToken $TOKEN_READ get-date;
done
Conclusion
It might be tempting to implement OAuth2 yourself. If so, try reading the RFCs and think about all the details that must be implemented correctly. Think about the time you'd spend developing OAuth2 tests and implementation. Think about the effort which has gone into developing the existing OAuth2 implementations. Can your home-grown OAuth2 implementation hope to match?
No.
Application security is very important. The last thing you want for your product is to gain notoriety from hackers breaking into your system and stealing data.
The Ory ecosystem is one of many stacks for authentication, authorization, user identity, and the like. It is refreshing to see a commercial company fully embrace the open source model as Ory does.
What we've shown in this article is that for the purpose of implementing OAuth2 Client Credential Flow, Hydra handles all (or most) of the task.