omgkit 2.2.0 → 2.3.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/package.json +1 -1
- package/plugin/skills/databases/mongodb/SKILL.md +60 -776
- package/plugin/skills/databases/prisma/SKILL.md +53 -744
- package/plugin/skills/databases/redis/SKILL.md +53 -860
- package/plugin/skills/devops/aws/SKILL.md +68 -672
- package/plugin/skills/devops/github-actions/SKILL.md +54 -657
- package/plugin/skills/devops/kubernetes/SKILL.md +67 -602
- package/plugin/skills/devops/performance-profiling/SKILL.md +59 -863
- package/plugin/skills/frameworks/django/SKILL.md +87 -853
- package/plugin/skills/frameworks/express/SKILL.md +95 -1301
- package/plugin/skills/frameworks/fastapi/SKILL.md +90 -1198
- package/plugin/skills/frameworks/laravel/SKILL.md +87 -1187
- package/plugin/skills/frameworks/nestjs/SKILL.md +106 -973
- package/plugin/skills/frameworks/react/SKILL.md +94 -962
- package/plugin/skills/frameworks/vue/SKILL.md +95 -1242
- package/plugin/skills/frontend/accessibility/SKILL.md +91 -1056
- package/plugin/skills/frontend/frontend-design/SKILL.md +69 -1262
- package/plugin/skills/frontend/responsive/SKILL.md +76 -799
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +73 -921
- package/plugin/skills/frontend/tailwindcss/SKILL.md +60 -788
- package/plugin/skills/frontend/threejs/SKILL.md +72 -1266
- package/plugin/skills/languages/javascript/SKILL.md +106 -849
- package/plugin/skills/methodology/brainstorming/SKILL.md +70 -576
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +79 -831
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +81 -654
- package/plugin/skills/methodology/executing-plans/SKILL.md +86 -529
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +95 -586
- package/plugin/skills/methodology/problem-solving/SKILL.md +67 -681
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +70 -533
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +70 -610
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +70 -646
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +70 -478
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +66 -559
- package/plugin/skills/methodology/test-driven-development/SKILL.md +91 -752
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +78 -687
- package/plugin/skills/methodology/token-optimization/SKILL.md +72 -602
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +108 -529
- package/plugin/skills/methodology/writing-plans/SKILL.md +79 -566
- package/plugin/skills/omega/omega-architecture/SKILL.md +91 -752
- package/plugin/skills/omega/omega-coding/SKILL.md +161 -552
- package/plugin/skills/omega/omega-sprint/SKILL.md +132 -777
- package/plugin/skills/omega/omega-testing/SKILL.md +157 -845
- package/plugin/skills/omega/omega-thinking/SKILL.md +165 -606
- package/plugin/skills/security/better-auth/SKILL.md +46 -1034
- package/plugin/skills/security/oauth/SKILL.md +80 -934
- package/plugin/skills/security/owasp/SKILL.md +78 -862
- package/plugin/skills/testing/playwright/SKILL.md +77 -700
- package/plugin/skills/testing/pytest/SKILL.md +73 -811
- package/plugin/skills/testing/vitest/SKILL.md +60 -920
- package/plugin/skills/tools/document-processing/SKILL.md +111 -838
- package/plugin/skills/tools/image-processing/SKILL.md +126 -659
- package/plugin/skills/tools/mcp-development/SKILL.md +85 -758
- package/plugin/skills/tools/media-processing/SKILL.md +118 -735
- package/plugin/stdrules/SKILL_STANDARDS.md +490 -0
- package/plugin/skills/SKILL_STANDARDS.md +0 -743
|
@@ -1,982 +1,129 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
3
|
-
description: OAuth 2.0 and OpenID Connect
|
|
4
|
-
category: security
|
|
5
|
-
triggers:
|
|
6
|
-
- oauth
|
|
7
|
-
- oauth2
|
|
8
|
-
- openid connect
|
|
9
|
-
- oidc
|
|
10
|
-
- social login
|
|
11
|
-
- authorization
|
|
2
|
+
name: Implementing OAuth
|
|
3
|
+
description: Claude implements OAuth 2.0 and OpenID Connect authorization flows. Use when adding social login, integrating OAuth providers, managing tokens, or securing APIs with OAuth.
|
|
12
4
|
---
|
|
13
5
|
|
|
14
|
-
# OAuth
|
|
6
|
+
# Implementing OAuth
|
|
15
7
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## Purpose
|
|
19
|
-
|
|
20
|
-
Build secure authorization systems:
|
|
21
|
-
|
|
22
|
-
- Implement OAuth 2.0 authorization flows
|
|
23
|
-
- Configure OpenID Connect for identity
|
|
24
|
-
- Integrate social login providers
|
|
25
|
-
- Manage access and refresh tokens
|
|
26
|
-
- Implement PKCE for public clients
|
|
27
|
-
- Handle token refresh and revocation
|
|
28
|
-
- Secure API endpoints with OAuth
|
|
29
|
-
|
|
30
|
-
## Features
|
|
31
|
-
|
|
32
|
-
### 1. Authorization Code Flow with PKCE
|
|
8
|
+
## Quick Start
|
|
33
9
|
|
|
34
10
|
```typescript
|
|
35
|
-
// lib/oauth/
|
|
11
|
+
// lib/oauth/client.ts
|
|
36
12
|
import crypto from "crypto";
|
|
37
13
|
|
|
38
|
-
export
|
|
39
|
-
codeVerifier: string;
|
|
40
|
-
codeChallenge: string;
|
|
41
|
-
state: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function generatePKCE(): PKCEPair {
|
|
45
|
-
// Generate code verifier (43-128 characters)
|
|
14
|
+
export function generatePKCE() {
|
|
46
15
|
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
47
|
-
|
|
48
|
-
// Generate code challenge using SHA-256
|
|
49
|
-
const codeChallenge = crypto
|
|
50
|
-
.createHash("sha256")
|
|
51
|
-
.update(codeVerifier)
|
|
52
|
-
.digest("base64url");
|
|
53
|
-
|
|
54
|
-
// Generate state for CSRF protection
|
|
16
|
+
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
|
|
55
17
|
const state = crypto.randomBytes(16).toString("hex");
|
|
56
|
-
|
|
57
18
|
return { codeVerifier, codeChallenge, state };
|
|
58
19
|
}
|
|
59
20
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface TokenResponse {
|
|
74
|
-
accessToken: string;
|
|
75
|
-
tokenType: string;
|
|
76
|
-
expiresIn: number;
|
|
77
|
-
refreshToken?: string;
|
|
78
|
-
idToken?: string;
|
|
79
|
-
scope?: string;
|
|
21
|
+
export function buildAuthUrl(config: OAuthConfig, pkce: PKCEPair) {
|
|
22
|
+
const params = new URLSearchParams({
|
|
23
|
+
client_id: config.clientId,
|
|
24
|
+
redirect_uri: config.redirectUri,
|
|
25
|
+
response_type: "code",
|
|
26
|
+
scope: config.scopes.join(" "),
|
|
27
|
+
state: pkce.state,
|
|
28
|
+
code_challenge: pkce.codeChallenge,
|
|
29
|
+
code_challenge_method: "S256",
|
|
30
|
+
});
|
|
31
|
+
return `${config.authorizationEndpoint}?${params}`;
|
|
80
32
|
}
|
|
33
|
+
```
|
|
81
34
|
|
|
82
|
-
|
|
83
|
-
private config: OAuthConfig;
|
|
84
|
-
private pkce: PKCEPair | null = null;
|
|
85
|
-
|
|
86
|
-
constructor(config: OAuthConfig) {
|
|
87
|
-
this.config = config;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
generateAuthorizationUrl(options: {
|
|
91
|
-
state?: string;
|
|
92
|
-
nonce?: string;
|
|
93
|
-
prompt?: "none" | "login" | "consent" | "select_account";
|
|
94
|
-
loginHint?: string;
|
|
95
|
-
} = {}): { url: string; pkce: PKCEPair } {
|
|
96
|
-
this.pkce = generatePKCE();
|
|
97
|
-
|
|
98
|
-
const params = new URLSearchParams({
|
|
99
|
-
client_id: this.config.clientId,
|
|
100
|
-
redirect_uri: this.config.redirectUri,
|
|
101
|
-
response_type: "code",
|
|
102
|
-
scope: this.config.scopes.join(" "),
|
|
103
|
-
state: options.state || this.pkce.state,
|
|
104
|
-
code_challenge: this.pkce.codeChallenge,
|
|
105
|
-
code_challenge_method: "S256",
|
|
106
|
-
});
|
|
35
|
+
## Features
|
|
107
36
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
37
|
+
| Feature | Description | Reference |
|
|
38
|
+
|---------|-------------|-----------|
|
|
39
|
+
| Authorization Code + PKCE | Secure flow for public/confidential clients | [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) |
|
|
40
|
+
| Token Management | Access/refresh token handling and storage | [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) |
|
|
41
|
+
| OpenID Connect | Identity layer with ID tokens and claims | [OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html) |
|
|
42
|
+
| Provider Integration | Google, GitHub, Microsoft configurations | [OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) |
|
|
43
|
+
| JWT Validation | ID token signature and claims verification | [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519) |
|
|
111
44
|
|
|
112
|
-
|
|
45
|
+
## Common Patterns
|
|
113
46
|
|
|
114
|
-
|
|
115
|
-
}
|
|
47
|
+
### Token Exchange
|
|
116
48
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
49
|
+
```typescript
|
|
50
|
+
async function exchangeCodeForTokens(code: string, codeVerifier: string) {
|
|
51
|
+
const response = await fetch(config.tokenEndpoint, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
54
|
+
body: new URLSearchParams({
|
|
122
55
|
grant_type: "authorization_code",
|
|
123
56
|
code,
|
|
124
|
-
redirect_uri:
|
|
125
|
-
client_id:
|
|
57
|
+
redirect_uri: config.redirectUri,
|
|
58
|
+
client_id: config.clientId,
|
|
126
59
|
code_verifier: codeVerifier,
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
// Add client secret for confidential clients
|
|
130
|
-
if (this.config.clientSecret) {
|
|
131
|
-
body.set("client_secret", this.config.clientSecret);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const response = await fetch(this.config.tokenEndpoint, {
|
|
135
|
-
method: "POST",
|
|
136
|
-
headers: {
|
|
137
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
138
|
-
},
|
|
139
|
-
body: body.toString(),
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
if (!response.ok) {
|
|
143
|
-
const error = await response.json();
|
|
144
|
-
throw new OAuthError(error.error, error.error_description);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const data = await response.json();
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
accessToken: data.access_token,
|
|
151
|
-
tokenType: data.token_type,
|
|
152
|
-
expiresIn: data.expires_in,
|
|
153
|
-
refreshToken: data.refresh_token,
|
|
154
|
-
idToken: data.id_token,
|
|
155
|
-
scope: data.scope,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
|
160
|
-
const body = new URLSearchParams({
|
|
161
|
-
grant_type: "refresh_token",
|
|
162
|
-
refresh_token: refreshToken,
|
|
163
|
-
client_id: this.config.clientId,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
if (this.config.clientSecret) {
|
|
167
|
-
body.set("client_secret", this.config.clientSecret);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const response = await fetch(this.config.tokenEndpoint, {
|
|
171
|
-
method: "POST",
|
|
172
|
-
headers: {
|
|
173
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
174
|
-
},
|
|
175
|
-
body: body.toString(),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
if (!response.ok) {
|
|
179
|
-
const error = await response.json();
|
|
180
|
-
throw new OAuthError(error.error, error.error_description);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const data = await response.json();
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
accessToken: data.access_token,
|
|
187
|
-
tokenType: data.token_type,
|
|
188
|
-
expiresIn: data.expires_in,
|
|
189
|
-
refreshToken: data.refresh_token || refreshToken,
|
|
190
|
-
idToken: data.id_token,
|
|
191
|
-
scope: data.scope,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async getUserInfo(accessToken: string): Promise<Record<string, unknown>> {
|
|
196
|
-
if (!this.config.userInfoEndpoint) {
|
|
197
|
-
throw new Error("UserInfo endpoint not configured");
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const response = await fetch(this.config.userInfoEndpoint, {
|
|
201
|
-
headers: {
|
|
202
|
-
Authorization: `Bearer ${accessToken}`,
|
|
203
|
-
},
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
if (!response.ok) {
|
|
207
|
-
throw new OAuthError("invalid_token", "Failed to fetch user info");
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return response.json();
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export class OAuthError extends Error {
|
|
215
|
-
constructor(
|
|
216
|
-
public code: string,
|
|
217
|
-
public description?: string
|
|
218
|
-
) {
|
|
219
|
-
super(description || code);
|
|
220
|
-
this.name = "OAuthError";
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
### 2. Provider Configuration
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
// lib/oauth/providers/google.ts
|
|
229
|
-
import { OAuthConfig } from "../client";
|
|
230
|
-
|
|
231
|
-
export function createGoogleConfig(options: {
|
|
232
|
-
clientId: string;
|
|
233
|
-
clientSecret: string;
|
|
234
|
-
redirectUri: string;
|
|
235
|
-
scopes?: string[];
|
|
236
|
-
}): OAuthConfig {
|
|
237
|
-
return {
|
|
238
|
-
clientId: options.clientId,
|
|
239
|
-
clientSecret: options.clientSecret,
|
|
240
|
-
redirectUri: options.redirectUri,
|
|
241
|
-
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
242
|
-
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
243
|
-
userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
244
|
-
scopes: options.scopes || ["openid", "email", "profile"],
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// lib/oauth/providers/github.ts
|
|
249
|
-
export function createGitHubConfig(options: {
|
|
250
|
-
clientId: string;
|
|
251
|
-
clientSecret: string;
|
|
252
|
-
redirectUri: string;
|
|
253
|
-
scopes?: string[];
|
|
254
|
-
}): OAuthConfig {
|
|
255
|
-
return {
|
|
256
|
-
clientId: options.clientId,
|
|
257
|
-
clientSecret: options.clientSecret,
|
|
258
|
-
redirectUri: options.redirectUri,
|
|
259
|
-
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
|
260
|
-
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
|
261
|
-
userInfoEndpoint: "https://api.github.com/user",
|
|
262
|
-
scopes: options.scopes || ["read:user", "user:email"],
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// lib/oauth/providers/microsoft.ts
|
|
267
|
-
export function createMicrosoftConfig(options: {
|
|
268
|
-
clientId: string;
|
|
269
|
-
clientSecret: string;
|
|
270
|
-
redirectUri: string;
|
|
271
|
-
tenant?: string;
|
|
272
|
-
scopes?: string[];
|
|
273
|
-
}): OAuthConfig {
|
|
274
|
-
const tenant = options.tenant || "common";
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
275
62
|
|
|
63
|
+
const data = await response.json();
|
|
276
64
|
return {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
tokenEndpoint: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
|
282
|
-
userInfoEndpoint: "https://graph.microsoft.com/oidc/userinfo",
|
|
283
|
-
scopes: options.scopes || ["openid", "email", "profile", "User.Read"],
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// lib/oauth/providers/index.ts
|
|
288
|
-
import { createGoogleConfig } from "./google";
|
|
289
|
-
import { createGitHubConfig } from "./github";
|
|
290
|
-
import { createMicrosoftConfig } from "./microsoft";
|
|
291
|
-
import { OAuthClient } from "../client";
|
|
292
|
-
|
|
293
|
-
export type Provider = "google" | "github" | "microsoft";
|
|
294
|
-
|
|
295
|
-
export interface ProviderConfig {
|
|
296
|
-
google?: {
|
|
297
|
-
clientId: string;
|
|
298
|
-
clientSecret: string;
|
|
65
|
+
accessToken: data.access_token,
|
|
66
|
+
refreshToken: data.refresh_token,
|
|
67
|
+
expiresIn: data.expires_in,
|
|
68
|
+
idToken: data.id_token,
|
|
299
69
|
};
|
|
300
|
-
github?: {
|
|
301
|
-
clientId: string;
|
|
302
|
-
clientSecret: string;
|
|
303
|
-
};
|
|
304
|
-
microsoft?: {
|
|
305
|
-
clientId: string;
|
|
306
|
-
clientSecret: string;
|
|
307
|
-
tenant?: string;
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function createOAuthClient(
|
|
312
|
-
provider: Provider,
|
|
313
|
-
config: ProviderConfig,
|
|
314
|
-
redirectUri: string
|
|
315
|
-
): OAuthClient {
|
|
316
|
-
switch (provider) {
|
|
317
|
-
case "google":
|
|
318
|
-
if (!config.google) throw new Error("Google config not provided");
|
|
319
|
-
return new OAuthClient(
|
|
320
|
-
createGoogleConfig({
|
|
321
|
-
clientId: config.google.clientId,
|
|
322
|
-
clientSecret: config.google.clientSecret,
|
|
323
|
-
redirectUri,
|
|
324
|
-
})
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
case "github":
|
|
328
|
-
if (!config.github) throw new Error("GitHub config not provided");
|
|
329
|
-
return new OAuthClient(
|
|
330
|
-
createGitHubConfig({
|
|
331
|
-
clientId: config.github.clientId,
|
|
332
|
-
clientSecret: config.github.clientSecret,
|
|
333
|
-
redirectUri,
|
|
334
|
-
})
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
case "microsoft":
|
|
338
|
-
if (!config.microsoft) throw new Error("Microsoft config not provided");
|
|
339
|
-
return new OAuthClient(
|
|
340
|
-
createMicrosoftConfig({
|
|
341
|
-
clientId: config.microsoft.clientId,
|
|
342
|
-
clientSecret: config.microsoft.clientSecret,
|
|
343
|
-
redirectUri,
|
|
344
|
-
tenant: config.microsoft.tenant,
|
|
345
|
-
})
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
default:
|
|
349
|
-
throw new Error(`Unknown provider: ${provider}`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
### 3. Token Validation and JWT
|
|
355
|
-
|
|
356
|
-
```typescript
|
|
357
|
-
// lib/oauth/jwt.ts
|
|
358
|
-
import jwt from "jsonwebtoken";
|
|
359
|
-
import jwksClient from "jwks-rsa";
|
|
360
|
-
|
|
361
|
-
export interface JWTClaims {
|
|
362
|
-
iss: string;
|
|
363
|
-
sub: string;
|
|
364
|
-
aud: string | string[];
|
|
365
|
-
exp: number;
|
|
366
|
-
iat: number;
|
|
367
|
-
nonce?: string;
|
|
368
|
-
email?: string;
|
|
369
|
-
email_verified?: boolean;
|
|
370
|
-
name?: string;
|
|
371
|
-
picture?: string;
|
|
372
|
-
[key: string]: unknown;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
export interface JWKSConfig {
|
|
376
|
-
jwksUri: string;
|
|
377
|
-
issuer: string;
|
|
378
|
-
audience: string;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
export class JWTValidator {
|
|
382
|
-
private client: jwksClient.JwksClient;
|
|
383
|
-
private issuer: string;
|
|
384
|
-
private audience: string;
|
|
385
|
-
|
|
386
|
-
constructor(config: JWKSConfig) {
|
|
387
|
-
this.client = jwksClient({
|
|
388
|
-
jwksUri: config.jwksUri,
|
|
389
|
-
cache: true,
|
|
390
|
-
cacheMaxEntries: 5,
|
|
391
|
-
cacheMaxAge: 600000, // 10 minutes
|
|
392
|
-
});
|
|
393
|
-
this.issuer = config.issuer;
|
|
394
|
-
this.audience = config.audience;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
private getKey(
|
|
398
|
-
header: jwt.JwtHeader,
|
|
399
|
-
callback: jwt.SigningKeyCallback
|
|
400
|
-
): void {
|
|
401
|
-
this.client.getSigningKey(header.kid, (err, key) => {
|
|
402
|
-
if (err) {
|
|
403
|
-
callback(err);
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
const signingKey = key?.getPublicKey();
|
|
407
|
-
callback(null, signingKey);
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async validateIdToken(
|
|
412
|
-
idToken: string,
|
|
413
|
-
options: { nonce?: string } = {}
|
|
414
|
-
): Promise<JWTClaims> {
|
|
415
|
-
return new Promise((resolve, reject) => {
|
|
416
|
-
jwt.verify(
|
|
417
|
-
idToken,
|
|
418
|
-
(header, callback) => this.getKey(header, callback),
|
|
419
|
-
{
|
|
420
|
-
issuer: this.issuer,
|
|
421
|
-
audience: this.audience,
|
|
422
|
-
algorithms: ["RS256"],
|
|
423
|
-
},
|
|
424
|
-
(err, decoded) => {
|
|
425
|
-
if (err) {
|
|
426
|
-
reject(new Error(`Token validation failed: ${err.message}`));
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const claims = decoded as JWTClaims;
|
|
431
|
-
|
|
432
|
-
// Validate nonce if provided
|
|
433
|
-
if (options.nonce && claims.nonce !== options.nonce) {
|
|
434
|
-
reject(new Error("Nonce mismatch"));
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
resolve(claims);
|
|
439
|
-
}
|
|
440
|
-
);
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// lib/oauth/token-store.ts
|
|
446
|
-
export interface StoredToken {
|
|
447
|
-
accessToken: string;
|
|
448
|
-
refreshToken?: string;
|
|
449
|
-
idToken?: string;
|
|
450
|
-
expiresAt: number;
|
|
451
|
-
tokenType: string;
|
|
452
|
-
scope?: string;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export interface TokenStore {
|
|
456
|
-
save(userId: string, token: StoredToken): Promise<void>;
|
|
457
|
-
get(userId: string): Promise<StoredToken | null>;
|
|
458
|
-
delete(userId: string): Promise<void>;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Redis implementation
|
|
462
|
-
import Redis from "ioredis";
|
|
463
|
-
|
|
464
|
-
export class RedisTokenStore implements TokenStore {
|
|
465
|
-
private redis: Redis;
|
|
466
|
-
private prefix: string;
|
|
467
|
-
|
|
468
|
-
constructor(redis: Redis, prefix = "oauth:token:") {
|
|
469
|
-
this.redis = redis;
|
|
470
|
-
this.prefix = prefix;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
async save(userId: string, token: StoredToken): Promise<void> {
|
|
474
|
-
const key = `${this.prefix}${userId}`;
|
|
475
|
-
const ttl = Math.floor((token.expiresAt - Date.now()) / 1000);
|
|
476
|
-
|
|
477
|
-
await this.redis.setex(key, ttl, JSON.stringify(token));
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
async get(userId: string): Promise<StoredToken | null> {
|
|
481
|
-
const key = `${this.prefix}${userId}`;
|
|
482
|
-
const data = await this.redis.get(key);
|
|
483
|
-
|
|
484
|
-
if (!data) return null;
|
|
485
|
-
|
|
486
|
-
return JSON.parse(data);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async delete(userId: string): Promise<void> {
|
|
490
|
-
const key = `${this.prefix}${userId}`;
|
|
491
|
-
await this.redis.del(key);
|
|
492
|
-
}
|
|
493
70
|
}
|
|
494
71
|
```
|
|
495
72
|
|
|
496
|
-
###
|
|
73
|
+
### Provider Configuration
|
|
497
74
|
|
|
498
75
|
```typescript
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const router = express.Router();
|
|
507
|
-
|
|
508
|
-
const providerConfig = {
|
|
509
|
-
google: {
|
|
510
|
-
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
511
|
-
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
512
|
-
},
|
|
513
|
-
github: {
|
|
514
|
-
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
515
|
-
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
516
|
-
},
|
|
76
|
+
// Google OAuth
|
|
77
|
+
const googleConfig = {
|
|
78
|
+
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
79
|
+
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
80
|
+
userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
81
|
+
scopes: ["openid", "email", "profile"],
|
|
517
82
|
};
|
|
518
83
|
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const client = createOAuthClient(provider, providerConfig, redirectUri);
|
|
526
|
-
const { url, pkce } = client.generateAuthorizationUrl();
|
|
527
|
-
|
|
528
|
-
// Store PKCE and state in session
|
|
529
|
-
req.session.oauth = {
|
|
530
|
-
provider,
|
|
531
|
-
state: pkce.state,
|
|
532
|
-
codeVerifier: pkce.codeVerifier,
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
res.redirect(url);
|
|
536
|
-
} catch (error) {
|
|
537
|
-
res.status(400).json({ error: "Invalid provider" });
|
|
538
|
-
}
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
// Handle OAuth callback
|
|
542
|
-
router.get("/callback/:provider", async (req, res) => {
|
|
543
|
-
const { code, state, error, error_description } = req.query;
|
|
544
|
-
|
|
545
|
-
// Handle OAuth errors
|
|
546
|
-
if (error) {
|
|
547
|
-
return res.redirect(
|
|
548
|
-
`/login?error=${encodeURIComponent(error_description as string || error as string)}`
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Validate state
|
|
553
|
-
if (!req.session.oauth || req.session.oauth.state !== state) {
|
|
554
|
-
return res.redirect("/login?error=invalid_state");
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const { provider, codeVerifier } = req.session.oauth;
|
|
558
|
-
const redirectUri = `${process.env.BASE_URL}/auth/callback/${provider}`;
|
|
559
|
-
|
|
560
|
-
try {
|
|
561
|
-
const client = createOAuthClient(
|
|
562
|
-
provider as Provider,
|
|
563
|
-
providerConfig,
|
|
564
|
-
redirectUri
|
|
565
|
-
);
|
|
566
|
-
|
|
567
|
-
// Exchange code for tokens
|
|
568
|
-
const tokens = await client.exchangeCodeForTokens(
|
|
569
|
-
code as string,
|
|
570
|
-
codeVerifier
|
|
571
|
-
);
|
|
572
|
-
|
|
573
|
-
// Get user info
|
|
574
|
-
const userInfo = await client.getUserInfo(tokens.accessToken);
|
|
575
|
-
|
|
576
|
-
// Find or create user
|
|
577
|
-
const user = await findOrCreateUser({
|
|
578
|
-
provider,
|
|
579
|
-
providerId: userInfo.sub || userInfo.id,
|
|
580
|
-
email: userInfo.email,
|
|
581
|
-
name: userInfo.name,
|
|
582
|
-
picture: userInfo.picture,
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
// Store tokens
|
|
586
|
-
const tokenStore = new RedisTokenStore(redis);
|
|
587
|
-
await tokenStore.save(user.id, {
|
|
588
|
-
accessToken: tokens.accessToken,
|
|
589
|
-
refreshToken: tokens.refreshToken,
|
|
590
|
-
idToken: tokens.idToken,
|
|
591
|
-
expiresAt: Date.now() + tokens.expiresIn * 1000,
|
|
592
|
-
tokenType: tokens.tokenType,
|
|
593
|
-
scope: tokens.scope,
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
// Create session
|
|
597
|
-
req.session.userId = user.id;
|
|
598
|
-
delete req.session.oauth;
|
|
599
|
-
|
|
600
|
-
res.redirect("/dashboard");
|
|
601
|
-
} catch (error) {
|
|
602
|
-
console.error("OAuth callback error:", error);
|
|
603
|
-
|
|
604
|
-
if (error instanceof OAuthError) {
|
|
605
|
-
return res.redirect(`/login?error=${encodeURIComponent(error.description || error.code)}`);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
res.redirect("/login?error=authentication_failed");
|
|
609
|
-
}
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
// Logout
|
|
613
|
-
router.post("/logout", async (req, res) => {
|
|
614
|
-
if (req.session.userId) {
|
|
615
|
-
const tokenStore = new RedisTokenStore(redis);
|
|
616
|
-
await tokenStore.delete(req.session.userId);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
req.session.destroy((err) => {
|
|
620
|
-
if (err) {
|
|
621
|
-
return res.status(500).json({ error: "Logout failed" });
|
|
622
|
-
}
|
|
623
|
-
res.clearCookie("connect.sid");
|
|
624
|
-
res.json({ success: true });
|
|
625
|
-
});
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
export default router;
|
|
629
|
-
```
|
|
630
|
-
|
|
631
|
-
### 5. Token Refresh Middleware
|
|
632
|
-
|
|
633
|
-
```typescript
|
|
634
|
-
// middleware/oauth.ts
|
|
635
|
-
import { Request, Response, NextFunction } from "express";
|
|
636
|
-
import { createOAuthClient, Provider } from "../lib/oauth/providers";
|
|
637
|
-
import { RedisTokenStore, StoredToken } from "../lib/oauth/token-store";
|
|
638
|
-
|
|
639
|
-
const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
|
640
|
-
|
|
641
|
-
export async function ensureFreshToken(
|
|
642
|
-
req: Request,
|
|
643
|
-
res: Response,
|
|
644
|
-
next: NextFunction
|
|
645
|
-
) {
|
|
646
|
-
if (!req.session.userId) {
|
|
647
|
-
return next();
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const tokenStore = new RedisTokenStore(redis);
|
|
651
|
-
const storedToken = await tokenStore.get(req.session.userId);
|
|
652
|
-
|
|
653
|
-
if (!storedToken) {
|
|
654
|
-
return next();
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Check if token needs refresh
|
|
658
|
-
const timeUntilExpiry = storedToken.expiresAt - Date.now();
|
|
659
|
-
|
|
660
|
-
if (timeUntilExpiry > TOKEN_REFRESH_THRESHOLD) {
|
|
661
|
-
req.accessToken = storedToken.accessToken;
|
|
662
|
-
return next();
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Refresh token if we have a refresh token
|
|
666
|
-
if (!storedToken.refreshToken) {
|
|
667
|
-
// Token expired and no refresh token
|
|
668
|
-
delete req.session.userId;
|
|
669
|
-
return res.redirect("/login?error=session_expired");
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
try {
|
|
673
|
-
const user = await getUserById(req.session.userId);
|
|
674
|
-
const provider = user.oauthProvider as Provider;
|
|
675
|
-
const redirectUri = `${process.env.BASE_URL}/auth/callback/${provider}`;
|
|
676
|
-
|
|
677
|
-
const client = createOAuthClient(provider, providerConfig, redirectUri);
|
|
678
|
-
const newTokens = await client.refreshTokens(storedToken.refreshToken);
|
|
679
|
-
|
|
680
|
-
// Store new tokens
|
|
681
|
-
await tokenStore.save(req.session.userId, {
|
|
682
|
-
accessToken: newTokens.accessToken,
|
|
683
|
-
refreshToken: newTokens.refreshToken || storedToken.refreshToken,
|
|
684
|
-
idToken: newTokens.idToken,
|
|
685
|
-
expiresAt: Date.now() + newTokens.expiresIn * 1000,
|
|
686
|
-
tokenType: newTokens.tokenType,
|
|
687
|
-
scope: newTokens.scope,
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
req.accessToken = newTokens.accessToken;
|
|
691
|
-
next();
|
|
692
|
-
} catch (error) {
|
|
693
|
-
console.error("Token refresh failed:", error);
|
|
694
|
-
delete req.session.userId;
|
|
695
|
-
await tokenStore.delete(req.session.userId);
|
|
696
|
-
res.redirect("/login?error=session_expired");
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// middleware/requireAuth.ts
|
|
701
|
-
export function requireAuth(
|
|
702
|
-
req: Request,
|
|
703
|
-
res: Response,
|
|
704
|
-
next: NextFunction
|
|
705
|
-
) {
|
|
706
|
-
if (!req.session.userId) {
|
|
707
|
-
if (req.accepts("html")) {
|
|
708
|
-
return res.redirect(`/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
|
|
709
|
-
}
|
|
710
|
-
return res.status(401).json({ error: "Authentication required" });
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
next();
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Combine middlewares
|
|
717
|
-
export const authMiddleware = [ensureFreshToken, requireAuth];
|
|
718
|
-
```
|
|
719
|
-
|
|
720
|
-
### 6. API Resource Protection
|
|
721
|
-
|
|
722
|
-
```typescript
|
|
723
|
-
// middleware/oauth-scope.ts
|
|
724
|
-
import { Request, Response, NextFunction } from "express";
|
|
725
|
-
|
|
726
|
-
export function requireScope(...requiredScopes: string[]) {
|
|
727
|
-
return async (req: Request, res: Response, next: NextFunction) => {
|
|
728
|
-
const tokenStore = new RedisTokenStore(redis);
|
|
729
|
-
const storedToken = await tokenStore.get(req.session.userId);
|
|
730
|
-
|
|
731
|
-
if (!storedToken || !storedToken.scope) {
|
|
732
|
-
return res.status(403).json({
|
|
733
|
-
error: "insufficient_scope",
|
|
734
|
-
message: "Required scopes not granted",
|
|
735
|
-
required_scopes: requiredScopes,
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
const grantedScopes = storedToken.scope.split(" ");
|
|
740
|
-
const hasAllScopes = requiredScopes.every((scope) =>
|
|
741
|
-
grantedScopes.includes(scope)
|
|
742
|
-
);
|
|
743
|
-
|
|
744
|
-
if (!hasAllScopes) {
|
|
745
|
-
return res.status(403).json({
|
|
746
|
-
error: "insufficient_scope",
|
|
747
|
-
message: "Required scopes not granted",
|
|
748
|
-
required_scopes: requiredScopes,
|
|
749
|
-
granted_scopes: grantedScopes,
|
|
750
|
-
});
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
next();
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// routes/api.ts
|
|
758
|
-
import { authMiddleware } from "../middleware/oauth";
|
|
759
|
-
import { requireScope } from "../middleware/oauth-scope";
|
|
760
|
-
|
|
761
|
-
const router = express.Router();
|
|
762
|
-
|
|
763
|
-
// Protected route - requires authentication
|
|
764
|
-
router.get("/profile", authMiddleware, async (req, res) => {
|
|
765
|
-
const user = await getUserById(req.session.userId);
|
|
766
|
-
res.json(user);
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
// Protected route - requires specific scope
|
|
770
|
-
router.get(
|
|
771
|
-
"/emails",
|
|
772
|
-
authMiddleware,
|
|
773
|
-
requireScope("email", "read:user"),
|
|
774
|
-
async (req, res) => {
|
|
775
|
-
const emails = await getUserEmails(req.session.userId, req.accessToken);
|
|
776
|
-
res.json(emails);
|
|
777
|
-
}
|
|
778
|
-
);
|
|
779
|
-
|
|
780
|
-
// Protected route - requires admin scope
|
|
781
|
-
router.get(
|
|
782
|
-
"/admin/users",
|
|
783
|
-
authMiddleware,
|
|
784
|
-
requireScope("admin:read"),
|
|
785
|
-
async (req, res) => {
|
|
786
|
-
const users = await getAllUsers();
|
|
787
|
-
res.json(users);
|
|
788
|
-
}
|
|
789
|
-
);
|
|
790
|
-
```
|
|
791
|
-
|
|
792
|
-
### 7. OpenID Connect Discovery
|
|
793
|
-
|
|
794
|
-
```typescript
|
|
795
|
-
// lib/oauth/oidc-discovery.ts
|
|
796
|
-
export interface OIDCConfiguration {
|
|
797
|
-
issuer: string;
|
|
798
|
-
authorization_endpoint: string;
|
|
799
|
-
token_endpoint: string;
|
|
800
|
-
userinfo_endpoint: string;
|
|
801
|
-
jwks_uri: string;
|
|
802
|
-
scopes_supported: string[];
|
|
803
|
-
response_types_supported: string[];
|
|
804
|
-
grant_types_supported: string[];
|
|
805
|
-
subject_types_supported: string[];
|
|
806
|
-
id_token_signing_alg_values_supported: string[];
|
|
807
|
-
claims_supported: string[];
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
export async function discoverOIDCConfiguration(
|
|
811
|
-
issuer: string
|
|
812
|
-
): Promise<OIDCConfiguration> {
|
|
813
|
-
const wellKnownUrl = `${issuer}/.well-known/openid-configuration`;
|
|
814
|
-
|
|
815
|
-
const response = await fetch(wellKnownUrl);
|
|
816
|
-
|
|
817
|
-
if (!response.ok) {
|
|
818
|
-
throw new Error(`Failed to discover OIDC configuration: ${response.status}`);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
return response.json();
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Usage
|
|
825
|
-
async function configureFromDiscovery(issuer: string) {
|
|
826
|
-
const config = await discoverOIDCConfiguration(issuer);
|
|
827
|
-
|
|
828
|
-
return new OAuthClient({
|
|
829
|
-
clientId: process.env.CLIENT_ID!,
|
|
830
|
-
clientSecret: process.env.CLIENT_SECRET!,
|
|
831
|
-
redirectUri: process.env.REDIRECT_URI!,
|
|
832
|
-
authorizationEndpoint: config.authorization_endpoint,
|
|
833
|
-
tokenEndpoint: config.token_endpoint,
|
|
834
|
-
userInfoEndpoint: config.userinfo_endpoint,
|
|
835
|
-
scopes: ["openid", "email", "profile"],
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// Common OIDC issuers
|
|
840
|
-
const OIDC_ISSUERS = {
|
|
841
|
-
google: "https://accounts.google.com",
|
|
842
|
-
microsoft: "https://login.microsoftonline.com/common/v2.0",
|
|
843
|
-
okta: "https://your-org.okta.com",
|
|
844
|
-
auth0: "https://your-tenant.auth0.com",
|
|
84
|
+
// GitHub OAuth
|
|
85
|
+
const githubConfig = {
|
|
86
|
+
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
|
87
|
+
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
|
88
|
+
userInfoEndpoint: "https://api.github.com/user",
|
|
89
|
+
scopes: ["read:user", "user:email"],
|
|
845
90
|
};
|
|
846
91
|
```
|
|
847
92
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
### React Integration
|
|
93
|
+
### Token Refresh Middleware
|
|
851
94
|
|
|
852
95
|
```typescript
|
|
853
|
-
|
|
854
|
-
|
|
96
|
+
async function ensureFreshToken(req: Request, res: Response, next: NextFunction) {
|
|
97
|
+
const token = await tokenStore.get(req.session.userId);
|
|
98
|
+
if (!token) return next();
|
|
855
99
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const login = useCallback(async (provider: string) => {
|
|
861
|
-
setLoading(true);
|
|
862
|
-
setError(null);
|
|
863
|
-
window.location.href = `/auth/login/${provider}`;
|
|
864
|
-
}, []);
|
|
865
|
-
|
|
866
|
-
const logout = useCallback(async () => {
|
|
867
|
-
setLoading(true);
|
|
868
|
-
try {
|
|
869
|
-
await fetch("/auth/logout", { method: "POST" });
|
|
870
|
-
window.location.href = "/";
|
|
871
|
-
} catch (err) {
|
|
872
|
-
setError("Logout failed");
|
|
873
|
-
} finally {
|
|
874
|
-
setLoading(false);
|
|
875
|
-
}
|
|
876
|
-
}, []);
|
|
877
|
-
|
|
878
|
-
return { login, logout, loading, error };
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// components/LoginButtons.tsx
|
|
882
|
-
export function LoginButtons() {
|
|
883
|
-
const { login, loading } = useOAuth();
|
|
884
|
-
|
|
885
|
-
return (
|
|
886
|
-
<div className="space-y-2">
|
|
887
|
-
<button onClick={() => login("google")} disabled={loading}>
|
|
888
|
-
Continue with Google
|
|
889
|
-
</button>
|
|
890
|
-
<button onClick={() => login("github")} disabled={loading}>
|
|
891
|
-
Continue with GitHub
|
|
892
|
-
</button>
|
|
893
|
-
<button onClick={() => login("microsoft")} disabled={loading}>
|
|
894
|
-
Continue with Microsoft
|
|
895
|
-
</button>
|
|
896
|
-
</div>
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
```
|
|
900
|
-
|
|
901
|
-
### State Management with Cookies
|
|
902
|
-
|
|
903
|
-
```typescript
|
|
904
|
-
// lib/oauth/state.ts
|
|
905
|
-
import { serialize, parse } from "cookie";
|
|
906
|
-
import crypto from "crypto";
|
|
907
|
-
|
|
908
|
-
const STATE_COOKIE_NAME = "oauth_state";
|
|
909
|
-
const STATE_MAX_AGE = 60 * 10; // 10 minutes
|
|
910
|
-
|
|
911
|
-
export function setStateCookie(res: Response, state: string, codeVerifier: string) {
|
|
912
|
-
const data = JSON.stringify({ state, codeVerifier });
|
|
913
|
-
const encrypted = encrypt(data);
|
|
914
|
-
|
|
915
|
-
const cookie = serialize(STATE_COOKIE_NAME, encrypted, {
|
|
916
|
-
httpOnly: true,
|
|
917
|
-
secure: process.env.NODE_ENV === "production",
|
|
918
|
-
sameSite: "lax",
|
|
919
|
-
maxAge: STATE_MAX_AGE,
|
|
920
|
-
path: "/",
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
res.setHeader("Set-Cookie", cookie);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
export function getStateCookie(req: Request): { state: string; codeVerifier: string } | null {
|
|
927
|
-
const cookies = parse(req.headers.cookie || "");
|
|
928
|
-
const encrypted = cookies[STATE_COOKIE_NAME];
|
|
929
|
-
|
|
930
|
-
if (!encrypted) return null;
|
|
931
|
-
|
|
932
|
-
try {
|
|
933
|
-
const decrypted = decrypt(encrypted);
|
|
934
|
-
return JSON.parse(decrypted);
|
|
935
|
-
} catch {
|
|
936
|
-
return null;
|
|
100
|
+
const timeUntilExpiry = token.expiresAt - Date.now();
|
|
101
|
+
if (timeUntilExpiry > 5 * 60 * 1000) {
|
|
102
|
+
req.accessToken = token.accessToken;
|
|
103
|
+
return next();
|
|
937
104
|
}
|
|
938
|
-
}
|
|
939
105
|
|
|
940
|
-
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
maxAge: 0,
|
|
946
|
-
path: "/",
|
|
106
|
+
// Refresh token
|
|
107
|
+
const newTokens = await client.refreshTokens(token.refreshToken);
|
|
108
|
+
await tokenStore.save(req.session.userId, {
|
|
109
|
+
...newTokens,
|
|
110
|
+
expiresAt: Date.now() + newTokens.expiresIn * 1000,
|
|
947
111
|
});
|
|
948
|
-
|
|
949
|
-
|
|
112
|
+
req.accessToken = newTokens.accessToken;
|
|
113
|
+
next();
|
|
950
114
|
}
|
|
951
115
|
```
|
|
952
116
|
|
|
953
117
|
## Best Practices
|
|
954
118
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
-
|
|
963
|
-
- Check token audience and issuer claims
|
|
964
|
-
- Implement proper error handling
|
|
965
|
-
- Log authentication events for auditing
|
|
966
|
-
- Use short-lived access tokens
|
|
967
|
-
|
|
968
|
-
### Don'ts
|
|
969
|
-
|
|
970
|
-
- Don't use implicit flow for new applications
|
|
971
|
-
- Don't store tokens in localStorage
|
|
972
|
-
- Don't expose client secrets in frontend code
|
|
973
|
-
- Don't skip state validation
|
|
974
|
-
- Don't ignore token expiration
|
|
975
|
-
- Don't use long-lived access tokens
|
|
976
|
-
- Don't trust unverified ID tokens
|
|
977
|
-
- Don't log sensitive token data
|
|
978
|
-
- Don't reuse authorization codes
|
|
979
|
-
- Don't skip nonce validation for OIDC
|
|
119
|
+
| Do | Avoid |
|
|
120
|
+
|----|-------|
|
|
121
|
+
| Always use PKCE for authorization code flow | Using implicit flow for new apps |
|
|
122
|
+
| Validate state parameter to prevent CSRF | Storing tokens in localStorage |
|
|
123
|
+
| Store tokens securely (encrypted, httpOnly) | Exposing client secrets in frontend |
|
|
124
|
+
| Implement token refresh before expiration | Ignoring token expiration |
|
|
125
|
+
| Validate ID token signatures with JWKS | Trusting unverified ID tokens |
|
|
126
|
+
| Use short-lived access tokens | Reusing authorization codes |
|
|
980
127
|
|
|
981
128
|
## References
|
|
982
129
|
|
|
@@ -984,4 +131,3 @@ export function clearStateCookie(res: Response) {
|
|
|
984
131
|
- [OAuth 2.0 PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
|
|
985
132
|
- [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html)
|
|
986
133
|
- [OAuth 2.0 Security Best Practices](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)
|
|
987
|
-
- [OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)
|