Parse-server: Improve AuthAdapter capabilities

Created on 8 Dec 2020  路  21Comments  路  Source: parse-community/parse-server

Is your feature request related to a problem? Please describe.
No

Describe the solution you'd like
We need to add some triggers , and improve AuthAdapter interface to support some special auth systems like MFA, WebAuthn

Describe alternatives you've considered
No alternative currently, contributors need to inject code directly in Routers/Controllers may be we can avoid this.

Additional context
The new interface should support with AuthAdapter pure implementation:

  • [x] MFA
  • [x] WebAuthn

MFA particularities:

  • Store recovery keys somewhere (authData?)
  • perform a check after a login (could be a classic login or other loginWith adapter)
  • Need a public endpoint to receive/display recovery keys

@dblythy tell me if you see other things for MFA

WebAuthn:

  • Need 2 public endpoints (assertion/attestation)
  • Store multi public keys (can be done through authData)
feature

All 21 comments

I like this holistic approach, makes a lot of sense to me to standardize that interface.

I'd imagine the MFA adapter would need:

-SDK method to "enable" the authentication maybe Parse.User.enableAuth('mfa')
-SDK method to "verify" that the authentication is setup Parse.User.verifyAuth('mfa',data)
-SDK method to pass auth data to login (which can be accessed via the auth adapter Parse.User.logIn(username,password,{auth: authData})
-Secure storage of the secret used to verify MFA login (maybe an option in the adapter that strips out keys such as authData.mfa.secret)
-Secure storage and encryption of the recoveryKeys used to unlock the MFA

I think it is a good idea to make this approach available to other auth adapters, as I think it is likely that similar methods will be required for features such as passwordless auth.

Okay i think based on what i know currently of different auth protocal (OTP,MFA, WebAuthn)

We can add the challenge concept

Challenge can initiate the auth workflow (send sms, retreive challenge etc...)

then the current validateAuthData can take then the auth data can take over and resolve login/signup

@dblythy tell me if you think this way:

Note: OOS = Out of Scope

-SDK method to "enable" the authentication maybe Parse.User.enableAuth('mfa'): OOS it's a SDK issue
-SDK method to "verify" that the authentication is setup Parse.User.verifyAuth('mfa',data): OOS too
-SDK method to pass auth data to login (which can be accessed via the auth adapter Parse.User.logIn(username,password,{auth: authData}): OOS
-Secure storage of the secret used to verify MFA login (maybe an option in the adapter that strips out keys such as authData.mfa.secret): we can introduce an exclude system if authData is get from database to remove some sensitive informations from the response
-Secure storage and encryption of the recoveryKeys used to unlock the MFA, a secretFields key into AuthAdatper could do the job on server response to exclude this fields, may be a database projection is a better idea of implementation.

then the front process could be depending of the adapter used

// In MFA case, it will act as an activation is current user do not have MFA enabled
Pase.User.challenge("mfa")
// Then
Parse.User.loginWith('default,mfa', { username: "xxxx", password: "xxxxx", otp: "xxxx"})
// The user is logged in via default auth system and mfa 
// user will not be logged in if one of auth services fail

I think a string 'default,mfa' could be a nice feature, since it allow a front end developer to control the execution order to handle more easly erros, wrong credentials

the graphql mutation could look like

mutation challenge {
   challenge(input: {type: 'mfa'}){
      challenge
   }
}

GraphQL challenge type could be a opaque Scalar like ChallengeResponse

I don't think the MFA will be entirely OOS, as I think the token and the secret can be generated on the frontend, such as:

    const user = await Parse.User.signUp('username', 'password');
    const secret = otplib.authenticator.generateSecret();
    const token = otplib.authenticator.generate(secret); // this token would be generated from TPA app
    const recoveryKeys = await user._linkWith('mfa', { authData : {
      token,secret
}
    }); // secret stored attached to user.authdata and used to verify all login requests

Or, even do all the OTP work in the SDK when linkWith MFA is called, e.g:

    const user = await Parse.User.signUp('username', 'password');
    const recoveryKeys = await user._linkWith('mfa'); // all the otp work and UI happens in the SDK

The MFA adapter could validateAuthData using token and secret and return recovery codes, which the developer could then show in their own UI.

Next, perhaps we could introduce a method for validateLoginAuthData or something for Parse.User.logIn(username,password,{auth: mfa : {token}})

What are your thoughts on this approach? Would this work for WebAuthn?

based on

const myAuthData = {
  id: '12345678'  // Required field. Used to uniquely identify the linked account.
};
const user = new Parse.User();
await user.linkWith('providerName', { authData: myAuthData });

and http://parseplatform.org/Parse-SDK-JS/api/2.18.0/Parse.User.html#linkWith

Currently linkWith only return a Promise<void>, so we can return some data at this moment, with a special field on response rest operation like authDataResponse (here the current rest response https://docs.parseplatform.org/rest/guide/#signing-up-and-logging-in)

authDataResponse could be the validateAuthData return value.

The rest signup interface could be with username auth

curl -X POST \
  -H "X-Parse-Application-Id: ${APPLICATION_ID}" \
  -H "X-Parse-REST-API-Key: ${REST_API_KEY}" \
  -H "X-Parse-Revocable-Session: 1" \
  -H "Content-Type: application/json" \
  -d '{
        username: "test",
       password: "test",
        "authData": {
          "mfa": {
            // ID on authData should be optional now
            "secret": "xxxx";
            "token": "xxxx",
          }
        }
      }' \
  https://YOUR.PARSE-SERVER.HERE/parse/users

Signup with facebook and mfa could be

curl -X POST \
  -H "X-Parse-Application-Id: ${APPLICATION_ID}" \
  -H "X-Parse-REST-API-Key: ${REST_API_KEY}" \
  -H "X-Parse-Revocable-Session: 1" \
  -H "Content-Type: application/json" \
  -d '{
        "authData": {
          // On server authdata check, run in priority AuthData with ID, then add the user object to the context for next adapters
          "facebook": {
           id: "xxxxx"
           },
         // Then the mfa could be triggered after the user is created 
          "mfa": {
            // ID on authData should be optional now
            "secret": "xxxx";
            "token": "xxxx",
          }
        }
      }' \
  https://YOUR.PARSE-SERVER.HERE/parse/users

Then login with facebook looks exactly the same but the secret key will be ignored

curl -X POST \
  -H "X-Parse-Application-Id: ${APPLICATION_ID}" \
  -H "X-Parse-REST-API-Key: ${REST_API_KEY}" \
  -H "X-Parse-Revocable-Session: 1" \
  -H "Content-Type: application/json" \
  -d '{
        "authData": {
          // On server authdata check, run in priority AuthData with ID, then add the user object to the context for next adapters
          "facebook": {
           id: "xxxxx"
           },
         // Then the mfa could be triggered after the user is created 
          "mfa": {
            // ID on authData should be optional now
            "secret": "xxxx";
            "token": "xxxx",
          }
        }
      }' \
  https://YOUR.PARSE-SERVER.HERE/parse/users

