@theshelf/authentication 0.3.1 → 0.4.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/README.md CHANGED
@@ -1,16 +1,7 @@
1
1
 
2
- # Authentication | The Shelf
2
+ # Authentication core | The Shelf
3
3
 
4
- The authentication package provides a universal interaction layer with an actual identity provider solution.
5
-
6
- This package is based on the following authentication flow:
7
-
8
- 1. Browser redirects to the IDP login page.
9
- 2. User authenticate at the IDP.
10
- 3. IDP provides identity information to this package.
11
- 4. This package starts a session based on the provided identity.
12
- 5. Sessions can be refreshed via this package.
13
- 6. Until the user logs out via this package.
4
+ This package contains the definition of the authentication flow. It uses a interchangeable driver system for performing the actual operations.
14
5
 
15
6
  ## Installation
16
7
 
@@ -18,54 +9,20 @@ This package is based on the following authentication flow:
18
9
  npm install @theshelf/authentication
19
10
  ```
20
11
 
21
- ## Drivers
22
-
23
- Currently, there are two drivers available:
24
-
25
- * **OpenID** - persistent document storage.
26
- * **Google** - authentication via Google accounts
27
-
28
12
  ## How to use
29
13
 
30
14
  The basic set up looks like this.
31
15
 
32
16
  ```ts
33
- import IdentityProvider, { OpenIDDriver | GoogleDriver as SelectedDriver } from '@theshelf/authentication';
17
+ import IdentityProvider from '@theshelf/authentication';
18
+ import driver from '/path/to/driver';
34
19
 
35
- const driver = new SelectedDriver(/* configuration */);
36
20
  const identityProvider = new IdentityProvider(driver);
37
21
 
38
22
  // Perform operations with the identityProvider instance
39
23
  ```
40
24
 
41
- ### Configuration
42
-
43
- #### OpenID driver
44
-
45
- ```ts
46
- type OpenIDConfiguration = {
47
- issuer: string; // URL to the provider
48
- clientId: string; // provided by the provider
49
- clientSecret: string; // provided by the provider
50
- redirectPath: string; // e.g. "https://application.com/login"
51
- allowInsecureRequests: boolean; // only set to false in development
52
- };
53
- ```
54
-
55
- #### Google driver
56
-
57
- ```ts
58
- type GoogleConfiguration = {
59
- issuer: string; // "https://accounts.google.com"
60
- clientId: string; // provided by Google
61
- clientSecret: string; // provided by Google
62
- redirectPath: string; // e.g. "https://application.com/login"
63
- accessType: string; // "online" | "offline"
64
- organizationDomain: string; // "application.com"
65
- };
66
- ```
67
-
68
- ### Operations
25
+ ## Operations
69
26
 
70
27
  ```ts
71
28
  import { Session } from '@theshelf/authentication';
@@ -91,12 +48,13 @@ const secondSession: Session = await identityProvider.refresh(firstSession);
91
48
  await identityProvider.logout(secondSession);
92
49
  ```
93
50
 
94
- ### Session structure
51
+ ## Types
95
52
 
96
53
  The session has the following structure.
97
54
 
98
55
  ```ts
