supertokens / supertokens-node

Node SDK for SuperTokens core
https://supertokens.com
Other
288 stars 78 forks source link

Third party module (backend) #79

Closed rishabhpoddar closed 3 years ago

rishabhpoddar commented 3 years ago

Recipe Module

let ThirdParty = require("supertokens-node/recipe/thirdparty");

Supertokens.init({
   appInfo: {},
   recipeList: [{
       ThirdParty.init({
           signInAndUpFeature: {
               disableDefaultImplementation?: boolean,
               handlePostSignUpIn?: (user: User, thirdPartyAuthCodeResponse: any) => Promise<void>,
               providers: [
               ThirdParty.Google({
                        clientSecret: string,
                        clientId: string,
                        scope?: string[], // will be merged with what we have
                        authCodePost?: {
                            authCodeAPIbody?: any, // Will be merged with our object      
                        },
                        authURL?: {
                            queryParams?: any
                        }
                   }),
                   {
                        id: "custom",
                        scope: string[],
                        authCodePost: {
                            url: string,
                            authCodeAPIbody: any, // Will be merged with our object      
                        }
                        authURL: {
                            url: string,
                            queryParams: any
                        },
                        getProfileInfo: (authCodeResponse: any) => Promise<{id: string, email?: {id: string, isVerified: boolean}}>
                   }
               ]
           },
           emailVerificationFeature: { // this is same as in emialpassword, except for the structure of User type
               disableDefaultImplementation?: boolean;
               getEmailVerificationURL?: (user: User) => Promise<string>;
               createAndSendCustomEmail?: (user: User, emailVerificationURLWithToken: string) => Promise<void>;
               handlePostEmailVerification?: (user: User) => Promise<void>;
           }
           signOutFeature: {
               disableDefaultImplementation?: boolean
           }
        }
    ]
});

Config normalisation:

Types:

TODO:

rishabhpoddar commented 3 years ago

We should consider using: https://github.com/ciaranj/node-oauth Also https://github.com/nextauthjs/next-auth/tree/canary/src/providers

kant01ne commented 3 years ago

@bhumilsarvaiya, there was a difference between providers we agreed on for the:

Please also include support for Twitter. I'll add support for Apple in the frontend.

kant01ne commented 3 years ago

We need to create tests applications for the thirdparty.demo.supertokens.io application. We will also need test applications for tests.

For all the below authorisation URLs:

Github:

Twitter: Twitter seems like an hybrid between OAuth 1.0 and 2.0, not sure if we can implement this in a generic way. Maybe we should postpone handling twitter for now. https://developer.twitter.com/en/docs/authentication/oauth-2-0/application-only

rishabhpoddar commented 3 years ago

@NkxxkN

For tests applications, how do we store client secret for tests applications? Is that ok to store them publicly since it's only for testing locally or on CI, and will only be used by "supertokens-node/supertokens-auth-react" maintainers.

Yes.

they should all be created from the same email address and devs should have a way to access them. A company address ideally, and a shared password.

Yea. Makes sense. WIll create them when we need access.

Twitter seems like an hybrid between OAuth 1.0 and 2.0, not sure if we can implement this in a generic way. Maybe we should postpone handling twitter for now

Agreed. NextJS auth handles OAuth 1.0 as well, which is what Twitter uses.. but we can do it later.

bhumilsarvaiya commented 3 years ago

@NkxxkN For Google, authorization url should be: (as per this link)

https://accounts.google.com/o/oauth2/v2/auth?
 scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&
 access_type=offline&
 include_granted_scopes=true&
 response_type=code&
 state={STATE}&
 redirect_uri={REDIRECT_URI}&
 client_id={CLIENT_ID}

For Facebook, it will be v9.0 instead of v7.0

For rest of the providers, it is correct.

rishabhpoddar commented 3 years ago

@bhumilsarvaiya state should not be there from the response from the API.

rishabhpoddar commented 3 years ago

@NkxxkN the redirect_uri should be set by the backend. Only state is set on the frontend. But I leave it to you and @bhumilsarvaiya. Please be on the same page about what is set on the backend and frontend.

bhumilsarvaiya commented 3 years ago

IGNORE THIS COMMENT

suggesting config for Google, Facebook, Github:

{
  clientSecret: string,
  clientId: string,
  scope?: string[], // will be merged with what we have
  accessTokenAPI?: {
      requestBody?: any, // Will be merged with our object
      requestHeaders?: any, // Will be merged with our object
      requestQuery?: any // Will be merged with our object
  },
  authURL?: {
      requestQuery?: any, // Will be merged with our object
      requestHeaders?: any, // Will be merged with our object
  }
}

For Apple:

{
  clientSecret: {
    teamId: string,
    privateKey: string,
    keyId: string
  },
  clientId: string,
  scope?: string[], // will be merged with what we have
  accessTokenAPI?: {
      additionalBody?: any, // Will be merged with our object
      additionalHeaders?: any, // Will be merged with our object
      additionalQuery?: any // Will be merged with our object
  },
  authURL?: {
      additionalQuery?: any, // Will be merged with our object
      additionalHeaders?: any, // Will be merged with our object
  }
}

Provider Type Suggestion:

{
  id: string,
  getAccessTokenFromAuthCode: (recipeInstance: Recipe, authCode: string): Promise<any>,
  generateAuthorisationURL: (recipeInstance: Recipe) => Promise<string>,
  getProfileInfo: (recipeInstance: Recipe, authCodeResponse: any) => Promise<{id: string, email?: {id: string, isVerified: boolean}}>
}
kant01ne commented 3 years ago

True! We can do redirect_uri on the backend.

Another thing to notice is that most / some providers will refuse redirections to http and enforce https. Hence, making it hard to test locally. We'll have to make sure our examples are running with SSL locally. (HTTPS=true npm run start seems to be working for the test app, I haven't tested others yet).

I updated the above comment with redirection URI for the test and dev apps.

Alternative way is to set up a proxy locally. Redirect URLs are actually one of the main reason for the success of the payed solution provided by https://ngrok.io. I hope we won't have to do it though.

rishabhpoddar commented 3 years ago

HTTPS will only be needed for the website domain right? The API domain can still be http?

kant01ne commented 3 years ago

Yeah only front end

kant01ne commented 3 years ago

I spent the last 2h trying to handle Invalid SSL certs locally while running tests without success.

It seems that Google/Facebook/Github/Slack/Spotify Apple doesn't seem to accept it. No information found for Linkedin.

I suggest that we add both http://localhost:3031/auth/callback/{provider} and https://localhost:3031/auth/callback/{provider} when possible. When developing locally, we can decide to use SSL or not. When running end to end tests, we will only test providers that accepts redirecting to non SSL localhost.

We will also need to setup a test account connected for each social providers (with email/password in .env) ? That means we will only have tests for non-paying providers.

kant01ne commented 3 years ago

@bhumilsarvaiya why additionalBody and requestBody? What is the difference? Similar question for query and headers.

rishabhpoddar commented 3 years ago

@NkxxkN ignore bhumil's eariler comment. It's not correct.

bhumilsarvaiya commented 3 years ago

IGNORE THIS COMMENT

interface GoogleConfig {
    clientId: string,
    clientSecret: string,
    scope?: string[] // default: ["https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"]
}

interface FacebookConfig {
    clientId: string,
    clientSecret: string,
    scope?: string[] // default: ["email"]
}

interface GithubConfig {
    clientId: string,
    clientSecret: string,
    scope?: string[] // default: user
}

interface AppleClientSceret {
    keyId: string;
    privateKey: string,
    teamId: string
}

interface AppleConfig {
    clientId: string,
    clientSecret: AppleConfig,
    scope?: string[] // default: ["name", "email"]
}

interface Provider {
    id: string;
    version: "2.0"; // can be used if we later wants to support "1.0A" which is used by twitter
    scope: string; // for non-custom providers, scope array will be unified to a single string based on how scopes are supposed to be passed for that particular provider
    accessTokenAPI: {
        url: string, // used to exchange auth code for access token
        params: object // request body params
    };
    authorizationAPI: {
        url: string; // base url for authroization url
        params: object // request query params
    };
    clientId: string;
    clientSecret: string | AppleClientSceret
    getProfileInfo: (accessTokenAPIResponse: any) => Promise<any>
}

Getting authroization URL

Getting accessToken by exchanging auth code

rishabhpoddar commented 3 years ago

IGNORE THIS COMMENT (this was just for explanation to @bhumilsarvaiya )

General structure:

(authCodeFromRequest: string | undefined, redirectURI: string ) => {
    id: string;
    accessTokenAPI: {
        url: string, // used to exchange auth code for access token
        params: object // request body params
    };
    authorizationAPI: {
        url: string; // base url for authroization url
        params: object // request query params
    };
    getProfileInfo: (accessTokenAPIResponse: any) => Promise<any>
}

Google

Google({
   clientId: string,
   clientSecret: string,
   authorizationAPI?: {
       params: object
   }
}) => (
    authCodeFromRequest: string | undefined,
    redirectURI: string,
    getClientSecret(...) => object | undefined
) => {
return {
    id: "google",
    accessTokenAPI: {
        url: "https://accounts.google.com/o/oauth2/token"
        params: {
            code: authCodeFromRequest,
            client_id: clientId,
            client_secret: clientSecret,
            redirect_uri: redirectURI,
            grant_type: "authorization_code"
        }
    };
    authorizationAPI: {
        url: "https://accounts.google.com/o/oauth2/v2/auth",
        params: {
            scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
            access_type: "offline",
            include_granted_scopes: true,
            response_type: "code",
            redirect_uri: redirectURI,
            client_id: clientId,
            ...authorizationAPI.params
        }
    };
    getProfileInfo: async (accessTokenAPIResponse: any) => {
          // TODO:
     }
}
}
kant01ne commented 3 years ago

@rishabhpoddar

I created accounts and OAuth apps for Google/Facebook/Github. I can share details in private. @bhumilsarvaiya you might need that too.

For Apple, it does not seem possible to create an application for free for solo developers. You need to register to their developer program and provide company DUNS number and name (and pay 99$ from what I understand). Apple is always over complicated...

Should we skip Apple for now too and add bunch of other easier ones?

rishabhpoddar commented 3 years ago

Should we skip Apple for now too and add bunch of other easier ones?

Yes please.. if there is a bug in apple's implementation, eventually the community will tell us :)

bhumilsarvaiya commented 3 years ago

@NkxxkN I already have OAuth apps for Google and Facebook. I'll let you know when I need for Github. Thanks! 😀

rishabhpoddar commented 3 years ago

@bhumilsarvaiya please coordinate with @NkxxkN to use the same accounts for testing for ease of maintenance.

bhumilsarvaiya commented 3 years ago

Google

Google(config: {
    clientId: string,
    clientSecret: string,
    scope?: string[],
    authorizationRedirect?: {
        params?: object
    }
}) => { // this function will also do other stuff like modify the scope etc..
    id: "google",
    get: (
        redirectURI: string,
        authCodeFromRequest: string | undefined
    ) => {
        return {
            accessTokenAPI: {
                url: "https://accounts.google.com/o/oauth2/token"
                params: {
                    code: authCodeFromRequest,
                    client_id: clientId,
                    client_secret: clientSecret,
                    redirect_uri: redirectURI,
                    grant_type: "authorization_code"
                }
            };
            authorizationRedirect: {
                url: "https://accounts.google.com/o/oauth2/v2/auth",
                params: {
                    scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
                    access_type: "offline",
                    include_granted_scopes: true,
                    response_type: "code",
                    redirect_uri: redirectURI,
                    client_id: clientId,
                    ...authorizationAPI.params
                }
            };
            getProfileInfo: async (accessTokenAPIResponse: any) => {
                // TODO: https://www.googleapis.com/oauth2/v1/userinfo?alt=json
            }
        };
    }
}

Facebook

Facebook(config: {
    clientId: string,
    clientSecret: string,
    scope?: string[]
}) => {
    id: "facebook",
    get: (
        redirectURI: string,
        authCodeFromRequest: string | undefined
    ) => {
        return {
            accessTokenAPI: {
                url: "https://graph.facebook.com/v9.0/oauth/access_token"
                params: {
                    code: authCodeFromRequest,
                    client_id: clientId,
                    client_secret: clientSecret,
                    redirect_uri: redirectURI
                }
            };
            authorizationRedirect: {
                url: "https://www.facebook.com/v9.0/dialog/oauth",
                params: {
                    scope: "email",
                    response_type: "code",
                    redirect_uri: redirectURI,
                    client_id: clientId,
                    ...authorizationAPI.params
                }
            };
            getProfileInfo: async (accessTokenAPIResponse: any) => {
                // TODO: https://graph.facebook.com/me?access_token=
            }
        };
    }
}

Github

Github(config: {
    clientId: string,
    clientSecret: string,
    scope?: string[],
    authorizationAPI?: {
        params?: object
    }
}) => {
    id: "github",
    get: (
        redirectURI: string,
        authCodeFromRequest: string | undefined
    ) => {
        return {
            accessTokenAPI: {
                url: "https://github.com/login/oauth/access_token"
                params: {
                    code: authCodeFromRequest,
                    client_id: clientId,
                    client_secret: clientSecret,
                    redirect_uri: redirectURI
                }
            };
            authorizationRedirect: {
                url: "https://github.com/login/oauth/authorize",
                params: {
                    scope: "user",
                    redirect_uri: redirectURI,
                    client_id: clientId,
                    ...authorizationAPI.params
                }
            };
            getProfileInfo: async (accessTokenAPIResponse: any) => {
                // TODO: https://api.github.com/user | application/vnd.github.v3+json
            }
        };
    }
}

Apple

Apple(config: {
    clientId: string,
    clientSecret: {
        keyId: string;
        privateKey: string,
        teamId: string
    },
    scope?: string[],
    authorizationRedirect?: {
        params?: object
    }
}) => {
    id: "apple",
    get: (
        redirectURI: string,
        authCodeFromRequest: string | undefined
    ) => {
        // we generate the client secret everytime the function is called, and if authCodeFromRequest !== undefined
        return {
            accessTokenAPI: {
                url: "https://appleid.apple.com/auth/token"
                params: {
                    code: authCodeFromRequest,
                    client_id: clientId,
                    client_secret: generatedClientSecret, // client secret will be generated
                    redirect_uri: redirectURI,
                    grant_type: "authorization_code"
                }
            };
            authorizationRedirect: {
                url: "https://appleid.apple.com/auth/authorize",
                params: {
                    scope: "name email",
                    redirect_uri: redirectURI,
                    client_id: clientId,
                    response_type: "code",
                    response_mode: "form_post",
                    ...authorizationAPI.params
                }
            };
            getProfileInfo: async (accessTokenAPIResponse: any) => {
                // TODO:
            }
        };
    }
}
bhumilsarvaiya commented 3 years ago

@bhumilsarvaiya please coordinate with @NkxxkN to use the same accounts for testing for ease of maintenance.

Okay

rishabhpoddar commented 3 years ago

@bhumilsarvaiya feedback on the config:

bhumilsarvaiya commented 3 years ago

Scopes

Google

https://www.googleapis.com/auth/userinfo.email

Facebook

email

Github

user

Apple

name
rishabhpoddar commented 3 years ago

@bhumilsarvaiya

bhumilsarvaiya commented 3 years ago

What about email verification details for each of them?

  • google sends the info in the userinfo API response
  • facebook only returns email in the response body if the email is verified
  • for github, an endpoint needs to be called
  • For apple, the information whether or not the account has been verified can be obtained by decoding the id token got in the response when exchanging the auth code for user tokens. (link)

For facebook, what is the user experience for "Still, this field will not be returned if no valid email address is available. " ? Do they see an error from facebook's UI our UI should the error?

No error. Just for the https://graph.facebook.com/me API, email field won't be present in the response.

For google, we do not need userinfo.profile, if the userId is there in userinfo.email

Yes, we can do that. If the scope is only set to userinfo.email, the userinfo API will return four fields in the response: picture, verified_email (boolean), id, email.

What about userId value for each of the providers?

  • for google, facebook and github, id is returned in their respective userinfo equivalent APIs.
  • for apple, user's email is the apple id.
rishabhpoddar commented 3 years ago

for apple, user's email is the apple id.

Doesn't apple provide some proxy email instead of the actual email? Are these lesser than 128 characters long?

bhumilsarvaiya commented 3 years ago

Doesn't apple provide some proxy email instead of the actual email? Are these lesser than 128 characters long?

If user has opted for that, then yes. But if so, is_private_email boolean obtained from decoded the idToken will be true.

rishabhpoddar commented 3 years ago

@bhumilsarvaiya Are the fake email that apple generates lesser than 128 characters long?

bhumilsarvaiya commented 3 years ago

Private relay email addresses have the following characteristics:

There is no mention regarding how long the email address length will be. The format is <unique-alphanumeric-string>@privaterelay.appleid.com. For example, if j.appleseed@icloud.com is the Apple ID, an unique, random email address for a given app might look like dpdcnf87nu@privaterelay.appleid.com.

bhumilsarvaiya commented 3 years ago

Google

getProfileInfo: async (accessTokenAPIResponse: {
    access_token: string,
    expires_in: number,
    token_type: string,
    scope: string,
    refresh_token: string
}) => {
    let accessToken = accessTokenAPIResponse.access_token;
    let authHeader = `Bearer ${accessToken}`;
    let response = makeRequest({
        url: "https://www.googleapis.com/oauth2/v1/userinfo",
        params: {
            alt: "json"
        }
        headers: {
            "Authorization": authHeader
        }
    });
    let userInfo = response.data;
    let id = userInfo.id;
    let email = userInfo.email;
    let isVerified = userInfo.verified_email;
    return {
        id,
        email: {
            id: email,
            isVerified
        }
    }
};

Facebook

getProfileInfo: async (accessTokenAPIResponse: {
    access_token: string,
    expires_in: number,
    token_type: string,
}) => {
    let accessToken = accessTokenAPIResponse.access_token;
    let authHeader = `Bearer ${accessToken}`;
    let response = makeRequest({
        url: "https://graph.facebook.com/me",
        params: {
            access_token: accessToken,
            fields: "id,email",
            format: "json"
        }
    });
    let userInfo = response.data;
    let id = userInfo.id;
    let email = userInfo.email;
    return {
        id,
        email: {
            id: email,
            isVerified: true
        }
    }
};

Github

getProfileInfo: async (accessTokenAPIResponse: {
    access_token: string,
    expires_in: number,
    token_type: string,
}) => {
    let accessToken = accessTokenAPIResponse.access_token;
    let response = makeRequest({
        url: "https://api.github.com/user"
        headers: {
            "Authorization": authHeader,
            "Accept": "application/vnd.github.v3+json"
        }
    });
    let emailsInfoResponse = makeRequest({
        url: "https://api.github.com/user/emails"
        headers: {
            "Authorization": authHeader,
            "Accept": "application/vnd.github.v3+json"
        }
    });
    let userInfo = response.data;
    let emailsInfo = emailsInfoResponse.data;
    let id = userInfo.id;
    let email = userInfo.email;
    let emailInfo = emailsInfo.find(e => e.email === email);
    let isVerified = emailInfo !== undefined ? emailInfo.verified : false;
    return {
        id,
        email: {
            id: email,
            isVerified
        }
    }
};

Apple

getProfileInfo: async (accessTokenAPIResponse: {
    access_token: string,
    expires_in: number,
    token_type: string,
    refresh_token: string,
    id_token: string // JWT
}) => {
    let payload = getTokenPaylaod(id_token);
    let id = payload.email;
    let isVerified = payload.email_verified;
    return {
        id,
        email: {
            id,
            isVerified
        }
    }
};
bhumilsarvaiya commented 3 years ago

@rishabhpoddar What error should be thrown to user if the accessTokenAPI returns with non-success status code?

rishabhpoddar commented 3 years ago

What error should be thrown to user if the accessTokenAPI returns with non-success status code?

@bhumilsarvaiya you throw a general error which gets propogated to the user in their error handler. You do not send a 500 response yourself.