Explore use of JWTs for authentication
(Migrated from a totten comment on another thread about simulating permissions in afform and searchkit)
JSON Web Tokens: Microsoft example
(This example demonstrates the data-flow. Many symbols/URLs are edited to improve readability/intuition.)
A "JSON Web Token" (JWT) is a building-block for an access-control system. (Analogy: JSON serves a purpose like XML... but JSON is easier to encode/decode/remix/improvise. Similarly, JWT serves a purpose like ASN.1/X.509... but JWT is easier to encode/decode/remix/improvise.)
Microsoft uses JWT for their web-service APIs - and also for email APIs, like MS Exchange Online. When Civi talks to MS Exchange Online, it uses JWTs. In this process, a user "Alice" (alice@example.com
) starts out in Civi web UI. We redirect her to https://login.microsoftonline.com
, where she clicks a button to say "Yes, trust CiviCRM", and (long-story-short) MS sends us an access token.
This access token looks like a long, random password (aSdF12.34QwErTy.DvORak
), and we can use it to make REST calls. For example, we might request a copy of Alice's user profile:
GET https://api.microsoft.com/about-me HTTP/1.1
Authorization: Bearer aSdF12.34QwErTy.DvORak
HTTP/1.1 200 OK
Content-type: application/json
{"email": "alice@example.com", "first_name": "Alice", "last_name": "Alison", ...}
Again, it looks like a password, so one can plug it into many different systems. We can use it to connect to IMAP or SMTP servers.
But it's not really a password. It is encoded JSON. If you clean it up, then it looks like this:
{
"issuer": "login.microsoftonline.com",
"user": "alice@example.com",
"scopes": ["imap", "smtp", "profile"],
"expires": "2021-04-08 12:16:20"
}
This says that login.microsoftonline.com
(the master security service at Microsoft) is vouching for us. We're approved to access a few services ("scopes":["imap", "smtp", "profile"]
) on behalf of a particular person ("user": "alice@example.com"
). When we connect to api.microsoft.com
or IMAP or SMTP, the server parses the JWT, validates the signature cryptographically, and then gives us access.
The fields (user
, scopes
, etc) determine access control. If we try to access any other API -- editing Alice's address book, deleting Word docs, generating maps, etc -- then it will reject us. That's because our "scopes"
do not include address_book
, manage_docs
, or mapping
.
The token format doesn't have to be based on JSON or JWT. Civi's email hashes are a fine example of a token that doesn't use JSON. However, JSON is flexible and easy to parse. If you're designing a protocol, you have a lot of latitude to pick and choose which fields to include. Additionally, because it's standard, it's easier to inspect/debug. (Got a failed request? Decode the JWT and see if anything looks wrong.)
JSON Web Tokens: Why
Why would someone like Microsoft adopt an access-token pattern like JWT?
Because they are actually running many distributed apps - and, when you get to a certain scale (#apps/#devs/#users), it gets hard to think straight (or run performantly) if they are linked-up one-by-one using a shared session.
Ordinarily, one would expect the demands on a self-hosted PHP app to be gentle enough to address with a straight-forward session/global variable ($session->get('userID')
; global $user
). There's a lot of mileage there. However, with the complexity of supporting multiple CMSs with different routing/session/identity mechanisms -- and with the goal of doing targeted permission escalations/changes/bypasses -- I'm not so sure. Civi is not positioned to be as simple as a singular PHP app - maybe it's more like a distributed app (where access+identity messages are coordinated among many different components - Drupal, Joomla, WordPress, Civi QF, Civi APIs, etc)
Browser (JS) apps and alternative permissions
OK, so how would you use JWT to accomplish alternate permissions in a Civi/JS/Angular/API app?
Recall that the page-load goes a bit like this:
GET https://example.org/civicrm/search HTTP/1.1
Cookie: SESS=abcd1234
GET https://example.org/civicrm/ajax/api4/SavedSearch/get?name=foobar HTTP/1.1
Cookie: SESS=abcd1234
GET https://example.org/civicrm/ajax/api4/Contact/get?complexCriteria=... HTTP/1.1
Cookie: SESS=abcd1234
In each request, the permissions are determined by the cookie. But you don't want those permissions. So... don't use the cookie. Use something with different permissions - like a JWT.
GET https://example.org/civicrm/search HTTP/1.1
Cookie: SESS=abcd1234
GET https://example.org/civicrm/ajax/api4/SavedSearch/get?name=foobar HTTP/1.1
Authorization: Bearer aSdF12.34QwErTy.DvORak
GET https://example.org/civicrm/ajax/api4/Contact/get?complexCriteria=... HTTP/1.1
Authorization: Bearer aSdF12.34QwErTy.DvORak
During the initial page-view, you have to construct and output the token. This is where you decide what kind of actions will be permitted. Maybe it looks like:
$token = Civi::service('crypto.jwt')->encode([
'exp' => time() + 3600,
'scopes' => ['api4/SavedSearch/get', 'api4/Contact/get'],
]);
Civi::resources()->addSetting('apiAuthToken', $token);
or maybe:
$token = Civi::service('crypto.jwt')->encode([
'exp' => time() + 1800,
'scopes' => ['SavedSearch:foobar'],
]);
Civi::resources()->addSetting('apiAuthToken', $token);
or:
$token = Civi::service('crypto.jwt')->encode([
'exp' => time() + 3600,
'contactId' => 1234,
'savedSearch' => 'foobar',
'perms' => ['access AJAX API']
Civi::resources()->addSetting('apiAuthToken', $token);
This is a very generic/flexible way to think about changing permissions while supporting AJAX apps.