appview/oauth: use client attestation #721

merged
opened by oppi.li targeting master from push-qryntruoqzmt

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
···
}
type OAuthConfig struct {
-
Jwks string `env:"JWKS"`
}
type JetstreamConfig struct {
···
}
type OAuthConfig struct {
+
ClientSecret string `env:"CLIENT_SECRET"`
+
ClientKid string `env:"CLIENT_KID"`
}
type JetstreamConfig struct {
+3 -13
appview/oauth/handler.go
···
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/go-chi/chi/v5"
-
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/posthog/posthog-go"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
···
}
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
-
jwks := o.Config.OAuth.Jwks
-
pubKey, err := pubKeyFromJwk(jwks)
-
if err != nil {
-
o.Logger.Error("error parsing public key", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
-
response := map[string]any{
-
"keys": []jwk.Key{pubKey},
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
json.NewEncoder(w).Encode(response)
}
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
···
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/go-chi/chi/v5"
"github.com/posthog/posthog-go"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
···
}
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "application/json")
+
body := o.ClientApp.Config.PublicJWKS()
+
if err := json.NewEncoder(w).Encode(body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
+10 -13
appview/oauth/oauth.go
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
atpclient "github.com/bluesky-social/indigo/atproto/client"
"github.com/bluesky-social/indigo/atproto/syntax"
xrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/gorilla/sessions"
-
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/posthog/posthog-go"
"tangled.org/core/appview/config"
"tangled.org/core/appview/db"
···
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
}
jwksUri := clientUri + "/oauth/jwks.json"
authStore, err := NewRedisStore(config.Redis.ToURL())
···
return errors.Join(err1, err2)
}
-
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
-
k, err := jwk.ParseKey([]byte(jwks))
-
if err != nil {
-
return nil, err
-
}
-
pubKey, err := k.PublicKey()
-
if err != nil {
-
return nil, err
-
}
-
return pubKey, nil
-
}
-
type User struct {
Did string
Pds string
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
atpclient "github.com/bluesky-social/indigo/atproto/client"
+
atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
"github.com/bluesky-social/indigo/atproto/syntax"
xrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/gorilla/sessions"
"github.com/posthog/posthog-go"
"tangled.org/core/appview/config"
"tangled.org/core/appview/db"
···
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
}
+
// configure client secret
+
priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret)
+
if err != nil {
+
return nil, err
+
}
+
if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil {
+
return nil, err
+
}
+
jwksUri := clientUri + "/oauth/jwks.json"
authStore, err := NewRedisStore(config.Redis.ToURL())
···
return errors.Join(err1, err2)
}
type User struct {
Did string
Pds string
-43
cmd/genjwks/main.go
···
-
// adapted from https://tangled.org/anirudh.fi/atproto-oauth
-
-
package main
-
-
import (
-
"crypto/ecdsa"
-
"crypto/elliptic"
-
"crypto/rand"
-
"encoding/json"
-
"fmt"
-
"time"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
func main() {
-
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
-
if err != nil {
-
panic(err)
-
}
-
-
key, err := jwk.FromRaw(privKey)
-
if err != nil {
-
panic(err)
-
}
-
-
kid := fmt.Sprintf("%d", time.Now().Unix())
-
-
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
-
panic(err)
-
}
-
-
if err := key.Set("use", "sig"); err != nil {
-
panic(err)
-
}
-
-
b, err := json.Marshal(key)
-
if err != nil {
-
panic(err)
-
}
-
-
fmt.Println(string(b))
-
}
···
+14 -4
docs/hacking.md
···
```
# oauth jwks should already be setup by the nix devshell:
-
echo $TANGLED_OAUTH_JWKS
-
{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
# if not, you can set it up yourself:
-
go build -o genjwks.out ./cmd/genjwks
-
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
# run redis in at a new shell to store oauth sessions
redis-server
···
```
# oauth jwks should already be setup by the nix devshell:
+
echo $TANGLED_OAUTH_CLIENT_SECRET
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
+
+
echo $TANGLED_OAUTH_CLIENT_KID
+
1761667908
# if not, you can set it up yourself:
+
goat key generate -t P-256
+
Key Type: P-256 / secp256r1 / ES256 private key
+
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
+
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
+
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
+
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
+
+
# the secret key from above
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
# run redis in at a new shell to store oauth sessions
redis-server
+5 -4
flake.nix
···
inherit (pkgs) gcc;
inherit sqlite-lib-src;
};
-
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
};
···
});
in {
overlays.default = final: prev: {
-
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
};
packages = forAllSystems (system: let
···
staticPackages = mkPackageSet pkgs.pkgsStatic;
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
in {
-
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
pkgsStatic-appview = staticPackages.appview;
pkgsStatic-knot = staticPackages.knot;
···
mkdir -p appview/pages/static
# no preserve is needed because watch-tailwind will want to be able to overwrite
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
-
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
'';
env.CGO_ENABLED = 1;
};
···
inherit (pkgs) gcc;
inherit sqlite-lib-src;
};
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
+
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
};
···
});
in {
overlays.default = final: prev: {
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
};
packages = forAllSystems (system: let
···
staticPackages = mkPackageSet pkgs.pkgsStatic;
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
in {
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
pkgsStatic-appview = staticPackages.appview;
pkgsStatic-knot = staticPackages.knot;
···
mkdir -p appview/pages/static
# no preserve is needed because watch-tailwind will want to be able to overwrite
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
+
export TANGLED_OAUTH_CLIENT_KID="$(date +%s)"
+
export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')"
'';
env.CGO_ENABLED = 1;
};
-18
nix/pkgs/genjwks.nix
···
-
{
-
buildGoApplication,
-
modules,
-
}:
-
buildGoApplication {
-
pname = "genjwks";
-
version = "0.1.0";
-
src = ../../cmd/genjwks;
-
postPatch = ''
-
ln -s ${../../go.mod} ./go.mod
-
'';
-
postInstall = ''
-
mv $out/bin/core $out/bin/genjwks
-
'';
-
inherit modules;
-
doCheck = false;
-
CGO_ENABLED = 0;
-
}
···
+12
nix/pkgs/goat.nix
···
···
+
{
+
buildGoModule,
+
indigo,
+
}:
+
buildGoModule {
+
pname = "goat";
+
version = "0.1.0";
+
src = indigo;
+
subPackages = ["cmd/goat"];
+
vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw=";
+
doCheck = false;
+
}
-26
scripts/appview.sh
···
-
#!/bin/bash
-
-
# Variables
-
BINARY_NAME="appview"
-
BINARY_PATH=".bin/app"
-
SERVER="95.111.206.63"
-
USER="appview"
-
-
# SCP the binary to root's home directory
-
scp "$BINARY_PATH" root@$SERVER:/root/"$BINARY_NAME"
-
-
# SSH into the server and perform the necessary operations
-
ssh root@$SERVER <<EOF
-
set -e # Exit on error
-
-
# Move binary to /usr/local/bin and set executable permissions
-
mv /root/$BINARY_NAME /usr/local/bin/$BINARY_NAME
-
chmod +x /usr/local/bin/$BINARY_NAME
-
-
su appview
-
cd ~
-
./reset.sh
-
EOF
-
-
echo "Deployment complete."
-
···
-5
scripts/generate-jwks.sh
···
-
#! /usr/bin/env bash
-
-
set -e
-
-
go run ./cmd/genjwks/
···