1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290 |
x4
x4
x4
x4
x4
x4
x4
x4
x4
x4
x4
x14
x14
x14
x4
x4
x18
x18
x18
x18
x18
x18
x1
x15
x15
x15
x15
x15
x15
x15
x15
x4
x4
x4
x20
x20
x19
x19
x19
x20
x37
x37
x19
x37
x1
x1
x1
x18
x18
x18
x20
x4
x10
x6
x1
x10
x4
x4
x2
x2
x2
x2
x4
x4
x4
x4
x4
x11
x11
x11
x1
x1
x10
x10
x11
x11
x11
x11
x11
x11
x11
x11
x11
x11
x11
x11
x11
x11
x11
x6
x6
x6
x1
x1
x5
x5
x3
x8
x8
x1
x1
x11
x4
x4
x4
x4
x4
x8
x8
x8
x8
x1
x1
x7
x7
x4
x4
x3
x3
x3
x3
x3
x1
x1
x2
x1
x1
x4
x4
x4
x8
x4
x4
x4
x4
x8
x1
x1
x7
x8
x8
x1
x1
x1
x1
x5
x5
x8
x4
x4
x4
x4
x4
x19
x18
x18
x19
x1
x1
x17
x18
x8
x19
x8
x9
x1
x1
x9
x9
x9
x9
x9
x9
x9
x9
x9
x8
x19
x4
x4
x4
x26
x26
x34
x34
x34
x1
x1
x1
x1
x34
x34
x34
x34
x1
x1
x34
x1
x1
x34
x34
x34
x34
x11
x11
x11
x11
x11
x34
x19
x19
x1
x30
x15
x1
x1
x26
x26 |
|
import type { Config } from "./config.ts";
import { discoveryDocument, jwksDocument, mintToken } from "./token.ts";
import {
type Challenge,
issueChallenge,
type ProofMethod,
randomNonce,
verifyChallenge,
} from "./challenge.ts";
import { parseSshEd25519, sshFingerprint } from "./sshkey.ts";
import { verifySshsig } from "./sshsig.ts";
import { encryptToRecipient } from "./agecrypt.ts";
import { MIN_SECRET_LENGTH, secretFingerprint } from "./hmacid.ts";
export interface HandlerOptions {
/** Injectable clock (unix seconds) for tests. */
now?: () => number;
}
/** An error carrying an HTTP status code. */
class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
const enc = new TextEncoder();
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
// Challenges and Tokens are single-use and time-bound — no cache (Cloudflare et al.)
// may ever store them, or a stale challenge gets replayed and rejected as expired.
function text(body: string): Response {
return new Response(body, {
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
},
});
}
/** Max accepted request body. A real /token body is < 1.5 KB; this is generous headroom. */
const MAX_BODY_BYTES = 16 * 1024;
/** Read a request body as text, aborting past `limit` bytes (streamed — Content-Length is not trusted). */
async function readBody(req: Request, limit: number): Promise<string> {
const reader = req.body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder();
let text = "";
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
if (total > limit) {
await reader.cancel();
throw new HttpError(413, "request body too large");
}
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
/** Classify a supported public key by its string form. */
function keyKind(key: string): "ssh-ed25519" | "age" {
if (key.startsWith("ssh-ed25519 ")) return "ssh-ed25519";
if (key.startsWith("age1")) return "age";
throw new HttpError(400, "unsupported key type");
}
/** The Token subject + key_type for a bound key. */
function identityOf(key: string): { sub: string; keyType: string } {
if (key.startsWith("age1")) return { sub: key, keyType: "age" };
return {
sub: sshFingerprint(parseSshEd25519(key).wire),
keyType: "ssh-ed25519",
};
}
/**
* GET /challenge — issue a challenge bound to {aud, iat, key}.
* A signing key receives the challenge in the clear (to sign); a decryption key
* receives it encrypted to the key, so only the holder can recover it.
*/
async function handleChallenge(
cfg: Config,
params: URLSearchParams,
now: () => number,
): Promise<Response> {
const key = params.get("key");
const aud = params.get("aud");
if (!key || !aud) {
throw new HttpError(400, "key and aud params are required");
}
const kind = keyKind(key);
const requested = params.get("method");
const method: ProofMethod = kind === "age"
? "decrypt"
: requested === "decrypt"
? "decrypt"
: "sign";
const challenge: Challenge = {
v: 1,
method,
aud,
key,
iat: now(),
nonce: randomNonce(),
};
const token = issueChallenge(challenge, cfg.hmacSecret);
if (method === "sign") {
try {
parseSshEd25519(key);
} catch {
throw new HttpError(400, "invalid ssh-ed25519 key");
}
return text(token);
}
try {
return text(await encryptToRecipient(key, enc.encode(token)));
} catch {
throw new HttpError(400, "invalid recipient key");
}
}
/** A proven Identity, ready to be minted into a Token. */
interface Grant {
sub: string;
keyType: string;
/** The `key` claim — the raw public key, or the fingerprint when there is no public half. */
key: string;
aud: string;
}
/**
* The Challenge grant: verify a Challenge + its Proof of Possession (Signing or
* Decryption) and report the Identity it proves.
*/
function grantFromChallenge(
cfg: Config,
body: URLSearchParams,
challengeStr: string,
nowSeconds: number,
): Grant {
let challenge: Challenge;
try {
challenge = verifyChallenge(challengeStr, cfg.hmacSecret, nowSeconds);
} catch {
throw new HttpError(401, "invalid or expired challenge");
}
// Signing Proof: the challenge was public, so require a signature over it that
// matches the bound key. Decryption Proof: recovering the MAC'd challenge (it
// was returned encrypted to the key) is itself the proof — nothing more needed.
if (challenge.method === "sign") {
const signature = body.get("signature");
if (!signature) throw new HttpError(400, "signature is required");
const bound = parseSshEd25519(challenge.key);
let signer;
try {
signer = verifySshsig(enc.encode(challengeStr), signature, cfg.namespace);
} catch {
throw new HttpError(401, "signature verification failed");
}
if (sshFingerprint(signer.publicKeyWire) !== sshFingerprint(bound.wire)) {
throw new HttpError(401, "signature key does not match the challenge");
}
}
const { sub, keyType } = identityOf(challenge.key);
return { sub, keyType, key: challenge.key, aud: challenge.aud };
}
/**
* The HMAC-mode grant (Disclosure Proof): the caller hands over the Shared Secret
* itself, so there is nothing to challenge — the request body *is* the proof, and
* a round-trip would add ceremony but no security. The Identity is the peppered
* HMAC of the secret; the Issuer neither stores the secret nor puts it in the Token.
*/
function grantFromSecret(
cfg: Config,
body: URLSearchParams,
secret: string,
): Grant {
if (!cfg.hmacIdentitySecret) {
throw new HttpError(501, "hmac mode is not enabled on this issuer");
}
const aud = body.get("aud");
if (!aud) throw new HttpError(400, "aud is required with secret");
if (secret.length < MIN_SECRET_LENGTH) {
throw new HttpError(
400,
`secret must be at least ${MIN_SECRET_LENGTH} characters`,
);
}
// No public half exists, so `key` restates the fingerprint rather than leaking
// the secret it was derived from.
const sub = secretFingerprint(secret, cfg.hmacIdentitySecret);
return { sub, keyType: "hmac", key: sub, aud };
}
/** POST /token — take a proof of possession (Challenge-bound or disclosed) and mint a Token. */
async function handleToken(
cfg: Config,
req: Request,
now: () => number,
): Promise<Response> {
const body = new URLSearchParams(await readBody(req, MAX_BODY_BYTES));
const challengeStr = body.get("challenge");
const secret = body.get("secret");
if (challengeStr && secret) {
throw new HttpError(400, "send either challenge or secret, not both");
}
let grant: Grant;
if (secret) {
grant = grantFromSecret(cfg, body, secret);
} else if (challengeStr) {
grant = grantFromChallenge(cfg, body, challengeStr, now());
} else {
throw new HttpError(400, "challenge or secret is required");
}
const jwt = await mintToken({
issuer: cfg.issuer,
subject: grant.sub,
audience: grant.aud,
key: grant.key,
keyType: grant.keyType,
signingKey: cfg.signingKey,
nowSeconds: now(),
});
return text(jwt);
}
/** Build the popoidc HTTP handler over a loaded Config. */
export function createHandler(
cfg: Config,
opts: HandlerOptions = {},
): (req: Request) => Promise<Response> {
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
return async (req: Request): Promise<Response> => {
const url = new URL(req.url);
try {
if (req.method === "GET" && url.pathname === "/") {
return Response.redirect(
"https://github.com/dtinth/popoidc#readme",
302,
);
}
if (
req.method === "GET" &&
url.pathname === "/.well-known/openid-configuration"
) {
return json(discoveryDocument(cfg.issuer));
}
if (req.method === "GET" && url.pathname === "/.well-known/jwks.json") {
return json(jwksDocument([cfg.signingKey]));
}
if (
url.pathname === "/challenge" &&
(req.method === "GET" || req.method === "POST")
) {
const params = req.method === "POST"
? new URLSearchParams(await readBody(req, MAX_BODY_BYTES))
: url.searchParams;
return await handleChallenge(cfg, params, now);
}
if (req.method === "POST" && url.pathname === "/token") {
return await handleToken(cfg, req, now);
}
return json({ error: "not_found" }, 404);
} catch (e) {
if (e instanceof HttpError) return json({ error: e.message }, e.status);
return json({ error: "internal_error" }, 500);
}
};
}
|