@user-center/next-better-auth 0.0.0-stage → 0.1.10
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 +21 -0
- package/README.md +52 -2
- package/dist/integration-contracts/src/compatibility.d.ts +9 -0
- package/dist/integration-contracts/src/compatibility.js +3 -0
- package/dist/integration-contracts/src/config.d.ts +15 -0
- package/dist/integration-contracts/src/config.js +17 -0
- package/dist/integration-contracts/src/environment.d.ts +25 -0
- package/dist/integration-contracts/src/environment.js +108 -0
- package/dist/integration-contracts/src/metadata.d.ts +4 -0
- package/dist/integration-contracts/src/metadata.js +71 -0
- package/dist/integration-contracts/src/public.d.ts +51 -0
- package/dist/integration-contracts/src/public.js +134 -0
- package/dist/next-better-auth/src/client.d.ts +10 -0
- package/dist/next-better-auth/src/client.js +36 -0
- package/dist/next-better-auth/src/config.d.ts +2 -0
- package/dist/next-better-auth/src/config.js +1 -0
- package/dist/next-better-auth/src/public.d.ts +1 -0
- package/dist/next-better-auth/src/public.js +1 -0
- package/dist/next-better-auth/src/server.d.ts +142 -0
- package/dist/next-better-auth/src/server.js +298 -0
- package/dist/reference/LICENSE +21 -0
- package/dist/reference/README.md +10 -0
- package/dist/reference/example-next/LICENSE +21 -0
- package/dist/reference/example-next/README.md +27 -0
- package/dist/reference/example-next/environment.example.txt +8 -0
- package/dist/reference/example-next/gitignore.txt +5 -0
- package/dist/reference/example-next/integration.json +10 -0
- package/dist/reference/example-next/next.config.ts +3 -0
- package/dist/reference/example-next/package.json +37 -0
- package/dist/reference/example-next/pnpm-workspace.yaml +4 -0
- package/dist/reference/example-next/profiles.example.json +34 -0
- package/dist/reference/example-next/scripts/schema.mts +22 -0
- package/dist/reference/example-next/src/app/actions.tsx +15 -0
- package/dist/reference/example-next/src/app/api/auth/[...all]/route.ts +4 -0
- package/dist/reference/example-next/src/app/api/me/route.ts +9 -0
- package/dist/reference/example-next/src/app/auth-error/page.tsx +2 -0
- package/dist/reference/example-next/src/app/dashboard/page.tsx +2 -0
- package/dist/reference/example-next/src/app/layout.tsx +2 -0
- package/dist/reference/example-next/src/app/login/page.tsx +6 -0
- package/dist/reference/example-next/src/app/page.tsx +2 -0
- package/dist/reference/example-next/src/app/projects/[id]/page.tsx +5 -0
- package/dist/reference/example-next/src/lib/auth.ts +17 -0
- package/dist/reference/example-next/src/lib/config.ts +45 -0
- package/dist/reference/example-next/src/lib/protected.tsx +13 -0
- package/dist/reference/example-next/tsconfig.json +37 -0
- package/dist/reference/guide/agent-prompt.md +68 -0
- package/dist/reference/guide/doctor.md +21 -0
- package/dist/reference/guide/environments.md +18 -0
- package/dist/reference/guide/index.md +188 -0
- package/dist/reference/guide/support.md +12 -0
- package/package.json +61 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type BetterAuthOptions } from "better-auth";
|
|
3
|
+
import type { OAuthProvider } from "better-auth/oauth2";
|
|
4
|
+
import { type EnvironmentSelection } from "../../integration-contracts/src/environment.js";
|
|
5
|
+
import { type ErrorCode, type PublicConfig } from "../../integration-contracts/src/public.js";
|
|
6
|
+
export interface Identity {
|
|
7
|
+
issuer: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
authUserId: string;
|
|
10
|
+
profile: {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
emailVerified: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export type { SafeSession } from "../../integration-contracts/src/public.js";
|
|
17
|
+
export type Principal<T> = {
|
|
18
|
+
status: "anonymous";
|
|
19
|
+
} | {
|
|
20
|
+
status: "mapping-required";
|
|
21
|
+
identity: Identity;
|
|
22
|
+
} | {
|
|
23
|
+
status: "authenticated";
|
|
24
|
+
identity: Identity;
|
|
25
|
+
user: T;
|
|
26
|
+
};
|
|
27
|
+
export type ResolutionContext = Readonly<{
|
|
28
|
+
requestId: string;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
}>;
|
|
31
|
+
export type Event = Readonly<{
|
|
32
|
+
code: ErrorCode | "OK";
|
|
33
|
+
phase: "ready" | "identity" | "business" | "login" | "callback" | "logout";
|
|
34
|
+
at: string;
|
|
35
|
+
requestId: string;
|
|
36
|
+
version: string;
|
|
37
|
+
}>;
|
|
38
|
+
export interface AuthOptions<T extends {
|
|
39
|
+
id: string;
|
|
40
|
+
}> {
|
|
41
|
+
manifest: unknown;
|
|
42
|
+
database: NonNullable<BetterAuthOptions["database"]>;
|
|
43
|
+
clientSecret: string;
|
|
44
|
+
sessionSecret: string;
|
|
45
|
+
environment?: EnvironmentSelection;
|
|
46
|
+
authUserPolicy: "allow-new" | "existing-only";
|
|
47
|
+
sessionTtlSeconds?: number;
|
|
48
|
+
resolveBusinessUser: (identity: Identity, context: ResolutionContext) => Promise<{
|
|
49
|
+
status: "ready";
|
|
50
|
+
user: T;
|
|
51
|
+
} | {
|
|
52
|
+
status: "unmapped";
|
|
53
|
+
}>;
|
|
54
|
+
onEvent?: (event: Event) => unknown;
|
|
55
|
+
}
|
|
56
|
+
export declare function createUserCenterAuth<T extends {
|
|
57
|
+
id: string;
|
|
58
|
+
}>(options: AuthOptions<T>): {
|
|
59
|
+
publicConfig: PublicConfig;
|
|
60
|
+
ready: () => Promise<void>;
|
|
61
|
+
getAuth: () => Promise<import("better-auth").Auth<{
|
|
62
|
+
baseURL: string;
|
|
63
|
+
basePath: string;
|
|
64
|
+
secret: string;
|
|
65
|
+
database: any;
|
|
66
|
+
trustedOrigins: string[];
|
|
67
|
+
emailAndPassword: {
|
|
68
|
+
enabled: false;
|
|
69
|
+
};
|
|
70
|
+
user: {
|
|
71
|
+
deleteUser: {
|
|
72
|
+
enabled: false;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
session: {
|
|
76
|
+
expiresIn: number;
|
|
77
|
+
disableSessionRefresh: true;
|
|
78
|
+
cookieCache: {
|
|
79
|
+
enabled: false;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
account: {
|
|
83
|
+
accountLinking: {
|
|
84
|
+
enabled: false;
|
|
85
|
+
};
|
|
86
|
+
encryptOAuthTokens: true;
|
|
87
|
+
};
|
|
88
|
+
advanced: {
|
|
89
|
+
cookiePrefix: string;
|
|
90
|
+
useSecureCookies: true;
|
|
91
|
+
crossSubDomainCookies: {
|
|
92
|
+
enabled: false;
|
|
93
|
+
};
|
|
94
|
+
defaultCookieAttributes: {
|
|
95
|
+
secure: true;
|
|
96
|
+
httpOnly: true;
|
|
97
|
+
sameSite: "lax";
|
|
98
|
+
path: string;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
onAPIError: {
|
|
102
|
+
errorURL: string;
|
|
103
|
+
};
|
|
104
|
+
logger: {
|
|
105
|
+
disabled: true;
|
|
106
|
+
};
|
|
107
|
+
rateLimit: {
|
|
108
|
+
enabled: true;
|
|
109
|
+
storage: "memory";
|
|
110
|
+
};
|
|
111
|
+
plugins: [{
|
|
112
|
+
init: (ctx: import("better-auth").AuthContext) => Promise<{
|
|
113
|
+
context: {
|
|
114
|
+
socialProviders: OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
|
|
115
|
+
genericOAuthPendingDiscovery: Map<string, {
|
|
116
|
+
config: import("better-auth/plugins/generic-oauth").GenericOAuthConfig;
|
|
117
|
+
attempt?: Promise<void> | undefined;
|
|
118
|
+
}>;
|
|
119
|
+
};
|
|
120
|
+
}>;
|
|
121
|
+
hooks: {
|
|
122
|
+
before: {
|
|
123
|
+
matcher: (context: import("better-auth").HookEndpointContext) => boolean;
|
|
124
|
+
handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
|
|
125
|
+
}[];
|
|
126
|
+
};
|
|
127
|
+
id: "generic-oauth";
|
|
128
|
+
version: string;
|
|
129
|
+
options: import("better-auth/plugins/generic-oauth").GenericOAuthOptions<string>;
|
|
130
|
+
$ERROR_CODES: {
|
|
131
|
+
INVALID_OAUTH_CONFIGURATION: import("better-auth").RawError<"INVALID_OAUTH_CONFIGURATION">;
|
|
132
|
+
TOKEN_URL_NOT_FOUND: import("better-auth").RawError<"TOKEN_URL_NOT_FOUND">;
|
|
133
|
+
};
|
|
134
|
+
}];
|
|
135
|
+
}>>;
|
|
136
|
+
getIdentity: (headers: Headers) => Promise<Identity | null>;
|
|
137
|
+
getPrincipal: (headers: Headers) => Promise<Principal<T>>;
|
|
138
|
+
handlers: {
|
|
139
|
+
GET: (request: Request) => Promise<Response>;
|
|
140
|
+
POST: (request: Request) => Promise<Response>;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { genericOAuth } from "better-auth/plugins/generic-oauth";
|
|
5
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
6
|
+
import { createRemoteJWKSet, customFetch } from "jose";
|
|
7
|
+
import { describeIntegration } from "../../integration-contracts/src/config.js";
|
|
8
|
+
import { validateEnvironmentSelection } from "../../integration-contracts/src/environment.js";
|
|
9
|
+
import { IntegrationError, TOOL_VERSION, parseLoginRequest, validateReturnTo } from "../../integration-contracts/src/public.js";
|
|
10
|
+
import { readPublicJson, validateDiscovery, validateJwks } from "../../integration-contracts/src/metadata.js";
|
|
11
|
+
const safeHeaders = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
|
|
12
|
+
const json = (data, status = 200, extra = {}) => Response.json(data, { status, headers: { ...safeHeaders, ...extra } });
|
|
13
|
+
function bounded(work, ms, code, onTimeout) {
|
|
14
|
+
let timer;
|
|
15
|
+
return Promise.race([work, new Promise((_, reject) => { timer = setTimeout(() => { onTimeout?.(); reject(new IntegrationError(code)); }, ms); })]).finally(() => clearTimeout(timer));
|
|
16
|
+
}
|
|
17
|
+
function claims(idToken, c) {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString("utf8"));
|
|
20
|
+
if (data.iss !== c.canonicalIssuer || typeof data.sub !== "string" || !data.sub.trim())
|
|
21
|
+
return null;
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function createUserCenterAuth(options) {
|
|
29
|
+
const c = describeIntegration(options.manifest);
|
|
30
|
+
const environment = options.environment === undefined ? undefined : validateEnvironmentSelection(options.environment);
|
|
31
|
+
if (!options.database || typeof options.clientSecret !== "string" || !options.clientSecret.trim() || typeof options.sessionSecret !== "string" || !options.sessionSecret.trim() || options.sessionSecret.length < 32 || options.sessionSecret === options.clientSecret || !["allow-new", "existing-only"].includes(options.authUserPolicy) || typeof options.resolveBusinessUser !== "function")
|
|
32
|
+
throw new IntegrationError("INVALID_CONFIG");
|
|
33
|
+
const ttl = options.sessionTtlSeconds ?? 86400;
|
|
34
|
+
if (!Number.isInteger(ttl) || ttl < 300 || ttl > 86400)
|
|
35
|
+
throw new IntegrationError("INVALID_CONFIG");
|
|
36
|
+
// Capture explicit server values. Mutating the caller's object cannot change policy later.
|
|
37
|
+
const { database, clientSecret, sessionSecret, resolveBusinessUser, onEvent } = options;
|
|
38
|
+
const existingOnly = options.authUserPolicy === "existing-only";
|
|
39
|
+
function emit(phase, code, requestId = randomUUID()) {
|
|
40
|
+
try {
|
|
41
|
+
void Promise.resolve(onEvent?.(Object.freeze({ phase, code, requestId, at: new Date().toISOString(), version: TOOL_VERSION }))).catch(() => { });
|
|
42
|
+
}
|
|
43
|
+
catch { /* Observation must not change authentication. */ }
|
|
44
|
+
}
|
|
45
|
+
function buildAuth(metadata, state) {
|
|
46
|
+
const algorithms = metadata.id_token_signing_alg_values_supported;
|
|
47
|
+
const jwksUrl = c.canonicalIssuer + "/jwks";
|
|
48
|
+
const jwks = createRemoteJWKSet(new URL(jwksUrl), { timeoutDuration: 5000, [customFetch]: async (url) => {
|
|
49
|
+
if (url !== jwksUrl)
|
|
50
|
+
throw new IntegrationError("UNTRUSTED_METADATA");
|
|
51
|
+
const data = await readPublicJson(jwksUrl, 1048576);
|
|
52
|
+
validateJwks(data);
|
|
53
|
+
return Response.json(data);
|
|
54
|
+
} });
|
|
55
|
+
function guardProviders(providers) {
|
|
56
|
+
if (state.failure)
|
|
57
|
+
throw state.failure;
|
|
58
|
+
function reject(code) {
|
|
59
|
+
const error = new IntegrationError(code);
|
|
60
|
+
state.invalidate(error);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const matching = providers.filter(p => p.id === c.providerId);
|
|
64
|
+
// A partial official initialization must never become a reusable instance.
|
|
65
|
+
if (!matching.length)
|
|
66
|
+
reject("CENTER_UNAVAILABLE");
|
|
67
|
+
if (matching.length !== 1)
|
|
68
|
+
reject("UNTRUSTED_METADATA");
|
|
69
|
+
for (const provider of matching) {
|
|
70
|
+
if (provider.issuer !== c.canonicalIssuer || !provider.idToken || !("jwks" in provider.idToken) || provider.requiresIdTokenNonce !== true || !provider.idToken.algorithms || provider.idToken.algorithms.length !== algorithms.length || provider.idToken.algorithms.some(a => !algorithms.includes(a)))
|
|
71
|
+
reject("UNTRUSTED_METADATA");
|
|
72
|
+
// Mutate the public provider object in place: its official verifier closes over it.
|
|
73
|
+
provider.idToken.jwks = jwks;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const plugin = genericOAuth({ config: [{
|
|
77
|
+
providerId: c.providerId, name: "User Center", clientId: c.clientId, clientSecret,
|
|
78
|
+
discoveryUrl: c.discoveryUrl, authorizationUrl: c.canonicalIssuer + "/oauth2/authorize", tokenUrl: c.canonicalIssuer + "/oauth2/token", userInfoUrl: c.canonicalIssuer + "/oauth2/userinfo",
|
|
79
|
+
scopes: ["openid", "profile", "email"], responseType: "code", responseMode: "query", pkce: true, requireIdTokenVerification: true, disableIdTokenNonceBinding: false,
|
|
80
|
+
tokenEndpointAuth: { method: "client_secret_basic" }, allowIdpInitiated: false, disableProviderLogout: true, disableSignUp: existingOnly, disableImplicitSignUp: existingOnly,
|
|
81
|
+
async getUserInfo(tokens) {
|
|
82
|
+
// This official extension runs only after the provider verifies signature and nonce.
|
|
83
|
+
const verified = claims(tokens.idToken, c);
|
|
84
|
+
if (!verified || !tokens.accessToken)
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
const data = await readPublicJson(c.canonicalIssuer + "/oauth2/userinfo", 65536, "CENTER_UNAVAILABLE", { Authorization: `Bearer ${tokens.accessToken}` });
|
|
88
|
+
if (data.sub !== verified.sub || data.email_verified !== true || typeof data.email !== "string" || !data.email.trim() || typeof data.name !== "string")
|
|
89
|
+
return null;
|
|
90
|
+
return { sub: verified.sub, id: verified.sub, email: data.email, emailVerified: true, name: data.name };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
accountSubject({ tokens, profile }) { const verified = claims(tokens.idToken, c); return verified && verified.sub === profile.sub ? verified.sub : ""; },
|
|
97
|
+
}] });
|
|
98
|
+
// Guard every API call, including retained auth objects, before and after official hooks.
|
|
99
|
+
const providerGuard = { matcher: () => true, handler: createAuthMiddleware(async (ctx) => { guardProviders(ctx.context.socialProviders); }) };
|
|
100
|
+
const guardedPlugin = { ...plugin, init: async (...args) => {
|
|
101
|
+
const result = await plugin.init(...args);
|
|
102
|
+
guardProviders(result.context.socialProviders);
|
|
103
|
+
return result;
|
|
104
|
+
}, hooks: { ...plugin.hooks, before: [providerGuard, ...plugin.hooks.before, providerGuard] } };
|
|
105
|
+
const auth = betterAuth({
|
|
106
|
+
baseURL: c.platformOrigin, basePath: c.authBasePath, secret: sessionSecret, database, trustedOrigins: [c.platformOrigin],
|
|
107
|
+
emailAndPassword: { enabled: false }, user: { deleteUser: { enabled: false } },
|
|
108
|
+
session: { expiresIn: ttl, disableSessionRefresh: true, cookieCache: { enabled: false } },
|
|
109
|
+
account: { accountLinking: { enabled: false }, encryptOAuthTokens: true },
|
|
110
|
+
advanced: { cookiePrefix: c.cookiePrefix, useSecureCookies: true, crossSubDomainCookies: { enabled: false }, defaultCookieAttributes: { secure: true, httpOnly: true, sameSite: "lax", path: "/" } },
|
|
111
|
+
onAPIError: { errorURL: c.platformOrigin + c.errorPath }, logger: { disabled: true }, rateLimit: { enabled: true, storage: "memory" }, plugins: [guardedPlugin],
|
|
112
|
+
});
|
|
113
|
+
return { auth, assertTrusted: async () => { guardProviders((await auth.$context).socialProviders); } };
|
|
114
|
+
}
|
|
115
|
+
let preparation;
|
|
116
|
+
async function loadAuth() {
|
|
117
|
+
if (!preparation) {
|
|
118
|
+
const state = { invalidate(error) {
|
|
119
|
+
state.failure ??= error;
|
|
120
|
+
if (preparation === attempt)
|
|
121
|
+
preparation = undefined;
|
|
122
|
+
} };
|
|
123
|
+
const work = (async () => {
|
|
124
|
+
const metadata = validateDiscovery(await readPublicJson(c.discoveryUrl, 1048576), c, environment?.targetEnvironment);
|
|
125
|
+
if (state.failure)
|
|
126
|
+
throw state.failure;
|
|
127
|
+
const prepared = buildAuth(metadata, state);
|
|
128
|
+
await prepared.assertTrusted();
|
|
129
|
+
return prepared;
|
|
130
|
+
})();
|
|
131
|
+
// All callers share one deadline; an expired generation cannot poison a later one.
|
|
132
|
+
const attempt = bounded(work, 10000, "CENTER_UNAVAILABLE", () => state.invalidate(new IntegrationError("CENTER_UNAVAILABLE")));
|
|
133
|
+
preparation = attempt;
|
|
134
|
+
void attempt.catch(error => state.invalidate(error instanceof IntegrationError ? error : new IntegrationError("CENTER_UNAVAILABLE")));
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const prepared = await preparation;
|
|
138
|
+
await prepared.assertTrusted();
|
|
139
|
+
return prepared.auth;
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
const error = e instanceof IntegrationError ? e : new IntegrationError("CENTER_UNAVAILABLE");
|
|
143
|
+
emit("ready", error.code);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function getAuth() {
|
|
148
|
+
return loadAuth();
|
|
149
|
+
}
|
|
150
|
+
async function getIdentity(headers) {
|
|
151
|
+
try {
|
|
152
|
+
const auth = await getAuth();
|
|
153
|
+
const session = await auth.api.getSession({ headers });
|
|
154
|
+
if (!session)
|
|
155
|
+
return null;
|
|
156
|
+
const accounts = await auth.api.listUserAccounts({ headers });
|
|
157
|
+
const matches = accounts.filter(a => a.providerId === c.providerId);
|
|
158
|
+
if (!matches.length)
|
|
159
|
+
return null;
|
|
160
|
+
if (matches.length !== 1 || matches[0].userId !== session.user.id || typeof matches[0].accountId !== "string" || !matches[0].accountId.trim())
|
|
161
|
+
throw new IntegrationError("IDENTITY_CONFLICT");
|
|
162
|
+
return { issuer: c.canonicalIssuer, subject: matches[0].accountId, authUserId: session.user.id, profile: { name: session.user.name, email: session.user.email, emailVerified: session.user.emailVerified } };
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
throw e instanceof IntegrationError ? e : new IntegrationError("CENTER_UNAVAILABLE");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function getPrincipal(headers) {
|
|
169
|
+
const identity = await getIdentity(headers);
|
|
170
|
+
if (!identity)
|
|
171
|
+
return { status: "anonymous" };
|
|
172
|
+
const requestId = randomUUID();
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
try {
|
|
175
|
+
const result = await bounded(Promise.resolve().then(() => resolveBusinessUser(identity, Object.freeze({ requestId, signal: controller.signal }))), 5000, "BUSINESS_RESOLUTION_FAILED", () => controller.abort());
|
|
176
|
+
if (result?.status === "unmapped")
|
|
177
|
+
return { status: "mapping-required", identity };
|
|
178
|
+
if (result?.status !== "ready" || typeof result.user?.id !== "string" || !result.user.id.trim())
|
|
179
|
+
throw new IntegrationError("BUSINESS_RESOLUTION_FAILED");
|
|
180
|
+
emit("business", "OK", requestId);
|
|
181
|
+
return { status: "authenticated", identity, user: result.user };
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
emit("business", "BUSINESS_RESOLUTION_FAILED", requestId);
|
|
185
|
+
throw new IntegrationError("BUSINESS_RESOLUTION_FAILED");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function safeSession(headers) {
|
|
189
|
+
const auth = await getAuth();
|
|
190
|
+
const session = await auth.api.getSession({ headers });
|
|
191
|
+
return session ? { user: { id: session.user.id, name: session.user.name, email: session.user.email, emailVerified: session.user.emailVerified }, expiresAt: session.session.expiresAt.toISOString() } : null;
|
|
192
|
+
}
|
|
193
|
+
function redirectError(code, response) {
|
|
194
|
+
const headers = new Headers(safeHeaders);
|
|
195
|
+
headers.set("Location", c.platformOrigin + c.errorPath + "?code=" + code);
|
|
196
|
+
// Preserve state cleanup, but never deliver a session on a rejected redirect.
|
|
197
|
+
if (response)
|
|
198
|
+
for (const cookie of response.headers.getSetCookie())
|
|
199
|
+
if (!cookie.includes("session_token="))
|
|
200
|
+
headers.append("Set-Cookie", cookie);
|
|
201
|
+
return new Response(null, { status: 302, headers });
|
|
202
|
+
}
|
|
203
|
+
async function handle(request) {
|
|
204
|
+
const url = new URL(request.url);
|
|
205
|
+
const path = url.pathname.slice(c.authBasePath.length);
|
|
206
|
+
if (url.origin !== c.platformOrigin || !url.pathname.startsWith(c.authBasePath + "/"))
|
|
207
|
+
return json({ code: "NOT_FOUND" }, 404);
|
|
208
|
+
const methods = { "/sign-in/social": "POST", "/get-session": "GET", "/sign-out": "POST", ["/callback/" + c.providerId]: "GET" };
|
|
209
|
+
if (!methods[path])
|
|
210
|
+
return json({ code: "NOT_FOUND" }, 404);
|
|
211
|
+
if (request.method !== methods[path])
|
|
212
|
+
return json({ code: "METHOD_NOT_ALLOWED" }, 405, { Allow: methods[path] });
|
|
213
|
+
if (request.method === "POST" && (request.headers.get("Origin") !== c.platformOrigin || request.headers.get("Sec-Fetch-Site") === "cross-site"))
|
|
214
|
+
return json({ code: "FORBIDDEN" }, 403);
|
|
215
|
+
try {
|
|
216
|
+
if (path === "/sign-in/social") {
|
|
217
|
+
if (url.search || !/^application\/json(?:;|$)/i.test(request.headers.get("Content-Type") ?? ""))
|
|
218
|
+
return json({ code: "INVALID_LOGIN_REQUEST" }, 400);
|
|
219
|
+
const text = await request.text();
|
|
220
|
+
if (text.length > 4096)
|
|
221
|
+
return json({ code: "INVALID_LOGIN_REQUEST" }, 400);
|
|
222
|
+
let body;
|
|
223
|
+
try {
|
|
224
|
+
body = JSON.parse(text);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return json({ code: "INVALID_LOGIN_REQUEST" }, 400);
|
|
228
|
+
}
|
|
229
|
+
const { returnTo } = parseLoginRequest(body, c);
|
|
230
|
+
const rebuilt = JSON.stringify({ provider: c.providerId, callbackURL: c.platformOrigin + returnTo, newUserCallbackURL: c.platformOrigin + returnTo, errorCallbackURL: c.platformOrigin + c.errorPath, disableRedirect: true });
|
|
231
|
+
const headers = new Headers(request.headers);
|
|
232
|
+
headers.delete("Content-Length");
|
|
233
|
+
headers.delete("Transfer-Encoding");
|
|
234
|
+
headers.set("Content-Type", "application/json");
|
|
235
|
+
const response = await (await loadAuth()).handler(new Request(request.url, { method: "POST", headers, body: rebuilt }));
|
|
236
|
+
if (!response.ok)
|
|
237
|
+
return json({ code: "LOGIN_UNAVAILABLE" }, response.status >= 500 ? 503 : 400);
|
|
238
|
+
const data = await response.json();
|
|
239
|
+
const target = new URL(data.url);
|
|
240
|
+
if (target.origin !== c.centerOrigin || target.pathname !== new URL(c.canonicalIssuer + "/oauth2/authorize").pathname || target.username || target.password || target.hash)
|
|
241
|
+
throw new IntegrationError("LOGIN_UNAVAILABLE");
|
|
242
|
+
const out = json({ url: target.href });
|
|
243
|
+
for (const cookie of response.headers.getSetCookie())
|
|
244
|
+
out.headers.append("Set-Cookie", cookie);
|
|
245
|
+
emit("login", "OK");
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
if (path === "/get-session")
|
|
249
|
+
return json(await safeSession(request.headers));
|
|
250
|
+
if (path.startsWith("/callback/")) {
|
|
251
|
+
if (["code", "state", "error"].some(k => url.searchParams.getAll(k).length > 1))
|
|
252
|
+
return redirectError("LOGIN_UNAVAILABLE");
|
|
253
|
+
const response = await (await loadAuth()).handler(request);
|
|
254
|
+
const location = response.headers.get("Location");
|
|
255
|
+
if (!location)
|
|
256
|
+
return redirectError("LOGIN_UNAVAILABLE", response);
|
|
257
|
+
const target = new URL(location, c.platformOrigin);
|
|
258
|
+
if (target.origin !== c.platformOrigin || target.username || target.password || target.hash)
|
|
259
|
+
return redirectError("LOGIN_UNAVAILABLE", response);
|
|
260
|
+
if (target.pathname === c.errorPath)
|
|
261
|
+
return redirectError(target.searchParams.get("error") === "account_not_linked" ? "ACCOUNT_CONFLICT" : "LOGIN_UNAVAILABLE", response);
|
|
262
|
+
try {
|
|
263
|
+
validateReturnTo(target.pathname + target.search, c);
|
|
264
|
+
if (target.hash)
|
|
265
|
+
throw new Error();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return redirectError("INVALID_RETURN_TO", response);
|
|
269
|
+
}
|
|
270
|
+
const headers = new Headers(response.headers);
|
|
271
|
+
for (const [k, v] of Object.entries(safeHeaders))
|
|
272
|
+
headers.set(k, v);
|
|
273
|
+
emit("callback", "OK");
|
|
274
|
+
return new Response(null, { status: response.status, headers });
|
|
275
|
+
}
|
|
276
|
+
const auth = await getAuth();
|
|
277
|
+
const response = await auth.handler(request);
|
|
278
|
+
let confirmed = false;
|
|
279
|
+
if (response.ok)
|
|
280
|
+
try {
|
|
281
|
+
confirmed = await auth.api.getSession({ headers: request.headers, query: { disableCookieCache: true, disableRefresh: true } }) === null;
|
|
282
|
+
}
|
|
283
|
+
catch { /* A failed confirmation read must not declare revocation. */ }
|
|
284
|
+
const out = confirmed ? json({ success: true }) : json({ code: "SIGN_OUT_UNCONFIRMED" }, 503);
|
|
285
|
+
for (const cookie of response.headers.getSetCookie())
|
|
286
|
+
out.headers.append("Set-Cookie", cookie);
|
|
287
|
+
emit("logout", confirmed ? "OK" : "SIGN_OUT_UNCONFIRMED");
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
const code = e instanceof IntegrationError ? e.code : "CENTER_UNAVAILABLE";
|
|
292
|
+
if (path.startsWith("/callback/"))
|
|
293
|
+
return redirectError(code);
|
|
294
|
+
return json({ code }, ["INVALID_LOGIN_REQUEST", "INVALID_RETURN_TO"].includes(code) ? 400 : code === "IDENTITY_CONFLICT" ? 409 : 503);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { publicConfig: describeIntegration(options.manifest), ready: async () => { await getAuth(); emit("ready", "OK"); }, getAuth, getIdentity, getPrincipal, handlers: { GET: handle, POST: handle } };
|
|
298
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 User Center contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# User Center 0.1.10 接入资料
|
|
2
|
+
|
|
3
|
+
本目录随 SDK 和 CLI 两个 npm 包完整交付,可安装后阅读,也可从精确版本 tgz 解包获得。无需访问中心源码仓库。许可见 [LICENSE](LICENSE),本发行内容使用 MIT;中心工程、原独立样例及第三方依赖的许可仍分别处理。
|
|
4
|
+
|
|
5
|
+
1. 从零接入先读[完整指南](guide/index.md)。
|
|
6
|
+
2. 三环境选择见[环境说明](guide/environments.md),只读检查见[doctor](guide/doctor.md),正式支持状态见[支持范围](guide/support.md)。
|
|
7
|
+
3. 给目标工程 Agent 使用[接入 Prompt](guide/agent-prompt.md)。
|
|
8
|
+
4. 新工程可手动复制[完整 Next 参考样例](example-next/README.md),已有工程按指南复用其接线,不覆盖已有用户和业务数据。
|
|
9
|
+
|
|
10
|
+
版本 0.1.10 是公开试用定位。候选 tgz 与已实际发布的 registry 版本应分别确认;dry-run 不上传。生产认证、真实邮件、宿主存储和正式兼容组合继续待验,blog 接入保持用户确认已完成。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 User Center contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# 可复制 Next 参考样例 0.1.10
|
|
2
|
+
|
|
3
|
+
这是随 SDK/CLI npm 包交付的宿主示例。手动复制本目录的内容到一个新的空目录,保留 LICENSE。把 gitignore.txt 复制为 .gitignore,把 environment.example.txt 复制为你自己的 .env.local,再填写秘密。原包完整指南在 dist/reference/guide/index.md,Agent Prompt 在 dist/reference/guide/agent-prompt.md;安装 SDK 后也可在 node_modules/@user-center/next-better-auth/dist/reference/ 中读取。无需中心仓库权限。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
本项目 package.json 已固定 SDK/CLI 0.1.10 和 Better Auth 1.7.12,不依赖 workspace 或仓库相对路径。版本已发布时在复制后的目录运行 pnpm install --ignore-scripts。
|
|
8
|
+
|
|
9
|
+
候选交付尚未上传 npm 时,先由操作者提供原 tgz 和摘要。将 package.json 中两个 @user-center 依赖改为已核验 tgz 的 file: 绝对路径,再运行 pnpm install --ignore-scripts 并保存自己的锁文件。不要仅凭本文猜装 registry 同名版本,也不要把别人的锁文件当作你的环境验收。
|
|
10
|
+
|
|
11
|
+
本地工具可先 pnpm typecheck。它只验证样例类型与路由,不完成认证。SDK 和 doctor 不执行复制、安装或迁移,所有宿主操作由你明确运行。
|
|
12
|
+
|
|
13
|
+
## 配置与数据库
|
|
14
|
+
|
|
15
|
+
旧单配置使用 integration.json,填写实际 issuer、HTTPS origin、专用 Client ID 及派生回调,保持模板中的三个可选选择器为注释。profiles 模式可把 profiles.example.json 复制为 integration.environments.json,再取消 USER_CENTER_CONFIG_FILE 与 USER_CENTER_RUNTIME_ENVIRONMENT 两行的注释并填写实际值,以及选中 profile 引用的秘密。未设置 USER_CENTER_TARGET_ENVIRONMENT 时默认与 runtime 相同,需要指定 target 才取消该行注释并填写有效值;不要启用空赋值。只有 development 可跨中心;切 target 不切平台库和会话密钥。
|
|
16
|
+
|
|
17
|
+
DATABASE_URL 指向样例自己的认证数据库,PLATFORM_SESSION_SECRET 至少 32 字符且独立于 Client Secret。仅准备且已授权的宿主数据库可以迁移:先 pnpm schema:plan,人工审查,再 pnpm schema:apply。两者使用 --conditions=react-server 并加载同一宿主环境;SDK/doctor 不代为执行。现有库不能直接套用干净样例迁移,须先核对旧表、外键、重复身份及 Account(providerId,accountId) 全局唯一约束。
|
|
18
|
+
|
|
19
|
+
本样例不生成证书、改 hosts 或创建中心 Client。你需准备可信 HTTPS 本地域名或受控反向代理,保证登记的 origin 与实际回调一致;localhost/回环 IP 不属于首期平台 origin 范围。可按自己的 HTTPS 入口运行 pnpm dev。
|
|
20
|
+
|
|
21
|
+
## 验收入口
|
|
22
|
+
|
|
23
|
+
从 /projects/example?tab=activity 发起,完成中心注册和真实邮件验证后主动登录、必要授权、派生回调和身份验证,回到同一业务目标;页面/API principal 一致,刷新保持会话,本地退出后拒绝访问。若原授权请求已过期,从平台重新发起,不延长或重放旧请求。
|
|
24
|
+
|
|
25
|
+
干净样例明确以 authUserId 作为业务 ID。已有工程应保留原用户归属并自行实现稳定 issuer/subject 映射,不按相同邮箱自动认领。受保护页面和 /api/me 都检查 principal;映射未完成、匿名和依赖故障分别拒绝访问。
|
|
26
|
+
|
|
27
|
+
pnpm exec user-center doctor --config integration.json --project . --env-file .env.local --json 仅是静态诊断。profiles 先由宿主导出单 Manifest。未配置样例的 FAIL 是预期结果;生产认证、真实邮件、宿主约束和正式兼容仍待验。blog 已由用户确认接入完成,本样例不据此推定其 SDK 版本。
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
USER_CENTER_CLIENT_SECRET=
|
|
2
|
+
PLATFORM_SESSION_SECRET=
|
|
3
|
+
DATABASE_URL=
|
|
4
|
+
# Optional profiles selectors. Keep commented for the legacy manifest.
|
|
5
|
+
# Enable CONFIG_FILE and RUNTIME together for profiles; omit TARGET to use runtime.
|
|
6
|
+
# USER_CENTER_CONFIG_FILE=
|
|
7
|
+
# USER_CENTER_RUNTIME_ENVIRONMENT=
|
|
8
|
+
# USER_CENTER_TARGET_ENVIRONMENT=
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"issuer": "https://auth.example.com/api/auth",
|
|
4
|
+
"platformOrigin": "https://platform.example.test:3443",
|
|
5
|
+
"clientId": "replace-with-registered-client-id",
|
|
6
|
+
"clientSecretEnv": "USER_CENTER_CLIENT_SECRET",
|
|
7
|
+
"sessionSecretEnv": "PLATFORM_SESSION_SECRET",
|
|
8
|
+
"returnTo": {"fallback": "/dashboard", "allowedPaths": ["/dashboard"], "allowedPathPrefixes": ["/projects"], "allowedQueryKeys": ["tab"]},
|
|
9
|
+
"errorPath": "/auth-error"
|
|
10
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "user-center-sdk-consumer",
|
|
3
|
+
"version": "0.1.10",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@12.9.1",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "next dev --hostname 0.0.0.0",
|
|
9
|
+
"build": "next build",
|
|
10
|
+
"start": "next start",
|
|
11
|
+
"typecheck": "next typegen && tsc --noEmit",
|
|
12
|
+
"schema:plan": "node --conditions=react-server --import tsx scripts/schema.mts",
|
|
13
|
+
"schema:apply": "node --conditions=react-server --import tsx scripts/schema.mts --apply"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@next/env": "16.4.3",
|
|
17
|
+
"@user-center/next-better-auth": "0.1.10",
|
|
18
|
+
"better-auth": "1.7.12",
|
|
19
|
+
"next": "16.4.3",
|
|
20
|
+
"pg": "8.23.1",
|
|
21
|
+
"react": "19.3.0",
|
|
22
|
+
"react-dom": "19.3.0",
|
|
23
|
+
"server-only": "0.0.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^24",
|
|
27
|
+
"@types/pg": "8.23.1",
|
|
28
|
+
"@types/react": "^19",
|
|
29
|
+
"@types/react-dom": "^19",
|
|
30
|
+
"@user-center/cli": "0.1.10",
|
|
31
|
+
"tsx": "4.23.15",
|
|
32
|
+
"typescript": "5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.21.1"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"centers": {
|
|
4
|
+
"development": "https://auth.user-center.test:3000/api/auth",
|
|
5
|
+
"staging": "https://auth-staging.example.com/api/auth",
|
|
6
|
+
"production": "https://auth.example.com/api/auth"
|
|
7
|
+
},
|
|
8
|
+
"runtimes": {
|
|
9
|
+
"development": {
|
|
10
|
+
"platformOrigin": "https://platform.user-center.test:3443",
|
|
11
|
+
"sessionSecretEnv": "PLATFORM_DEVELOPMENT_SESSION_SECRET",
|
|
12
|
+
"database": {"urlEnv": "PLATFORM_DEVELOPMENT_DATABASE_URL", "hostname": "127.0.0.1", "port": 54330, "database": "platform_dev", "username": "platform_dev"},
|
|
13
|
+
"clients": {
|
|
14
|
+
"development": {"clientId": "register-local-client-in-local-center", "clientSecretEnv": "LOCAL_TO_DEVELOPMENT_CLIENT_SECRET"},
|
|
15
|
+
"staging": {"clientId": "register-local-callback-client-in-staging-center", "clientSecretEnv": "LOCAL_TO_STAGING_CLIENT_SECRET"},
|
|
16
|
+
"production": {"clientId": "register-local-callback-client-in-production-center", "clientSecretEnv": "LOCAL_TO_PRODUCTION_CLIENT_SECRET"}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"staging": {
|
|
20
|
+
"platformOrigin": "https://platform-staging.example.com",
|
|
21
|
+
"sessionSecretEnv": "PLATFORM_STAGING_SESSION_SECRET",
|
|
22
|
+
"database": {"urlEnv": "PLATFORM_STAGING_DATABASE_URL", "hostname": "staging-db.example.com", "port": 5432, "database": "platform_staging", "username": "platform_staging"},
|
|
23
|
+
"clients": {"staging": {"clientId": "register-staging-platform-client", "clientSecretEnv": "STAGING_TO_STAGING_CLIENT_SECRET"}}
|
|
24
|
+
},
|
|
25
|
+
"production": {
|
|
26
|
+
"platformOrigin": "https://platform.example.com",
|
|
27
|
+
"sessionSecretEnv": "PLATFORM_PRODUCTION_SESSION_SECRET",
|
|
28
|
+
"database": {"urlEnv": "PLATFORM_PRODUCTION_DATABASE_URL", "hostname": "production-db.example.com", "port": 5432, "database": "platform_production", "username": "platform_production"},
|
|
29
|
+
"clients": {"production": {"clientId": "register-production-platform-client", "clientSecretEnv": "PRODUCTION_TO_PRODUCTION_CLIENT_SECRET"}}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"returnTo": {"fallback": "/dashboard", "allowedPaths": ["/dashboard"], "allowedPathPrefixes": ["/projects"], "allowedQueryKeys": ["tab"]},
|
|
33
|
+
"errorPath": "/auth-error"
|
|
34
|
+
}
|