Skip to content

Instantly share code, notes, and snippets.

@fideloper
Last active February 19, 2024 18:09
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save fideloper/6ce6c2b4c55162bd2fc9607585816353 to your computer and use it in GitHub Desktop.
Save fideloper/6ce6c2b4c55162bd2fc9607585816353 to your computer and use it in GitHub Desktop.
Microsoft Graph API - oAuth apps with PHP and JWT certification authentication

Microsoft Graph API - oAuth apps with PHP and JWT certification authentication

If you've ever wanted to create an oAuth style application with Microsoft, you might have felt this pain before.

In true Enterprise Microsoft Fashion™, there's a lot going on.

This will be a bit long because of that. I hope I haven't missed anything (but I'm sure I have)!

We'll be using PHP (Laravel in my case).

Registering an Application

You don't need an Azure account to setup an oAuth app for use with Microsof Graph API's. An Office365 account will suffice. However, Microsoft mixes all of their services together (a result of using AzureAD behind the scenes for authentication across all services? I can't tell).

You may have headed to Office365's web site to see if you can register an oAuth application, and if you're lucky, you'll find the correct path of random things to click on, eventually finding your self at a URL that starts with this domain:

https://aad.portal.azure.com/

The clicks to get there are roughly (subject to change any given hour of the day):

  1. Sign into Office365
  2. Head to the "Admin" area (left-hand nav)
  3. Expand the resulting left-hand menu via "Show all"
  4. Click on "Azure Active Directory"

Or just head to https://aad.portal.azure.com/ directly.

Head to Enterprise Applications and click + New Application near the top. (This option may be greyed out if you don't have the right user privileges within your Office365 account).

You'll be greeted with many more options. Ignore them, and head to + Create your own application near the top again.

Select the option Register an application to integrate with Azure AD (App you're developing) when naming the application. This is the only one that lets you create an oAuth application.

Then select one of the Supported account types that matches your use case.

  1. Accounts in this organizational directory only - Only if this is oAuth for internal company use
  2. Accounts in any organizational directory (Any Azure AD directory - Multitenant) - For other Office365/Azure AD users
  3. Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox) - Office 365, Azure AD, and public apps that use Microsoft login
  4. Personal Microsoft accounts only - For only personal apps like xBox or Skype

You can set your application callback URL ("Redirect URI") here also. I used the "Web" usec case. This can be changed later.

Once the application is created, head to Single Sign-on and hopefully buried in a few paragraphs there are the words "Please go to _your app name_ in the App registreation experience to edit permissions....". Click the link there after "Please go to..." and you'll get to the location that matters for oAuth applications.

I have no idea how else to reach this page, but it has all the things we need - the ability to get the client id, generate client secrets, etc.

There's a few places to configure options:

  1. Branding & properties: Add your logo, adjust the application name, point to your TOS and Privacy pages. This is also where you request Publisher Verification, if you're making an oAuth application for "public" use.
    • That's a whole other process and would require another blog post, sorry!
  2. Authentication: Configure your oAuth callback URL(s) ("Redirect URI") here. You can add multiple.
    • I also setup "Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)" in my case, as it's to be used publicly.
    • For web apps doing oAuth flows, you won't need Implicit Grant or Hybrid flows (those are more for mobile apps / desktop apps)
  3. Certificates and Secrets: Generate client secrets, or use certificate (JWT) based authentication
    • Secrets are fine, although they may have shorter expirations than you want.
    • Certificate auth uses JWT's and is technically more secure. You can get longer (much longer) expiration times as well.
  4. API Permissions: You can do this later, but it shoulid in theory have the permissions needed for the API calls your application will make (as far as I can tell, this is more for publishing verification than it is for setting oAuth scopes)
  5. Owners: Set other Office365 users as ones who can manage this application

Those are the only settings I had to set in my case.

Client Secret Authentication

If you're using regular old client-secrets, there isn't much friction.

The benefit of using Client Secrets is that you can use Laravel Socialite for the oAuth "stuff". If you're not using secrets and instead use JWT's, you'll need to extend/change (or not use) Socialite's Microsoft provider.

Note that using Certifications and JWT's is essentially just a way to replace the client_secret parameter when making API requests against the Microsoft Graph API.

You still use access_token's (representing the authenticated user) for use in the Authentication header as a Bearer token when making API requests.

When you do the oAuth round trip, you get a code back that can be exchanged for a authentication token. This part is handled by Socialite (when you call the Socialite::provider('microsoft')->user() call in your call back).

Doing that manually looks a bit like this (using Laravel's HTTP client):

// This happens AFTER we redirect and the user authenticates against Microsoft.
// This is the code that would happen in the callback URL.
// (We don't need to set scopes here, that's already happened)


// Exchange the "code" for a user access token
$response = Http::asForm()->acceptJson()->post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [
    'grant_type' => 'authorization_code',
    'client_id' => $this->clientId,
    'client_secret' => $this->clientSecret, // CLIENT SECRET HERE!
    'code' => $code, // Code retried from oAuth round trip
    'redirect_uri' => $this->redirectUrl,
]);

$data = $response->json();
$userAccessToken = $data['access_token'];
$userRefreshToken = $data['refresh_token'];

// Get the current user information
$response = Http::acceptJson()->withHeaders([
    'Authorization' => 'Bearer ' . $userAccessToken,
])->get('https://graph.microsoft.com/v1.0/me');

$user = $response->json();

Interesting (annoying) note: Depending on the scopes you use, you MAY need to immediately get a refresh token (after grabbing the User information). This is related to Microsoft Graph covering multiple services, in particular Outlook (and email related services) vs other Graph "stuff". It's weird, yep!

In my case I was sending email on user's behalf, and so I needed to immediately refresh the token (using the same scopes I set before, but nothing with Microsoft makse sense):

$response = Http::asForm()->acceptJson()->withHeaders([
    'Authorization' => 'Bearer '.$user->token,
])->post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [
    'grant_type' => 'refresh_token',
    'client_id' => config('services.microsoft.client_id'),
    'client_secret' => config('services.microsoft.client_secret'),
    'refresh_token' => $user->refreshToken,
    'scope' => implode(' ', $this->scopes), // comma separated list of scopes
]);

$data = $response->json();
$newToken = $data['access_token'];
$newRefreshToken = $data['refresh_token'];
$newTokenExpiration = $data['expires_in']; // integer, number of seconds until expiration

From then on, you can periodically refresh the token and make API calls as allowed by your scopes.

Certificate Authentication (JWT)

Certificate authentication took much head banging to figure out.

First, you need to generate a certificate. This is NOT an SSH keypair. It is, instead, a self-signed SSL certificate.

That means it will have an expiration date (just like your client secret)!

Generating a Certificate

Here's how I generated one:

# Create a private key
openssl genrsa -out privkey.pem 4096

# Create a CSR
openssl req -new -key privkey.pem -out cert.csr

# Create a certificate with the CSR. It will ask you a few questions, the domain and other
# information used here does *not* matter.
# I created a passwordless certificate.
# We create a cert with a 1 year expiration in this case. You can make this very long if you want!
# For example, 10 years until expiration would be `-days 3650`
openssl x509 -req -days 365 -in cert.csr -signkey privkey.pem -out my_certificate.pem

Now you have these files:

  1. privkey.pem - Needed to generate JWT tokens
  2. cert.csr - No longer needed
  3. my_certificate.pem - The public key. This should be backed up, and uploaded as your Certificate in your Enterprise Application

So, upload the public key (my_certificate.pem) in your Enterprise Application under the Certificates and Keys section.

Creating a JWT

Every API request where you used to give a client_secret now requires 2 parameters in it's place.

You can think of the JWT token as a client secret replacement.

Our code above to refresh a user's token used to use client_secret, but instead now looks like this:

$jwt = $this->generateJWT(); // See below

$response = Http::asForm()->acceptJson()->withHeaders([
    'Authorization' => 'Bearer '.$currentUserToken,
])->post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [
    'grant_type' => 'refresh_token',
    'client_id' => config('services.microsoft.client_id'),

    # Instead of client_secret, we have 2 new parameters:

    // 'client_secret' => config('services.microsoft.client_secret'),
    'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', // Always use this value
    'client_assertion' => $jwt,

    'refresh_token' => $currentUserRefreshToken,
    'scope' => 'list,of,scopes', // I'm not sure scopes is required here
]);

