diff --git a/server/src/crate_server.gleam b/server/src/crate_server.gleam index 0733fb8..920687a 100644 --- a/server/src/crate_server.gleam +++ b/server/src/crate_server.gleam @@ -8,7 +8,6 @@ import crate_server/identity_cache import crate_server/jetstream_consumer import crate_server/known_users.{type Store as KnownUsers} import crate_server/oauth/config -import crate_server/oauth/keys import crate_server/oauth/store import crate_server/reconcile import crate_server/router @@ -78,7 +77,7 @@ pub fn main() -> Nil { resolver:, store:, sessions:, - key: keys.load(), + key: config_env.client_key(base_url), base_url:, plc_directory: config_env.plc_directory(), ) diff --git a/server/src/crate_server/config_env.gleam b/server/src/crate_server/config_env.gleam index 20fad4a..7cd8a98 100644 --- a/server/src/crate_server/config_env.gleam +++ b/server/src/crate_server/config_env.gleam @@ -13,7 +13,9 @@ import crate_server/jetstream_consumer import crate_server/known_users import crate_server/known_users_memory import crate_server/known_users_postgres +import crate_server/oauth/config import crate_server/oauth/did_resolution +import crate_server/oauth/keys import crate_server/oauth/sessions.{type Store} import crate_server/oauth/sessions_memory import crate_server/oauth/sessions_postgres @@ -124,6 +126,30 @@ pub fn base_url(port: Int) -> String { } } +/// The OAuth client signing key. Like DATABASE_URL, a set-but-unusable value is +/// a deploy bug: an ephemeral fallback rotates jwks.json on every restart and +/// breaks refreshes for every confidential-client session. +pub fn client_key(base_url: String) -> keys.ClientKey { + case envoy.get("OAUTH_CLIENT_JWK") { + Ok(jwk_json) -> + case keys.from_json(jwk_json) { + Ok(key) -> key + Error(e) -> panic as { "OAUTH_CLIENT_JWK is unusable: " <> e } + } + Error(Nil) -> + case config.confidential(base_url) { + True -> + panic as "OAUTH_CLIENT_JWK unset: the confidential client (https BASE_URL) needs a stable signing key" + False -> { + wisp.log_warning( + "OAUTH_CLIENT_JWK unset: generating an ephemeral key for the localhost client", + ) + keys.generate() + } + } + } +} + pub fn static_directory() -> String { let priv = application.priv_directory("crate_server") diff --git a/server/src/crate_server/oauth/config.gleam b/server/src/crate_server/oauth/config.gleam index b2ce06b..0523143 100644 --- a/server/src/crate_server/oauth/config.gleam +++ b/server/src/crate_server/oauth/config.gleam @@ -27,6 +27,12 @@ pub type Config { ) } +/// Whether `base_url` selects the confidential client, which needs a stable +/// signing key. +pub fn confidential(base_url: String) -> Bool { + string.starts_with(base_url, "https://") +} + pub fn new( client client: Client, resolver resolver: String, @@ -38,7 +44,7 @@ pub fn new( ) -> Config { let scope = client_metadata.scope() let did_resolution = did_resolution.default_policy(plc_directory) - case string.starts_with(base_url, "https://") { + case confidential(base_url) { True -> Config( client:, diff --git a/server/src/crate_server/oauth/keys.gleam b/server/src/crate_server/oauth/keys.gleam index 9cc2e7c..3e4a386 100644 --- a/server/src/crate_server/oauth/keys.gleam +++ b/server/src/crate_server/oauth/keys.gleam @@ -1,46 +1,49 @@ -//// The confidential client's signing key (ES256). Loaded from a private JWK in -//// OAUTH_CLIENT_JWK, or generated ephemerally for dev. The kid is the RFC 7638 +//// The confidential client's signing key (ES256): parsed from a private JWK, or +//// generated ephemerally for the localhost dev client. The kid is the RFC 7638 //// thumbprint so it matches the key advertised in jwks.json. -import envoy import gleam/bit_array import gleam/json import gleam/result import gose import gose/jose/jwk +import gose/jose/jws import kryptos/ec import kryptos/hash -import wisp + +const es256 = gose.DigitalSignature(gose.Ecdsa(gose.EcdsaP256)) pub type ClientKey { ClientKey(key: gose.Key(String), jkt: String) } -pub fn load() -> ClientKey { - let base = case envoy.get("OAUTH_CLIENT_JWK") { - Ok(jwk_json) -> from_json_or_generate(jwk_json) - Error(Nil) -> generate() - } - let assert Ok(jkt) = jwk.thumbprint(base, hash.Sha256) - ClientKey(key: gose.with_kid(base, jkt), jkt:) +/// Parses a private P-256 JWK into a key that is proven to sign ES256, so a +/// public-only or wrong-curve key fails at boot instead of at the first login. +pub fn from_json(jwk_json: String) -> Result(ClientKey, String) { + use base <- result.try( + jwk.from_json(jwk_json) |> result.replace_error("not a JWK"), + ) + use _ <- result.try( + jws.new(es256) + |> jws.sign(base, <<"probe">>) + |> result.replace_error("cannot sign ES256 (needs a private P-256 key)"), + ) + with_thumbprint(base) } -fn from_json_or_generate(jwk_json: String) -> gose.Key(String) { - case jwk.from_json(jwk_json) { - Ok(key) -> key - Error(_) -> { - wisp.log_warning("OAUTH_CLIENT_JWK is not a valid JWK; generating one") - generate() - } - } +/// A fresh key, not stable across restarts: only for the public localhost +/// client and tests. +pub fn generate() -> ClientKey { + let assert Ok(ck) = with_thumbprint(gose.generate_ec(ec.P256)) + ck } -fn generate() -> gose.Key(String) { - wisp.log_warning( - "OAUTH_CLIENT_JWK unset: generating an ephemeral key (not stable across " - <> "restarts; set OAUTH_CLIENT_JWK for a real confidential client)", +fn with_thumbprint(base: gose.Key(String)) -> Result(ClientKey, String) { + use jkt <- result.map( + jwk.thumbprint(base, hash.Sha256) + |> result.replace_error("cannot compute the JWK thumbprint"), ) - gose.generate_ec(ec.P256) + ClientKey(key: gose.with_kid(base, jkt), jkt:) } fn b64(bits: BitArray) -> String { diff --git a/server/test/oauth_test.gleam b/server/test/oauth_test.gleam index 0a254e7..808e462 100644 --- a/server/test/oauth_test.gleam +++ b/server/test/oauth_test.gleam @@ -29,6 +29,7 @@ import gleam/option.{None, Some} import gleam/result import gleam/string import gose +import gose/jose/jwk import kryptos/ec import oauth_fakes import support @@ -61,7 +62,7 @@ pub fn pkce_challenge_is_s256_of_verifier_test() { } pub fn dpop_proof_has_dpop_header_and_bound_claims_test() { - let key = keys.load().key + let key = keys.generate().key let assert Ok(proof) = dpop.proof( key, @@ -84,7 +85,7 @@ pub fn dpop_proof_has_dpop_header_and_bound_claims_test() { } pub fn dpop_proof_includes_nonce_when_given_test() { - let key = keys.load().key + let key = keys.generate().key let assert Ok(proof) = dpop.proof( key, @@ -96,8 +97,31 @@ pub fn dpop_proof_includes_nonce_when_given_test() { assert string.contains(segment(proof, 1), "\"nonce\":\"nonce-123\"") } +fn jwk_json(key: gose.Key(String)) -> String { + jwk.to_json(key) |> json.to_string +} + +pub fn client_key_from_a_private_p256_jwk_keeps_the_thumbprint_kid_test() { + let generated = keys.generate() + let assert Ok(parsed) = keys.from_json(jwk_json(generated.key)) + assert parsed.jkt == generated.jkt +} + +pub fn client_key_rejects_jwks_that_cannot_sign_es256_test() { + let assert Ok(public) = gose.public_key(keys.generate().key) + [ + "not json", + "{\"kty\":\"EC\"}", + jwk_json(public), + jwk_json(gose.generate_ec(ec.P384)), + ] + |> list.each(fn(input) { + let assert Error(_) = keys.from_json(input) + }) +} + pub fn client_assertion_is_signed_with_kid_and_audience_test() { - let ck = keys.load() + let ck = keys.generate() let assert Ok(jwt) = assertion.client_assertion( ck, diff --git a/server/test/support.gleam b/server/test/support.gleam index cb917a8..331df03 100644 --- a/server/test/support.gleam +++ b/server/test/support.gleam @@ -222,7 +222,7 @@ pub fn stub_config_with( resolver:, store: st, sessions: ss, - key: keys.load(), + key: keys.generate(), base_url:, plc_directory: "https://plc.directory", )