Implementing OAuth2 Client Credentials Flow using self-hosted Ory tools

; Date: Fri Sep 20 2024

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 and client_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 an authToken.
  • 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: (github.com) https://github.com/robogeek/oauth2-client-credentials-ory

The repository contains these directories

  1. (github.com) server - server code
  2. (github.com) cli - CLI tool to act as a client
  3. (github.com) hydra - Configuration and scripts to self-host Hydra
  4. (github.com) 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 (www.ory.sh) 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 ( (jqlang.github.io) 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 (www.ory.sh) 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, (www.ory.sh) 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 (docs.docker.com) 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.

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.