The classic login could be

curl -X POST \
  -H "X-Parse-Application-Id: ${APPLICATION_ID}" \
  -H "X-Parse-REST-API-Key: ${REST_API_KEY}" \
  -H "X-Parse-Revocable-Session: 1" \
  -H "Content-Type: application/json" \
  -d '{
         // Parse server will try to validate first the username/password and then add the user object to the context
        // for the next mfa adapter
         username: "test",
         password: "test",
        "authData": {
         // Then the mfa could be triggered after the user is created 
          "mfa": {
            // ID on authData should be optional now
            "secret": "xxxx";
            "token": "xxxx",
          }
        }
      }' \
  https://YOUR.PARSE-SERVER.HERE/parse/users

@dblythy in MFA use does recovery keys come from the front or generated on the backend ?

the challenge endpoint could be (web auth example)

curl -X POST \
  -H "X-Parse-Application-Id: ${APPLICATION_ID}" \
  -H "X-Parse-REST-API-Key: ${REST_API_KEY}" \
  -H "X-Parse-Revocable-Session: 1" \
  -H "Content-Type: application/json" \
  -d '{
        "authData": {
          "webauthn": true  (could be also an object, the adapter need to validate it's own params structure)
        }
      }' \
  https://YOUR.PARSE-SERVER.HERE/parse/challenge

