wabe 0.6.9 → 0.6.10
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 +138 -32
- package/bucket/b.txt +1 -0
- package/dev/index.ts +215 -0
- package/dist/authentication/Session.d.ts +4 -1
- package/dist/authentication/interface.d.ts +16 -0
- package/dist/email/interface.d.ts +1 -1
- package/dist/graphql/resolvers.d.ts +4 -2
- package/dist/hooks/index.d.ts +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +8713 -8867
- package/dist/server/index.d.ts +4 -2
- package/dist/utils/crypto.d.ts +7 -0
- package/dist/utils/helper.d.ts +4 -1
- package/generated/schema.graphql +16 -14
- package/generated/wabe.ts +4 -4
- package/package.json +15 -15
- package/src/authentication/OTP.test.ts +69 -0
- package/src/authentication/OTP.ts +66 -0
- package/src/authentication/Session.test.ts +665 -0
- package/src/authentication/Session.ts +529 -0
- package/src/authentication/defaultAuthentication.ts +214 -0
- package/src/authentication/index.ts +3 -0
- package/src/authentication/interface.ts +157 -0
- package/src/authentication/oauth/GitHub.test.ts +105 -0
- package/src/authentication/oauth/GitHub.ts +133 -0
- package/src/authentication/oauth/Google.test.ts +105 -0
- package/src/authentication/oauth/Google.ts +110 -0
- package/src/authentication/oauth/Oauth2Client.test.ts +225 -0
- package/src/authentication/oauth/Oauth2Client.ts +140 -0
- package/src/authentication/oauth/index.ts +2 -0
- package/src/authentication/oauth/utils.test.ts +35 -0
- package/src/authentication/oauth/utils.ts +28 -0
- package/src/authentication/providers/EmailOTP.test.ts +138 -0
- package/src/authentication/providers/EmailOTP.ts +93 -0
- package/src/authentication/providers/EmailPassword.test.ts +187 -0
- package/src/authentication/providers/EmailPassword.ts +130 -0
- package/src/authentication/providers/EmailPasswordSRP.test.ts +206 -0
- package/src/authentication/providers/EmailPasswordSRP.ts +184 -0
- package/src/authentication/providers/GitHub.ts +30 -0
- package/src/authentication/providers/Google.ts +30 -0
- package/src/authentication/providers/OAuth.test.ts +185 -0
- package/src/authentication/providers/OAuth.ts +112 -0
- package/src/authentication/providers/PhonePassword.test.ts +187 -0
- package/src/authentication/providers/PhonePassword.ts +129 -0
- package/src/authentication/providers/QRCodeOTP.test.ts +79 -0
- package/src/authentication/providers/QRCodeOTP.ts +65 -0
- package/src/authentication/providers/index.ts +6 -0
- package/src/authentication/resolvers/refreshResolver.test.ts +37 -0
- package/src/authentication/resolvers/refreshResolver.ts +20 -0
- package/src/authentication/resolvers/signInWithResolver.inte.test.ts +59 -0
- package/src/authentication/resolvers/signInWithResolver.test.ts +307 -0
- package/src/authentication/resolvers/signInWithResolver.ts +102 -0
- package/src/authentication/resolvers/signOutResolver.test.ts +41 -0
- package/src/authentication/resolvers/signOutResolver.ts +22 -0
- package/src/authentication/resolvers/signUpWithResolver.test.ts +186 -0
- package/src/authentication/resolvers/signUpWithResolver.ts +69 -0
- package/src/authentication/resolvers/verifyChallenge.test.ts +136 -0
- package/src/authentication/resolvers/verifyChallenge.ts +69 -0
- package/src/authentication/roles.test.ts +59 -0
- package/src/authentication/roles.ts +40 -0
- package/src/authentication/utils.test.ts +99 -0
- package/src/authentication/utils.ts +43 -0
- package/src/cache/InMemoryCache.test.ts +62 -0
- package/src/cache/InMemoryCache.ts +45 -0
- package/src/cron/index.test.ts +17 -0
- package/src/cron/index.ts +46 -0
- package/src/database/DatabaseController.test.ts +625 -0
- package/src/database/DatabaseController.ts +983 -0
- package/src/database/index.test.ts +1230 -0
- package/src/database/index.ts +9 -0
- package/src/database/interface.ts +312 -0
- package/src/email/DevAdapter.ts +8 -0
- package/src/email/EmailController.test.ts +29 -0
- package/src/email/EmailController.ts +13 -0
- package/src/email/index.ts +2 -0
- package/src/email/interface.ts +36 -0
- package/src/email/templates/sendOtpCode.ts +120 -0
- package/src/file/FileController.ts +28 -0
- package/src/file/FileDevAdapter.ts +54 -0
- package/src/file/hookDeleteFile.ts +27 -0
- package/src/file/hookReadFile.ts +70 -0
- package/src/file/hookUploadFile.ts +53 -0
- package/src/file/index.test.ts +979 -0
- package/src/file/index.ts +2 -0
- package/src/file/interface.ts +42 -0
- package/src/graphql/GraphQLSchema.test.ts +4399 -0
- package/src/graphql/GraphQLSchema.ts +928 -0
- package/src/graphql/index.ts +2 -0
- package/src/graphql/parseGraphqlSchema.ts +94 -0
- package/src/graphql/parser.test.ts +217 -0
- package/src/graphql/parser.ts +566 -0
- package/src/graphql/pointerAndRelationFunction.ts +200 -0
- package/src/graphql/resolvers.ts +467 -0
- package/src/graphql/tests/aggregation.test.ts +1123 -0
- package/src/graphql/tests/e2e.test.ts +596 -0
- package/src/graphql/tests/scalars.test.ts +250 -0
- package/src/graphql/types.ts +219 -0
- package/src/hooks/HookObject.test.ts +122 -0
- package/src/hooks/HookObject.ts +168 -0
- package/src/hooks/authentication.ts +76 -0
- package/src/hooks/createUser.test.ts +77 -0
- package/src/hooks/createUser.ts +10 -0
- package/src/hooks/defaultFields.test.ts +187 -0
- package/src/hooks/defaultFields.ts +40 -0
- package/src/hooks/deleteSession.test.ts +181 -0
- package/src/hooks/deleteSession.ts +20 -0
- package/src/hooks/hashFieldHook.test.ts +163 -0
- package/src/hooks/hashFieldHook.ts +97 -0
- package/src/hooks/index.test.ts +207 -0
- package/src/hooks/index.ts +430 -0
- package/src/hooks/permissions.test.ts +424 -0
- package/src/hooks/permissions.ts +113 -0
- package/src/hooks/protected.test.ts +551 -0
- package/src/hooks/protected.ts +72 -0
- package/src/hooks/searchableFields.test.ts +166 -0
- package/src/hooks/searchableFields.ts +98 -0
- package/src/hooks/session.test.ts +138 -0
- package/src/hooks/session.ts +78 -0
- package/src/hooks/setEmail.test.ts +216 -0
- package/src/hooks/setEmail.ts +35 -0
- package/src/hooks/setupAcl.test.ts +589 -0
- package/src/hooks/setupAcl.ts +29 -0
- package/src/index.ts +9 -0
- package/src/schema/Schema.test.ts +484 -0
- package/src/schema/Schema.ts +795 -0
- package/src/schema/defaultResolvers.ts +94 -0
- package/src/schema/index.ts +1 -0
- package/src/schema/resolvers/meResolver.test.ts +62 -0
- package/src/schema/resolvers/meResolver.ts +14 -0
- package/src/schema/resolvers/newFile.ts +0 -0
- package/src/schema/resolvers/resetPassword.test.ts +345 -0
- package/src/schema/resolvers/resetPassword.ts +64 -0
- package/src/schema/resolvers/sendEmail.test.ts +118 -0
- package/src/schema/resolvers/sendEmail.ts +21 -0
- package/src/schema/resolvers/sendOtpCode.test.ts +153 -0
- package/src/schema/resolvers/sendOtpCode.ts +52 -0
- package/src/security.test.ts +3461 -0
- package/src/server/defaultSessionHandler.test.ts +66 -0
- package/src/server/defaultSessionHandler.ts +115 -0
- package/src/server/generateCodegen.ts +476 -0
- package/src/server/index.test.ts +552 -0
- package/src/server/index.ts +354 -0
- package/src/server/interface.ts +11 -0
- package/src/server/routes/authHandler.ts +187 -0
- package/src/server/routes/index.ts +40 -0
- package/src/utils/crypto.test.ts +41 -0
- package/src/utils/crypto.ts +121 -0
- package/src/utils/export.ts +13 -0
- package/src/utils/helper.ts +195 -0
- package/src/utils/index.test.ts +11 -0
- package/src/utils/index.ts +201 -0
- package/src/utils/preload.ts +8 -0
- package/src/utils/testHelper.ts +117 -0
- package/tsconfig.json +32 -0
- package/bunfig.toml +0 -4
- package/dist/ai/index.d.ts +0 -1
- package/dist/ai/interface.d.ts +0 -9
- /package/dist/server/{defaultHandlers.d.ts → defaultSessionHandler.d.ts} +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { OAuth2Client } from '.'
|
|
2
|
+
import type { WabeConfig } from '../../server'
|
|
3
|
+
import type { OAuth2ProviderWithPKCE, Tokens } from './utils'
|
|
4
|
+
|
|
5
|
+
const authorizeEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
6
|
+
const tokenEndpoint = 'https://oauth2.googleapis.com/token'
|
|
7
|
+
|
|
8
|
+
interface AuthorizationCodeResponseBody {
|
|
9
|
+
access_token: string
|
|
10
|
+
refresh_token?: string
|
|
11
|
+
expires_in: number
|
|
12
|
+
id_token: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RefreshTokenResponseBody {
|
|
16
|
+
access_token: string
|
|
17
|
+
expires_in: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Google implements OAuth2ProviderWithPKCE {
|
|
21
|
+
private client: OAuth2Client
|
|
22
|
+
private clientSecret: string
|
|
23
|
+
|
|
24
|
+
constructor(config: WabeConfig<any>) {
|
|
25
|
+
const googleConfig = config.authentication?.providers?.google
|
|
26
|
+
|
|
27
|
+
if (!googleConfig) throw new Error('Google config not found')
|
|
28
|
+
|
|
29
|
+
const baseUrl = `http${config.isProduction ? 's' : ''}://${config.authentication?.backDomain || '127.0.0.1:' + config.port || 3001}`
|
|
30
|
+
|
|
31
|
+
const redirectURI = `${baseUrl}/auth/oauth/callback`
|
|
32
|
+
|
|
33
|
+
this.client = new OAuth2Client(
|
|
34
|
+
googleConfig.clientId,
|
|
35
|
+
authorizeEndpoint,
|
|
36
|
+
tokenEndpoint,
|
|
37
|
+
redirectURI,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
this.clientSecret = googleConfig.clientSecret
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
createAuthorizationURL(
|
|
44
|
+
state: string,
|
|
45
|
+
codeVerifier: string,
|
|
46
|
+
options?: {
|
|
47
|
+
scopes?: string[]
|
|
48
|
+
},
|
|
49
|
+
): URL {
|
|
50
|
+
const scopes = options?.scopes ?? []
|
|
51
|
+
const url = this.client.createAuthorizationURL({
|
|
52
|
+
state,
|
|
53
|
+
codeVerifier,
|
|
54
|
+
scopes: [...scopes, 'openid'],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
url.searchParams.set('access_type', 'offline')
|
|
58
|
+
url.searchParams.set('prompt', 'select_account')
|
|
59
|
+
|
|
60
|
+
return url
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async validateAuthorizationCode(
|
|
64
|
+
code: string,
|
|
65
|
+
codeVerifier: string,
|
|
66
|
+
): Promise<Tokens> {
|
|
67
|
+
const { access_token, expires_in, refresh_token, id_token } =
|
|
68
|
+
await this.client.validateAuthorizationCode<AuthorizationCodeResponseBody>(
|
|
69
|
+
code,
|
|
70
|
+
{
|
|
71
|
+
authenticateWith: 'request_body',
|
|
72
|
+
credentials: this.clientSecret,
|
|
73
|
+
codeVerifier,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
accessToken: access_token,
|
|
79
|
+
refreshToken: refresh_token,
|
|
80
|
+
accessTokenExpiresAt: new Date(Date.now() + expires_in * 1000),
|
|
81
|
+
idToken: id_token,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async refreshAccessToken(refreshToken: string): Promise<Tokens> {
|
|
86
|
+
const { access_token, expires_in } =
|
|
87
|
+
await this.client.refreshAccessToken<RefreshTokenResponseBody>(
|
|
88
|
+
refreshToken,
|
|
89
|
+
{
|
|
90
|
+
authenticateWith: 'request_body',
|
|
91
|
+
credentials: this.clientSecret,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
accessToken: access_token,
|
|
97
|
+
accessTokenExpiresAt: new Date(Date.now() + expires_in * 1000),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async getUserInfo(accessToken: string) {
|
|
102
|
+
const userInfo = await fetch(
|
|
103
|
+
`https://www.googleapis.com/oauth2/v1/userinfo?alt=json&access_token=${accessToken}`,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const { email, verified_email } = await userInfo.json()
|
|
107
|
+
|
|
108
|
+
return { email, verifiedEmail: verified_email }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, expect, it, spyOn, mock, afterAll } from 'bun:test'
|
|
2
|
+
import { fail } from 'node:assert'
|
|
3
|
+
import { OAuth2Client } from './Oauth2Client'
|
|
4
|
+
import { base64URLencode } from './utils'
|
|
5
|
+
|
|
6
|
+
const mockFetch = mock(() => {})
|
|
7
|
+
|
|
8
|
+
const originalFetch = global.fetch
|
|
9
|
+
|
|
10
|
+
// @ts-expect-error
|
|
11
|
+
global.fetch = mockFetch
|
|
12
|
+
|
|
13
|
+
describe('Oauth2Client', () => {
|
|
14
|
+
const oauthClient = new OAuth2Client(
|
|
15
|
+
'clientId',
|
|
16
|
+
'https://authorizationEndpoint',
|
|
17
|
+
'https://tokenEndpoint',
|
|
18
|
+
'https://redirectURI',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
global.fetch = originalFetch
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should create authorization URl', () => {
|
|
26
|
+
const authorizationURL = oauthClient.createAuthorizationURL()
|
|
27
|
+
|
|
28
|
+
expect(authorizationURL.toString()).toEqual(
|
|
29
|
+
'https://authorizationendpoint/?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2FredirectURI',
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const authorizationURLWithState = oauthClient.createAuthorizationURL({
|
|
33
|
+
state: 'state',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(authorizationURLWithState.toString()).toEqual(
|
|
37
|
+
'https://authorizationendpoint/?response_type=code&client_id=clientId&state=state&redirect_uri=https%3A%2F%2FredirectURI',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const authorizationURLWithScopes = oauthClient.createAuthorizationURL({
|
|
41
|
+
scopes: ['scope1', 'scope2'],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(authorizationURLWithScopes.toString()).toEqual(
|
|
45
|
+
'https://authorizationendpoint/?response_type=code&client_id=clientId&scope=scope1+scope2&redirect_uri=https%3A%2F%2FredirectURI',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const authorizationURLWithCodeVerifier = oauthClient.createAuthorizationURL(
|
|
49
|
+
{
|
|
50
|
+
codeVerifier: 'codeVerifier',
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const codeChallenge = base64URLencode('codeVerifier')
|
|
55
|
+
|
|
56
|
+
expect(authorizationURLWithCodeVerifier.toString()).toEqual(
|
|
57
|
+
`https://authorizationendpoint/?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2FredirectURI&code_challenge_method=S256&code_challenge=${codeChallenge.replace(
|
|
58
|
+
'/',
|
|
59
|
+
'%2F',
|
|
60
|
+
)}`,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should validate authorization code', async () => {
|
|
65
|
+
const spySendTokenRequest = spyOn(
|
|
66
|
+
OAuth2Client.prototype,
|
|
67
|
+
'_sendTokenRequest',
|
|
68
|
+
).mockResolvedValue({} as any)
|
|
69
|
+
|
|
70
|
+
await oauthClient.validateAuthorizationCode('code')
|
|
71
|
+
|
|
72
|
+
const expectedBody = new URLSearchParams()
|
|
73
|
+
expectedBody.set('code', 'code')
|
|
74
|
+
expectedBody.set('client_id', 'clientId')
|
|
75
|
+
expectedBody.set('grant_type', 'authorization_code')
|
|
76
|
+
expectedBody.set('redirect_uri', 'https://redirectURI')
|
|
77
|
+
|
|
78
|
+
expect(spySendTokenRequest).toHaveBeenCalledTimes(1)
|
|
79
|
+
const body = spySendTokenRequest.mock.calls[0]?.[0]
|
|
80
|
+
const options = spySendTokenRequest.mock.calls[0]?.[1]
|
|
81
|
+
expect(body?.toString()).toEqual(expectedBody.toString())
|
|
82
|
+
expect(options).toBeUndefined()
|
|
83
|
+
|
|
84
|
+
await oauthClient.validateAuthorizationCode('code', {
|
|
85
|
+
codeVerifier: 'codeVerifier',
|
|
86
|
+
authenticateWith: 'http_basic_auth',
|
|
87
|
+
credentials: 'credentials',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const expectedBody2 = new URLSearchParams()
|
|
91
|
+
expectedBody2.set('code', 'code')
|
|
92
|
+
expectedBody2.set('client_id', 'clientId')
|
|
93
|
+
expectedBody2.set('grant_type', 'authorization_code')
|
|
94
|
+
expectedBody2.set('redirect_uri', 'https://redirectURI')
|
|
95
|
+
expectedBody2.set('code_verifier', 'codeVerifier')
|
|
96
|
+
|
|
97
|
+
expect(spySendTokenRequest).toHaveBeenCalledTimes(2)
|
|
98
|
+
const body2 = spySendTokenRequest.mock.calls[1]?.[0]
|
|
99
|
+
const options2 = spySendTokenRequest.mock.calls[1]?.[1]
|
|
100
|
+
expect(body2?.toString()).toEqual(expectedBody2.toString())
|
|
101
|
+
expect(options2).toEqual({
|
|
102
|
+
codeVerifier: 'codeVerifier',
|
|
103
|
+
authenticateWith: 'http_basic_auth',
|
|
104
|
+
credentials: 'credentials',
|
|
105
|
+
} as any)
|
|
106
|
+
|
|
107
|
+
spySendTokenRequest.mockRestore()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should refresh access token', async () => {
|
|
111
|
+
const spySendTokenRequest = spyOn(
|
|
112
|
+
OAuth2Client.prototype,
|
|
113
|
+
'_sendTokenRequest',
|
|
114
|
+
).mockResolvedValue({} as any)
|
|
115
|
+
|
|
116
|
+
await oauthClient.refreshAccessToken('refreshToken')
|
|
117
|
+
|
|
118
|
+
const expectedBody = new URLSearchParams()
|
|
119
|
+
expectedBody.set('refresh_token', 'refreshToken')
|
|
120
|
+
expectedBody.set('client_id', 'clientId')
|
|
121
|
+
expectedBody.set('grant_type', 'refresh_token')
|
|
122
|
+
|
|
123
|
+
expect(spySendTokenRequest).toHaveBeenCalledTimes(1)
|
|
124
|
+
const body = spySendTokenRequest.mock.calls[0]?.[0]
|
|
125
|
+
const options = spySendTokenRequest.mock.calls[0]?.[1]
|
|
126
|
+
expect(body?.toString()).toEqual(expectedBody.toString())
|
|
127
|
+
expect(options).toBeUndefined()
|
|
128
|
+
|
|
129
|
+
await oauthClient.refreshAccessToken('refreshToken', {
|
|
130
|
+
authenticateWith: 'http_basic_auth',
|
|
131
|
+
credentials: 'credentials',
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const expectedBody2 = new URLSearchParams()
|
|
135
|
+
expectedBody2.set('refresh_token', 'refreshToken')
|
|
136
|
+
expectedBody2.set('client_id', 'clientId')
|
|
137
|
+
expectedBody2.set('grant_type', 'refresh_token')
|
|
138
|
+
|
|
139
|
+
expect(spySendTokenRequest).toHaveBeenCalledTimes(2)
|
|
140
|
+
const body2 = spySendTokenRequest.mock.calls[1]?.[0]
|
|
141
|
+
const options2 = spySendTokenRequest.mock.calls[1]?.[1]
|
|
142
|
+
expect(body2?.toString()).toEqual(expectedBody2.toString())
|
|
143
|
+
expect(options2).toEqual({
|
|
144
|
+
authenticateWith: 'http_basic_auth',
|
|
145
|
+
credentials: 'credentials',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
spySendTokenRequest.mockRestore()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should send token request', async () => {
|
|
152
|
+
mockFetch.mockResolvedValue({
|
|
153
|
+
json: () => Promise.resolve({ access_token: 'access_token' }),
|
|
154
|
+
ok: true,
|
|
155
|
+
status: 200,
|
|
156
|
+
} as never)
|
|
157
|
+
|
|
158
|
+
await oauthClient._sendTokenRequest(new URLSearchParams(), {
|
|
159
|
+
authenticateWith: 'http_basic_auth',
|
|
160
|
+
credentials: 'credentials',
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const encodeCredentials = btoa('clientId:credentials')
|
|
164
|
+
|
|
165
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
166
|
+
// @ts-expect-error
|
|
167
|
+
const receivedRequest = mockFetch.mock.calls[0][0] as any
|
|
168
|
+
|
|
169
|
+
if (!receivedRequest) fail()
|
|
170
|
+
expect(receivedRequest.url).toEqual('https://tokenendpoint/')
|
|
171
|
+
expect(receivedRequest.method).toEqual('POST')
|
|
172
|
+
expect(receivedRequest.headers.get('content-type')).toEqual(
|
|
173
|
+
'application/x-www-form-urlencoded',
|
|
174
|
+
)
|
|
175
|
+
expect(receivedRequest.headers.get('accept')).toEqual('application/json')
|
|
176
|
+
expect(receivedRequest.headers.get('user-agent')).toEqual('wabe')
|
|
177
|
+
expect(receivedRequest.headers.get('authorization')).toEqual(
|
|
178
|
+
`Basic ${encodeCredentials}`,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
mockFetch.mockRestore()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should throw an error if the result of the request is not valid', () => {
|
|
185
|
+
mockFetch.mockResolvedValue({
|
|
186
|
+
json: () => Promise.resolve({}),
|
|
187
|
+
ok: false,
|
|
188
|
+
} as never)
|
|
189
|
+
|
|
190
|
+
expect(
|
|
191
|
+
oauthClient._sendTokenRequest(new URLSearchParams(), {
|
|
192
|
+
authenticateWith: 'http_basic_auth',
|
|
193
|
+
credentials: 'credentials',
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toThrow('Error in token request')
|
|
196
|
+
|
|
197
|
+
mockFetch.mockResolvedValue({
|
|
198
|
+
json: () => Promise.resolve({}),
|
|
199
|
+
ok: true,
|
|
200
|
+
status: 400,
|
|
201
|
+
} as never)
|
|
202
|
+
|
|
203
|
+
expect(
|
|
204
|
+
oauthClient._sendTokenRequest(new URLSearchParams(), {
|
|
205
|
+
authenticateWith: 'http_basic_auth',
|
|
206
|
+
credentials: 'credentials',
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toThrow('Error in token request')
|
|
209
|
+
|
|
210
|
+
mockFetch.mockResolvedValue({
|
|
211
|
+
json: () => Promise.resolve({}),
|
|
212
|
+
ok: true,
|
|
213
|
+
status: 200,
|
|
214
|
+
} as never)
|
|
215
|
+
|
|
216
|
+
expect(
|
|
217
|
+
oauthClient._sendTokenRequest(new URLSearchParams(), {
|
|
218
|
+
authenticateWith: 'http_basic_auth',
|
|
219
|
+
credentials: 'credentials',
|
|
220
|
+
}),
|
|
221
|
+
).rejects.toThrow('Error in token request')
|
|
222
|
+
|
|
223
|
+
mockFetch.mockRestore()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Code inspired by Oslo : https://github.com/pilcrowOnPaper/oslo/blob/main/src/oauth2/index.ts
|
|
2
|
+
|
|
3
|
+
import { base64URLencode } from './utils'
|
|
4
|
+
|
|
5
|
+
export interface TokenResponseBody {
|
|
6
|
+
access_token: string
|
|
7
|
+
token_type?: string
|
|
8
|
+
expires_in?: number
|
|
9
|
+
refresh_token?: string
|
|
10
|
+
scope?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class OAuth2Client {
|
|
14
|
+
public clientId: string
|
|
15
|
+
|
|
16
|
+
private authorizeEndpoint: string
|
|
17
|
+
private tokenEndpoint: string
|
|
18
|
+
private redirectURI: string
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
clientId: string,
|
|
22
|
+
authorizeEndpoint: string,
|
|
23
|
+
tokenEndpoint: string,
|
|
24
|
+
redirectURI: string,
|
|
25
|
+
) {
|
|
26
|
+
this.clientId = clientId
|
|
27
|
+
this.authorizeEndpoint = authorizeEndpoint
|
|
28
|
+
this.tokenEndpoint = tokenEndpoint
|
|
29
|
+
this.redirectURI = redirectURI
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
createAuthorizationURL(options?: {
|
|
33
|
+
state?: string
|
|
34
|
+
codeVerifier?: string
|
|
35
|
+
scopes?: string[]
|
|
36
|
+
}): URL {
|
|
37
|
+
const scopes = Array.from(new Set(options?.scopes || [])) // remove duplicates
|
|
38
|
+
const authorizationUrl = new URL(this.authorizeEndpoint)
|
|
39
|
+
authorizationUrl.searchParams.set('response_type', 'code')
|
|
40
|
+
authorizationUrl.searchParams.set('client_id', this.clientId)
|
|
41
|
+
|
|
42
|
+
if (options?.state !== undefined)
|
|
43
|
+
authorizationUrl.searchParams.set('state', options.state)
|
|
44
|
+
|
|
45
|
+
if (scopes.length > 0)
|
|
46
|
+
authorizationUrl.searchParams.set('scope', scopes.join(' '))
|
|
47
|
+
|
|
48
|
+
if (this.redirectURI !== null)
|
|
49
|
+
authorizationUrl.searchParams.set('redirect_uri', this.redirectURI)
|
|
50
|
+
|
|
51
|
+
if (options?.codeVerifier !== undefined) {
|
|
52
|
+
const codeChallenge = base64URLencode(options.codeVerifier)
|
|
53
|
+
|
|
54
|
+
authorizationUrl.searchParams.set('code_challenge_method', 'S256')
|
|
55
|
+
authorizationUrl.searchParams.set('code_challenge', codeChallenge)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return authorizationUrl
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
validateAuthorizationCode<_TokenResponseBody extends TokenResponseBody>(
|
|
62
|
+
authorizationCode: string,
|
|
63
|
+
options?: {
|
|
64
|
+
codeVerifier?: string
|
|
65
|
+
credentials?: string
|
|
66
|
+
authenticateWith?: 'http_basic_auth' | 'request_body'
|
|
67
|
+
},
|
|
68
|
+
): Promise<_TokenResponseBody> {
|
|
69
|
+
const body = new URLSearchParams()
|
|
70
|
+
body.set('code', authorizationCode)
|
|
71
|
+
body.set('client_id', this.clientId)
|
|
72
|
+
body.set('grant_type', 'authorization_code')
|
|
73
|
+
|
|
74
|
+
if (this.redirectURI !== null) body.set('redirect_uri', this.redirectURI)
|
|
75
|
+
|
|
76
|
+
if (options?.codeVerifier !== undefined)
|
|
77
|
+
body.set('code_verifier', options.codeVerifier)
|
|
78
|
+
|
|
79
|
+
return this._sendTokenRequest<_TokenResponseBody>(body, options)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async refreshAccessToken<_TokenResponseBody extends TokenResponseBody>(
|
|
83
|
+
refreshToken: string,
|
|
84
|
+
options?: {
|
|
85
|
+
credentials?: string
|
|
86
|
+
authenticateWith?: 'http_basic_auth' | 'request_body'
|
|
87
|
+
scopes?: string[]
|
|
88
|
+
},
|
|
89
|
+
): Promise<_TokenResponseBody> {
|
|
90
|
+
const body = new URLSearchParams()
|
|
91
|
+
body.set('refresh_token', refreshToken)
|
|
92
|
+
body.set('client_id', this.clientId)
|
|
93
|
+
body.set('grant_type', 'refresh_token')
|
|
94
|
+
|
|
95
|
+
const scopes = Array.from(new Set(options?.scopes ?? [])) // remove duplicates
|
|
96
|
+
if (scopes.length > 0) body.set('scope', scopes.join(' '))
|
|
97
|
+
|
|
98
|
+
return await this._sendTokenRequest<_TokenResponseBody>(body, options)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async _sendTokenRequest<_TokenResponseBody extends TokenResponseBody>(
|
|
102
|
+
body: URLSearchParams,
|
|
103
|
+
options?: {
|
|
104
|
+
credentials?: string
|
|
105
|
+
authenticateWith?: 'http_basic_auth' | 'request_body'
|
|
106
|
+
},
|
|
107
|
+
): Promise<_TokenResponseBody> {
|
|
108
|
+
const headers = new Headers()
|
|
109
|
+
headers.set('Content-Type', 'application/x-www-form-urlencoded')
|
|
110
|
+
headers.set('Accept', 'application/json')
|
|
111
|
+
headers.set('User-Agent', 'wabe')
|
|
112
|
+
|
|
113
|
+
if (options?.credentials !== undefined) {
|
|
114
|
+
const authenticateWith = options?.authenticateWith || 'http_basic_auth'
|
|
115
|
+
if (authenticateWith === 'http_basic_auth') {
|
|
116
|
+
const encodedCredentials = btoa(
|
|
117
|
+
`${this.clientId}:${options.credentials}`,
|
|
118
|
+
)
|
|
119
|
+
headers.set('Authorization', `Basic ${encodedCredentials}`)
|
|
120
|
+
} else {
|
|
121
|
+
body.set('client_secret', options.credentials)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const request = new Request(this.tokenEndpoint, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers,
|
|
128
|
+
body,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const response = await fetch(request)
|
|
132
|
+
const result: _TokenResponseBody = await response.json()
|
|
133
|
+
|
|
134
|
+
// providers are allowed to return non-400 status code for errors
|
|
135
|
+
if (!('access_token' in result) || response.status !== 200 || !response.ok)
|
|
136
|
+
throw new Error('Error in token request')
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { base64URLencode, generateRandomValues } from './utils'
|
|
3
|
+
|
|
4
|
+
describe('Oauth utils', () => {
|
|
5
|
+
it('should encode url with base64', () => {
|
|
6
|
+
const content = 'test'
|
|
7
|
+
|
|
8
|
+
// Keep Bun. here to be sure the compatibility between node and Bun implem
|
|
9
|
+
const hasher = new Bun.CryptoHasher('sha256')
|
|
10
|
+
hasher.update(new TextEncoder().encode(content))
|
|
11
|
+
const resultWithPadding = hasher.digest('base64')
|
|
12
|
+
|
|
13
|
+
const result = base64URLencode(content)
|
|
14
|
+
|
|
15
|
+
expect(resultWithPadding).toBe(
|
|
16
|
+
'n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=',
|
|
17
|
+
)
|
|
18
|
+
expect(result).toBe('n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Real use case check with oauth simulator
|
|
22
|
+
it('should encode correctly with base64', () => {
|
|
23
|
+
const content = 'bIaNJCsNzrZE7QEzYjwbl0fa1CyzF49moM6Ua4H0d5cG-l7d'
|
|
24
|
+
const result = base64URLencode(content)
|
|
25
|
+
|
|
26
|
+
expect(result).toBe('1pdL2CLvBbNBnrfBZeNYlFzpedMhUTbgyhn0CnWVYoc')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should generate random values for code_verifier or state', () => {
|
|
30
|
+
const randomValue = generateRandomValues()
|
|
31
|
+
|
|
32
|
+
// Google recommends an entropy between 43 and 128 characters for the code_verifier
|
|
33
|
+
expect(randomValue.length).toEqual(80)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export interface Tokens {
|
|
4
|
+
accessToken: string
|
|
5
|
+
refreshToken?: string | null
|
|
6
|
+
accessTokenExpiresAt?: Date
|
|
7
|
+
refreshTokenExpiresAt?: Date | null
|
|
8
|
+
idToken?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OAuth2ProviderWithPKCE {
|
|
12
|
+
createAuthorizationURL(state: string, codeVerifier: string): URL
|
|
13
|
+
validateAuthorizationCode(code: string, codeVerifier: string): Promise<Tokens>
|
|
14
|
+
refreshAccessToken?(refreshToken: string): Promise<Tokens>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// https://datatracker.ietf.org/doc/html/rfc7636#appendix-A
|
|
18
|
+
export const base64URLencode = (content: string) => {
|
|
19
|
+
const hasher = crypto.createHash('sha256').update(content)
|
|
20
|
+
|
|
21
|
+
const result = hasher.digest('base64')
|
|
22
|
+
|
|
23
|
+
// @ts-expect-error
|
|
24
|
+
return result.split('=')[0].replaceAll('+', '-').replaceAll('/', '_')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const generateRandomValues = () =>
|
|
28
|
+
crypto.randomBytes(60).toString('base64url')
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
spyOn,
|
|
6
|
+
beforeAll,
|
|
7
|
+
afterAll,
|
|
8
|
+
afterEach,
|
|
9
|
+
} from 'bun:test'
|
|
10
|
+
import { EmailOTP } from './EmailOTP'
|
|
11
|
+
import type { DevWabeTypes } from '../../utils/helper'
|
|
12
|
+
import { setupTests, closeTests } from '../../utils/testHelper'
|
|
13
|
+
import { EmailDevAdapter, type Wabe } from '../..'
|
|
14
|
+
import * as sendOtpCodeTemplate from '../../email/templates/sendOtpCode'
|
|
15
|
+
import { OTP } from '../OTP'
|
|
16
|
+
|
|
17
|
+
describe('EmailOTPProvider', () => {
|
|
18
|
+
const spyEmailSend = spyOn(EmailDevAdapter.prototype, 'send')
|
|
19
|
+
const spySendOtpCodeTemplate = spyOn(
|
|
20
|
+
sendOtpCodeTemplate,
|
|
21
|
+
'sendOtpCodeTemplate',
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
let wabe: Wabe<DevWabeTypes>
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
const setup = await setupTests()
|
|
28
|
+
wabe = setup.wabe
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await closeTests(wabe)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
spyEmailSend.mockClear()
|
|
37
|
+
spySendOtpCodeTemplate.mockClear()
|
|
38
|
+
|
|
39
|
+
await wabe.controllers.database.clearDatabase()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("should send an OTP code to the user's email", async () => {
|
|
43
|
+
const createdUser = await wabe.controllers.database.createObject({
|
|
44
|
+
className: 'User',
|
|
45
|
+
context: {
|
|
46
|
+
wabe,
|
|
47
|
+
isRoot: true,
|
|
48
|
+
},
|
|
49
|
+
data: {
|
|
50
|
+
email: 'email@test.fr',
|
|
51
|
+
},
|
|
52
|
+
select: {
|
|
53
|
+
id: true,
|
|
54
|
+
email: true,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (!createdUser) throw new Error('User not created')
|
|
59
|
+
|
|
60
|
+
const emailOTP = new EmailOTP()
|
|
61
|
+
|
|
62
|
+
await emailOTP.onSendChallenge({
|
|
63
|
+
context: {
|
|
64
|
+
wabe,
|
|
65
|
+
isRoot: false,
|
|
66
|
+
},
|
|
67
|
+
user: createdUser,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(spyEmailSend).toHaveBeenCalledTimes(1)
|
|
71
|
+
expect(spyEmailSend).toHaveBeenCalledWith(
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
from: 'main.email@wabe.com',
|
|
74
|
+
to: ['email@test.fr'],
|
|
75
|
+
subject: 'Your OTP code',
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const otp = spySendOtpCodeTemplate.mock.calls[0]?.[0]
|
|
80
|
+
|
|
81
|
+
expect(spySendOtpCodeTemplate).toHaveBeenCalledTimes(1)
|
|
82
|
+
expect(otp?.length).toBe(6)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should return the userId if the OTP code is valid', async () => {
|
|
86
|
+
const createdUser = await wabe.controllers.database.createObject({
|
|
87
|
+
className: 'User',
|
|
88
|
+
context: {
|
|
89
|
+
wabe,
|
|
90
|
+
isRoot: true,
|
|
91
|
+
},
|
|
92
|
+
data: {
|
|
93
|
+
email: 'email@test.fr',
|
|
94
|
+
},
|
|
95
|
+
select: {
|
|
96
|
+
id: true,
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!createdUser) throw new Error('User not created')
|
|
101
|
+
|
|
102
|
+
const otp = new OTP(wabe.config.rootKey).generate(createdUser.id)
|
|
103
|
+
|
|
104
|
+
const emailOTP = new EmailOTP()
|
|
105
|
+
|
|
106
|
+
expect(
|
|
107
|
+
await emailOTP.onVerifyChallenge({
|
|
108
|
+
context: {
|
|
109
|
+
wabe,
|
|
110
|
+
isRoot: false,
|
|
111
|
+
},
|
|
112
|
+
input: {
|
|
113
|
+
email: 'email@test.fr',
|
|
114
|
+
otp,
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
).toEqual({
|
|
118
|
+
userId: createdUser.id,
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("should return null if the user doesn't exist", async () => {
|
|
123
|
+
const emailOTP = new EmailOTP()
|
|
124
|
+
|
|
125
|
+
expect(
|
|
126
|
+
await emailOTP.onVerifyChallenge({
|
|
127
|
+
context: {
|
|
128
|
+
wabe,
|
|
129
|
+
isRoot: false,
|
|
130
|
+
},
|
|
131
|
+
input: {
|
|
132
|
+
email: 'email@test.fr',
|
|
133
|
+
otp: '123456',
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
).toEqual(null)
|
|
137
|
+
})
|
|
138
|
+
})
|