@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,615 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
3
|
+
import type { Env, Variables } from "../types.ts";
|
|
4
|
+
import { actorApId, formatUsername } from "../federation-helpers.ts";
|
|
5
|
+
import { verifyBootstrapOrPassword } from "../lib/crypto.ts";
|
|
6
|
+
import {
|
|
7
|
+
fetchUserInfo,
|
|
8
|
+
getAuthConfig,
|
|
9
|
+
getClientCredentials,
|
|
10
|
+
getProvider,
|
|
11
|
+
} from "../lib/oauth-providers.ts";
|
|
12
|
+
import { verifyOidcIdToken } from "../lib/oidc-id-token.ts";
|
|
13
|
+
import {
|
|
14
|
+
deleteOAuthState,
|
|
15
|
+
generateCodeChallenge,
|
|
16
|
+
generateCodeVerifier,
|
|
17
|
+
generateId as generateOAuthId,
|
|
18
|
+
generateNonce,
|
|
19
|
+
getOAuthState,
|
|
20
|
+
saveOAuthState,
|
|
21
|
+
} from "../lib/oauth-utils.ts";
|
|
22
|
+
import {
|
|
23
|
+
clearLoginLockout,
|
|
24
|
+
getLoginLockoutStatus,
|
|
25
|
+
recordFailedLoginAttempt,
|
|
26
|
+
} from "../lib/auth-lockout.ts";
|
|
27
|
+
import { getClientIP } from "../lib/client-ip.ts";
|
|
28
|
+
import { and, asc, count, eq, or } from "drizzle-orm";
|
|
29
|
+
import { actors, notDeleted, sessions } from "../../db/index.ts";
|
|
30
|
+
import { hashSessionIdForEnv } from "../lib/crypto.ts";
|
|
31
|
+
import {
|
|
32
|
+
createActor,
|
|
33
|
+
deleteSessionSafely,
|
|
34
|
+
exchangeOAuthToken,
|
|
35
|
+
findOrCreateOAuthActor,
|
|
36
|
+
formatAccountResponse,
|
|
37
|
+
lockoutErrorResponse,
|
|
38
|
+
parseJsonObject,
|
|
39
|
+
parseNonEmptyString,
|
|
40
|
+
rotateSession,
|
|
41
|
+
} from "./auth-helpers.ts";
|
|
42
|
+
import { logger } from "../lib/logger.ts";
|
|
43
|
+
|
|
44
|
+
const log = logger.child({ component: "auth" });
|
|
45
|
+
|
|
46
|
+
const KNOWN_OAUTH_ERRORS = new Set([
|
|
47
|
+
"access_denied",
|
|
48
|
+
"invalid_request",
|
|
49
|
+
"unauthorized_client",
|
|
50
|
+
"unsupported_response_type",
|
|
51
|
+
"invalid_scope",
|
|
52
|
+
"server_error",
|
|
53
|
+
"temporarily_unavailable",
|
|
54
|
+
"interaction_required",
|
|
55
|
+
"login_required",
|
|
56
|
+
"consent_required",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const auth = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
60
|
+
|
|
61
|
+
// Maximum number of sub-accounts a single instance owner may mint, capping
|
|
62
|
+
// abuse even from the legitimate owner session (#23).
|
|
63
|
+
const MAX_SUB_ACCOUNTS = 20;
|
|
64
|
+
|
|
65
|
+
// 認証設定取得
|
|
66
|
+
auth.get("/providers", async (c) => {
|
|
67
|
+
const config = getAuthConfig(c.env);
|
|
68
|
+
return c.json({
|
|
69
|
+
providers: config.providers.map((p) => ({
|
|
70
|
+
id: p.id,
|
|
71
|
+
name: p.name,
|
|
72
|
+
icon: p.icon,
|
|
73
|
+
})),
|
|
74
|
+
password_enabled: config.passwordEnabled,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 現在のユーザー情報
|
|
79
|
+
auth.get("/me", async (c) => {
|
|
80
|
+
const actor = c.get("actor");
|
|
81
|
+
if (!actor) return c.json({ error: "Not authenticated" }, 401);
|
|
82
|
+
|
|
83
|
+
const sessionId = getCookie(c, "session");
|
|
84
|
+
let provider: string | null = null;
|
|
85
|
+
let hasTakosAccess = false;
|
|
86
|
+
|
|
87
|
+
if (sessionId) {
|
|
88
|
+
const db = c.get("db");
|
|
89
|
+
const sessionKey = await hashSessionIdForEnv(c.env, sessionId);
|
|
90
|
+
const session = await db
|
|
91
|
+
.select({
|
|
92
|
+
provider: sessions.provider,
|
|
93
|
+
providerAccessToken: sessions.providerAccessToken,
|
|
94
|
+
})
|
|
95
|
+
.from(sessions)
|
|
96
|
+
.where(eq(sessions.id, sessionKey))
|
|
97
|
+
.get();
|
|
98
|
+
if (session) {
|
|
99
|
+
provider = session.provider;
|
|
100
|
+
hasTakosAccess =
|
|
101
|
+
session.provider === "takos" && !!session.providerAccessToken;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return c.json({
|
|
106
|
+
actor: {
|
|
107
|
+
ap_id: actor.ap_id,
|
|
108
|
+
username: formatUsername(actor.ap_id),
|
|
109
|
+
preferred_username: actor.preferred_username,
|
|
110
|
+
name: actor.name,
|
|
111
|
+
summary: actor.summary,
|
|
112
|
+
icon_url: actor.icon_url,
|
|
113
|
+
header_url: actor.header_url,
|
|
114
|
+
follower_count: actor.follower_count,
|
|
115
|
+
following_count: actor.following_count,
|
|
116
|
+
post_count: actor.post_count,
|
|
117
|
+
role: actor.role,
|
|
118
|
+
},
|
|
119
|
+
provider,
|
|
120
|
+
has_takos_access: hasTakosAccess,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// パスワード認証
|
|
125
|
+
auth.post("/login", async (c) => {
|
|
126
|
+
const config = getAuthConfig(c.env);
|
|
127
|
+
if (!config.passwordEnabled) {
|
|
128
|
+
return c.json({ error: "Password auth not enabled" }, 400);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const clientIp = getClientIP(c);
|
|
132
|
+
const lockoutKey = `password:${clientIp}`;
|
|
133
|
+
|
|
134
|
+
const lockoutStatus = await getLoginLockoutStatus(c.env.KV, lockoutKey);
|
|
135
|
+
if (lockoutStatus.locked) {
|
|
136
|
+
c.header("Retry-After", String(lockoutStatus.retryAfterSeconds));
|
|
137
|
+
return c.json(lockoutErrorResponse(lockoutStatus.retryAfterSeconds), 429);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const body = await parseJsonObject(c);
|
|
141
|
+
if (!body) {
|
|
142
|
+
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const password = parseNonEmptyString(body.password);
|
|
146
|
+
if (!password) {
|
|
147
|
+
return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Verify password against AUTH_PASSWORD_HASH. Properly-hashed values use the
|
|
151
|
+
// PBKDF2 salt:hash path; a fresh Capsule install generates a colon-less
|
|
152
|
+
// bootstrap token that is matched verbatim in constant time so the install
|
|
153
|
+
// is actually loginnable out of the box (#11).
|
|
154
|
+
let isValid = false;
|
|
155
|
+
|
|
156
|
+
// Treat a blank / whitespace-only AUTH_PASSWORD_HASH as "password login
|
|
157
|
+
// disabled", not "the password is whitespace": otherwise a misconfigured
|
|
158
|
+
// `AUTH_PASSWORD_HASH=" "` would let an attacker log in by submitting " ".
|
|
159
|
+
if (c.env.AUTH_PASSWORD_HASH?.trim()) {
|
|
160
|
+
isValid = await verifyBootstrapOrPassword(
|
|
161
|
+
password,
|
|
162
|
+
c.env.AUTH_PASSWORD_HASH,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!isValid) {
|
|
167
|
+
const failedStatus = await recordFailedLoginAttempt(c.env.KV, lockoutKey);
|
|
168
|
+
if (failedStatus.locked) {
|
|
169
|
+
c.header("Retry-After", String(failedStatus.retryAfterSeconds));
|
|
170
|
+
return c.json(lockoutErrorResponse(failedStatus.retryAfterSeconds), 429);
|
|
171
|
+
}
|
|
172
|
+
return c.json({ error: "Invalid password" }, 401);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const db = c.get("db");
|
|
176
|
+
|
|
177
|
+
// Single-user instance: find existing owner or create default.
|
|
178
|
+
// Exclude tombstoned rows (`notDeleted`): a wave-8 account deletion keeps the
|
|
179
|
+
// owner row as a scrubbed tombstone (role still "owner") for the federation
|
|
180
|
+
// Delete signer. Without this guard the owner-resolution query would re-resolve
|
|
181
|
+
// that zombie and log a fresh login into a deleted account; with it, login
|
|
182
|
+
// falls through to createActor and provisions a clean owner.
|
|
183
|
+
const actorData =
|
|
184
|
+
(await db
|
|
185
|
+
.select()
|
|
186
|
+
.from(actors)
|
|
187
|
+
.where(and(eq(actors.role, "owner"), notDeleted(actors)))
|
|
188
|
+
.get()) ??
|
|
189
|
+
(await createActor(db, c.env, {
|
|
190
|
+
username: "tako",
|
|
191
|
+
name: "tako",
|
|
192
|
+
takosUserId: "password:owner",
|
|
193
|
+
role: "owner",
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
await rotateSession(
|
|
197
|
+
c,
|
|
198
|
+
actorData.apId,
|
|
199
|
+
null,
|
|
200
|
+
null,
|
|
201
|
+
c.env.ENCRYPTION_KEY,
|
|
202
|
+
"password login rotation",
|
|
203
|
+
);
|
|
204
|
+
await clearLoginLockout(c.env.KV, lockoutKey);
|
|
205
|
+
|
|
206
|
+
return c.json({ success: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// OAuth: 認証開始
|
|
210
|
+
auth.get("/login/:provider", async (c) => {
|
|
211
|
+
const providerId = c.req.param("provider");
|
|
212
|
+
const provider = getProvider(c.env, providerId);
|
|
213
|
+
|
|
214
|
+
if (!provider) {
|
|
215
|
+
return c.json({ error: "Unknown or unconfigured provider" }, 400);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const state = generateOAuthId();
|
|
219
|
+
const codeVerifier = generateCodeVerifier();
|
|
220
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
221
|
+
|
|
222
|
+
const nonce = generateNonce();
|
|
223
|
+
await saveOAuthState(c.env.KV, state, {
|
|
224
|
+
provider: providerId,
|
|
225
|
+
codeVerifier,
|
|
226
|
+
createdAt: Date.now(),
|
|
227
|
+
nonce,
|
|
228
|
+
});
|
|
229
|
+
// Bind OAuth state to this browser session to prevent login CSRF (Issue 107).
|
|
230
|
+
// Secure unless served over explicit http:// (see the session cookie).
|
|
231
|
+
setCookie(c, "oauth_nonce", nonce, {
|
|
232
|
+
httpOnly: true,
|
|
233
|
+
secure: !(c.env.APP_URL ?? "").startsWith("http://"),
|
|
234
|
+
sameSite: "Lax",
|
|
235
|
+
path: "/",
|
|
236
|
+
maxAge: 600,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { clientId } = getClientCredentials(c.env, providerId);
|
|
240
|
+
const redirectUri = `${c.env.APP_URL}/api/auth/callback/${providerId}`;
|
|
241
|
+
|
|
242
|
+
const params = new URLSearchParams({
|
|
243
|
+
response_type: "code",
|
|
244
|
+
client_id: clientId,
|
|
245
|
+
redirect_uri: redirectUri,
|
|
246
|
+
scope: provider.scopes.join(" "),
|
|
247
|
+
state,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (provider.supportsPkce) {
|
|
251
|
+
params.set("code_challenge", codeChallenge);
|
|
252
|
+
params.set("code_challenge_method", "S256");
|
|
253
|
+
}
|
|
254
|
+
// For OIDC providers, send the `nonce` so the issuer echoes it in the ID Token
|
|
255
|
+
// and the callback can bind the token to this exact login (replay protection).
|
|
256
|
+
// We reuse the same random value that backs the state/CSRF binding.
|
|
257
|
+
if (provider.issuer) {
|
|
258
|
+
params.set("nonce", nonce);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return c.redirect(`${provider.authorizeUrl}?${params.toString()}`);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// OAuth: コールバック
|
|
265
|
+
auth.get("/callback/:provider", async (c) => {
|
|
266
|
+
const providerId = c.req.param("provider");
|
|
267
|
+
const error = c.req.query("error");
|
|
268
|
+
|
|
269
|
+
if (error) {
|
|
270
|
+
log.error("OAuth provider returned error", {
|
|
271
|
+
event: "auth.oauth.callback_error",
|
|
272
|
+
provider: providerId,
|
|
273
|
+
oauthError: error,
|
|
274
|
+
errorDescription: c.req.query("error_description"),
|
|
275
|
+
});
|
|
276
|
+
const safeError = KNOWN_OAUTH_ERRORS.has(error) ? error : "oauth_error";
|
|
277
|
+
return c.redirect(`/?error=${safeError}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const code = c.req.query("code");
|
|
281
|
+
const state = c.req.query("state");
|
|
282
|
+
if (!code || !state) {
|
|
283
|
+
return c.redirect("/?error=missing_params");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const storedState = await getOAuthState(c.env.KV, state);
|
|
287
|
+
if (!storedState) return c.redirect("/?error=invalid_state");
|
|
288
|
+
if (storedState.provider !== providerId) {
|
|
289
|
+
return c.redirect("/?error=provider_mismatch");
|
|
290
|
+
}
|
|
291
|
+
// Verify browser-session binding nonce to prevent login CSRF (Issue 107).
|
|
292
|
+
if (storedState.nonce) {
|
|
293
|
+
const cookieNonce = getCookie(c, "oauth_nonce");
|
|
294
|
+
if (!cookieNonce || cookieNonce !== storedState.nonce) {
|
|
295
|
+
await deleteOAuthState(c.env.KV, state);
|
|
296
|
+
deleteCookie(c, "oauth_nonce");
|
|
297
|
+
return c.redirect("/?error=csrf_check_failed");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await deleteOAuthState(c.env.KV, state);
|
|
301
|
+
deleteCookie(c, "oauth_nonce");
|
|
302
|
+
|
|
303
|
+
const provider = getProvider(c.env, providerId);
|
|
304
|
+
if (!provider) return c.redirect("/?error=unknown_provider");
|
|
305
|
+
|
|
306
|
+
const tokens = await exchangeOAuthToken(
|
|
307
|
+
providerId,
|
|
308
|
+
code,
|
|
309
|
+
storedState.codeVerifier,
|
|
310
|
+
c.env,
|
|
311
|
+
provider,
|
|
312
|
+
);
|
|
313
|
+
if (!tokens) return c.redirect("/?error=token_exchange_failed");
|
|
314
|
+
|
|
315
|
+
let userInfo;
|
|
316
|
+
try {
|
|
317
|
+
userInfo = await fetchUserInfo(provider, tokens.access_token);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
log.error("Failed to fetch user info", {
|
|
320
|
+
event: "auth.oauth.user_info_failed",
|
|
321
|
+
provider: providerId,
|
|
322
|
+
error: err,
|
|
323
|
+
});
|
|
324
|
+
return c.redirect("/?error=user_info_failed");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// For an OIDC provider (Takosumi Accounts) the ID Token is the authoritative
|
|
328
|
+
// identity assertion and carries name/email/sub that the minimal userinfo
|
|
329
|
+
// endpoint omits. Verify it (ES256 against the issuer JWKS + iss/aud/exp) and
|
|
330
|
+
// let its claims fill/override the userinfo; an ID Token that is present but
|
|
331
|
+
// invalid fails the login (fail closed — never trust an unverified subject).
|
|
332
|
+
// `issuer`+`jwksUrl` is the "this is an OIDC provider" discriminator. For such
|
|
333
|
+
// a provider the id_token MUST be present and valid — a token response that
|
|
334
|
+
// simply omits it must NOT fall through to unverified userinfo (that would skip
|
|
335
|
+
// the aud + nonce binding the rest of this flow enforces). Fail closed.
|
|
336
|
+
if (provider.issuer && provider.jwksUrl) {
|
|
337
|
+
if (!tokens.id_token) {
|
|
338
|
+
log.error("OIDC provider returned no id_token", {
|
|
339
|
+
event: "auth.oauth.id_token_missing",
|
|
340
|
+
provider: providerId,
|
|
341
|
+
});
|
|
342
|
+
return c.redirect("/?error=id_token_missing");
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const { clientId } = getClientCredentials(c.env, providerId);
|
|
346
|
+
const claims = await verifyOidcIdToken(tokens.id_token, {
|
|
347
|
+
issuer: provider.issuer,
|
|
348
|
+
clientId,
|
|
349
|
+
jwksUrl: provider.jwksUrl,
|
|
350
|
+
expectedNonce: storedState.nonce,
|
|
351
|
+
});
|
|
352
|
+
userInfo = {
|
|
353
|
+
...userInfo,
|
|
354
|
+
id: claims.sub || userInfo.id,
|
|
355
|
+
name: claims.name ?? userInfo.name,
|
|
356
|
+
email: claims.email ?? userInfo.email,
|
|
357
|
+
username: claims.preferred_username ?? userInfo.username,
|
|
358
|
+
};
|
|
359
|
+
} catch (err) {
|
|
360
|
+
log.error("OIDC ID token verification failed", {
|
|
361
|
+
event: "auth.oauth.id_token_invalid",
|
|
362
|
+
provider: providerId,
|
|
363
|
+
error: err,
|
|
364
|
+
});
|
|
365
|
+
return c.redirect("/?error=id_token_invalid");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const actorData = await findOrCreateOAuthActor(
|
|
370
|
+
c.get("db"),
|
|
371
|
+
c.env,
|
|
372
|
+
providerId,
|
|
373
|
+
userInfo,
|
|
374
|
+
);
|
|
375
|
+
if (!actorData) return c.redirect("/?error=actor_creation_failed");
|
|
376
|
+
|
|
377
|
+
await rotateSession(
|
|
378
|
+
c,
|
|
379
|
+
actorData.apId,
|
|
380
|
+
providerId,
|
|
381
|
+
providerId === "takos" ? tokens : null,
|
|
382
|
+
c.env.ENCRYPTION_KEY,
|
|
383
|
+
"oauth login rotation",
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
return c.redirect("/");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ログアウト
|
|
390
|
+
auth.post("/logout", async (c) => {
|
|
391
|
+
const sessionId = getCookie(c, "session");
|
|
392
|
+
if (sessionId) {
|
|
393
|
+
await deleteSessionSafely(c.get("db"), c.env, sessionId, "logout");
|
|
394
|
+
deleteCookie(c, "session");
|
|
395
|
+
}
|
|
396
|
+
return c.json({ success: true });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
auth.get("/accounts", async (c) => {
|
|
400
|
+
const actor = c.get("actor");
|
|
401
|
+
if (!actor) return c.json({ error: "Not authenticated" }, 401);
|
|
402
|
+
|
|
403
|
+
const db = c.get("db");
|
|
404
|
+
|
|
405
|
+
// Find the root owner ap_id: current actor may be a sub-account.
|
|
406
|
+
// ownerActorApId is set on actors created via POST /accounts; root actors have null.
|
|
407
|
+
const currentActorRecord = await db
|
|
408
|
+
.select({
|
|
409
|
+
ownerActorApId: actors.ownerActorApId,
|
|
410
|
+
})
|
|
411
|
+
.from(actors)
|
|
412
|
+
.where(eq(actors.apId, actor.ap_id))
|
|
413
|
+
.get();
|
|
414
|
+
const rootOwnerApId = currentActorRecord?.ownerActorApId ?? actor.ap_id;
|
|
415
|
+
|
|
416
|
+
// Return only the root actor and any sub-accounts they own (Issue 106).
|
|
417
|
+
const accounts = await db
|
|
418
|
+
.select({
|
|
419
|
+
apId: actors.apId,
|
|
420
|
+
preferredUsername: actors.preferredUsername,
|
|
421
|
+
name: actors.name,
|
|
422
|
+
iconUrl: actors.iconUrl,
|
|
423
|
+
})
|
|
424
|
+
.from(actors)
|
|
425
|
+
.where(
|
|
426
|
+
or(
|
|
427
|
+
eq(actors.apId, rootOwnerApId),
|
|
428
|
+
eq(actors.ownerActorApId, rootOwnerApId),
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
.orderBy(asc(actors.createdAt))
|
|
432
|
+
.all();
|
|
433
|
+
|
|
434
|
+
return c.json({
|
|
435
|
+
accounts: accounts.map(formatAccountResponse),
|
|
436
|
+
current_ap_id: actor.ap_id,
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
auth.post("/switch", async (c) => {
|
|
441
|
+
const currentActor = c.get("actor");
|
|
442
|
+
if (!currentActor) return c.json({ error: "Not authenticated" }, 401);
|
|
443
|
+
|
|
444
|
+
const sessionId = getCookie(c, "session");
|
|
445
|
+
if (!sessionId) return c.json({ error: "No session" }, 401);
|
|
446
|
+
|
|
447
|
+
const body = await parseJsonObject(c);
|
|
448
|
+
if (!body) {
|
|
449
|
+
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const targetApId = parseNonEmptyString(body.ap_id);
|
|
453
|
+
if (!targetApId) {
|
|
454
|
+
return c.json({ error: "ap_id required", code: "BAD_REQUEST" }, 400);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const db = c.get("db");
|
|
458
|
+
|
|
459
|
+
// Resolve the root owner of the current session to enforce ownership (Issue 106).
|
|
460
|
+
const currentActorRecord = await db
|
|
461
|
+
.select({
|
|
462
|
+
ownerActorApId: actors.ownerActorApId,
|
|
463
|
+
})
|
|
464
|
+
.from(actors)
|
|
465
|
+
.where(eq(actors.apId, currentActor.ap_id))
|
|
466
|
+
.get();
|
|
467
|
+
const rootOwnerApId =
|
|
468
|
+
currentActorRecord?.ownerActorApId ?? currentActor.ap_id;
|
|
469
|
+
|
|
470
|
+
const targetActor = await db
|
|
471
|
+
.select({
|
|
472
|
+
apId: actors.apId,
|
|
473
|
+
ownerActorApId: actors.ownerActorApId,
|
|
474
|
+
})
|
|
475
|
+
.from(actors)
|
|
476
|
+
.where(eq(actors.apId, targetApId))
|
|
477
|
+
.get();
|
|
478
|
+
|
|
479
|
+
if (!targetActor) return c.json({ error: "Account not found" }, 404);
|
|
480
|
+
|
|
481
|
+
// Only allow switching to the root account or its own sub-accounts.
|
|
482
|
+
const isAllowed =
|
|
483
|
+
targetActor.apId === rootOwnerApId ||
|
|
484
|
+
targetActor.ownerActorApId === rootOwnerApId;
|
|
485
|
+
if (!isAllowed) return c.json({ error: "Forbidden" }, 403);
|
|
486
|
+
|
|
487
|
+
// Rotate the session instead of repointing `member_id` in place. Two reasons:
|
|
488
|
+
// (1) a fresh session id is minted and the old one invalidated (fixation
|
|
489
|
+
// hygiene), and (2) — critically — the owner's encrypted provider/OAuth tokens
|
|
490
|
+
// do NOT carry over to the sub-account (provider=null, tokens=null). A raw
|
|
491
|
+
// member_id rewrite would leave the owner's `provider_access_token` attached to
|
|
492
|
+
// a session now acting as a different actor.
|
|
493
|
+
await rotateSession(
|
|
494
|
+
c,
|
|
495
|
+
targetApId,
|
|
496
|
+
null,
|
|
497
|
+
null,
|
|
498
|
+
c.env.ENCRYPTION_KEY,
|
|
499
|
+
"account switch",
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
return c.json({ success: true });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
auth.post("/accounts", async (c) => {
|
|
506
|
+
const currentActor = c.get("actor");
|
|
507
|
+
if (!currentActor) return c.json({ error: "Not authenticated" }, 401);
|
|
508
|
+
|
|
509
|
+
const body = await parseJsonObject(c);
|
|
510
|
+
if (!body) {
|
|
511
|
+
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const username = parseNonEmptyString(body.username);
|
|
515
|
+
if (!username) {
|
|
516
|
+
return c.json({ error: "username required", code: "BAD_REQUEST" }, 400);
|
|
517
|
+
}
|
|
518
|
+
if (!/^[a-zA-Z0-9_]{1,30}$/.test(username)) {
|
|
519
|
+
// Length-bound the identifier (1-30): it becomes the actor apId primary key
|
|
520
|
+
// and preferredUsername, so the unanchored character class let an
|
|
521
|
+
// arbitrarily long string into a PK column.
|
|
522
|
+
return c.json(
|
|
523
|
+
{
|
|
524
|
+
error: "Invalid username. Use 1-30 letters, numbers, and underscores.",
|
|
525
|
+
},
|
|
526
|
+
400,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (body.name !== undefined && typeof body.name !== "string") {
|
|
531
|
+
return c.json({ error: "name must be a string", code: "BAD_REQUEST" }, 400);
|
|
532
|
+
}
|
|
533
|
+
if (typeof body.name === "string" && body.name.length > 50) {
|
|
534
|
+
// Mirror the profile-update cap (MAX_PROFILE_NAME_LENGTH) so the display name
|
|
535
|
+
// can't be an unbounded string.
|
|
536
|
+
return c.json(
|
|
537
|
+
{ error: "name too long (max 50)", code: "BAD_REQUEST" },
|
|
538
|
+
400,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const name: string | undefined =
|
|
542
|
+
typeof body.name === "string" ? body.name : undefined;
|
|
543
|
+
|
|
544
|
+
const db = c.get("db");
|
|
545
|
+
|
|
546
|
+
// Resolve the root owner of the current session, then gate sub-account
|
|
547
|
+
// creation to the instance owner only (#23). Sub-accounts are minted with
|
|
548
|
+
// role "member", so an arbitrary logged-in member must never be able to
|
|
549
|
+
// create more accounts. We resolve the root actor (sub-accounts carry
|
|
550
|
+
// ownerActorApId pointing at their root) and require that root to be the
|
|
551
|
+
// instance owner. This still lets the owner manage accounts while switched
|
|
552
|
+
// into one of their own sub-accounts.
|
|
553
|
+
const creatorRecord = await db
|
|
554
|
+
.select({
|
|
555
|
+
ownerActorApId: actors.ownerActorApId,
|
|
556
|
+
})
|
|
557
|
+
.from(actors)
|
|
558
|
+
.where(eq(actors.apId, currentActor.ap_id))
|
|
559
|
+
.get();
|
|
560
|
+
const rootOwnerApId = creatorRecord?.ownerActorApId ?? currentActor.ap_id;
|
|
561
|
+
|
|
562
|
+
const rootOwnerRecord = await db
|
|
563
|
+
.select({ role: actors.role })
|
|
564
|
+
.from(actors)
|
|
565
|
+
.where(eq(actors.apId, rootOwnerApId))
|
|
566
|
+
.get();
|
|
567
|
+
if (!rootOwnerRecord || rootOwnerRecord.role !== "owner") {
|
|
568
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Cap the number of sub-accounts owned by this root owner (#23).
|
|
572
|
+
const subAccountCount = await db
|
|
573
|
+
.select({ value: count() })
|
|
574
|
+
.from(actors)
|
|
575
|
+
.where(eq(actors.ownerActorApId, rootOwnerApId))
|
|
576
|
+
.get();
|
|
577
|
+
if ((subAccountCount?.value ?? 0) >= MAX_SUB_ACCOUNTS) {
|
|
578
|
+
return c.json(
|
|
579
|
+
{
|
|
580
|
+
error: "Sub-account limit reached",
|
|
581
|
+
code: "SUB_ACCOUNT_LIMIT_REACHED",
|
|
582
|
+
},
|
|
583
|
+
400,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const apId = actorApId(c.env.APP_URL, username);
|
|
588
|
+
|
|
589
|
+
// Exclude tombstoned rows: a deleted account keeps its row (with a renamed
|
|
590
|
+
// sentinel handle, but the original apId is unique-keyed) until the reaper
|
|
591
|
+
// drains it. A freed handle must be immediately re-registerable, so only a
|
|
592
|
+
// LIVE collision blocks creation (#9).
|
|
593
|
+
const existing = await db
|
|
594
|
+
.select({ apId: actors.apId })
|
|
595
|
+
.from(actors)
|
|
596
|
+
.where(and(eq(actors.apId, apId), notDeleted(actors)))
|
|
597
|
+
.get();
|
|
598
|
+
|
|
599
|
+
if (existing) return c.json({ error: "Username already taken" }, 400);
|
|
600
|
+
|
|
601
|
+
const newActor = await createActor(db, c.env, {
|
|
602
|
+
username,
|
|
603
|
+
name: name || username,
|
|
604
|
+
takosUserId: `local:${username}`,
|
|
605
|
+
role: "member",
|
|
606
|
+
ownerActorApId: rootOwnerApId,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
return c.json({
|
|
610
|
+
success: true,
|
|
611
|
+
account: formatAccountResponse(newActor),
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
export default auth;
|