This article describes how to add authentication for a Basecamp account to an Azure AD B2C custom policy.
Architecture
The following diagram illustrates the authentication flow for a Basecamp account to an Azure AD B2C custom policy.
The authentication flow requires two Azure functions:
- One that exchanges an authorization code for an access token, because Basecamp’s token endpoint contains the non-standard request parameter
type=web_server
, which isn’t supported by Azure AD B2C. - Another that retrieves flattened claims for the authenticated user
For information about Basecamp and OAuth 2.0, see https://github.com/basecamp/api/blob/master/sections/authentication.md.
Prerequisites
- If you don’t already have one, then you must create an Azure AD B2C tenant that is linked to your Azure subscription.
- Prepare your Azure AD B2C tenant by creating the token signing and encryption keys and creating the Identity Experience Framework applications.
- Download one of the starter packs for Azure AD B2C from Microsoft’s GitHub repository.
Create Azure functions
- Create a C# function containing the following code. This implements a GetBasecampAccessToken function that exchanges an authorization code for an access token. Then publish this C# function to a function app.
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
namespace SignInWithBasecamp
{
public static class GetBasecampAccessToken
{
private static readonly HttpClient InnerClient = new HttpClient();
[FunctionName("GetBasecampAccessToken")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest request,
ILogger logger)
{
if (request.Form == null)
{
logger.LogError("Form is missing");
return new BadRequestResult();
}
if (!request.Form.TryGetValue("client_id", out var clientId))
{
logger.LogError("The client identifier is missing");
return new BadRequestResult();
}
if (!request.Form.TryGetValue("client_secret", out var clientSecret))
{
logger.LogError("The client secret is missing");
return new BadRequestResult();
}
if (!request.Form.TryGetValue("code", out var code))
{
logger.LogError("Code is missing");
return new BadRequestResult();
}
if (!request.Form.TryGetValue("redirect_uri", out var redirectUri))
{
logger.LogError("The redirection URI is missing");
return new BadRequestResult();
}
var innerRequest = new HttpRequestMessage(HttpMethod.Post, "https://launchpad.37signals.com/authorization/token");
var innerRequestModel = new Dictionary<string, string>
{
{"type", "web_server"},
{"client_id", clientId},
{"client_secret", clientSecret},
{"code", code},
{"redirect_uri", redirectUri}
};
innerRequest.Content = new FormUrlEncodedContent(innerRequestModel);
var innerResponse = await InnerClient.SendAsync(innerRequest);
if (innerResponse.StatusCode != HttpStatusCode.OK && innerResponse.StatusCode != HttpStatusCode.Unauthorized && innerResponse.StatusCode != HttpStatusCode.Forbidden)
{
return new InternalServerErrorResult();
}
if (innerResponse.StatusCode == HttpStatusCode.Unauthorized)
{
var innerErrorResponseModel = await innerResponse.Content.ReadAsJsonAsync<BasecampAccessTokenErrorResponseModel>();
logger.LogError($"Error: {innerErrorResponseModel.Error}, error description: {innerErrorResponseModel.ErrorDescription}.");
return new UnauthorizedResult();
}
if (innerResponse.StatusCode == HttpStatusCode.Forbidden)
{
var innerErrorResponseModel = await innerResponse.Content.ReadAsJsonAsync<BasecampAccessTokenErrorResponseModel>();
logger.LogError($"Error: {innerErrorResponseModel.Error}, error description: {innerErrorResponseModel.ErrorDescription}.");
return new ForbiddenResult();
}
var innerResponseModel = await innerResponse.Content.ReadAsJsonAsync<BasecampAccessTokenResponseModel>();
var responseModel = new
{
access_token = innerResponseModel.AccessToken
};
return new OkObjectResult(responseModel);
}
}
}
- Create another C# function containing the following code. This implements a GetBasecampClaims function that retrieves flattened claims for the authenticated user. Then publish this C# function to a function app.
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System.Web.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
namespace SignInWithBasecamp
{
public static class GetBasecampClaims
{
private static readonly HttpClient InnerClient = new HttpClient();
[FunctionName("GetBasecampClaims")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest request,
ILogger logger)
{
if (!request.Headers.TryGetValue("Authorization", out var authorizationHeaderValues))
{
logger.LogError("The Authorization header is missing");
return new BadRequestResult();
}
if (authorizationHeaderValues.Count > 1)
{
logger.LogError("The Authorization header is invalid");
return new BadRequestResult();
}
var authorizationHeaderValue = authorizationHeaderValues[0];
var authorizationHeaderValueParts = authorizationHeaderValue.Split(' ');
if (authorizationHeaderValueParts.Length != 2 || !authorizationHeaderValueParts[0].Equals("Bearer", StringComparison.OrdinalIgnoreCase))
{
logger.LogError("The Authorization header is invalid");
return new BadRequestResult();
}
var innerRequest = new HttpRequestMessage(HttpMethod.Get, "https://launchpad.37signals.com/authorization.json");
innerRequest.Headers.Authorization = new AuthenticationHeaderValue(authorizationHeaderValueParts[0], authorizationHeaderValueParts[1]);
var innerResponse = await InnerClient.SendAsync(innerRequest);
if (innerResponse.StatusCode != HttpStatusCode.OK)
{
return new InternalServerErrorResult();
}
var innerResponseModel = await innerResponse.Content.ReadAsJsonAsync<BasecampClaimsResponseModel>();
var responseModel = new
{
id = innerResponseModel.Identity.Id,
email = innerResponseModel.Identity.EmailAddress,
name = $"{innerResponseModel.Identity.FirstName} {innerResponseModel.Identity.LastName}"
};
return new OkObjectResult(responseModel);
}
}
}
The model classes are implemented as follows.
using Newtonsoft.Json;
namespace SignInWithBasecamp
{
public class BasecampAccessTokenResponseModel
{
[JsonProperty("access_token")]
public string AccessToken { get; set; }
}
public class BasecampAccessTokenErrorResponseModel
{
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("error_description")]
public long ErrorDescription { get; set; }
}
public class BasecampClaimsResponseModel
{
[JsonProperty("identity")]
public BasecampIdentityModel Identity { get; set; }
}
}
The ForbiddenResult class is implemented as follows.
using Microsoft.AspNetCore.Mvc;
namespace SignInWithBasecamp
{
public class ForbiddenResult : StatusCodeResult
{
public ForbiddenResult()
: base(403)
{
}
}
}
The ReadAsJsonAsync extension method is implemented as follows.
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace SignInWithBasecamp
{
public static class HttpContentExtensions
{
public static async Task<T> ReadAsJsonAsync<T>(this HttpContent content)
{
if (content == null)
{
return default;
}
var value = await content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<T>(value);
}
}
}
Configure a Basecamp application
- Log in to https://integrate.37signals.com/.
- On the Your Applications page, select Register one now.
- On the New Application page, enter the following fields and then select Register this app:
- Name of your application
- Your company’s name
- Your website URL
- Products: Select Basecamp 3.
- Redirect URI: Enter either https://your-tenant-name.b2clogin.com/your-tenant-name.onmicrosoft.com/oauth2/authresp if you use the built-in domain or https://your-domain-name/your-tenant-name.onmicrosoft.com/oauth2/authresp if you use a custom domain for the Callback URL field. Replace your-tenant-name with your tenant name and your-domain-name with your custom domain.
- On the Your Applications page, select the application name.
- On the application details page, copy the Client ID and Client Secret fields.
Add the client secret for the Basecamp application as a policy key
- Sign in to the Azure AD B2C portal.
- Select Identity Experience Framework.
- Select Policy keys.
- Select Add.
- In the Create a key section, enter the following fields and then select Create:
- Options:
Manual
- Name:
BasecampClientSecret
- Secret: Paste the Client Secret field that was copied in the previous section.
- Options:
Configure Basecamp as an identity provider
- Open the TrustFrameworkExtensions.xml file.
- Find the ClaimsProviders element. If it doesn’t exist, then add it to the TrustFrameworkPolicy element.
- Add the following ClaimsProvider element to the ClaimsProviders element. Replace
your-basecamp-client-id
with the Client ID field that was copied in the Configure a Basecamp application section. Replaceyour-function-app-name
with the function app name that was created in the Create Azure functions section.
<ClaimsProvider>
<Domain>37signals.com</Domain>
<DisplayName>Basecamp</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="Basecamp-OAuth2">
<DisplayName>Basecamp</DisplayName>
<Protocol Name="OAuth2" />
<Metadata>
<Item Key="authorization_endpoint">https://launchpad.37signals.com/authorization/new?type=web_server</Item>
<Item Key="client_id">your-basecamp-client-id</Item>
<Item Key="HttpBinding">POST</Item>
<Item Key="AccessTokenEndpoint">https://your-function-app-name.azurewebsites.net/api/GetBasecampAccessToken</Item>
<Item Key="ClaimsEndpoint">https://your-function-app-name.azurewebsites.net/api/GetBasecampClaims</Item>
<Item Key="BearerTokenTransmissionMethod">AuthorizationHeader</Item>
<Item Key="ProviderName">37signals.com</Item>
<Item Key="UsePolicyInRedirectUri">false</Item>
</Metadata>
<CryptographicKeys>
<Key Id="client_secret" StorageReferenceId="B2C_1A_BasecampClientSecret" />
</CryptographicKeys>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="authenticationSource" DefaultValue="socialAccountAuthentication" AlwaysUseDefaultValue="true" />
<OutputClaim ClaimTypeReferenceId="identityProvider" DefaultValue="37signals.com" AlwaysUseDefaultValue="true" />
<OutputClaim ClaimTypeReferenceId="issuerUserId" PartnerClaimType="id" />
<OutputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="name" />
<OutputClaim ClaimTypeReferenceId="email" PartnerClaimType="email" />
</OutputClaims>
<OutputClaimsTransformations>
<OutputClaimsTransformation ReferenceId="CreateRandomUPNUserName" />
<OutputClaimsTransformation ReferenceId="CreateUserPrincipalName" />
<OutputClaimsTransformation ReferenceId="CreateAlternativeSecurityId" />
</OutputClaimsTransformations>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-SocialLogin" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
Add a user journey
- Open the TrustFrameworkBase.xml file.
- Copy the UserJourney element that includes
Id="SignUpOrSignIn"
. - Open the TrustFrameworkExtensions.xml file and find the UserJourneys element. If it doesn’t exist, then add it to the TrustFrameworkPolicy element.
- Paste the UserJourney element that was copied in step 2 to the UserJourneys element and replace the Id attribute for this UserJourney element from
"SignUpOrSignIn"
to"BasecampSignUpOrSignIn"
.
Add the identity provider to the user journey
- Add the claims provider that was configured in the Configure Basecamp as an identity provider section to the user journey that was added in the previous section.
<OrchestrationStep Order="1" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
<ClaimsProviderSelections>
...
<ClaimsProviderSelection TargetClaimsExchangeId="BasecampExchange" />
</ClaimsProviderSelections>
...
</OrchestrationStep>
<OrchestrationStep Order="2" Type="ClaimsExchange">
...
<ClaimsExchanges>
<ClaimsExchange Id="BasecampExchange" TechnicalProfileReferenceId="Basecamp-OAuth2" />
</ClaimsExchanges>
...
</OrchestrationStep>
Configure the relying party policy
- Open the SignUpOrSignIn.xml file.
- Replace the ReferenceId attribute for the DefaultUserJourney element from
"SignUpOrSignIn"
to"BasecampSignUpOrSignIn"
.
<RelyingParty>
<DefaultUserJourney ReferenceId="BasecampSignUpSignIn" />
...
</RelyingParty>
Upload and test the custom policy
- Upload all policy files in the following order to your Azure AD B2C tenant:
- TrustFrameworkBase.xml
- TrustFrameworkLocalization.xml
- TrustFrameworkExtensions.xml
- SignUpOrSignIn.xml
- Test the B2C_1A_signup_signin policy from your Azure AD B2C tenant.