ananay / passport-apple

Passport strategy for Sign in with Apple
https://passport-apple.ananay.dev
142 stars 49 forks source link

Problem parsing the response when using NestJS's passport #30

Closed JoaquimLey closed 3 years ago

JoaquimLey commented 3 years ago

Here's my strategy (https://docs.nestjs.com/security/authentication):


import { DecodedIdToken, Profile, Strategy, VerifyCallback } from 'passport-apple';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

import { AppleConfig } from '../../apple.config';

@Injectable()
export class AppleStrategy extends PassportStrategy(Strategy, 'apple') {
    constructor() {
        super({
            clientID: AppleConfig.clientId(),
            teamID: AppleConfig.teamId(),
            callbackURL: AppleConfig.callbackUrl(),
            keyID: AppleConfig.keyId(),
            privateKeyString: AppleConfig.privateKey(),
            scope: AppleConfig.scope(),
        });
    }

    async validate(
        req: any,
        accessToken: string,
        refreshToken: string,
        decodedIdToken: DecodedIdToken,
        profile: Profile,
        verified: VerifyCallback) {
        console.log('-----------------------------')
        console.log('Apple req.body: ', req.body)
        console.log('-----------------------------')
        console.log('Apple accessToken: ', accessToken)
        console.log('Apple refreshToken: ', refreshToken)
        console.log('-----------------------------')
        console.log('Apple decodedIdToken: ', decodedIdToken)
        console.log('Apple decodedIdToken.sub: ', decodedIdToken.sub)
        console.log('-----------------------------')
        console.log('Apple profile: ', profile)
        console.log('Apple verified: ', verified)
        console.log('-----------------------------')
}

And this is my server output

Apple req.body:  {
    state: '09242360a0',
    code: 'cdf7fd95e36774d0cb0dcd81c4e09d010.0.mrwus.l1alsFHcuv-AH0OKNKycfw'
}
-----------------------------
Apple accessToken:  a342a17989eae4eea8b5d26b0a35afdb2.0.mrwus.JBPcce0eZz5e1u_ZrGAZKg
Apple refreshToken:  r9d87da422ee741d6aab4c1a2a148d319.0.mrwus.UoJnv-s3y5u2ciYWh_AvLw
-----------------------------
Apple decodedIdToken:  {}
Apple decodedIdToken.sub:  undefined
-----------------------------
Apple profile:  [Function: verified]
Apple verified:  undefined
-----------------------------

So there's definitely something wrong with the validate/callback function at least on NestJS's passport wrapper implementation, I've spent a lot of time trying to figure the way but for some reason, I can't.

I know the user is not returned all the time, but even at the first sign in the "profile" which is clearly mapped to the callback function has nothing there, only the body has a user {} object. My issue here I can't wrap my head around how I should decode the AT or RT to get anything that maps to the user (these change on each request).

Also, the req is always passed to the callback regardless of the option set above.

For some context, here's my (working) google counterpart using passport-google-oauth20


import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

import { GoogleConfig } from '../../google.config';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
    constructor() {
        super({
            clientID: GoogleConfig.clientId(),
            clientSecret: GoogleConfig.clientSecret(),
            callbackURL: GoogleConfig.callbackUrl(),
            scope: GoogleConfig.scope(),
        });
    }

    async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback) {
        const { id, name, emails, photos } = profile
        const user = <UserFromAuthProvider>{
            id: id,
            email: emails[0].value,
            first_name: name.givenName,
            last_name: name.familyName,
            picture_url: photos[0].value,
            provider_token: accessToken
        }
        return done(null, user)
    }
}

thank you in advance for your time

JoaquimLey commented 3 years ago

OK, after digging quite a bit I've noticed what I think might be the culprit, using Apple's JS button the response_type query param has both the code and id_token (Apple's JS does not call the GET request to my own API to fetch the URL for apple).

While using this passport library (with the above implementation):

@ananay any pointers?

JoaquimLey commented 3 years ago

I could not solve this issue with the library instead I directly called apple from the frontend, on my strategy I changed it to: (jwt is an abstraction to '@nestjs/jwt'/jsonwebtoken library)


    async validate(
        req: any,
        accessToken: string,
        refreshToken: string,
        idToken: any,
        profile: any,
        verified: any
    ): Promise<UserFromAuthProvider> {
        const body = req.body

        const userProfile = body.user ? JSON.parse(body.user) : null
        const decodedToken = this.jwt.decode(body.id_token)

        // The UserFromAuthProvider is an abstraction/model specific to my project, you can return wtv you want.
        const user = <UserFromAuthProvider>{
            id: accessToken,
            email: decodedToken.email,
            first_name: userProfile ? userProfile.name.firstName : null,
            last_name: userProfile ? userProfile.name.lastName : null,
            picture_url: null,
            provider_token: decodedToken.sub
        }
        return user
    }

Hope this helps someone that has the same or similar issue.

zfanta commented 2 years ago

@JoaquimLey response_type is is written code by jaredhanson/passport-oauth2 at https://github.com/jaredhanson/passport-oauth2/blob/ee3fe9f17c0f3a90f2d9d938f267e9942b9fba49/lib/strategy.js#L229

JoaquimLey commented 2 years ago

@zfanta I didn't really get what you meant?

AkshayCloudAnalogy commented 2 years ago

@zfanta @JoaquimLey any other solution for the above instead of doing it from frontend? I have faced the same issue in which i am not getting the user data in return.

zfanta commented 2 years ago

@JoaquimLey @AkshayCloudAnalogy to prevent https://github.com/jaredhanson/passport-oauth2/blob/ee3fe9f17c0f3a90f2d9d938f267e9942b9fba49/lib/strategy.js#L229 from overwriting response_type to code, I manually wrote authenticate method.


@Injectable()
export class AppleStrategy extends PassportStrategy(Strategy, 'apple') {
  constructor (...) {
    super({...})
  }

  async validate (@Req() req: Request): Promise<any> {
    ...
  }

  authenticate (req: Request, options?: object): void {
    if (req.body.code !== undefined) {
      super.authenticate(req, {
        ...options
      })
    } else {
      const params = new URLSearchParams()
      params.append('property', 'user')
      params.append('response_type', 'code id_token')
      params.append('scope', 'email')
      params.append('response_mode', 'form_post')
      params.append('redirect_uri', <return url>)
      params.append('client_id', <client_id>)

      req.res?.writeHead(302, {
        Location: `https://appleid.apple.com/auth/authorize?${params.toString()}`
      })
      req.res?.end()
    }
  }
}
AkshayCloudAnalogy commented 2 years ago

Thanks. This solved the issue.

JoaquimLey commented 2 years ago

@zfanta I did not test this as for my use case I'm fine calling apple from the frontend but I'll definitely keep this in mind if in the future I need to redirect the call through a safe environment.