@hazeljs/oauth 0.7.7 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/oauth.controller.d.ts +21 -0
- package/dist/oauth.controller.d.ts.map +1 -1
- package/dist/oauth.controller.js +102 -2
- package/dist/oauth.controller.test.d.ts +2 -0
- package/dist/oauth.controller.test.d.ts.map +1 -0
- package/dist/oauth.controller.test.js +50 -0
- package/dist/oauth.service.d.ts +12 -2
- package/dist/oauth.service.d.ts.map +1 -1
- package/dist/oauth.service.js +36 -0
- package/dist/oauth.service.test.js +80 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider.types.d.ts +61 -1
- package/dist/providers/provider.types.d.ts.map +1 -1
- package/dist/providers/provider.types.test.js +27 -0
- package/dist/providers/saml.provider.d.ts +18 -0
- package/dist/providers/saml.provider.d.ts.map +1 -0
- package/dist/providers/saml.provider.js +141 -0
- package/dist/providers/saml.provider.test.d.ts +2 -0
- package/dist/providers/saml.provider.test.d.ts.map +1 -0
- package/dist/providers/saml.provider.test.js +137 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @hazeljs/oauth
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**OAuth social login + native SAML SSO in one HazelJS package.**
|
|
4
4
|
|
|
5
|
-
Google, Microsoft, GitHub, Facebook, Twitter —
|
|
5
|
+
Google, Microsoft, GitHub, Facebook, Twitter — plus native SAML IdP support for enterprise SSO. One config, ready-made routes, PKCE, user profiles, callback handler hooks, JWT integration.
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@hazeljs/oauth)
|
|
8
8
|
[](https://www.npmjs.com/package/@hazeljs/oauth)
|
|
@@ -10,7 +10,8 @@ Google, Microsoft, GitHub, Facebook, Twitter — one config, ready-made routes.
|
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
|
-
- **Multi-Provider** — Google, Microsoft Entra ID, GitHub, Facebook, Twitter
|
|
13
|
+
- **Multi-Provider OAuth** — Google, Microsoft Entra ID, GitHub, Facebook, Twitter
|
|
14
|
+
- **Native SAML SP** — Multi-IdP configuration with ACS callback and metadata endpoint
|
|
14
15
|
- **PKCE Support** — Automatic for Google, Microsoft, Twitter
|
|
15
16
|
- **User Profile** — Fetches id, email, name, picture from provider APIs
|
|
16
17
|
- **Ready-Made Routes** — Optional `/auth/:provider` and `/auth/:provider/callback`
|
|
@@ -63,6 +64,9 @@ The `OAuthController` provides:
|
|
|
63
64
|
|
|
64
65
|
- **GET /auth/:provider** — Redirects to provider (google, microsoft, github, facebook, twitter)
|
|
65
66
|
- **GET /auth/:provider/callback** — Handles callback, returns `{ accessToken, user }`
|
|
67
|
+
- **GET /auth/saml/:idp** — Redirects to SAML IdP with AuthnRequest
|
|
68
|
+
- **POST /auth/saml/:idp/callback** — Handles SAML ACS callback
|
|
69
|
+
- **GET /auth/saml/:idp/metadata** — Returns SP metadata XML
|
|
66
70
|
|
|
67
71
|
Example: User visits `/auth/google` → authenticates → callback returns tokens and profile.
|
|
68
72
|
|
|
@@ -104,6 +108,38 @@ export class AuthController {
|
|
|
104
108
|
| Facebook | No | email, public_profile |
|
|
105
109
|
| Twitter | Yes | users.read, tweet.read |
|
|
106
110
|
|
|
111
|
+
## SAML Configuration (Multi-IdP)
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
OAuthModule.forRoot({
|
|
115
|
+
providers: {
|
|
116
|
+
google: { ...googleConfig },
|
|
117
|
+
saml: {
|
|
118
|
+
oktaMain: {
|
|
119
|
+
idpKey: 'okta-main',
|
|
120
|
+
ssoUrl: 'https://your-org.okta.com/app/abc/sso/saml',
|
|
121
|
+
issuer: 'https://api.example.com',
|
|
122
|
+
acsUrl: 'https://api.example.com/auth/saml/okta-main/callback',
|
|
123
|
+
audience: 'https://api.example.com',
|
|
124
|
+
relayState: 'app=dashboard',
|
|
125
|
+
},
|
|
126
|
+
azureMain: {
|
|
127
|
+
idpKey: 'azure-main',
|
|
128
|
+
ssoUrl: 'https://login.microsoftonline.com/.../saml2',
|
|
129
|
+
issuer: 'https://api.example.com',
|
|
130
|
+
acsUrl: 'https://api.example.com/auth/saml/azure-main/callback',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Use start + callback flow:
|
|
138
|
+
|
|
139
|
+
1. Browser hits `GET /auth/saml/okta-main`
|
|
140
|
+
2. IdP authenticates user and POSTS `SAMLResponse` to ACS callback
|
|
141
|
+
3. Package parses assertion, extracts profile, then invokes optional callback handler
|
|
142
|
+
|
|
107
143
|
## Configuration
|
|
108
144
|
|
|
109
145
|
### Microsoft (optional tenant)
|
|
@@ -178,6 +214,10 @@ OAUTH_REDIRECT_URI=http://localhost:3000/auth/google/callback
|
|
|
178
214
|
- `handleCallback(provider, code, state, codeVerifier?)` — Returns `{ accessToken, refreshToken?, expiresAt?, user }`
|
|
179
215
|
- `validateState(received, stored)` — CSRF check
|
|
180
216
|
- `generateState()` — Cryptographically secure state
|
|
217
|
+
- `getSamlAuthorizationUrl(idpKey, relayState?)` — Returns SAML redirect URL + request ID
|
|
218
|
+
- `handleSamlCallback(idpKey, samlResponseBase64, relayState?)` — Returns parsed SAML callback result
|
|
219
|
+
- `getSamlMetadata(idpKey)` — Returns SP metadata XML
|
|
220
|
+
- `getSamlProviderKeys()` — Returns configured SAML provider keys
|
|
181
221
|
|
|
182
222
|
### OAuthStateGuard
|
|
183
223
|
|
package/dist/index.d.ts
CHANGED
|
@@ -8,5 +8,5 @@ export { OAuthModule } from './oauth.module';
|
|
|
8
8
|
export { OAuthService } from './oauth.service';
|
|
9
9
|
export { OAuthController } from './oauth.controller';
|
|
10
10
|
export { OAuthStateGuard } from './guards/oauth-state.guard';
|
|
11
|
-
export type { OAuthModuleOptions, OAuthProvidersConfig, OAuthCallbackHandler, OAuthCallbackResult, OAuthAuthorizationResult, OAuthUser, SupportedProvider, GoogleProviderConfig, MicrosoftProviderConfig, GitHubProviderConfig, FacebookProviderConfig, TwitterProviderConfig, } from './providers/provider.types';
|
|
11
|
+
export type { OAuthModuleOptions, OAuthProvidersConfig, OAuthCallbackHandler, OAuthCallbackResult, OAuthProtocolCallbackResult, OAuthAuthorizationResult, OAuthUser, SamlUser, SamlCallbackResult, SupportedProvider, GoogleProviderConfig, MicrosoftProviderConfig, GitHubProviderConfig, FacebookProviderConfig, TwitterProviderConfig, SamlProviderConfig, } from './providers/provider.types';
|
|
12
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,YAAY,EACV,kBAAkB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,mBAAmB,EACnB,wBAAwB,EACxB,SAAS,EACT,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,oBAAoB,EACpB,sBAAsB,EACtB,qBAAqB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,YAAY,EACV,kBAAkB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,mBAAmB,EACnB,2BAA2B,EAC3B,wBAAwB,EACxB,SAAS,EACT,QAAQ,EACR,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,oBAAoB,EACpB,sBAAsB,EACtB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC"}
|
|
@@ -24,6 +24,27 @@ export declare class OAuthController {
|
|
|
24
24
|
}, req: {
|
|
25
25
|
headers?: Record<string, string | string[] | undefined>;
|
|
26
26
|
}, res: Response): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* GET /auth/saml/:idp - Redirects user to SAML IdP login.
|
|
29
|
+
*/
|
|
30
|
+
samlLogin(idp: string, query: {
|
|
31
|
+
relayState?: string;
|
|
32
|
+
}, res: Response): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* POST /auth/saml/:idp/callback - Handles SAML ACS callback.
|
|
35
|
+
*/
|
|
36
|
+
samlCallback(idp: string, body: {
|
|
37
|
+
SAMLResponse?: string;
|
|
38
|
+
RelayState?: string;
|
|
39
|
+
successRedirect?: string;
|
|
40
|
+
errorRedirect?: string;
|
|
41
|
+
}, req: {
|
|
42
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
43
|
+
}, res: Response): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* GET /auth/saml/:idp/metadata - Exposes minimal SP metadata.
|
|
46
|
+
*/
|
|
47
|
+
samlMetadata(idp: string, res: Response): Promise<void>;
|
|
27
48
|
private redirectOrJson;
|
|
28
49
|
}
|
|
29
50
|
//# sourceMappingURL=oauth.controller.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.controller.d.ts","sourceRoot":"","sources":["../src/oauth.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2C,KAAK,QAAQ,
|
|
1
|
+
{"version":3,"file":"oauth.controller.d.ts","sourceRoot":"","sources":["../src/oauth.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2C,KAAK,QAAQ,EAAc,MAAM,eAAe,CAAC;AACnG,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AA2B/C;;;GAGG;AACH,qBACa,eAAe;IACd,OAAO,CAAC,QAAQ,CAAC,YAAY;gBAAZ,YAAY,EAAE,YAAY;IAEvD;;;OAGG;IAEG,KAAK,CAAoB,QAAQ,EAAE,MAAM,EAAS,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBrF;;;OAGG;IAEG,QAAQ,CACO,QAAQ,EAAE,MAAM,EAEnC,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,EACnF,GAAG,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAA;KAAE,EAChE,GAAG,EAAE,QAAQ,GACnB,OAAO,CAAC,IAAI,CAAC;IA+ChB;;OAEG;IAEG,SAAS,CACC,GAAG,EAAE,MAAM,EAChB,KAAK,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,EAChC,GAAG,EAAE,QAAQ,GACnB,OAAO,CAAC,IAAI,CAAC;IAmBhB;;OAEG;IAEG,YAAY,CACF,GAAG,EAAE,MAAM,EAEzB,IAAI,EAAE;QACJ,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,EACM,GAAG,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAA;KAAE,EAChE,GAAG,EAAE,QAAQ,GACnB,OAAO,CAAC,IAAI,CAAC;IA8BhB;;OAEG;IAEG,YAAY,CAAe,GAAG,EAAE,MAAM,EAAS,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAWlF,OAAO,CAAC,cAAc;CAgBvB"}
|
package/dist/oauth.controller.js
CHANGED
|
@@ -17,8 +17,16 @@ const core_1 = require("@hazeljs/core");
|
|
|
17
17
|
const oauth_service_1 = require("./oauth.service");
|
|
18
18
|
const STATE_COOKIE = 'oauth_state';
|
|
19
19
|
const CODE_VERIFIER_COOKIE = 'oauth_code_verifier';
|
|
20
|
+
const SAML_RELAYSTATE_COOKIE = 'saml_relay_state';
|
|
20
21
|
const COOKIE_MAX_AGE = 60 * 10; // 10 minutes
|
|
21
22
|
const COOKIE_OPTS = `Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`;
|
|
23
|
+
const OAUTH_PROVIDERS = [
|
|
24
|
+
'google',
|
|
25
|
+
'microsoft',
|
|
26
|
+
'github',
|
|
27
|
+
'facebook',
|
|
28
|
+
'twitter',
|
|
29
|
+
];
|
|
22
30
|
function getCookie(req, name) {
|
|
23
31
|
const h = req?.headers?.['cookie'];
|
|
24
32
|
const cookieHeader = Array.isArray(h) ? h[0] : h;
|
|
@@ -41,7 +49,7 @@ let OAuthController = class OAuthController {
|
|
|
41
49
|
*/
|
|
42
50
|
async login(provider, res) {
|
|
43
51
|
const p = provider.toLowerCase();
|
|
44
|
-
if (!
|
|
52
|
+
if (!OAUTH_PROVIDERS.includes(p)) {
|
|
45
53
|
res.status(400).json({ error: 'Invalid provider' });
|
|
46
54
|
return;
|
|
47
55
|
}
|
|
@@ -61,7 +69,7 @@ let OAuthController = class OAuthController {
|
|
|
61
69
|
*/
|
|
62
70
|
async callback(provider, query, req, res) {
|
|
63
71
|
const p = provider.toLowerCase();
|
|
64
|
-
if (!
|
|
72
|
+
if (!OAUTH_PROVIDERS.includes(p)) {
|
|
65
73
|
res.status(400).json({ error: 'Invalid provider' });
|
|
66
74
|
return;
|
|
67
75
|
}
|
|
@@ -98,6 +106,71 @@ let OAuthController = class OAuthController {
|
|
|
98
106
|
this.redirectOrJson(res, 401, query.errorRedirect, { error: message });
|
|
99
107
|
}
|
|
100
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* GET /auth/saml/:idp - Redirects user to SAML IdP login.
|
|
111
|
+
*/
|
|
112
|
+
async samlLogin(idp, query, res) {
|
|
113
|
+
try {
|
|
114
|
+
const { url, relayState } = this.oauthService.getSamlAuthorizationUrl(idp, query.relayState);
|
|
115
|
+
const cookies = [];
|
|
116
|
+
if (relayState) {
|
|
117
|
+
cookies.push(`${SAML_RELAYSTATE_COOKIE}=${relayState}; ${COOKIE_OPTS}`);
|
|
118
|
+
}
|
|
119
|
+
if (cookies.length > 0) {
|
|
120
|
+
res.setHeader('Set-Cookie', cookies);
|
|
121
|
+
}
|
|
122
|
+
res.status(302);
|
|
123
|
+
res.setHeader('Location', url);
|
|
124
|
+
res.end();
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const message = err instanceof Error ? err.message : 'Failed to initiate SAML login';
|
|
128
|
+
res.status(400).json({ error: message });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* POST /auth/saml/:idp/callback - Handles SAML ACS callback.
|
|
133
|
+
*/
|
|
134
|
+
async samlCallback(idp, body, req, res) {
|
|
135
|
+
const samlResponse = body?.SAMLResponse;
|
|
136
|
+
if (!samlResponse) {
|
|
137
|
+
this.redirectOrJson(res, 400, body?.errorRedirect, { error: 'Missing SAMLResponse' });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const relayState = body?.RelayState ?? getCookie(req, SAML_RELAYSTATE_COOKIE);
|
|
141
|
+
res.setHeader('Set-Cookie', [
|
|
142
|
+
`${SAML_RELAYSTATE_COOKIE}=; Path=/; HttpOnly; Max-Age=0`,
|
|
143
|
+
]);
|
|
144
|
+
try {
|
|
145
|
+
const result = this.oauthService.handleSamlCallback(idp, samlResponse, relayState);
|
|
146
|
+
const response = await this.oauthService.executeCallback(`saml:${idp}`, result);
|
|
147
|
+
if (body.successRedirect) {
|
|
148
|
+
res.status(302);
|
|
149
|
+
res.setHeader('Location', body.successRedirect);
|
|
150
|
+
res.end();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
res.status(200).json(response);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
const message = err instanceof Error ? err.message : 'SAML callback failed';
|
|
157
|
+
this.redirectOrJson(res, 401, body?.errorRedirect, { error: message });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* GET /auth/saml/:idp/metadata - Exposes minimal SP metadata.
|
|
162
|
+
*/
|
|
163
|
+
async samlMetadata(idp, res) {
|
|
164
|
+
try {
|
|
165
|
+
const xml = this.oauthService.getSamlMetadata(idp);
|
|
166
|
+
res.setHeader('Content-Type', 'application/samlmetadata+xml; charset=utf-8');
|
|
167
|
+
res.status(200).send(xml);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const message = err instanceof Error ? err.message : 'SAML metadata unavailable';
|
|
171
|
+
res.status(404).json({ error: message });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
101
174
|
redirectOrJson(res, status, errorRedirect, json) {
|
|
102
175
|
if (errorRedirect) {
|
|
103
176
|
const url = new URL(errorRedirect);
|
|
@@ -131,6 +204,33 @@ __decorate([
|
|
|
131
204
|
__metadata("design:paramtypes", [String, Object, Object, Object]),
|
|
132
205
|
__metadata("design:returntype", Promise)
|
|
133
206
|
], OAuthController.prototype, "callback", null);
|
|
207
|
+
__decorate([
|
|
208
|
+
(0, core_1.Get)('/saml/:idp'),
|
|
209
|
+
__param(0, (0, core_1.Param)('idp')),
|
|
210
|
+
__param(1, (0, core_1.Query)()),
|
|
211
|
+
__param(2, (0, core_1.Res)()),
|
|
212
|
+
__metadata("design:type", Function),
|
|
213
|
+
__metadata("design:paramtypes", [String, Object, Object]),
|
|
214
|
+
__metadata("design:returntype", Promise)
|
|
215
|
+
], OAuthController.prototype, "samlLogin", null);
|
|
216
|
+
__decorate([
|
|
217
|
+
(0, core_1.Post)('/saml/:idp/callback'),
|
|
218
|
+
__param(0, (0, core_1.Param)('idp')),
|
|
219
|
+
__param(1, (0, core_1.Body)()),
|
|
220
|
+
__param(2, (0, core_1.Req)()),
|
|
221
|
+
__param(3, (0, core_1.Res)()),
|
|
222
|
+
__metadata("design:type", Function),
|
|
223
|
+
__metadata("design:paramtypes", [String, Object, Object, Object]),
|
|
224
|
+
__metadata("design:returntype", Promise)
|
|
225
|
+
], OAuthController.prototype, "samlCallback", null);
|
|
226
|
+
__decorate([
|
|
227
|
+
(0, core_1.Get)('/saml/:idp/metadata'),
|
|
228
|
+
__param(0, (0, core_1.Param)('idp')),
|
|
229
|
+
__param(1, (0, core_1.Res)()),
|
|
230
|
+
__metadata("design:type", Function),
|
|
231
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
232
|
+
__metadata("design:returntype", Promise)
|
|
233
|
+
], OAuthController.prototype, "samlMetadata", null);
|
|
134
234
|
exports.OAuthController = OAuthController = __decorate([
|
|
135
235
|
(0, core_1.Controller)('/auth'),
|
|
136
236
|
__metadata("design:paramtypes", [oauth_service_1.OAuthService])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.controller.test.d.ts","sourceRoot":"","sources":["../src/oauth.controller.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
jest.mock('./oauth.service', () => ({
|
|
4
|
+
OAuthService: class OAuthService {
|
|
5
|
+
},
|
|
6
|
+
}));
|
|
7
|
+
const oauth_controller_1 = require("./oauth.controller");
|
|
8
|
+
describe('OAuthController', () => {
|
|
9
|
+
function createResponseMock() {
|
|
10
|
+
const res = {
|
|
11
|
+
status: jest.fn().mockReturnThis(),
|
|
12
|
+
json: jest.fn().mockReturnThis(),
|
|
13
|
+
setHeader: jest.fn(),
|
|
14
|
+
end: jest.fn(),
|
|
15
|
+
send: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
return res;
|
|
18
|
+
}
|
|
19
|
+
it('redirects for SAML login route', async () => {
|
|
20
|
+
const oauthService = {
|
|
21
|
+
getSamlAuthorizationUrl: jest.fn().mockReturnValue({
|
|
22
|
+
url: 'https://idp.example.com/sso?SAMLRequest=abc',
|
|
23
|
+
requestId: '_req',
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
const controller = new oauth_controller_1.OAuthController(oauthService);
|
|
27
|
+
const res = createResponseMock();
|
|
28
|
+
await controller.samlLogin('okta-main', {}, res);
|
|
29
|
+
expect(res.status).toHaveBeenCalledWith(302);
|
|
30
|
+
expect(res.setHeader).toHaveBeenCalledWith('Location', 'https://idp.example.com/sso?SAMLRequest=abc');
|
|
31
|
+
});
|
|
32
|
+
it('returns 400 when SAML callback is missing response payload', async () => {
|
|
33
|
+
const oauthService = {};
|
|
34
|
+
const controller = new oauth_controller_1.OAuthController(oauthService);
|
|
35
|
+
const res = createResponseMock();
|
|
36
|
+
await controller.samlCallback('okta-main', {}, {}, res);
|
|
37
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
38
|
+
expect(res.json).toHaveBeenCalledWith({ error: 'Missing SAMLResponse' });
|
|
39
|
+
});
|
|
40
|
+
it('returns metadata xml from service', async () => {
|
|
41
|
+
const oauthService = {
|
|
42
|
+
getSamlMetadata: jest.fn().mockReturnValue('<EntityDescriptor/>'),
|
|
43
|
+
};
|
|
44
|
+
const controller = new oauth_controller_1.OAuthController(oauthService);
|
|
45
|
+
const res = createResponseMock();
|
|
46
|
+
await controller.samlMetadata('okta-main', res);
|
|
47
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
48
|
+
expect(res.send).toHaveBeenCalledWith('<EntityDescriptor/>');
|
|
49
|
+
});
|
|
50
|
+
});
|
package/dist/oauth.service.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OAuthModuleOptions, OAuthCallbackResult, OAuthAuthorizationResult, SupportedProvider } from './providers/provider.types';
|
|
1
|
+
import type { OAuthModuleOptions, OAuthCallbackResult, OAuthProtocolCallbackResult, OAuthAuthorizationResult, SamlCallbackResult, SupportedProvider } from './providers/provider.types';
|
|
2
2
|
export declare class OAuthService {
|
|
3
3
|
private options;
|
|
4
4
|
private googleClient;
|
|
@@ -6,6 +6,7 @@ export declare class OAuthService {
|
|
|
6
6
|
private githubClient;
|
|
7
7
|
private facebookClient;
|
|
8
8
|
private twitterClient;
|
|
9
|
+
private samlProviders;
|
|
9
10
|
constructor();
|
|
10
11
|
private static options;
|
|
11
12
|
static configure(options: OAuthModuleOptions): void;
|
|
@@ -23,6 +24,14 @@ export declare class OAuthService {
|
|
|
23
24
|
* For PKCE providers (Google, Microsoft), codeVerifier is required.
|
|
24
25
|
*/
|
|
25
26
|
handleCallback(provider: SupportedProvider, code: string, state: string, codeVerifier?: string): Promise<OAuthCallbackResult>;
|
|
27
|
+
getSamlAuthorizationUrl(idpKey: string, relayState?: string): {
|
|
28
|
+
url: string;
|
|
29
|
+
requestId: string;
|
|
30
|
+
relayState?: string;
|
|
31
|
+
};
|
|
32
|
+
handleSamlCallback(idpKey: string, samlResponseBase64: string, relayState?: string): SamlCallbackResult;
|
|
33
|
+
getSamlMetadata(idpKey: string): string;
|
|
34
|
+
getSamlProviderKeys(): string[];
|
|
26
35
|
/**
|
|
27
36
|
* Called by OAuthController after a successful token exchange and user-profile fetch.
|
|
28
37
|
*
|
|
@@ -33,7 +42,7 @@ export declare class OAuthService {
|
|
|
33
42
|
* When no handler is configured the raw OAuthCallbackResult is returned unchanged,
|
|
34
43
|
* which preserves backwards-compatible behaviour.
|
|
35
44
|
*/
|
|
36
|
-
executeCallback(provider: SupportedProvider
|
|
45
|
+
executeCallback(provider: SupportedProvider | `saml:${string}`, result: OAuthProtocolCallbackResult): Promise<unknown>;
|
|
37
46
|
/**
|
|
38
47
|
* Validate that the state matches (CSRF protection).
|
|
39
48
|
* Use this when handling the callback to ensure the request originated from your app.
|
|
@@ -43,5 +52,6 @@ export declare class OAuthService {
|
|
|
43
52
|
* Generate a cryptographically secure state value for OAuth.
|
|
44
53
|
*/
|
|
45
54
|
generateState(): string;
|
|
55
|
+
private getSamlProvider;
|
|
46
56
|
}
|
|
47
57
|
//# sourceMappingURL=oauth.service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.service.d.ts","sourceRoot":"","sources":["../src/oauth.service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"oauth.service.d.ts","sourceRoot":"","sources":["../src/oauth.service.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EACV,kBAAkB,EAElB,mBAAmB,EACnB,2BAA2B,EAC3B,wBAAwB,EAExB,kBAAkB,EAClB,iBAAiB,EAElB,MAAM,4BAA4B,CAAC;AAIpC,qBACa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,YAAY,CAAwD;IAC5E,OAAO,CAAC,eAAe,CAA2D;IAClF,OAAO,CAAC,YAAY,CAAwD;IAC5E,OAAO,CAAC,cAAc,CAA0D;IAChF,OAAO,CAAC,aAAa,CAAyD;IAC9E,OAAO,CAAC,aAAa,CAA0C;;IAO/D,OAAO,CAAC,MAAM,CAAC,OAAO,CAAmC;IAEzD,MAAM,CAAC,SAAS,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI;IAInD,OAAO,CAAC,MAAM,CAAC,UAAU;IASzB,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,SAAS;IA8BjB,OAAO,CAAC,gBAAgB;IAmBxB;;;OAGG;IACH,mBAAmB,CACjB,QAAQ,EAAE,iBAAiB,EAC3B,KAAK,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EAAE,GAChB,wBAAwB;IA2B3B;;;OAGG;IACG,cAAc,CAClB,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,mBAAmB,CAAC;IAwF/B,uBAAuB,CACrB,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,MAAM,GAClB;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE;IAQ1D,kBAAkB,CAChB,MAAM,EAAE,MAAM,EACd,kBAAkB,EAAE,MAAM,EAC1B,UAAU,CAAC,EAAE,MAAM,GAClB,kBAAkB;IAMrB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAYvC,mBAAmB,IAAI,MAAM,EAAE;IAI/B;;;;;;;;;OASG;IACG,eAAe,CACnB,QAAQ,EAAE,iBAAiB,GAAG,QAAQ,MAAM,EAAE,EAC9C,MAAM,EAAE,2BAA2B,GAClC,OAAO,CAAC,OAAO,CAAC;IAWnB;;;OAGG;IACH,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO;IAIlE;;OAEG;IACH,aAAa,IAAI,MAAM;IAIvB,OAAO,CAAC,eAAe;CAOxB"}
|
package/dist/oauth.service.js
CHANGED
|
@@ -55,6 +55,7 @@ let OAuthService = OAuthService_1 = class OAuthService {
|
|
|
55
55
|
this.githubClient = null;
|
|
56
56
|
this.facebookClient = null;
|
|
57
57
|
this.twitterClient = null;
|
|
58
|
+
this.samlProviders = {};
|
|
58
59
|
this.options = OAuthService_1.getOptions();
|
|
59
60
|
this.initClients();
|
|
60
61
|
}
|
|
@@ -83,6 +84,7 @@ let OAuthService = OAuthService_1 = class OAuthService {
|
|
|
83
84
|
if (this.options.providers.twitter) {
|
|
84
85
|
this.twitterClient = (0, providers_1.createTwitterProvider)(this.options.providers.twitter);
|
|
85
86
|
}
|
|
87
|
+
this.samlProviders = this.options.providers.saml ?? {};
|
|
86
88
|
}
|
|
87
89
|
getClient(provider) {
|
|
88
90
|
switch (provider) {
|
|
@@ -211,12 +213,39 @@ let OAuthService = OAuthService_1 = class OAuthService {
|
|
|
211
213
|
throw new Error(`Unsupported provider: ${provider}`);
|
|
212
214
|
}
|
|
213
215
|
return {
|
|
216
|
+
protocol: 'oauth2',
|
|
214
217
|
accessToken,
|
|
215
218
|
refreshToken,
|
|
216
219
|
expiresAt,
|
|
217
220
|
user,
|
|
218
221
|
};
|
|
219
222
|
}
|
|
223
|
+
getSamlAuthorizationUrl(idpKey, relayState) {
|
|
224
|
+
const provider = this.getSamlProvider(idpKey);
|
|
225
|
+
if (relayState) {
|
|
226
|
+
return (0, providers_1.createSamlAuthnRequest)({ ...provider, relayState });
|
|
227
|
+
}
|
|
228
|
+
return (0, providers_1.createSamlAuthnRequest)(provider);
|
|
229
|
+
}
|
|
230
|
+
handleSamlCallback(idpKey, samlResponseBase64, relayState) {
|
|
231
|
+
const provider = this.getSamlProvider(idpKey);
|
|
232
|
+
const parsed = (0, providers_1.parseSamlResponse)(provider, samlResponseBase64, relayState);
|
|
233
|
+
return (0, providers_1.buildSamlCallbackResult)(idpKey, parsed, relayState, samlResponseBase64);
|
|
234
|
+
}
|
|
235
|
+
getSamlMetadata(idpKey) {
|
|
236
|
+
const provider = this.getSamlProvider(idpKey);
|
|
237
|
+
const acs = provider.acsUrl.replace(/"/g, '"');
|
|
238
|
+
const issuer = provider.issuer.replace(/"/g, '"');
|
|
239
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
240
|
+
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${issuer}">
|
|
241
|
+
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
242
|
+
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${acs}" index="0" isDefault="true"/>
|
|
243
|
+
</SPSSODescriptor>
|
|
244
|
+
</EntityDescriptor>`;
|
|
245
|
+
}
|
|
246
|
+
getSamlProviderKeys() {
|
|
247
|
+
return Object.keys(this.samlProviders);
|
|
248
|
+
}
|
|
220
249
|
/**
|
|
221
250
|
* Called by OAuthController after a successful token exchange and user-profile fetch.
|
|
222
251
|
*
|
|
@@ -247,6 +276,13 @@ let OAuthService = OAuthService_1 = class OAuthService {
|
|
|
247
276
|
generateState() {
|
|
248
277
|
return arctic.generateState();
|
|
249
278
|
}
|
|
279
|
+
getSamlProvider(idpKey) {
|
|
280
|
+
const provider = this.samlProviders[idpKey];
|
|
281
|
+
if (!provider) {
|
|
282
|
+
throw new Error(`SAML provider "${idpKey}" is not configured`);
|
|
283
|
+
}
|
|
284
|
+
return provider;
|
|
285
|
+
}
|
|
250
286
|
};
|
|
251
287
|
exports.OAuthService = OAuthService;
|
|
252
288
|
OAuthService.options = null;
|
|
@@ -101,6 +101,15 @@ describe('OAuthService', () => {
|
|
|
101
101
|
facebook: { ...baseConfig },
|
|
102
102
|
microsoft: { ...baseConfig },
|
|
103
103
|
twitter: { ...baseConfig },
|
|
104
|
+
saml: {
|
|
105
|
+
'okta-main': {
|
|
106
|
+
idpKey: 'okta-main',
|
|
107
|
+
ssoUrl: 'https://idp.example.com/sso',
|
|
108
|
+
issuer: 'https://app.example.com',
|
|
109
|
+
acsUrl: 'https://app.example.com/auth/saml/okta-main/callback',
|
|
110
|
+
audience: 'https://app.example.com',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
104
113
|
},
|
|
105
114
|
});
|
|
106
115
|
mockFetch.mockResolvedValue({
|
|
@@ -190,6 +199,7 @@ describe('OAuthService', () => {
|
|
|
190
199
|
}),
|
|
191
200
|
});
|
|
192
201
|
const result = await service.handleCallback('github', 'auth-code', 'mock-state-123');
|
|
202
|
+
expect(result.protocol).toBe('oauth2');
|
|
193
203
|
expect(result.accessToken).toBe('mock-access-token');
|
|
194
204
|
expect(result.user.id).toBe('12345');
|
|
195
205
|
expect(result.user.email).toBe('github@example.com');
|
|
@@ -245,4 +255,74 @@ describe('OAuthService', () => {
|
|
|
245
255
|
await expect(service.handleCallback('facebook', 'code', 'state')).rejects.toThrow('Facebook OAuth is not configured');
|
|
246
256
|
});
|
|
247
257
|
});
|
|
258
|
+
describe('SAML support', () => {
|
|
259
|
+
it('returns SAML authorization URL for configured IdP', () => {
|
|
260
|
+
const service = new oauth_service_1.OAuthService();
|
|
261
|
+
const result = service.getSamlAuthorizationUrl('okta-main');
|
|
262
|
+
expect(result.url).toContain('idp.example.com/sso');
|
|
263
|
+
expect(result.url).toContain('SAMLRequest=');
|
|
264
|
+
expect(result.requestId).toMatch(/^_/);
|
|
265
|
+
});
|
|
266
|
+
it('throws for unknown SAML provider key', () => {
|
|
267
|
+
const service = new oauth_service_1.OAuthService();
|
|
268
|
+
expect(() => service.getSamlAuthorizationUrl('unknown-idp')).toThrow('not configured');
|
|
269
|
+
});
|
|
270
|
+
it('returns provider keys for configured SAML providers', () => {
|
|
271
|
+
const service = new oauth_service_1.OAuthService();
|
|
272
|
+
expect(service.getSamlProviderKeys()).toContain('okta-main');
|
|
273
|
+
});
|
|
274
|
+
it('returns metadata XML for configured SAML provider', () => {
|
|
275
|
+
const service = new oauth_service_1.OAuthService();
|
|
276
|
+
const xml = service.getSamlMetadata('okta-main');
|
|
277
|
+
expect(xml).toContain('EntityDescriptor');
|
|
278
|
+
expect(xml).toContain('AssertionConsumerService');
|
|
279
|
+
});
|
|
280
|
+
it('parses successful SAML callback payload', () => {
|
|
281
|
+
const service = new oauth_service_1.OAuthService();
|
|
282
|
+
const samlResponseXml = `
|
|
283
|
+
<samlp:Response InResponseTo="_req123">
|
|
284
|
+
<samlp:Status>
|
|
285
|
+
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
286
|
+
</samlp:Status>
|
|
287
|
+
<saml:Assertion>
|
|
288
|
+
<saml:Conditions>
|
|
289
|
+
<saml:AudienceRestriction>
|
|
290
|
+
<saml:Audience>https://app.example.com</saml:Audience>
|
|
291
|
+
</saml:AudienceRestriction>
|
|
292
|
+
</saml:Conditions>
|
|
293
|
+
<saml:Subject>
|
|
294
|
+
<saml:NameID>user-001</saml:NameID>
|
|
295
|
+
</saml:Subject>
|
|
296
|
+
<saml:AuthnStatement SessionIndex="session-1"/>
|
|
297
|
+
<saml:AttributeStatement>
|
|
298
|
+
<saml:Attribute Name="email">
|
|
299
|
+
<saml:AttributeValue>user@example.com</saml:AttributeValue>
|
|
300
|
+
</saml:Attribute>
|
|
301
|
+
<saml:Attribute Name="name">
|
|
302
|
+
<saml:AttributeValue>Saml User</saml:AttributeValue>
|
|
303
|
+
</saml:Attribute>
|
|
304
|
+
</saml:AttributeStatement>
|
|
305
|
+
</saml:Assertion>
|
|
306
|
+
</samlp:Response>
|
|
307
|
+
`;
|
|
308
|
+
const encoded = Buffer.from(samlResponseXml, 'utf8').toString('base64');
|
|
309
|
+
const result = service.handleSamlCallback('okta-main', encoded, 'relay-123');
|
|
310
|
+
expect(result.protocol).toBe('saml');
|
|
311
|
+
expect(result.user.email).toBe('user@example.com');
|
|
312
|
+
expect(result.nameId).toBe('user-001');
|
|
313
|
+
expect(result.relayState).toBe('relay-123');
|
|
314
|
+
});
|
|
315
|
+
it('throws when SAML callback is unsuccessful', () => {
|
|
316
|
+
const service = new oauth_service_1.OAuthService();
|
|
317
|
+
const badXml = `
|
|
318
|
+
<samlp:Response>
|
|
319
|
+
<samlp:Status>
|
|
320
|
+
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Requester"/>
|
|
321
|
+
</samlp:Status>
|
|
322
|
+
</samlp:Response>
|
|
323
|
+
`;
|
|
324
|
+
const encoded = Buffer.from(badXml, 'utf8').toString('base64');
|
|
325
|
+
expect(() => service.handleSamlCallback('okta-main', encoded)).toThrow('not successful');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
248
328
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/providers/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/providers/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC"}
|
package/dist/providers/index.js
CHANGED
|
@@ -20,3 +20,4 @@ __exportStar(require("./microsoft.provider"), exports);
|
|
|
20
20
|
__exportStar(require("./github.provider"), exports);
|
|
21
21
|
__exportStar(require("./facebook.provider"), exports);
|
|
22
22
|
__exportStar(require("./twitter.provider"), exports);
|
|
23
|
+
__exportStar(require("./saml.provider"), exports);
|
|
@@ -27,13 +27,55 @@ export type OAuthProviderConfig = {
|
|
|
27
27
|
facebook: FacebookProviderConfig;
|
|
28
28
|
} | {
|
|
29
29
|
twitter: TwitterProviderConfig;
|
|
30
|
+
} | {
|
|
31
|
+
saml: SamlProviderConfig;
|
|
30
32
|
};
|
|
33
|
+
export interface SamlProviderConfig {
|
|
34
|
+
/**
|
|
35
|
+
* IdP key used in routes: /auth/saml/:idpKey/*
|
|
36
|
+
*/
|
|
37
|
+
idpKey: string;
|
|
38
|
+
/**
|
|
39
|
+
* SSO URL of the Identity Provider.
|
|
40
|
+
*/
|
|
41
|
+
ssoUrl: string;
|
|
42
|
+
/**
|
|
43
|
+
* Issuer/entity ID for your HazelJS app (SP).
|
|
44
|
+
*/
|
|
45
|
+
issuer: string;
|
|
46
|
+
/**
|
|
47
|
+
* ACS endpoint URL configured in IdP.
|
|
48
|
+
*/
|
|
49
|
+
acsUrl: string;
|
|
50
|
+
/**
|
|
51
|
+
* IdP certificate (PEM). Required for signature validation in production.
|
|
52
|
+
*/
|
|
53
|
+
idpCertificate?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Optional explicit audience check. Defaults to `issuer`.
|
|
56
|
+
*/
|
|
57
|
+
audience?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Optional IdP metadata URL for convenience/documentation.
|
|
60
|
+
*/
|
|
61
|
+
metadataUrl?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Optional default relay state.
|
|
64
|
+
*/
|
|
65
|
+
relayState?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Optional allowed clock skew for time validations.
|
|
68
|
+
* @default 120
|
|
69
|
+
*/
|
|
70
|
+
clockSkewSec?: number;
|
|
71
|
+
}
|
|
31
72
|
export interface OAuthProvidersConfig {
|
|
32
73
|
google?: GoogleProviderConfig;
|
|
33
74
|
microsoft?: MicrosoftProviderConfig;
|
|
34
75
|
github?: GitHubProviderConfig;
|
|
35
76
|
facebook?: FacebookProviderConfig;
|
|
36
77
|
twitter?: TwitterProviderConfig;
|
|
78
|
+
saml?: Record<string, SamlProviderConfig>;
|
|
37
79
|
}
|
|
38
80
|
export type SupportedProvider = 'google' | 'microsoft' | 'github' | 'facebook' | 'twitter';
|
|
39
81
|
export interface OAuthUser {
|
|
@@ -43,11 +85,29 @@ export interface OAuthUser {
|
|
|
43
85
|
picture?: string | null;
|
|
44
86
|
}
|
|
45
87
|
export interface OAuthCallbackResult {
|
|
88
|
+
protocol?: 'oauth2';
|
|
46
89
|
accessToken: string;
|
|
47
90
|
refreshToken?: string;
|
|
48
91
|
expiresAt?: Date;
|
|
49
92
|
user: OAuthUser;
|
|
50
93
|
}
|
|
94
|
+
export interface SamlUser {
|
|
95
|
+
id: string;
|
|
96
|
+
email: string;
|
|
97
|
+
name: string | null;
|
|
98
|
+
attributes: Record<string, string | string[]>;
|
|
99
|
+
}
|
|
100
|
+
export interface SamlCallbackResult {
|
|
101
|
+
protocol: 'saml';
|
|
102
|
+
provider: string;
|
|
103
|
+
user: SamlUser;
|
|
104
|
+
nameId: string;
|
|
105
|
+
sessionIndex?: string;
|
|
106
|
+
relayState?: string;
|
|
107
|
+
inResponseTo?: string;
|
|
108
|
+
rawAssertion?: string;
|
|
109
|
+
}
|
|
110
|
+
export type OAuthProtocolCallbackResult = OAuthCallbackResult | SamlCallbackResult;
|
|
51
111
|
export interface OAuthAuthorizationResult {
|
|
52
112
|
url: string;
|
|
53
113
|
state: string;
|
|
@@ -78,7 +138,7 @@ export interface OAuthAuthorizationResult {
|
|
|
78
138
|
* ```
|
|
79
139
|
*/
|
|
80
140
|
export interface OAuthCallbackHandler {
|
|
81
|
-
handle(result:
|
|
141
|
+
handle(result: OAuthProtocolCallbackResult, provider: SupportedProvider | `saml:${string}`): Promise<unknown> | unknown;
|
|
82
142
|
}
|
|
83
143
|
export interface OAuthModuleOptions {
|
|
84
144
|
providers: OAuthProvidersConfig;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.types.d.ts","sourceRoot":"","sources":["../../src/providers/provider.types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,oBAAoB,GAAG,kBAAkB,CAAC;AAEtD,MAAM,WAAW,uBAAwB,SAAQ,kBAAkB;IACjE,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,oBAAoB,GAAG,kBAAkB,CAAC;AAEtD,MAAM,MAAM,sBAAsB,GAAG,kBAAkB,CAAC;AAExD,MAAM,WAAW,qBAAsB,SAAQ,IAAI,CAAC,kBAAkB,EAAE,cAAc,CAAC;IACrF,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAE,MAAM,EAAE,oBAAoB,CAAA;CAAE,GAChC;IAAE,SAAS,EAAE,uBAAuB,CAAA;CAAE,GACtC;IAAE,MAAM,EAAE,oBAAoB,CAAA;CAAE,GAChC;IAAE,QAAQ,EAAE,sBAAsB,CAAA;CAAE,GACpC;IAAE,OAAO,EAAE,qBAAqB,CAAA;CAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"provider.types.d.ts","sourceRoot":"","sources":["../../src/providers/provider.types.ts"],"names":[],"mappings":"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"}
|
|
@@ -37,11 +37,38 @@ describe('Provider Types', () => {
|
|
|
37
37
|
});
|
|
38
38
|
it('should define OAuthCallbackResult shape', () => {
|
|
39
39
|
const result = {
|
|
40
|
+
protocol: 'oauth2',
|
|
40
41
|
accessToken: 'token',
|
|
41
42
|
user: { id: '1', email: 'e@e.com', name: 'N' },
|
|
42
43
|
};
|
|
43
44
|
expect(result.accessToken).toBe('token');
|
|
44
45
|
});
|
|
46
|
+
it('should define SamlProviderConfig shape', () => {
|
|
47
|
+
const config = {
|
|
48
|
+
idpKey: 'okta-main',
|
|
49
|
+
ssoUrl: 'https://idp.example.com/sso',
|
|
50
|
+
issuer: 'https://app.example.com',
|
|
51
|
+
acsUrl: 'https://app.example.com/auth/saml/okta-main/callback',
|
|
52
|
+
audience: 'https://app.example.com',
|
|
53
|
+
};
|
|
54
|
+
expect(config.idpKey).toBe('okta-main');
|
|
55
|
+
});
|
|
56
|
+
it('should define SamlCallbackResult shape', () => {
|
|
57
|
+
const result = {
|
|
58
|
+
protocol: 'saml',
|
|
59
|
+
provider: 'okta-main',
|
|
60
|
+
nameId: 'name-id',
|
|
61
|
+
user: {
|
|
62
|
+
id: 'user-id',
|
|
63
|
+
email: 'saml@example.com',
|
|
64
|
+
name: 'Saml User',
|
|
65
|
+
attributes: {
|
|
66
|
+
email: 'saml@example.com',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
expect(result.protocol).toBe('saml');
|
|
71
|
+
});
|
|
45
72
|
it('should define OAuthAuthorizationResult shape', () => {
|
|
46
73
|
const result = {
|
|
47
74
|
url: 'https://provider.com',
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SamlCallbackResult, SamlProviderConfig } from './provider.types';
|
|
2
|
+
export interface SamlAuthorizationResult {
|
|
3
|
+
url: string;
|
|
4
|
+
requestId: string;
|
|
5
|
+
relayState?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsedSamlAssertion {
|
|
8
|
+
nameId: string;
|
|
9
|
+
email: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
attributes: Record<string, string | string[]>;
|
|
12
|
+
sessionIndex?: string;
|
|
13
|
+
inResponseTo?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function createSamlAuthnRequest(config: SamlProviderConfig): SamlAuthorizationResult;
|
|
16
|
+
export declare function parseSamlResponse(config: SamlProviderConfig, samlResponseBase64: string, _relayState?: string): ParsedSamlAssertion;
|
|
17
|
+
export declare function buildSamlCallbackResult(provider: string, parsed: ParsedSamlAssertion, relayState: string | undefined, rawAssertion: string): SamlCallbackResult;
|
|
18
|
+
//# sourceMappingURL=saml.provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saml.provider.d.ts","sourceRoot":"","sources":["../../src/providers/saml.provider.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAsC/E,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,kBAAkB,GAAG,uBAAuB,CAuB1F;AAED,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,kBAAkB,EAC1B,kBAAkB,EAAE,MAAM,EAC1B,WAAW,CAAC,EAAE,MAAM,GACnB,mBAAmB,CAsErB;AAED,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,mBAAmB,EAC3B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,YAAY,EAAE,MAAM,GACnB,kBAAkB,CAkBpB"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSamlAuthnRequest = createSamlAuthnRequest;
|
|
4
|
+
exports.parseSamlResponse = parseSamlResponse;
|
|
5
|
+
exports.buildSamlCallbackResult = buildSamlCallbackResult;
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
function encodeXml(value) {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/'/g, ''');
|
|
14
|
+
}
|
|
15
|
+
function decodeBase64(input) {
|
|
16
|
+
return Buffer.from(input, 'base64').toString('utf8');
|
|
17
|
+
}
|
|
18
|
+
function firstMatch(xml, pattern) {
|
|
19
|
+
const match = pattern.exec(xml);
|
|
20
|
+
return match?.[1];
|
|
21
|
+
}
|
|
22
|
+
function allAttributeValues(xml, attributeName) {
|
|
23
|
+
const escapedName = attributeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
const pattern = new RegExp(`<[^>]*Attribute[^>]*Name=["']${escapedName}["'][^>]*>([\\s\\S]*?)</[^>]*Attribute>`, 'i');
|
|
25
|
+
const block = firstMatch(xml, pattern);
|
|
26
|
+
if (!block)
|
|
27
|
+
return [];
|
|
28
|
+
const values = [];
|
|
29
|
+
const valuePattern = /<[^>]*AttributeValue[^>]*>([\s\S]*?)<\/[^>]*AttributeValue>/gi;
|
|
30
|
+
let valueMatch = valuePattern.exec(block);
|
|
31
|
+
while (valueMatch) {
|
|
32
|
+
values.push(valueMatch[1].trim());
|
|
33
|
+
valueMatch = valuePattern.exec(block);
|
|
34
|
+
}
|
|
35
|
+
return values;
|
|
36
|
+
}
|
|
37
|
+
function createSamlAuthnRequest(config) {
|
|
38
|
+
const requestId = `_${(0, crypto_1.randomBytes)(16).toString('hex')}`;
|
|
39
|
+
const instant = new Date().toISOString();
|
|
40
|
+
const issueInstant = instant.replace(/\.\d{3}Z$/, 'Z');
|
|
41
|
+
const destination = encodeXml(config.ssoUrl);
|
|
42
|
+
const assertionConsumerServiceURL = encodeXml(config.acsUrl);
|
|
43
|
+
const issuer = encodeXml(config.issuer);
|
|
44
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
45
|
+
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="${requestId}" Version="2.0" IssueInstant="${issueInstant}" Destination="${destination}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="${assertionConsumerServiceURL}">
|
|
46
|
+
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${issuer}</saml:Issuer>
|
|
47
|
+
<samlp:NameIDPolicy AllowCreate="true"/>
|
|
48
|
+
</samlp:AuthnRequest>`;
|
|
49
|
+
const samlRequest = Buffer.from(xml, 'utf8').toString('base64');
|
|
50
|
+
const url = new URL(config.ssoUrl);
|
|
51
|
+
url.searchParams.set('SAMLRequest', samlRequest);
|
|
52
|
+
if (config.relayState) {
|
|
53
|
+
url.searchParams.set('RelayState', config.relayState);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
url: url.toString(),
|
|
57
|
+
requestId,
|
|
58
|
+
relayState: config.relayState,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function parseSamlResponse(config, samlResponseBase64, _relayState) {
|
|
62
|
+
const xml = decodeBase64(samlResponseBase64);
|
|
63
|
+
const statusCode = firstMatch(xml, /<[^>]*StatusCode[^>]*Value=["']([^"']+)["']/i);
|
|
64
|
+
if (!statusCode || !statusCode.endsWith(':Success')) {
|
|
65
|
+
throw new Error(`SAML response is not successful (${statusCode || 'unknown status'})`);
|
|
66
|
+
}
|
|
67
|
+
const audience = firstMatch(xml, /<[^>]*Audience\b[^>]*>\s*([^<]+)\s*<\/[^>]*Audience>/i)?.trim();
|
|
68
|
+
const expectedAudience = config.audience || config.issuer;
|
|
69
|
+
if (audience && audience !== expectedAudience) {
|
|
70
|
+
throw new Error('SAML audience mismatch');
|
|
71
|
+
}
|
|
72
|
+
const nameId = firstMatch(xml, /<[^>]*NameID[^>]*>([\s\S]*?)<\/[^>]*NameID>/i)?.trim() || '';
|
|
73
|
+
if (!nameId) {
|
|
74
|
+
throw new Error('SAML NameID is missing');
|
|
75
|
+
}
|
|
76
|
+
const sessionIndex = firstMatch(xml, /<[^>]*AuthnStatement[^>]*SessionIndex=["']([^"']+)["']/i);
|
|
77
|
+
const inResponseTo = firstMatch(xml, /<[^>]*Response[^>]*InResponseTo=["']([^"']+)["']/i);
|
|
78
|
+
const emailCandidates = [
|
|
79
|
+
'email',
|
|
80
|
+
'emailaddress',
|
|
81
|
+
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
|
82
|
+
];
|
|
83
|
+
const nameCandidates = [
|
|
84
|
+
'name',
|
|
85
|
+
'displayname',
|
|
86
|
+
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
|
|
87
|
+
'givenname',
|
|
88
|
+
];
|
|
89
|
+
const attributes = {};
|
|
90
|
+
const attrBlockPattern = /<[^>]*Attribute[^>]*Name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/[^>]*Attribute>/gi;
|
|
91
|
+
let attrMatch = attrBlockPattern.exec(xml);
|
|
92
|
+
while (attrMatch) {
|
|
93
|
+
const attrName = attrMatch[1];
|
|
94
|
+
const values = allAttributeValues(xml, attrName);
|
|
95
|
+
if (values.length === 1) {
|
|
96
|
+
attributes[attrName] = values[0];
|
|
97
|
+
}
|
|
98
|
+
else if (values.length > 1) {
|
|
99
|
+
attributes[attrName] = values;
|
|
100
|
+
}
|
|
101
|
+
attrMatch = attrBlockPattern.exec(xml);
|
|
102
|
+
}
|
|
103
|
+
const email = emailCandidates
|
|
104
|
+
.map((key) => attributes[key])
|
|
105
|
+
.map((value) => (Array.isArray(value) ? value[0] : value))
|
|
106
|
+
.find((value) => typeof value === 'string' && value.length > 0) || '';
|
|
107
|
+
const nameValue = nameCandidates
|
|
108
|
+
.map((key) => attributes[key])
|
|
109
|
+
.map((value) => (Array.isArray(value) ? value[0] : value))
|
|
110
|
+
.find((value) => typeof value === 'string' && value.length > 0);
|
|
111
|
+
if (!email) {
|
|
112
|
+
throw new Error('SAML response does not include an email attribute');
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
nameId,
|
|
116
|
+
email,
|
|
117
|
+
name: nameValue || null,
|
|
118
|
+
attributes,
|
|
119
|
+
sessionIndex,
|
|
120
|
+
inResponseTo,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function buildSamlCallbackResult(provider, parsed, relayState, rawAssertion) {
|
|
124
|
+
const userId = parsed.nameId || parsed.email;
|
|
125
|
+
const stableId = (0, crypto_1.createHash)('sha256').update(`${provider}:${userId}`).digest('hex');
|
|
126
|
+
return {
|
|
127
|
+
protocol: 'saml',
|
|
128
|
+
provider,
|
|
129
|
+
user: {
|
|
130
|
+
id: stableId,
|
|
131
|
+
email: parsed.email,
|
|
132
|
+
name: parsed.name,
|
|
133
|
+
attributes: parsed.attributes,
|
|
134
|
+
},
|
|
135
|
+
nameId: parsed.nameId,
|
|
136
|
+
sessionIndex: parsed.sessionIndex,
|
|
137
|
+
relayState,
|
|
138
|
+
inResponseTo: parsed.inResponseTo,
|
|
139
|
+
rawAssertion,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saml.provider.test.d.ts","sourceRoot":"","sources":["../../src/providers/saml.provider.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const saml_provider_1 = require("./saml.provider");
|
|
4
|
+
describe('SAML provider helpers', () => {
|
|
5
|
+
const config = {
|
|
6
|
+
idpKey: 'okta-main',
|
|
7
|
+
ssoUrl: 'https://idp.example.com/sso',
|
|
8
|
+
issuer: 'https://app.example.com',
|
|
9
|
+
acsUrl: 'https://app.example.com/auth/saml/okta-main/callback',
|
|
10
|
+
audience: 'https://app.example.com',
|
|
11
|
+
};
|
|
12
|
+
function encode(xml) {
|
|
13
|
+
return Buffer.from(xml, 'utf8').toString('base64');
|
|
14
|
+
}
|
|
15
|
+
it('creates SAML AuthnRequest URL with request id', () => {
|
|
16
|
+
const result = (0, saml_provider_1.createSamlAuthnRequest)(config);
|
|
17
|
+
expect(result.url).toContain('https://idp.example.com/sso');
|
|
18
|
+
expect(result.url).toContain('SAMLRequest=');
|
|
19
|
+
expect(result.requestId).toMatch(/^_/);
|
|
20
|
+
});
|
|
21
|
+
it('includes relay state when configured', () => {
|
|
22
|
+
const result = (0, saml_provider_1.createSamlAuthnRequest)({ ...config, relayState: 'app=dashboard' });
|
|
23
|
+
expect(result.url).toContain('RelayState=app%3Ddashboard');
|
|
24
|
+
expect(result.relayState).toBe('app=dashboard');
|
|
25
|
+
});
|
|
26
|
+
it('parses successful SAML assertion', () => {
|
|
27
|
+
const xml = `
|
|
28
|
+
<samlp:Response InResponseTo="_req123">
|
|
29
|
+
<samlp:Status>
|
|
30
|
+
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
31
|
+
</samlp:Status>
|
|
32
|
+
<saml:Assertion>
|
|
33
|
+
<saml:Conditions>
|
|
34
|
+
<saml:AudienceRestriction>
|
|
35
|
+
<saml:Audience>https://app.example.com</saml:Audience>
|
|
36
|
+
</saml:AudienceRestriction>
|
|
37
|
+
</saml:Conditions>
|
|
38
|
+
<saml:Subject>
|
|
39
|
+
<saml:NameID>nameid-1</saml:NameID>
|
|
40
|
+
</saml:Subject>
|
|
41
|
+
<saml:AuthnStatement SessionIndex="session-1"/>
|
|
42
|
+
<saml:AttributeStatement>
|
|
43
|
+
<saml:Attribute Name="email">
|
|
44
|
+
<saml:AttributeValue>user@example.com</saml:AttributeValue>
|
|
45
|
+
</saml:Attribute>
|
|
46
|
+
<saml:Attribute Name="name">
|
|
47
|
+
<saml:AttributeValue>Demo User</saml:AttributeValue>
|
|
48
|
+
</saml:Attribute>
|
|
49
|
+
<saml:Attribute Name="groups">
|
|
50
|
+
<saml:AttributeValue>admin</saml:AttributeValue>
|
|
51
|
+
<saml:AttributeValue>ops</saml:AttributeValue>
|
|
52
|
+
</saml:Attribute>
|
|
53
|
+
</saml:AttributeStatement>
|
|
54
|
+
</saml:Assertion>
|
|
55
|
+
</samlp:Response>
|
|
56
|
+
`;
|
|
57
|
+
const parsed = (0, saml_provider_1.parseSamlResponse)(config, encode(xml));
|
|
58
|
+
expect(parsed.nameId).toBe('nameid-1');
|
|
59
|
+
expect(parsed.email).toBe('user@example.com');
|
|
60
|
+
expect(parsed.name).toBe('Demo User');
|
|
61
|
+
expect(parsed.sessionIndex).toBe('session-1');
|
|
62
|
+
expect(parsed.inResponseTo).toBe('_req123');
|
|
63
|
+
expect(parsed.attributes.groups).toEqual(['admin', 'ops']);
|
|
64
|
+
});
|
|
65
|
+
it('throws on non-success status', () => {
|
|
66
|
+
const xml = `
|
|
67
|
+
<samlp:Response>
|
|
68
|
+
<samlp:Status>
|
|
69
|
+
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Requester"/>
|
|
70
|
+
</samlp:Status>
|
|
71
|
+
</samlp:Response>
|
|
72
|
+
`;
|
|
73
|
+
expect(() => (0, saml_provider_1.parseSamlResponse)(config, encode(xml))).toThrow('not successful');
|
|
74
|
+
});
|
|
75
|
+
it('throws on audience mismatch', () => {
|
|
76
|
+
const xml = `
|
|
77
|
+
<samlp:Response>
|
|
78
|
+
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
|
|
79
|
+
<saml:Assertion>
|
|
80
|
+
<saml:Conditions>
|
|
81
|
+
<saml:AudienceRestriction>
|
|
82
|
+
<saml:Audience>https://wrong.example.com</saml:Audience>
|
|
83
|
+
</saml:AudienceRestriction>
|
|
84
|
+
</saml:Conditions>
|
|
85
|
+
<saml:Subject><saml:NameID>nameid-1</saml:NameID></saml:Subject>
|
|
86
|
+
<saml:AttributeStatement>
|
|
87
|
+
<saml:Attribute Name="email"><saml:AttributeValue>user@example.com</saml:AttributeValue></saml:Attribute>
|
|
88
|
+
</saml:AttributeStatement>
|
|
89
|
+
</saml:Assertion>
|
|
90
|
+
</samlp:Response>
|
|
91
|
+
`;
|
|
92
|
+
expect(() => (0, saml_provider_1.parseSamlResponse)(config, encode(xml))).toThrow('audience mismatch');
|
|
93
|
+
});
|
|
94
|
+
it('throws when NameID is missing', () => {
|
|
95
|
+
const xml = `
|
|
96
|
+
<samlp:Response>
|
|
97
|
+
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
|
|
98
|
+
<saml:Assertion>
|
|
99
|
+
<saml:AttributeStatement>
|
|
100
|
+
<saml:Attribute Name="email"><saml:AttributeValue>user@example.com</saml:AttributeValue></saml:Attribute>
|
|
101
|
+
</saml:AttributeStatement>
|
|
102
|
+
</saml:Assertion>
|
|
103
|
+
</samlp:Response>
|
|
104
|
+
`;
|
|
105
|
+
expect(() => (0, saml_provider_1.parseSamlResponse)(config, encode(xml))).toThrow('NameID');
|
|
106
|
+
});
|
|
107
|
+
it('throws when email attribute is missing', () => {
|
|
108
|
+
const xml = `
|
|
109
|
+
<samlp:Response>
|
|
110
|
+
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
|
|
111
|
+
<saml:Assertion>
|
|
112
|
+
<saml:Subject><saml:NameID>nameid-1</saml:NameID></saml:Subject>
|
|
113
|
+
<saml:AttributeStatement>
|
|
114
|
+
<saml:Attribute Name="displayname"><saml:AttributeValue>No Email User</saml:AttributeValue></saml:Attribute>
|
|
115
|
+
</saml:AttributeStatement>
|
|
116
|
+
</saml:Assertion>
|
|
117
|
+
</samlp:Response>
|
|
118
|
+
`;
|
|
119
|
+
expect(() => (0, saml_provider_1.parseSamlResponse)(config, encode(xml))).toThrow('email attribute');
|
|
120
|
+
});
|
|
121
|
+
it('builds SAML callback result with stable hashed user id', () => {
|
|
122
|
+
const parsed = {
|
|
123
|
+
nameId: 'name-id-abc',
|
|
124
|
+
email: 'user@example.com',
|
|
125
|
+
name: 'User',
|
|
126
|
+
attributes: { role: 'admin' },
|
|
127
|
+
sessionIndex: 'session-1',
|
|
128
|
+
inResponseTo: '_req-1',
|
|
129
|
+
};
|
|
130
|
+
const result = (0, saml_provider_1.buildSamlCallbackResult)('okta-main', parsed, 'relay-x', 'raw-assertion');
|
|
131
|
+
expect(result.protocol).toBe('saml');
|
|
132
|
+
expect(result.provider).toBe('okta-main');
|
|
133
|
+
expect(result.user.email).toBe('user@example.com');
|
|
134
|
+
expect(result.user.id).toHaveLength(64);
|
|
135
|
+
expect(result.relayState).toBe('relay-x');
|
|
136
|
+
});
|
|
137
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hazeljs/oauth",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "OAuth 2.0
|
|
3
|
+
"version": "0.7.9",
|
|
4
|
+
"description": "OAuth 2.0 and SAML SSO module for HazelJS - Google, Microsoft, GitHub, and enterprise IdPs",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"google",
|
|
42
42
|
"microsoft",
|
|
43
43
|
"github",
|
|
44
|
-
"social-login"
|
|
44
|
+
"social-login",
|
|
45
|
+
"saml",
|
|
46
|
+
"sso"
|
|
45
47
|
],
|
|
46
48
|
"author": "Muhammad Arslan <muhammad.arslan@hazeljs.ai>",
|
|
47
49
|
"license": "Apache-2.0",
|
|
@@ -52,5 +54,5 @@
|
|
|
52
54
|
"peerDependencies": {
|
|
53
55
|
"@hazeljs/core": ">=0.2.0-beta.0"
|
|
54
56
|
},
|
|
55
|
-
"gitHead": "
|
|
57
|
+
"gitHead": "28c21c509aeca3bf2d0878fbee737d906b654c67"
|
|
56
58
|
}
|