@takosjp/yurucommu-core 3.0.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/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Provider Configuration
|
|
3
|
+
*
|
|
4
|
+
* 環境変数に設定されたプロバイダーのみ有効になる
|
|
5
|
+
* 複数のプロバイダーを自由に組み合わせ可能
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Env } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
export interface OAuthProvider {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
icon: string;
|
|
14
|
+
authorizeUrl: string;
|
|
15
|
+
tokenUrl: string;
|
|
16
|
+
userInfoUrl: string;
|
|
17
|
+
scopes: string[];
|
|
18
|
+
// PKCE対応
|
|
19
|
+
supportsPkce: boolean;
|
|
20
|
+
// For OIDC providers (Takosumi Accounts): the issuer origin + JWKS endpoint,
|
|
21
|
+
// used to verify the ID Token signature + claims in the login callback. Absent
|
|
22
|
+
// for plain OAuth2 providers (e.g. X) that issue no OIDC ID Token.
|
|
23
|
+
issuer?: string;
|
|
24
|
+
jwksUrl?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuthConfig {
|
|
28
|
+
passwordEnabled: boolean;
|
|
29
|
+
providers: OAuthProvider[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function envValue(env: Env, key: keyof Env): string | undefined {
|
|
33
|
+
const value = env[key];
|
|
34
|
+
return typeof value === "string" && value.trim() !== ""
|
|
35
|
+
? value.trim()
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getOidcIssuerUrl(env: Env): string | null {
|
|
40
|
+
return (
|
|
41
|
+
envValue(env, "OIDC_ISSUER_URL") ??
|
|
42
|
+
envValue(env, "OAUTH_ISSUER_URL") ??
|
|
43
|
+
envValue(env, "TAKOSUMI_ACCOUNTS_ISSUER_URL") ??
|
|
44
|
+
null
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getOidcClientCredentials(env: Env): {
|
|
49
|
+
clientId: string;
|
|
50
|
+
clientSecret: string;
|
|
51
|
+
} {
|
|
52
|
+
return {
|
|
53
|
+
clientId:
|
|
54
|
+
envValue(env, "OIDC_CLIENT_ID") ??
|
|
55
|
+
envValue(env, "TAKOSUMI_ACCOUNTS_CLIENT_ID") ??
|
|
56
|
+
"",
|
|
57
|
+
clientSecret:
|
|
58
|
+
envValue(env, "OIDC_CLIENT_SECRET") ??
|
|
59
|
+
envValue(env, "TAKOSUMI_ACCOUNTS_CLIENT_SECRET") ??
|
|
60
|
+
"",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function issuerEndpoint(issuer: string, path: string): string {
|
|
65
|
+
return `${issuer.replace(/\/$/, "")}${path}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 環境変数から有効な認証方法を取得
|
|
70
|
+
*/
|
|
71
|
+
export function getAuthConfig(env: Env): AuthConfig {
|
|
72
|
+
const providers: OAuthProvider[] = [];
|
|
73
|
+
|
|
74
|
+
// Google OAuth
|
|
75
|
+
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
|
76
|
+
providers.push({
|
|
77
|
+
id: "google",
|
|
78
|
+
name: "Google",
|
|
79
|
+
icon: "/icons/google.svg",
|
|
80
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
81
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
82
|
+
userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
83
|
+
scopes: ["openid", "profile", "email"],
|
|
84
|
+
supportsPkce: true,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// X (Twitter) OAuth 2.0
|
|
89
|
+
if (env.X_CLIENT_ID && env.X_CLIENT_SECRET) {
|
|
90
|
+
providers.push({
|
|
91
|
+
id: "x",
|
|
92
|
+
name: "X",
|
|
93
|
+
icon: "/icons/x.svg",
|
|
94
|
+
authorizeUrl: "https://twitter.com/i/oauth2/authorize",
|
|
95
|
+
tokenUrl: "https://api.twitter.com/2/oauth2/token",
|
|
96
|
+
userInfoUrl: "https://api.twitter.com/2/users/me",
|
|
97
|
+
scopes: ["tweet.read", "users.read", "offline.access"],
|
|
98
|
+
supportsPkce: true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Takosumi Accounts OIDC. The client SECRET is optional: when Takosumi
|
|
103
|
+
// materializes the OIDC client for an auto-provisioned Capsule it mints a
|
|
104
|
+
// PUBLIC client (token_endpoint_auth_method "none", PKCE-only, no secret — the
|
|
105
|
+
// service-graph resolve path can't deliver a confidential secret). A confidential
|
|
106
|
+
// client (secret set) also works. Either way PKCE-S256 protects the exchange,
|
|
107
|
+
// so issuer + client_id are sufficient to offer the provider.
|
|
108
|
+
const oidcIssuer = getOidcIssuerUrl(env);
|
|
109
|
+
const { clientId: oidcClientId } = getOidcClientCredentials(env);
|
|
110
|
+
if (oidcIssuer && oidcClientId) {
|
|
111
|
+
providers.push({
|
|
112
|
+
id: "takos",
|
|
113
|
+
name: "Takosumi Accounts",
|
|
114
|
+
icon: "/icons/takos.svg",
|
|
115
|
+
authorizeUrl: issuerEndpoint(oidcIssuer, "/oauth/authorize"),
|
|
116
|
+
tokenUrl: issuerEndpoint(oidcIssuer, "/oauth/token"),
|
|
117
|
+
userInfoUrl: issuerEndpoint(oidcIssuer, "/oauth/userinfo"),
|
|
118
|
+
scopes: ["openid", "profile", "email"],
|
|
119
|
+
supportsPkce: true,
|
|
120
|
+
issuer: oidcIssuer.replace(/\/+$/, ""),
|
|
121
|
+
jwksUrl: issuerEndpoint(oidcIssuer, "/oauth/jwks"),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
// A whitespace-only hash is treated as "disabled" (mirrors the login gate),
|
|
127
|
+
// never as a one-character password.
|
|
128
|
+
passwordEnabled: !!env.AUTH_PASSWORD_HASH?.trim(),
|
|
129
|
+
providers,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* プロバイダーIDからプロバイダー設定を取得
|
|
135
|
+
*/
|
|
136
|
+
export function getProvider(
|
|
137
|
+
env: Env,
|
|
138
|
+
providerId: string,
|
|
139
|
+
): OAuthProvider | null {
|
|
140
|
+
const config = getAuthConfig(env);
|
|
141
|
+
return config.providers.find((p) => p.id === providerId) || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 既知のプロバイダーID 集合。 caller boundary は `string` で受けるが、
|
|
146
|
+
* switch では narrowed union で exhaustive に分岐する。
|
|
147
|
+
*/
|
|
148
|
+
export type ProviderId = "google" | "x" | "takos";
|
|
149
|
+
|
|
150
|
+
// Upper bound for the userinfo fetch so a hung provider can't stall login.
|
|
151
|
+
const USERINFO_FETCH_TIMEOUT_MS = 10_000;
|
|
152
|
+
|
|
153
|
+
const KNOWN_PROVIDER_IDS: ReadonlySet<ProviderId> = new Set<ProviderId>([
|
|
154
|
+
"google",
|
|
155
|
+
"x",
|
|
156
|
+
"takos",
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
function isKnownProviderId(value: string): value is ProviderId {
|
|
160
|
+
return KNOWN_PROVIDER_IDS.has(value as ProviderId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function assertNeverProvider(x: never): never {
|
|
164
|
+
throw new Error(`Unhandled provider id: ${JSON.stringify(x)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* プロバイダーIDからクライアント認証情報を取得
|
|
169
|
+
*/
|
|
170
|
+
export function getClientCredentials(
|
|
171
|
+
env: Env,
|
|
172
|
+
providerId: string,
|
|
173
|
+
): { clientId: string; clientSecret: string } {
|
|
174
|
+
if (!isKnownProviderId(providerId)) {
|
|
175
|
+
return { clientId: "", clientSecret: "" };
|
|
176
|
+
}
|
|
177
|
+
switch (providerId) {
|
|
178
|
+
case "google":
|
|
179
|
+
return {
|
|
180
|
+
clientId: env.GOOGLE_CLIENT_ID || "",
|
|
181
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
|
|
182
|
+
};
|
|
183
|
+
case "x":
|
|
184
|
+
return {
|
|
185
|
+
clientId: env.X_CLIENT_ID || "",
|
|
186
|
+
clientSecret: env.X_CLIENT_SECRET || "",
|
|
187
|
+
};
|
|
188
|
+
case "takos":
|
|
189
|
+
return getOidcClientCredentials(env);
|
|
190
|
+
default:
|
|
191
|
+
return assertNeverProvider(providerId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ユーザー情報を正規化
|
|
197
|
+
*/
|
|
198
|
+
export interface NormalizedUserInfo {
|
|
199
|
+
id: string;
|
|
200
|
+
name: string;
|
|
201
|
+
email?: string;
|
|
202
|
+
picture?: string;
|
|
203
|
+
username?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
207
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function getString(
|
|
211
|
+
record: Record<string, unknown>,
|
|
212
|
+
key: string,
|
|
213
|
+
): string | undefined {
|
|
214
|
+
const value = record[key];
|
|
215
|
+
return typeof value === "string" ? value : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getObject(
|
|
219
|
+
record: Record<string, unknown>,
|
|
220
|
+
key: string,
|
|
221
|
+
): Record<string, unknown> | undefined {
|
|
222
|
+
const value = record[key];
|
|
223
|
+
return isJsonObject(value) ? value : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseGoogleUserInfo(
|
|
227
|
+
data: Record<string, unknown>,
|
|
228
|
+
): NormalizedUserInfo {
|
|
229
|
+
const id = getString(data, "id");
|
|
230
|
+
const name = getString(data, "name");
|
|
231
|
+
if (!id || !name) {
|
|
232
|
+
throw new Error("Google userinfo response missing required fields");
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
id,
|
|
236
|
+
name,
|
|
237
|
+
email: getString(data, "email"),
|
|
238
|
+
picture: getString(data, "picture"),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseXUserInfo(data: Record<string, unknown>): NormalizedUserInfo {
|
|
243
|
+
const inner = getObject(data, "data");
|
|
244
|
+
if (!inner) {
|
|
245
|
+
throw new Error("X userinfo response missing data field");
|
|
246
|
+
}
|
|
247
|
+
const id = getString(inner, "id");
|
|
248
|
+
const name = getString(inner, "name");
|
|
249
|
+
const username = getString(inner, "username");
|
|
250
|
+
if (!id || !name || !username) {
|
|
251
|
+
throw new Error("X userinfo response missing required fields");
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
id,
|
|
255
|
+
name,
|
|
256
|
+
username,
|
|
257
|
+
picture: getString(inner, "profile_image_url"),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseTakosUserInfo(data: Record<string, unknown>): NormalizedUserInfo {
|
|
262
|
+
const user = getObject(data, "user");
|
|
263
|
+
const id = user ? getString(user, "id") : undefined;
|
|
264
|
+
const sub = getString(data, "sub");
|
|
265
|
+
const resolvedId = id ?? sub;
|
|
266
|
+
const userName = user ? getString(user, "name") : undefined;
|
|
267
|
+
const topName = getString(data, "name");
|
|
268
|
+
const resolvedName = userName ?? topName ?? resolvedId;
|
|
269
|
+
if (!resolvedId || !resolvedName) {
|
|
270
|
+
throw new Error("Takosumi Accounts userinfo response missing subject");
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
id: resolvedId,
|
|
274
|
+
name: resolvedName,
|
|
275
|
+
email:
|
|
276
|
+
(user ? getString(user, "email") : undefined) ?? getString(data, "email"),
|
|
277
|
+
picture:
|
|
278
|
+
(user ? getString(user, "picture") : undefined) ??
|
|
279
|
+
getString(data, "picture"),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function fetchUserInfo(
|
|
284
|
+
provider: OAuthProvider,
|
|
285
|
+
accessToken: string,
|
|
286
|
+
): Promise<NormalizedUserInfo> {
|
|
287
|
+
const url =
|
|
288
|
+
provider.id === "x"
|
|
289
|
+
? `${provider.userInfoUrl}?user.fields=profile_image_url`
|
|
290
|
+
: provider.userInfoUrl;
|
|
291
|
+
|
|
292
|
+
// Bound the userinfo fetch (and its body read) so a hung provider can't stall
|
|
293
|
+
// the login request; the timer stays armed through res.json(). Mirrors the
|
|
294
|
+
// token-exchange and fetchJwks timeouts.
|
|
295
|
+
const controller = new AbortController();
|
|
296
|
+
const timer = setTimeout(() => controller.abort(), USERINFO_FETCH_TIMEOUT_MS);
|
|
297
|
+
let raw: unknown;
|
|
298
|
+
try {
|
|
299
|
+
const res = await fetch(url, {
|
|
300
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
301
|
+
signal: controller.signal,
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok) {
|
|
304
|
+
throw new Error(`Failed to fetch user info: ${res.status}`);
|
|
305
|
+
}
|
|
306
|
+
raw = await res.json();
|
|
307
|
+
} finally {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
}
|
|
310
|
+
if (!isJsonObject(raw)) {
|
|
311
|
+
throw new Error(`Invalid userinfo response from provider: ${provider.id}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
switch (provider.id) {
|
|
315
|
+
case "google":
|
|
316
|
+
return parseGoogleUserInfo(raw);
|
|
317
|
+
case "x":
|
|
318
|
+
return parseXUserInfo(raw);
|
|
319
|
+
case "takos":
|
|
320
|
+
return parseTakosUserInfo(raw);
|
|
321
|
+
default:
|
|
322
|
+
throw new Error(`Unknown provider: ${provider.id}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Utilities -- PKCE, state generation, and KV-backed state storage.
|
|
3
|
+
*
|
|
4
|
+
* State tokens use dual-layer expiration:
|
|
5
|
+
* 1. KV TTL (600 s) -- authoritative, auto-deletes the key.
|
|
6
|
+
* 2. Manual createdAt check in getOAuthState() -- defense-in-depth
|
|
7
|
+
* against KV eventual-consistency delays.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IKeyValueStore } from "../runtime/types.ts";
|
|
11
|
+
|
|
12
|
+
const ALPHANUMERIC =
|
|
13
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
14
|
+
const PKCE_CHARSET = ALPHANUMERIC + "-._~";
|
|
15
|
+
const OAUTH_KV_PREFIX = "oauth:";
|
|
16
|
+
const STATE_TTL_SECONDS = 600;
|
|
17
|
+
const STATE_TTL_MS = STATE_TTL_SECONDS * 1000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a cryptographically random string from the given alphabet.
|
|
21
|
+
*/
|
|
22
|
+
function randomString(length: number, alphabet: string): string {
|
|
23
|
+
const n = alphabet.length;
|
|
24
|
+
// Rejection sampling: discard bytes in the biased tail (>= 256 - 256%n) so
|
|
25
|
+
// every character is uniformly distributed. A plain `b % n` slightly favors
|
|
26
|
+
// the first (256 % n) characters when n does not divide 256 — a minor but
|
|
27
|
+
// real bias in OAuth state / PKCE / nonce tokens.
|
|
28
|
+
const limit = 256 - (256 % n);
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
while (out.length < length) {
|
|
31
|
+
const bytes = new Uint8Array(length - out.length);
|
|
32
|
+
crypto.getRandomValues(bytes);
|
|
33
|
+
for (const b of bytes) {
|
|
34
|
+
if (b < limit) out.push(alphabet[b % n]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out.join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function generateId(length = 21): string {
|
|
41
|
+
return randomString(length, ALPHANUMERIC);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate a PKCE code_verifier (64 characters from the unreserved charset).
|
|
46
|
+
*/
|
|
47
|
+
export function generateCodeVerifier(): string {
|
|
48
|
+
return randomString(64, PKCE_CHARSET);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate a random nonce for OAuth CSRF browser-session binding (32 chars).
|
|
53
|
+
*/
|
|
54
|
+
export function generateNonce(): string {
|
|
55
|
+
return randomString(32, ALPHANUMERIC);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* PKCE code_challenge を生成 (S256)
|
|
60
|
+
*/
|
|
61
|
+
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
|
62
|
+
const encoder = new TextEncoder();
|
|
63
|
+
const data = encoder.encode(verifier);
|
|
64
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
65
|
+
return base64UrlEncode(digest);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Base64-URL-encode an ArrayBuffer (RFC 4648 section 5, no padding).
|
|
70
|
+
* Uses chunked conversion to avoid stack overflow on large buffers.
|
|
71
|
+
*/
|
|
72
|
+
export function base64UrlEncode(
|
|
73
|
+
buffer: ArrayBuffer | null | undefined,
|
|
74
|
+
): string {
|
|
75
|
+
if (buffer == null) {
|
|
76
|
+
throw new Error("base64UrlEncode: buffer cannot be null or undefined");
|
|
77
|
+
}
|
|
78
|
+
if (!(buffer instanceof ArrayBuffer)) {
|
|
79
|
+
throw new Error("base64UrlEncode: buffer must be an ArrayBuffer");
|
|
80
|
+
}
|
|
81
|
+
if (buffer.byteLength === 0) {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const bytes = new Uint8Array(buffer);
|
|
86
|
+
const CHUNK_SIZE = 8192;
|
|
87
|
+
let binary = "";
|
|
88
|
+
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
|
89
|
+
const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
|
|
90
|
+
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return btoa(binary)
|
|
94
|
+
.replace(/\+/g, "-")
|
|
95
|
+
.replace(/\//g, "_")
|
|
96
|
+
.replace(/=+$/, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface OAuthState {
|
|
100
|
+
provider: string;
|
|
101
|
+
codeVerifier: string;
|
|
102
|
+
createdAt: number;
|
|
103
|
+
/**
|
|
104
|
+
* Browser-session binding nonce for login CSRF protection (Issue 107).
|
|
105
|
+
* Set as a short-lived HttpOnly cookie on the initiating browser and stored
|
|
106
|
+
* here so the callback handler can verify both values match.
|
|
107
|
+
*/
|
|
108
|
+
nonce?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function oauthKey(state: string): string {
|
|
112
|
+
return `${OAUTH_KV_PREFIX}${state}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function saveOAuthState(
|
|
116
|
+
kv: IKeyValueStore,
|
|
117
|
+
state: string,
|
|
118
|
+
data: OAuthState,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
await kv.put(oauthKey(state), JSON.stringify(data), {
|
|
121
|
+
expirationTtl: STATE_TTL_SECONDS,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function getOAuthState(
|
|
126
|
+
kv: IKeyValueStore,
|
|
127
|
+
state: string,
|
|
128
|
+
): Promise<OAuthState | null> {
|
|
129
|
+
const stored = await kv.get(oauthKey(state));
|
|
130
|
+
if (!stored) return null;
|
|
131
|
+
|
|
132
|
+
const data = JSON.parse(stored) as OAuthState;
|
|
133
|
+
|
|
134
|
+
// Defense-in-depth: reject expired states even if KV TTL hasn't propagated
|
|
135
|
+
if (Date.now() - data.createdAt > STATE_TTL_MS) {
|
|
136
|
+
await kv.delete(oauthKey(state));
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function deleteOAuthState(
|
|
144
|
+
kv: IKeyValueStore,
|
|
145
|
+
state: string,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
await kv.delete(oauthKey(state));
|
|
148
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC ID Token verification.
|
|
3
|
+
*
|
|
4
|
+
* For the authorization-code flow the ID Token is the issuer's primary identity
|
|
5
|
+
* assertion. Takosumi Accounts signs it ES256 and exposes its keys at the JWKS
|
|
6
|
+
* endpoint; its minimal userinfo response omits `name`/`email` (those live on
|
|
7
|
+
* the ID Token), so the login callback reads identity claims from here.
|
|
8
|
+
*
|
|
9
|
+
* We verify the signature (ES256 against the issuer JWKS) and the standard
|
|
10
|
+
* claims (iss / aud / exp / sub) rather than trusting the token blindly — even
|
|
11
|
+
* though it arrives over the TLS-authenticated token endpoint — to match the
|
|
12
|
+
* codebase's fail-closed posture and reject a token from a mis-issuer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type OidcIdTokenClaims = {
|
|
16
|
+
sub: string;
|
|
17
|
+
iss?: string;
|
|
18
|
+
aud?: string | string[];
|
|
19
|
+
exp?: number;
|
|
20
|
+
iat?: number;
|
|
21
|
+
nonce?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
email?: string;
|
|
24
|
+
email_verified?: boolean;
|
|
25
|
+
preferred_username?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Jwk = JsonWebKey & { kid?: string; alg?: string; use?: string };
|
|
29
|
+
|
|
30
|
+
function b64urlToBytes(segment: string): Uint8Array {
|
|
31
|
+
const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
32
|
+
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
|
33
|
+
const binary = atob(padded);
|
|
34
|
+
const bytes = new Uint8Array(binary.length);
|
|
35
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function b64urlToJson<T>(segment: string): T {
|
|
40
|
+
return JSON.parse(new TextDecoder().decode(b64urlToBytes(segment))) as T;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchJwks(jwksUrl: string): Promise<Jwk[]> {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(jwksUrl, {
|
|
48
|
+
headers: { Accept: "application/json" },
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
|
|
52
|
+
const body = (await res.json()) as { keys?: Jwk[] };
|
|
53
|
+
if (!Array.isArray(body.keys)) throw new Error("JWKS missing keys array");
|
|
54
|
+
return body.keys;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function selectEcKey(keys: Jwk[], kid: string | undefined): Jwk | undefined {
|
|
61
|
+
const ecKeys = keys.filter(
|
|
62
|
+
(k) => k.kty === "EC" && k.crv === "P-256" && (k.use ?? "sig") === "sig",
|
|
63
|
+
);
|
|
64
|
+
if (kid) {
|
|
65
|
+
const match = ecKeys.find((k) => k.kid === kid);
|
|
66
|
+
if (match) return match;
|
|
67
|
+
}
|
|
68
|
+
// No kid (or no match): only safe to fall back when exactly one candidate.
|
|
69
|
+
return ecKeys.length === 1 ? ecKeys[0] : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Verify an OIDC ID Token (ES256) against the issuer's JWKS and validate its
|
|
74
|
+
* core claims. Returns the decoded claims on success; throws on any failure.
|
|
75
|
+
*/
|
|
76
|
+
export async function verifyOidcIdToken(
|
|
77
|
+
idToken: string,
|
|
78
|
+
opts: {
|
|
79
|
+
issuer: string;
|
|
80
|
+
clientId: string;
|
|
81
|
+
jwksUrl: string;
|
|
82
|
+
expectedNonce?: string;
|
|
83
|
+
},
|
|
84
|
+
): Promise<OidcIdTokenClaims> {
|
|
85
|
+
const parts = idToken.split(".");
|
|
86
|
+
if (parts.length !== 3) throw new Error("malformed id_token");
|
|
87
|
+
|
|
88
|
+
const header = b64urlToJson<{ alg?: string; kid?: string }>(parts[0]);
|
|
89
|
+
// ES256 only — reject `none` and any non-ES256 alg so an attacker can't
|
|
90
|
+
// downgrade to an unsigned/forgeable token.
|
|
91
|
+
if (header.alg !== "ES256") {
|
|
92
|
+
throw new Error(`unexpected id_token alg: ${header.alg ?? "none"}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const claims = b64urlToJson<OidcIdTokenClaims>(parts[1]);
|
|
96
|
+
|
|
97
|
+
const jwks = await fetchJwks(opts.jwksUrl);
|
|
98
|
+
const jwk = selectEcKey(jwks, header.kid);
|
|
99
|
+
if (!jwk) throw new Error("no matching JWKS signing key for id_token");
|
|
100
|
+
|
|
101
|
+
const key = await crypto.subtle.importKey(
|
|
102
|
+
"jwk",
|
|
103
|
+
jwk,
|
|
104
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
105
|
+
false,
|
|
106
|
+
["verify"],
|
|
107
|
+
);
|
|
108
|
+
// A JWS ES256 signature is the raw r||s concatenation (IEEE P1363) — exactly
|
|
109
|
+
// what WebCrypto ECDSA verify expects, no DER unwrap needed.
|
|
110
|
+
const ok = await crypto.subtle.verify(
|
|
111
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
112
|
+
key,
|
|
113
|
+
b64urlToBytes(parts[2]).buffer as ArrayBuffer,
|
|
114
|
+
new TextEncoder().encode(`${parts[0]}.${parts[1]}`).buffer as ArrayBuffer,
|
|
115
|
+
);
|
|
116
|
+
if (!ok) throw new Error("id_token signature invalid");
|
|
117
|
+
|
|
118
|
+
// Standard claim validation (fail closed).
|
|
119
|
+
const norm = (s: string) => s.replace(/\/+$/, "");
|
|
120
|
+
if (!claims.iss || norm(claims.iss) !== norm(opts.issuer)) {
|
|
121
|
+
throw new Error("id_token iss mismatch");
|
|
122
|
+
}
|
|
123
|
+
const auds = Array.isArray(claims.aud)
|
|
124
|
+
? claims.aud
|
|
125
|
+
: claims.aud
|
|
126
|
+
? [claims.aud]
|
|
127
|
+
: [];
|
|
128
|
+
if (!auds.includes(opts.clientId)) {
|
|
129
|
+
throw new Error("id_token aud mismatch");
|
|
130
|
+
}
|
|
131
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
132
|
+
// `exp` is REQUIRED by OIDC — reject a token that omits it (fail closed) rather
|
|
133
|
+
// than treating it as eternal. 60s clock-skew slack on the comparison.
|
|
134
|
+
if (typeof claims.exp !== "number") {
|
|
135
|
+
throw new Error("id_token missing exp");
|
|
136
|
+
}
|
|
137
|
+
if (claims.exp < nowSec - 60) {
|
|
138
|
+
throw new Error("id_token expired");
|
|
139
|
+
}
|
|
140
|
+
if (!claims.sub) throw new Error("id_token missing sub");
|
|
141
|
+
|
|
142
|
+
// Optional OIDC nonce binding: when the RP sent a nonce in the authorize
|
|
143
|
+
// request, the issuer echoes it in the id_token and we MUST match it (replay
|
|
144
|
+
// protection). When no expected nonce is configured, skip (back-channel
|
|
145
|
+
// code-flow is already bound by state + PKCE).
|
|
146
|
+
if (opts.expectedNonce !== undefined && claims.nonce !== opts.expectedNonce) {
|
|
147
|
+
throw new Error("id_token nonce mismatch");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return claims;
|
|
151
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function parseJsonObject(c: {
|
|
6
|
+
req: { json: () => Promise<unknown> };
|
|
7
|
+
}): Promise<Record<string, unknown> | null> {
|
|
8
|
+
try {
|
|
9
|
+
const body = await c.req.json();
|
|
10
|
+
if (!isRecord(body)) return null;
|
|
11
|
+
return body;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseNonEmptyString(value: unknown): string | null {
|
|
18
|
+
if (typeof value !== "string") return null;
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isUniqueConstraintError(error: unknown): boolean {
|
|
24
|
+
return (
|
|
25
|
+
typeof error === "object" &&
|
|
26
|
+
error !== null &&
|
|
27
|
+
"message" in error &&
|
|
28
|
+
typeof (error as { message: string }).message === "string" &&
|
|
29
|
+
(error as { message: string }).message.includes("UNIQUE constraint failed")
|
|
30
|
+
);
|
|
31
|
+
}
|