To generate the JWT, we can use the firebase/php-jwt composer package. This may already be part of your Laravel installation, otherwise you can run:

composer require firebase/php-jwt

Here are the relevant Microsoft docs on generating a JWT: https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials

Here's how to generate the JWT:

One thing you'll need is the SHA1 certificate of the public key. There is a fingerprint of the key in the Microsoft application, but that one they generate is not the same thing (unfortunately).

Use this magic incantation (from hours of Googling!) to get the SHA1 fingerprint in the format that JWT / Microsoft wants.

# From https://stackoverflow.com/questions/50657463/how-to-obtain-value-of-x5t-using-certificate-credentials-for-application-authe/52625165
echo $(openssl x509 -in my_certificate.pem -fingerprint -noout) | sed 's/SHA1 Fingerprint=//g' | sed 's/://g' | xxd -r -ps | base64

Then you can finally generate your JWT token:

use Carbon\Carbon;
use Illuminate\Support\Str;

/**
 * @link https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials
 * @return string
 */
protected function generateJWT()
{
    return JWT::encode([
            'aud' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
            'exp' => Carbon::now()->addMinutes(9.5)->timestamp,
            'nbf' => Carbon::now()->timestamp - 1,
            'iss' => config('services.microsoft.client_id'),
            'sub' => config('services.microsoft.client_id'),
            'jti' => Str::uuid()->toString(),
        ],
        file_get_contents(storage_path('keys/privkey.pem')), // or where ever your private key is located
        'RS256',
        null, // Although I used the `keyId` given in the Microsoft App's Manifest JSON, under the `keyCredentials` array
        [
            "alg" => "RS256",
            "typ" => "JWT",
            // @link https://stackoverflow.com/questions/50657463/how-to-obtain-value-of-x5t-using-certificate-credentials-for-application-authe/52625165
            // echo $(openssl x509 -in helpspot.pem -fingerprint -noout) | sed 's/SHA1 Fingerprint=//g' | sed 's/://g' | xxd -r -ps | base64
            "x5t" => "your_base64_encoded_thumbprint",
        ]
    );
}

