All files / config.ts

100.00% Branches 17/17
100.00% Functions 2/2
100.00% Lines 41/41
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
x1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x13
x13
x13
x12
x13
 
 
 
 
 
 
 
 
 
x1
x5
x5
x5
 
x5
x5
 
x5
x5
x5
x5
x1
x1
x5
x1
x1
 
x1
 
x2
x2
x2
x2
x2
x2
x2
x2
x2
 
x2
x2
x2
x2
x5
x5
x5
x5
x5
x5















































































import { importJWK, type JWK } from "jose";
import type { SigningKey } from "./token.ts";

/** Runtime configuration, loaded once at startup. */
export interface Config {
  /** Public HTTPS issuer URL; equals the `iss` claim (no trailing slash). */
  issuer: string;
  /** SSHSIG namespace bound into Signing Proofs. */
  namespace: string;
  /** Secret keying the challenge HMAC. */
  hmacSecret: Uint8Array;
  /**
   * Pepper deriving HMAC-mode Secret Fingerprints. Optional: leaving it unset
   * disables HMAC mode entirely. Kept apart from `hmacSecret` because that one is
   * rotatable at will (it only invalidates in-flight challenges) whereas this one
   * *names* every Shared Secret Identity — changing it renames all of them.
   */
  hmacIdentitySecret?: Uint8Array;
  /** The RSA key Tokens are signed with. */
  signingKey: SigningKey;
}

/** Minimal view of an environment source, for testability. */
export interface EnvSource {
  get(key: string): string | undefined;
}

function required(env: EnvSource, key: string): string {
  const v = env.get(key);
  if (!v) throw new Error(`missing required env var ${key}`);
  return v;
}

/**
 * Load configuration from the environment:
 *   POPOIDC_ISSUER               public issuer URL
 *   POPOIDC_SIGNING_JWK          RSA private JWK (JSON) with a `kid`
 *   POPOIDC_HMAC_SECRET          challenge HMAC secret
 *   POPOIDC_HMAC_IDENTITY_SECRET optional identity pepper; enables HMAC mode
 *   POPOIDC_NAMESPACE            optional SSHSIG namespace (default "popoidc")
 */
export async function loadConfig(env: EnvSource = Deno.env): Promise<Config> {
  const issuer = required(env, "POPOIDC_ISSUER").replace(/\/+$/, "");
  const hmacSecret = new TextEncoder().encode(
    required(env, "POPOIDC_HMAC_SECRET"),
  );
  const identitySecret = env.get("POPOIDC_HMAC_IDENTITY_SECRET");
  const namespace = env.get("POPOIDC_NAMESPACE") ?? "popoidc";

  let jwk: JWK & { kid?: string };
  try {
    jwk = JSON.parse(required(env, "POPOIDC_SIGNING_JWK"));
  } catch {
    throw new Error("POPOIDC_SIGNING_JWK is not valid JSON");
  }
  if (jwk.kty !== "RSA" || !jwk.kid || !jwk.d) {
    throw new Error(
      "POPOIDC_SIGNING_JWK must be an RSA private JWK with a kid",
    );
  }

  const privateKey = await importJWK(jwk, "RS256") as CryptoKey;
  const publicJwk: JWK = {
    kty: "RSA",
    n: jwk.n,
    e: jwk.e,
    kid: jwk.kid,
    alg: "RS256",
    use: "sig",
  };

  return {
    issuer,
    namespace,
    hmacSecret,
    hmacIdentitySecret: identitySecret
      ? new TextEncoder().encode(identitySecret)
      : undefined,
    signingKey: { kid: jwk.kid, privateKey, publicJwk },
  };
}