99
56
  type Session = {
57
+ id: string;
100
58
  key?: string;
101
59
  requester?: unknown;
102
60
  identity: Identity;
@@ -1,8 +1,9 @@
1
+ import type Logger from '@theshelf/logging';
1
2
  import type { Driver } from './definitions/interfaces.js';
2
3
  import type { Session } from './definitions/types.js';
3
- export default class IdentityProvider implements Driver {
4
+ export default class IdentityProvider {
4
5
  #private;
5
- constructor(driver: Driver);
6
+ constructor(driver: Driver, logger?: Logger);
6
7
  get connected(): boolean;
7
8
  connect(): Promise<void>;
8
9
  disconnect(): Promise<void>;
@@ -1,27 +1,90 @@
1
+ import NotConnected from './errors/NotConnected.js';
1
2
  export default class IdentityProvider {
2
3
  #driver;
3
- constructor(driver) {
4
+ #logger;
5
+ #logPrefix;
6
+ constructor(driver, logger) {
4
7
  this.#driver = driver;
8
+ this.#logger = logger?.for(IdentityProvider.name);
9
+ this.#logPrefix = `${this.#driver.name} ->`;
5
10
  }
6
11
  get connected() {
7
12
  return this.#driver.connected;
8
13
  }
9
- connect() {
10
- return this.#driver.connect();
14
+ async connect() {
15
+ if (this.connected === true) {
16
+ return;
17
+ }
18
+ this.#logger?.debug(this.#logPrefix, 'Connecting');
19
+ try {
20
+ await this.#driver.connect();
21
+ }
22
+ catch (error) {
23
+ this.#logger?.error(this.#logPrefix, 'Connect failed with error', error);
24
+ throw error;
25
+ }
11
26
  }
12
- disconnect() {
13
- return this.#driver.disconnect();
27
+ async disconnect() {
28
+ if (this.connected === false) {
29
+ return;
30
+ }
31
+ this.#logger?.debug(this.#logPrefix, 'Disconnecting');
32
+ try {
33
+ return await this.#driver.disconnect();
34
+ }
35
+ catch (error) {
36
+ this.#logger?.error(this.#logPrefix, 'Disconnect failed with error', error);
37
+ throw error;
38
+ }
14
39
  }
15
- getLoginUrl(origin) {
16
- return this.#driver.getLoginUrl(origin);
40
+ async getLoginUrl(origin) {
41
+ this.#logger?.debug(this.#logPrefix, 'Getting login URL for origin', origin);
42
+ try {
43
+ this.#validateConnection();
44
+ return await this.#driver.getLoginUrl(origin);
45
+ }
46
+ catch (error) {
47
+ this.#logger?.error(this.#logPrefix, 'Get login URL for origin', origin, 'failed with error', error);
48
+ throw error;
49
+ }
17
50
  }
18
- login(origin, data) {
19
- return this.#driver.login(origin, data);
51
+ async login(origin, data) {
52
+ this.#logger?.debug(this.#logPrefix, 'Logging in');
53
+ try {
54
+ this.#validateConnection();
55
+ return await this.#driver.login(origin, data);
56
+ }
57
+ catch (error) {
58
+ // Do NOT log data, as it might contain sensitive information
59
+ this.#logger?.error(this.#logPrefix, 'Login for origin', origin, 'failed with error', error);
60
+ throw error;
61
+ }
20
62
  }
21
- refresh(session) {
22
- return this.#driver.refresh(session);
63
+ async refresh(session) {
64
+ this.#logger?.debug(this.#logPrefix, 'Refreshing session');
65
+ try {
66
+ this.#validateConnection();
67
+ return await this.#driver.refresh(session);
68
+ }
69
+ catch (error) {
70
+ this.#logger?.error(this.#logPrefix, 'Refresh session for', session.id, 'failed with error', error);
71
+ throw error;
72
+ }
23
73
  }
24
- logout(session) {
25
- return this.#driver.logout(session);
74
+ async logout(session) {
75
+ this.#logger?.debug(this.#logPrefix, 'Logging out');
76
+ try {
77
+ this.#validateConnection();
78
+ return await this.#driver.logout(session);
79
+ }
80
+ catch (error) {
81
+ this.#logger?.error(this.#logPrefix, 'Logout session for', session.id, 'failed with error', error);
82
+ throw error;
83
+ }
84
+ }
85
+ #validateConnection() {
86
+ if (this.connected === false) {
87
+ throw new NotConnected();
88
+ }
26
89
  }
27
90
  }
@@ -1,5 +1,6 @@
1
1
  import type { Session } from './types.js';
2
2
  export interface Driver {
3
+ get name(): string;
3
4
  get connected(): boolean;
4
5
  connect(): Promise<void>;
5
6
  disconnect(): Promise<void>;
@@ -7,6 +7,7 @@ type Identity = {
7
7
  };
8
8
  type Token = string;
9
9
  type Session = {
10
+ id: string;
10
11
  key?: string;
11
12
  requester?: unknown;
12
13
  identity: Identity;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- export type * from './definitions/interfaces.js';
2
- export type * from './definitions/types.js';
1
+ export type { Driver } from './definitions/interfaces.js';
2
+ export type { Identity, Session } from './definitions/types.js';
3
3
  export { default as AuthenticationError } from './errors/AuthenticationError.js';
4
4
  export { default as NotConnected } from './errors/NotConnected.js';
5
- export { default as GoogleDriver } from './drivers/Google.js';
6
- export { default as OpenIDDriver } from './drivers/OpenID.js';
5
+ export { default as LoginFailed } from './errors/LoginFailed.js';
6
+ export { default as RefreshFailed } from './errors/RefreshFailed.js';
7
+ export { default as generateId } from './utils/generateId.js';
7
8
  export { default } from './IdentityProvider.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { default as AuthenticationError } from './errors/AuthenticationError.js';
2
2
  export { default as NotConnected } from './errors/NotConnected.js';
3
- export { default as GoogleDriver } from './drivers/Google.js';
4
- export { default as OpenIDDriver } from './drivers/OpenID.js';
3
+ export { default as LoginFailed } from './errors/LoginFailed.js';
4
+ export { default as RefreshFailed } from './errors/RefreshFailed.js';
5
+ export { default as generateId } from './utils/generateId.js';
5
6
  export { default } from './IdentityProvider.js';
@@ -0,0 +1 @@
1
+ export default function generateId(): string;
@@ -0,0 +1,4 @@
1
+ import crypto from 'node:crypto';
2
+ export default function generateId() {
3
+ return crypto.randomUUID();
4
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@theshelf/authentication",
3
3
  "private": false,
4
- "version": "0.3.1",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "url": "git+https://github.com/MaskingTechnology/theshelf.git"
8
8
  },
9
+ "license": "MIT",
9
10
  "scripts": {
10
11
  "build": "tsc",
11
12
  "clean": "rimraf dist",
@@ -17,9 +18,9 @@
17
18
  "README.md",
18
19
  "dist"
19
20
  ],
20
- "types": "dist/index.d.ts",
21
+ "types": "./dist/index.d.ts",
21
22
  "exports": "./dist/index.js",
22
- "dependencies": {
23
- "openid-client": "6.8.1"
23
+ "peerDependencies": {
24
+ "@theshelf/logging": "^0.4.0"
24
25
  }
25
26
  }
@@ -1,22 +0,0 @@
1
- import type { Driver } from '../definitions/interfaces.js';
2
- import type { Session } from '../definitions/types.js';
3
- type GoogleConfiguration = {
4
- issuer: string;
5
- clientId: string;
6
- clientSecret: string;
7
- redirectPath: string;
8
- accessType: string;
9
- organizationDomain: string;
10
- };
11
- export default class OpenID implements Driver {
12
- #private;
13
- constructor(configuration: GoogleConfiguration);
14
- get connected(): boolean;
15
- connect(): Promise<void>;
16
- disconnect(): Promise<void>;
17
- getLoginUrl(origin: string): Promise<string>;
18
- login(origin: string, data: Record<string, unknown>): Promise<Session>;
19
- refresh(session: Session): Promise<Session>;
20
- logout(session: Session): Promise<void>;
21
- }
22
- export {};
@@ -1,151 +0,0 @@
1
- import { authorizationCodeGrant, buildAuthorizationUrl, calculatePKCECodeChallenge, discovery, fetchUserInfo, randomNonce, randomPKCECodeVerifier, refreshTokenGrant, tokenRevocation } from 'openid-client';
2
- import crypto from 'node:crypto';
3
- import LoginFailed from '../errors/LoginFailed.js';
4
- import RefreshFailed from '../errors/RefreshFailed.js';
5
- import NotConnected from '../errors/NotConnected.js';
6
- const SECRET = crypto.randomUUID() + crypto.randomUUID();
7
- const TTL = 30000;
8
- export default class OpenID {
9
- #providerConfiguration;
10
- #clientConfiguration;
11
- #codeVerifier = randomPKCECodeVerifier();
12
- constructor(configuration) {
13
- this.#providerConfiguration = configuration;
14
- }
15
- get connected() {
16
- return this.#clientConfiguration !== undefined;
17
- }
18
- async connect() {
19
- const issuer = new URL(this.#providerConfiguration.issuer);
20
- const clientId = this.#providerConfiguration.clientId;
21
- const clientSecret = this.#providerConfiguration.clientSecret;
22
- this.#clientConfiguration = await discovery(issuer, clientId, clientSecret);
23
- }
24
- async disconnect() {
25
- this.#clientConfiguration = undefined;
26
- }
27
- async getLoginUrl(origin) {
28
- const redirect_uri = new URL(this.#providerConfiguration.redirectPath, origin).href;
29
- const scope = 'openid profile email';
30
- const code_challenge = await calculatePKCECodeChallenge(this.#codeVerifier);
31
- const code_challenge_method = 'S256';
32
- const access_type = this.#providerConfiguration.accessType;
33
- const hd = this.#providerConfiguration.organizationDomain;
34
- const payload = this.#createPayload();
35
- const state = this.#calculateSignature(payload);
36
- const parameters = {
37
- redirect_uri,
38
- scope,
39
- code_challenge,
40
- code_challenge_method,
41
- access_type,
42
- hd,
43
- prompt: 'consent',
44
- nonce: payload.nonce,
45
- state
46
- };
47
- const clientConfiguration = this.#getClientConfiguration();
48
- const redirectTo = buildAuthorizationUrl(clientConfiguration, parameters);
49
- return redirectTo.href;
50
- }
51
- async login(origin, data) {
52
- const clientConfiguration = this.#getClientConfiguration();
53
- const url = new URL(this.#providerConfiguration.redirectPath, origin);
54
- for (const [key, value] of Object.entries(data)) {
55
- url.searchParams.set(key, String(value));
56
- }
57
- const payload = this.#getPayload(data.state);
58
- const tokens = await authorizationCodeGrant(clientConfiguration, url, {
59
- pkceCodeVerifier: this.#codeVerifier,
60
- expectedNonce: payload.nonce,
61
- expectedState: data.state,
62
- idTokenExpected: true
63
- });
64
- const access_token = tokens.access_token;
65
- const claims = this.#getClaims(tokens);
66
- const sub = claims.sub;
67
- const expires = claims.exp * 1000;
68
- const userInfo = await fetchUserInfo(clientConfiguration, access_token, sub);
69
- const identity = {
70
- name: userInfo.name,
71
- nickname: userInfo.nickname,
72
- picture: userInfo.picture,
73
- email: userInfo.email,
74
- email_verified: userInfo.email_verified
75
- };
76
- return {
77
- identity: identity,
78
- accessToken: tokens.access_token,
79
- refreshToken: tokens.refresh_token,
80
- expires: new Date(expires)
81
- };
82
- }
83
- async refresh(session) {
84
- if (session.refreshToken === undefined) {
85
- throw new RefreshFailed('Missing refresh token');
86
- }
87
- const config = this.#getClientConfiguration();
88
- const tokens = await refreshTokenGrant(config, session.refreshToken);
89
- const claims = this.#getClaims(tokens);
90
- const expires = claims.exp * 1000;
91
- return {
92
- requester: session.requester,
93
- identity: session.identity,
94
- accessToken: tokens.access_token,
95
- refreshToken: tokens.refresh_token,
96
- expires: new Date(expires)
97
- };
98
- }
99
- logout(session) {
100
- const config = this.#getClientConfiguration();
101
- return tokenRevocation(config, session.refreshToken ?? session.accessToken);
102
- }
103
- #getClientConfiguration() {
104
- if (this.#clientConfiguration === undefined) {
105
- throw new NotConnected('Google client not connected');
106
- }
107
- return this.#clientConfiguration;
108
- }
109
- #getClaims(tokens) {
110
- const claims = tokens.claims();
111
- if (claims === undefined) {
112
- throw new LoginFailed('No claims in ID token');
113
- }
114
- return claims;
115
- }
116
- #createPayload() {
117
- return {
118
- jti: crypto.randomUUID(),
119
- nonce: randomNonce(),
120
- iat: Date.now(),
121
- exp: Date.now() + TTL
122
- };
123
- }
124
- #calculateSignature(payload) {
125
- const data = JSON.stringify(payload);
126
- const value = Buffer.from(data).toString('base64url');
127
- const signature = Buffer.from(crypto.createHmac("sha512", SECRET).update(data).digest()).toString('base64url');
128
- return `${value}.${signature}`;
129
- }
130
- #getPayload(state) {
131
- if (typeof state !== 'string') {
132
- throw new LoginFailed('Invalid state');
133
- }
134
- if (state.includes('.') === false) {
135
- throw new LoginFailed('Invalid state');
136
- }
137
- const [value, signature] = state.split('.');
138
- const decodedValue = Buffer.from(value, 'base64').toString('utf8');
139
- const decodedSignature = Buffer.from(signature, 'base64');
140
- const check = Buffer.from(crypto.createHmac("sha512", SECRET).update(decodedValue).digest());
141
- if (crypto.timingSafeEqual(check, decodedSignature) === false) {
142
- throw new LoginFailed('Invalid state');
143
- }
144
- const payload = JSON.parse(decodedValue);
145
- const now = Date.now();
146
- if (payload.iat > now || payload.exp < now) {
147
- throw new LoginFailed('Invalid state');
148
- }
149
- return payload;
150
- }
151
- }
@@ -1,21 +0,0 @@
1
- import type { Driver } from '../definitions/interfaces.js';
2
- import type { Session } from '../definitions/types.js';
3
- type OpenIDConfiguration = {
4
- issuer: string;
5
- clientId: string;
6
- clientSecret: string;
7
- redirectPath: string;
8
- allowInsecureRequests: boolean;
9
- };
10
- export default class OpenID implements Driver {
11
- #private;
12
- constructor(configuration: OpenIDConfiguration);
13
- get connected(): boolean;
14
- connect(): Promise<void>;
15
- disconnect(): Promise<void>;
16
- getLoginUrl(origin: string): Promise<string>;
17
- login(origin: string, data: Record<string, unknown>): Promise<Session>;
18
- refresh(session: Session): Promise<Session>;
19
- logout(session: Session): Promise<void>;
20
- }
21
- export {};
@@ -1,109 +0,0 @@
1
- import { allowInsecureRequests, authorizationCodeGrant, buildAuthorizationUrlWithPAR, calculatePKCECodeChallenge, discovery, fetchUserInfo, randomPKCECodeVerifier, refreshTokenGrant, tokenRevocation } from 'openid-client';
2
- import LoginFailed from '../errors/LoginFailed.js';
3
- import RefreshFailed from '../errors/RefreshFailed.js';
4
- import NotConnected from '../errors/NotConnected.js';
5
- export default class OpenID {
6
- #providerConfiguration;
7
- #clientConfiguration;
8
- #codeVerifier = randomPKCECodeVerifier();
9
- constructor(configuration) {
10
- this.#providerConfiguration = configuration;
11
- }
12
- get connected() {
13
- return this.#clientConfiguration !== undefined;
14
- }
15
- async connect() {
16
- const issuer = new URL(this.#providerConfiguration.issuer);
17
- const clientId = this.#providerConfiguration.clientId;
18
- const clientSecret = this.#providerConfiguration.clientSecret;
19
- const requestOptions = this.#getRequestOptions();
20
- this.#clientConfiguration = await discovery(issuer, clientId, clientSecret, undefined, requestOptions);
21
- }
22
- async disconnect() {
23
- this.#clientConfiguration = undefined;
24
- }
25
- async getLoginUrl(origin) {
26
- const redirect_uri = new URL(this.#providerConfiguration.redirectPath, origin).href;
27
- const scope = 'openid profile email';
28
- const code_challenge = await calculatePKCECodeChallenge(this.#codeVerifier);
29
- const code_challenge_method = 'S256';
30
- const parameters = {
31
- redirect_uri,
32
- scope,
33
- code_challenge,
34
- code_challenge_method
35
- };
36
- const clientConfiguration = this.#getClientConfiguration();
37
- const redirectTo = await buildAuthorizationUrlWithPAR(clientConfiguration, parameters);
38
- return redirectTo.href;
39
- }
40
- async login(origin, data) {
41
- const clientConfiguration = this.#getClientConfiguration();
42
- const url = new URL(this.#providerConfiguration.redirectPath, origin);
43
- for (const [key, value] of Object.entries(data)) {
44
- url.searchParams.set(key, String(value));
45
- }
46
- const tokens = await authorizationCodeGrant(clientConfiguration, url, {
47
- pkceCodeVerifier: this.#codeVerifier,
48
- idTokenExpected: true
49
- });
50
- const access_token = tokens.access_token;
51
- const claims = this.#getClaims(tokens);
52
- const sub = claims.sub;
53
- const expires = claims.exp * 1000;
54
- const userInfo = await fetchUserInfo(clientConfiguration, access_token, sub);
55
- const identity = {
56
- name: userInfo.name,
57
- nickname: userInfo.nickname,
58
- picture: userInfo.picture,
59
- email: userInfo.email,
60
- email_verified: userInfo.email_verified
61
- };
62
- return {
63
- identity: identity,
64
- accessToken: tokens.access_token,
65
- refreshToken: tokens.refresh_token,
66
- expires: new Date(expires)
67
- };
68
- }
69
- async refresh(session) {
70
- if (session.refreshToken === undefined) {
71
- throw new RefreshFailed('Missing refresh token');
72
- }
73
- const config = this.#getClientConfiguration();
74
- const tokens = await refreshTokenGrant(config, session.refreshToken);
75
- const claims = this.#getClaims(tokens);
76
- const expires = claims.exp * 1000;
77
- return {
78
- requester: session.requester,
79
- identity: session.identity,
80
- accessToken: tokens.access_token,
81
- refreshToken: tokens.refresh_token,
82
- expires: new Date(expires)
83
- };
84
- }
85
- logout(session) {
86
- const config = this.#getClientConfiguration();
87
- return tokenRevocation(config, session.refreshToken ?? session.accessToken);
88
- }
89
- #getClientConfiguration() {
90
- if (this.#clientConfiguration === undefined) {
91
- throw new NotConnected('OpenID client not connected');
92
- }
93
- return this.#clientConfiguration;
94
- }
95
- #getRequestOptions() {
96
- const options = {};
97
- if (this.#providerConfiguration.allowInsecureRequests) {
98
- options.execute = [allowInsecureRequests];
99
- }
100
- return options;
101
- }
102
- #getClaims(tokens) {
103
- const claims = tokens.claims();
104
- if (claims === undefined) {
105
- throw new LoginFailed('No claims in ID token');
106
- }
107
- return claims;
108
- }
109
- }