Typing of the result of getAuthenticateOptions is misleading/incorrect
eesdil opened this issue · comments
Is there an existing issue for this?
- I have searched the existing issues
Current behavior
Current typing is:
getAuthenticateOptions(context): IAuthModuleOptions | undefined;
export interface IAuthModuleOptions<T = any> {
defaultStrategy?: string | string[];
session?: boolean;
property?: string;
[key: string]: any;
}
It is allowing/suggesting that the defaultStrategy can be passed, which in fact is ignored.
Also it is not showing that a promise can be used.
Minimum reproduction code
https://github.com/nestjs/passport/blob/master/lib/auth.guard.ts
Steps to reproduce
No response
Expected behavior
Disable adding the defaultStrategy with using different signature, something like: Omit<IAuthModuleOptions, 'defaultStrategy'>
Plus having the Promise<Omit<IAuthModuleOptions, 'defaultStrategy'>> also.
Package version
8.2.2
Passport version
No response
NestJS version
No response
Node.js version
No response
In which operating systems have you tested?
- macOS
- Windows
- Linux
Other
No response
This issue is related to #946.
I guess that @eesdil's point is very important, because...
The method AuthGuard.getAuthenticateOptions
was implemented to resolve #322 issue on #323 PR. The goal of this method is to allow the derived AuthGuard
to customize the authenticate options that will be passed to passport.authenticate
function call. The contract of the passport.authenticate
function expects an AuthenticateOptions
object parameter that must be handled by the associated strategy when it handles the authentication request.
So, currently...
AuthGuard
's constructor receives, by injection, theAuthModuleOptions
object, that's responsible to configure the module of this library;getAuthenticateOptions
returns an object that matches the interfaceIAuthModuleOptions
;AuthGuard.canActivate
obtain the options fromgetAuthenticateOptions
and merges it with the injected options of theAuthGuard
instance;AuthGuard.canActivate
pass the merged options topassport.authenticate
.
export interface IAuthModuleOptions<T = any> {
defaultStrategy?: string | string[];
session?: boolean;
property?: string;
[key: string]: any;
}
export type IAuthGuard = CanActivate & {
// ...
getAuthenticateOptions(context: ExecutionContext): IAuthModuleOptions | undefined;
// ...
};
class MixinAuthGuard<TUser = any> implements CanActivate {
@Optional()
@Inject(AuthModuleOptions)
protected options: AuthModuleOptions = {};
constructor(@Optional() options?: AuthModuleOptions) {
this.options = options ?? this.options;
// ...
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const options = { // <-- MERGED OPTIONS
...defaultOptions,
...this.options,
...(await this.getAuthenticateOptions(context))
};
const [request, response] = [
this.getRequest(context),
this.getResponse(context)
];
const passportFn = createPassportContext(request, response);
const user = await passportFn(
type || this.options.defaultStrategy,
options, // <-- PASS OPTIONS TO passport.authenticate
(err, user, info, status) =>
this.handleRequest(err, user, info, context, status)
);
request[options.property || defaultOptions.property] = user;
return true;
}
// ...
}
const createPassportContext =
(request, response) => (type, options, callback: Function) =>
new Promise<void>((resolve, reject) =>
passport.authenticate(type, options, (err, user, info, status) => { // <-- passport.authenticate CALL
try {
request.authInfo = info;
return resolve(callback(err, user, info, status));
} catch (err) {
reject(err);
}
})(request, response, (err) => (err ? reject(err) : resolve()))
);
IAuthModuleOptions
and AuthenticateOptions
are not syntactically incompatible, as both interfaces have all their properties as optional. But, semantically this is not true. The AuthenticateOptions
is a contract that must be handled by the strategy, and the IAuthModuleOptions
must be handled by the AuthGuard
's module.
I noticed this when inspecting the authorization URL generated by the openid-client
's library strategy. The openid-client
's strategy uses the incoming AuthenticateOptions
object to handle the authorization URL generation, which includes all parameters to the authorization URL. Therefore, the generated URL was wrong, containing invalid parameters that expose the AuthGuard
module options. Like this:
client_id: my-app
scope: openid profile
response_type: code
redirect_uri: http://localhost:3000/auth/oidc/callback
state: 4nCwYO3jS8tKHzbM1FN_lv5yyAOHVMlITMUW8dND494
session: true # <-- EXPOSED OPTION
property: user # <-- EXPOSED OPTION
defaultStrategy: oidc # <-- EXPOSED OPTION
code_challenge: TYrhDeMoO9j9_CXuHSLXdCGdIXukT4orrYtZ8X3783g
code_challenge_method: S256
I think IAuthModuleOptions
should be changed to have a new authenticateOptions
property that sets the AuthenticateOptions
object at the AuthGuard
's module level; and the getAuthenticateOptions
method should returns an object that matches the AuthenticateOptions
interface. In the end, the AuthGuard.canActivate
should merge these options and pass them to the passport.authenticate
function. This is the way to prevent the AuthGuard
's module options from being exposed to the strategy and segregate the interests of these options.
As proposed, the approach should be changed to:
export interface IAuthModuleOptions<T = any> {
defaultStrategy?: string | string[];
session?: boolean;
property?: string;
authenticateOptions?: AuthenticateOptions; // <-- NEW PROPERTY
[key: string]: any;
}
export type IAuthGuard = CanActivate & {
// ...
// CHANGED RETURN TYPE
getAuthenticateOptions(context: ExecutionContext): Promise<AuthenticateOptions> | AuthenticateOptions | undefined;
// ...
};
class MixinAuthGuard<TUser = any> implements CanActivate {
@Optional()
@Inject(AuthModuleOptions)
protected options: AuthModuleOptions = {};
constructor(@Optional() options?: AuthModuleOptions) {
this.options = options ?? this.options;
// ...
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const options = this.options; // <-- MODULE OPTIONS
const authenticateOptions = { // <-- AUTHENTICATE OPTIONS
...defaultOptions.authenticateOptions,
...options.authenticateOptions,
...(await this.getAuthenticateOptions(context))
};
const [request, response] = [
this.getRequest(context),
this.getResponse(context)
];
const passportFn = createPassportContext(request, response);
const user = await passportFn(
type || this.options.defaultStrategy,
authenticateOptions, // <-- PASS OPTIONS TO passport.authenticate
(err, user, info, status) =>
this.handleRequest(err, user, info, context, status)
);
request[options.property || defaultOptions.property] = user;
return true;
}
// ...
}
PR #1610