@theshelf/authentication-driver-openid 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 +37 -0
- package/dist/OpenID.d.ts +22 -0
- package/dist/OpenID.js +170 -0
- package/dist/SecretManager.d.ts +12 -0
- package/dist/SecretManager.js +44 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
|
|
2
|
+
# Authentication OpenID driver | The Shelf
|
|
3
|
+
|
|
4
|
+
This package contains the driver implementation for OpenID. This driver can be used by the [core package](../../core/README.md) for performing the actual operations.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @theshelf/authentication @theshelf/authentication-driver-openid
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## How to use
|
|
13
|
+
|
|
14
|
+
The basic set up looks like this.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import IdentityProvider from '@theshelf/authentication';
|
|
18
|
+
import { OpenIDDriver } from '@theshelf/authentication-driver-openid';
|
|
19
|
+
|
|
20
|
+
const driver = new OpenIDDriver({/* Configuration options */});
|
|
21
|
+
const identityProvider = new IdentityProvider(driver);
|
|
22
|
+
|
|
23
|
+
// Perform operations with the identityProvider instance
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Configuration options
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
type OpenIDConfiguration = {
|
|
30
|
+
issuer: string; // URL to the provider
|
|
31
|
+
clientId: string; // provided by the provider
|
|
32
|
+
clientSecret: string; // provided by the provider
|
|
33
|
+
redirectPath: string; // e.g. "https://application.com/login"
|
|
34
|
+
secretKey: string; // a high entropy string for hmac
|
|
35
|
+
allowInsecureRequests: boolean; // only set to false in development
|
|
36
|
+
};
|
|
37
|
+
```
|
package/dist/OpenID.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Driver, Session } from '@theshelf/authentication';
|
|
2
|
+
type OpenIDConfiguration = {
|
|
3
|
+
issuer: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
redirectPath: string;
|
|
7
|
+
secretKey: string;
|
|
8
|
+
allowInsecureRequests: boolean;
|
|
9
|
+
};
|
|
10
|
+
export default class OpenID implements Driver {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(configuration: OpenIDConfiguration);
|
|
13
|
+
get name(): string;
|
|
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 {};
|
package/dist/OpenID.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { allowInsecureRequests, authorizationCodeGrant, buildAuthorizationUrlWithPAR, calculatePKCECodeChallenge, discovery, fetchUserInfo, randomNonce, randomPKCECodeVerifier, refreshTokenGrant, tokenRevocation } from 'openid-client';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { LoginFailed, RefreshFailed, NotConnected, generateId } from '@theshelf/authentication';
|
|
4
|
+
import SecretManager from './SecretManager.js';
|
|
5
|
+
const TTL = 30_000;
|
|
6
|
+
const HMAC_ALGORITHM = 'sha512';
|
|
7
|
+
const URL_ENCODING = 'base64url';
|
|
8
|
+
export default class OpenID {
|
|
9
|
+
#providerConfiguration;
|
|
10
|
+
#key;
|
|
11
|
+
#clientConfiguration;
|
|
12
|
+
#secretManager = new SecretManager();
|
|
13
|
+
constructor(configuration) {
|
|
14
|
+
this.#providerConfiguration = configuration;
|
|
15
|
+
this.#key = configuration.secretKey;
|
|
16
|
+
}
|
|
17
|
+
get name() { return OpenID.name; }
|
|
18
|
+
get connected() {
|
|
19
|
+
return this.#clientConfiguration !== undefined;
|
|
20
|
+
}
|
|
21
|
+
async connect() {
|
|
22
|
+
const issuer = new URL(this.#providerConfiguration.issuer);
|
|
23
|
+
const clientId = this.#providerConfiguration.clientId;
|
|
24
|
+
const clientSecret = this.#providerConfiguration.clientSecret;
|
|
25
|
+
const requestOptions = this.#getRequestOptions();
|
|
26
|
+
this.#clientConfiguration = await discovery(issuer, clientId, clientSecret, undefined, requestOptions);
|
|
27
|
+
this.#secretManager.start();
|
|
28
|
+
}
|
|
29
|
+
async disconnect() {
|
|
30
|
+
this.#secretManager.stop();
|
|
31
|
+
this.#clientConfiguration = undefined;
|
|
32
|
+
}
|
|
33
|
+
async getLoginUrl(origin) {
|
|
34
|
+
const redirect_uri = new URL(this.#providerConfiguration.redirectPath, origin).href;
|
|
35
|
+
const scope = 'openid profile email';
|
|
36
|
+
const nonce = randomNonce();
|
|
37
|
+
const codeVerifier = randomPKCECodeVerifier();
|
|
38
|
+
const code_challenge = await calculatePKCECodeChallenge(codeVerifier);
|
|
39
|
+
const code_challenge_method = 'S256';
|
|
40
|
+
const payload = this.#createPayload();
|
|
41
|
+
const state = this.#calculateState(payload);
|
|
42
|
+
const parameters = {
|
|
43
|
+
redirect_uri,
|
|
44
|
+
scope,
|
|
45
|
+
code_challenge,
|
|
46
|
+
code_challenge_method,
|
|
47
|
+
state,
|
|
48
|
+
nonce
|
|
49
|
+
};
|
|
50
|
+
const clientConfiguration = this.#getClientConfiguration();
|
|
51
|
+
const redirectTo = await buildAuthorizationUrlWithPAR(clientConfiguration, parameters);
|
|
52
|
+
const secret = { codeVerifier, nonce };
|
|
53
|
+
this.#secretManager.set(state, secret);
|
|
54
|
+
return redirectTo.href;
|
|
55
|
+
}
|
|
56
|
+
async login(origin, data) {
|
|
57
|
+
const clientConfiguration = this.#getClientConfiguration();
|
|
58
|
+
const url = new URL(this.#providerConfiguration.redirectPath, origin);
|
|
59
|
+
for (const [key, value] of Object.entries(data)) {
|
|
60
|
+
url.searchParams.set(key, String(value));
|
|
61
|
+
}
|
|
62
|
+
const state = this.#getState(data);
|
|
63
|
+
const secret = this.#secretManager.get(state);
|
|
64
|
+
if (secret === undefined) {
|
|
65
|
+
throw new LoginFailed('Missing secret');
|
|
66
|
+
}
|
|
67
|
+
const tokens = await authorizationCodeGrant(clientConfiguration, url, {
|
|
68
|
+
pkceCodeVerifier: secret.codeVerifier,
|
|
69
|
+
expectedNonce: secret.nonce,
|
|
70
|
+
expectedState: state,
|
|
71
|
+
idTokenExpected: true
|
|
72
|
+
});
|
|
73
|
+
const access_token = tokens.access_token;
|
|
74
|
+
const claims = this.#getClaims(tokens);
|
|
75
|
+
const sub = claims.sub;
|
|
76
|
+
const expires = claims.exp * 1000;
|
|
77
|
+
const userInfo = await fetchUserInfo(clientConfiguration, access_token, sub);
|
|
78
|
+
const identity = {
|
|
79
|
+
name: userInfo.name,
|
|
80
|
+
nickname: userInfo.nickname,
|
|
81
|
+
picture: userInfo.picture,
|
|
82
|
+
email: userInfo.email,
|
|
83
|
+
email_verified: userInfo.email_verified
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
id: generateId(),
|
|
87
|
+
identity: identity,
|
|
88
|
+
accessToken: tokens.access_token,
|
|
89
|
+
refreshToken: tokens.refresh_token,
|
|
90
|
+
expires: new Date(expires)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async refresh(session) {
|
|
94
|
+
if (session.refreshToken === undefined) {
|
|
95
|
+
throw new RefreshFailed('Missing refresh token');
|
|
96
|
+
}
|
|
97
|
+
const config = this.#getClientConfiguration();
|
|
98
|
+
const tokens = await refreshTokenGrant(config, session.refreshToken);
|
|
99
|
+
const claims = this.#getClaims(tokens);
|
|
100
|
+
const expires = claims.exp * 1000;
|
|
101
|
+
return {
|
|
102
|
+
id: session.id,
|
|
103
|
+
requester: session.requester,
|
|
104
|
+
identity: session.identity,
|
|
105
|
+
accessToken: tokens.access_token,
|
|
106
|
+
refreshToken: tokens.refresh_token,
|
|
107
|
+
expires: new Date(expires)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
logout(session) {
|
|
111
|
+
const config = this.#getClientConfiguration();
|
|
112
|
+
return tokenRevocation(config, session.refreshToken ?? session.accessToken);
|
|
113
|
+
}
|
|
114
|
+
#getClientConfiguration() {
|
|
115
|
+
if (this.#clientConfiguration === undefined) {
|
|
116
|
+
throw new NotConnected('OpenID client not connected');
|
|
117
|
+
}
|
|
118
|
+
return this.#clientConfiguration;
|
|
119
|
+
}
|
|
120
|
+
#getRequestOptions() {
|
|
121
|
+
const options = {};
|
|
122
|
+
if (this.#providerConfiguration.allowInsecureRequests) {
|
|
123
|
+
options.execute = [allowInsecureRequests];
|
|
124
|
+
}
|
|
125
|
+
return options;
|
|
126
|
+
}
|
|
127
|
+
#getClaims(tokens) {
|
|
128
|
+
const claims = tokens.claims();
|
|
129
|
+
if (claims === undefined) {
|
|
130
|
+
throw new LoginFailed('No claims in ID token');
|
|
131
|
+
}
|
|
132
|
+
return claims;
|
|
133
|
+
}
|
|
134
|
+
#createPayload() {
|
|
135
|
+
return {
|
|
136
|
+
jti: crypto.randomBytes(32).toString('base64'),
|
|
137
|
+
iat: Date.now(),
|
|
138
|
+
exp: Date.now() + TTL
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
#calculateState(payload) {
|
|
142
|
+
const data = JSON.stringify(payload);
|
|
143
|
+
const value = Buffer.from(data).toString(URL_ENCODING);
|
|
144
|
+
const signature = crypto.createHmac(HMAC_ALGORITHM, this.#key).update(data).digest(URL_ENCODING);
|
|
145
|
+
return `${value}.${signature}`;
|
|
146
|
+
}
|
|
147
|
+
#getState(data) {
|
|
148
|
+
const state = data.state;
|
|
149
|
+
if (typeof state !== 'string') {
|
|
150
|
+
throw new LoginFailed('Invalid state');
|
|
151
|
+
}
|
|
152
|
+
const parts = state.split('.');
|
|
153
|
+
if (parts.length !== 2) {
|
|
154
|
+
throw new LoginFailed('Invalid state');
|
|
155
|
+
}
|
|
156
|
+
const [value, signature] = parts;
|
|
157
|
+
const decodedValue = Buffer.from(value, URL_ENCODING).toString('utf8');
|
|
158
|
+
const decodedSignature = Buffer.from(signature, URL_ENCODING);
|
|
159
|
+
const check = Buffer.from(crypto.createHmac(HMAC_ALGORITHM, this.#key).update(decodedValue).digest());
|
|
160
|
+
if (check.length !== decodedSignature.length || crypto.timingSafeEqual(check, decodedSignature) === false) {
|
|
161
|
+
throw new LoginFailed('Invalid state');
|
|
162
|
+
}
|
|
163
|
+
const payload = JSON.parse(decodedValue);
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (payload.iat > now || payload.exp < now) {
|
|
166
|
+
throw new LoginFailed('Invalid state');
|
|
167
|
+
}
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const TTL = 40_000;
|
|
2
|
+
const CLEANUP_INTERVAL = 10_000;
|
|
3
|
+
export default class SecretManager {
|
|
4
|
+
#cache = new Map();
|
|
5
|
+
#cleanupInterval;
|
|
6
|
+
set(key, secret) {
|
|
7
|
+
const entry = {
|
|
8
|
+
secret,
|
|
9
|
+
expiresAt: Date.now() + TTL
|
|
10
|
+
};
|
|
11
|
+
this.#cache.set(key, entry);
|
|
12
|
+
}
|
|
13
|
+
get(key) {
|
|
14
|
+
const entry = this.#cache.get(key);
|
|
15
|
+
if (entry === undefined)
|
|
16
|
+
return;
|
|
17
|
+
this.#cache.delete(key);
|
|
18
|
+
if (entry.expiresAt < Date.now()) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
return entry.secret;
|
|
22
|
+
}
|
|
23
|
+
start() {
|
|
24
|
+
if (this.#cleanupInterval !== undefined)
|
|
25
|
+
return;
|
|
26
|
+
this.#cleanupInterval = setInterval(() => this.#cleanup(), CLEANUP_INTERVAL);
|
|
27
|
+
this.#cleanupInterval.unref();
|
|
28
|
+
}
|
|
29
|
+
stop() {
|
|
30
|
+
if (this.#cleanupInterval === undefined)
|
|
31
|
+
return;
|
|
32
|
+
clearInterval(this.#cleanupInterval);
|
|
33
|
+
this.#cache.clear();
|
|
34
|
+
this.#cleanupInterval = undefined;
|
|
35
|
+
}
|
|
36
|
+
#cleanup() {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
for (const [key, entry] of this.#cache.entries()) {
|
|
39
|
+
if (entry.expiresAt < now) {
|
|
40
|
+
this.#cache.delete(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as OpenIDDriver } from './OpenID.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as OpenIDDriver } from './OpenID.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theshelf/authentication-driver-openid",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "git+https://github.com/MaskingTechnology/theshelf.git"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"clean": "rimraf dist",
|
|
13
|
+
"lint": "eslint",
|
|
14
|
+
"review": "npm run build && npm run lint",
|
|
15
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"README.md",
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": "./dist/index.js",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"openid-client": "6.8.1"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@theshelf/authentication": "^0.4.0"
|
|
28
|
+
}
|
|
29
|
+
}
|