Skip to content
This repository has been archived by the owner on Oct 19, 2022. It is now read-only.

Commit

Permalink
Merge pull request #88 from stormpath/rc3
Browse files Browse the repository at this point in the history
RC3 release
  • Loading branch information
nbarbettini authored May 19, 2017
2 parents 7020245 + 66c0d9b commit 768f1bc
Show file tree
Hide file tree
Showing 18 changed files with 500 additions and 37 deletions.
13 changes: 6 additions & 7 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ If you have questions or need help, please reach out to us at support@stormpath.

Follow the [migration guide](migrating.md) to understand how to migrate an application step-by-step.

### Coming soon

These features don't work yet, but are coming in a future RC.

* Client Credentials (API key/secret) authentication
* Updating user profile or custom fields (reading works, no way to save currently)

### Stormpath features that will not migrate

See the Compatibility Matrix on the [Stormpath-Okta Customer FAQ](https://stormpath.com/oktaplusstormpath) for a complete list of features that are not being migrated. The relevant points for this library are:
Expand All @@ -26,12 +19,18 @@ See the Compatibility Matrix on the [Stormpath-Okta Customer FAQ](https://stormp
* Custom Data is only be available on account resources.
* The Verification Success Email, Welcome Email, and Password Reset Success Email workflows are not supported.

## Version 4.0.0-RC3

### Breaking changes

* The Client Credentials grant works, but it is handled differently in Okta than it was at Stormpath. The API key ID and secret are stored in the user profile, and are verified locally by this middleware code. This feature is intended to help our customers migrate, but won't be how Okta supports API key management going forward. If you're using the API key management features of Stormpath heavily, please reach out to [email protected] and let us know so we can assist.

## Version 4.0.0-RC2

### Breaking changes

* Authorizing (using attributes in ASP.NET or handlers in ASP.NET Core) by Group `href` is no longer possible. Authorizing by Group name still works.
* Any `OrganizationNameKey` value that is set in a pre-handler is not honored.

#### Social login

Expand Down
11 changes: 11 additions & 0 deletions src/Stormpath.Owin.Middleware/AccessTokenValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ public AccessTokenValidator(

public Task<TokenIntrospectionResult> ValidateAsync(string token, CancellationToken cancellationToken)
{
// Try to validate the token as a token issued by the Client Credentials flow (OauthRoute)
var orphanValidator = new OrphanAccessTokenValidator(
_configuration.Application.Id,
_configuration.OktaEnvironment.ClientId,
_configuration.OktaEnvironment.ClientSecret);
var orphanValidationResult = orphanValidator.ValidateAsync(token);
if (orphanValidationResult != TokenIntrospectionResult.Invalid)
{
return Task.FromResult(orphanValidationResult);
}

if (_configuration.Web.Oauth2.Password.ValidationStrategy == WebOauth2TokenValidationStrategy.Stormpath)
{
var remoteValidator = new RemoteTokenValidator(
Expand Down
39 changes: 39 additions & 0 deletions src/Stormpath.Owin.Middleware/ConstantTimeComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Text;

namespace Stormpath.Owin.Middleware
{
public static class ConstantTimeComparer
{
public static bool Equals(string x, string y)
{
var xIsNull = x == null;
var yIsNull = y == null;

if (xIsNull && yIsNull)
{
return true;
}

if ((xIsNull && !yIsNull) || (!xIsNull && yIsNull))
{
return false;
}

if (x.Length != y.Length)
{
return false;
}

byte[] decodedCryptoBytes = Encoding.ASCII.GetBytes(x);
byte[] decodedSignatureBytes = Encoding.ASCII.GetBytes(y);

byte result = 0;
for (int i = 0; i < x.Length; i++)
{
result |= (byte)(decodedCryptoBytes[i] ^ decodedSignatureBytes[i]);
}

return result == 0;
}
}
}
1 change: 1 addition & 0 deletions src/Stormpath.Owin.Middleware/GrantResult.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Newtonsoft.Json;
using Stormpath.Owin.Middleware.Okta;

namespace Stormpath.Owin.Middleware
{
Expand Down
93 changes: 83 additions & 10 deletions src/Stormpath.Owin.Middleware/LoginExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@
// limitations under the License.
// </copyright>

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Stormpath.Configuration.Abstractions.Immutable;
using Microsoft.IdentityModel.Tokens;
using Stormpath.Owin.Abstractions;
using Stormpath.Owin.Abstractions.Configuration;
using Stormpath.Owin.Middleware.Internal;
using Stormpath.Owin.Middleware.Model.Error;
using Stormpath.Owin.Middleware.Okta;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Stormpath.Owin.Middleware
{
Expand All @@ -48,7 +51,7 @@ public LoginExecutor(
_logger = logger;
}

public async Task<GrantResult> PasswordGrantAsync(
public async Task<(GrantResult GrantResult, User User)> PasswordGrantAsync(
IOwinEnvironment environment,
Func<string, CancellationToken, Task> errorHandler,
string login,
Expand All @@ -70,7 +73,7 @@ public async Task<GrantResult> PasswordGrantAsync(
? "An error has occurred. Please try again."
: preLoginHandlerContext.Result.ErrorMessage;
await errorHandler(message, cancellationToken);
return null;
return (null, null);
}
}

Expand All @@ -93,10 +96,79 @@ public async Task<GrantResult> PasswordGrantAsync(
if (!validationResult.Active)
{
_logger.LogWarning("The user's token was invalid.");
return null;
return (null, null);
}

return grantResult;
var user = await UserHelper.GetUserFromAccessTokenAsync(_oktaClient, grantResult.AccessToken, _logger, cancellationToken);

return (grantResult, user);
}

public async Task<GrantResult> ClientCredentialsGrantAsync(
IOwinEnvironment environment,
Func<AbstractError, CancellationToken, Task> errorHandler,
string id,
string userId,
CancellationToken cancellationToken)
{
var preLoginHandlerContext = new PreLoginContext(environment)
{
Login = id
};

await _handlers.PreLoginHandler(preLoginHandlerContext, cancellationToken);

if (preLoginHandlerContext.Result != null)
{
if (!preLoginHandlerContext.Result.Success)
{
var message = string.IsNullOrEmpty(preLoginHandlerContext.Result.ErrorMessage)
? "An error has occurred. Please try again."
: preLoginHandlerContext.Result.ErrorMessage;
await errorHandler(new BadRequest(message), cancellationToken);
return null;
}
}

var ttl = _configuration.Web.Oauth2.Client_Credentials.AccessToken.Ttl ?? 3600;

return new GrantResult
{
AccessToken = BuildClientCredentialsAccessToken(id, userId, ttl),
TokenType = "Bearer",
ExpiresIn = ttl,
// Scope = TODO
};
}

private string BuildClientCredentialsAccessToken(string id, string userId, int timeToLive)
{
var signingKey = _configuration.OktaEnvironment.ClientSecret;

var signingCredentials = new Microsoft.IdentityModel.Tokens.SigningCredentials(
new SymmetricSecurityKey(Encoding.ASCII.GetBytes(signingKey)), SecurityAlgorithms.HmacSha256);

var now = DateTime.UtcNow;

var claims = new[]
{
new Claim("sub", id),
new Claim("jti", Guid.NewGuid().ToString()),
new Claim("iat", ((long)((now - Cookies.Epoch).TotalSeconds)).ToString(), ClaimValueTypes.Integer64),
new Claim("cid", _configuration.OktaEnvironment.ClientId),
new Claim("uid", userId)
};

var jwt = new JwtSecurityToken(
claims: claims,
issuer: _configuration.Application.Id,
expires: now + TimeSpan.FromSeconds(timeToLive),
notBefore: DateTime.UtcNow,
signingCredentials: signingCredentials);
// TODO audience
// TODO scope

return new JwtSecurityTokenHandler().WriteToken(jwt);
}

// TODO restore
Expand All @@ -112,14 +184,15 @@ public async Task<GrantResult> PasswordGrantAsync(
public async Task<ICompatibleOktaAccount> HandlePostLoginAsync(
IOwinEnvironment context,
GrantResult grantResult,
User user,
CancellationToken cancellationToken)
{
var stormpathCompatibleUser = await UserHelper.GetUserFromAccessTokenAsync(_oktaClient, grantResult.AccessToken, _logger, cancellationToken);
var stormpathCompatibleUser = new CompatibleOktaAccount(user);

var postLoginHandlerContext = new PostLoginContext(context, stormpathCompatibleUser);
await _handlers.PostLoginHandler(postLoginHandlerContext, cancellationToken);

//Save the custom redirect URI from the handler, if any
// Save the custom redirect URI from the handler, if any
_nextUriFromPostHandler = postLoginHandlerContext.Result?.RedirectUri;

// Add Stormpath cookies
Expand Down
36 changes: 36 additions & 0 deletions src/Stormpath.Owin.Middleware/Okta/ApiKeyResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Stormpath.Owin.Middleware.Okta
{
public class ApiKeyResolver
{
private readonly IOktaClient _oktaClient;

public ApiKeyResolver(IOktaClient oktaClient)
{
_oktaClient = oktaClient;
}

public async Task<ShimApiKey> LookupApiKeyIdAsync(string id, string secret, CancellationToken cancellationToken)
{
var apiKey = await _oktaClient.GetApiKeyAsync(id, cancellationToken);

var validKey =
apiKey != null &&
apiKey.Status.Equals("enabled", StringComparison.OrdinalIgnoreCase) &&
ConstantTimeComparer.Equals(apiKey.Secret, secret);

if (!validKey) return null;

var validAccount =
apiKey.User != null &&
apiKey.User.Status.Equals("active", StringComparison.OrdinalIgnoreCase);

if (!validAccount) return null;

return apiKey;
}
}
}
2 changes: 2 additions & 0 deletions src/Stormpath.Owin.Middleware/Okta/IOktaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,7 @@ Task SendPasswordResetEmailAsync(
Task<IdentityProvider[]> GetIdentityProvidersAsync(CancellationToken ct);

Task<Group[]> GetGroupsForUserIdAsync(string userId, CancellationToken cancellationToken);

Task<ShimApiKey> GetApiKeyAsync(string apiKeyId, CancellationToken cancellationToken);
}
}
12 changes: 12 additions & 0 deletions src/Stormpath.Owin.Middleware/Okta/OauthApiError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Newtonsoft.Json;

namespace Stormpath.Owin.Middleware.Okta
{
public class OauthApiError
{
public string Error { get; set; }

[JsonProperty("error_description")]
public string Description { get; set; }
}
}
75 changes: 74 additions & 1 deletion src/Stormpath.Owin.Middleware/Okta/OktaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Stormpath.Owin.Middleware.Internal;
using System.Linq;

namespace Stormpath.Owin.Middleware.Okta
{
Expand Down Expand Up @@ -295,8 +296,28 @@ public Task<GrantResult> PostPasswordGrantAsync(
};
request.Content = new FormUrlEncodedContent(parameters);

var exceptionFormatter = new Func<int, string, Exception>((statusCode, body) =>
{
_logger.LogWarning($"{statusCode} {body}");
try
{
var deserialized = JsonConvert.DeserializeObject<OauthApiError>(body);
var isInvalidGrantError = deserialized?.Error?.Equals("invalid_grant") ?? false;
if (!isInvalidGrantError) return DefaultExceptionFormatter(statusCode, body);
return new InvalidOperationException("Invalid username or password.");
}
catch (Exception ex)
{
_logger.LogError(1005, ex, "Error while formatting error response");
return DefaultExceptionFormatter(statusCode, body);
}
});

_logger.LogTrace($"Executing password grant flow for subject {username}");
return SendAsync<GrantResult>(request, cancellationToken);
return SendAsync<GrantResult>(request, cancellationToken, exceptionFormatter);
}

public Task<GrantResult> PostRefreshGrantAsync(
Expand Down Expand Up @@ -457,5 +478,57 @@ public Task<IdentityProvider[]> GetIdentityProvidersAsync(CancellationToken ct)

public Task<Group[]> GetGroupsForUserIdAsync(string userId, CancellationToken cancellationToken)
=> GetResource<Group[]>($"{ApiPrefix}/users/{userId}/groups", cancellationToken);

private const string ProfileAttributeDoesNotExist = "E0000031";

public async Task<ShimApiKey> GetApiKeyAsync(string apiKeyId, CancellationToken cancellationToken)
{
User foundUser = null;
string foundKeypair = null;

for (var i = 0; i < 10; i++)
{
try
{
var foundUsers = await SearchUsersAsync($"profile.stormpathApiKey_{i} sw \"{apiKeyId}\"", cancellationToken);

foundUser = foundUsers?.FirstOrDefault();

if (foundUser == null) continue;

foundUser.Profile.TryGetValue($"stormpathApiKey_{i}", out var rawValue);
foundKeypair = rawValue?.ToString();

var keypairTokens = foundKeypair?.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries);
var valid = keypairTokens?.Length == 2;

if (!valid) continue;

return new ShimApiKey
{
Id = keypairTokens[0],
Secret = keypairTokens[1],
Status = "ENABLED",
User = foundUser
};
}
catch (OktaException oex)
{
object rawCode = null;
oex?.Body?.TryGetValue("errorCode", out rawCode);
var code = rawCode?.ToString();
if (string.IsNullOrEmpty(code)) throw;

// Code E0000031 means "the profile attribute doesn't exist"
if (code.Equals(ProfileAttributeDoesNotExist))
{
_logger.LogWarning($"The profile attribute 'profile.stormpathApiKey_{i}' should be added to your Universal Directory configuration.");
continue;
}
}
}

return null;
}
}
}
Loading

0 comments on commit 768f1bc

Please sign in to comment.