@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.70.0",
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.70.0",
41
- "@stamhoofd/backend-middleware": "2.70.0",
42
- "@stamhoofd/email": "2.70.0",
43
- "@stamhoofd/models": "2.70.0",
44
- "@stamhoofd/queues": "2.70.0",
45
- "@stamhoofd/sql": "2.70.0",
46
- "@stamhoofd/structures": "2.70.0",
47
- "@stamhoofd/utility": "2.70.0",
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": "5c6f4bbab24bf118b45da350bdaf3c62756909ad"
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 { OpenIDConnectHelper } from '../../helpers/OpenIDConnectHelper';
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
- const organization = await Context.setOptionalOrganizationScope();
33
- let configuration: OpenIDClientConfiguration | null;
34
- const platform = await Platform.getShared();
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 { SimpleError } from '@simonbackx/simple-errors';
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 { OpenIDConnectHelper } from '../../helpers/OpenIDConnectHelper';
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
- const organization = await Context.setOptionalOrganizationScope();
34
- const webshopId = request.body.webshopId;
35
- const platform = await Platform.getShared();
31
+ await Context.setUserOrganizationScope();
32
+ await Context.optionalAuthenticate({ allowWithoutAccount: false });
33
+ console.log('Full start connect body;', await request.request.body);
36
34
 
37
- // Host should match correctly
38
- let redirectUri = 'https://' + STAMHOOFD.domains.dashboard;
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 helper = new OpenIDConnectHelper(organization, configuration);
105
- return await helper.startAuthCodeFlow(redirectUri, request.body.provider, request.body.spaState, request.body.prompt);
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
- type Query = undefined;
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(_: DecodedRequest<Params, Query, Body>) {
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
- if (organization) {
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 { OpenIDConnectHelper } from '../../../helpers/OpenIDConnectHelper';
7
- import { Platform } from '@stamhoofd/models';
6
+ import { SSOService } from '../../../services/SSOService';
7
+ import { SSOQuery } from './GetSSOEndpoint';
8
8
 
9
9
  type Params = Record<string, never>;
10
- type Query = undefined;
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
- let newConfig: OpenIDClientConfiguration;
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
  }
@@ -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
- const header = this.request.headers.authorization;
154
- if (!header) {
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
- const header = this.request.headers.authorization;
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 { DecodedRequest, Response } from '@simonbackx/simple-endpoints';
1
+ import { Response } from '@simonbackx/simple-endpoints';
2
2
  import cookie from 'cookie';
3
+ import http from 'http';
3
4
 
4
- type DecodedRequestWithCookies = DecodedRequest<any, any, any> & { cookies?: Record<string, string> };
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: DecodedRequest<any, any, any>): Record<string, string> {
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: DecodedRequest<any, any, any>, name: string): string | undefined {
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
- }