Skip to content

Commit 8079884

Browse files
feat(provider): add SAML passport authentication strategy (#137)
* feat(provider): add SAML passport authentication strategy GH-131 * feat(provider): add SAML passport authentication strategy GH-131 * feat(provider): add SAML passport authentication strategy GH-131
1 parent b4f61c1 commit 8079884

File tree

14 files changed

+562
-2
lines changed

14 files changed

+562
-2
lines changed

README.md

Lines changed: 308 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ It provides support for seven passport based strategies.
2626
8. [passport-apple](https://github.com/ananay/passport-apple) - Passport strategy for authenticating with Apple using the Apple OAuth 2.0 API. This module lets you authenticate using Apple in your Node.js applications.
2727
9. [passport-facebook](https://github.com/jaredhanson/passport-facebook) - Passport strategy for authenticating with Facebook using the Facebook OAuth 2.0 API. This module lets you authenticate using Facebook in your Node.js applications.
2828
10. [passport-cognito-oauth2](https://github.com/ebuychance/passport-cognito-oauth2) - Passport strategy for authenticating with Cognito using the Cognito OAuth 2.0 API. This module lets you authenticate using Cognito in your Node.js applications.
29-
11. custom-passport-otp - Created a Custom Passport strategy for 2-Factor-Authentication using OTP (One Time Password).
29+
11. [passport-SAML](https://github.com/node-saml/passport-saml) - Passport strategy for authenticating with SAML using the SAML 2.0 API. This module lets you authenticate using SAML in your Node.js applications
30+
12. custom-passport-otp - Created a Custom Passport strategy for 2-Factor-Authentication using OTP (One Time Password).
3031

3132
You can use one or more strategies of the above in your application. For each of the strategy (only which you use), you just need to provide your own verifier function, making it easily configurable. Rest of the strategy implementation intricacies is handled by extension.
3233

@@ -2595,6 +2596,312 @@ this.bind(VerifyBindings.BEARER_SIGNUP_VERIFY_PROVIDER).toProvider(
25952596
);
25962597
```
25972598

2599+
### SAML
2600+
2601+
SAML (Security Assertion Markup Language) is an XML-based standard for exchanging authentication and authorization data between parties, in particular, between an identity provider (IdP) and a service provider (SP).
2602+
2603+
First, create a AuthUser model implementing the IAuthUser interface. You can implement the interface in the user model itself. See sample below.
2604+
2605+
```ts
2606+
@model({
2607+
name: 'users',
2608+
})
2609+
export class User extends Entity implements IAuthUser {
2610+
@property({
2611+
type: 'number',
2612+
id: true,
2613+
})
2614+
id?: number;
2615+
@property({
2616+
type: 'string',
2617+
required: true,
2618+
name: 'first_name',
2619+
})
2620+
firstName: string;
2621+
@property({
2622+
type: 'string',
2623+
name: 'last_name',
2624+
})
2625+
lastName: string;
2626+
@property({
2627+
type: 'string',
2628+
name: 'middle_name',
2629+
})
2630+
middleName?: string;
2631+
@property({
2632+
type: 'string',
2633+
required: true,
2634+
})
2635+
username: string;
2636+
@property({
2637+
type: 'string',
2638+
})
2639+
email?: string;
2640+
// Auth provider - 'SAML'
2641+
@property({
2642+
type: 'string',
2643+
required: true,
2644+
name: 'auth_provider',
2645+
})
2646+
authProvider: string;
2647+
// Id from external provider
2648+
@property({
2649+
type: 'string',
2650+
name: 'auth_id',
2651+
})
2652+
authId?: string;
2653+
@property({
2654+
type: 'string',
2655+
name: 'auth_token',
2656+
})
2657+
authToken?: string;
2658+
@property({
2659+
type: 'string',
2660+
})
2661+
password?: string;
2662+
constructor(data?: Partial<User>) {
2663+
super(data);
2664+
}
2665+
}
2666+
```
2667+
2668+
Now bind this model to USER_MODEL key in application.ts
2669+
2670+
```ts
2671+
this.bind(AuthenticationBindings.USER_MODEL).to(User);
2672+
```
2673+
2674+
Create CRUD repository for the above model. Use loopback CLI.
2675+
2676+
```sh
2677+
lb4 repository
2678+
```
2679+
2680+
Add the verifier function for the strategy. You need to create a provider for the same. You can add your application specific business logic for client auth here. Here is a simple example.
2681+
2682+
```ts
2683+
import {Provider} from '@loopback/context';
2684+
import {repository} from '@loopback/repository';
2685+
import {HttpErrors} from '@loopback/rest';
2686+
import {AuthErrorKeys, VerifyFunction} from 'loopback4-authentication';
2687+
import {Tenant} from '../../../models';
2688+
import {UserCredentialsRepository, UserRepository} from '../../../repositories';
2689+
import {AuthUser} from '../models/auth-user.model';
2690+
export class SamlVerifyProvider implements Provider<VerifyFunction.SamlFn> {
2691+
constructor(
2692+
@repository(UserRepository)
2693+
public userRepository: UserRepository,
2694+
@repository(UserCredentialsRepository)
2695+
public userCredsRepository: UserCredentialsRepository,
2696+
) {}
2697+
value(): VerifyFunction.SamlFn {
2698+
return async (profile) => {
2699+
const user = await this.userRepository.findOne({
2700+
where: {
2701+
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
2702+
email: (profile as any)._json.email,
2703+
},
2704+
});
2705+
if (!user) {
2706+
throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials);
2707+
}
2708+
if (!user || user.authProvider !== 'saml' || user.authId !== profile.id) {
2709+
throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials);
2710+
}
2711+
const authUser: AuthUser = new AuthUser({
2712+
...user,
2713+
id: user.id as string,
2714+
});
2715+
authUser.permissions = [];
2716+
return this.postVerifyProvider(profile, authUser);
2717+
};
2718+
}
2719+
}
2720+
```
2721+
2722+
Please note the Verify function type _VerifyFunction.LocalPasswordFn_
2723+
2724+
Now bind this provider to the application in application.ts.
2725+
2726+
```ts
2727+
import {AuthenticationComponent, Strategies} from 'loopback4-authentication';
2728+
```
2729+
2730+
```ts
2731+
// Add authentication component
2732+
this.component(AuthenticationComponent);
2733+
// Customize authentication verify handlers
2734+
this.bind(Strategies.Passport.SAML_VERIFIER).toProvider(SamlVerifyProvider);
2735+
```
2736+
2737+
Finally, add the authenticate function as a sequence action to sequence.ts.
2738+
2739+
```ts
2740+
export class MySequence implements SequenceHandler {
2741+
constructor(
2742+
@inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
2743+
@inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
2744+
@inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
2745+
@inject(SequenceActions.SEND) public send: Send,
2746+
@inject(SequenceActions.REJECT) public reject: Reject,
2747+
@inject(AuthenticationBindings.USER_AUTH_ACTION)
2748+
protected authenticateRequest: AuthenticateFn<AuthUser>,
2749+
) {}
2750+
async handle(context: RequestContext) {
2751+
try {
2752+
const {request, response} = context;
2753+
const route = this.findRoute(request);
2754+
const args = await this.parseParams(request, route);
2755+
request.body = args[args.length - 1];
2756+
const authUser: AuthUser = await this.authenticateRequest(
2757+
request,
2758+
response,
2759+
);
2760+
const result = await this.invoke(route, args);
2761+
this.send(response, result);
2762+
} catch (err) {
2763+
this.reject(context, err);
2764+
}
2765+
}
2766+
}
2767+
```
2768+
2769+
After this, you can use decorator to apply auth to controller functions wherever needed. See below.
2770+
2771+
```ts
2772+
@authenticateClient(STRATEGY.CLIENT_PASSWORD)
2773+
@authenticate(
2774+
STRATEGY.SAML,
2775+
{
2776+
accessType: 'offline',
2777+
scope: ['profile', 'email'],
2778+
authorizationURL: process.env.SAML_URL,
2779+
callbackURL: process.env.SAML_CALLBACK_URL,
2780+
clientID: process.env.SAML_CLIENT_ID,
2781+
clientSecret: process.env.SAML_CLIENT_SECRET,
2782+
tokenURL: process.env.SAML_TOKEN_URL,
2783+
},
2784+
queryGen('body'),
2785+
)
2786+
@authorize({permissions: ['*']})
2787+
@post('/auth/saml', {
2788+
description: 'POST Call for saml based login',
2789+
responses: {
2790+
[STATUS_CODE.OK]: {
2791+
description: 'Saml Token Response',
2792+
content: {
2793+
[CONTENT_TYPE.JSON]: {
2794+
schema: {[X_TS_TYPE]: TokenResponse},
2795+
},
2796+
},
2797+
},
2798+
},
2799+
})
2800+
async postLoginViaSaml(
2801+
@requestBody({
2802+
content: {
2803+
[CONTENT_TYPE.FORM_URLENCODED]: {
2804+
schema: getModelSchemaRef(ClientAuthRequest),
2805+
},
2806+
},
2807+
})
2808+
clientCreds?: ClientAuthRequest, //NOSONAR
2809+
): Promise<void> {
2810+
//do nothing
2811+
}
2812+
@authenticate(
2813+
STRATEGY.SAML,
2814+
{
2815+
accessType: 'offline',
2816+
scope: ['profile', 'email'],
2817+
authorizationURL: process.env.SAML_URL,
2818+
callbackURL: process.env.SAML_CALLBACK_URL,
2819+
clientID: process.env.SAML_CLIENT_ID,
2820+
clientSecret: process.env.SAML_CLIENT_SECRET,
2821+
tokenURL: process.env.SAML_TOKEN_URL,
2822+
},
2823+
queryGen('query'),
2824+
)
2825+
@authorize({permissions: ['*']})
2826+
@get('/auth/saml-redirect', {
2827+
responses: {
2828+
[STATUS_CODE.OK]: {
2829+
description: 'Saml Redirect Token Response',
2830+
content: {
2831+
[CONTENT_TYPE.JSON]: {
2832+
schema: {[X_TS_TYPE]: TokenResponse},
2833+
},
2834+
},
2835+
},
2836+
},
2837+
})
2838+
async samlCallback(
2839+
@param.query.string('code') code: string, //NOSONAR
2840+
@param.query.string('state') state: string,
2841+
@param.query.string('session_state') sessionState: string, //NOSONAR
2842+
@inject(RestBindings.Http.RESPONSE) response: Response,
2843+
@inject(AuthenticationBindings.CURRENT_USER)
2844+
user: AuthUser | undefined,
2845+
): Promise<void> {
2846+
const clientId = new URLSearchParams(state).get('client_id');
2847+
if (!clientId || !user) {
2848+
throw new HttpErrors.Unauthorized(AuthErrorKeys.ClientInvalid);
2849+
}
2850+
const client = await this.authClientRepository.findOne({
2851+
where: {
2852+
clientId,
2853+
},
2854+
});
2855+
if (!client?.redirectUrl) {
2856+
throw new HttpErrors.Unauthorized(AuthErrorKeys.ClientInvalid);
2857+
}
2858+
try {
2859+
const token = await this.getAuthCode(client, user);
2860+
response.redirect(`${client.redirectUrl}?code=${token}`);
2861+
} catch (error) {
2862+
this.logger.error(error);
2863+
throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials);
2864+
}
2865+
}
2866+
```
2867+
2868+
Please note above that we are creating two new APIs for SAML. The first one is for UI clients to hit. We are authenticating client as well, then passing the details to the SAML. Then, the actual authentication is done by SAML authorization url, which redirects to the second API we created after success. The first API method body is empty as we do not need to handle its response. The SAML provider in this package will do the redirection for you automatically.
2869+
2870+
For accessing the authenticated AuthUser model reference, you can inject the CURRENT_USER provider, provided by the extension, which is populated by the auth action sequence above.
2871+
2872+
```ts
2873+
@inject.getter(AuthenticationBindings.CURRENT_USER)
2874+
private readonly getCurrentUser: Getter<User>,
2875+
```
2876+
2877+
The `logoutVerify` function is used in the node-saml library as a part of the Passport SAML authentication process. This function is used to verify the authenticity of a SAML logout request.
2878+
The logout process in SAML is used to end the user's session on the service provider, and the logoutVerify function is used to verify that the logout request is coming from a trusted IdP.
2879+
The implementation of the logoutVerify function may vary depending on the specific requirements and the security constraints of the application. It is typically used to verify the signature on the logout request, to ensure that the request has not been tampered with, and to extract the user's identity information from the request.
2880+
2881+
```ts
2882+
function logoutVerify(
2883+
req: Request<AnyObject, AnyObject, AnyObject>,
2884+
profile: Profile | null,
2885+
done: VerifiedCallback,
2886+
): void {
2887+
// Check if a user is currently authenticated
2888+
if (req.isAuthenticated()) {
2889+
// Log the user out by removing their session data
2890+
req.logout(done);
2891+
// Call the "done" callback to indicate success
2892+
done(null, {message: 'User successfully logged out'});
2893+
} else {
2894+
// Call the "done" callback with an error to indicate that the user is not logged in
2895+
done(new Error('User is not currently logged in'));
2896+
}
2897+
}
2898+
```
2899+
2900+
This function is called when a user logs out of the application.Once this function is implemented,it will be called when the user logs out of the application,allowing the application to perform any necessary tasks before ending the user's session.
2901+
@param req - The request object.
2902+
@param {Profile | null} profile - The user's profile, as returned by the provider.
2903+
@param {VerifiedCallback} done - A callback to be called when the verificationis complete.
2904+
25982905
### Https proxy support for keycloak and google auth
25992906

26002907
If a https proxy agent is needed for keycloak and google auth, just add an environment variable named `HTTPS_PROXY` or `https_proxy` with proxy url as value. It will add that proxy agent to the request.

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@exlinc/keycloak-passport": "^1.0.2",
5656
"@loopback/context": "^5.0.7",
5757
"@loopback/core": "^4.0.7",
58+
"@node-saml/passport-saml": "^4.0.2",
5859
"ajv": "^8.11.0",
5960
"https-proxy-agent": "^5.0.0",
6061
"jsonwebtoken": "^9.0.0",

0 commit comments

Comments
 (0)