@stamhoofd/backend 2.70.0 → 2.71.0
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/package.json +10 -10
- package/src/endpoints/auth/OpenIDConnectCallbackEndpoint.ts +4 -25
- package/src/endpoints/auth/OpenIDConnectStartEndpoint.ts +9 -75
- package/src/endpoints/auth/PatchUserEndpoint.ts +43 -1
- package/src/endpoints/global/sso/GetSSOEndpoint.ts +13 -9
- package/src/endpoints/global/sso/SetSSOEndpoint.ts +7 -26
- package/src/helpers/AuthenticatedStructures.ts +2 -0
- package/src/helpers/Context.ts +23 -5
- package/src/helpers/CookieHelper.ts +9 -4
- package/src/services/SSOService.ts +505 -0
- package/src/helpers/OpenIDConnectHelper.ts +0 -295
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.71.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -37,14 +37,14 @@
|
|
|
37
37
|
"@simonbackx/simple-encoding": "2.19.0",
|
|
38
38
|
"@simonbackx/simple-endpoints": "1.15.0",
|
|
39
39
|
"@simonbackx/simple-logging": "^1.0.1",
|
|
40
|
-
"@stamhoofd/backend-i18n": "2.
|
|
41
|
-
"@stamhoofd/backend-middleware": "2.
|
|
42
|
-
"@stamhoofd/email": "2.
|
|
43
|
-
"@stamhoofd/models": "2.
|
|
44
|
-
"@stamhoofd/queues": "2.
|
|
45
|
-
"@stamhoofd/sql": "2.
|
|
46
|
-
"@stamhoofd/structures": "2.
|
|
47
|
-
"@stamhoofd/utility": "2.
|
|
40
|
+
"@stamhoofd/backend-i18n": "2.71.0",
|
|
41
|
+
"@stamhoofd/backend-middleware": "2.71.0",
|
|
42
|
+
"@stamhoofd/email": "2.71.0",
|
|
43
|
+
"@stamhoofd/models": "2.71.0",
|
|
44
|
+
"@stamhoofd/queues": "2.71.0",
|
|
45
|
+
"@stamhoofd/sql": "2.71.0",
|
|
46
|
+
"@stamhoofd/structures": "2.71.0",
|
|
47
|
+
"@stamhoofd/utility": "2.71.0",
|
|
48
48
|
"archiver": "^7.0.1",
|
|
49
49
|
"aws-sdk": "^2.885.0",
|
|
50
50
|
"axios": "1.6.8",
|
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public"
|
|
66
66
|
},
|
|
67
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "8b35d36e694303905aa27cf9bff5539777d6ace1"
|
|
68
68
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { AnyDecoder, Decoder } from '@simonbackx/simple-encoding';
|
|
2
2
|
import { DecodedRequest, Endpoint, Request } from '@simonbackx/simple-endpoints';
|
|
3
|
-
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
3
|
|
|
5
4
|
import { Context } from '../../helpers/Context';
|
|
6
|
-
import {
|
|
7
|
-
import { OpenIDClientConfiguration } from '@stamhoofd/structures';
|
|
8
|
-
import { Platform } from '@stamhoofd/models';
|
|
5
|
+
import { SSOServiceWithSession } from '../../services/SSOService';
|
|
9
6
|
|
|
10
7
|
type Params = Record<string, never>;
|
|
11
8
|
type Query = undefined;
|
|
@@ -29,26 +26,8 @@ export class OpenIDConnectCallbackEndpoint extends Endpoint<Params, Query, Body,
|
|
|
29
26
|
}
|
|
30
27
|
|
|
31
28
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (organization) {
|
|
37
|
-
configuration = organization.serverMeta.ssoConfiguration;
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
configuration = platform.serverConfig.ssoConfiguration;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!configuration) {
|
|
44
|
-
throw new SimpleError({
|
|
45
|
-
code: 'invalid_client',
|
|
46
|
-
message: 'SSO not configured',
|
|
47
|
-
statusCode: 400,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const helper = new OpenIDConnectHelper(organization, configuration);
|
|
52
|
-
return await helper.callback(request);
|
|
29
|
+
await Context.setUserOrganizationScope();
|
|
30
|
+
const ssoService = await SSOServiceWithSession.fromSession(request);
|
|
31
|
+
return await ssoService.callback();
|
|
53
32
|
}
|
|
54
33
|
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { Decoder } from '@simonbackx/simple-encoding';
|
|
2
2
|
import { DecodedRequest, Endpoint, Request } from '@simonbackx/simple-endpoints';
|
|
3
|
-
import {
|
|
4
|
-
import { Platform, Webshop } from '@stamhoofd/models';
|
|
5
|
-
import { OpenIDClientConfiguration, StartOpenIDFlowStruct } from '@stamhoofd/structures';
|
|
3
|
+
import { StartOpenIDFlowStruct } from '@stamhoofd/structures';
|
|
6
4
|
|
|
7
5
|
import { Context } from '../../helpers/Context';
|
|
8
|
-
import {
|
|
6
|
+
import { SSOService } from '../../services/SSOService';
|
|
9
7
|
|
|
10
8
|
type Params = Record<string, never>;
|
|
11
9
|
type Query = undefined;
|
|
@@ -30,78 +28,14 @@ export class OpenIDConnectStartEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
30
28
|
|
|
31
29
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
32
30
|
// Check webshop and/or organization
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
await Context.setUserOrganizationScope();
|
|
32
|
+
await Context.optionalAuthenticate({ allowWithoutAccount: false });
|
|
33
|
+
console.log('Full start connect body;', await request.request.body);
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (organization) {
|
|
41
|
-
redirectUri = 'https://' + organization.getHost();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// todo: also support the app as redirect uri using app schemes (could be required for mobile apps)
|
|
45
|
-
|
|
46
|
-
if (webshopId) {
|
|
47
|
-
if (!organization) {
|
|
48
|
-
throw new SimpleError({
|
|
49
|
-
code: 'invalid_organization',
|
|
50
|
-
message: 'Organization required when specifying webshopId',
|
|
51
|
-
statusCode: 400,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const webshop = await Webshop.getByID(webshopId);
|
|
56
|
-
if (!webshop || webshop.organizationId !== organization.id) {
|
|
57
|
-
throw new SimpleError({
|
|
58
|
-
code: 'invalid_webshop',
|
|
59
|
-
message: 'Invalid webshop',
|
|
60
|
-
statusCode: 400,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
redirectUri = 'https://' + webshop.setRelation(Webshop.organization, organization).getHost();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (request.body.redirectUri) {
|
|
67
|
-
try {
|
|
68
|
-
const allowedHost = new URL(redirectUri);
|
|
69
|
-
const givenUrl = new URL(request.body.redirectUri);
|
|
70
|
-
|
|
71
|
-
if (allowedHost.host === givenUrl.host && givenUrl.protocol === 'https:') {
|
|
72
|
-
redirectUri = givenUrl.href;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
catch (e) {
|
|
76
|
-
console.error('Invalid redirect uri', request.body.redirectUri);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (request.body.spaState.length < 10) {
|
|
81
|
-
throw new SimpleError({
|
|
82
|
-
code: 'invalid_state',
|
|
83
|
-
message: 'Invalid state',
|
|
84
|
-
statusCode: 400,
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let configuration: OpenIDClientConfiguration | null;
|
|
89
|
-
|
|
90
|
-
if (organization) {
|
|
91
|
-
configuration = organization.serverMeta.ssoConfiguration;
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
configuration = platform.serverConfig.ssoConfiguration;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (!configuration) {
|
|
98
|
-
throw new SimpleError({
|
|
99
|
-
code: 'invalid_client',
|
|
100
|
-
message: 'SSO not configured',
|
|
101
|
-
statusCode: 400,
|
|
102
|
-
});
|
|
35
|
+
if (Context.user) {
|
|
36
|
+
console.log('User:', Context.user);
|
|
103
37
|
}
|
|
104
|
-
const
|
|
105
|
-
return await
|
|
38
|
+
const service = await SSOService.fromContext(request.body.provider);
|
|
39
|
+
return await service.validateAndStartAuthCodeFlow(request.body);
|
|
106
40
|
}
|
|
107
41
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AutoEncoderPatchType, Decoder } from '@simonbackx/simple-encoding';
|
|
1
|
+
import { AutoEncoderPatchType, Decoder, isPatch } from '@simonbackx/simple-encoding';
|
|
2
2
|
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
3
3
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
4
|
import { EmailVerificationCode, Member, PasswordToken, Token, User } from '@stamhoofd/models';
|
|
@@ -109,6 +109,31 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
if (request.body.meta) {
|
|
113
|
+
if (request.body.meta.loginProviderIds && isPatch(request.body.meta.loginProviderIds)) {
|
|
114
|
+
// Delete deleted login providers
|
|
115
|
+
for (const [key, value] of request.body.meta.loginProviderIds) {
|
|
116
|
+
if (value !== null) {
|
|
117
|
+
// Not allowed
|
|
118
|
+
throw Context.auth.error('You are not allowed to change the login provider ids');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (editUser.meta?.loginProviderIds.has(key)) {
|
|
122
|
+
// Check has remaining method
|
|
123
|
+
if (editUser.meta.loginProviderIds.size <= 1 && !editUser.hasPasswordBasedAccount()) {
|
|
124
|
+
throw new SimpleError({
|
|
125
|
+
code: 'invalid_request',
|
|
126
|
+
message: 'You cannot remove the last login provider',
|
|
127
|
+
human: 'Stel eerst een wachtwoord in voor jouw account voor je deze loginmethode uitschakelt.',
|
|
128
|
+
statusCode: 400,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
editUser.meta.loginProviderIds.delete(key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
if (editUser.id === user.id && request.body.password) {
|
|
113
138
|
// password changes
|
|
114
139
|
await editUser.changePassword(request.body.password);
|
|
@@ -116,6 +141,23 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
|
|
|
116
141
|
await Token.clearFor(editUser.id, token.accessToken);
|
|
117
142
|
}
|
|
118
143
|
|
|
144
|
+
if (request.body.hasPassword === false) {
|
|
145
|
+
if (editUser.hasPasswordBasedAccount()) {
|
|
146
|
+
// Check other login methods available
|
|
147
|
+
if (!editUser.meta?.loginProviderIds?.size) {
|
|
148
|
+
throw new SimpleError({
|
|
149
|
+
code: 'invalid_request',
|
|
150
|
+
message: 'You cannot remove the last login provider',
|
|
151
|
+
human: 'Je kan jouw wachtwoord niet verwijderen als je geen andere loginmethode hebt.',
|
|
152
|
+
statusCode: 400,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
editUser.password = null;
|
|
156
|
+
await PasswordToken.clearFor(editUser.id);
|
|
157
|
+
await Token.clearFor(editUser.id, token.accessToken);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
119
161
|
await editUser.save();
|
|
120
162
|
|
|
121
163
|
if (await Context.auth.canEditUserEmail(editUser)) {
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
2
|
-
import { OpenIDClientConfiguration } from '@stamhoofd/structures';
|
|
2
|
+
import { LoginProviderType, OpenIDClientConfiguration } from '@stamhoofd/structures';
|
|
3
3
|
|
|
4
4
|
import { Context } from '../../../helpers/Context';
|
|
5
5
|
import { Platform } from '@stamhoofd/models';
|
|
6
|
+
import { AutoEncoder, Decoder, EnumDecoder, field } from '@simonbackx/simple-encoding';
|
|
7
|
+
import { SSOService } from '../../../services/SSOService';
|
|
6
8
|
|
|
7
9
|
type Params = Record<string, never>;
|
|
8
|
-
|
|
10
|
+
export class SSOQuery extends AutoEncoder {
|
|
11
|
+
@field({ decoder: new EnumDecoder(LoginProviderType) })
|
|
12
|
+
provider: LoginProviderType;
|
|
13
|
+
}
|
|
14
|
+
type Query = SSOQuery;
|
|
9
15
|
type Body = undefined;
|
|
10
16
|
type ResponseBody = OpenIDClientConfiguration;
|
|
11
17
|
|
|
@@ -14,6 +20,8 @@ type ResponseBody = OpenIDClientConfiguration;
|
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
export class GetOrganizationSSOEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
23
|
+
queryDecoder = SSOQuery as Decoder<Query>;
|
|
24
|
+
|
|
17
25
|
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
18
26
|
if (request.method !== 'GET') {
|
|
19
27
|
return [false];
|
|
@@ -27,19 +35,15 @@ export class GetOrganizationSSOEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
27
35
|
return [false];
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
async handle(
|
|
38
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
31
39
|
const organization = await Context.setOptionalOrganizationScope();
|
|
32
40
|
await Context.authenticate();
|
|
41
|
+
const service = await SSOService.fromContext(request.query.provider);
|
|
33
42
|
|
|
34
43
|
if (!await Context.auth.canManageSSOSettings(organization?.id ?? null)) {
|
|
35
44
|
throw Context.auth.error();
|
|
36
45
|
}
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
return new Response(organization.serverMeta.ssoConfiguration ?? OpenIDClientConfiguration.create({}));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const platform = await Platform.getShared();
|
|
43
|
-
return new Response(platform.serverConfig.ssoConfiguration ?? OpenIDClientConfiguration.create({}));
|
|
47
|
+
return new Response(service.configuration);
|
|
44
48
|
}
|
|
45
49
|
}
|
|
@@ -3,11 +3,11 @@ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-
|
|
|
3
3
|
import { OpenIDClientConfiguration } from '@stamhoofd/structures';
|
|
4
4
|
|
|
5
5
|
import { Context } from '../../../helpers/Context';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { SSOService } from '../../../services/SSOService';
|
|
7
|
+
import { SSOQuery } from './GetSSOEndpoint';
|
|
8
8
|
|
|
9
9
|
type Params = Record<string, never>;
|
|
10
|
-
type Query =
|
|
10
|
+
type Query = SSOQuery;
|
|
11
11
|
type Body = AutoEncoderPatchType<OpenIDClientConfiguration>;
|
|
12
12
|
type ResponseBody = OpenIDClientConfiguration;
|
|
13
13
|
|
|
@@ -17,6 +17,7 @@ type ResponseBody = OpenIDClientConfiguration;
|
|
|
17
17
|
|
|
18
18
|
export class SetOrganizationSSOEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
19
19
|
bodyDecoder = OpenIDClientConfiguration.patchType() as Decoder<AutoEncoderPatchType<OpenIDClientConfiguration>>;
|
|
20
|
+
queryDecoder = SSOQuery as Decoder<Query>;
|
|
20
21
|
|
|
21
22
|
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
22
23
|
if (request.method !== 'POST') {
|
|
@@ -34,34 +35,14 @@ export class SetOrganizationSSOEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
34
35
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
35
36
|
const organization = await Context.setOptionalOrganizationScope();
|
|
36
37
|
await Context.authenticate();
|
|
38
|
+
const service = await SSOService.fromContext(request.query.provider);
|
|
37
39
|
|
|
38
40
|
if (!await Context.auth.canManageSSOSettings(organization?.id ?? null)) {
|
|
39
41
|
throw Context.auth.error();
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (organization) {
|
|
45
|
-
newConfig = (organization.serverMeta.ssoConfiguration ?? OpenIDClientConfiguration.create({})).patch(request.body);
|
|
46
|
-
|
|
47
|
-
// Validate configuration
|
|
48
|
-
const helper = new OpenIDConnectHelper(organization, newConfig);
|
|
49
|
-
await helper.getClient();
|
|
50
|
-
|
|
51
|
-
organization.serverMeta.ssoConfiguration = newConfig;
|
|
52
|
-
await organization.save();
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
const platform = await Platform.getShared();
|
|
56
|
-
newConfig = (platform.serverConfig.ssoConfiguration ?? OpenIDClientConfiguration.create({})).patch(request.body);
|
|
57
|
-
|
|
58
|
-
// Validate configuration
|
|
59
|
-
const helper = new OpenIDConnectHelper(null, newConfig);
|
|
60
|
-
await helper.getClient();
|
|
61
|
-
|
|
62
|
-
platform.serverConfig.ssoConfiguration = newConfig;
|
|
63
|
-
await platform.save();
|
|
64
|
-
}
|
|
44
|
+
const newConfig: OpenIDClientConfiguration = service.configuration.patch(request.body);
|
|
45
|
+
await service.setConfiguration(newConfig);
|
|
65
46
|
|
|
66
47
|
return new Response(newConfig);
|
|
67
48
|
}
|
|
@@ -287,6 +287,7 @@ export class AuthenticatedStructures {
|
|
|
287
287
|
return UserWithMembers.create({
|
|
288
288
|
...user,
|
|
289
289
|
hasAccount: user.hasAccount(),
|
|
290
|
+
hasPassword: user.hasPasswordBasedAccount(),
|
|
290
291
|
|
|
291
292
|
// Always include the current context organization - because it is possible we switch organization and we don't want to refetch every time
|
|
292
293
|
members: await this.membersBlob(members, true, user),
|
|
@@ -306,6 +307,7 @@ export class AuthenticatedStructures {
|
|
|
306
307
|
structs.push(UserWithMembers.create({
|
|
307
308
|
...user,
|
|
308
309
|
hasAccount: user.hasAccount(),
|
|
310
|
+
hasPassword: user.hasPasswordBasedAccount(),
|
|
309
311
|
members: await this.membersBlob(filteredMembers, false),
|
|
310
312
|
}));
|
|
311
313
|
}
|
package/src/helpers/Context.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Request } from '@simonbackx/simple-endpoints';
|
|
1
|
+
import { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
3
3
|
import { I18n } from '@stamhoofd/backend-i18n';
|
|
4
4
|
import { Organization, Platform, RateLimiter, Token, User } from '@stamhoofd/models';
|
|
5
5
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
6
6
|
|
|
7
7
|
import { AdminPermissionChecker } from './AdminPermissionChecker';
|
|
8
|
+
import { AutoEncoder, field, Decoder, StringDecoder } from '@simonbackx/simple-encoding';
|
|
8
9
|
|
|
9
10
|
export const apiUserRateLimiter = new RateLimiter({
|
|
10
11
|
limits: [
|
|
@@ -31,6 +32,11 @@ export const apiUserRateLimiter = new RateLimiter({
|
|
|
31
32
|
],
|
|
32
33
|
});
|
|
33
34
|
|
|
35
|
+
export class AuthorizationPostBody extends AutoEncoder {
|
|
36
|
+
@field({ decoder: StringDecoder })
|
|
37
|
+
header_authorization: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
export class ContextInstance {
|
|
35
41
|
request: Request;
|
|
36
42
|
|
|
@@ -150,15 +156,27 @@ export class ContextInstance {
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
async optionalAuthenticate({ allowWithoutAccount = false }: { allowWithoutAccount?: boolean } = {}): Promise<{ user?: User }> {
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
try {
|
|
160
|
+
return await this.authenticate({ allowWithoutAccount });
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
155
163
|
return {};
|
|
156
164
|
}
|
|
157
|
-
return this.authenticate({ allowWithoutAccount });
|
|
158
165
|
}
|
|
159
166
|
|
|
160
167
|
async authenticate({ allowWithoutAccount = false }: { allowWithoutAccount?: boolean } = {}): Promise<{ user: User; token: Token }> {
|
|
161
|
-
|
|
168
|
+
let header = this.request.headers.authorization;
|
|
169
|
+
|
|
170
|
+
if (!header && this.request.method === 'POST') {
|
|
171
|
+
try {
|
|
172
|
+
const decoded = await DecodedRequest.fromRequest(this.request, undefined, undefined, AuthorizationPostBody as Decoder<AuthorizationPostBody>);
|
|
173
|
+
header = decoded.body.header_authorization;
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
// Ignore: failed to read from body
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
if (!header) {
|
|
163
181
|
throw new SimpleError({
|
|
164
182
|
code: 'not_authenticated',
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Response } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import cookie from 'cookie';
|
|
3
|
+
import http from 'http';
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
+
export type ObjectWithHeaders = {
|
|
6
|
+
headers: http.IncomingHttpHeaders;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type DecodedRequestWithCookies = ObjectWithHeaders & { cookies?: Record<string, string> };
|
|
5
10
|
|
|
6
11
|
export class CookieHelper {
|
|
7
|
-
static getCookies(request:
|
|
12
|
+
static getCookies(request: ObjectWithHeaders): Record<string, string> {
|
|
8
13
|
const r = request as DecodedRequestWithCookies;
|
|
9
14
|
if (r.cookies) {
|
|
10
15
|
return r.cookies;
|
|
@@ -21,7 +26,7 @@ export class CookieHelper {
|
|
|
21
26
|
return r.cookies;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
static getCookie(request:
|
|
29
|
+
static getCookie(request: ObjectWithHeaders, name: string): string | undefined {
|
|
25
30
|
const cookies = this.getCookies(request);
|
|
26
31
|
return cookies[name];
|
|
27
32
|
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { DecodedRequest, Response } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
+
import { Organization, Platform, Token, User, Webshop } from '@stamhoofd/models';
|
|
4
|
+
import { LoginProviderType, OpenIDClientConfiguration, StartOpenIDFlowStruct, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import { generators, Issuer } from 'openid-client';
|
|
7
|
+
import { Context } from '../helpers/Context';
|
|
8
|
+
|
|
9
|
+
import { CookieHelper, ObjectWithHeaders } from '../helpers/CookieHelper';
|
|
10
|
+
|
|
11
|
+
async function randomBytes(size: number): Promise<Buffer> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
|
|
14
|
+
if (err) {
|
|
15
|
+
reject(err);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
resolve(buf);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type SSOSessionContext = {
|
|
24
|
+
expires: Date;
|
|
25
|
+
code_verifier: string;
|
|
26
|
+
state: string;
|
|
27
|
+
nonce: string;
|
|
28
|
+
redirectUri: string;
|
|
29
|
+
spaState: string;
|
|
30
|
+
providerType: LoginProviderType;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Link this method to this existing user and don't create a new token
|
|
34
|
+
*/
|
|
35
|
+
userId?: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class SSOService {
|
|
39
|
+
provider: LoginProviderType;
|
|
40
|
+
platform: Platform;
|
|
41
|
+
organization: Organization | null;
|
|
42
|
+
user: User | null = null;
|
|
43
|
+
|
|
44
|
+
static sessionStorage = new Map<string, SSOSessionContext>();
|
|
45
|
+
|
|
46
|
+
constructor(data: { provider: LoginProviderType; platform: Platform; organization?: Organization | null; user?: User | null }) {
|
|
47
|
+
this.provider = data.provider;
|
|
48
|
+
this.platform = data.platform;
|
|
49
|
+
this.organization = data.organization ?? null;
|
|
50
|
+
this.user = data.user ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* This is the redirectUri we'll use towards the provider - but we store a different internal redirectUri in the session to allow more flexibility
|
|
55
|
+
* with the multiple domains we can redirect to.
|
|
56
|
+
*/
|
|
57
|
+
get externalRedirectUri() {
|
|
58
|
+
if (this.configuration.redirectUri) {
|
|
59
|
+
return this.configuration.redirectUri;
|
|
60
|
+
}
|
|
61
|
+
// todo: we might need a special url for the app here
|
|
62
|
+
|
|
63
|
+
if (!this.organization) {
|
|
64
|
+
return 'https://' + STAMHOOFD.domains.api + '/openid/callback';
|
|
65
|
+
}
|
|
66
|
+
return 'https://' + this.organization.id + '.' + STAMHOOFD.domains.api + '/openid/callback';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get defaultRedirectUri() {
|
|
70
|
+
// Host should match correctly
|
|
71
|
+
let redirectUri = 'https://' + STAMHOOFD.domains.dashboard;
|
|
72
|
+
|
|
73
|
+
if (this.organization) {
|
|
74
|
+
redirectUri = 'https://' + this.organization.getHost();
|
|
75
|
+
}
|
|
76
|
+
return redirectUri;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static async fromContext(provider: LoginProviderType) {
|
|
80
|
+
const organization = Context.organization;
|
|
81
|
+
const platform = await Platform.getShared();
|
|
82
|
+
|
|
83
|
+
const service = new SSOService({ provider, platform, organization, user: Context.user });
|
|
84
|
+
// Validate configuration
|
|
85
|
+
const _ = service.configuration;
|
|
86
|
+
return service;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get configuration() {
|
|
90
|
+
let configuration: OpenIDClientConfiguration | null = null;
|
|
91
|
+
|
|
92
|
+
if (this.provider === LoginProviderType.SSO) {
|
|
93
|
+
if (this.organization) {
|
|
94
|
+
configuration = this.organization.serverMeta.ssoConfiguration ?? OpenIDClientConfiguration.create({});
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
configuration = this.platform.serverConfig.ssoConfiguration ?? OpenIDClientConfiguration.create({});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (this.provider === LoginProviderType.Google) {
|
|
101
|
+
if (this.organization) {
|
|
102
|
+
// Not supported yet
|
|
103
|
+
configuration = null;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
configuration = this.platform.serverConfig.googleConfiguration ?? OpenIDClientConfiguration.create({});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!configuration) {
|
|
111
|
+
throw new SimpleError({
|
|
112
|
+
code: 'invalid_client',
|
|
113
|
+
message: 'SSO not configured',
|
|
114
|
+
statusCode: 400,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return configuration;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async setConfiguration(configuration: OpenIDClientConfiguration) {
|
|
121
|
+
if (this.provider === LoginProviderType.SSO) {
|
|
122
|
+
if (this.organization) {
|
|
123
|
+
this.organization.serverMeta.ssoConfiguration = configuration;
|
|
124
|
+
await this.getClient();
|
|
125
|
+
await this.organization.save();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.platform.serverConfig.ssoConfiguration = configuration;
|
|
130
|
+
await this.getClient();
|
|
131
|
+
await this.platform.save();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (this.provider === LoginProviderType.Google) {
|
|
136
|
+
if (!this.organization) {
|
|
137
|
+
this.platform.serverConfig.googleConfiguration = configuration;
|
|
138
|
+
await this.getClient();
|
|
139
|
+
await this.platform.save();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw new SimpleError({
|
|
145
|
+
code: 'invalid_client',
|
|
146
|
+
message: 'SSO not supported here',
|
|
147
|
+
statusCode: 400,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getClient() {
|
|
152
|
+
const issuer = await Issuer.discover(this.configuration.issuer);
|
|
153
|
+
const client = new issuer.Client({
|
|
154
|
+
client_id: this.configuration.clientId,
|
|
155
|
+
client_secret: this.configuration.clientSecret,
|
|
156
|
+
redirect_uris: [this.externalRedirectUri],
|
|
157
|
+
response_types: ['code'],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Todo: in the future we can add a cache here
|
|
161
|
+
|
|
162
|
+
return client;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
static async storeSession(response: Response<any>, data: SSOSessionContext) {
|
|
166
|
+
const sessionId = (await randomBytes(192)).toString('base64');
|
|
167
|
+
|
|
168
|
+
// Delete expired sessions
|
|
169
|
+
for (const [key, value] of this.sessionStorage) {
|
|
170
|
+
if (value.expires < new Date()) {
|
|
171
|
+
this.sessionStorage.delete(key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.sessionStorage.set(sessionId, data);
|
|
176
|
+
|
|
177
|
+
// Store
|
|
178
|
+
CookieHelper.setCookie(response, 'oid_session_id', sessionId, {
|
|
179
|
+
httpOnly: true,
|
|
180
|
+
secure: STAMHOOFD.environment !== 'development',
|
|
181
|
+
expires: data.expires,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
static getSession(request: ObjectWithHeaders): SSOSessionContext | null {
|
|
186
|
+
const sessionId = CookieHelper.getCookie(request, 'oid_session_id');
|
|
187
|
+
if (!sessionId) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const session = this.sessionStorage.get(sessionId);
|
|
192
|
+
if (!session) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (session.expires < new Date()) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return session;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async validateAndStartAuthCodeFlow(data: StartOpenIDFlowStruct) {
|
|
204
|
+
// Host should match correctly
|
|
205
|
+
let redirectUri = this.defaultRedirectUri;
|
|
206
|
+
|
|
207
|
+
// todo: also support the app as redirect uri using app schemes (could be required for mobile apps)
|
|
208
|
+
const webshopId = data.webshopId;
|
|
209
|
+
|
|
210
|
+
if (webshopId) {
|
|
211
|
+
if (!this.organization) {
|
|
212
|
+
throw new SimpleError({
|
|
213
|
+
code: 'invalid_organization',
|
|
214
|
+
message: 'Organization required when specifying webshopId',
|
|
215
|
+
statusCode: 400,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const webshop = await Webshop.getByID(webshopId);
|
|
220
|
+
if (!webshop || webshop.organizationId !== this.organization.id) {
|
|
221
|
+
throw new SimpleError({
|
|
222
|
+
code: 'invalid_webshop',
|
|
223
|
+
message: 'Invalid webshop',
|
|
224
|
+
statusCode: 400,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
redirectUri = 'https://' + webshop.setRelation(Webshop.organization, this.organization).getHost();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (data.redirectUri) {
|
|
231
|
+
try {
|
|
232
|
+
const allowedHost = new URL(redirectUri);
|
|
233
|
+
const givenUrl = new URL(data.redirectUri);
|
|
234
|
+
|
|
235
|
+
if (allowedHost.host === givenUrl.host && givenUrl.protocol === 'https:') {
|
|
236
|
+
redirectUri = givenUrl.href;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
throw new SimpleError({
|
|
240
|
+
code: 'redirect_uri_not_allowed',
|
|
241
|
+
message: 'Redirect uri not allowed',
|
|
242
|
+
field: 'redirectUri',
|
|
243
|
+
statusCode: 400,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
throw new SimpleError({
|
|
249
|
+
code: 'invalid_redirect_uri',
|
|
250
|
+
message: 'Invalid redirect uri',
|
|
251
|
+
field: 'redirectUri',
|
|
252
|
+
statusCode: 400,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (data.spaState.length < 10) {
|
|
258
|
+
throw new SimpleError({
|
|
259
|
+
code: 'invalid_state',
|
|
260
|
+
message: 'Invalid state',
|
|
261
|
+
statusCode: 400,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null): Promise<Response<undefined>> {
|
|
269
|
+
const code_verifier = generators.codeVerifier();
|
|
270
|
+
const state = generators.state();
|
|
271
|
+
const nonce = generators.nonce();
|
|
272
|
+
const code_challenge = generators.codeChallenge(code_verifier);
|
|
273
|
+
const expires = new Date(Date.now() + 1000 * 60 * 15);
|
|
274
|
+
|
|
275
|
+
const session: SSOSessionContext = {
|
|
276
|
+
expires,
|
|
277
|
+
code_verifier,
|
|
278
|
+
state,
|
|
279
|
+
nonce,
|
|
280
|
+
redirectUri,
|
|
281
|
+
spaState,
|
|
282
|
+
providerType: this.provider,
|
|
283
|
+
userId: Context.user?.id ?? null,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const response = new Response(undefined);
|
|
288
|
+
|
|
289
|
+
const client = await this.getClient();
|
|
290
|
+
await SSOService.storeSession(response, session);
|
|
291
|
+
|
|
292
|
+
const redirect = client.authorizationUrl({
|
|
293
|
+
scope: 'openid email profile',
|
|
294
|
+
code_challenge,
|
|
295
|
+
code_challenge_method: 'S256',
|
|
296
|
+
response_mode: 'form_post',
|
|
297
|
+
response_type: 'code',
|
|
298
|
+
state,
|
|
299
|
+
nonce,
|
|
300
|
+
prompt: prompt ?? undefined,
|
|
301
|
+
login_hint: this.user?.email ?? undefined,
|
|
302
|
+
redirect_uri: this.externalRedirectUri,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
response.headers['location'] = redirect;
|
|
306
|
+
response.status = 302;
|
|
307
|
+
|
|
308
|
+
return response;
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.');
|
|
312
|
+
console.error('Error in openID callback', e);
|
|
313
|
+
return SSOServiceWithSession.getErrorRedirectResponse(session, message);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export class SSOServiceWithSession {
|
|
319
|
+
session: SSOSessionContext;
|
|
320
|
+
service: SSOService;
|
|
321
|
+
request: DecodedRequest<any, any, any>;
|
|
322
|
+
|
|
323
|
+
constructor(session: SSOSessionContext, service: SSOService, request: DecodedRequest<any, any, any>) {
|
|
324
|
+
this.session = session;
|
|
325
|
+
this.service = service;
|
|
326
|
+
this.request = request;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
static async fromSession(request: DecodedRequest<any, any, any>): Promise<SSOServiceWithSession> {
|
|
330
|
+
const session = SSOService.getSession(request);
|
|
331
|
+
|
|
332
|
+
if (!session) {
|
|
333
|
+
throw new Error('Missing session');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const service = await SSOService.fromContext(session.providerType);
|
|
337
|
+
|
|
338
|
+
return new SSOServiceWithSession(session, service, request);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async callback(): Promise<Response<undefined>> {
|
|
342
|
+
const session = SSOService.getSession(Context.request);
|
|
343
|
+
|
|
344
|
+
if (!session) {
|
|
345
|
+
throw new Error('Missing session');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const response = new Response(undefined);
|
|
350
|
+
const client = await this.service.getClient();
|
|
351
|
+
|
|
352
|
+
const tokenSet = await client.callback(this.service.externalRedirectUri, this.request.body as Record<string, unknown>, {
|
|
353
|
+
code_verifier: session.code_verifier,
|
|
354
|
+
state: session.state,
|
|
355
|
+
nonce: session.nonce,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
console.log('received and validated tokens %j', tokenSet);
|
|
359
|
+
|
|
360
|
+
const claims = tokenSet.claims();
|
|
361
|
+
console.log('validated ID Token claims %j', claims);
|
|
362
|
+
|
|
363
|
+
if (!claims.name) {
|
|
364
|
+
throw new SimpleError({
|
|
365
|
+
code: 'invalid_user',
|
|
366
|
+
message: 'Missing name',
|
|
367
|
+
statusCode: 400,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let firstName = claims.name.split(' ')[0];
|
|
372
|
+
let lastName = claims.name.split(' ').slice(1).join(' ');
|
|
373
|
+
|
|
374
|
+
// Get from API
|
|
375
|
+
if (tokenSet.access_token) {
|
|
376
|
+
const userinfo = await client.userinfo(tokenSet.access_token);
|
|
377
|
+
console.log('userinfo', userinfo);
|
|
378
|
+
|
|
379
|
+
if (userinfo.given_name) {
|
|
380
|
+
console.log('userinfo given_name', userinfo.given_name);
|
|
381
|
+
firstName = userinfo.given_name;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (userinfo.family_name) {
|
|
385
|
+
console.log('userinfo family_name', userinfo.family_name);
|
|
386
|
+
lastName = userinfo.family_name;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!claims.email) {
|
|
391
|
+
throw new SimpleError({
|
|
392
|
+
code: 'invalid_user',
|
|
393
|
+
message: 'Missing email address',
|
|
394
|
+
statusCode: 400,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!claims.sub) {
|
|
399
|
+
throw new SimpleError({
|
|
400
|
+
code: 'invalid_user',
|
|
401
|
+
message: 'Missing sub',
|
|
402
|
+
statusCode: 400,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Get user from database
|
|
407
|
+
let user = await User.getForRegister(this.service.organization?.id ?? null, claims.email);
|
|
408
|
+
if (!user) {
|
|
409
|
+
if (this.session.userId) {
|
|
410
|
+
throw new SimpleError({
|
|
411
|
+
code: 'invalid_user',
|
|
412
|
+
message: 'User not found: please log in with the same email address as the user you are trying to link',
|
|
413
|
+
human: 'Je moet inloggen met hetzelfde e-mailadres als het account dat je probeert te koppelen',
|
|
414
|
+
statusCode: 404,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Create a new user
|
|
419
|
+
user = await User.registerSSO(this.service.organization, {
|
|
420
|
+
id: undefined,
|
|
421
|
+
email: claims.email,
|
|
422
|
+
firstName,
|
|
423
|
+
lastName,
|
|
424
|
+
type: session.providerType,
|
|
425
|
+
sub: claims.sub,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
if (!user) {
|
|
429
|
+
throw new SimpleError({
|
|
430
|
+
code: 'invalid_user',
|
|
431
|
+
message: 'Failed to create user',
|
|
432
|
+
statusCode: 500,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
if (this.session.userId && user.id !== this.session.userId) {
|
|
438
|
+
throw new SimpleError({
|
|
439
|
+
code: 'invalid_user',
|
|
440
|
+
message: 'User or email mismatch',
|
|
441
|
+
statusCode: 400,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Update name
|
|
446
|
+
if (!user.firstName || !user.hasPasswordBasedAccount()) {
|
|
447
|
+
user.firstName = firstName;
|
|
448
|
+
}
|
|
449
|
+
if (!user.lastName || !user.hasPasswordBasedAccount()) {
|
|
450
|
+
user.lastName = lastName;
|
|
451
|
+
}
|
|
452
|
+
user.linkLoginProvider(this.service.provider, claims.sub, !!this.session.userId);
|
|
453
|
+
await user.save();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Redirect back
|
|
457
|
+
const redirectUri = new URL(session.redirectUri);
|
|
458
|
+
|
|
459
|
+
if (!this.session.userId) {
|
|
460
|
+
const token = await Token.createExpiredToken(user);
|
|
461
|
+
|
|
462
|
+
if (!token) {
|
|
463
|
+
throw new SimpleError({
|
|
464
|
+
code: 'error',
|
|
465
|
+
message: 'Could not generate token',
|
|
466
|
+
human: 'Er ging iets mis bij het aanmelden',
|
|
467
|
+
statusCode: 500,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const st = new TokenStruct(token);
|
|
472
|
+
redirectUri.searchParams.set('oid_rt', st.refreshToken);
|
|
473
|
+
redirectUri.searchParams.set('s', session.spaState);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
redirectUri.searchParams.set('s', session.spaState);
|
|
477
|
+
redirectUri.searchParams.set('msg', 'Je account is succesvol gekoppeld');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
response.headers['location'] = redirectUri.toString();
|
|
481
|
+
response.status = 302;
|
|
482
|
+
|
|
483
|
+
return response;
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.');
|
|
487
|
+
console.error('Error in openID callback', e);
|
|
488
|
+
return SSOServiceWithSession.getErrorRedirectResponse(session, message);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
static getErrorRedirectResponse(session: SSOSessionContext, errorMessage: string) {
|
|
493
|
+
const response = new Response(undefined);
|
|
494
|
+
|
|
495
|
+
// Redirect back to webshop
|
|
496
|
+
const redirectUri = new URL(session.redirectUri);
|
|
497
|
+
redirectUri.searchParams.set('s', session.spaState);
|
|
498
|
+
redirectUri.searchParams.set('error', errorMessage);
|
|
499
|
+
|
|
500
|
+
response.headers['location'] = redirectUri.toString();
|
|
501
|
+
response.status = 302;
|
|
502
|
+
|
|
503
|
+
return response;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import { DecodedRequest, Response } from '@simonbackx/simple-endpoints';
|
|
2
|
-
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
-
import { Organization, Token, User } from '@stamhoofd/models';
|
|
4
|
-
import { LoginProviderType, OpenIDClientConfiguration, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
|
-
import crypto from 'crypto';
|
|
6
|
-
import { generators, Issuer } from 'openid-client';
|
|
7
|
-
|
|
8
|
-
import { CookieHelper } from './CookieHelper';
|
|
9
|
-
|
|
10
|
-
async function randomBytes(size: number): Promise<Buffer> {
|
|
11
|
-
return new Promise((resolve, reject) => {
|
|
12
|
-
crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
|
|
13
|
-
if (err) {
|
|
14
|
-
reject(err);
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
resolve(buf);
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
type SessionContext = {
|
|
23
|
-
expires: Date;
|
|
24
|
-
code_verifier: string;
|
|
25
|
-
state: string;
|
|
26
|
-
nonce: string;
|
|
27
|
-
redirectUri: string;
|
|
28
|
-
spaState: string;
|
|
29
|
-
providerType: LoginProviderType;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export class OpenIDConnectHelper {
|
|
33
|
-
organization: Organization | null;
|
|
34
|
-
configuration: OpenIDClientConfiguration;
|
|
35
|
-
|
|
36
|
-
static sessionStorage = new Map<string, SessionContext>();
|
|
37
|
-
|
|
38
|
-
constructor(organization: Organization | null, configuration: OpenIDClientConfiguration) {
|
|
39
|
-
this.organization = organization;
|
|
40
|
-
this.configuration = configuration;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
get redirectUri() {
|
|
44
|
-
if (this.configuration.redirectUri) {
|
|
45
|
-
return this.configuration.redirectUri;
|
|
46
|
-
}
|
|
47
|
-
// todo: we might need a special url for the app here
|
|
48
|
-
|
|
49
|
-
if (!this.organization) {
|
|
50
|
-
return 'https://' + STAMHOOFD.domains.api + '/openid/callback';
|
|
51
|
-
}
|
|
52
|
-
return 'https://' + this.organization.id + '.' + STAMHOOFD.domains.api + '/openid/callback';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async getClient() {
|
|
56
|
-
const issuer = await Issuer.discover(this.configuration.issuer);
|
|
57
|
-
const client = new issuer.Client({
|
|
58
|
-
client_id: this.configuration.clientId,
|
|
59
|
-
client_secret: this.configuration.clientSecret,
|
|
60
|
-
redirect_uris: [this.redirectUri],
|
|
61
|
-
response_types: ['code'],
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Todo: in the future we can add a cache here
|
|
65
|
-
|
|
66
|
-
return client;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
static async storeSession(response: Response<any>, data: SessionContext) {
|
|
70
|
-
const sessionId = (await randomBytes(192)).toString('base64');
|
|
71
|
-
|
|
72
|
-
// Delete expired sessions
|
|
73
|
-
for (const [key, value] of this.sessionStorage) {
|
|
74
|
-
if (value.expires < new Date()) {
|
|
75
|
-
this.sessionStorage.delete(key);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
this.sessionStorage.set(sessionId, data);
|
|
80
|
-
|
|
81
|
-
// Store
|
|
82
|
-
CookieHelper.setCookie(response, 'oid_session_id', sessionId, {
|
|
83
|
-
httpOnly: true,
|
|
84
|
-
secure: STAMHOOFD.environment !== 'development',
|
|
85
|
-
expires: data.expires,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
static getSession(request: DecodedRequest<any, any, any>): SessionContext | null {
|
|
90
|
-
const sessionId = CookieHelper.getCookie(request, 'oid_session_id');
|
|
91
|
-
if (!sessionId) {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const session = this.sessionStorage.get(sessionId);
|
|
96
|
-
if (!session) {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (session.expires < new Date()) {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return session;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async startAuthCodeFlow(redirectUri: string, providerType: LoginProviderType, spaState: string, prompt: string | null = null): Promise<Response<undefined>> {
|
|
108
|
-
const code_verifier = generators.codeVerifier();
|
|
109
|
-
const state = generators.state();
|
|
110
|
-
const nonce = generators.nonce();
|
|
111
|
-
const code_challenge = generators.codeChallenge(code_verifier);
|
|
112
|
-
const expires = new Date(Date.now() + 1000 * 60 * 15);
|
|
113
|
-
|
|
114
|
-
const session: SessionContext = {
|
|
115
|
-
expires,
|
|
116
|
-
code_verifier,
|
|
117
|
-
state,
|
|
118
|
-
nonce,
|
|
119
|
-
redirectUri,
|
|
120
|
-
spaState,
|
|
121
|
-
providerType,
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
const response = new Response(undefined);
|
|
126
|
-
|
|
127
|
-
const client = await this.getClient();
|
|
128
|
-
await OpenIDConnectHelper.storeSession(response, session);
|
|
129
|
-
|
|
130
|
-
const redirect = client.authorizationUrl({
|
|
131
|
-
scope: 'openid email profile',
|
|
132
|
-
code_challenge,
|
|
133
|
-
code_challenge_method: 'S256',
|
|
134
|
-
response_mode: 'form_post',
|
|
135
|
-
response_type: 'code',
|
|
136
|
-
state,
|
|
137
|
-
nonce,
|
|
138
|
-
prompt: prompt ?? undefined,
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
response.headers['location'] = redirect;
|
|
142
|
-
response.status = 302;
|
|
143
|
-
|
|
144
|
-
return response;
|
|
145
|
-
}
|
|
146
|
-
catch (e) {
|
|
147
|
-
const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.');
|
|
148
|
-
console.error('Error in openID callback', e);
|
|
149
|
-
return OpenIDConnectHelper.getErrorRedirectResponse(session, message);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async callback(request: DecodedRequest<any, any, any>): Promise<Response<undefined>> {
|
|
154
|
-
const session = OpenIDConnectHelper.getSession(request);
|
|
155
|
-
|
|
156
|
-
if (!session) {
|
|
157
|
-
throw new Error('Missing session');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
const response = new Response(undefined);
|
|
162
|
-
const client = await this.getClient();
|
|
163
|
-
|
|
164
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
165
|
-
const tokenSet = await client.callback(this.redirectUri, request.body, {
|
|
166
|
-
code_verifier: session.code_verifier,
|
|
167
|
-
state: session.state,
|
|
168
|
-
nonce: session.nonce,
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
console.log('received and validated tokens %j', tokenSet);
|
|
172
|
-
|
|
173
|
-
const claims = tokenSet.claims();
|
|
174
|
-
console.log('validated ID Token claims %j', claims);
|
|
175
|
-
|
|
176
|
-
if (!claims.name) {
|
|
177
|
-
throw new SimpleError({
|
|
178
|
-
code: 'invalid_user',
|
|
179
|
-
message: 'Missing name',
|
|
180
|
-
statusCode: 400,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
let firstName = claims.name.split(' ')[0];
|
|
185
|
-
let lastName = claims.name.split(' ').slice(1).join(' ');
|
|
186
|
-
|
|
187
|
-
// Get from API
|
|
188
|
-
if (tokenSet.access_token) {
|
|
189
|
-
const userinfo = await client.userinfo(tokenSet.access_token);
|
|
190
|
-
console.log('userinfo', userinfo);
|
|
191
|
-
|
|
192
|
-
if (userinfo.given_name) {
|
|
193
|
-
console.log('userinfo given_name', userinfo.given_name);
|
|
194
|
-
firstName = userinfo.given_name;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (userinfo.family_name) {
|
|
198
|
-
console.log('userinfo family_name', userinfo.family_name);
|
|
199
|
-
lastName = userinfo.family_name;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (!claims.email) {
|
|
204
|
-
throw new SimpleError({
|
|
205
|
-
code: 'invalid_user',
|
|
206
|
-
message: 'Missing email address',
|
|
207
|
-
statusCode: 400,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (!claims.sub) {
|
|
212
|
-
throw new SimpleError({
|
|
213
|
-
code: 'invalid_user',
|
|
214
|
-
message: 'Missing sub',
|
|
215
|
-
statusCode: 400,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Get user from database
|
|
220
|
-
let user = await User.getForRegister(this.organization?.id ?? null, claims.email);
|
|
221
|
-
if (!user) {
|
|
222
|
-
// Create a new user
|
|
223
|
-
user = await User.registerSSO(this.organization, {
|
|
224
|
-
id: undefined,
|
|
225
|
-
email: claims.email,
|
|
226
|
-
firstName,
|
|
227
|
-
lastName,
|
|
228
|
-
type: session.providerType,
|
|
229
|
-
sub: claims.sub,
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
if (!user) {
|
|
233
|
-
throw new SimpleError({
|
|
234
|
-
code: 'invalid_user',
|
|
235
|
-
message: 'Failed to create user',
|
|
236
|
-
statusCode: 500,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
// Update name
|
|
242
|
-
if (!user.firstName || !user.hasPasswordBasedAccount()) {
|
|
243
|
-
user.firstName = firstName;
|
|
244
|
-
}
|
|
245
|
-
if (!user.lastName || !user.hasPasswordBasedAccount()) {
|
|
246
|
-
user.lastName = lastName;
|
|
247
|
-
}
|
|
248
|
-
user.linkLoginProvider(session.providerType, claims.sub);
|
|
249
|
-
await user.save();
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const token = await Token.createExpiredToken(user);
|
|
253
|
-
|
|
254
|
-
if (!token) {
|
|
255
|
-
throw new SimpleError({
|
|
256
|
-
code: 'error',
|
|
257
|
-
message: 'Could not generate token',
|
|
258
|
-
human: 'Er ging iets mis bij het aanmelden',
|
|
259
|
-
statusCode: 500,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const st = new TokenStruct(token);
|
|
264
|
-
|
|
265
|
-
// Redirect back to webshop
|
|
266
|
-
const redirectUri = new URL(session.redirectUri);
|
|
267
|
-
redirectUri.searchParams.set('oid_rt', st.refreshToken);
|
|
268
|
-
redirectUri.searchParams.set('s', session.spaState);
|
|
269
|
-
|
|
270
|
-
response.headers['location'] = redirectUri.toString();
|
|
271
|
-
response.status = 302;
|
|
272
|
-
|
|
273
|
-
return response;
|
|
274
|
-
}
|
|
275
|
-
catch (e) {
|
|
276
|
-
const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.');
|
|
277
|
-
console.error('Error in openID callback', e);
|
|
278
|
-
return OpenIDConnectHelper.getErrorRedirectResponse(session, message);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
static getErrorRedirectResponse(session: SessionContext, errorMessage: string) {
|
|
283
|
-
const response = new Response(undefined);
|
|
284
|
-
|
|
285
|
-
// Redirect back to webshop
|
|
286
|
-
const redirectUri = new URL(session.redirectUri);
|
|
287
|
-
redirectUri.searchParams.set('s', session.spaState);
|
|
288
|
-
redirectUri.searchParams.set('error', errorMessage);
|
|
289
|
-
|
|
290
|
-
response.headers['location'] = redirectUri.toString();
|
|
291
|
-
response.status = 302;
|
|
292
|
-
|
|
293
|
-
return response;
|
|
294
|
-
}
|
|
295
|
-
}
|