@alepha/react 0.11.11 → 0.12.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 +1 -183
- package/dist/auth/index.browser.js +1460 -0
- package/dist/auth/index.browser.js.map +1 -0
- package/dist/auth/index.cjs +3647 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +564 -0
- package/dist/auth/index.d.cts.map +1 -0
- package/dist/auth/index.d.ts +564 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +3615 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/{index.browser.js → core/index.browser.js} +36 -35
- package/dist/core/index.browser.js.map +1 -0
- package/dist/{index.cjs → core/index.cjs} +141 -140
- package/dist/core/index.cjs.map +1 -0
- package/dist/{index.d.cts → core/index.d.cts} +68 -68
- package/dist/core/index.d.cts.map +1 -0
- package/dist/{index.d.ts → core/index.d.ts} +68 -68
- package/dist/core/index.d.ts.map +1 -0
- package/dist/{index.js → core/index.js} +39 -38
- package/dist/core/index.js.map +1 -0
- package/dist/form/index.cjs +2054 -0
- package/dist/form/index.cjs.map +1 -0
- package/dist/form/index.d.cts +211 -0
- package/dist/form/index.d.cts.map +1 -0
- package/dist/form/index.d.ts +211 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +2026 -0
- package/dist/form/index.js.map +1 -0
- package/dist/head/index.browser.js +1503 -0
- package/dist/head/index.browser.js.map +1 -0
- package/dist/head/index.cjs +1908 -0
- package/dist/head/index.cjs.map +1 -0
- package/dist/head/index.d.cts +595 -0
- package/dist/head/index.d.cts.map +1 -0
- package/dist/head/index.d.ts +601 -0
- package/dist/head/index.d.ts.map +1 -0
- package/dist/head/index.js +1880 -0
- package/dist/head/index.js.map +1 -0
- package/dist/i18n/index.cjs +1886 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +168 -0
- package/dist/i18n/index.d.cts.map +1 -0
- package/dist/i18n/index.d.ts +168 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +1857 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/websocket/index.cjs +1774 -0
- package/dist/websocket/index.cjs.map +1 -0
- package/dist/websocket/index.d.cts +118 -0
- package/dist/websocket/index.d.cts.map +1 -0
- package/dist/websocket/index.d.ts +118 -0
- package/dist/websocket/index.d.ts.map +1 -0
- package/dist/websocket/index.js +1750 -0
- package/dist/websocket/index.js.map +1 -0
- package/package.json +89 -67
- package/src/auth/descriptors/$auth.ts +436 -0
- package/src/auth/descriptors/$authApple.ts +8 -0
- package/src/auth/descriptors/$authGithub.ts +81 -0
- package/src/auth/descriptors/$authGoogle.ts +38 -0
- package/src/auth/errors/SessionExpiredError.ts +6 -0
- package/src/auth/hooks/useAuth.ts +31 -0
- package/src/auth/index.browser.ts +16 -0
- package/src/auth/index.shared.ts +3 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/providers/ReactAuthProvider.ts +629 -0
- package/src/auth/schemas/tokenResponseSchema.ts +11 -0
- package/src/auth/schemas/tokensSchema.ts +21 -0
- package/src/auth/schemas/userinfoResponseSchema.ts +10 -0
- package/src/auth/services/ReactAuth.ts +124 -0
- package/src/{components → core/components}/ErrorViewer.tsx +3 -2
- package/src/{components → core/components}/NestedView.tsx +1 -1
- package/src/{contexts → core/contexts}/AlephaContext.ts +1 -1
- package/src/{descriptors → core/descriptors}/$page.ts +4 -4
- package/src/{hooks → core/hooks}/useAction.ts +1 -1
- package/src/{hooks → core/hooks}/useAlepha.ts +1 -1
- package/src/{hooks → core/hooks}/useClient.ts +1 -1
- package/src/{hooks → core/hooks}/useEvents.ts +1 -1
- package/src/{hooks → core/hooks}/useInject.ts +1 -1
- package/src/{hooks → core/hooks}/useQueryParams.ts +1 -1
- package/src/{hooks → core/hooks}/useRouterState.ts +1 -1
- package/src/{hooks → core/hooks}/useSchema.ts +3 -3
- package/src/{hooks → core/hooks}/useStore.ts +2 -2
- package/src/{index.browser.ts → core/index.browser.ts} +4 -4
- package/src/{index.ts → core/index.ts} +6 -6
- package/src/{providers → core/providers}/ReactBrowserProvider.ts +6 -6
- package/src/{providers → core/providers}/ReactBrowserRendererProvider.ts +2 -2
- package/src/{providers → core/providers}/ReactBrowserRouterProvider.ts +3 -3
- package/src/{providers → core/providers}/ReactPageProvider.ts +3 -3
- package/src/{providers → core/providers}/ReactServerProvider.ts +7 -7
- package/src/{services → core/services}/ReactPageServerService.ts +2 -2
- package/src/{services → core/services}/ReactPageService.ts +1 -1
- package/src/{services → core/services}/ReactRouter.ts +1 -1
- package/src/form/components/FormState.tsx +17 -0
- package/src/form/hooks/useForm.ts +47 -0
- package/src/form/hooks/useFormState.ts +130 -0
- package/src/form/index.ts +38 -0
- package/src/form/services/FormModel.ts +548 -0
- package/src/head/descriptors/$head.ts +25 -0
- package/src/head/hooks/useHead.ts +62 -0
- package/src/head/index.browser.ts +25 -0
- package/src/head/index.ts +47 -0
- package/src/head/interfaces/Head.ts +46 -0
- package/src/head/providers/BrowserHeadProvider.ts +105 -0
- package/src/head/providers/HeadProvider.ts +73 -0
- package/src/head/providers/ServerHeadProvider.ts +109 -0
- package/src/i18n/README.md +76 -0
- package/src/i18n/components/Localize.tsx +35 -0
- package/src/i18n/descriptors/$dictionary.ts +65 -0
- package/src/i18n/hooks/useI18n.ts +18 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/providers/I18nProvider.ts +277 -0
- package/src/websocket/hooks/useRoom.tsx +223 -0
- package/src/websocket/index.ts +7 -0
- package/dist/index.browser.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- /package/src/{components → core/components}/ClientOnly.tsx +0 -0
- /package/src/{components → core/components}/ErrorBoundary.tsx +0 -0
- /package/src/{components → core/components}/Link.tsx +0 -0
- /package/src/{components → core/components}/NotFound.tsx +0 -0
- /package/src/{contexts → core/contexts}/RouterLayerContext.ts +0 -0
- /package/src/{errors → core/errors}/Redirection.ts +0 -0
- /package/src/{hooks → core/hooks}/useActive.ts +0 -0
- /package/src/{hooks → core/hooks}/useRouter.ts +0 -0
- /package/src/{index.shared.ts → core/index.shared.ts} +0 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { $hook, $inject, Alepha, t } from "alepha";
|
|
2
|
+
import { DateTimeProvider } from "alepha/datetime";
|
|
3
|
+
import { $logger } from "alepha/logger";
|
|
4
|
+
import { SecurityError, type UserAccount } from "alepha/security";
|
|
5
|
+
import { $route, BadRequestError, UnauthorizedError } from "alepha/server";
|
|
6
|
+
import {
|
|
7
|
+
$cookie,
|
|
8
|
+
type Cookies,
|
|
9
|
+
ServerCookiesProvider,
|
|
10
|
+
} from "alepha/server/cookies";
|
|
11
|
+
import { ServerLinksProvider } from "alepha/server/links";
|
|
12
|
+
import {
|
|
13
|
+
authorizationCodeGrant,
|
|
14
|
+
buildAuthorizationUrl,
|
|
15
|
+
buildEndSessionUrl,
|
|
16
|
+
calculatePKCECodeChallenge,
|
|
17
|
+
randomPKCECodeVerifier,
|
|
18
|
+
randomState,
|
|
19
|
+
} from "openid-client";
|
|
20
|
+
import { $auth, type AuthDescriptor } from "../descriptors/$auth.ts";
|
|
21
|
+
import { tokenResponseSchema } from "../schemas/tokenResponseSchema.ts";
|
|
22
|
+
import { type Tokens, tokensSchema } from "../schemas/tokensSchema.ts";
|
|
23
|
+
import { userinfoResponseSchema } from "../schemas/userinfoResponseSchema.ts";
|
|
24
|
+
import { ReactAuth } from "../services/ReactAuth.ts";
|
|
25
|
+
|
|
26
|
+
export class ReactAuthProvider {
|
|
27
|
+
protected readonly log = $logger();
|
|
28
|
+
protected readonly alepha = $inject(Alepha);
|
|
29
|
+
protected readonly serverCookiesProvider = $inject(ServerCookiesProvider);
|
|
30
|
+
protected readonly dateTimeProvider = $inject(DateTimeProvider);
|
|
31
|
+
protected readonly serverLinksProvider = $inject(ServerLinksProvider);
|
|
32
|
+
protected readonly reactAuth = $inject(ReactAuth);
|
|
33
|
+
|
|
34
|
+
protected readonly authorizationCode = $cookie({
|
|
35
|
+
name: "authorizationCode",
|
|
36
|
+
ttl: [15, "minutes"],
|
|
37
|
+
httpOnly: true,
|
|
38
|
+
schema: t.object({
|
|
39
|
+
provider: t.text(),
|
|
40
|
+
codeVerifier: t.optional(t.text({ size: "long" })),
|
|
41
|
+
redirectUri: t.optional(t.text({ size: "long" })),
|
|
42
|
+
state: t.optional(t.text()),
|
|
43
|
+
nonce: t.optional(t.text()),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
public readonly tokens = $cookie({
|
|
48
|
+
name: "tokens",
|
|
49
|
+
ttl: [30, "days"],
|
|
50
|
+
httpOnly: true,
|
|
51
|
+
compress: true,
|
|
52
|
+
encrypt: true,
|
|
53
|
+
schema: tokensSchema,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
public readonly onRender = $hook({
|
|
57
|
+
on: "react:server:render:begin",
|
|
58
|
+
handler: async ({ request, state }) => {
|
|
59
|
+
if (request?.user) {
|
|
60
|
+
const { token, realm, ...user } = request.user; // do not send token and realm to the client
|
|
61
|
+
this.alepha.state.set("alepha.server.request.user", user); // for hydration, browser, etc...
|
|
62
|
+
state.user = user;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
public get identities(): Array<AuthDescriptor> {
|
|
68
|
+
return this.alepha
|
|
69
|
+
.descriptors($auth)
|
|
70
|
+
.filter((auth) => !auth.options.disabled);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
protected readonly configure = $hook({
|
|
74
|
+
on: "configure",
|
|
75
|
+
handler: async () => {
|
|
76
|
+
for (const identity of this.identities) {
|
|
77
|
+
await identity.prepare();
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
protected getAccessTokens(tokens: Tokens) {
|
|
83
|
+
const idp = this.provider(tokens.provider);
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
"oidc" in idp.options &&
|
|
87
|
+
!("realm" in idp.options) &&
|
|
88
|
+
idp.options.oidc?.useIdToken
|
|
89
|
+
) {
|
|
90
|
+
return tokens.id_token;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return tokens.access_token;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fill request headers with access token from cookies or fallback to provider's fallback function.
|
|
98
|
+
*/
|
|
99
|
+
protected readonly onRequest = $hook({
|
|
100
|
+
on: "server:onRequest",
|
|
101
|
+
after: this.serverCookiesProvider,
|
|
102
|
+
handler: async ({ request }) => {
|
|
103
|
+
const cookies = request.cookies;
|
|
104
|
+
|
|
105
|
+
// [feature] forward cookies to request headers
|
|
106
|
+
if (cookies) {
|
|
107
|
+
const tokens = await this.cookiesToTokens(cookies);
|
|
108
|
+
if (tokens) {
|
|
109
|
+
request.headers.authorization = `Bearer ${this.getAccessTokens(tokens)}`;
|
|
110
|
+
this.log.trace("Access token set in request headers", {
|
|
111
|
+
provider: tokens.provider,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// [feature] support for auth providers with fallback
|
|
117
|
+
if (!request.headers.authorization) {
|
|
118
|
+
for (const provider of this.identities) {
|
|
119
|
+
if (!("realm" in provider.options) && !!provider.options.fallback) {
|
|
120
|
+
const token = await provider.options.fallback();
|
|
121
|
+
if (token) {
|
|
122
|
+
request.headers.authorization = `Bearer ${token}`;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Convert cookies to tokens.
|
|
133
|
+
* If the tokens are expired, try to refresh them using the refresh token.
|
|
134
|
+
*/
|
|
135
|
+
protected async cookiesToTokens(
|
|
136
|
+
cookies: Cookies,
|
|
137
|
+
): Promise<Tokens | undefined> {
|
|
138
|
+
const tokens = this.tokens.get({ cookies });
|
|
139
|
+
if (!tokens) {
|
|
140
|
+
// no cookie, no tokens
|
|
141
|
+
this.log.trace("No tokens found in cookies");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.log.trace("Tokens found in cookies", {
|
|
146
|
+
expires_in: tokens.expires_in,
|
|
147
|
+
issued_at: tokens.issued_at,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// check if tokens are expired
|
|
151
|
+
const refreshedTokens = await this.refreshTokens(tokens);
|
|
152
|
+
if (!refreshedTokens) {
|
|
153
|
+
this.tokens.del({ cookies });
|
|
154
|
+
// 08/25: exception here will go to Server error handler, not the React one
|
|
155
|
+
// better to remove cookie & session and let the page handle 401 Unauthorized
|
|
156
|
+
//throw new SessionExpiredError("Session expired. Please login again.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (refreshedTokens.access_token !== tokens.access_token) {
|
|
161
|
+
this.setTokens(refreshedTokens, cookies);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return refreshedTokens;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
protected async refreshTokens(tokens: Tokens): Promise<Tokens | undefined> {
|
|
168
|
+
if (tokens.expires_in && tokens.issued_at) {
|
|
169
|
+
const gracePeriodSec = 10;
|
|
170
|
+
const expiresAt = tokens.issued_at + (tokens.expires_in - gracePeriodSec);
|
|
171
|
+
|
|
172
|
+
if (expiresAt < this.dateTimeProvider.now().unix()) {
|
|
173
|
+
this.log.trace("Tokens are expired");
|
|
174
|
+
|
|
175
|
+
// oh no, it is expired
|
|
176
|
+
if (tokens.refresh_token) {
|
|
177
|
+
this.log.trace("Trying to refresh tokens using refresh token");
|
|
178
|
+
// but has refresh token!
|
|
179
|
+
try {
|
|
180
|
+
const provider = this.provider(tokens);
|
|
181
|
+
const result = await provider.refresh(
|
|
182
|
+
tokens.refresh_token,
|
|
183
|
+
tokens.access_token,
|
|
184
|
+
);
|
|
185
|
+
const newTokens = {
|
|
186
|
+
...result,
|
|
187
|
+
provider: tokens.provider,
|
|
188
|
+
issued_at: this.dateTimeProvider.now().unix(),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
this.log.debug("Tokens refreshed successfully");
|
|
192
|
+
|
|
193
|
+
return newTokens;
|
|
194
|
+
} catch (e) {
|
|
195
|
+
this.log.warn("Failed to refresh token", e);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// session expired and no (valid) refresh token
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!tokens.issued_at && tokens.access_token) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return tokens;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get user information.
|
|
215
|
+
*/
|
|
216
|
+
public readonly userinfo = $route({
|
|
217
|
+
path: ReactAuth.path.userinfo,
|
|
218
|
+
schema: {
|
|
219
|
+
response: userinfoResponseSchema,
|
|
220
|
+
},
|
|
221
|
+
handler: async ({ user, headers, cookies }) => {
|
|
222
|
+
const tokens = this.tokens.get({ cookies });
|
|
223
|
+
if (tokens) {
|
|
224
|
+
const provider = this.provider(tokens);
|
|
225
|
+
if (!("realm" in provider.options)) {
|
|
226
|
+
const user = await provider.user(tokens);
|
|
227
|
+
const api = await this.serverLinksProvider.getUserApiLinks({
|
|
228
|
+
authorization: headers.authorization,
|
|
229
|
+
user,
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
api,
|
|
233
|
+
user,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const api = await this.serverLinksProvider.getUserApiLinks({
|
|
239
|
+
authorization: headers.authorization,
|
|
240
|
+
user,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
api,
|
|
245
|
+
user,
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Refresh a token for internal providers.
|
|
252
|
+
*/
|
|
253
|
+
public readonly refresh = $route({
|
|
254
|
+
path: ReactAuth.path.refresh,
|
|
255
|
+
method: "POST",
|
|
256
|
+
schema: {
|
|
257
|
+
query: t.object({
|
|
258
|
+
provider: t.text(),
|
|
259
|
+
}),
|
|
260
|
+
body: t.object({
|
|
261
|
+
refresh_token: t.text({
|
|
262
|
+
size: "rich",
|
|
263
|
+
}),
|
|
264
|
+
access_token: t.optional(
|
|
265
|
+
t.text({
|
|
266
|
+
size: "rich",
|
|
267
|
+
description:
|
|
268
|
+
"Required if provider has stateless refresh token on credentials mode",
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
}),
|
|
272
|
+
response: tokensSchema,
|
|
273
|
+
},
|
|
274
|
+
handler: async ({ query, body, cookies }) => {
|
|
275
|
+
const provider = this.provider(query);
|
|
276
|
+
|
|
277
|
+
const tokens = {
|
|
278
|
+
provider: query.provider,
|
|
279
|
+
...(await provider.refresh(body.refresh_token, body.access_token)),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// for web applications, we store tokens in cookies
|
|
283
|
+
this.setTokens(tokens, cookies);
|
|
284
|
+
|
|
285
|
+
return tokens;
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Login for local password-based authentication.
|
|
291
|
+
*/
|
|
292
|
+
public readonly token = $route({
|
|
293
|
+
path: ReactAuth.path.token,
|
|
294
|
+
method: "POST",
|
|
295
|
+
schema: {
|
|
296
|
+
query: t.object({
|
|
297
|
+
provider: t.text(),
|
|
298
|
+
}),
|
|
299
|
+
body: t.object({
|
|
300
|
+
username: t.text(),
|
|
301
|
+
password: t.text(),
|
|
302
|
+
}),
|
|
303
|
+
response: tokenResponseSchema,
|
|
304
|
+
},
|
|
305
|
+
handler: async ({ query, body, cookies }) => {
|
|
306
|
+
const provider = this.provider(query);
|
|
307
|
+
const realm = "realm" in provider.options && provider.options.realm;
|
|
308
|
+
if (!realm) {
|
|
309
|
+
throw new SecurityError(
|
|
310
|
+
`Auth provider '${query.provider}' does not support password grant`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const credentials =
|
|
315
|
+
"credentials" in provider.options && provider.options.credentials;
|
|
316
|
+
|
|
317
|
+
if (!credentials) {
|
|
318
|
+
throw new SecurityError(
|
|
319
|
+
`Auth provider '${query.provider}' does not support password grant`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let user: UserAccount;
|
|
324
|
+
try {
|
|
325
|
+
user = await credentials.account(body);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
throw new UnauthorizedError(`Failed to authenticate user`, {
|
|
328
|
+
cause: e,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const tokens = {
|
|
333
|
+
provider: query.provider,
|
|
334
|
+
...(await realm.createToken(user)),
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// for web applications, we store tokens in cookies
|
|
338
|
+
this.setTokens(tokens, cookies);
|
|
339
|
+
|
|
340
|
+
const api = await this.serverLinksProvider.getUserApiLinks({
|
|
341
|
+
user,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// mobile apps require this
|
|
345
|
+
return {
|
|
346
|
+
...tokens,
|
|
347
|
+
user,
|
|
348
|
+
api,
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Oauth2/OIDC login route.
|
|
355
|
+
*/
|
|
356
|
+
public readonly login = $route({
|
|
357
|
+
path: ReactAuth.path.login,
|
|
358
|
+
schema: {
|
|
359
|
+
query: t.object({
|
|
360
|
+
provider: t.text(),
|
|
361
|
+
redirect_uri: t.optional(t.text({ size: "rich" })),
|
|
362
|
+
}),
|
|
363
|
+
},
|
|
364
|
+
handler: async ({ query, url, reply }) => {
|
|
365
|
+
const provider = this.provider(query);
|
|
366
|
+
const oauth = provider.oauth;
|
|
367
|
+
if (!oauth) {
|
|
368
|
+
throw new SecurityError(
|
|
369
|
+
`Auth provider '${query.provider}' does not support OAuth2`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const scope = provider.scope;
|
|
374
|
+
let redirect_uri = provider.redirect_uri || ReactAuth.path.callback;
|
|
375
|
+
if (redirect_uri.startsWith("/")) {
|
|
376
|
+
redirect_uri = `${url.protocol}//${url.host}${redirect_uri}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const oidc = "oidc" in provider.options && provider.options.oidc;
|
|
380
|
+
|
|
381
|
+
if (!oauth.serverMetadata().supportsPKCE()) {
|
|
382
|
+
const state = randomState();
|
|
383
|
+
const parameters: Record<string, string> = {
|
|
384
|
+
redirect_uri,
|
|
385
|
+
state,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
if (oidc) {
|
|
389
|
+
parameters.nonce = randomState();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (scope) {
|
|
393
|
+
parameters.scope = scope;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this.authorizationCode.set({
|
|
397
|
+
state,
|
|
398
|
+
nonce: parameters.nonce,
|
|
399
|
+
redirectUri: query.redirect_uri ?? "/",
|
|
400
|
+
provider: query.provider,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
reply.redirect(buildAuthorizationUrl(oauth, parameters).toString());
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const codeVerifier = randomPKCECodeVerifier();
|
|
408
|
+
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
|
409
|
+
|
|
410
|
+
const parameters: Record<string, string> = {
|
|
411
|
+
redirect_uri,
|
|
412
|
+
code_challenge: codeChallenge,
|
|
413
|
+
code_challenge_method: "S256",
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
if (scope) {
|
|
417
|
+
parameters.scope = scope;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
this.authorizationCode.set({
|
|
421
|
+
codeVerifier,
|
|
422
|
+
redirectUri: query.redirect_uri ?? "/",
|
|
423
|
+
provider: query.provider,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
reply.redirect(buildAuthorizationUrl(oauth, parameters).toString());
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Callback for OAuth2/OIDC providers.
|
|
432
|
+
* It handles the authorization code flow and retrieves the access token.
|
|
433
|
+
*/
|
|
434
|
+
public readonly callback = $route({
|
|
435
|
+
path: ReactAuth.path.callback,
|
|
436
|
+
handler: async ({ url, reply, cookies }) => {
|
|
437
|
+
const authorizationCode = this.authorizationCode.get({ cookies });
|
|
438
|
+
if (!authorizationCode) {
|
|
439
|
+
throw new BadRequestError("Missing code verifier");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const provider = this.provider(authorizationCode);
|
|
443
|
+
const oauth = provider.oauth;
|
|
444
|
+
if (!oauth) {
|
|
445
|
+
throw new SecurityError(
|
|
446
|
+
`Auth provider '${provider.name}' does not support OAuth2`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const redirectUri = authorizationCode.redirectUri ?? "/";
|
|
451
|
+
|
|
452
|
+
const externalTokens = await authorizationCodeGrant(oauth, url, {
|
|
453
|
+
pkceCodeVerifier: authorizationCode.codeVerifier,
|
|
454
|
+
expectedState: authorizationCode.state,
|
|
455
|
+
expectedNonce: authorizationCode.nonce,
|
|
456
|
+
})
|
|
457
|
+
.then((tokens) => ({
|
|
458
|
+
issued_at: this.dateTimeProvider.now().unix(),
|
|
459
|
+
provider: provider.name,
|
|
460
|
+
...tokens,
|
|
461
|
+
}))
|
|
462
|
+
.catch((e) => {
|
|
463
|
+
this.log.error("Failed to get access token", e);
|
|
464
|
+
throw new SecurityError("Failed to get access token", {
|
|
465
|
+
cause: e,
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
this.authorizationCode.del({ cookies });
|
|
470
|
+
|
|
471
|
+
const realm = "realm" in provider.options && provider.options.realm;
|
|
472
|
+
|
|
473
|
+
// external, full OIDC System (e.g. Keycloak, Auth0)
|
|
474
|
+
if (!realm) {
|
|
475
|
+
this.setTokens(externalTokens, cookies);
|
|
476
|
+
reply.redirect(redirectUri);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// internal, we need to create our own tokens
|
|
481
|
+
|
|
482
|
+
const user = await provider.user(externalTokens);
|
|
483
|
+
const tokens = await realm.createToken(user);
|
|
484
|
+
|
|
485
|
+
this.setTokens(
|
|
486
|
+
{
|
|
487
|
+
...tokens,
|
|
488
|
+
issued_at: this.dateTimeProvider.now().unix(),
|
|
489
|
+
provider: provider.name,
|
|
490
|
+
},
|
|
491
|
+
cookies,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
reply.redirect(redirectUri);
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Logout route for OAuth2/OIDC providers.
|
|
500
|
+
*/
|
|
501
|
+
public readonly logout = $route({
|
|
502
|
+
path: ReactAuth.path.logout,
|
|
503
|
+
method: "GET",
|
|
504
|
+
schema: {
|
|
505
|
+
query: t.object({
|
|
506
|
+
post_logout_redirect_uri: t.optional(t.text()),
|
|
507
|
+
}),
|
|
508
|
+
},
|
|
509
|
+
handler: async ({ query, reply, cookies }) => {
|
|
510
|
+
const redirect = query.post_logout_redirect_uri ?? "/";
|
|
511
|
+
const tokens = this.tokens.get({ cookies });
|
|
512
|
+
if (!tokens) {
|
|
513
|
+
reply.redirect(redirect);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const provider = this.provider(tokens.provider);
|
|
518
|
+
|
|
519
|
+
this.tokens.del({ cookies });
|
|
520
|
+
|
|
521
|
+
// for internal providers, we can delete the session - if available
|
|
522
|
+
if ("realm" in provider.options && tokens.refresh_token) {
|
|
523
|
+
const onDeleteSession =
|
|
524
|
+
provider.options.realm.options.settings?.onDeleteSession;
|
|
525
|
+
if (onDeleteSession) {
|
|
526
|
+
try {
|
|
527
|
+
await onDeleteSession(tokens.refresh_token);
|
|
528
|
+
} catch (e) {
|
|
529
|
+
this.log.error("Failed to delete session", e);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const oauth = provider.oauth;
|
|
535
|
+
if (!oauth) {
|
|
536
|
+
reply.redirect(redirect);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const params = new URLSearchParams();
|
|
541
|
+
const idToken = tokens?.id_token;
|
|
542
|
+
|
|
543
|
+
params.set("post_logout_redirect_uri", redirect);
|
|
544
|
+
if (idToken) {
|
|
545
|
+
params.set("id_token_hint", idToken);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const customLogoutUri =
|
|
549
|
+
"oidc" in provider.options
|
|
550
|
+
? provider.options.oidc?.logoutUri
|
|
551
|
+
: undefined;
|
|
552
|
+
|
|
553
|
+
if (customLogoutUri) {
|
|
554
|
+
reply.redirect(`${customLogoutUri}?${params}`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!oauth.serverMetadata().end_session_endpoint) {
|
|
559
|
+
// await tokenRevocation(
|
|
560
|
+
// oauth,
|
|
561
|
+
// tokens?.refresh_token ?? tokens.access_token,
|
|
562
|
+
// );
|
|
563
|
+
reply.redirect(redirect);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
reply.redirect(buildEndSessionUrl(oauth, params).toString());
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
protected provider(opts: string | { provider: string }): AuthDescriptor {
|
|
572
|
+
const name = typeof opts === "string" ? opts : opts.provider;
|
|
573
|
+
const identity = this.identities.find((identity) => identity.name === name);
|
|
574
|
+
|
|
575
|
+
if (!identity) {
|
|
576
|
+
throw new SecurityError(`Auth provider '${name}' not found`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return identity;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
protected setTokens(tokens: Tokens, cookies?: Cookies): void {
|
|
583
|
+
const exp =
|
|
584
|
+
tokens.refresh_token_expires_in ||
|
|
585
|
+
tokens.refresh_expires_in ||
|
|
586
|
+
tokens.expires_in;
|
|
587
|
+
|
|
588
|
+
const ttl = exp
|
|
589
|
+
? this.dateTimeProvider.duration(exp, "seconds")
|
|
590
|
+
: undefined;
|
|
591
|
+
|
|
592
|
+
this.tokens.set(tokens, {
|
|
593
|
+
cookies,
|
|
594
|
+
ttl,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export interface OAuth2Profile {
|
|
600
|
+
sub: string; // Subject - unique ID per user (required by OpenID)
|
|
601
|
+
email?: string;
|
|
602
|
+
name?: string;
|
|
603
|
+
given_name?: string;
|
|
604
|
+
family_name?: string;
|
|
605
|
+
middle_name?: string;
|
|
606
|
+
nickname?: string;
|
|
607
|
+
preferred_username?: string;
|
|
608
|
+
profile?: string;
|
|
609
|
+
picture?: string;
|
|
610
|
+
website?: string;
|
|
611
|
+
email_verified?: boolean;
|
|
612
|
+
gender?: string;
|
|
613
|
+
birthdate?: string; // ISO 8601: YYYY-MM-DD
|
|
614
|
+
zoneinfo?: string;
|
|
615
|
+
locale?: string;
|
|
616
|
+
phone_number?: string;
|
|
617
|
+
phone_number_verified?: boolean;
|
|
618
|
+
address?: {
|
|
619
|
+
formatted?: string;
|
|
620
|
+
street_address?: string;
|
|
621
|
+
locality?: string;
|
|
622
|
+
region?: string;
|
|
623
|
+
postal_code?: string;
|
|
624
|
+
country?: string;
|
|
625
|
+
};
|
|
626
|
+
updated_at?: number; // seconds since epoch
|
|
627
|
+
// Allow additional fields (provider-specific)
|
|
628
|
+
[key: string]: unknown;
|
|
629
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Static, t } from "alepha";
|
|
2
|
+
import { userAccountInfoSchema } from "alepha/security";
|
|
3
|
+
import { apiLinksResponseSchema } from "alepha/server/links";
|
|
4
|
+
import { tokensSchema } from "./tokensSchema.ts";
|
|
5
|
+
|
|
6
|
+
export const tokenResponseSchema = t.extend(tokensSchema, {
|
|
7
|
+
user: userAccountInfoSchema,
|
|
8
|
+
api: apiLinksResponseSchema,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type TokenResponse = Static<typeof tokenResponseSchema>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Static } from "alepha";
|
|
2
|
+
import { t } from "alepha";
|
|
3
|
+
|
|
4
|
+
export const tokensSchema = t.object({
|
|
5
|
+
provider: t.text(),
|
|
6
|
+
access_token: t.text({ size: "rich" }),
|
|
7
|
+
issued_at: t.number(),
|
|
8
|
+
expires_in: t.optional(t.number()),
|
|
9
|
+
refresh_token: t.optional(t.text({ size: "rich" })),
|
|
10
|
+
refresh_token_expires_in: t.optional(t.number()),
|
|
11
|
+
refresh_expires_in: t.optional(
|
|
12
|
+
t.number({
|
|
13
|
+
description:
|
|
14
|
+
"Alias of `refresh_token_expires_in` for compatibility with some providers.",
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
id_token: t.optional(t.text({ size: "rich" })),
|
|
18
|
+
scope: t.optional(t.text()),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type Tokens = Static<typeof tokensSchema>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Static, t } from "alepha";
|
|
2
|
+
import { userAccountInfoSchema } from "alepha/security";
|
|
3
|
+
import { apiLinksResponseSchema } from "alepha/server/links";
|
|
4
|
+
|
|
5
|
+
export const userinfoResponseSchema = t.object({
|
|
6
|
+
user: t.optional(userAccountInfoSchema),
|
|
7
|
+
api: apiLinksResponseSchema,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type UserinfoResponse = Static<typeof userinfoResponseSchema>;
|