@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 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
+ ```
@@ -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,12 @@
1
+ type Secret = {
2
+ codeVerifier: string;
3
+ nonce: string;
4
+ };
5
+ export default class SecretManager {
6
+ #private;
7
+ set(key: string, secret: Secret): void;
8
+ get(key: string): Secret | undefined;
9
+ start(): void;
10
+ stop(): void;
11
+ }
12
+ export {};
@@ -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
+ }
@@ -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
+ }