@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
package/LICENSE
ADDED
|
@@ -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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# User Center authentication SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Version 0.1.10 is a public trial for Node, Next App Router and Better Auth 1.7.12. Distributed package contents use MIT, with the complete notice in LICENSE. The intended npm access is public and the distribution tag is next. Production authentication, mail, host storage and formal runtime combinations still require acceptance.
|
|
4
|
+
|
|
5
|
+
## Install a fixed version
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add --save-exact better-auth@1.7.12 server-only@0.0.1 @user-center/next-better-auth@0.1.10
|
|
9
|
+
pnpm add -D --save-exact @user-center/cli@0.1.10
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
For a candidate delivered as a tgz, replace the package specifiers with the verified SDK and CLI archive paths. A local candidate or dry run does not prove that a version is available on npm. Check the exact version and keep your lockfile. npm tags, including latest, do not certify production readiness.
|
|
13
|
+
|
|
14
|
+
## Complete guide and copyable example
|
|
15
|
+
|
|
16
|
+
Both packages include the same self contained reference material. No access to the center source repository is needed to obtain it.
|
|
17
|
+
|
|
18
|
+
| Material | Path inside this installed package |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| Start here | dist/reference/README.md |
|
|
21
|
+
| Full registration, storage, routing and acceptance guide | dist/reference/guide/index.md |
|
|
22
|
+
| Environment profiles and local to remote setup | dist/reference/guide/environments.md |
|
|
23
|
+
| Read only doctor, output and exit codes | dist/reference/guide/doctor.md |
|
|
24
|
+
| Supported targets and pending acceptance | dist/reference/guide/support.md |
|
|
25
|
+
| Prompt for your project Agent | dist/reference/guide/agent-prompt.md |
|
|
26
|
+
| Copyable independent Next application | dist/reference/example-next/ |
|
|
27
|
+
|
|
28
|
+
After installation, open `node_modules/@user-center/next-better-auth/dist/reference/README.md`. You can also extract the verified tgz and read `package/dist/reference/README.md`. Once this exact version has been published, its files can also be browsed through the Code tab on this [npm version page](https://www.npmjs.com/package/@user-center/next-better-auth/v/0.1.10). A candidate archive already provides all materials locally; it does not create the remote page. Repository visibility does not control these package contents.
|
|
29
|
+
|
|
30
|
+
Copy the example directory yourself into a new empty project, then follow its README. Its dependencies use fixed registry versions; for an unpublished candidate, explicitly substitute the verified tgz paths before installing. The SDK and doctor do not copy files, install dependencies or execute migrations. The example is reference source, not a preconfigured authentication service.
|
|
31
|
+
|
|
32
|
+
## Register and wire your host
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { describeRegistration } from '@user-center/next-better-auth/config';
|
|
36
|
+
const registration = describeRegistration({
|
|
37
|
+
issuer: 'https://auth.example.com/api/auth',
|
|
38
|
+
platformOrigin: 'https://platform.example.test',
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Register the exact returned callback and origin, obtain a dedicated confidential Web Client, then create a Manifest v1. Provide your own database adapter, independent session secret, explicit authUserPolicy and business identity resolver to createUserCenterAuth. Mount its handlers.GET/POST in your own route and protect pages and APIs with getPrincipal. The complete working wiring, migration planning and validation steps are included in the example and guide above.
|
|
43
|
+
|
|
44
|
+
| Public module | Purpose |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| root or /public | Public configuration and types |
|
|
47
|
+
| /config | Registration, environment selection and storage binding helpers |
|
|
48
|
+
| /server | createUserCenterAuth and protected server operations |
|
|
49
|
+
| /client | createUserCenterClient for controlled login, session reads and local logout |
|
|
50
|
+
|
|
51
|
+
Import runtime code only through these public modules. The reference directory is documentation and host example source, not another SDK runtime API. Server code is protected by server-only. Secrets stay in your host server configuration. An authentication session does not grant business permissions; your host owns user mapping, roles and authorization.
|
|
52
|
+
|
|
53
|
+
Blog integration is completed according to the user's confirmation, without certifying its SDK version or a formal runtime combination. Real production authentication and storage acceptance remain pending. Source checkout build scripts need workspace tools; installed code is precompiled and has no install or publish lifecycle hooks.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const MATRIX_VERSION = "2026-11-04.1";
|
|
2
|
+
export interface RuntimeCombination {
|
|
3
|
+
node: string;
|
|
4
|
+
next: string;
|
|
5
|
+
react: string;
|
|
6
|
+
betterAuth: string;
|
|
7
|
+
evidence: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const VERIFIED_COMBINATIONS: readonly RuntimeCombination[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type PublicConfig } from "./public.js";
|
|
2
|
+
export { ENVIRONMENTS, resolveEnvironmentConfig, validateEnvironmentSelection, assertPostgresBinding } from "./environment.js";
|
|
3
|
+
export type { EnvironmentName, EnvironmentSelection, PostgresBinding } from "./environment.js";
|
|
4
|
+
export declare function describeRegistration(input: {
|
|
5
|
+
issuer: string;
|
|
6
|
+
platformOrigin: string;
|
|
7
|
+
authBasePath?: string;
|
|
8
|
+
}): {
|
|
9
|
+
canonicalIssuer: string;
|
|
10
|
+
platformOrigin: string;
|
|
11
|
+
authBasePath: string;
|
|
12
|
+
providerId: string;
|
|
13
|
+
callbackUrl: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function describeIntegration(input: unknown): PublicConfig;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalizeIssuer, parseManifest, validateOrigin, validateAuthBasePath, record, keys } from "./public.js";
|
|
3
|
+
export { ENVIRONMENTS, resolveEnvironmentConfig, validateEnvironmentSelection, assertPostgresBinding } from "./environment.js";
|
|
4
|
+
const digest = (s) => createHash("sha256").update(s, "utf8").digest("hex");
|
|
5
|
+
export function describeRegistration(input) {
|
|
6
|
+
keys(record(input), ["issuer", "platformOrigin", "authBasePath"]);
|
|
7
|
+
const canonicalIssuer = canonicalizeIssuer(input.issuer);
|
|
8
|
+
const platformOrigin = validateOrigin(input.platformOrigin);
|
|
9
|
+
const authBasePath = validateAuthBasePath(input.authBasePath);
|
|
10
|
+
const providerId = "user-center-" + digest(canonicalIssuer);
|
|
11
|
+
return { canonicalIssuer, platformOrigin, authBasePath, providerId, callbackUrl: platformOrigin + authBasePath + "/callback/" + providerId };
|
|
12
|
+
}
|
|
13
|
+
export function describeIntegration(input) {
|
|
14
|
+
const manifest = parseManifest(input);
|
|
15
|
+
const registration = describeRegistration({ issuer: manifest.issuer, platformOrigin: manifest.platformOrigin, authBasePath: manifest.authBasePath });
|
|
16
|
+
return { ...manifest, ...registration, centerOrigin: new URL(registration.canonicalIssuer).origin, discoveryUrl: registration.canonicalIssuer + "/.well-known/openid-configuration", cookiePrefix: "uc_" + digest(manifest.platformOrigin + "\n" + registration.canonicalIssuer).slice(0, 24) };
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Manifest } from "./public.js";
|
|
2
|
+
export declare const ENVIRONMENTS: readonly ["development", "staging", "production"];
|
|
3
|
+
export type EnvironmentName = typeof ENVIRONMENTS[number];
|
|
4
|
+
export interface EnvironmentSelection {
|
|
5
|
+
runtimeEnvironment: EnvironmentName;
|
|
6
|
+
targetEnvironment: EnvironmentName;
|
|
7
|
+
}
|
|
8
|
+
export interface PostgresBinding {
|
|
9
|
+
urlEnv: string;
|
|
10
|
+
hostname: string;
|
|
11
|
+
port: number;
|
|
12
|
+
database: string;
|
|
13
|
+
username: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function validateEnvironmentSelection(input: unknown): Readonly<EnvironmentSelection>;
|
|
16
|
+
export declare function assertPostgresBinding(url: string, input: unknown): void;
|
|
17
|
+
/** Pure selection only. The host reads the selected secret variables on its server. */
|
|
18
|
+
export declare function resolveEnvironmentConfig(input: unknown, selection: {
|
|
19
|
+
runtimeEnvironment: string;
|
|
20
|
+
targetEnvironment?: string;
|
|
21
|
+
}): {
|
|
22
|
+
manifest: Manifest;
|
|
23
|
+
environment: Readonly<EnvironmentSelection>;
|
|
24
|
+
database?: PostgresBinding;
|
|
25
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { IntegrationError, canonicalizeIssuer, keys, parseManifest, record, validateOrigin } from "./public.js";
|
|
2
|
+
export const ENVIRONMENTS = ["development", "staging", "production"];
|
|
3
|
+
const invalid = () => { throw new IntegrationError("ENVIRONMENT_MISMATCH"); };
|
|
4
|
+
function environment(value) {
|
|
5
|
+
if (typeof value !== "string" || !ENVIRONMENTS.includes(value))
|
|
6
|
+
return invalid();
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function reference(value) {
|
|
10
|
+
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{1,127}$/.test(value))
|
|
11
|
+
return invalid();
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
const localHost = (hostname) => ["localhost", "[::1]"].includes(hostname) || /^127\./.test(hostname) || /\.(test|localhost|invalid)$/.test(hostname);
|
|
15
|
+
export function validateEnvironmentSelection(input) {
|
|
16
|
+
const selection = record(input);
|
|
17
|
+
keys(selection, ["runtimeEnvironment", "targetEnvironment"]);
|
|
18
|
+
const runtimeEnvironment = environment(selection.runtimeEnvironment);
|
|
19
|
+
const targetEnvironment = selection.targetEnvironment === undefined ? runtimeEnvironment : environment(selection.targetEnvironment);
|
|
20
|
+
if (runtimeEnvironment !== "development" && targetEnvironment !== runtimeEnvironment)
|
|
21
|
+
invalid();
|
|
22
|
+
return Object.freeze({ runtimeEnvironment, targetEnvironment });
|
|
23
|
+
}
|
|
24
|
+
function postgresBinding(input) {
|
|
25
|
+
const binding = record(input);
|
|
26
|
+
keys(binding, ["urlEnv", "hostname", "port", "database", "username"]);
|
|
27
|
+
const urlEnv = reference(binding.urlEnv);
|
|
28
|
+
for (const key of ["hostname", "database", "username"])
|
|
29
|
+
if (typeof binding[key] !== "string" || !binding[key].trim())
|
|
30
|
+
invalid();
|
|
31
|
+
if (!Number.isInteger(binding.port) || Number(binding.port) < 1 || Number(binding.port) > 65535)
|
|
32
|
+
invalid();
|
|
33
|
+
return { urlEnv, hostname: binding.hostname, port: binding.port, database: binding.database, username: binding.username };
|
|
34
|
+
}
|
|
35
|
+
export function assertPostgresBinding(url, input) {
|
|
36
|
+
const binding = postgresBinding(input);
|
|
37
|
+
try {
|
|
38
|
+
const connection = new URL(url);
|
|
39
|
+
if (connection.hash || [...connection.searchParams.keys()].some(key => !["sslmode", "sslrootcert", "sslcert", "sslkey", "sslpassword", "connect_timeout", "application_name"].includes(key)))
|
|
40
|
+
invalid();
|
|
41
|
+
if (!["postgres:", "postgresql:"].includes(connection.protocol) || connection.hostname.toLowerCase() !== binding.hostname.toLowerCase() || Number(connection.port || 5432) !== binding.port || decodeURIComponent(connection.pathname.slice(1)) !== binding.database || decodeURIComponent(connection.username) !== binding.username)
|
|
42
|
+
invalid();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
invalid();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Pure selection only. The host reads the selected secret variables on its server. */
|
|
49
|
+
export function resolveEnvironmentConfig(input, selection) {
|
|
50
|
+
const selected = validateEnvironmentSelection(selection);
|
|
51
|
+
const config = record(input);
|
|
52
|
+
keys(config, ["schemaVersion", "centers", "runtimes", "authBasePath", "returnTo", "errorPath"]);
|
|
53
|
+
if (config.schemaVersion !== 1)
|
|
54
|
+
invalid();
|
|
55
|
+
const centers = record(config.centers), runtimes = record(config.runtimes);
|
|
56
|
+
keys(centers, ENVIRONMENTS);
|
|
57
|
+
keys(runtimes, ENVIRONMENTS);
|
|
58
|
+
const issuers = new Set(), origins = new Set(), secretRefs = new Set(), databaseRefs = new Set(), databaseIdentities = new Set(), clientIds = new Set();
|
|
59
|
+
const canonicalCenters = {};
|
|
60
|
+
for (const [name, issuer] of Object.entries(centers)) {
|
|
61
|
+
const canonical = canonicalizeIssuer(issuer);
|
|
62
|
+
if (issuers.has(canonical) || (name !== "development" && localHost(new URL(canonical).hostname)))
|
|
63
|
+
invalid();
|
|
64
|
+
issuers.add(canonical);
|
|
65
|
+
canonicalCenters[environment(name)] = canonical;
|
|
66
|
+
}
|
|
67
|
+
let result;
|
|
68
|
+
for (const [runtimeName, value] of Object.entries(runtimes)) {
|
|
69
|
+
const runtime = environment(runtimeName), profile = record(value);
|
|
70
|
+
keys(profile, ["platformOrigin", "sessionSecretEnv", "database", "clients"]);
|
|
71
|
+
const origin = validateOrigin(profile.platformOrigin), sessionSecretEnv = reference(profile.sessionSecretEnv);
|
|
72
|
+
if (origins.has(origin) || secretRefs.has(sessionSecretEnv) || (runtime !== "development" && localHost(new URL(origin).hostname)))
|
|
73
|
+
invalid();
|
|
74
|
+
origins.add(origin);
|
|
75
|
+
secretRefs.add(sessionSecretEnv);
|
|
76
|
+
const database = profile.database === undefined ? undefined : postgresBinding(profile.database);
|
|
77
|
+
if (database) {
|
|
78
|
+
const identity = JSON.stringify([database.hostname.toLowerCase(), database.port, database.database, database.username]);
|
|
79
|
+
if (databaseRefs.has(database.urlEnv) || secretRefs.has(database.urlEnv) || databaseIdentities.has(identity))
|
|
80
|
+
invalid();
|
|
81
|
+
databaseRefs.add(database.urlEnv);
|
|
82
|
+
secretRefs.add(database.urlEnv);
|
|
83
|
+
databaseIdentities.add(identity);
|
|
84
|
+
}
|
|
85
|
+
const clients = record(profile.clients);
|
|
86
|
+
keys(clients, ENVIRONMENTS);
|
|
87
|
+
for (const [targetName, value] of Object.entries(clients)) {
|
|
88
|
+
const target = environment(targetName);
|
|
89
|
+
validateEnvironmentSelection({ runtimeEnvironment: runtime, targetEnvironment: target });
|
|
90
|
+
const client = record(value);
|
|
91
|
+
keys(client, ["clientId", "clientSecretEnv"]);
|
|
92
|
+
const clientSecretEnv = reference(client.clientSecretEnv), issuer = canonicalCenters[target];
|
|
93
|
+
if (!issuer || secretRefs.has(clientSecretEnv))
|
|
94
|
+
invalid();
|
|
95
|
+
secretRefs.add(clientSecretEnv);
|
|
96
|
+
const clientKey = issuer + "\n" + client.clientId;
|
|
97
|
+
if (clientIds.has(clientKey))
|
|
98
|
+
invalid();
|
|
99
|
+
clientIds.add(clientKey);
|
|
100
|
+
const manifest = parseManifest({ schemaVersion: 1, issuer, platformOrigin: origin, clientId: client.clientId, clientSecretEnv, sessionSecretEnv, authBasePath: config.authBasePath, returnTo: config.returnTo, errorPath: config.errorPath });
|
|
101
|
+
if (runtime === selected.runtimeEnvironment && target === selected.targetEnvironment)
|
|
102
|
+
result = { manifest, environment: selected, ...(database ? { database } : {}) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!result)
|
|
106
|
+
return invalid();
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type PublicConfig } from "./public.js";
|
|
2
|
+
export declare function readPublicJson(url: string, maxBytes: number, failure?: "CENTER_UNAVAILABLE" | "UNTRUSTED_METADATA", headers?: Record<string, string>): Promise<Record<string, unknown>>;
|
|
3
|
+
export declare function validateDiscovery(data: Record<string, unknown>, c: PublicConfig, expectedEnvironment?: string): Record<string, unknown>;
|
|
4
|
+
export declare function validateJwks(data: Record<string, unknown>): void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { IntegrationError } from "./public.js";
|
|
2
|
+
export async function readPublicJson(url, maxBytes, failure = "CENTER_UNAVAILABLE", headers = {}) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(url, { method: "GET", redirect: "error", credentials: "omit", cache: "no-store", signal: controller.signal, headers: { Accept: "application/json", ...headers } });
|
|
7
|
+
if (!response.ok || !response.body)
|
|
8
|
+
throw new IntegrationError(failure);
|
|
9
|
+
const reader = response.body.getReader();
|
|
10
|
+
let size = 0;
|
|
11
|
+
const chunks = [];
|
|
12
|
+
try {
|
|
13
|
+
while (true) {
|
|
14
|
+
const chunk = await reader.read();
|
|
15
|
+
if (chunk.done)
|
|
16
|
+
break;
|
|
17
|
+
size += chunk.value.length;
|
|
18
|
+
if (size > maxBytes)
|
|
19
|
+
throw new IntegrationError("UNTRUSTED_METADATA");
|
|
20
|
+
chunks.push(chunk.value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await reader.cancel();
|
|
25
|
+
}
|
|
26
|
+
const bytes = new Uint8Array(size);
|
|
27
|
+
let offset = 0;
|
|
28
|
+
for (const chunk of chunks) {
|
|
29
|
+
bytes.set(chunk, offset);
|
|
30
|
+
offset += chunk.length;
|
|
31
|
+
}
|
|
32
|
+
let data;
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new IntegrationError("UNTRUSTED_METADATA");
|
|
38
|
+
}
|
|
39
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
40
|
+
throw new IntegrationError("UNTRUSTED_METADATA");
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw e instanceof IntegrationError ? e : new IntegrationError(failure);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function validateDiscovery(data, c, expectedEnvironment) {
|
|
51
|
+
if (expectedEnvironment !== undefined && data.user_center_environment !== expectedEnvironment)
|
|
52
|
+
throw new IntegrationError("ENVIRONMENT_MISMATCH");
|
|
53
|
+
const invalid = () => { throw new IntegrationError("UNTRUSTED_METADATA"); };
|
|
54
|
+
if (data.issuer !== c.canonicalIssuer)
|
|
55
|
+
invalid();
|
|
56
|
+
for (const [key, path] of Object.entries({ authorization_endpoint: "/oauth2/authorize", token_endpoint: "/oauth2/token", userinfo_endpoint: "/oauth2/userinfo", jwks_uri: "/jwks" }))
|
|
57
|
+
if (data[key] !== c.canonicalIssuer + path)
|
|
58
|
+
invalid();
|
|
59
|
+
for (const [key, values] of Object.entries({ response_types_supported: ["code"], grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], scopes_supported: ["openid", "profile", "email"] }))
|
|
60
|
+
if (!Array.isArray(data[key]) || values.some(x => !data[key].includes(x)))
|
|
61
|
+
invalid();
|
|
62
|
+
if (!Array.isArray(data.id_token_signing_alg_values_supported) || !data.id_token_signing_alg_values_supported.length || data.id_token_signing_alg_values_supported.some(a => typeof a !== "string" || !/^(?:RS(?:256|384|512)|PS(?:256|384|512)|ES(?:256|384|512)|EdDSA|Ed25519)$/.test(a)))
|
|
63
|
+
invalid();
|
|
64
|
+
if (data.token_endpoint_auth_methods_supported !== undefined && (!Array.isArray(data.token_endpoint_auth_methods_supported) || !data.token_endpoint_auth_methods_supported.includes("client_secret_basic")))
|
|
65
|
+
invalid();
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
export function validateJwks(data) {
|
|
69
|
+
if (!Array.isArray(data.keys) || !data.keys.length || data.keys.some(k => !k || typeof k !== "object" || Array.isArray(k) || typeof k.kty !== "string" || ["d", "p", "q", "dp", "dq", "qi", "oth", "k"].some(p => p in k) || !((k.kty === "RSA" && typeof k.n === "string" && k.n && typeof k.e === "string" && k.e) || ((k.kty === "EC" || k.kty === "OKP") && typeof k.crv === "string" && k.crv && typeof k.x === "string" && k.x && (k.kty === "OKP" || (typeof k.y === "string" && k.y))))))
|
|
70
|
+
throw new IntegrationError("UNTRUSTED_METADATA");
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const TOOL_VERSION = "0.1.10";
|
|
2
|
+
export declare const ERROR_CODES: readonly ["INVALID_CONFIG", "ENVIRONMENT_MISMATCH", "INVALID_RETURN_TO", "INVALID_LOGIN_REQUEST", "LOGIN_UNAVAILABLE", "SIGN_OUT_UNCONFIRMED", "CENTER_UNAVAILABLE", "UNTRUSTED_METADATA", "DEPENDENCY_UNSUPPORTED", "IDENTITY_CONFLICT", "ACCOUNT_CONFLICT", "BUSINESS_RESOLUTION_FAILED"];
|
|
3
|
+
export type ErrorCode = typeof ERROR_CODES[number];
|
|
4
|
+
export declare class IntegrationError extends Error {
|
|
5
|
+
readonly code: ErrorCode;
|
|
6
|
+
constructor(code: ErrorCode);
|
|
7
|
+
}
|
|
8
|
+
export interface ReturnPolicy {
|
|
9
|
+
fallback: string;
|
|
10
|
+
allowedPaths: string[];
|
|
11
|
+
allowedPathPrefixes: string[];
|
|
12
|
+
allowedQueryKeys: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface Manifest {
|
|
15
|
+
schemaVersion: 1;
|
|
16
|
+
issuer: string;
|
|
17
|
+
platformOrigin: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
authBasePath: string;
|
|
20
|
+
clientSecretEnv: string;
|
|
21
|
+
sessionSecretEnv: string;
|
|
22
|
+
returnTo: ReturnPolicy;
|
|
23
|
+
errorPath: string;
|
|
24
|
+
}
|
|
25
|
+
export interface PublicConfig extends Manifest {
|
|
26
|
+
canonicalIssuer: string;
|
|
27
|
+
centerOrigin: string;
|
|
28
|
+
providerId: string;
|
|
29
|
+
callbackUrl: string;
|
|
30
|
+
discoveryUrl: string;
|
|
31
|
+
cookiePrefix: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SafeSession {
|
|
34
|
+
user: {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
email: string;
|
|
38
|
+
emailVerified: boolean;
|
|
39
|
+
};
|
|
40
|
+
expiresAt: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function record(value: unknown): Record<string, unknown>;
|
|
43
|
+
export declare function keys(value: Record<string, unknown>, allowed: readonly string[]): void;
|
|
44
|
+
export declare function canonicalizeIssuer(input: unknown): string;
|
|
45
|
+
export declare function validateOrigin(input: unknown): string;
|
|
46
|
+
export declare function validateAuthBasePath(input?: unknown): string;
|
|
47
|
+
export declare function validateReturnTo(value: unknown, config: Pick<Manifest, "returnTo" | "authBasePath">): string;
|
|
48
|
+
export declare function parseManifest(input: unknown): Manifest;
|
|
49
|
+
export declare function parseLoginRequest(input: unknown, config: Manifest): {
|
|
50
|
+
returnTo: string;
|
|
51
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export const TOOL_VERSION = "0.1.10";
|
|
2
|
+
export const ERROR_CODES = ["INVALID_CONFIG", "ENVIRONMENT_MISMATCH", "INVALID_RETURN_TO", "INVALID_LOGIN_REQUEST", "LOGIN_UNAVAILABLE", "SIGN_OUT_UNCONFIRMED", "CENTER_UNAVAILABLE", "UNTRUSTED_METADATA", "DEPENDENCY_UNSUPPORTED", "IDENTITY_CONFLICT", "ACCOUNT_CONFLICT", "BUSINESS_RESOLUTION_FAILED"];
|
|
3
|
+
export class IntegrationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code) {
|
|
6
|
+
super(code);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "IntegrationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const bad = () => { throw new IntegrationError("INVALID_CONFIG"); };
|
|
12
|
+
export function record(value) {
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
14
|
+
return bad();
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function keys(value, allowed) { if (Object.keys(value).some(k => !allowed.includes(k)))
|
|
18
|
+
bad(); }
|
|
19
|
+
const clean = (s, max) => typeof s === "string" && s.length > 0 && s.length <= max && !/[\s\u0000-\u001f\u007f\\]/u.test(s);
|
|
20
|
+
export function canonicalizeIssuer(input) {
|
|
21
|
+
if (!clean(input, 2048) || /[?#]/.test(input))
|
|
22
|
+
return bad();
|
|
23
|
+
const match = /^https:\/\/([^/]+)(.*)$/i.exec(input);
|
|
24
|
+
if (!match || match[1].includes("@"))
|
|
25
|
+
return bad();
|
|
26
|
+
const path = match[2];
|
|
27
|
+
if (path && path !== "/" && (!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(path) || path.split("/").some(x => x === "." || x === "..")))
|
|
28
|
+
return bad();
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(input);
|
|
31
|
+
if (u.protocol !== "https:" || !u.hostname || u.username || u.password)
|
|
32
|
+
return bad();
|
|
33
|
+
return u.origin + (path === "/" ? "" : path);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return bad();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function validateOrigin(input) {
|
|
40
|
+
if (!clean(input, 2048))
|
|
41
|
+
return bad();
|
|
42
|
+
try {
|
|
43
|
+
const u = new URL(input);
|
|
44
|
+
if (u.protocol !== "https:" || input !== u.origin || u.hostname === "localhost" || u.hostname.endsWith(".localhost") || /^127\./.test(u.hostname) || u.hostname === "[::1]")
|
|
45
|
+
return bad();
|
|
46
|
+
return input;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return bad();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function localPath(input) {
|
|
53
|
+
if (!clean(input, 2048) || !input.startsWith("/") || input.includes("//") || /[?#]/.test(input) || /%(?:2f|5c|25)/i.test(input))
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
return !input.split("/").some(s => { const p = decodeURIComponent(s); return p === "." || p === ".." || /[\u0000-\u001f\u007f\\/]/.test(p); });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function validateAuthBasePath(input = "/api/auth") {
|
|
63
|
+
if (!localPath(input) || input.length > 256 || input.endsWith("/"))
|
|
64
|
+
return bad();
|
|
65
|
+
return input;
|
|
66
|
+
}
|
|
67
|
+
const reserved = /^(?:code|state|token|secret|password|flow|authorization|cookie|session|nonce|access_token|refresh_token|id_token|client_secret|code_verifier|code_challenge|sessiontoken)$/i;
|
|
68
|
+
function strings(value) {
|
|
69
|
+
if (!Array.isArray(value) || value.some(s => typeof s !== "string") || new Set(value).size !== value.length)
|
|
70
|
+
return bad();
|
|
71
|
+
return [...value];
|
|
72
|
+
}
|
|
73
|
+
export function validateReturnTo(value, config) {
|
|
74
|
+
const fail = () => { throw new IntegrationError("INVALID_RETURN_TO"); };
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
value = config.returnTo.fallback;
|
|
77
|
+
if (!clean(value, 2048) || value.includes("#"))
|
|
78
|
+
return fail();
|
|
79
|
+
const split = value.indexOf("?");
|
|
80
|
+
const path = split < 0 ? value : value.slice(0, split);
|
|
81
|
+
if (!localPath(path) || path === config.authBasePath || path.startsWith(config.authBasePath + "/"))
|
|
82
|
+
return fail();
|
|
83
|
+
const p = config.returnTo;
|
|
84
|
+
if (!p.allowedPaths.includes(path) && !p.allowedPathPrefixes.some(prefix => prefix === "/" || path === prefix || path.startsWith(prefix + "/")))
|
|
85
|
+
return fail();
|
|
86
|
+
const query = new URLSearchParams(split < 0 ? "" : value.slice(split + 1));
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
for (const [k, v] of query) {
|
|
89
|
+
if (seen.has(k) || reserved.test(k) || !p.allowedQueryKeys.includes(k) || /[\u0000-\u001f\u007f]/.test(k + v))
|
|
90
|
+
return fail();
|
|
91
|
+
seen.add(k);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
export function parseManifest(input) {
|
|
96
|
+
const m = record(input);
|
|
97
|
+
keys(m, ["schemaVersion", "issuer", "platformOrigin", "clientId", "authBasePath", "clientSecretEnv", "sessionSecretEnv", "returnTo", "errorPath"]);
|
|
98
|
+
if (m.schemaVersion !== 1 || typeof m.clientId !== "string" || m.clientId.length < 1 || m.clientId.length > 256 || /[\u0000-\u001f\u007f]/.test(m.clientId) || !m.clientId.trim())
|
|
99
|
+
return bad();
|
|
100
|
+
const issuer = canonicalizeIssuer(m.issuer);
|
|
101
|
+
const platformOrigin = validateOrigin(m.platformOrigin);
|
|
102
|
+
const authBasePath = validateAuthBasePath(m.authBasePath);
|
|
103
|
+
for (const field of [m.clientSecretEnv, m.sessionSecretEnv])
|
|
104
|
+
if (typeof field !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(field))
|
|
105
|
+
return bad();
|
|
106
|
+
if (m.clientSecretEnv === m.sessionSecretEnv)
|
|
107
|
+
return bad();
|
|
108
|
+
const r = record(m.returnTo);
|
|
109
|
+
keys(r, ["fallback", "allowedPaths", "allowedPathPrefixes", "allowedQueryKeys"]);
|
|
110
|
+
const returnTo = { fallback: r.fallback, allowedPaths: strings(r.allowedPaths ?? []), allowedPathPrefixes: strings(r.allowedPathPrefixes ?? []), allowedQueryKeys: strings(r.allowedQueryKeys ?? []) };
|
|
111
|
+
if (!returnTo.allowedPaths.length && !returnTo.allowedPathPrefixes.length)
|
|
112
|
+
return bad();
|
|
113
|
+
for (const path of [...returnTo.allowedPaths, ...returnTo.allowedPathPrefixes])
|
|
114
|
+
if (!localPath(path) || (path !== "/" && path.endsWith("/")))
|
|
115
|
+
return bad();
|
|
116
|
+
for (const key of returnTo.allowedQueryKeys)
|
|
117
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key) || reserved.test(key))
|
|
118
|
+
return bad();
|
|
119
|
+
if (!localPath(m.errorPath) || m.errorPath === authBasePath || m.errorPath.startsWith(authBasePath + "/"))
|
|
120
|
+
return bad();
|
|
121
|
+
const manifest = { schemaVersion: 1, issuer, platformOrigin, clientId: m.clientId, authBasePath, clientSecretEnv: m.clientSecretEnv, sessionSecretEnv: m.sessionSecretEnv, returnTo, errorPath: m.errorPath };
|
|
122
|
+
try {
|
|
123
|
+
validateReturnTo(returnTo.fallback, manifest);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return bad();
|
|
127
|
+
}
|
|
128
|
+
return manifest;
|
|
129
|
+
}
|
|
130
|
+
export function parseLoginRequest(input, config) {
|
|
131
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "returnTo") || ("returnTo" in input && typeof input.returnTo !== "string"))
|
|
132
|
+
throw new IntegrationError("INVALID_LOGIN_REQUEST");
|
|
133
|
+
return { returnTo: validateReturnTo(input.returnTo, config) };
|
|
134
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type PublicConfig, type SafeSession } from "../../integration-contracts/src/public.js";
|
|
2
|
+
export declare function createUserCenterClient(publicConfig: PublicConfig): {
|
|
3
|
+
startLogin(input?: {
|
|
4
|
+
returnTo?: string;
|
|
5
|
+
}): Promise<{
|
|
6
|
+
url: string;
|
|
7
|
+
}>;
|
|
8
|
+
getSession(): Promise<SafeSession | null>;
|
|
9
|
+
signOut(): Promise<void>;
|
|
10
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { IntegrationError, parseManifest, parseLoginRequest } from "../../integration-contracts/src/public.js";
|
|
2
|
+
export function createUserCenterClient(publicConfig) {
|
|
3
|
+
const config = parseManifest({ schemaVersion: publicConfig.schemaVersion, issuer: publicConfig.issuer, platformOrigin: publicConfig.platformOrigin, clientId: publicConfig.clientId, authBasePath: publicConfig.authBasePath, clientSecretEnv: publicConfig.clientSecretEnv, sessionSecretEnv: publicConfig.sessionSecretEnv, returnTo: publicConfig.returnTo, errorPath: publicConfig.errorPath });
|
|
4
|
+
async function call(path, method, code, body) {
|
|
5
|
+
if (globalThis.location?.origin !== config.platformOrigin)
|
|
6
|
+
throw new IntegrationError(code);
|
|
7
|
+
try {
|
|
8
|
+
const r = await fetch(config.authBasePath + path, { method, credentials: "same-origin", cache: "no-store", redirect: "error", headers: body === undefined ? {} : { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
9
|
+
if (!r.ok)
|
|
10
|
+
throw new IntegrationError(code);
|
|
11
|
+
return await r.json();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new IntegrationError(code);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
async startLogin(input = {}) {
|
|
19
|
+
const body = parseLoginRequest(input, config);
|
|
20
|
+
const data = await call("/sign-in/social", "POST", "LOGIN_UNAVAILABLE", body);
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(data.url);
|
|
23
|
+
const issuer = new URL(config.issuer);
|
|
24
|
+
if (url.origin !== issuer.origin || url.pathname !== issuer.pathname.replace(/\/$/, "") + "/oauth2/authorize")
|
|
25
|
+
throw new Error();
|
|
26
|
+
return { url: url.href };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new IntegrationError("LOGIN_UNAVAILABLE");
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async getSession() { return await call("/get-session", "GET", "CENTER_UNAVAILABLE"); },
|
|
33
|
+
async signOut() { const data = await call("/sign-out", "POST", "SIGN_OUT_UNCONFIRMED", {}); if (data?.success !== true)
|
|
34
|
+
throw new IntegrationError("SIGN_OUT_UNCONFIRMED"); },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../../integration-contracts/src/config.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../../integration-contracts/src/public.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../../integration-contracts/src/public.js";
|