Skip to content

Pass a list of allowed origins instead of a single origin #184

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 2, 2024
10 changes: 10 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
### 0.10.0.0

* [#184](https://github.com/tweag/webauthn/pull/184) Pass a list of allowed origins instead of a single origin.
This is a breaking change needed for allowing native apps to use WebAuthn. It is also needed for Relying Parties
that want to allow multiple subdomains to access WebAuthn credentials.
Unlike the rest of this library, which strictly follows the L2 version of this spec, this feature is defined
in the [L3 draft](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin). However because WebAuthn on
Native Apps is widely deployed through the push of Passkeys we decided to include this feature in this library early.


### 0.9.0.0

* [#182](https://github.com/tweag/webauthn/pull/182) Migrate to the crypton library ecosystem.
Expand Down
5 changes: 3 additions & 2 deletions server/src/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import System.Hourglass (dateCurrent)
import qualified Web.Cookie as Cookie
import Web.Scotty (ScottyM)
import qualified Web.Scotty as Scotty
import qualified Data.List.NonEmpty as NE

data RegisterBeginReq = RegisterBeginReq
{ accountName :: Text,
Expand Down Expand Up @@ -261,7 +262,7 @@ completeRegistration origin rpIdHash db pending registryVar = do
-- FIXME
registry <- Scotty.liftAndCatchIO $ readTVarIO registryVar
now <- Scotty.liftAndCatchIO dateCurrent
result <- case WA.verifyRegistrationResponse origin rpIdHash registry now options cred of
result <- case WA.verifyRegistrationResponse (NE.singleton origin) rpIdHash registry now options cred of
Failure errs@(err :| _) -> do
Scotty.liftAndCatchIO $ TIO.putStrLn $ "Register complete had errors: " <> Text.pack (show errs)
fail $ show err
Expand Down Expand Up @@ -376,7 +377,7 @@ completeLogin origin rpIdHash db pending = do
-- not be verified.
let verificationResult =
WA.verifyAuthenticationResponse
origin
(NE.singleton origin)
rpIdHash
(Just (WA.ceUserHandle entry))
entry
Expand Down
36 changes: 30 additions & 6 deletions src/Crypto/WebAuthn/Operation/Authentication.hs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import qualified Data.ByteString.Lazy as LBS
import Data.List.NonEmpty (NonEmpty)
import Data.Text (Text)
import Data.Validation (Validation)
import qualified Data.List.NonEmpty as NE

-- | Errors that may occur during [assertion](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)
data AuthenticationError
Expand Down Expand Up @@ -78,7 +79,7 @@ data AuthenticationError
AuthenticationOriginMismatch
{ -- | The origin explicitly passed to the `verifyAuthenticationResponse`
-- response, set by the RP
aeExpectedOrigin :: M.Origin,
aeExpectedOrigin :: NonEmpty M.Origin,
-- | The origin received from the client as part of the client data
aeReceivedOrigin :: M.Origin
}
Expand Down Expand Up @@ -157,11 +158,32 @@ newtype AuthenticationResult = AuthenticationResult

-- | [(spec)](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)
-- Verifies a 'M.Credential' response for an [authentication ceremony](https://www.w3.org/TR/webauthn-2/#authentication).
--
-- The 'arSignatureCounterResult' field of the result should be inspected to
-- enforce Relying Party policy regarding potentially cloned authenticators.
--
-- Though this library implements the WebAuthn L2 spec, for origin validation we
-- follow the L3 draft. This is because allowing multiple origins is often
-- needed in the wild. See [Validating the origin of a credential](https://www.w3.org/tr/webauthn-3/#sctn-validating-origin)
-- more details.
-- In the simplest case, just a single origin is allowed and this is the 'M.RpId' with @https://@ prepended:
--
-- > verifyAuthenticationResponse (NE.singleton (M.Origin "https://example.org")) ...
--
-- In the more complex case, multiple origins are allowed:
--
-- > verifyAuthenticationResponse (M.Origin <$> "https://example.org" :| ["https://signin.example.org"]) ...
--
-- One might also allow native apps to authenticate:
--
-- > verifyAuthenticationResponse (M.Origin <$> "https://example.org" :| ["ios:bundle-id:org.example.ourapp"]) ...
--
-- See Apple's documentation on [associated domains](https://developer.apple.com/documentation/authenticationservices/public-private_key_authentication/supporting_passkeys/)
-- and Google's documentation on [Digital Asset Links](https://developers.google.com/identity/passkeys/developer-guides) for more information on how to link app
-- origins to your Relying Party ID.
verifyAuthenticationResponse ::
-- | The origin of the server
M.Origin ->
-- | The list of allowed origins for the ceremony
NonEmpty M.Origin ->
-- | The hash of the relying party id
M.RpIdHash ->
-- | The user handle, in case the user is identified already
Expand All @@ -179,7 +201,7 @@ verifyAuthenticationResponse ::
-- Or in case of success a signature counter result, which should be dealt
-- with
Validation (NonEmpty AuthenticationError) AuthenticationResult
verifyAuthenticationResponse origin rpIdHash midentifiedUser entry options credential = do
verifyAuthenticationResponse origins rpIdHash midentifiedUser entry options credential = do
-- 1. Let options be a new PublicKeyCredentialRequestOptions structure
-- configured to the Relying Party's needs for the ceremony.
-- NOTE: Implemented by caller
Expand Down Expand Up @@ -290,9 +312,11 @@ verifyAuthenticationResponse origin rpIdHash midentifiedUser entry options crede
AuthenticationChallengeMismatch (M.coaChallenge options) (M.ccdChallenge c)

-- 13. Verify that the value of C.origin matches the Relying Party's origin.
unless (M.ccdOrigin c == origin) $
-- NOTE: We follow the L3 draft of the spec here, which allows for multiple origins.
-- https://www.w3.org/TR/webauthn-3/#rp-op-verifying-assertion-step-origin
unless (M.ccdOrigin c `elem` NE.toList origins) $
failure $
AuthenticationOriginMismatch origin (M.ccdOrigin c)
AuthenticationOriginMismatch origins (M.ccdOrigin c)

-- 14. Verify that the value of C.tokenBinding.status matches the state of
-- Token Binding for the TLS connection over which the attestation was
Expand Down
36 changes: 30 additions & 6 deletions src/Crypto/WebAuthn/Operation/Registration.hs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ data RegistrationError
RegistrationOriginMismatch
{ -- | The origin explicitly passed to the `verifyRegistrationResponse`
-- response, set by the RP
reExpectedOrigin :: M.Origin,
reExpectedOrigin :: NonEmpty M.Origin,
-- | The origin received from the client as part of the client data
reReceivedOrigin :: M.Origin
}
Expand Down Expand Up @@ -264,13 +264,35 @@ data RegistrationResult = RegistrationResult
deriving instance ToJSON RegistrationResult

-- | [(spec)](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential)
-- Verifies a 'M.Credential' response for a [registration ceremony](https://www.w3.org/TR/webauthn-2/#registration-ceremony).
--
-- The resulting 'rrEntry' of this call should be stored in a database by the
-- Relying Party. The 'rrAttestationStatement' contains the result of the
-- attempted attestation, allowing the Relying Party to reject certain
-- authenticators/attempted entry creations based on policy.
--
-- Though this library implements the WebAuthn L2 spec, for origin validation we
-- follow the L3 draft. This is because allowing multiple origins is often
-- needed in the wild. See [Validating the origin of a credential](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin)
-- more details.
-- In the simplest case, just a single origin is allowed and this is the 'M.RpId' with @https://@ prepended:
--
-- > verifyRegistrationResponse (NE.singleton (M.Origin "https://example.org")) ...
--
-- In the more complex case, multiple origins are allowed:
--
-- > verifyRegistrationResponse (M.Origin <$> "https://example.org" :| ["https://signin.example.org"]) ...
--
-- One might also allow native apps to authenticate:
--
-- > verifyRegistrationResponse (M.Origin <$> "https://example.org" :| ["ios:bundle-id:org.example.ourapp"]) ...
--
-- See Apple's documentation on [associated domains](https://developer.apple.com/documentation/authenticationservices/public-private_key_authentication/supporting_passkeys/)
-- and Google's documentation on [Digital Asset Links](https://developers.google.com/identity/passkeys/developer-guides) for more information on how to link app
-- origins to your Relying Party ID.
verifyRegistrationResponse ::
-- | The origin of the server
M.Origin ->
-- | The list of allowed origins for the ceremony
NonEmpty M.Origin ->
-- | The relying party id
M.RpIdHash ->
-- | The metadata registry, used for verifying the validity of the
Expand All @@ -287,7 +309,7 @@ verifyRegistrationResponse ::
-- Or () in case of a result.
Validation (NonEmpty RegistrationError) RegistrationResult
verifyRegistrationResponse
rpOrigin
origins
rpIdHash
registry
currentTime
Expand Down Expand Up @@ -349,9 +371,11 @@ verifyRegistrationResponse
RegistrationChallengeMismatch corChallenge (M.ccdChallenge c)

-- 9. Verify that the value of C.origin matches the Relying Party's origin.
unless (rpOrigin == M.ccdOrigin c) $
-- NOTE: We follow the L3 draft of the spec here, which allows for multiple origins.
-- https://www.w3.org/TR/webauthn-3/#rp-op-registering-a-new-credential-step-origin
unless (M.ccdOrigin c `elem` NE.toList origins) $
failure $
RegistrationOriginMismatch rpOrigin (M.ccdOrigin c)
RegistrationOriginMismatch origins (M.ccdOrigin c)

-- 10. Verify that the value of C.tokenBinding.status matches the state of
-- Token Binding for the TLS connection over which the assertion was
Expand Down
55 changes: 45 additions & 10 deletions tests/Emulation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import Emulation.Client
import Emulation.Client.Arbitrary ()
import Spec.Util (predeterminedDateTime)
import Test.Hspec (SpecWith, describe, it, shouldSatisfy)
import Test.QuickCheck (property)
import Test.QuickCheck (property, (==>))

-- | Custom type to combine the MonadPseudoRandom with the Except monad. We
-- force the ChaChaDRG to ensure the App type is completely pure, and
Expand All @@ -61,12 +61,13 @@ runApp seed (App except) =
register ::
(Random.MonadRandom m, MonadFail m) =>
AnnotatedOrigin ->
NE.NonEmpty M.Origin ->
UserAgentConformance ->
Authenticator ->
Meta.MetadataServiceRegistry ->
DateTime ->
m (Either (NE.NonEmpty O.RegistrationError) O.RegistrationResult, Authenticator, M.CredentialOptions 'M.Registration)
register ao conformance authenticator registry now = do
register ao allowedOrigins conformance authenticator registry now = do
-- Generate new random input
assertionChallenge <- M.generateChallenge
userId <- M.generateUserHandle
Expand All @@ -84,7 +85,7 @@ register ao conformance authenticator registry now = do
let registerResult =
toEither $
O.verifyRegistrationResponse
(aoOrigin ao)
allowedOrigins
(M.RpIdHash . hash . encodeUtf8 . M.unRpId $ aoRpId ao)
registry
now
Expand All @@ -95,11 +96,12 @@ register ao conformance authenticator registry now = do
login ::
(Random.MonadRandom m, MonadFail m) =>
AnnotatedOrigin ->
NE.NonEmpty M.Origin ->
UserAgentConformance ->
Authenticator ->
O.CredentialEntry ->
m (Either (NE.NonEmpty O.AuthenticationError) O.SignatureCounterResult)
login ao conformance authenticator [email protected] {..} = do
login ao allowedOrigins conformance authenticator [email protected] {..} = do
attestationChallenge <- M.generateChallenge
let options = defaultCog attestationChallenge
-- Perform client assertion emulation with the same authenticator, this
Expand All @@ -109,7 +111,7 @@ login ao conformance authenticator [email protected] {..} = do
. second O.arSignatureCounterResult
. toEither
$ O.verifyAuthenticationResponse
(aoOrigin ao)
allowedOrigins
(M.RpIdHash . hash . encodeUtf8 . M.unRpId $ aoRpId ao)
(Just ceUserHandle)
ce
Expand All @@ -118,27 +120,60 @@ login ao conformance authenticator [email protected] {..} = do

spec :: SpecWith ()
spec =
describe "None" $
describe "None" $ do
it "rejects unknown origin during registration" $ do
property $ \seed authenticator allowedOrigins' origin' -> not (null allowedOrigins') && origin' `notElem` allowedOrigins' ==> do
let origin = M.Origin origin'
let allowedOrigins = M.Origin <$> NE.fromList allowedOrigins'
let annotatedOrigin = AnnotatedOrigin { aoRpId = M.RpId "localhost", aoOrigin = origin }
let registry = mempty
let userAgentConformance = mempty
let Right (registerResult, _, _) = runApp seed (register annotatedOrigin allowedOrigins userAgentConformance authenticator registry predeterminedDateTime)
registerResult `shouldSatisfy` \case
Left errors -> any (\case O.RegistrationOriginMismatch _ _ -> True; _ -> False) errors
Right _ -> False
it "rejects unknown origin during login" $ do
property $ \seed authenticator allowedOrigins' origin' -> not (null allowedOrigins') && origin' `notElem` allowedOrigins' ==> do
let allowedOrigins = M.Origin <$> NE.fromList allowedOrigins'
let origin = NE.head allowedOrigins
let wrongOrigin = M.Origin origin'
let annotatedOrigin = AnnotatedOrigin { aoRpId = M.RpId "localhost", aoOrigin = origin }
let wrongAnnotatedOrigin = AnnotatedOrigin { aoRpId = M.RpId "localhost", aoOrigin = wrongOrigin }
let registry = mempty
let userAgentConformance = mempty
let Right (registerResult, authenticator', options) = runApp seed (register annotatedOrigin allowedOrigins userAgentConformance authenticator registry predeterminedDateTime)
let registerResult' = second O.rrEntry registerResult
registerResult' `shouldSatisfy` validAttestationResult authenticator userAgentConformance options
case registerResult' of
Right credentialEntry -> do
let Right loginResult = runApp (seed + 1) (login wrongAnnotatedOrigin allowedOrigins userAgentConformance authenticator' credentialEntry)
loginResult `shouldSatisfy` \case
Left errors -> any (\case O.AuthenticationOriginMismatch _ _ -> True; _ -> False) errors
Right _ -> False
_ -> pure ()

it "succeeds" $
property $ \seed authenticator userAgentConformance -> do
property $ \seed authenticator userAgentConformance allowedOrigins' -> length allowedOrigins' > 1 ==> do
let allowedOrigins = M.Origin <$> NE.fromList allowedOrigins'
let annotatedOrigin =
AnnotatedOrigin
{ aoRpId = M.RpId "localhost",
aoOrigin = M.Origin "https://localhost:8080"
aoOrigin = NE.head allowedOrigins
}

-- Since our emulator only supports None attestation the registry can be left empty.
let registry = mempty
-- We are not currently interested in client or authenticator fails, we
-- only wish to test our relying party implementation and are thus only
-- interested in its errors.
let Right (registerResult, authenticator', options) = runApp seed (register annotatedOrigin userAgentConformance authenticator registry predeterminedDateTime)
let Right (registerResult, authenticator', options) = runApp seed (register annotatedOrigin allowedOrigins userAgentConformance authenticator registry predeterminedDateTime)
-- Since we only do None attestation, we only care about the resulting entry
let registerResult' = second O.rrEntry registerResult
registerResult' `shouldSatisfy` validAttestationResult authenticator userAgentConformance options
-- Only if attestation succeeded can we continue with assertion
case registerResult' of
Right credentialEntry -> do
let Right loginResult = runApp (seed + 1) (login annotatedOrigin userAgentConformance authenticator' credentialEntry)
let Right loginResult = runApp (seed + 1) (login annotatedOrigin allowedOrigins userAgentConformance authenticator' credentialEntry)
loginResult `shouldSatisfy` validAssertionResult authenticator userAgentConformance
_ -> pure ()

Expand Down
10 changes: 5 additions & 5 deletions tests/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ registerTestFromFile fp origin rpId verifiable service now = do
let registerResult =
toEither $
O.verifyRegistrationResponse
origin
(NE.singleton origin)
(M.RpIdHash . hash . encodeUtf8 . M.unRpId $ rpId)
service
now
Expand Down Expand Up @@ -125,7 +125,7 @@ main = Hspec.hspec $ do
registerResult =
toEither $
O.verifyRegistrationResponse
(M.Origin "http://localhost:8080")
(NE.singleton $ M.Origin "http://localhost:8080")
(M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
registry
predeterminedDateTime
Expand All @@ -142,7 +142,7 @@ main = Hspec.hspec $ do
signInResult =
toEither $
O.verifyAuthenticationResponse
(M.Origin "http://localhost:8080")
(NE.singleton $ M.Origin "http://localhost:8080")
(M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
(Just (M.UserHandle "UserId"))
credentialEntry
Expand All @@ -163,7 +163,7 @@ main = Hspec.hspec $ do
registerResult =
toEither $
O.verifyRegistrationResponse
(M.Origin "http://localhost:8080")
(NE.singleton $ M.Origin "http://localhost:8080")
(M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
registry
predeterminedDateTime
Expand All @@ -180,7 +180,7 @@ main = Hspec.hspec $ do
signInResult =
toEither $
O.verifyAuthenticationResponse
(M.Origin "http://localhost:8080")
(NE.singleton $ M.Origin "http://localhost:8080")
(M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
(Just (M.UserHandle "UserId"))
credentialEntry
Expand Down
Loading