Now you can generate a JWT for all of your API calls to the Microsoft Graph API for your application.

I wrote this post in anger, it may have typos, and may be missing information. I just hope Google finds it, and therefore, so you do!

Resources I Found Useful

  1. Authenticating with Azure AD using JWT's
  2. Microsoft's docs on using JWT's
@fideloper
Copy link
Author

@gkilwein Your welcome! Did you find this page in a google search?!

@gkilwein
Copy link

@fideloper I did! I found it searching for "create ssl certificate for microsoft graph api using PHP". I think it would have taken me a week or more to piece together all of this, so thanks for taking the time to write all this up!

@utpal4job
Copy link

Hello, thanks for detailed explanation. I have implemented what you said but errors appears, here is the error msg "Fatal error: Uncaught Error: Class "Carbon\Carbon" not found ".

Pls help me to solve the issue. i have almost invested 7 days on JWT issue.

Regards.

@utpal4job
Copy link

Hello, i am able to run the code but still getting the "JWT" issue. pls check it below.

Array
(
[error] => Array
(
[code] => InvalidAuthenticationToken
[message] => IDX14100: JWT is not well formed, there are no dots (.).
The token needs to be in JWS or JWE Compact Serialization Format. (JWS): 'EncodedHeader.EndcodedPayload.EncodedSignature'. (JWE): 'EncodedProtectedHeader.EncodedEncryptedKey.EncodedInitializationVector.EncodedCiphertext.EncodedAuthenticationTag'.
[innerError] => Array
(
[date] => 2024-02-19T16:17:24
[request-id] => 25fed227-2e32-4cd2-8856-20aee695b99b
[client-request-id] => 25fed227-2e32-4cd2-8856-20aee695b99b
)

    )

)

Pls help me to solve the issue.
Regards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment