Monorepo for Tangled tangled.org

appview/oauth: use client attestation

this change makes our tangled appview a "confidential" client.

this change includes breaking changes to the appview service, it now
requires two different environment variables:

- TANGLED_OAUTH_CLIENT_SECRET: the secret component of the old JWKs
object
- TANGLED_OAUTH_CLIENT_KID: the key ID the old JWKs object

both of these can be extracted from the old JWKs object: `obj.d` and
`obj.kid` respectively.

Signed-off-by: oppiliappan <me@oppi.li>

Changed files
+46 -127
appview
cmd
genjwks
docs
nix
scripts
+2 -1
appview/config/config.go
··· 25 25 } 26 26 27 27 type OAuthConfig struct { 28 - Jwks string `env:"JWKS"` 28 + ClientSecret string `env:"CLIENT_SECRET"` 29 + ClientKid string `env:"CLIENT_KID"` 29 30 } 30 31 31 32 type JetstreamConfig struct {
+3 -13
appview/oauth/handler.go
··· 12 12 13 13 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 14 "github.com/go-chi/chi/v5" 15 - "github.com/lestrrat-go/jwx/v2/jwk" 16 15 "github.com/posthog/posthog-go" 17 16 "tangled.org/core/api/tangled" 18 17 "tangled.org/core/appview/db" ··· 41 40 } 42 41 43 42 func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 44 - jwks := o.Config.OAuth.Jwks 45 - pubKey, err := pubKeyFromJwk(jwks) 46 - if err != nil { 47 - o.Logger.Error("error parsing public key", "err", err) 43 + w.Header().Set("Content-Type", "application/json") 44 + body := o.ClientApp.Config.PublicJWKS() 45 + if err := json.NewEncoder(w).Encode(body); err != nil { 48 46 http.Error(w, err.Error(), http.StatusInternalServerError) 49 47 return 50 48 } 51 - 52 - response := map[string]any{ 53 - "keys": []jwk.Key{pubKey}, 54 - } 55 - 56 - w.Header().Set("Content-Type", "application/json") 57 - w.WriteHeader(http.StatusOK) 58 - json.NewEncoder(w).Encode(response) 59 49 } 60 50 61 51 func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
+10 -13
appview/oauth/oauth.go
··· 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 11 "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 12 atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 xrpc "github.com/bluesky-social/indigo/xrpc" 15 16 "github.com/gorilla/sessions" 16 - "github.com/lestrrat-go/jwx/v2/jwk" 17 17 "github.com/posthog/posthog-go" 18 18 "tangled.org/core/appview/config" 19 19 "tangled.org/core/appview/db" ··· 47 47 clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 48 48 callbackUri := clientUri + "/oauth/callback" 49 49 oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 50 + } 51 + 52 + // configure client secret 53 + priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret) 54 + if err != nil { 55 + return nil, err 56 + } 57 + if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil { 58 + return nil, err 50 59 } 51 60 52 61 jwksUri := clientUri + "/oauth/jwks.json" ··· 138 147 err2 := o.SessStore.Save(r, w, userSession) 139 148 140 149 return errors.Join(err1, err2) 141 - } 142 - 143 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 144 - k, err := jwk.ParseKey([]byte(jwks)) 145 - if err != nil { 146 - return nil, err 147 - } 148 - pubKey, err := k.PublicKey() 149 - if err != nil { 150 - return nil, err 151 - } 152 - return pubKey, nil 153 150 } 154 151 155 152 type User struct {
-43
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 - 3 - package main 4 - 5 - import ( 6 - "crypto/ecdsa" 7 - "crypto/elliptic" 8 - "crypto/rand" 9 - "encoding/json" 10 - "fmt" 11 - "time" 12 - 13 - "github.com/lestrrat-go/jwx/v2/jwk" 14 - ) 15 - 16 - func main() { 17 - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 18 - if err != nil { 19 - panic(err) 20 - } 21 - 22 - key, err := jwk.FromRaw(privKey) 23 - if err != nil { 24 - panic(err) 25 - } 26 - 27 - kid := fmt.Sprintf("%d", time.Now().Unix()) 28 - 29 - if err := key.Set(jwk.KeyIDKey, kid); err != nil { 30 - panic(err) 31 - } 32 - 33 - if err := key.Set("use", "sig"); err != nil { 34 - panic(err) 35 - } 36 - 37 - b, err := json.Marshal(key) 38 - if err != nil { 39 - panic(err) 40 - } 41 - 42 - fmt.Println(string(b)) 43 - }
+14 -4
docs/hacking.md
··· 37 37 38 38 ``` 39 39 # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_JWKS 41 - {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 40 + echo $TANGLED_OAUTH_CLIENT_SECRET 41 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 + 43 + echo $TANGLED_OAUTH_CLIENT_KID 44 + 1761667908 42 45 43 46 # if not, you can set it up yourself: 44 - go build -o genjwks.out ./cmd/genjwks 45 - export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 47 + goat key generate -t P-256 48 + Key Type: P-256 / secp256r1 / ES256 private key 49 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 + 54 + # the secret key from above 55 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 46 56 47 57 # run redis in at a new shell to store oauth sessions 48 58 redis-server
+5 -4
flake.nix
··· 78 78 inherit (pkgs) gcc; 79 79 inherit sqlite-lib-src; 80 80 }; 81 - genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 82 81 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 82 + goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 83 83 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 84 84 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 85 85 }; ··· 90 90 }); 91 91 in { 92 92 overlays.default = final: prev: { 93 - inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 93 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 94 94 }; 95 95 96 96 packages = forAllSystems (system: let ··· 99 99 staticPackages = mkPackageSet pkgs.pkgsStatic; 100 100 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 101 101 in { 102 - inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 102 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 103 103 104 104 pkgsStatic-appview = staticPackages.appview; 105 105 pkgsStatic-knot = staticPackages.knot; ··· 167 167 mkdir -p appview/pages/static 168 168 # no preserve is needed because watch-tailwind will want to be able to overwrite 169 169 cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 170 - export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 170 + export TANGLED_OAUTH_CLIENT_KID="$(date +%s)" 171 + export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')" 171 172 ''; 172 173 env.CGO_ENABLED = 1; 173 174 };
-18
nix/pkgs/genjwks.nix
··· 1 - { 2 - buildGoApplication, 3 - modules, 4 - }: 5 - buildGoApplication { 6 - pname = "genjwks"; 7 - version = "0.1.0"; 8 - src = ../../cmd/genjwks; 9 - postPatch = '' 10 - ln -s ${../../go.mod} ./go.mod 11 - ''; 12 - postInstall = '' 13 - mv $out/bin/core $out/bin/genjwks 14 - ''; 15 - inherit modules; 16 - doCheck = false; 17 - CGO_ENABLED = 0; 18 - }
+12
nix/pkgs/goat.nix
··· 1 + { 2 + buildGoModule, 3 + indigo, 4 + }: 5 + buildGoModule { 6 + pname = "goat"; 7 + version = "0.1.0"; 8 + src = indigo; 9 + subPackages = ["cmd/goat"]; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 + doCheck = false; 12 + }
-26
scripts/appview.sh
··· 1 - #!/bin/bash 2 - 3 - # Variables 4 - BINARY_NAME="appview" 5 - BINARY_PATH=".bin/app" 6 - SERVER="95.111.206.63" 7 - USER="appview" 8 - 9 - # SCP the binary to root's home directory 10 - scp "$BINARY_PATH" root@$SERVER:/root/"$BINARY_NAME" 11 - 12 - # SSH into the server and perform the necessary operations 13 - ssh root@$SERVER <<EOF 14 - set -e # Exit on error 15 - 16 - # Move binary to /usr/local/bin and set executable permissions 17 - mv /root/$BINARY_NAME /usr/local/bin/$BINARY_NAME 18 - chmod +x /usr/local/bin/$BINARY_NAME 19 - 20 - su appview 21 - cd ~ 22 - ./reset.sh 23 - EOF 24 - 25 - echo "Deployment complete." 26 -
-5
scripts/generate-jwks.sh
··· 1 - #! /usr/bin/env bash 2 - 3 - set -e 4 - 5 - go run ./cmd/genjwks/