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).
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:
The clicks to get there are roughly (subject to change any given hour of the day):
- Sign into Office365
- Head to the "Admin" area (left-hand nav)
- Expand the resulting left-hand menu via "Show all"
- 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.
Accounts in this organizational directory only
- Only if this is oAuth for internal company useAccounts in any organizational directory (Any Azure AD directory - Multitenant)
- For other Office365/Azure AD usersAccounts 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 loginPersonal 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:
- 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!
- 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)
- 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.
- 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)
- Owners: Set other Office365 users as ones who can manage this application
Those are the only settings I had to set in my case.
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 theAuthentication
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 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)!
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:
- privkey.pem - Needed to generate JWT tokens
- cert.csr - No longer needed
- 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.
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!
@PhilippKolmann agreed, brilliant. It would have taken me way longer to figure this out without this gist from @fideloper .