Skip to content

Commit 3827a13

Browse files
committed
Merge branch 'develop-prod' into develop
2 parents 57d3a6d + ee883ba commit 3827a13

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+6349
-2110
lines changed

Dockerfile.prod-test

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM node:22-bookworm-slim
2+
3+
WORKDIR /app
4+
5+
ENV PNPM_HOME=/pnpm
6+
ENV PATH=$PNPM_HOME:$PATH
7+
8+
RUN corepack enable && corepack prepare pnpm@10.14.0 --activate
9+
10+
COPY . .
11+
12+
RUN pnpm install --frozen-lockfile
13+
RUN pnpm build:all
14+
15+
EXPOSE 3000 3010
16+
17+
CMD ["pnpm", "start:prod"]

__tests__/auth/managed-sso.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
buildManagedCasLoginUrl,
3+
buildManagedCasValidateUrl,
4+
parseCasServiceResponse,
5+
resolveManagedCasProfile,
6+
toManagedCasProviderConfig,
7+
toPublicManagedSsoProvider,
8+
} from '@lib/auth/managed-sso';
9+
import { SsoProvider } from '@lib/types/database';
10+
11+
function createCasProvider(overrides?: Partial<SsoProvider>): SsoProvider {
12+
return {
13+
id: '11111111-1111-4111-8111-111111111111',
14+
name: 'BISTU CAS',
15+
protocol: 'CAS',
16+
settings: {
17+
email_domain: 'bistu.edu.cn',
18+
protocol_config: {
19+
base_url: 'https://sso.bistu.edu.cn',
20+
endpoints: {
21+
login: '/login',
22+
logout: '/logout',
23+
validate: '/serviceValidate',
24+
validate_v3: '/p3/serviceValidate',
25+
},
26+
attributes_mapping: {
27+
employee_id: 'cas:user',
28+
username: 'log_username',
29+
full_name: 'cas:name',
30+
email: 'mail',
31+
},
32+
},
33+
security: {
34+
allowed_redirect_hosts: ['bistu.edu.cn'],
35+
},
36+
ui: {
37+
icon: '🏛️',
38+
description: '北京信息科技大学统一认证系统',
39+
},
40+
},
41+
client_id: null,
42+
client_secret: null,
43+
metadata_url: null,
44+
enabled: true,
45+
display_order: 0,
46+
button_text: '北信科统一认证',
47+
created_at: '2026-03-08T00:00:00.000Z',
48+
updated_at: '2026-03-08T00:00:00.000Z',
49+
...overrides,
50+
};
51+
}
52+
53+
describe('managed sso helpers', () => {
54+
it('maps CAS provider into public login provider', () => {
55+
const provider = toPublicManagedSsoProvider(createCasProvider());
56+
57+
expect(provider).toEqual(
58+
expect.objectContaining({
59+
providerId: '11111111-1111-4111-8111-111111111111',
60+
authFlow: 'managed-cas',
61+
mode: 'managed-cas',
62+
icon: '🏛️',
63+
displayName: '北信科统一认证',
64+
domain: 'bistu.edu.cn',
65+
})
66+
);
67+
});
68+
69+
it('parses CAS service response and resolves profile', () => {
70+
const xml = `
71+
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
72+
<cas:authenticationSuccess>
73+
<cas:user>20260001</cas:user>
74+
<cas:attributes>
75+
<cas:log_username>zhangsan</cas:log_username>
76+
<cas:name>张三</cas:name>
77+
<cas:employeeNumber>20260001</cas:employeeNumber>
78+
</cas:attributes>
79+
</cas:authenticationSuccess>
80+
</cas:serviceResponse>
81+
`;
82+
83+
const parsed = parseCasServiceResponse(xml);
84+
const config = toManagedCasProviderConfig(createCasProvider());
85+
expect(config).not.toBeNull();
86+
87+
const profile = resolveManagedCasProfile(config!, parsed);
88+
expect(parsed.success).toBe(true);
89+
expect(parsed.user).toBe('20260001');
90+
expect(profile).toEqual(
91+
expect.objectContaining({
92+
subject: '20260001',
93+
username: 'zhangsan',
94+
fullName: '张三',
95+
email: '20260001@bistu.edu.cn',
96+
employeeNumber: '20260001',
97+
})
98+
);
99+
});
100+
101+
it('builds CAS login and validate URLs with stable service', () => {
102+
const config = toManagedCasProviderConfig(createCasProvider());
103+
expect(config).not.toBeNull();
104+
105+
const serviceUrl =
106+
'https://agent.bistu.edu.cn/api/sso/11111111-1111-4111-8111-111111111111/callback?returnUrl=%2Fchat';
107+
108+
expect(buildManagedCasLoginUrl(config!, serviceUrl)).toBe(
109+
'https://sso.bistu.edu.cn/login?service=https%3A%2F%2Fagent.bistu.edu.cn%2Fapi%2Fsso%2F11111111-1111-4111-8111-111111111111%2Fcallback%3FreturnUrl%3D%252Fchat'
110+
);
111+
expect(buildManagedCasValidateUrl(config!, serviceUrl, 'ST-123')).toBe(
112+
'https://sso.bistu.edu.cn/p3/serviceValidate?service=https%3A%2F%2Fagent.bistu.edu.cn%2Fapi%2Fsso%2F11111111-1111-4111-8111-111111111111%2Fcallback%3FreturnUrl%3D%252Fchat&ticket=ST-123'
113+
);
114+
});
115+
});

