@oj-bin/oj-aarch64-apple-darwin 0.1.13
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/README.md +52 -0
- package/devkit/README.md +29 -0
- package/devkit/SKILL.md +84 -0
- package/devkit/api-manual.md +1628 -0
- package/devkit/global.d.ts +332 -0
- package/devkit/oidc-implementation.md +177 -0
- package/devkit/oidc-integration.md +168 -0
- package/oj +0 -0
- package/package.json +10 -0
- package/plugins/aarch64-apple-darwin/libauth.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libblob-s3.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libbus-kafka.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libbus-rabbitmq.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libdb-mysql.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libdb-postgres.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libes.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libkv-redis.dylib +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// .route 声明的 TS 支持(编辑器不报错;dev server 不依赖此文件运行)。
|
|
2
|
+
//
|
|
3
|
+
// 下面为 src/bridge/bootstrap.js 在运行时注入的全局对象提供类型声明,
|
|
4
|
+
// 使 sample/src 中的业务代码在编辑器内不报 TS 错误。
|
|
5
|
+
//
|
|
6
|
+
// ext_boot.js(config.yaml 同目录,可选)在运行时补充的全局**不在本文件声明** ——
|
|
7
|
+
// 它们是项目自定义的,框架无法预知。用 ext_boot.js 增补了全局(如 `json.page()`)后,
|
|
8
|
+
// 在业务项目里另建一个 .d.ts 自行声明(并加进 tsconfig 的 include),否则编辑器报
|
|
9
|
+
// TS2339 "Property 'page' does not exist"。运行时不受影响——类型只影响编辑器与 tsc。
|
|
10
|
+
|
|
11
|
+
// JSON 反序列化后可能出现的值(SQL 行 / KV / fetch body 等)。
|
|
12
|
+
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
|
|
13
|
+
|
|
14
|
+
// 数据库查询返回的一行(列名 -> 值)。
|
|
15
|
+
type Row = Record<string, Json>;
|
|
16
|
+
|
|
17
|
+
// db.table(...).where(cond) 的单个条件。
|
|
18
|
+
interface WhereCond {
|
|
19
|
+
field: string;
|
|
20
|
+
op?: string; // eq/neq/gt/gte/lt/lte/like/in/... 依服务端支持
|
|
21
|
+
value?: unknown;
|
|
22
|
+
and?: WhereCond[];
|
|
23
|
+
or?: WhereCond[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// db.table(...).orderBy(items) 的单个排序项。
|
|
27
|
+
interface OrderByItem {
|
|
28
|
+
field: string;
|
|
29
|
+
dir?: "asc" | "desc" | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// db.table(name) 返回的安全查询构造器(流式、结构化)。
|
|
33
|
+
interface QueryBuilder {
|
|
34
|
+
select(cols?: string[]): QueryBuilder;
|
|
35
|
+
where(cond: WhereCond): QueryBuilder;
|
|
36
|
+
orderBy(items?: OrderByItem[]): QueryBuilder;
|
|
37
|
+
limit(n: number): QueryBuilder;
|
|
38
|
+
offset(n: number): QueryBuilder;
|
|
39
|
+
all(): Promise<Json[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// DB(name) 返回的命名数据库实例。
|
|
43
|
+
interface DBInstance {
|
|
44
|
+
// 原始 SQL + 绑定参数(params 可选)。返回行数组。
|
|
45
|
+
query(sql: string, params?: unknown[]): Promise<Row[]>;
|
|
46
|
+
// 执行写操作,返回受影响行数。
|
|
47
|
+
exec(sql: string, params?: unknown[]): Promise<number>;
|
|
48
|
+
// 安全查询构造器(标识符白名单 + 参数化值)。
|
|
49
|
+
table(name: string): QueryBuilder;
|
|
50
|
+
// 事务:回调 resolve 提交 / throw 回滚再抛;tx.query/exec/table 同签名走同一连接。
|
|
51
|
+
// 每请求至多一个活跃事务(嵌套报错);请求结束未完结自动回滚。
|
|
52
|
+
tx(fn: (tx: DBInstance) => unknown): Promise<unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// json.* :统一响应信封 + 响应头。
|
|
56
|
+
interface JsonApi {
|
|
57
|
+
ok(data?: unknown): void;
|
|
58
|
+
fail(code: number, msg: string, data?: unknown): void;
|
|
59
|
+
header(name: string, value: string): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// http.* :当前请求上下文(只读,懒加载,per-request 最新)。
|
|
63
|
+
interface HttpApi {
|
|
64
|
+
method: string;
|
|
65
|
+
params: Record<string, string>;
|
|
66
|
+
query: Record<string, string>;
|
|
67
|
+
headers: Record<string, string>;
|
|
68
|
+
body: any;
|
|
69
|
+
// 取路由参数或 query 参数:路径参数优先,query 兜底,均缺失返回 def 原值。
|
|
70
|
+
param(name: string, def?: unknown): any;
|
|
71
|
+
// 租户 id(tenant 启用时从租户头提取;未启用为 null)。
|
|
72
|
+
tenantId: string | null;
|
|
73
|
+
// 已验签用户(auth 启用且通过 Bearer 守卫;否则 null)。
|
|
74
|
+
user: AuthUser | null;
|
|
75
|
+
// multipart 上传文件元信息(非 multipart 为空数组)。
|
|
76
|
+
files: UploadedFileMeta[];
|
|
77
|
+
// 取第 i 个上传文件的字节(越界报错 no such file)。
|
|
78
|
+
file(i: number): Promise<Uint8Array>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 已验签用户(JWT claims)。
|
|
82
|
+
interface AuthUser {
|
|
83
|
+
id: string | number;
|
|
84
|
+
roles: string[];
|
|
85
|
+
claims: Record<string, Json>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// multipart 上传文件元信息。
|
|
89
|
+
interface UploadedFileMeta {
|
|
90
|
+
field: string;
|
|
91
|
+
filename: string;
|
|
92
|
+
content_type: string;
|
|
93
|
+
size: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// log.* :结构化日志(zap SugaredLogger 风格:msg + 交替键值对)。
|
|
97
|
+
interface Logger {
|
|
98
|
+
debug(msg: string, ...kv: unknown[]): void;
|
|
99
|
+
info(msg: string, ...kv: unknown[]): void;
|
|
100
|
+
warn(msg: string, ...kv: unknown[]): void;
|
|
101
|
+
error(msg: string, ...kv: unknown[]): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// redis.* / kv.* :KV 存储(redis.default 配置即真 Redis,否则进程内存 KV)。
|
|
105
|
+
interface KVApi {
|
|
106
|
+
get(key: string): Promise<string | null>;
|
|
107
|
+
set(key: string, value: string): Promise<boolean>;
|
|
108
|
+
del(key: string): Promise<boolean>;
|
|
109
|
+
// 设过期(秒)。真 Redis 走 EXPIRE;内存 KV 惰性过期。
|
|
110
|
+
expire(key: string, ttlSec: number): Promise<boolean>;
|
|
111
|
+
// 自增返回新值(键不存在从 0 起)。
|
|
112
|
+
incr(key: string): Promise<number>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ws.* :WebSocket 生命周期钩子内的主动发送/关闭控制(HTTP 路径 no-op)。
|
|
116
|
+
interface WSApi {
|
|
117
|
+
send(data: string): void;
|
|
118
|
+
close(): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// blob.* :对象存储(可调用取命名实例:blob("media").put(...);裸调用 blob.put(...) 等价 default)。
|
|
122
|
+
interface BlobApi {
|
|
123
|
+
(name?: string): BlobApi;
|
|
124
|
+
put(key: string, bytes: Uint8Array, contentType?: string): Promise<boolean>;
|
|
125
|
+
get(key: string): Promise<Uint8Array>;
|
|
126
|
+
// 幂等:不存在视为成功。
|
|
127
|
+
del(key: string): Promise<boolean>;
|
|
128
|
+
// local = {base}/blob/{key};s3 = presigned URL(15min)。
|
|
129
|
+
url(key: string): Promise<string>;
|
|
130
|
+
// local 缺失 sidecar 且无法按扩展名推断时返回空串;s3 无 Content-Type 时返回 null。
|
|
131
|
+
contentType(key: string): Promise<string | null>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// bus.* :主题广播。publish 广播给订阅 topic 的全部 WS 会话,返回接收方数;
|
|
135
|
+
// subscribe 仅 WS 会话内可用(HTTP 路径报错);kind 报告活跃 broker 类型。
|
|
136
|
+
interface BusApi {
|
|
137
|
+
publish(topic: string, data?: unknown): Promise<number>;
|
|
138
|
+
subscribe(topic: string): Promise<void>;
|
|
139
|
+
kind(): Promise<string>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// cert.* :JWS 证书生成/重签(RSA + RS256 在 Rust 侧;纯内存,不落盘)。
|
|
143
|
+
interface CertApi {
|
|
144
|
+
// 生成密钥对并签发 JWS。bits >= 2048;nbf/exp 为 Unix 秒,exp 必须 > nbf。
|
|
145
|
+
generate(
|
|
146
|
+
bits: number,
|
|
147
|
+
nbf: number,
|
|
148
|
+
exp: number,
|
|
149
|
+
): Promise<{ private_pem: string; public_pem: string; cert_jws: string }>;
|
|
150
|
+
// 用现有 PKCS#8 私钥重签续期(公钥不变),返回新 cert.jws 串。
|
|
151
|
+
renew(privatePem: string, nbf: number, exp: number): Promise<string>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// es.* :Elasticsearch 薄客户端(直通 ES 响应体;未配置调用报 es not configured)。
|
|
155
|
+
interface EsApi {
|
|
156
|
+
search(index: string, dsl?: unknown): Promise<Json>;
|
|
157
|
+
index(index: string, id: string, doc?: unknown): Promise<Json>;
|
|
158
|
+
del(index: string, id: string): Promise<Json>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// jwt.* :JWT 签发/验签(secret/alg/durations 由 config auth: 注入;未配置调用报
|
|
162
|
+
// jwt not configured)。iat/exp 由 Rust 补(JS 不可控有效期),exp 取 access 时长。
|
|
163
|
+
interface JwtApi {
|
|
164
|
+
// payload 至少含 sub;roles 可选(缺省空数组)。返回签名 token。
|
|
165
|
+
sign(payload: { sub: string; roles?: string[] }): string;
|
|
166
|
+
// 验签 + exp(leeway 0);篡改/过期/算法不符直接抛错。
|
|
167
|
+
verify(token: string): { sub: string; roles: string[]; iat: number; exp: number };
|
|
168
|
+
// 配置的有效期(秒;getter 惰性求值)。
|
|
169
|
+
readonly accessDuration: number;
|
|
170
|
+
readonly refreshDuration: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// oidc.* :内置 OP/RP(RS256 私钥只在 Rust 侧;issuer/rp/clients 由 config oidc: 注入;
|
|
174
|
+
// 未配置段调用报 oidc not configured)。verify 传 jwks 时按其 RS256 kid 匹配验签,
|
|
175
|
+
// 不传(或 null)回落本地私钥;篡改/过期/无匹配钥直接抛错。
|
|
176
|
+
interface OidcApi {
|
|
177
|
+
// claims 至少含 iss/sub/aud/iat/exp。返回签名 token。
|
|
178
|
+
sign(claims: Record<string, unknown>): string;
|
|
179
|
+
// 验签 + exp;jwks 可选(缺省用本地钥)。
|
|
180
|
+
verify(token: string, jwks?: unknown): Record<string, unknown>;
|
|
181
|
+
// 本地公钥 JWKS(keys[0].kid/kty/alg/n/e)。
|
|
182
|
+
jwks(): unknown;
|
|
183
|
+
// config oidc.issuer(getter 惰性求值)。
|
|
184
|
+
readonly issuer: string;
|
|
185
|
+
// RP 侧 tenant → IdP 配置。
|
|
186
|
+
readonly rp: Record<string, { issuer: string; client_id: string; client_secret: string; scope: string }>;
|
|
187
|
+
// OP 侧客户端白名单。
|
|
188
|
+
readonly clients: Record<string, { secret: string; redirect_uris: string[]; tenant: string }>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// bcrypt.* :密码哈希(Rust 侧 spawn_blocking,CPU 密集不卡 isolate)。
|
|
192
|
+
interface BcryptApi {
|
|
193
|
+
hash(password: string, cost?: number): Promise<string>;
|
|
194
|
+
// 非法 hash 返回 false(不抛错)。
|
|
195
|
+
verify(password: string, hash: string): Promise<boolean>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
declare global {
|
|
199
|
+
interface Function {
|
|
200
|
+
route?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const json: JsonApi;
|
|
204
|
+
const http: HttpApi;
|
|
205
|
+
const log: Logger;
|
|
206
|
+
const kv: KVApi;
|
|
207
|
+
const redis: KVApi;
|
|
208
|
+
const ws: WSApi;
|
|
209
|
+
const blob: BlobApi;
|
|
210
|
+
const bus: BusApi;
|
|
211
|
+
const es: EsApi;
|
|
212
|
+
|
|
213
|
+
// 命名数据库实例;未配置的名字返回 undefined。
|
|
214
|
+
function DB(name: string): DBInstance | undefined;
|
|
215
|
+
// 默认("default")数据库实例。
|
|
216
|
+
const db: DBInstance;
|
|
217
|
+
const cert: CertApi;
|
|
218
|
+
const jwt: JwtApi;
|
|
219
|
+
const oidc: OidcApi;
|
|
220
|
+
const bcrypt: BcryptApi;
|
|
221
|
+
|
|
222
|
+
// crypto 增补(bootstrap 对原生 crypto 做 Object.assign 合并,原生成员保留):
|
|
223
|
+
// sha256Hex = 十六进制摘要;randomHex = nBytes 字节随机数的 hex(默认 32 字节)。
|
|
224
|
+
interface Crypto {
|
|
225
|
+
sha256Hex(s: string): string;
|
|
226
|
+
randomHex(nBytes?: number): string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- oj test L1 测试 SDK(oj test 运行时注入;仅测试文件使用) ----
|
|
230
|
+
// 进程内 HTTP 派发助手,对标 Go Fiber app.Test:client.get/post/... 触发真实
|
|
231
|
+
// 路由 + 真实运行时 + 真实后端(零 TCP)。path 为相对 base 的路径(如 "/user/account")。
|
|
232
|
+
interface ClientResp {
|
|
233
|
+
status: number;
|
|
234
|
+
headers: Record<string, string>;
|
|
235
|
+
body: string;
|
|
236
|
+
upgrade: boolean;
|
|
237
|
+
}
|
|
238
|
+
interface ClientOptions {
|
|
239
|
+
headers?: Record<string, string>;
|
|
240
|
+
body?: string;
|
|
241
|
+
}
|
|
242
|
+
interface Client {
|
|
243
|
+
get(path: string, opts?: ClientOptions): Promise<ClientResp>;
|
|
244
|
+
post(path: string, opts?: ClientOptions): Promise<ClientResp>;
|
|
245
|
+
put(path: string, opts?: ClientOptions): Promise<ClientResp>;
|
|
246
|
+
del(path: string, opts?: ClientOptions): Promise<ClientResp>;
|
|
247
|
+
patch(path: string, opts?: ClientOptions): Promise<ClientResp>;
|
|
248
|
+
head(path: string, opts?: ClientOptions): Promise<ClientResp>;
|
|
249
|
+
options(path: string, opts?: ClientOptions): Promise<ClientResp>;
|
|
250
|
+
// 登录助手:POST /auth/login → 返回 access_token(失败抛错)。headers 透传(如租户头)。
|
|
251
|
+
login(username: string, password: string, headers?: Record<string, string>): Promise<string>;
|
|
252
|
+
}
|
|
253
|
+
const client: Client;
|
|
254
|
+
|
|
255
|
+
// 轻量测试框架(vitest 风格子集):describe/it/expect/beforeEach。
|
|
256
|
+
function describe(name: string, fn: () => void): void;
|
|
257
|
+
function it(name: string, fn: () => void | Promise<void>): void;
|
|
258
|
+
function beforeEach(fn: () => void | Promise<void>): void;
|
|
259
|
+
function expect(actual: unknown): {
|
|
260
|
+
toBe(e: unknown): void;
|
|
261
|
+
toEqual(e: unknown): void;
|
|
262
|
+
toBeTruthy(): void;
|
|
263
|
+
toBeFalsy(): void;
|
|
264
|
+
toContain(sub: unknown): void;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// 标记会话结束。
|
|
268
|
+
function finish(): void;
|
|
269
|
+
// CJS 同步 require(eval + 进程级缓存)。
|
|
270
|
+
function __ojRequire(name: string, referrerPath?: string): any;
|
|
271
|
+
// 浏览器兼容的 fetch(url 必填,options 同 RequestInit 的常用子集)。
|
|
272
|
+
function fetch(url: string, options?: {
|
|
273
|
+
method?: string;
|
|
274
|
+
headers?: Record<string, string>;
|
|
275
|
+
body?: string | null;
|
|
276
|
+
}): Promise<OjFetchResponse>;
|
|
277
|
+
// 已加载插件自省:[{name, semver, abi_version, fingerprint, host_abi_version}]。
|
|
278
|
+
function plugins(): any[];
|
|
279
|
+
|
|
280
|
+
// 命名 MQ 客户端(config kafkas:/rabbits: 段;未配置的名 → undefined)。
|
|
281
|
+
// 消费方法(poll/commit/ack/nack)仅长任务上下文可用(HTTP/WS 内调用报错)。
|
|
282
|
+
function Kafka(name: string): OjKafkaClient | undefined;
|
|
283
|
+
function RabbitMQ(name: string): OjRabbitClient | undefined;
|
|
284
|
+
// 长任务上下文(对象全局):stopping = 停机信号(HTTP/WS 上下文恒 false);
|
|
285
|
+
// sleep = 等待原语(本运行时无 timer 全局,setTimeout 不可用)。
|
|
286
|
+
const tasks: {
|
|
287
|
+
stopping(): boolean;
|
|
288
|
+
sleep(ms: number): Promise<void>;
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface OjMqMessage {
|
|
293
|
+
topic: string;
|
|
294
|
+
partition?: number;
|
|
295
|
+
offset?: number;
|
|
296
|
+
key?: string;
|
|
297
|
+
value: any;
|
|
298
|
+
headers?: Record<string, string>;
|
|
299
|
+
ts?: number;
|
|
300
|
+
delivery_tag?: number; // rabbit 专属:ack/nack 载荷原样回传
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface OjKafkaClient {
|
|
304
|
+
kind(): Promise<string>;
|
|
305
|
+
send(topic: string, o: { key?: string; partition?: number; headers?: Record<string, string>; value: any }): Promise<{ sent: number }>;
|
|
306
|
+
poll(topics: string[], o?: { max?: number; timeoutMs?: number }): Promise<{ messages: OjMqMessage[] }>;
|
|
307
|
+
commit(m: OjMqMessage): Promise<Record<string, never>>;
|
|
308
|
+
metadata(): Promise<any>;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface OjRabbitClient {
|
|
312
|
+
kind(): Promise<string>;
|
|
313
|
+
publish(exchange: string, routingKey: string, value: any, o?: { headers?: Record<string, string> }): Promise<{ sent: number }>;
|
|
314
|
+
poll(queues: string[], o?: { max?: number; timeoutMs?: number }): Promise<{ messages: OjMqMessage[] }>;
|
|
315
|
+
ack(m: OjMqMessage): Promise<Record<string, never>>;
|
|
316
|
+
nack(m: OjMqMessage, requeue?: boolean): Promise<Record<string, never>>;
|
|
317
|
+
metadata(): Promise<any>;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// fetch 返回的 Response(浏览器风格子集)。
|
|
321
|
+
interface OjFetchResponse {
|
|
322
|
+
ok: boolean;
|
|
323
|
+
status: number;
|
|
324
|
+
statusText: string;
|
|
325
|
+
headers: Record<string, string>;
|
|
326
|
+
json(): Promise<Json | null>;
|
|
327
|
+
text(): Promise<string>;
|
|
328
|
+
arrayBuffer(): Promise<Uint8Array>;
|
|
329
|
+
clone(): OjFetchResponse;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# OIDC 实现文档(OP + RP 双角色)
|
|
2
|
+
|
|
3
|
+
> 实现走读:sample 内置 OIDC 的架构、时序与数据流。接入视角见
|
|
4
|
+
> [oidc-integration.md](oidc-integration.md),设计裁决见
|
|
5
|
+
> `docs/superpowers/specs/2026-09-05-sample-oidc-design.md`。
|
|
6
|
+
|
|
7
|
+
## 1. 总体架构
|
|
8
|
+
|
|
9
|
+
同进程双角色:`src/idp/` 是 OP(OIDC Provider,签发身份),`src/oidc/` 是 RP
|
|
10
|
+
(Relying Party,消费身份)。两者共享 core 注入的 `oidc` 全局(RS256 原语 + 配置态)
|
|
11
|
+
与 KV;会话桥接复用既有 `auth` 模块的 `issueTokens`。业务请求鉴权不变——oj-auth 插件
|
|
12
|
+
Bearer 守卫 + 租户头照旧,OIDC 只负责「换 token」那一跳。
|
|
13
|
+
|
|
14
|
+
```mermaid
|
|
15
|
+
flowchart TD
|
|
16
|
+
subgraph Client["浏览器 / 调用方"]
|
|
17
|
+
UA["浏览器 / curl"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
subgraph Server["oj server(同进程)"]
|
|
21
|
+
subgraph RP["RP 模块 sample/src/oidc/"]
|
|
22
|
+
LOGIN["oidc/login<br/>state+nonce+PKCE"]
|
|
23
|
+
CB["oidc/callback<br/>交换+验签+JIT"]
|
|
24
|
+
LO["oidc/logout"]
|
|
25
|
+
end
|
|
26
|
+
subgraph OP["OP 模块 sample/src/idp/"]
|
|
27
|
+
DISC[".well-known/openid-configuration"]
|
|
28
|
+
JWKS["jwks.json"]
|
|
29
|
+
AUTHZ["authorize<br/>白名单+PKCE+code"]
|
|
30
|
+
ILOGIN["login<br/>bcrypt+cookie 会话"]
|
|
31
|
+
TOK["token<br/>RS256 签发"]
|
|
32
|
+
UI["userinfo"]
|
|
33
|
+
end
|
|
34
|
+
subgraph Core["core(Rust 注入)"]
|
|
35
|
+
OIDCG["oidc 全局<br/>sign / verify / jwks / issuer / rp / clients"]
|
|
36
|
+
RAW["json.raw<br/>裸 JSON(OP 对外端点)"]
|
|
37
|
+
EXEMPT["tenant.anonymous_paths<br/>跳转腿豁免"]
|
|
38
|
+
end
|
|
39
|
+
KV["KV<br/>OJ-OIDC:STATE:* / OJ-OIDC:CODE:* / OJ-IDP:SESS:*"]
|
|
40
|
+
USERS["users 表(_platform)"]
|
|
41
|
+
SESSION["auth/_shared session.ts<br/>issueTokens + Bearer 会话"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
UA -->|"GET ?tenant=X(豁免租户头)"| LOGIN
|
|
45
|
+
UA -->|"GET ?code&state(豁免)"| CB
|
|
46
|
+
UA -->|"POST username/password(豁免)"| ILOGIN
|
|
47
|
+
UA -->|"GET authorize(豁免)"| AUTHZ
|
|
48
|
+
LOGIN -->|"fetch discovery"| DISC
|
|
49
|
+
LOGIN -->|"快照"| KV
|
|
50
|
+
AUTHZ -->|"code(60s 一次一用)"| KV
|
|
51
|
+
ILOGIN -->|"bcrypt.verify"| USERS
|
|
52
|
+
ILOGIN -->|"IDP_SESSION"| KV
|
|
53
|
+
CB -->|"code+verifier 换 token"| TOK
|
|
54
|
+
CB -->|"fetch jwks_uri"| JWKS
|
|
55
|
+
CB -->|"verify(id_token, jwks)"| OIDCG
|
|
56
|
+
CB -->|"JIT:oidc:<tenant>:<sub>"| USERS
|
|
57
|
+
CB -->|"issueTokens 桥接"| SESSION
|
|
58
|
+
TOK -->|"claims 签名"| OIDCG
|
|
59
|
+
DISC --> RAW
|
|
60
|
+
JWKS --> RAW
|
|
61
|
+
TOK --> RAW
|
|
62
|
+
UI --> RAW
|
|
63
|
+
LOGIN -.->|"302 跳转腿免租户头"| EXEMPT
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
要点:
|
|
67
|
+
|
|
68
|
+
- **密钥不出 Rust**:私钥装配期经 `OidcState::from_section` 装入 `StableState`(构造期、
|
|
69
|
+
不可变),JS 只见 sign/verify/jwks 接口面;手写脱敏 `Debug`,PEM/secret 不进日志。
|
|
70
|
+
- **OP 对外端点说标准协议**:discovery/jwks/token/userinfo 成功用 `json.raw` 回裸 JSON
|
|
71
|
+
(RFC 6749 要求 `access_token` 是顶层字段),错误仍走 `{code,msg,data}` 信封。
|
|
72
|
+
- **豁免面最小**:`tenant.anonymous_paths` 只豁免「缺失租户头的 400」,且仅列出三条
|
|
73
|
+
跳转腿路径(`/oidc/*`、`/idp/*`、`/idp/.well-known/*`);带有效租户头的请求照常注入
|
|
74
|
+
`http.tenantId`。匹配是严格一层通配(比 oj-auth 插件的多层前缀更严,深路径需显式列出)。
|
|
75
|
+
|
|
76
|
+
## 2. 全链时序图
|
|
77
|
+
|
|
78
|
+
```mermaid
|
|
79
|
+
sequenceDiagram
|
|
80
|
+
autonumber
|
|
81
|
+
actor UA as 浏览器/调用方
|
|
82
|
+
participant RP as RP oidc/*
|
|
83
|
+
participant KV as KV
|
|
84
|
+
participant OP as OP idp/*
|
|
85
|
+
participant DB as users 表
|
|
86
|
+
participant Core as oidc 全局(Rust)
|
|
87
|
+
|
|
88
|
+
UA->>RP: GET /oidc/login?tenant=default
|
|
89
|
+
RP->>KV: state -> {tenant,nonce,verifier,IdP 快照} (TTL 600s)
|
|
90
|
+
RP->>OP: fetch /.well-known/openid-configuration
|
|
91
|
+
OP-->>RP: discovery(裸 JSON)
|
|
92
|
+
RP-->>UA: 302 authorize_url?...code_challenge=S256(nonce,state)
|
|
93
|
+
|
|
94
|
+
UA->>OP: GET /idp/authorize?...
|
|
95
|
+
OP-->>UA: 401 login required(无 cookie 会话)
|
|
96
|
+
UA->>OP: POST /idp/login {username,password}
|
|
97
|
+
OP->>DB: select + bcrypt.verify
|
|
98
|
+
OP-->>UA: Set-Cookie IDP_SESSION(KV TTL=refresh 时长)
|
|
99
|
+
UA->>OP: GET /idp/authorize(带 cookie)
|
|
100
|
+
OP->>KV: code -> {client_id,redirect_uri,state,nonce,challenge,uid,tenant} (TTL 60s)
|
|
101
|
+
OP-->>UA: 302 redirect_uri?code&state
|
|
102
|
+
|
|
103
|
+
UA->>RP: GET /oidc/callback?code&state
|
|
104
|
+
RP->>KV: 取 state 并立即删除(一次一用)
|
|
105
|
+
RP->>OP: POST /idp/token(code+verifier+凭证,form)
|
|
106
|
+
OP->>KV: 取 code 并立即删除(一次一用)
|
|
107
|
+
OP->>Core: 校验 PKCE/redirect_uri/client 绑定
|
|
108
|
+
OP->>Core: oidc.sign({iss,sub,aud,iat,exp,nonce,tenant}) ×2
|
|
109
|
+
OP-->>RP: access_token + id_token(裸 JSON)
|
|
110
|
+
RP->>OP: fetch jwks_uri
|
|
111
|
+
OP-->>RP: JWKS
|
|
112
|
+
RP->>Core: oidc.verify(id_token, jwks)(RS256/kid/exp/nonce/iss/aud)
|
|
113
|
+
RP->>DB: JIT:username = oidc:<tenant>:<sub>,无则插入('!oidc' 占位不可登录)
|
|
114
|
+
RP->>UA: 200 信封 issueTokens()(access/refresh HS256 桥接会话)
|
|
115
|
+
|
|
116
|
+
UA->>RP: 重放 callback?code&state
|
|
117
|
+
RP->>KV: state 已删除
|
|
118
|
+
RP-->>UA: 401 invalid or expired state
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
安全闸门与失效路径:
|
|
122
|
+
|
|
123
|
+
| 闸门 | 失败返回 |
|
|
124
|
+
|---|---|
|
|
125
|
+
| authorize:client_id/redirect_uri 白名单不命中 | 400(**绝不重定向**) |
|
|
126
|
+
| authorize:response_type ≠ code / scope 无 openid / PKCE 非 S256 | 400 |
|
|
127
|
+
| authorize / token:会话或 code 无效、过期、已用 | 401 |
|
|
128
|
+
| callback:state 无效/过期/重放 | 401 |
|
|
129
|
+
| callback:jwks 拉取失败 | 502(**不回落本地验签**) |
|
|
130
|
+
| callback:id_token 验签失败 / nonce、iss、aud 与快照不符 | 401 |
|
|
131
|
+
| token:code 归属 client 与凭证不符 | 401(RFC 6749 §4.1.3) |
|
|
132
|
+
|
|
133
|
+
## 3. 多租户数据流
|
|
134
|
+
|
|
135
|
+
```mermaid
|
|
136
|
+
flowchart LR
|
|
137
|
+
Q["GET /oidc/login?tenant=acme"] --> LOOK["oidc.rp[acme]<br/>(config 段,未知 -> 400)"]
|
|
138
|
+
LOOK --> SNAP["state 快照绑定 IdP 配置<br/>(callback 只信快照,不信 query)"]
|
|
139
|
+
SNAP --> IDP["按 issuer 对接该租户的 IdP<br/>(通用 discovery)"]
|
|
140
|
+
IDP --> TOK2["id_token.tenant =<br/>clients.<client>.tenant"]
|
|
141
|
+
TOK2 --> JIT["JIT 本地行 username=<br/>oidc:acme:<sub>(租户隔离)"]
|
|
142
|
+
JIT --> API["业务请求:X-TENANT-ID 头恢复强制<br/>租户隔离沿用既有语义"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- **RP 侧**:tenant 决定用哪个 IdP。tenant 从 query 进入豁免路由,建 state 时快照进 KV,
|
|
146
|
+
后续只信快照——query 无法劫持已建立的登录流。
|
|
147
|
+
- **OP 侧**:tenant 来自 client 注册(`oidc.clients.<id>.tenant`),随 code 进入
|
|
148
|
+
id_token/access_token claims。
|
|
149
|
+
- **豁免边界**:只有跳转腿豁免;登录后的业务请求照常强制 `X-TENANT-ID`,与全站语义一致。
|
|
150
|
+
|
|
151
|
+
## 4. 组件与文件清单
|
|
152
|
+
|
|
153
|
+
| 组件 | 文件 | 职责 |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| 配置段 | `src/config.rs` | `OidcSection{issuer, private_key_path, rp, clients}` + `TenantCfg.anonymous_paths` |
|
|
156
|
+
| 状态 | `src/bridge/oidc.rs` | `OidcState`:PKCS#8 装载、kid(sha256(b64u(n)) 前 16 hex)、JWKS、rp/clients 透出;`from_section` fail-fast |
|
|
157
|
+
| ops | `src/bridge/oidc.rs` | `op_oidc_sign`(RS256+kid header)/ `op_oidc_verify`(本机或 jwks 按 kid,锁 RS256,leeway 0)/ `op_oidc_info` |
|
|
158
|
+
| 裸 JSON | `src/bridge/json.rs` | `json.raw(data)`——200 裸 JSON,默认 content-type,`json.header` 可覆盖 |
|
|
159
|
+
| 全局挂载 | `src/bridge/bootstrap.js` | `oidc` 全局(sign/verify/jwks/issuer/rp/clients);ASCII 纪律 |
|
|
160
|
+
| 装配 | `oj/src/app.rs` | `cfg.oidc → OidcState::from_section → Extras.oidc/StableState`(构造期,fail-fast) |
|
|
161
|
+
| 租户豁免 | `server/src/lib.rs` | `Pipeline.tenant_anon` + `path_matches`(严格一层通配);run 闭包豁免缺失头 400 |
|
|
162
|
+
| OP | `sample/src/idp/` | discovery / jwks.json / authorize / login / token / userinfo(六端点) |
|
|
163
|
+
| RP | `sample/src/oidc/` | login / callback / logout(三端点) |
|
|
164
|
+
| 共享工具 | `sample/src/auth/_shared/util.ts` | redirect / nowSecs / b64uFromHex / parseForm / parseCookies(构建顺序 auth 最先) |
|
|
165
|
+
| 测试 | `sample/tests/oidc.test.ts` | L1:39 用例(进程内) |
|
|
166
|
+
| 测试 | `oj/tests/oidc_e2e.rs` | 真 TCP 全链 e2e。**独立测试目标**:oj-auth 插件 `static GUARD: OnceLock` 进程级只认首次 init,与 e2e.rs 共进程会互染 |
|
|
167
|
+
|
|
168
|
+
## 5. 已知边界(刻意不做的)
|
|
169
|
+
|
|
170
|
+
- OP sub = `users.id` 字符串;自举登录 JIT 出 `oidc:default:<id>` 本地行,与原 demo 行
|
|
171
|
+
**不合并**(外部 IdP 语义下 sub 本就 opaque)。
|
|
172
|
+
- 不支持:consent 页(登录即同意)、refresh_token / client_credentials grant、
|
|
173
|
+
OP end_session、JWKS 轮换运维(kid 机制已就位,轮换是配置问题)。
|
|
174
|
+
- code/state 的消费是 get→del 非原子(重放必落空;并发窗口由 PKCE/state 绑定兜底;
|
|
175
|
+
redis 后端可换 GETDEL)。secret/PKCE 比较非常量时间(demo 范围)。
|
|
176
|
+
- `client_secret` 明文存 config(与 `auth.jwt_secret` 同信任级),经 `oidc.rp/clients`
|
|
177
|
+
可读——handler JS 与 jwt_secret 同级受信。
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# OIDC 接入手册
|
|
2
|
+
|
|
3
|
+
> 面向要在自己的 oj 项目里启用 OIDC 的开发者:接外部 IdP(RP 角色)、用内置 OP、
|
|
4
|
+
> 多租户接线与排障。实现原理见 [oidc-implementation.md](oidc-implementation.md),
|
|
5
|
+
> JS 全局签名见 [devkit/api-manual.md](devkit/api-manual.md) §6 `oidc` 行。
|
|
6
|
+
|
|
7
|
+
## 1. 前置条件
|
|
8
|
+
|
|
9
|
+
- oj ≥ 本特性版本;`plugins:` 里装配了 `oj-auth`(Bearer 守卫;OIDC 会话桥接依赖它)。
|
|
10
|
+
- RS256 私钥一把(PKCS#8 PEM)。生成任选:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out config/oidc_rs256.pem
|
|
14
|
+
# 或复用 oj-cert(其 private.pem 即 PKCS#8):
|
|
15
|
+
cargo run -p oj-cert -- gen -o config --days 365
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- RP 角色对接的 IdP 必须支持:**authorization code flow + PKCE S256 + RS256 id_token +
|
|
19
|
+
discovery**(`<issuer>/.well-known/openid-configuration`)。四者缺一不可(RP 强制 PKCE
|
|
20
|
+
S256、强制验签)。
|
|
21
|
+
|
|
22
|
+
## 2. 最小可用配置
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
oidc:
|
|
26
|
+
issuer: "https://idp.example.com" # 本机 OP 的对外 URL;只当 RP 时此字段仍必填(OP 侧身份)
|
|
27
|
+
private_key_path: "./config/oidc_rs256.pem" # 相对 config.yaml 所在目录
|
|
28
|
+
rp: # tenant → 外部 IdP 注册
|
|
29
|
+
default:
|
|
30
|
+
issuer: "https://idp.example.com" # IdP 的 issuer(与它 discovery 文档里的 issuer 一字不差)
|
|
31
|
+
client_id: "my-app"
|
|
32
|
+
client_secret: "..."
|
|
33
|
+
scope: "openid profile"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
只当 RP、不用内置 OP 时,`rp` 段配好即可——`clients` 段可以省略。`oidc:` 段缺省 =
|
|
37
|
+
不启用(`oidc` 全局不存在,调用报 `oidc not configured`)。
|
|
38
|
+
|
|
39
|
+
IdP 侧注册(在 IdP 的管理台上做):
|
|
40
|
+
|
|
41
|
+
- redirect_uri 填你服务的**精确串**:`https://<host>/v1/api/oidc/callback`
|
|
42
|
+
(`server.api_prefix` 非默认时替换前缀)。
|
|
43
|
+
- 允许的 grant:`authorization_code`;强制 PKCE S256 最好(本 RP 永远发 PKCE)。
|
|
44
|
+
- 签名算法 RS256。
|
|
45
|
+
|
|
46
|
+
## 3. 登录链路(RP 角色)
|
|
47
|
+
|
|
48
|
+
业务前端只需两步:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 1) 拿 IdP 授权地址(302,Location 即 IdP authorize URL)
|
|
52
|
+
curl -si 'https://your.app/v1/api/oidc/login?tenant=default'
|
|
53
|
+
# → 302 Location: https://idp.example.com/authorize?...code_challenge=...
|
|
54
|
+
|
|
55
|
+
# 2) 浏览器走完 IdP 登录后回跳 callback,callback 直接返回会话信封:
|
|
56
|
+
# {"code":0,"msg":"ok","data":{"access_token":"...","refresh_token":"...",
|
|
57
|
+
# "expires_in":60,"user":{"id":"42","roles":[]}}}
|
|
58
|
+
# 之后所有业务 API 照常:Authorization: Bearer <access_token> + X-TENANT-ID 头
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
要点:
|
|
62
|
+
|
|
63
|
+
- `?tenant=` 必须是 `oidc.rp` 里的键,未知租户 400。
|
|
64
|
+
- `tenant` 与 `state` 一起快照进 KV(TTL 10 分钟),callback 只信快照——用户改 query
|
|
65
|
+
动不了已建立的登录流。
|
|
66
|
+
- 登录成功后本地账号按 **`oidc:<tenant>:<sub>`** JIT 创建(首次登录自动建行,不可密码
|
|
67
|
+
登录),与本地口令账号天然隔离;`roles` 取该行 `roles` 列。
|
|
68
|
+
- `/oidc/*`、`/idp/*` 路径必须同时加进 **`auth.anonymous_paths` 与
|
|
69
|
+
`tenant.anonymous_paths`**(浏览器跳转腿带不了 Bearer 和租户头):
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
auth:
|
|
73
|
+
anonymous_paths:
|
|
74
|
+
- "/oidc/*"
|
|
75
|
+
- "/idp/*"
|
|
76
|
+
- "/idp/.well-known/*"
|
|
77
|
+
tenant:
|
|
78
|
+
enable: true
|
|
79
|
+
anonymous_paths: # 与 auth 的同名机制同形,但匹配是严格一层通配
|
|
80
|
+
- "/oidc/*"
|
|
81
|
+
- "/idp/*"
|
|
82
|
+
- "/idp/.well-known/*"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 4. 用内置 OP(自己当身份源)
|
|
86
|
+
|
|
87
|
+
在 `oidc:` 段再加 `clients`(OP 侧 client 白名单),并把要自举的租户的 `rp.<tenant>.issuer`
|
|
88
|
+
指向本机 OP:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
oidc:
|
|
92
|
+
issuer: "http://localhost:9778/v1/api/idp" # OP 对外身份(必须与实际可达地址一致)
|
|
93
|
+
private_key_path: "./config/oidc_rs256.pem"
|
|
94
|
+
clients: # OP 侧:谁可以来登录
|
|
95
|
+
sample-rp:
|
|
96
|
+
secret: "rp-secret"
|
|
97
|
+
redirect_uris: # 精确串白名单;不在表内绝不重定向
|
|
98
|
+
- "http://localhost:9778/v1/api/oidc/callback"
|
|
99
|
+
tenant: "default" # 该 client 登录成功后 id_token.tenant 的值
|
|
100
|
+
rp:
|
|
101
|
+
default: # 指向自己 = 自举演示
|
|
102
|
+
issuer: "http://localhost:9778/v1/api/idp"
|
|
103
|
+
client_id: "sample-rp"
|
|
104
|
+
client_secret: "rp-secret"
|
|
105
|
+
scope: "openid profile"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
OP 端点一览(对外标准协议,成功响应为裸 JSON):
|
|
109
|
+
|
|
110
|
+
| 端点 | 方法 | 说明 |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `/v1/api/idp/.well-known/openid-configuration` | GET | discovery(勿硬编码下游地址,走发现) |
|
|
113
|
+
| `/v1/api/idp/jwks.json` | GET | 验签公钥(RS256,kid) |
|
|
114
|
+
| `/v1/api/idp/authorize` | GET | 无 OP 会话 → 401 `login required`(先 POST login 持 cookie 重来) |
|
|
115
|
+
| `/v1/api/idp/login` | POST | `{username,password}` → `Set-Cookie: IDP_SESSION`(users 表 + bcrypt) |
|
|
116
|
+
| `/v1/api/idp/token` | POST | form:`grant_type=authorization_code&code&client_id&client_secret&redirect_uri&code_verifier` |
|
|
117
|
+
| `/v1/api/idp/userinfo` | GET | `Authorization: Bearer <access_token>` → `{sub, tenant}` |
|
|
118
|
+
|
|
119
|
+
OP 的登录用户 = 本库 `users` 表(bcrypt)。给用户开号即插一行(`INSERT OR IGNORE INTO
|
|
120
|
+
users (username, password_hash, roles) VALUES (?, '<bcrypt hash>', '[]')`)。
|
|
121
|
+
|
|
122
|
+
## 5. 多租户接线
|
|
123
|
+
|
|
124
|
+
| 位置 | 配置 | 语义 |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| RP(接谁的 IdP) | `oidc.rp.<tenant>` | tenant → IdP issuer/凭证;`/oidc/login?tenant=<键>` 选用 |
|
|
127
|
+
| OP(谁可以登录) | `oidc.clients.<id>.tenant` | 该 client 的登录会签发到哪个租户(id_token.tenant) |
|
|
128
|
+
| 跳转腿豁免 | `tenant.anonymous_paths` | 浏览器 302 带不了租户头;列表外照常 400 |
|
|
129
|
+
| 业务隔离 | `X-TENANT-ID` 头 | 登录后恢复全站强制;行级过滤仍由业务 SQL 自理(框架不自动改写) |
|
|
130
|
+
|
|
131
|
+
一个 users 表服务多租户时,本地账号名 `oidc:<tenant>:<sub>` 已按租户隔离;**不要**把
|
|
132
|
+
两个不同信任级的 IdP 指向同一租户键。
|
|
133
|
+
|
|
134
|
+
## 6. 会话与登出
|
|
135
|
+
|
|
136
|
+
- callback 返回的 `access_token/refresh_token` 就是既有 `auth` 会话(HS256 + KV 轮换,
|
|
137
|
+
时长取 `auth.access_token_duration/refresh_token_duration`)——**后续鉴权、refresh、
|
|
138
|
+
logout 与本地登录完全同构**,前端无感。
|
|
139
|
+
- OP 侧会话(`IDP_SESSION` cookie)独立存在,只用于 authorize 免重复输密码;Path 限定
|
|
140
|
+
`/…/idp`,HttpOnly + SameSite=Lax。
|
|
141
|
+
- `POST /v1/api/oidc/logout`(body `{refresh_token}`)等价 `auth/logout`。
|
|
142
|
+
|
|
143
|
+
## 7. 排障表
|
|
144
|
+
|
|
145
|
+
| 症状 | 原因 / 处置 |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `oidc not configured` | config 没有 `oidc:` 段(或 `oj build` 产物未带新二进制);段存在但 `issuer`/`private_key_path` 为空 → 启动即报错退出 |
|
|
148
|
+
| 启动报 `oidc private key … parse pkcs8 pem` | 私钥不是 PKCS#8 PEM(`BEGIN PRIVATE KEY`);用 §1 命令重新生成 |
|
|
149
|
+
| `/oidc/login` → 400 `unknown tenant` | `?tenant=` 不在 `oidc.rp` 键里 |
|
|
150
|
+
| `/oidc/login` → 502 `discovery failed` | IdP 不可达 / issuer 写错 / IdP 无 discovery 端点;`curl <issuer>/.well-known/openid-configuration` 自查 |
|
|
151
|
+
| `/oidc/callback` → 502 `jwks fetch failed` | jwks_uri 拉取失败(IdP 抖动);**不会**静默回落本机验签,重试即可 |
|
|
152
|
+
| `/oidc/callback` → 401 `id_token verification failed` | RS256/kid 不匹配(IdP 轮了密钥)或 token 被篡改 |
|
|
153
|
+
| `/oidc/callback` → 401 `id_token claims mismatch` | nonce/iss/aud 与快照不符:多为 issuer 大小写/尾斜杠不一致 |
|
|
154
|
+
| `/oidc/callback` → 401 `invalid or expired state` | state 已用或超 10 分钟;回 `/oidc/login` 重新起流程 |
|
|
155
|
+
| authorize → 400 `redirect_uri not registered` | 跳转地址不在 `oidc.clients.<id>.redirect_uris` 精确表内(scheme/host/端口/路径全都要一致) |
|
|
156
|
+
| authorize → 401 `login required` | 正常:先 `POST /idp/login` 拿 IDP_SESSION cookie 再访问 authorize |
|
|
157
|
+
| 登录后业务 API 400 `missing tenant header` | 正常:跳转腿豁免不覆盖业务请求;客户端把 `X-TENANT-ID` 带上 |
|
|
158
|
+
| e2e/测试里 `/oidc/*` 意外 401 | oj-auth 插件 `static GUARD` 进程级只认首次 init 的匿名表——同进程多服务器测试请把用例放进独立测试目标(参考 `oj/tests/oidc_e2e.rs`) |
|
|
159
|
+
|
|
160
|
+
## 8. 安全清单(上线前自查)
|
|
161
|
+
|
|
162
|
+
- [ ] `redirect_uris` 精确串(无通配、无 query);白名单外绝不发生 302
|
|
163
|
+
- [ ] PKCE S256 强制(RP 已强制;IdP 侧也开)
|
|
164
|
+
- [ ] `client_secret` 不进前端/日志;轮换时 `rp` 与 IdP 两侧同步
|
|
165
|
+
- [ ] 生产 IdP 走 HTTPS(issuer 与 discovery 返回值逐字一致)
|
|
166
|
+
- [ ] `auth.anonymous_paths` 与 `tenant.anonymous_paths` 只列跳转腿三段,不加业务路径
|
|
167
|
+
- [ ] 私钥文件权限收紧(600);`config/` 不进镜像分发
|
|
168
|
+
- [ ] 已知晓:demo 私钥 `sample/config/oidc_rs256.pem` 仅供演示,勿用于生产
|
package/oj
ADDED
|
Binary file
|