@theshelf/authentication 0.0.4 → 0.2.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
@@ -18,45 +18,57 @@ This package is based on the following authentication flow:
18
18
  npm install @theshelf/authentication
19
19
  ```
20
20
 
21
- ## Implementations
21
+ ## Drivers
22
22
 
23
- Currently, there is only one implementation:
23
+ Currently, there are two drivers available:
24
24
 
25
25
  * **OpenID** - persistent document storage.
26
+ * **Google** - authentication via Google accounts
26
27
 
27
- ## Configuration
28
+ ## How to use
28
29
 
29
- The used implementation needs to be configured in the `.env` file, together with the client URL.
30
+ The basic set up looks like this.
30
31
 
31
- ```env
32
- AUTHENTICATION_IMPLEMENTATION="openid"
33
- AUTHENTICATION_CLIENT_URI="https://application.com/authenticate"
34
- ```
32
+ ```ts
33
+ import IdentityProvider, { OpenIDDriver | GoogleDriver as SelectedDriver } from '@theshelf/authentication';
35
34
 
36
- In case of OpenID, additional configuration is required.
35
+ const driver = new SelectedDriver(/* configuration */);
36
+ const identityProvider = new IdentityProvider(driver);
37
37
 
38
- ```env
39
- OPENID_ISSUER="https://identityprovider.com"
40
- OPENID_CLIENT_ID="openid"
41
- OPENID_CLIENT_SECRET=""
42
- OPENID_REDIRECT_PATH="https://application.com/login"
43
- OPENID_ALLOW_INSECURE_REQUESTS=false
38
+ // Perform operations with the identityProvider instance
44
39
  ```
45
40
 
46
- ## How to use
41
+ ### Configuration
47
42
 
48
- An instance of the configured identity provider implementation can be imported for performing authentication operations.
43
+ #### OpenID driver
49
44
 
50
45
  ```ts
51
- import identityProvider from '@theshelf/authentication';
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
+ ```
52
54
 
53
- // Perform operations with the identityProvider instance
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
+ };
54
66
  ```
55
67
 
56
68
  ### Operations
57
69
 
58
70
  ```ts
59
- import identityProvider, { Session } from '@theshelf/authentication';
71
+ import { Session } from '@theshelf/authentication';
60
72
 
61
73
  // Open connection
62
74
  await identityProvider.connect();