app/api/auth/sso/providers/route.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
import { getPublicSsoProviders } from '@lib/auth/better-auth/server';
2+
import {
3+
PublicLoginSsoProvider,
4+
toPublicManagedSsoProvider,
5+
} from '@lib/auth/managed-sso';
6+
import { listManagedSsoProvidersForLogin } from '@lib/auth/managed-sso-server';
27
import { nextApiErrorResponse } from '@lib/errors/next-api-error-response';
38

49
import { NextResponse } from 'next/server';
510

611
export async function GET(request: Request) {
712
try {
13+
const managedProviders = (await listManagedSsoProvidersForLogin())
14+
.map(toPublicManagedSsoProvider)
15+
.filter(
16+
(provider): provider is PublicLoginSsoProvider => provider !== null
17+
);
18+
19+
const runtimeProviders: PublicLoginSsoProvider[] =
20+
getPublicSsoProviders().map(provider => ({
21+
...provider,
22+
authFlow: 'better-auth',
23+
}));
24+
825
return NextResponse.json({
9-
providers: getPublicSsoProviders(),
26+
providers: [...managedProviders, ...runtimeProviders],
1027
success: true,
1128
});
1229
} catch (error) {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { resolveSessionIdentityReadOnly } from '@lib/auth/better-auth/session-identity';
2+
import {
3+
REQUEST_ID_HEADER,
4+
buildAppErrorDetail,
5+
buildAppErrorEnvelope,
6+
resolveRequestId,
7+
} from '@lib/errors/app-error';
8+
import { recordErrorEvent } from '@lib/server/errors/error-events';
9+
10+
import { NextResponse } from 'next/server';
11+
12+
export const runtime = 'nodejs';
13+
14+
type PayloadObject = Record<string, unknown>;
15+
16+
type SupportedSeverity = 'info' | 'warn' | 'error' | 'critical';
17+
18+
function readString(value: unknown): string | null {
19+
if (typeof value !== 'string') {
20+
return null;
21+
}
22+
23+
const normalized = value.trim();
24+
return normalized.length > 0 ? normalized.slice(0, 4000) : null;
25+
}
26+
27+
function readBoolean(value: unknown, fallbackValue: boolean): boolean {
28+
if (typeof value === 'boolean') {
29+
return value;
30+
}
31+
return fallbackValue;
32+
}
33+
34+
function readNumber(value: unknown): number | null {
35+
if (typeof value !== 'number' || !Number.isFinite(value)) {
36+
return null;
37+
}
38+
return value;
39+
}
40+
41+
function readObject(value: unknown): PayloadObject {
42+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
43+
return {};
44+
}
45+
return value as PayloadObject;
46+
}
47+
48+
function readSeverity(value: unknown): SupportedSeverity {
49+
const normalized = readString(value)?.toLowerCase();
50+
if (
51+
normalized === 'info' ||
52+
normalized === 'warn' ||
53+
normalized === 'error' ||
54+
normalized === 'critical'
55+
) {
56+
return normalized;
57+
}
58+
return 'error';
59+
}
60+
61+
function buildErrorResponse(
62+
request: Request,
63+
input: {
64+
status: number;
65+
code: string;
66+
userMessage: string;
67+
developerMessage: string;
68+
}
69+
) {
70+
const requestId = resolveRequestId(request);
71+
const detail = buildAppErrorDetail({
72+
status: input.status,
73+
source: 'next-api',
74+
code: input.code,
75+
userMessage: input.userMessage,
76+
developerMessage: input.developerMessage,
77+
requestId,
78+
});
79+
80+
const response = NextResponse.json(
81+
buildAppErrorEnvelope(detail, input.userMessage),
82+
{
83+
status: input.status,
84+
headers: {
85+
'Cache-Control': 'no-store',
86+
},
87+
}
88+
);
89+
response.headers.set(REQUEST_ID_HEADER, requestId);
90+
return response;
91+
}
92+
93+
export async function POST(request: Request) {
94+
const fallbackRequestId = resolveRequestId(request);
95+
96+
try {
97+
const rawPayload = await request.json();
98+
const payload = readObject(rawPayload);
99+
if (Object.keys(payload).length === 0) {
100+
return buildErrorResponse(request, {
101+
status: 400,
102+
code: 'CLIENT_ERROR_REPORT_INVALID_PAYLOAD',
103+
userMessage: 'Invalid client error payload',
104+
developerMessage: 'Payload is missing or not an object',
105+
});
106+
}
107+
108+
const userMessage =
109+
readString(payload.userMessage) ||
110+
readString(payload.message) ||
111+
'Unexpected client error';
112+
const developerMessage =
113+
readString(payload.developerMessage) || readString(payload.message);
114+
const requestId = readString(payload.requestId) || fallbackRequestId;
115+
const context = readObject(payload.context);
116+
const route =
117+
readString(payload.route) ||
118+
readString(context.pathname) ||
119+
readString(context.href) ||
120+
'/client';
121+
122+
let actorUserId: string | undefined;
123+
try {
124+
const resolvedIdentity = await resolveSessionIdentityReadOnly(
125+
request.headers
126+
);
127+
if (resolvedIdentity.success && resolvedIdentity.data?.userId) {
128+
actorUserId = resolvedIdentity.data.userId;
129+
}
130+
} catch (identityError) {
131+
console.warn(
132+
'[ClientErrorReport] failed to resolve session identity:',
133+
identityError instanceof Error
134+
? identityError.message
135+
: String(identityError)
136+
);
137+
}
138+
139+
await recordErrorEvent({
140+
code: readString(payload.code) || 'CLIENT_RUNTIME_ERROR',
141+
source: 'frontend',
142+
severity: readSeverity(payload.severity),
143+
retryable: readBoolean(payload.retryable, true),
144+
userMessage,
145+
developerMessage: developerMessage || undefined,
146+
requestId,
147+
traceId: readString(payload.traceId) || undefined,
148+
actorUserId,
149+
httpStatus: readNumber(payload.httpStatus) || undefined,
150+
method: readString(payload.method) || 'CLIENT',
151+
route,
152+
context: {
153+
...context,
154+
report_origin: 'browser',
155+
server_received_at: new Date().toISOString(),
156+
reporter_user_agent:
157+
request.headers.get('user-agent')?.slice(0, 1000) || null,
158+
},
159+
});
160+
161+
const response = NextResponse.json(
162+
{
163+
success: true,
164+
request_id: requestId,
165+
},
166+
{
167+
status: 202,
168+
headers: {
169+
'Cache-Control': 'no-store',
170+
},
171+
}
172+
);
173+
response.headers.set(REQUEST_ID_HEADER, requestId);
174+
return response;
175+
} catch (error) {
176+
console.error('[ClientErrorReport] failed to record client error:', error);
177+
return buildErrorResponse(request, {
178+
status: 500,
179+
code: 'CLIENT_ERROR_REPORT_FAILED',
180+
userMessage: 'Failed to record client error',
181+
developerMessage:
182+
error instanceof Error ? error.message : 'Unknown client error report failure',
183+
});
184+
}
185+
}

0 commit comments

Comments
 (0)