Example of challenge with pre logged user (SMS OTP example)

curl -X POST \
  -H "X-Parse-Application-Id: ${APPLICATION_ID}" \
  -H "X-Parse-REST-API-Key: ${REST_API_KEY}" \
  -H "X-Parse-Revocable-Session: 1" \
  -H "Content-Type: application/json" \
  -d '{
        "username": "test",
        "password": "test";
        "authData": {
          "otp": true  (could be also an object, the adapter need to validate it's own params structure)
        }
      }' \
  https://YOUR.PARSE-SERVER.HERE/parse/challenge

the GraphQL API could easly follow this pattern only need a challenge mutation.
The JS SDK implementation is also easy

// MFA
// One call for signup and login
const user = Parse.User.loginWith({
  username: "test"
  password: "test"
  authData: {
   mfa : { token, secret } // secret will be skipped if user already have a secret
 }
})

const { mfa: { recoveryKeys } } = user.get('authDataResponse')
// OTP
// User already created and have a phone field set
await Parse.User.challenge({
  username: "test"
  password: "test"
  authData: {
  otp: true
 }
})

const user = await Parse.User.loginWith({
  username: "test"
  password: "test"
  authData: {
  otp: token
 }
})
// WebAuthn for signup
await Parse.User.challenge({
  authData: {
  webauthn: true
 }
})

const user = await Parse.User.loginWith({
  authData: {
  webauthn: {
     id: credentialId
     data: webauthnData
  }
 }
})

Super secure auth could be

// WebAuthn for signup
await Parse.User.challenge({
 // need username and password since OTP need a pre logged user and id is not provided on webauthn
  username: "test",
  password: "test";
  authData: {
    webauthn: true // could be an object if adapter need some data
    otp: true // could be an object if adapter need some data
 }
})
// Parse server will check first the username/password, then webauthn, then otp and finally mfa
// if all checks are green use is logged in
const user = await Parse.User.loginWith({
  username: "test",
  password: "test" 
  authData: {
    webauthn: {
      id: credentialId
      data: webauthnData
    },
    otp: token
    mfa: { token }
 }
})
// Challenge with facebook pre login and otp
await Parse.User.challenge({
  authData: {
    facebook: {
      id: fbUserId
    }
    otp: true
 }
})

// Will pre log user with facebook and finish auth with otp validation
const user = await Parse.User.loginWith({
  authData: {
    facebook: {
      id: fbId
      ... otherFbInfos 
    },
    otp: token
 }
})


@dblythy what do you think about this examples ?

does recovery keys come from the front or generated on the backend ?

Currently backend, but It probably doesn't matter as long as they are long, secure strings that are stored as passwords.

I like the examples, I think i'll be able to get it going for MFA. I'm currently working on a quick proof of concept that i'll share with you in a minute to get your thoughts!

Since for companies where i work i use extensively AuthAdapter, i know exactly what we have to do to support the feature and at first glance the thing could be rather short to implement

Currently backend, but It probably doesn't matter as long as they are long, secure strings that are stored as passwords.

i think the recovery keys should be generated on the front end and only retrieved on set up time. The backend should only keep the validation secret ?

here the MFA example updated

// MFA
// One call for signup and login
const user = Parse.User.loginWith({
  username: "test"
  password: "test"
  authData: {
   mfa : { token, secret } // secret will be skipped if user already have a secret
 }
})

const { mfa: { recoveryKeys } } = user.get('authDataResponse')

we should use loginChallenge rest endpoint since challenge lacks of clarity and can conflicts with an already existing Challenge class