@@ -0,0 +1,9 @@
1
+ import type { ConnectionState } from './definitions/constants.js';
2
+ import type { Driver } from './definitions/interfaces.js';
3
+ export default class ConnectionManager {
4
+ #private;
5
+ constructor(driver: Driver);
6
+ get state(): ConnectionState;
7
+ connect(): Promise<void>;
8
+ disconnect(): Promise<void>;
9
+ }
@@ -0,0 +1,53 @@
1
+ import { ConnectionStates } from './definitions/constants.js';
2
+ export default class ConnectionManager {
3
+ #driver;
4
+ #state = ConnectionStates.DISCONNECTED;
5
+ #connectPromise;
6
+ #disconnectPromise;
7
+ constructor(driver) {
8
+ this.#driver = driver;
9
+ }
10
+ get state() { return this.#state; }
11
+ async connect() {
12
+ if (this.#connectPromise !== undefined) {
13
+ return this.#connectPromise;
14
+ }
15
+ if (this.#state !== ConnectionStates.DISCONNECTED) {
16
+ return;
17
+ }
18
+ this.#state = ConnectionStates.CONNECTING;
19
+ try {
20
+ this.#connectPromise = this.#driver.connect();
21
+ await this.#connectPromise;
22
+ this.#state = ConnectionStates.CONNECTED;
23
+ }
24
+ catch (error) {
25
+ this.#state = ConnectionStates.DISCONNECTED;
26
+ throw error;
27
+ }
28
+ finally {
29
+ this.#connectPromise = undefined;
30
+ }
31
+ }
32
+ async disconnect() {
33
+ if (this.#disconnectPromise !== undefined) {
34
+ return this.#disconnectPromise;
35
+ }
36
+ if (this.#state !== ConnectionStates.CONNECTED) {
37
+ return;
38
+ }
39
+ this.#state = ConnectionStates.DISCONNECTING;
40
+ try {
41
+ this.#disconnectPromise = this.#driver.disconnect();
42
+ await this.#disconnectPromise;
43
+ this.#state = ConnectionStates.DISCONNECTED;
44
+ }
45
+ catch (error) {
46
+ this.#state = ConnectionStates.CONNECTED;
47
+ throw error;
48
+ }
49
+ finally {
50
+ this.#disconnectPromise = undefined;
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,15 @@
1
+ import type { ConnectionState } from './definitions/constants.js';
2
+ import type { Driver } from './definitions/interfaces.js';
3
+ import type { Session } from './definitions/types.js';
4
+ export default class IdentityProvider implements Driver {
5
+ #private;
6
+ constructor(driver: Driver);
7
+ get connectionState(): ConnectionState;
8
+ get connected(): boolean;
9
+ connect(): Promise<void>;
10
+ disconnect(): Promise<void>;
11
+ getLoginUrl(origin: string): Promise<string>;
12
+ login(origin: string, data: Record<string, unknown>): Promise<Session>;
13
+ refresh(session: Session): Promise<Session>;
14
+ logout(session: Session): Promise<void>;
15
+ }
@@ -0,0 +1,34 @@
1
+ import { ConnectionStates } from './definitions/constants.js';
2
+ import ConnectionManager from './ConnectionManager.js';
3
+ export default class IdentityProvider {
4
+ #driver;
5
+ #connectionManager;
6
+ constructor(driver) {
7
+ this.#driver = driver;
8
+ this.#connectionManager = new ConnectionManager(driver);
9
+ }
10
+ get connectionState() {
11
+ return this.#connectionManager.state;
12
+ }
13
+ get connected() {
14
+ return this.connectionState === ConnectionStates.CONNECTED;
15
+ }
16
+ connect() {
17
+ return this.#connectionManager.connect();
18
+ }
19
+ disconnect() {
20
+ return this.#connectionManager.disconnect();
21
+ }
22
+ getLoginUrl(origin) {
23
+ return this.#driver.getLoginUrl(origin);
24
+ }
25
+ login(origin, data) {
26
+ return this.#driver.login(origin, data);
27
+ }
28
+ refresh(session) {
29
+ return this.#driver.refresh(session);
30
+ }
31
+ logout(session) {
32
+ return this.#driver.logout(session);
33
+ }
34
+ }
@@ -0,0 +1,7 @@
1
+ export declare const ConnectionStates: {
2
+ readonly DISCONNECTED: "DISCONNECTED";
3
+ readonly DISCONNECTING: "DISCONNECTING";
4
+ readonly CONNECTING: "CONNECTING";
5
+ readonly CONNECTED: "CONNECTED";
6
+ };
7
+ export type ConnectionState = typeof ConnectionStates[keyof typeof ConnectionStates];
@@ -0,0 +1,6 @@
1
+ export const ConnectionStates = {
2
+ DISCONNECTED: 'DISCONNECTED',
3
+ DISCONNECTING: 'DISCONNECTING',
4
+ CONNECTING: 'CONNECTING',
5
+ CONNECTED: 'CONNECTED'
6
+ };
@@ -1,5 +1,5 @@
1
1
  import type { Session } from './types.js';
2
- export interface IdentityProvider {
2
+ export interface Driver {
3
3
  get connected(): boolean;
4
4
  connect(): Promise<void>;
5
5
  disconnect(): Promise<void>;
@@ -11,7 +11,7 @@ type Session = {
11
11
  requester?: unknown;
12
12
  identity: Identity;
13
13
  accessToken: Token;
14
- refreshToken: Token;
14
+ refreshToken?: Token;
15
15
  expires: Date;
16
16
  };
17
17
  export type { Identity, Session };
@@ -0,0 +1,22 @@
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 {};
@@ -0,0 +1,151 @@
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,5 +1,5 @@
1
- import type { IdentityProvider } from '../../definitions/interfaces.js';
2
- import type { Session } from '../../definitions/types.js';
1
+ import type { Driver } from '../definitions/interfaces.js';
2
+ import type { Session } from '../definitions/types.js';
3
3
  type OpenIDConfiguration = {
4
4
  issuer: string;
5
5
  clientId: string;
@@ -7,7 +7,7 @@ type OpenIDConfiguration = {
7
7
  redirectPath: string;
8
8
  allowInsecureRequests: boolean;
9
9
  };
10
- export default class OpenID implements IdentityProvider {
10
+ export default class OpenID implements Driver {
11
11
  #private;
12
12
  constructor(configuration: OpenIDConfiguration);
13
13
  get connected(): boolean;
@@ -1,6 +1,7 @@
1
1
  import { allowInsecureRequests, authorizationCodeGrant, buildAuthorizationUrlWithPAR, calculatePKCECodeChallenge, discovery, fetchUserInfo, randomPKCECodeVerifier, refreshTokenGrant, tokenRevocation } from 'openid-client';
2
- import LoginFailed from '../../errors/LoginFailed.js';
3
- import NotConnected from '../../errors/NotConnected.js';
2
+ import LoginFailed from '../errors/LoginFailed.js';
3
+ import RefreshFailed from '../errors/RefreshFailed.js';
4
+ import NotConnected from '../errors/NotConnected.js';
4
5
  export default class OpenID {
5
6
  #providerConfiguration;
6
7
  #clientConfiguration;
@@ -39,9 +40,9 @@ export default class OpenID {
39
40
  async login(origin, data) {
40
41
  const clientConfiguration = this.#getClientConfiguration();
41
42
  const url = new URL(this.#providerConfiguration.redirectPath, origin);
42
- url.searchParams.set('session_state', data.session_state);
43
- url.searchParams.set('iss', data.iss);
44
- url.searchParams.set('code', data.code);
43
+ for (const [key, value] of Object.entries(data)) {
44
+ url.searchParams.set(key, String(value));
45
+ }
45
46
  const tokens = await authorizationCodeGrant(clientConfiguration, url, {
46
47
  pkceCodeVerifier: this.#codeVerifier,
47
48
  idTokenExpected: true
@@ -66,6 +67,9 @@ export default class OpenID {
66
67
  };
67
68
  }
68
69
  async refresh(session) {
70
+ if (session.refreshToken === undefined) {
71
+ throw new RefreshFailed('Missing refresh token');
72
+ }
69
73
  const config = this.#getClientConfiguration();
70
74
  const tokens = await refreshTokenGrant(config, session.refreshToken);
71
75
  const claims = this.#getClaims(tokens);
@@ -80,7 +84,7 @@ export default class OpenID {
80
84
  }
81
85
  logout(session) {
82
86
  const config = this.#getClientConfiguration();
83
- return tokenRevocation(config, session.refreshToken);
87
+ return tokenRevocation(config, session.refreshToken ?? session.accessToken);
84
88
  }
85
89
  #getClientConfiguration() {
86
90
  if (this.#clientConfiguration === undefined) {
@@ -1,2 +1,3 @@
1
1
  export default class AuthenticationError extends Error {
2
+ constructor(message?: string);
2
3
  }
@@ -1,2 +1,9 @@
1
1
  export default class AuthenticationError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = this.constructor.name;
5
+ if (Error.captureStackTrace) {
6
+ Error.captureStackTrace(this, this.constructor);
7
+ }
8
+ }
2
9
  }
@@ -0,0 +1,3 @@
1
+ import AuthenticationError from './AuthenticationError.js';
2
+ export default class RefreshFailed extends AuthenticationError {
3
+ }
@@ -0,0 +1,3 @@
1
+ import AuthenticationError from './AuthenticationError.js';
2
+ export default class RefreshFailed extends AuthenticationError {
3
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- export * from './definitions/interfaces.js';
2
- export * from './definitions/types.js';
1
+ export type * from './definitions/interfaces.js';
2
+ export type * 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 UnknownImplementation } from './errors/UnknownImplementation.js';
6
- export { default } from './implementation.js';
5
+ export { default as GoogleDriver } from './drivers/Google.js';
6
+ export { default as OpenIDDriver } from './drivers/OpenID.js';
7
+ export { default } from './IdentityProvider.js';
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
- export * from './definitions/interfaces.js';
2
- export * from './definitions/types.js';
3
1
  export { default as AuthenticationError } from './errors/AuthenticationError.js';
4
2
  export { default as NotConnected } from './errors/NotConnected.js';
5
- export { default as UnknownImplementation } from './errors/UnknownImplementation.js';
6
- export { default } from './implementation.js';
3
+ export { default as GoogleDriver } from './drivers/Google.js';
4
+ export { default as OpenIDDriver } from './drivers/OpenID.js';
5
+ export { default } from './IdentityProvider.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@theshelf/authentication",
3
3
  "private": false,
4
- "version": "0.0.4",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "url": "https://github.com/MaskingTechnology/theshelf"
@@ -1,4 +0,0 @@
1
- import AuthenticationError from './AuthenticationError.js';
2
- export default class UnknownImplementation extends AuthenticationError {
3
- constructor(name: string);
4
- }
@@ -1,6 +0,0 @@
1
- import AuthenticationError from './AuthenticationError.js';
2
- export default class UnknownImplementation extends AuthenticationError {
3
- constructor(name) {
4
- super(`Unknown authentication implementation: ${name}`);
5
- }
6
- }
@@ -1,3 +0,0 @@
1
- import type { IdentityProvider } from './definitions/interfaces.js';
2
- declare const _default: IdentityProvider;
3
- export default _default;
@@ -1,12 +0,0 @@
1
- import UnknownImplementation from './errors/UnknownImplementation.js';
2
- import createOpenID from './implementations/openid/create.js';
3
- const implementations = new Map([
4
- ['openid', createOpenID],
5
- ]);
6
- const DEFAULT_AUTHENTICATION_IMPLEMENTATION = 'openid';
7
- const implementationName = process.env.AUTHENTICATION_IMPLEMENTATION ?? DEFAULT_AUTHENTICATION_IMPLEMENTATION;
8
- const creator = implementations.get(implementationName.toLowerCase());
9
- if (creator === undefined) {
10
- throw new UnknownImplementation(implementationName);
11
- }
12
- export default creator();
@@ -1,2 +0,0 @@
1
- import OpenID from './OpenID.js';
2
- export default function create(): OpenID;
@@ -1,9 +0,0 @@
1
- import OpenID from './OpenID.js';
2
- export default function create() {
3
- const issuer = process.env.OPENID_ISSUER ?? 'undefined';
4
- const clientId = process.env.OPENID_CLIENT_ID ?? 'undefined';
5
- const clientSecret = process.env.OPENID_CLIENT_SECRET ?? 'undefined';
6
- const redirectPath = process.env.OPENID_REDIRECT_PATH ?? 'undefined';
7
- const allowInsecureRequests = process.env.OPENID_ALLOW_INSECURE_REQUESTS === 'true';
8
- return new OpenID({ issuer, clientId, clientSecret, redirectPath, allowInsecureRequests });
9
- }