This document summarizes the architecture of the @inrupt/solid-client-authn-*
modules. It applies to both @inrupt/solid-client-authn-node
and @inrupt/solid-client-authn-browser
.
https://github.com/inrupt/solid-client-authn-js is a Lerna-based monorepo, which means this single Git repository actually hosts several npm modules that are related to each other. The following diagram shows an overview of the modules and their relationships.
@inrupt/solid-client-authn-node
and @inrupt/solid-client-authn-browser
(grouped under the
“Client libraries” label) are the modules we expect developers to import.
As their names imply, each of these modules is specific to a given environment
(NodeJS or the browser). However, they both have very similar APIs and architecture,
and mostly differ by their main dependency, namely the third-party library implementing the
OpenID Connect protocol.
@inrupt/solid-client-authn-node
depends on openid-client
.@inrupt/solid-client-authn-browser
depends on @inrupt/oidc-client-ext
.
@inrupt/oidc-client-ext
in turn depends on
@inrupt/oidc-client
,
extending it with support for
DPoP tokens and
Dynamic Client Registration.The four modules are available in the standard Lerna packages directory.
The client libraries aim to help developers authenticate their application users via the OpenID Connect protocol (often abbreviated OIDC). OIDC is an industry-wide standard protocol based on the OAuth2.0 framework. In order to understand how our libraries work internally, some understanding of OAuth/OIDC is definitely preferable.
Here is a list of terms having a specific meaning in the context of OIDC:
With the Solid-OIDC specification, Solid extends the OIDC protocol in order to allow it fit better into a decentralized ecosystem, specifically with Client WebIDs and DPoP tokens:
Solid-OIDC introduces the notion of Client WebID, which enables Client-managed identifiers instead of Issuer-managed identifiers. By using identifiers they control, Clients are no longer required to get their identifiers from the Issuer through either static or dynamic client registration.
Solid-OIDC also makes the support for Key-bound Access Tokens (referred to as DPoP tokens) mandatory: it is only optional in traditional OIDC, where Bearer tokens are the default option. DPoP tokens cannot be replayed by a Resource Server to another Resource Server, which is an important security feature in a decentralized ecosystem such as Solid’s.
There are some OIDC flows that we intentionally don’t plan on supporting:
packages/*/src/login/oidc/IssuerConfigFetcher.ts
packages/*/src/login/oidc/ClientRegistrar.ts
packages/*/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts
packages/*/src/login/oidc/redirectHandler/*Handler.ts
,
the specific Handler depends on which Handler’s canHandle()
method first returns
true
.packages/*/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts
Note that in this case, no redirection happens (i.e., there’s only a Backchannel exchange): the Access Token is received directly in response to a request containing the Refresh Token.
Here are some recommended resources to help understand OAuth/OIDC in general:
These resources may help in getting a better understanding of the authentication flows described in the previous section, and in particular by providing relevant use cases for each flow.
This section will give a high-level description of the shared inner workings of
@inrupt/solid-client-authn-node
and @inrupt/solid-client-authn-browser
,
omitting anything too module-specific.
Most of the code for these modules is internal and hidden from the developer. The
public API is located in packages/*/src/Session.ts
. Developers are expected to
instantiate a Session
object, and to use it to interact with the session.
Usage examples can be found in our
public documentation.
Various components, such as the Login and Incoming Redirect components, are based on the Handler design pattern. Given data contained in a request, a set of classes implementing a similar API will declare whether or not they may handle said request.
canHandle(request)
, which returns a boolean indicating the handler’s ability to handle the requesthandle(request)
, which actually processes the requestcanHandle()
returns
true
process the request. The handler aggregator has the same API as the handlers
it aggregates, and brokers the request to the underlying handlers.
More on that in the Dependency Injection section.In the context of this library, a request is an API call to execute some OIDC-related operation, for instance redirect the Resource Owner to the OIDC issuer, or process the data sent by the OIDC issuer to the Client. Handlers will determine whether they can handle the request based on the options specified by the code snippet making the call.
The login operation is initiated by the Client. It may result in one of the following:
Handlers for the login operation are located in packages/*/src/login/oidc/oidcHandlers/*Handler.ts
.
The incoming redirect operation is initiated by the Issuer.
At the Issuer webpage, the Resource Owner authenticates (e.g., by entering a username
and a password), after which the Issuer sends them to a webpage under the Client
app’s control (specifically its redirect_uri
). The Issuer appends some very important query
parameters to the IRI the Resource Owner is redirected to, which are needed by the Client
to complete the login flow. This is done when the developer calls handleIncomingRedirect()
,
and the Handlers for the incoming redirect are located
in packages/*/src/login/oidc/redirectHandler/*Handler.ts
.
An important architectural component of this library is dependency injection,
implemented here using TSyringe. Dependencies
are declared in packages/*/src/dependencies.ts
.
The order in which the dependencies sharing the same label are declared matters. Let’s have a look at some code to make things clearer.
container.register<IOidcHandler>("browser:oidcHandler", {
useClass: AggregateOidcHandler,
});
container.register<IOidcHandler>("browser:oidcHandlers", {
useClass: RefreshTokenOidcHandler,
});
container.register<IOidcHandler>("browser:oidcHandlers", {
useClass: AuthorizationCodeWithPkceOidcHandler,
});
container.register<IOidcHandler>("browser:oidcHandlers", {
useClass: ClientCredentialsOidcHandler,
});
Here, AggregateOidcHandler
is the handler aggregator (as defined in the Handler
Pattern section), and RefreshTokenOidcHandler
, AuthorizationCodeWithPkceOidcHandler
and ClientCredentialsOidcHandler
are its underlying handlers.
Note that the label for the containers of the aggregator and the underlying handlers differ in the example above:
browser:oidcHandler
(without an ‘s’) for the aggregator.browser:oidcHandlers
(with an ‘s’), for the aggregated handlers.When receiving a
request, AggregateOidcHandler
will first invoke its instance of RefreshTokenOidcHandler
to check if it can handle it. If so, that instance of RefreshTokenOidcHandler
will handle the request, and the instances of AuthorizationCodeWithPkceOidcHandler
and ClientCredentialsOidcHandler
will not be called. This means that it is
important to declare the dependencies from the most specialized to the most generic,
because if a fallback handler that can handle all requests is declared first,
the other more specialized handlers will never be called.
The order in which the container
object registers dependencies isn’t relevant
in the case they are registered with different labels. The
Aggregator implements the class from packages/core/src/util/handlerPattern/AggregateHandler.ts
,
and uses the @injectAll
annotation to receive all the handlers registered to a
given container.
Dependency injection makes the codebase more flexible. Because each component is presented with its declared dependencies at runtime, it becomes easier to add a dependency to a component without changing the whole codebase.
However, for testing a component, mocking dependency injection in test code wouldn’t bring any value. Instead, to test the object, construct the object with mocked dependencies provided to its constructor. For instance, a class such as
@injectable()
export default class RefreshTokenOidcHandler implements IOidcHandler {
constructor(
@inject("node:tokenRefresher") private tokenRefresher: ITokenRefresher,
@inject("node:storageUtility") private storageUtility: IStorageUtility
) {}
// ...
can be tested as follows:
const refreshTokenOidcHandler = new RefreshTokenOidcHandler(
someMockedTokenRefresher,
someMockedStorageUtility
);