-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
fix: passport strategy adapter must support oauth2 flows #4919
Conversation
54747ef
to
ee0198f
Compare
0b8f894
to
ae56e93
Compare
bc9d387
to
fe38976
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@deepakrkris Great effort and awesome documentations, very detailed! I left two comments about the design in #4919 (comment) and #4919 (comment)
const passport = new Oauth2Strategy(oauth2Options, verify); | ||
|
||
// passport-oauth2 base class leaves user profile creation to subclass implementations | ||
passport.userProfile = (accessToken, done) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is userProfile
a function required by the Oauth2Strategy
middleware?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jannyHou userprofile is a no-op function in 'Passport-oauth2' Strategy since it is a base class for all oauth2 implementations like 'passport-facebook', etc. we are just overriding it here for our oauth2 impl.
// HTTP status code 302 is returned to the browser | ||
const response = await supertest('') | ||
.post('http://localhost:9000/login_submit?' + params) | ||
.send({username: 'user', password: 'abc'}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reason repeat the username
and password
in the request body and the parameters?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was having a hard time to submit the form with variables in the beginning, so tried all kinds of approach. I will remove the query parameters since form submission is working now.
*/ | ||
app.post('/login_submit', urlencodedParser, async function (req, res) { | ||
const user = findUser( | ||
req.body.username || 'user1', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reason to have a default user and password set here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will remove the default values, added them when I was trying to make the login page submit work.
@@ -42,9 +42,9 @@ export class StrategyAdapter<U> implements AuthenticationStrategy { | |||
* 3. authenticate using the strategy | |||
* @param request The incoming request. | |||
*/ | |||
authenticate(request: Request): Promise<UserProfile> { | |||
authenticate(request: Request): Promise<UserProfile | RedirectRoute> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@deepakrkris could you explain more about why we have to allow returning a RedirectRoute
here?
According to the passport-oauth2 module, it returns a user (which conceptually same as lb's userProfile
) after the authentication done, wondering why we couldn't follow the same pattern here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The configure-strategy
deep-link you have commented above, is only to set the list of strategies in the base 'passport' module (which is heavily dependent on express).
The passport.authenticate()
method that is handling the express routes interprets the incoming request and does the redirection. The base 'passport' module implements this as an express middleware without any code written by the user. For LoopBack, the strategy adapter will have to handle the redirect callback from the passport-strategy (done by the base passport
module in express) + we also need the controller to configure the routes (done by the passport.authenticate()
method) + the injected send() method in the sequence must be aware of this redirection (done by the base passport module in express).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the part I found tricky (due to my lack of knowledge in authentication when I started in Jan 2019), the authentication strategy code needs to execute before it reaches a controller method. If the user is not allowed to call this controller method, then they should get a not authenticated
http error. So in the flow you're following in all the code updates you've made @deepakrkris , is a user able to access and get into controller method code before
this oauth authentication flow is finished? Perhaps we need a chart explaining each step of the code path through our code and through the oauth provider backend?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the Oauth2Controller methods that we have defined are similar to the endpoints defined in the authenticate requests section of the passport oauth2 readme.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@deepakrkris Thank you for the explanation 👍 the redirection passes the authenticated user's info to the callback endpoint, which results in the controller function maps to /thirdparty
won't have a user profile.
constructor() {} | ||
|
||
// this configures the oauth2 strategy | ||
@authenticate('oauth2') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@deepakrkris related to my comment in #4919 (comment)
I think the scenario here is different than what's expected from our authentication system...user profile injection should happen here, which means after the oauth2 strategy is invoked, a user profile should be returned as the result of the authentication action.
My confusion point is, why we have to handle the redirect then get the user profile?
My expectation would be, the user profile is injected at controller level instead of the endpoint level. So that when the controller functions are invoked, the user profile is ready to use, both /thirdparty
and /callback
can print it.
I am not very familiar with the newly created redirectRoute
therefore having a hard time to illustrate which part should handle the callback redirection, the strategy? or the controller?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To explain that quickly, businessUsecase
endpoints wont be configured with anoauth2
strategy. The OAuth2Controller
is configured with an oauth2 strategy so that it can begin the Authorization Code Grant
dialog. Once that flow is completed, the web client would switch to other strategies like session
or jwt
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The longer version :) :
/thirdparty
route acts as an interface to begin the oauth2 dialog with the thirdparty and /callback
is for the third-party login to redirect back to LoopBack app. Once /callback
endpoint has the oauth token + the user profile, it can proceed in three different ways.
-
It can create a browser session. This is the most popular one we use everyday to login to an app using facebook/google credentials. This means the client from there on would use the
passport-session
strategy to access otherUsecase
endpoints in the LoopBack App. -
Or it can return the original oauth token from the third-party to the web client which can then call other
Usecase
endpoints using the token as aBearer
(jwt
strategy) -
Or it can create a new token with the same expiration time of the original thirdparty token (the
exp
field in that token says it) and send that to the web client. (jwt
strategy)
231bc30
to
6756ead
Compare
Thanks for adding more documentation; including that ASCII diagram in the comment ;) |
// | | | v | | ||
// | | <------------------------[8]----------------------- | **** | | ||
// +------------+ LB App returns to browser the access token +--------------+ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the detailed explanation. It might be better to have a separate README.md
though.
// bind redirection url and status as 'authentication.oauth2.redirectUrl' to context | ||
// controller should handle actual redirection | ||
this.ctx | ||
.bind('authentication.redirect.url') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please define constants for such keys in keys.ts
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed
redirect(response: Response): void { | ||
response.statusCode = this.statusCode || 302; | ||
response.setHeader('Location', this.targetLocation); | ||
response.setHeader('Content-Length', '0'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably not needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed
) { | ||
const redirectHandler = new RedirectRoute('/', redirectUrl, status); | ||
redirectHandler.redirect(response); | ||
return redirectHandler; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change it to return response
please.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@raymondfeng , if RedirectRoute is not returned from the controller, the writer.ts (send function) cannot do a type check for RedirectRoute.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we should return the Response object, in the writer.ts should we do a lookup against the request context for a redirect url value instead of the RedirectRoute type check ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Raymond I see your point, writer.ts returns immediately on a response result. Thank you. I will make the change.
@@ -87,7 +87,9 @@ export interface AuthenticationStrategy { | |||
* | |||
* @param request - Express request object | |||
*/ | |||
authenticate(request: Request): Promise<UserProfile | undefined>; | |||
authenticate( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Had a chat with @deepakrkris yesterday and I agree it's reasonable to allow returning a redirect route. Might be a breaking change.
OperationRetval, | ||
PathParameterValues, | ||
} from '../types'; | ||
import {OperationArgs, OperationRetval, PathParameterValues} from '../types'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
effectively no change here, just undid the change for last commit
@raymondfeng could you please review/approve, I have fixed your review comments. |
@@ -29,6 +29,7 @@ export class AuthenticateActionProvider implements Provider<AuthenticateFn> { | |||
readonly getStrategy: Getter<AuthenticationStrategy>, | |||
@inject.setter(SecurityBindings.USER) | |||
readonly setCurrentUser: Setter<UserProfile>, | |||
@inject.context() private ctx: Context, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
inject.setter
is a better option. See https://loopback.io/doc/en/lb4/Decorators_inject.html for examples.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already use @inject.setter
for user:
@inject.setter(SecurityBindings.USER)
readonly setCurrentUser: Setter<UserProfile>,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed
.to(redirectOptions.targetLocation); | ||
this.ctx | ||
.bind(AuthenticationBindings.AUTHENTICATION_REDIRECT_STATUS) | ||
.to(redirectOptions.statusCode); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These can be replaced with setters (https://loopback.io/doc/en/lb4/Decorators_inject.html).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed
@raymondfeng ready for review again, thank you for the perfect comments |
d869966
to
b08a2ce
Compare
@inject.setter(AuthenticationBindings.AUTHENTICATION_REDIRECT_URL) | ||
readonly setRedirectUrl: Setter<string>, | ||
@inject.setter(AuthenticationBindings.AUTHENTICATION_REDIRECT_STATUS) | ||
readonly setRedirectStatus: Setter<number>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
fixes: #4902
highlights of changes :
packages/rest/src/writer.ts
- modified to handle redirections, controller can choose to redirect an api callextensions/authentication-passport/src/strategy-adapter.ts
- adapter is modified to handle redirection calls in passport strategyextensions/authentication-passport/src/__tests__/acceptance/fixtures/oauth2-provider.ts
- Mock oauth2 provider to mock the whole authorization flow
extensions/authentication-passport/src/_tests__/acceptance/authentication-with-passport-strategy-oauth2-adapter.acceptance.ts
- test cases for the whole authorization flow
Checklist
👉 Read and sign the CLA (Contributor License Agreement) 👈
npm test
passes on your machinepackages/cli
were updatedexamples/*
were updated👉 Check out how to submit a PR 👈