@dblythy if you want to check, i started a draft PR

Great stuff @Moumouls. I wrote up a proof of concept of MFA (untested) working as an adapter instead of coded inbuilt. Feel free to use any of my existing code 馃槉. I created a new method 'loginWithAuthData', which can be used on any adapter to validate login requests. The adapter also handles all the encryption.

currently @dblythy i think that MFA do not need an additional endpoint here, the tricks for better handling

function validateAuthData(authData, { encryptionKey }, user) {
  // If user not defined it means that it's a signup
// so you can introduce loginWithAuthData here when user is not defined 
  if (!user || user.get('authData')) {
    throw new Parse.Error(
      Parse.Error.OBJECT_NOT_FOUND,
      'MFA auth is already enabled for this user.'
    );
  }

we will try to plug your Adapter with my draft PR once i finished some required stuff

Then a small JS SDK PR will be needed to support the new system we need to also update REST/GraphQL/Js SDK docs

This work looks great! I am looking to implement this soon, has there been any progress on it since the 8 Dec 20?

Hi @Taylorsuk ,

Yes the feature is finished, i currently use it with my forked package (waiting open source PRs to be merged). If you want to give it a try, you can install this package.

"parse-server": "moumouls/parse-server#beta.8" in your package.json

Here the webauthn example that use the new spec.

const verifyLogin = ({ assertion, signedChallenge }, options = {}, config, user) => {
  const dbAuthData = user && user.get('authData') && user.get('authData').webauthn;
  if (!dbAuthData)
    throw new Parse.Error(
      Parse.Error.OTHER_CAUSE,
      'webauthn not configured for this user or credential id not recognized.'
    );
  if (!assertion) throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'assertion is required.');
  const expectedChallenge = extractSignedChallenge(signedChallenge, config);
  try {
    const { verified, authenticatorInfo } = verifyAssertionResponse({
      credential: assertion,
      expectedChallenge,
      expectedOrigin: options.origin || getOrigin(config),
      expectedRPID: options.rpId || getOrigin(config),
      authenticator: {
        credentialID: dbAuthData.id,
        counter: dbAuthData.counter,
        publicKey: dbAuthData.publicKey,
      },
    });
    if (verified) {
      return {
        ...dbAuthData,
        counter: authenticatorInfo.counter,
      };
    }
    throw new Error();
  } catch (e) {
    throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Invalid webauthn assertion');
  }
};

export const challenge = async (challengeData, authData, adapterConfig = {}, req) => {
  // Allow logged user to update/setUp webauthn
  if (req.auth.user && req.auth.user.id) {
    return registerOptions(req.auth.user, adapterConfig.options, req.config);
  }

  return loginOptions(req.config);
};

export const validateSetUp = async (authData, adapterConfig = {}, req) => {
  if (!req.auth.user)
    throw new Parse.Error(
      Parse.Error.OTHER_CAUSE,
      'Webauthn can only be configured on an already logged in user.'
    );
  return { save: await verifyRegister(authData, adapterConfig.options, req.config) };
};

export const validateUpdate = validateSetUp;

export const validateLogin = async (authData, adapterConfig = {}, req, user) => {
  if (!user) throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found for webauthn login.');
  // Will save updated counter of the credential
  // and avoid cloned/bugged authenticators
  return { save: verifyLogin(authData, adapterConfig.options, req.config, user) };
};

export const policy = 'solo';

Then the MFA not already implemented will be a good example of and additional Auth Adapter (only requested once the user will have configured the MFA)

@Moumouls Amazing work! Thank you for the example code too. We'll start a feature branch with your fork and then by the time it's tested, hopefully things will be released in the core package. 馃コ

@Taylorsuk yes i also hope that PRs will be merged soon.
Also a little note here the challenge endpoint is not ready on the Parse JS SDK: https://github.com/parse-community/Parse-SDK-JS/pull/1276

Was this page helpful?
0 / 5 - 0 ratings