diff --git a/Stormpath.SDK/Stormpath.SDK.Tests/Impl/IdSite/DefaultIdSiteUrlBuilder_tests.cs b/Stormpath.SDK/Stormpath.SDK.Tests/Impl/IdSite/DefaultIdSiteUrlBuilder_tests.cs new file mode 100644 index 00000000..0411a8ab --- /dev/null +++ b/Stormpath.SDK/Stormpath.SDK.Tests/Impl/IdSite/DefaultIdSiteUrlBuilder_tests.cs @@ -0,0 +1,202 @@ +// +// Copyright (c) 2015 Stormpath, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using NSubstitute; +using Shouldly; +using Stormpath.SDK.Api; +using Stormpath.SDK.Client; +using Stormpath.SDK.IdSite; +using Stormpath.SDK.Impl.Client; +using Stormpath.SDK.Impl.IdSite; +using Stormpath.SDK.Impl.Utility; +using Stormpath.SDK.Jwt; +using Xunit; + +namespace Stormpath.SDK.Tests.Impl.IdSite +{ + public class DefaultIdSiteUrlBuilder_tests + { + private static readonly string FakeApiKeyId = "foobar"; + private static readonly string FakeApiKeySecret = "veryLONGsupersecret_string123"; + + private static readonly string FakeApplicationHref = "https://api.stormpath.com/v1/applications/foobarApplication"; + private static readonly string FakeCallbackUri = "http://my.app/login"; + + private static readonly string SsoUrl = "https://api.stormpath.com/sso"; + private static readonly string JwtArg = "?jwtRequest="; + + private readonly string fakeJti = "12345"; + private readonly IIdSiteJtiProvider fakeJtiProvider; + + private readonly DateTimeOffset fakeNow = new DateTimeOffset(2016, 01, 01, 00, 00, 00, TimeSpan.Zero); + private readonly IClock fakeClock; + + private readonly IClient fakeClient; + + public DefaultIdSiteUrlBuilder_tests() + { + this.fakeJtiProvider = Substitute.For(); + this.fakeJtiProvider.NewJti().Returns(this.fakeJti); + + this.fakeClock = Substitute.For(); + this.fakeClock.Now.Returns(this.fakeNow); + + var testApiKey = ClientApiKeys.Builder() + .SetId(FakeApiKeyId) + .SetSecret(FakeApiKeySecret) + .Build(); + + this.fakeClient = Clients.Builder() + .SetApiKey(testApiKey) + .Build(); + } + + private static readonly string PlainRequest = $"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM0NSIsImlhdCI6MTQ1MTYwNjQwMCwiaXNzIjoiZm9vYmFyIiwic3ViIjoiaHR0cHM6Ly9hcGkuc3Rvcm1wYXRoLmNvbS92MS9hcHBsaWNhdGlvbnMvZm9vYmFyQXBwbGljYXRpb24iLCJjYl91cmkiOiJodHRwOi8vbXkuYXBwL2xvZ2luIn0.s5tkKAJX3SzAey9fvaG_MGJCESpcLfWdM8PVtrwx5KI"; + + [Fact] + public void Generates_url() + { + var builder = this.GetBuilder(); + + var url = builder.Build(); + url.ShouldBe($"{SsoUrl}{JwtArg}{PlainRequest}"); + + var jwt = this.ParseUrl(url); + this.VerifyCommon(jwt); + } + + [Fact] + public void Generates_url_for_logout() + { + var builder = this.GetBuilder(); + builder.ForLogout(); + + var url = builder.Build(); + url.ShouldBe($"{SsoUrl}/logout{JwtArg}{PlainRequest}"); + } + + [Fact] + public void Generates_url_with_path() + { + var builder = this.GetBuilder(); + builder.SetPath("/foo"); + + var url = builder.Build(); + + var jwt = this.ParseUrl(url); + this.VerifyCommon(jwt); + jwt.Body.GetClaim("path").ShouldBe("/foo"); + } + + [Fact] + public void Generates_url_with_organization_nameKey() + { + var builder = this.GetBuilder(); + builder.SetOrganizationNameKey("gonk"); + + var url = builder.Build(); + + var jwt = this.ParseUrl(url); + this.VerifyCommon(jwt); + jwt.Body.GetClaim("onk").ShouldBe("gonk"); + } + + [Fact] + public void Generates_url_with_subdomain_flag() + { + var builder = this.GetBuilder(); + builder.SetUseSubdomain(true); + + var url = builder.Build(); + + var jwt = this.ParseUrl(url); + this.VerifyCommon(jwt); + jwt.Body.GetClaim("usd").ShouldBe(true); + } + + [Fact] + public void Generates_url_with_organization_field_flag() + { + var builder = this.GetBuilder(); + builder.SetShowOrganizationField(true); + + var url = builder.Build(); + + var jwt = this.ParseUrl(url); + this.VerifyCommon(jwt); + jwt.Body.GetClaim("sof").ShouldBe(true); + } + + [Fact] + public void Generates_url_with_state() + { + var builder = this.GetBuilder(); + builder.SetState("foobar123!"); + + var url = builder.Build(); + + var jwt = this.ParseUrl(url); + this.VerifyCommon(jwt); + jwt.Body.GetClaim("state").ShouldBe("foobar123!"); + } + + [Fact] + public void Generates_url_with_all_the_things() + { + var builder = this.GetBuilder(); + builder.ForLogout(); + builder.SetOrganizationNameKey("FirstOrder"); + builder.SetPath("/blah"); + builder.SetShowOrganizationField(false); + builder.SetState("123"); + builder.SetUseSubdomain(false); + + var url = builder.Build(); + url.ShouldStartWith($"{SsoUrl}/logout{JwtArg}"); + + var jwt = this.ParseUrl(url); + this.VerifyCommon(jwt); + jwt.Body.GetClaim("onk").ShouldBe("FirstOrder"); + jwt.Body.GetClaim("path").ShouldBe("/blah"); + jwt.Body.GetClaim("sof").ShouldBe(false); + jwt.Body.GetClaim("state").ShouldBe("123"); + jwt.Body.GetClaim("usd").ShouldBe(false); + } + + private IIdSiteUrlBuilder GetBuilder() + { + var client = this.fakeClient as DefaultClient; + + IIdSiteUrlBuilder builder = new DefaultIdSiteUrlBuilder( + client.DataStore, FakeApplicationHref, this.fakeJtiProvider, this.fakeClock); + builder.SetCallbackUri(FakeCallbackUri); + + return builder; + } + + private IJwt ParseUrl(string url) + => this.fakeClient.NewJwtParser().Parse(url.Split('=')[1]); + + private void VerifyCommon(IJwt jwt) + { + jwt.Body.Issuer.ShouldBe(FakeApiKeyId); + jwt.Body.Subject.ShouldBe(FakeApplicationHref); + jwt.Body.GetClaim("cb_uri").ShouldBe(FakeCallbackUri); + } + } +} diff --git a/Stormpath.SDK/Stormpath.SDK.Tests/Stormpath.SDK.Tests.csproj b/Stormpath.SDK/Stormpath.SDK.Tests/Stormpath.SDK.Tests.csproj index 48f0be02..cfe38cf0 100644 --- a/Stormpath.SDK/Stormpath.SDK.Tests/Stormpath.SDK.Tests.csproj +++ b/Stormpath.SDK/Stormpath.SDK.Tests/Stormpath.SDK.Tests.csproj @@ -122,6 +122,7 @@ + diff --git a/Stormpath.SDK/Stormpath.SDK/Impl/Application/DefaultApplication.IdSite.cs b/Stormpath.SDK/Stormpath.SDK/Impl/Application/DefaultApplication.IdSite.cs index d36fd332..a477255f 100644 --- a/Stormpath.SDK/Stormpath.SDK/Impl/Application/DefaultApplication.IdSite.cs +++ b/Stormpath.SDK/Stormpath.SDK/Impl/Application/DefaultApplication.IdSite.cs @@ -16,13 +16,14 @@ using Stormpath.SDK.Application; using Stormpath.SDK.Impl.IdSite; +using Stormpath.SDK.Impl.Utility; namespace Stormpath.SDK.Impl.Application { internal sealed partial class DefaultApplication { SDK.IdSite.IIdSiteUrlBuilder IApplication.NewIdSiteUrlBuilder() - => new DefaultIdSiteUrlBuilder(this.GetInternalDataStore(), this.AsInterface.Href); + => new DefaultIdSiteUrlBuilder(this.GetInternalDataStore(), this.AsInterface.Href, new DefaultIdSiteJtiProvider(), new DefaultClock()); SDK.IdSite.IIdSiteAsyncCallbackHandler IApplication.NewIdSiteAsyncCallbackHandler(SDK.Http.IHttpRequest request) => new DefaultIdSiteAsyncCallbackHandler(this.GetInternalDataStore(), request); diff --git a/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/DefaultIdSiteJtiProvider.cs b/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/DefaultIdSiteJtiProvider.cs new file mode 100644 index 00000000..c70d8399 --- /dev/null +++ b/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/DefaultIdSiteJtiProvider.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2015 Stormpath, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Stormpath.SDK.Impl.IdSite +{ + internal sealed class DefaultIdSiteJtiProvider : IIdSiteJtiProvider + { + string IIdSiteJtiProvider.NewJti() => Guid.NewGuid().ToString(); + } +} diff --git a/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/DefaultIdSiteUrlBuilder.cs b/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/DefaultIdSiteUrlBuilder.cs index 407e528f..49d77996 100644 --- a/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/DefaultIdSiteUrlBuilder.cs +++ b/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/DefaultIdSiteUrlBuilder.cs @@ -19,6 +19,7 @@ using Stormpath.SDK.IdSite; using Stormpath.SDK.Impl.DataStore; using Stormpath.SDK.Impl.Jwt; +using Stormpath.SDK.Impl.Utility; using Stormpath.SDK.Jwt; namespace Stormpath.SDK.Impl.IdSite @@ -31,6 +32,9 @@ internal sealed class DefaultIdSiteUrlBuilder : IIdSiteUrlBuilder private readonly string ssoEndpoint; private readonly string applicationHref; + private readonly IIdSiteJtiProvider jtiProvider; + private readonly IClock clock; + private string callbackUri; private string state; private string path; @@ -39,7 +43,7 @@ internal sealed class DefaultIdSiteUrlBuilder : IIdSiteUrlBuilder private bool? useSubdomain; private bool? showOrganizationField; - public DefaultIdSiteUrlBuilder(IInternalDataStore internalDataStore, string applicationHref) + public DefaultIdSiteUrlBuilder(IInternalDataStore internalDataStore, string applicationHref, IIdSiteJtiProvider jtiProvider, IClock clock) { if (internalDataStore == null) { @@ -51,7 +55,19 @@ public DefaultIdSiteUrlBuilder(IInternalDataStore internalDataStore, string appl throw new ArgumentNullException(nameof(applicationHref)); } + if (jtiProvider == null) + { + throw new ArgumentNullException(nameof(jtiProvider)); + } + + if (clock == null) + { + throw new ArgumentNullException(nameof(clock)); + } + this.internalDataStore = internalDataStore; + this.jtiProvider = jtiProvider; + this.clock = clock; this.applicationHref = applicationHref; this.ssoEndpoint = GetBaseUrl(applicationHref) + "/sso"; } @@ -144,17 +160,17 @@ string IIdSiteUrlBuilder.Build() throw new ApplicationException($"{nameof(this.callbackUri)} cannot be null or empty."); } - var jti = Guid.NewGuid().ToString(); - var now = DateTimeOffset.UtcNow; + var jti = this.jtiProvider.NewJti(); var apiKey = this.internalDataStore.ApiKey; IJwtBuilder jwtBuilder = new DefaultJwtBuilder(this.internalDataStore.Serializer); jwtBuilder .SetId(jti) - .SetIssuedAt(DateTimeOffset.Now) + .SetIssuedAt(this.clock.Now) .SetIssuer(apiKey.GetId()) .SetSubject(this.applicationHref) - .SetClaim(IdSiteClaims.RedirectUri, this.callbackUri); + .SetClaim(IdSiteClaims.RedirectUri, this.callbackUri) + .SignWith(apiKey.GetSecret(), Encoding.UTF8); if (!string.IsNullOrEmpty(this.path)) { @@ -171,12 +187,12 @@ string IIdSiteUrlBuilder.Build() jwtBuilder.SetClaim(IdSiteClaims.OrganizationNameKey, this.organizationNameKey); } - if (this.useSubdomain.HasValue) + if (this.useSubdomain != null) { jwtBuilder.SetClaim(IdSiteClaims.UseSubdomain, this.useSubdomain.Value); } - if (this.showOrganizationField.HasValue) + if (this.showOrganizationField != null) { jwtBuilder.SetClaim(IdSiteClaims.ShowOrganizationField, this.showOrganizationField.Value); } diff --git a/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/IIdSiteJtiProvider.cs b/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/IIdSiteJtiProvider.cs new file mode 100644 index 00000000..56a6646a --- /dev/null +++ b/Stormpath.SDK/Stormpath.SDK/Impl/IdSite/IIdSiteJtiProvider.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) 2015 Stormpath, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Stormpath.SDK.Impl.IdSite +{ + /// + /// Represents a JWT ID (jti) provider used for ID Site. + /// + /// + /// This is abstracted so it can be injectable during testing. + /// + internal interface IIdSiteJtiProvider + { + /// + /// Generates a new JWT ID (jti) string. + /// + /// A JWT ID string. + string NewJti(); + } +} diff --git a/Stormpath.SDK/Stormpath.SDK/Impl/Utility/DefaultClock.cs b/Stormpath.SDK/Stormpath.SDK/Impl/Utility/DefaultClock.cs new file mode 100644 index 00000000..b8287798 --- /dev/null +++ b/Stormpath.SDK/Stormpath.SDK/Impl/Utility/DefaultClock.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2015 Stormpath, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Stormpath.SDK.Impl.Utility +{ + internal sealed class DefaultClock : IClock + { + DateTimeOffset IClock.Now => DateTimeOffset.Now; + } +} diff --git a/Stormpath.SDK/Stormpath.SDK/Impl/Utility/IClock.cs b/Stormpath.SDK/Stormpath.SDK/Impl/Utility/IClock.cs new file mode 100644 index 00000000..d4929ab4 --- /dev/null +++ b/Stormpath.SDK/Stormpath.SDK/Impl/Utility/IClock.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2015 Stormpath, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Stormpath.SDK.Impl.Utility +{ + /// + /// Injectable clock interface for testing. + /// + internal interface IClock + { + /// + /// Gets a object that is set to the current date and time + /// on the current computer, with the offset set to the local time's offset from + /// Coordinated Universal Time (UTC). + /// + /// + /// A System.DateTimeOffset object whose date and time is the current local time + /// and whose offset is the local time zone's offset from Coordinated Universal Time (UTC). + /// + DateTimeOffset Now { get; } + } +} diff --git a/Stormpath.SDK/Stormpath.SDK/Stormpath.SDK.csproj b/Stormpath.SDK/Stormpath.SDK/Stormpath.SDK.csproj index d47451ea..62366da4 100644 --- a/Stormpath.SDK/Stormpath.SDK/Stormpath.SDK.csproj +++ b/Stormpath.SDK/Stormpath.SDK/Stormpath.SDK.csproj @@ -122,6 +122,7 @@ + @@ -264,6 +265,7 @@ + @@ -453,9 +455,11 @@ + +