@commonpub/layer 0.19.2 → 0.21.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.
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
// Initializes from build-time runtime config, then hydrates from /api/features
|
|
3
3
|
// to pick up runtime DB overrides set via admin panel.
|
|
4
4
|
|
|
5
|
+
export interface IdentityFeatures {
|
|
6
|
+
linkRemoteAccounts: boolean;
|
|
7
|
+
signInWithRemote: boolean;
|
|
8
|
+
actingAs: boolean;
|
|
9
|
+
remoteInteract: boolean;
|
|
10
|
+
remotePublish: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export interface FeatureFlags {
|
|
6
14
|
content: boolean;
|
|
7
15
|
social: boolean;
|
|
@@ -17,6 +25,12 @@ export interface FeatureFlags {
|
|
|
17
25
|
admin: boolean;
|
|
18
26
|
emailNotifications: boolean;
|
|
19
27
|
publicApi: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Cross-instance delegated authorization. All sub-flags default false.
|
|
30
|
+
* Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
|
|
31
|
+
* docs/sessions/136-cross-instance-identity-plan.md.
|
|
32
|
+
*/
|
|
33
|
+
identity: IdentityFeatures;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
let hydrated = false;
|
|
@@ -31,13 +45,28 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|
|
31
45
|
contests: false, events: false, learning: true, explainers: true,
|
|
32
46
|
editorial: true, federation: false, admin: false, emailNotifications: false,
|
|
33
47
|
publicApi: false,
|
|
48
|
+
identity: {
|
|
49
|
+
linkRemoteAccounts: false,
|
|
50
|
+
signInWithRemote: false,
|
|
51
|
+
actingAs: false,
|
|
52
|
+
remoteInteract: false,
|
|
53
|
+
remotePublish: false,
|
|
54
|
+
},
|
|
34
55
|
};
|
|
35
56
|
|
|
36
57
|
/** Build the initial flags by merging the layer's runtime config over defaults. */
|
|
37
58
|
export function getInitialFlags(): FeatureFlags {
|
|
38
59
|
const config = useRuntimeConfig();
|
|
39
60
|
const buildFlags = (config.public.features as unknown as Partial<FeatureFlags> | undefined) ?? {};
|
|
40
|
-
|
|
61
|
+
// Merge top-level booleans, but deep-merge `identity` so a partial
|
|
62
|
+
// runtime override (e.g., `{ identity: { actingAs: true } }`) lands on
|
|
63
|
+
// top of the defaulted sub-flags rather than replacing the whole
|
|
64
|
+
// nested object.
|
|
65
|
+
return {
|
|
66
|
+
...DEFAULT_FLAGS,
|
|
67
|
+
...buildFlags,
|
|
68
|
+
identity: { ...DEFAULT_FLAGS.identity, ...(buildFlags.identity ?? {}) },
|
|
69
|
+
};
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
export function useFeatures() {
|
|
@@ -56,9 +85,18 @@ export function useFeatures() {
|
|
|
56
85
|
if (import.meta.client && !hydrated) {
|
|
57
86
|
hydrated = true;
|
|
58
87
|
($fetch as Function)('/api/features')
|
|
59
|
-
.then((dynamic: FeatureFlags) => {
|
|
88
|
+
.then((dynamic: Partial<FeatureFlags>) => {
|
|
60
89
|
if (dynamic && typeof dynamic === 'object') {
|
|
61
|
-
|
|
90
|
+
// Deep-merge `identity` so a server response that omits some
|
|
91
|
+
// sub-flag doesn't blank it out at the client.
|
|
92
|
+
flags.value = {
|
|
93
|
+
...flags.value,
|
|
94
|
+
...dynamic,
|
|
95
|
+
identity: {
|
|
96
|
+
...flags.value.identity,
|
|
97
|
+
...(dynamic.identity ?? {}),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
62
100
|
}
|
|
63
101
|
})
|
|
64
102
|
.catch(() => { /* use build-time defaults on failure */ });
|
|
@@ -80,5 +118,6 @@ export function useFeatures() {
|
|
|
80
118
|
admin: computed(() => flags.value.admin),
|
|
81
119
|
emailNotifications: computed(() => flags.value.emailNotifications),
|
|
82
120
|
publicApi: computed(() => flags.value.publicApi),
|
|
121
|
+
identity: computed(() => flags.value.identity),
|
|
83
122
|
};
|
|
84
123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@commonpub/editor": "0.7.9",
|
|
54
|
-
"@commonpub/config": "0.12.0",
|
|
55
|
-
"@commonpub/docs": "0.6.2",
|
|
56
53
|
"@commonpub/auth": "0.6.0",
|
|
54
|
+
"@commonpub/docs": "0.6.2",
|
|
55
|
+
"@commonpub/editor": "0.7.9",
|
|
57
56
|
"@commonpub/learning": "0.5.2",
|
|
58
|
-
"@commonpub/
|
|
57
|
+
"@commonpub/explainer": "0.7.12",
|
|
59
58
|
"@commonpub/schema": "0.16.0",
|
|
59
|
+
"@commonpub/config": "0.12.0",
|
|
60
|
+
"@commonpub/server": "2.51.0",
|
|
60
61
|
"@commonpub/ui": "0.8.5",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/explainer": "0.7.12"
|
|
62
|
+
"@commonpub/protocol": "0.9.9"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -66,11 +66,30 @@ export default defineEventHandler(async (event) => {
|
|
|
66
66
|
// Check if the current user is logged in — if so, link to their account
|
|
67
67
|
const auth = event.context.auth;
|
|
68
68
|
if (auth?.user) {
|
|
69
|
-
await linkFederatedAccount(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
await linkFederatedAccount(
|
|
70
|
+
db,
|
|
71
|
+
auth.user.id,
|
|
72
|
+
tokenResult.user.actorUri,
|
|
73
|
+
oauthState.instanceDomain,
|
|
74
|
+
{
|
|
75
|
+
preferredUsername: tokenResult.user.username,
|
|
76
|
+
displayName: tokenResult.user.displayName ?? undefined,
|
|
77
|
+
avatarUrl: tokenResult.user.avatarUrl ?? undefined,
|
|
78
|
+
},
|
|
79
|
+
// Phase 1b: persist the access token so future delegated actions
|
|
80
|
+
// (Phase 4) can call the remote on this user's behalf. Encrypted
|
|
81
|
+
// at rest via @commonpub/infra/tokenCrypto. Scopes default to
|
|
82
|
+
// 'read write follow' for CommonPub↔CommonPub trust (the existing
|
|
83
|
+
// SSO model assumes mutual trustedInstances). Software-kind is
|
|
84
|
+
// 'cpub' here because this callback handles CommonPub→CommonPub;
|
|
85
|
+
// a Mastodon-login flow (Phase 2) will use a separate callback
|
|
86
|
+
// that detects software via WebFinger / megalodon.
|
|
87
|
+
{
|
|
88
|
+
accessToken: tokenResult.accessToken,
|
|
89
|
+
scopes: ['read', 'write', 'follow'],
|
|
90
|
+
softwareKind: 'cpub',
|
|
91
|
+
},
|
|
92
|
+
);
|
|
74
93
|
|
|
75
94
|
return sendRedirect(event, '/settings/account?federated=linked', 302);
|
|
76
95
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/auth/mastodon/callback?code=<auth_code>&state=<state>
|
|
3
|
+
*
|
|
4
|
+
* Phase 2a — server side of the Mastodon-login flow. Exchanges the
|
|
5
|
+
* auth code for an access token, verifies the remote account, then
|
|
6
|
+
* routes to one of three outcomes:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Already linked** — `findUserByFederatedAccount` finds a local
|
|
9
|
+
* user. Mint a Better Auth session, redirect to dashboard. (Or
|
|
10
|
+
* to `returnTo` if the start request specified it.)
|
|
11
|
+
*
|
|
12
|
+
* 2. **Currently logged-in user adding a link** — `event.context.auth`
|
|
13
|
+
* has a user. Call `linkFederatedAccount` with the grant; redirect
|
|
14
|
+
* to settings.
|
|
15
|
+
*
|
|
16
|
+
* 3. **Anonymous + first-time** — call `storePendingLink` (existing
|
|
17
|
+
* v1 SSO machinery). Redirect to login form with the link token;
|
|
18
|
+
* Phase 2b's UI will consume the token to auto-provision a fresh
|
|
19
|
+
* local account or link to an existing user the visitor signs in
|
|
20
|
+
* as.
|
|
21
|
+
*
|
|
22
|
+
* Gated by `features.identity.signInWithRemote`.
|
|
23
|
+
*/
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import type { H3Event } from 'h3';
|
|
26
|
+
import {
|
|
27
|
+
consumeMastodonLoginState,
|
|
28
|
+
createFederatedSession,
|
|
29
|
+
exchangeCodeAndVerify,
|
|
30
|
+
findUserByFederatedAccount,
|
|
31
|
+
getOrRegisterRemoteClient,
|
|
32
|
+
linkFederatedAccount,
|
|
33
|
+
storePendingLink,
|
|
34
|
+
} from '@commonpub/server';
|
|
35
|
+
|
|
36
|
+
const callbackSchema = z.object({
|
|
37
|
+
code: z.string().min(1),
|
|
38
|
+
state: z.string().min(1),
|
|
39
|
+
// OAuth error responses come back as ?error=...&error_description=...
|
|
40
|
+
// We surface a friendly message rather than crashing.
|
|
41
|
+
error: z.string().optional(),
|
|
42
|
+
error_description: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function setSessionCookie(event: H3Event, token: string, expiresAt: Date): void {
|
|
46
|
+
setCookie(event, 'better-auth.session_token', token, {
|
|
47
|
+
httpOnly: true,
|
|
48
|
+
secure: process.env.NODE_ENV === 'production',
|
|
49
|
+
sameSite: 'lax',
|
|
50
|
+
path: '/',
|
|
51
|
+
expires: expiresAt,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default defineEventHandler(async (event) => {
|
|
56
|
+
const config = useConfig();
|
|
57
|
+
if (!config.features.identity.signInWithRemote) {
|
|
58
|
+
throw createError({ statusCode: 404, statusMessage: 'Not found' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const db = useDB();
|
|
62
|
+
const params = parseQueryParams(event, callbackSchema);
|
|
63
|
+
|
|
64
|
+
if (params.error) {
|
|
65
|
+
// Remote denied / errored. Surface back to the login page with the
|
|
66
|
+
// OAuth error message so the UI can render it cleanly.
|
|
67
|
+
const msg = encodeURIComponent(params.error_description || params.error);
|
|
68
|
+
return sendRedirect(event, `/auth/login?mastodon_error=${msg}`, 302);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Single-use, atomic. Returns null if expired / already consumed.
|
|
72
|
+
const loginState = await consumeMastodonLoginState(db, params.state);
|
|
73
|
+
if (!loginState) {
|
|
74
|
+
throw createError({
|
|
75
|
+
statusCode: 400,
|
|
76
|
+
statusMessage: 'Invalid or expired login state. Please try again.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Re-read the cached client credentials for this host; they were
|
|
81
|
+
// stored by the start route during `getOrRegisterRemoteClient`.
|
|
82
|
+
// Pass the same redirectUri so we don't mistakenly re-register.
|
|
83
|
+
const creds = await getOrRegisterRemoteClient(db, loginState.host, loginState.redirectUri);
|
|
84
|
+
|
|
85
|
+
// Exchange + verify in one atomic-ish step. Throws on token-exchange
|
|
86
|
+
// failure or 4xx on verifyCredentials.
|
|
87
|
+
let verified: Awaited<ReturnType<typeof exchangeCodeAndVerify>>;
|
|
88
|
+
try {
|
|
89
|
+
verified = await exchangeCodeAndVerify(loginState.host, creds, params.code);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
92
|
+
throw createError({
|
|
93
|
+
statusCode: 502,
|
|
94
|
+
statusMessage: `Sign-in via ${loginState.host} failed: ${msg}`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ipAddress =
|
|
99
|
+
getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
|
|
100
|
+
?? getRequestHeader(event, 'x-real-ip')
|
|
101
|
+
?? undefined;
|
|
102
|
+
const userAgent = getRequestHeader(event, 'user-agent') ?? undefined;
|
|
103
|
+
|
|
104
|
+
// Outcome 1: identity already linked → log them in, redirect.
|
|
105
|
+
const existingLink = await findUserByFederatedAccount(db, verified.profile.actorUri);
|
|
106
|
+
if (existingLink) {
|
|
107
|
+
const session = await createFederatedSession(db, existingLink.userId, ipAddress, userAgent);
|
|
108
|
+
setSessionCookie(event, session.sessionToken, session.expiresAt);
|
|
109
|
+
return sendRedirect(event, loginState.returnTo ?? '/dashboard', 302);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Outcome 2: a user is signed in locally and wants to add this link.
|
|
113
|
+
const auth = event.context.auth;
|
|
114
|
+
if (auth?.user) {
|
|
115
|
+
await linkFederatedAccount(
|
|
116
|
+
db,
|
|
117
|
+
auth.user.id,
|
|
118
|
+
verified.profile.actorUri,
|
|
119
|
+
loginState.host,
|
|
120
|
+
{
|
|
121
|
+
preferredUsername: verified.profile.username,
|
|
122
|
+
displayName: verified.profile.displayName,
|
|
123
|
+
avatarUrl: verified.profile.avatarUrl,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
accessToken: verified.accessToken,
|
|
127
|
+
scopes: verified.scopes,
|
|
128
|
+
softwareKind: verified.softwareKind,
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
return sendRedirect(event, '/settings/account?federated=linked', 302);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Outcome 3: anonymous + first-time → store as pending link, hand
|
|
135
|
+
// the opaque token to the login UI. Phase 2b's UI consumes the token.
|
|
136
|
+
const linkToken = await storePendingLink(db, {
|
|
137
|
+
actorUri: verified.profile.actorUri,
|
|
138
|
+
username: verified.profile.username,
|
|
139
|
+
instanceDomain: loginState.host,
|
|
140
|
+
displayName: verified.profile.displayName,
|
|
141
|
+
avatarUrl: verified.profile.avatarUrl,
|
|
142
|
+
});
|
|
143
|
+
return sendRedirect(event, `/auth/login?federated=true&linkToken=${linkToken}`, 302);
|
|
144
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/auth/mastodon/start?host=<domain>[&returnTo=<path>]
|
|
3
|
+
*
|
|
4
|
+
* Phase 2a — server side of "Sign in via @user@host" / "Link a
|
|
5
|
+
* Mastodon-API account". CommonPub plays the OAuth client role here:
|
|
6
|
+
* registers itself with the remote, then redirects the user to the
|
|
7
|
+
* remote's `/oauth/authorize`.
|
|
8
|
+
*
|
|
9
|
+
* Gated by `features.identity.signInWithRemote`. When the flag is off,
|
|
10
|
+
* this route 404s — no leakage of new endpoints to scrapers.
|
|
11
|
+
*
|
|
12
|
+
* The companion `callback.get.ts` handles the auth code return.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import {
|
|
16
|
+
buildAuthorizeUrl,
|
|
17
|
+
getOrRegisterRemoteClient,
|
|
18
|
+
isValidHost,
|
|
19
|
+
storeMastodonLoginState,
|
|
20
|
+
} from '@commonpub/server';
|
|
21
|
+
|
|
22
|
+
const startSchema = z.object({
|
|
23
|
+
host: z.string().min(3).max(253),
|
|
24
|
+
returnTo: z.string().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export default defineEventHandler(async (event) => {
|
|
28
|
+
const config = useConfig();
|
|
29
|
+
if (!config.features.identity.signInWithRemote) {
|
|
30
|
+
throw createError({ statusCode: 404, statusMessage: 'Not found' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const db = useDB();
|
|
34
|
+
const { host: rawHost, returnTo } = parseQueryParams(event, startSchema);
|
|
35
|
+
const host = rawHost.trim().toLowerCase();
|
|
36
|
+
|
|
37
|
+
if (!isValidHost(host)) {
|
|
38
|
+
throw createError({
|
|
39
|
+
statusCode: 400,
|
|
40
|
+
statusMessage: `Invalid host: ${rawHost}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const redirectUri = `https://${config.instance.domain}/api/auth/mastodon/callback`;
|
|
45
|
+
|
|
46
|
+
// Get or register our OAuth client at the remote. First call to a
|
|
47
|
+
// given host hits the network (megalodon's `registerApp` →
|
|
48
|
+
// POST /api/v1/apps); subsequent calls read the cache.
|
|
49
|
+
let creds: Awaited<ReturnType<typeof getOrRegisterRemoteClient>>;
|
|
50
|
+
try {
|
|
51
|
+
creds = await getOrRegisterRemoteClient(db, host, redirectUri);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = err instanceof Error ? err.message : 'Unknown registration error';
|
|
54
|
+
throw createError({
|
|
55
|
+
statusCode: 502,
|
|
56
|
+
statusMessage: `Could not register CommonPub at ${host}: ${msg}`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Mint a CSRF state token bound to this (host, redirectUri, returnTo)
|
|
61
|
+
// tuple. Single-use, 10-min TTL.
|
|
62
|
+
const state = await storeMastodonLoginState(db, {
|
|
63
|
+
host,
|
|
64
|
+
redirectUri,
|
|
65
|
+
returnTo,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Redirect the user to the remote's authorize endpoint.
|
|
69
|
+
const authUrl = buildAuthorizeUrl(host, creds, state);
|
|
70
|
+
return sendRedirect(event, authUrl, 302);
|
|
71
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-instance identity — Nitro startup wiring.
|
|
3
|
+
*
|
|
4
|
+
* Runs at app init to:
|
|
5
|
+
*
|
|
6
|
+
* 1. Validate that any token-using `features.identity.*` flag has
|
|
7
|
+
* `CPUB_FED_TOKEN_KEY` set. Throws if misconfigured — the boot
|
|
8
|
+
* fails loudly rather than 500-ing partway through a real user's
|
|
9
|
+
* OAuth callback. Only `actingAs` is exempt (UI-only, no token I/O).
|
|
10
|
+
*
|
|
11
|
+
* 2. Register the Mastodon-API-backed FediClient factory so
|
|
12
|
+
* `run(event, ctx.active, action, input)` can dispatch to remote
|
|
13
|
+
* handlers when `ctx.active.kind === 'linked'`. Factory closes
|
|
14
|
+
* over the request-scoped DB handle.
|
|
15
|
+
*
|
|
16
|
+
* Plugin order: this should run early, alongside other infrastructure
|
|
17
|
+
* plugins. Nitro picks up plugins in alphabetical order — file is
|
|
18
|
+
* named `identity-startup.ts` so it sorts before app-level plugins.
|
|
19
|
+
*
|
|
20
|
+
* Phase-skip ergonomics: if no identity flag is enabled, the factory
|
|
21
|
+
* is registered anyway (cheap; the factory is lazy and only executes
|
|
22
|
+
* when `getFediClient` is called). The startup invariant only fires
|
|
23
|
+
* for flags that need the key.
|
|
24
|
+
*
|
|
25
|
+
* See docs/sessions/136-cross-instance-identity-plan.md.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
assertIdentityConfig,
|
|
29
|
+
createMastodonFediClientFactory,
|
|
30
|
+
setFediClientFactory,
|
|
31
|
+
} from '@commonpub/server';
|
|
32
|
+
|
|
33
|
+
export default defineNitroPlugin(() => {
|
|
34
|
+
const config = useConfig();
|
|
35
|
+
|
|
36
|
+
// Fails loudly + early if a token-using flag is on without the
|
|
37
|
+
// encryption key. Listed errors include the env var name to set.
|
|
38
|
+
assertIdentityConfig(config);
|
|
39
|
+
|
|
40
|
+
// Register the factory exactly once. Subsequent calls would replace
|
|
41
|
+
// (which is fine in tests; in prod this fires once per process).
|
|
42
|
+
setFediClientFactory(createMastodonFediClientFactory(useDB()));
|
|
43
|
+
});
|