@theshelf/authentication 0.0.3 → 0.1.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 +14 -0
- package/dist/implementation.js +2 -0
- package/dist/implementations/google/Google.d.ts +22 -0
- package/dist/implementations/google/Google.js +147 -0
- package/dist/implementations/google/create.d.ts +2 -0
- package/dist/implementations/google/create.js +10 -0
- package/dist/implementations/openid/OpenID.js +3 -3
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ npm install @theshelf/authentication
|
|
|
23
23
|
Currently, there is only one implementation:
|
|
24
24
|
|
|
25
25
|
* **OpenID** - persistent document storage.
|
|
26
|
+
* **Google** - authentication via Google accounts
|
|
26
27
|
|
|
27
28
|
## Configuration
|
|
28
29
|
|
|
@@ -43,6 +44,19 @@ OPENID_REDIRECT_PATH="https://application.com/login"
|
|
|
43
44
|
OPENID_ALLOW_INSECURE_REQUESTS=false
|
|
44
45
|
```
|
|
45
46
|
|
|
47
|
+
In case of Google, the following configuration is required.
|
|
48
|
+
|
|
49
|
+
```env
|
|
50
|
+
GOOGLE_ISSUER="https://accounts.google.com"
|
|
51
|
+
GOOGLE_CLIENT_ID="google-client-id.apps.googleusercontent.com"
|
|
52
|
+
GOOGLE_CLIENT_SECRET=""
|
|
53
|
+
GOOGLE_REDIRECT_PATH="https://application.com/login"
|
|
54
|
+
GOOGLE_ACCESS_TYPE="offline"
|
|
55
|
+
GOOGLE_ORGANIZATION_DOMAIN="yourdomain.com"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The ACCESS_TYPE can be either `online` or `offline`. The default is `offline`, which provides refresh tokens. The ORGANIZATION_DOMAIN can be used to restrict login to a specific Google Workspace domain.
|
|
59
|
+
|
|
46
60
|
## How to use
|
|
47
61
|
|
|
48
62
|
An instance of the configured identity provider implementation can be imported for performing authentication operations.
|
package/dist/implementation.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import UnknownImplementation from './errors/UnknownImplementation.js';
|
|
2
|
+
import createGoogle from './implementations/google/create.js';
|
|
2
3
|
import createOpenID from './implementations/openid/create.js';
|
|
3
4
|
const implementations = new Map([
|
|
4
5
|
['openid', createOpenID],
|
|
6
|
+
['google', createGoogle]
|
|
5
7
|
]);
|
|
6
8
|
const DEFAULT_AUTHENTICATION_IMPLEMENTATION = 'openid';
|
|
7
9
|
const implementationName = process.env.AUTHENTICATION_IMPLEMENTATION ?? DEFAULT_AUTHENTICATION_IMPLEMENTATION;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IdentityProvider } 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 IdentityProvider {
|
|
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,147 @@
|
|
|
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 NotConnected from '../../errors/NotConnected.js';
|
|
5
|
+
const SECRET = crypto.randomUUID() + crypto.randomUUID();
|
|
6
|
+
const TTL = 30000;
|
|
7
|
+
export default class OpenID {
|
|
8
|
+
#providerConfiguration;
|
|
9
|
+
#clientConfiguration;
|
|
10
|
+
#codeVerifier = randomPKCECodeVerifier();
|
|
11
|
+
constructor(configuration) {
|
|
12
|
+
this.#providerConfiguration = configuration;
|
|
13
|
+
}
|
|
14
|
+
get connected() {
|
|
15
|
+
return this.#clientConfiguration !== undefined;
|
|
16
|
+
}
|
|
17
|
+
async connect() {
|
|
18
|
+
const issuer = new URL(this.#providerConfiguration.issuer);
|
|
19
|
+
const clientId = this.#providerConfiguration.clientId;
|
|
20
|
+
const clientSecret = this.#providerConfiguration.clientSecret;
|
|
21
|
+
this.#clientConfiguration = await discovery(issuer, clientId, clientSecret);
|
|
22
|
+
}
|
|
23
|
+
async disconnect() {
|
|
24
|
+
this.#clientConfiguration = undefined;
|
|
25
|
+
}
|
|
26
|
+
async getLoginUrl(origin) {
|
|
27
|
+
const redirect_uri = new URL(this.#providerConfiguration.redirectPath, origin).href;
|
|
28
|
+
const scope = 'openid profile email';
|
|
29
|
+
const code_challenge = await calculatePKCECodeChallenge(this.#codeVerifier);
|
|
30
|
+
const code_challenge_method = 'S256';
|
|
31
|
+
const access_type = this.#providerConfiguration.accessType;
|
|
32
|
+
const hd = this.#providerConfiguration.organizationDomain;
|
|
33
|
+
const payload = this.#createPayload();
|
|
34
|
+
const state = this.#calculateSignature(payload);
|
|
35
|
+
const parameters = {
|
|
36
|
+
redirect_uri,
|
|
37
|
+
scope,
|
|
38
|
+
code_challenge,
|
|
39
|
+
code_challenge_method,
|
|
40
|
+
access_type,
|
|
41
|
+
hd,
|
|
42
|
+
prompt: 'consent',
|
|
43
|
+
nonce: payload.nonce,
|
|
44
|
+
state
|
|
45
|
+
};
|
|
46
|
+
const clientConfiguration = this.#getClientConfiguration();
|
|
47
|
+
const redirectTo = buildAuthorizationUrl(clientConfiguration, parameters);
|
|
48
|
+
return redirectTo.href;
|
|
49
|
+
}
|
|
50
|
+
async login(origin, data) {
|
|
51
|
+
const clientConfiguration = this.#getClientConfiguration();
|
|
52
|
+
const url = new URL(this.#providerConfiguration.redirectPath, origin);
|
|
53
|
+
for (const [key, value] of Object.entries(data)) {
|
|
54
|
+
url.searchParams.set(key, value);
|
|
55
|
+
}
|
|
56
|
+
const payload = this.#getPayload(data.state);
|
|
57
|
+
const tokens = await authorizationCodeGrant(clientConfiguration, url, {
|
|
58
|
+
pkceCodeVerifier: this.#codeVerifier,
|
|
59
|
+
expectedNonce: payload.nonce,
|
|
60
|
+
expectedState: data.state,
|
|
61
|
+
idTokenExpected: true
|
|
62
|
+
});
|
|
63
|
+
const access_token = tokens.access_token;
|
|
64
|
+
const claims = this.#getClaims(tokens);
|
|
65
|
+
const sub = claims.sub;
|
|
66
|
+
const expires = claims.exp * 1000;
|
|
67
|
+
const userInfo = await fetchUserInfo(clientConfiguration, access_token, sub);
|
|
68
|
+
const identity = {
|
|
69
|
+
name: userInfo.name,
|
|
70
|
+
nickname: userInfo.nickname,
|
|
71
|
+
picture: userInfo.picture,
|
|
72
|
+
email: userInfo.email,
|
|
73
|
+
email_verified: userInfo.email_verified
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
identity: identity,
|
|
77
|
+
accessToken: tokens.access_token,
|
|
78
|
+
refreshToken: tokens.refresh_token,
|
|
79
|
+
expires: new Date(expires)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async refresh(session) {
|
|
83
|
+
const config = this.#getClientConfiguration();
|
|
84
|
+
const tokens = await refreshTokenGrant(config, session.refreshToken);
|
|
85
|
+
const claims = this.#getClaims(tokens);
|
|
86
|
+
const expires = claims.exp * 1000;
|
|
87
|
+
return {
|
|
88
|
+
requester: session.requester,
|
|
89
|
+
identity: session.identity,
|
|
90
|
+
accessToken: tokens.access_token,
|
|
91
|
+
refreshToken: tokens.refresh_token,
|
|
92
|
+
expires: new Date(expires)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
logout(session) {
|
|
96
|
+
const config = this.#getClientConfiguration();
|
|
97
|
+
return tokenRevocation(config, session.refreshToken);
|
|
98
|
+
}
|
|
99
|
+
#getClientConfiguration() {
|
|
100
|
+
if (this.#clientConfiguration === undefined) {
|
|
101
|
+
throw new NotConnected('Google client not connected');
|
|
102
|
+
}
|
|
103
|
+
return this.#clientConfiguration;
|
|
104
|
+
}
|
|
105
|
+
#getClaims(tokens) {
|
|
106
|
+
const claims = tokens.claims();
|
|
107
|
+
if (claims === undefined) {
|
|
108
|
+
throw new LoginFailed('No claims in ID token');
|
|
109
|
+
}
|
|
110
|
+
return claims;
|
|
111
|
+
}
|
|
112
|
+
#createPayload() {
|
|
113
|
+
return {
|
|
114
|
+
jti: crypto.randomUUID(),
|
|
115
|
+
nonce: randomNonce(),
|
|
116
|
+
iat: Date.now(),
|
|
117
|
+
exp: Date.now() + TTL
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
#calculateSignature(payload) {
|
|
121
|
+
const data = JSON.stringify(payload);
|
|
122
|
+
const value = Buffer.from(data).toString('base64url');
|
|
123
|
+
const signature = Buffer.from(crypto.createHmac("sha512", SECRET).update(data).digest()).toString('base64url');
|
|
124
|
+
return `${value}.${signature}`;
|
|
125
|
+
}
|
|
126
|
+
#getPayload(state) {
|
|
127
|
+
if (typeof state !== 'string') {
|
|
128
|
+
throw new LoginFailed('Invalid state');
|
|
129
|
+
}
|
|
130
|
+
if (state.includes('.') === false) {
|
|
131
|
+
throw new LoginFailed('Invalid state');
|
|
132
|
+
}
|
|
133
|
+
const [value, signature] = state.split('.');
|
|
134
|
+
const decodedValue = Buffer.from(value, 'base64').toString('utf8');
|
|
135
|
+
const decodedSignature = Buffer.from(signature, 'base64');
|
|
136
|
+
const check = Buffer.from(crypto.createHmac("sha512", SECRET).update(decodedValue).digest());
|
|
137
|
+
if (crypto.timingSafeEqual(check, decodedSignature) === false) {
|
|
138
|
+
throw new LoginFailed('Invalid state');
|
|
139
|
+
}
|
|
140
|
+
const payload = JSON.parse(decodedValue);
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
if (payload.iat > now || payload.exp < now) {
|
|
143
|
+
throw new LoginFailed('Invalid state');
|
|
144
|
+
}
|
|
145
|
+
return payload;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import Google from './Google.js';
|
|
2
|
+
export default function create() {
|
|
3
|
+
const issuer = process.env.GOOGLE_ISSUER ?? 'undefined';
|
|
4
|
+
const clientId = process.env.GOOGLE_CLIENT_ID ?? 'undefined';
|
|
5
|
+
const clientSecret = process.env.GOOGLE_CLIENT_SECRET ?? 'undefined';
|
|
6
|
+
const redirectPath = process.env.GOOGLE_REDIRECT_PATH ?? 'undefined';
|
|
7
|
+
const accessType = process.env.GOOGLE_ACCESS_TYPE ?? 'online';
|
|
8
|
+
const organizationDomain = process.env.GOOGLE_ORGANIZATION_DOMAIN ?? '';
|
|
9
|
+
return new Google({ issuer, clientId, clientSecret, redirectPath, accessType, organizationDomain });
|
|
10
|
+
}
|
|
@@ -39,9 +39,9 @@ export default class OpenID {
|
|
|
39
39
|
async login(origin, data) {
|
|
40
40
|
const clientConfiguration = this.#getClientConfiguration();
|
|
41
41
|
const url = new URL(this.#providerConfiguration.redirectPath, origin);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
for (const [key, value] of Object.entries(data)) {
|
|
43
|
+
url.searchParams.set(key, value);
|
|
44
|
+
}
|
|
45
45
|
const tokens = await authorizationCodeGrant(clientConfiguration, url, {
|
|
46
46
|
pkceCodeVerifier: this.#codeVerifier,
|
|
47
47
|
idTokenExpected: true
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theshelf/authentication",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.0
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/MaskingTechnology/theshelf"
|
|
8
|
+
},
|
|
6
9
|
"scripts": {
|
|
7
10
|
"build": "tsc",
|
|
8
11
|
"clean": "rimraf dist",
|