@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 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.
@@ -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,2 @@
1
+ import Google from './Google.js';
2
+ export default function create(): Google;
@@ -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
- url.searchParams.set('session_state', data.session_state);
43
- url.searchParams.set('iss', data.iss);
44
- url.searchParams.set('code', data.code);
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.3",
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",