├── .gitignore ├── .prettierrc ├── LICENSE ├── Makefile ├── README.md ├── client-metadata.example.json ├── cmd └── oatproxy │ ├── key_table.go │ ├── oatproxy_main.go │ └── oatproxy_store.go ├── go.mod ├── go.sum └── pkg └── oatproxy ├── dpop_helpers.go ├── helpers.go ├── locks.go ├── oatproxy.go ├── oauth_0_metadata.go ├── oauth_1_par.go ├── oauth_2_authorize.go ├── oauth_3_return.go ├── oauth_4_token.go ├── oauth_5_revoke.go ├── oauth_middleware.go ├── oauth_nonce.go ├── oauth_nonce_test.go ├── oauth_session.go ├── resolution.go ├── token_generation.go ├── types.go ├── wildcard.go └── xrpc_client.go /.gitignore: -------------------------------------------------------------------------------- 1 | client-metadata.json 2 | /oatproxy 3 | *.sqlite3 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "overrides": [ 4 | { 5 | "files": "*.yml", 6 | "options": { 7 | "proseWrap": "preserve" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Streamplace. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: all 3 | all: 4 | go build -o oatproxy ./cmd/oatproxy/... 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OATProxy: An ATProto OAuth Proxy 2 | 3 | **ALPHA SOFTWARE, USE AT YOUR OWN RISK! LOTS MORE TO COME IN THE NEXT WEEK!** 4 | 5 | Tired of getting logged out of your AT Protocol applications every 48 hours? 6 | Introducing OATProxy! OATProxy acts as a transparent passthrough XRPC proxy 7 | between your front-end application, upgrading your users from 8 | frequently-expiring "public" OAuth sessions to robust, hearty "confidential" 9 | OAuth sessions. 10 | 11 | | Session Type | Inactivity Timeout | Max Session Length | 12 | | ------------ | ------------------ | ------------------ | 13 | | Public | 2 days | 7 days | 14 | | Confidential | 1 month | 1 year | 15 | 16 | OATProxy exists as both a Go library for embedding in applications and as a 17 | standalone microservice. 18 | 19 | # Prerequisites 20 | 21 | You'll need: 22 | 23 | - A public HTTPS address that forwards to this server. (Built-in TLS is coming!) 24 | - A `client-metadata.json` file. You can customize the 25 | `client-metadata.example.json` file in this repo. 26 | - An ATProto app that already works with "public" OAuth. 27 | 28 | # Installing 29 | 30 | ``` 31 | go install github.com/streamplace/oatproxy/cmd/oatproxy@latest 32 | ``` 33 | 34 | # Running 35 | 36 | ``` 37 | oatproxy --host=example.com --client-metadata=client-metadata.json 38 | ``` 39 | 40 | The server will then be available to handle requests on port 8080. 41 | 42 | Optionally, OATProxy can operate as a reverse proxy for another application 43 | server behind it with the `--upstream-host` parameter. If you operate in this 44 | mode, OATProxy will handle all requests for `/oauth`, `/xrpc`, and the OAuth 45 | documents in `/.well-known`. All other requests will be proxied upstream. 46 | 47 | # Usage with `@atproto/oauth-client-browser` 48 | 49 | (This also applies to `@streamplace/oauth-client-react-native`.) 50 | 51 | For this to work, you're going to have to tell some lies. Specifically, you're 52 | going to need to tell `@atproto/oauth-client-browser` that, no matter who the 53 | user is, their PDS URL is OATProxy's URL. This can be accomplished by overriding 54 | the `fetch` handler passed to the client: 55 | 56 | ```typescript 57 | import { BrowserOAuthClient, OAuthClient } from "@atproto/oauth-client-browser"; 58 | 59 | const fetchWithLies = async ( 60 | oatProxyUrl: string, 61 | input: RequestInfo | URL, 62 | init?: RequestInit 63 | ) => { 64 | // Normalize input to a Request object 65 | let request: Request; 66 | if (typeof input === "string" || input instanceof URL) { 67 | request = new Request(input, init); 68 | } else { 69 | request = input; 70 | } 71 | 72 | if ( 73 | request.url.includes("plc.directory") || // did:plc 74 | request.url.endsWith("did.json") // did:web 75 | ) { 76 | const res = await fetch(request, init); 77 | if (!res.ok) { 78 | return res; 79 | } 80 | const data = await res.json(); 81 | const service = data.service.find((s: any) => s.id === "#atproto_pds"); 82 | if (!service) { 83 | return res; 84 | } 85 | service.serviceEndpoint = oatProxyUrl; 86 | return new Response(JSON.stringify(data), { 87 | status: res.status, 88 | headers: res.headers, 89 | }); 90 | } 91 | 92 | return fetch(request, init); 93 | }; 94 | 95 | export default async function createOAuthClient( 96 | oatProxyUrl: string 97 | ): Promise { 98 | return await BrowserOAuthClient.load({ 99 | clientId: `${oatProxyUrl}/oauth/downstream/client-metadata.json`, 100 | handleResolver: oatProxyUrl, 101 | responseMode: "query", 102 | 103 | // Lie to the oauth client and use our upstream server instead 104 | fetch: (input, init) => fetchWithLies(oatProxyUrl, input, init), 105 | }); 106 | } 107 | ``` 108 | 109 | # Partial List of Endpoints 110 | 111 | These can be useful for debugging purposes: 112 | 113 | | URL | Description | 114 | | ---------------------------------------- | ------------------------------------------------------------------------------ | 115 | | `/oauth/downstream/client-metadata.json` | "Public" client metadata document presented to the "downstream" browser client | 116 | | `/oauth/upstream/client-metadata.json` | "Confidential" client metadata presented to the "upstream" PDS | 117 | 118 | # Building 119 | 120 | ``` 121 | make 122 | ``` 123 | 124 | # TODO 125 | 126 | - Many tests 127 | - Built-in TLS support 128 | - Simple local example 129 | - Docker image 130 | - Postgres support 131 | - Document usage as a library 132 | - Document usage on a worker of some kind 133 | - Document usage with atcute 134 | - Ship `@streamplace/atproto-oauth-client-isomorphic` that tells lies 135 | automatically 136 | 137 | # Credits 138 | 139 | This library brought to you by 140 | [Streamplace](https://github.com/streamplace/streamplace). 141 | ["Upstream" Go ATProto OAuth client forked from haileyok](https://github.com/haileyok/atproto-oauth-golang). 142 | -------------------------------------------------------------------------------- /client-metadata.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_name": "Example OAuth Provider", 3 | "client_uri": "https://example.com", 4 | "policy_uri": "https://example.com/policy", 5 | "tos_uri": "https://example.com/terms", 6 | "logo_uri": "https://example.com/logo.png", 7 | "redirect_uris": ["https://example.com/login"], 8 | "scope": "atproto transition:generic", 9 | "contacts": ["admin@example.com"] 10 | } 11 | -------------------------------------------------------------------------------- /cmd/oatproxy/key_table.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "time" 7 | 8 | oauth_helpers "github.com/streamplace/atproto-oauth-golang/helpers" 9 | "github.com/lestrrat-go/jwx/v2/jwk" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type Key struct { 14 | ID string `gorm:"primaryKey"` 15 | CreatedAt time.Time 16 | UpdatedAt time.Time 17 | DeletedAt gorm.DeletedAt `gorm:"index"` 18 | Key string 19 | } 20 | 21 | func (s *Store) GetKey(id string) (jwk.Key, error) { 22 | var key Key 23 | err := s.DB.Where("id = ?", id).First(&key).Error 24 | if errors.Is(err, gorm.ErrRecordNotFound) { 25 | return s.GenerateKey(id) 26 | } 27 | return jwk.ParseKey([]byte(key.Key)) 28 | } 29 | 30 | func (s *Store) GenerateKey(id string) (jwk.Key, error) { 31 | k, err := oauth_helpers.GenerateKey(nil) 32 | if err != nil { 33 | return nil, err 34 | } 35 | bs, err := json.Marshal(k) 36 | if err != nil { 37 | return nil, err 38 | } 39 | err = s.DB.Create(&Key{ 40 | ID: id, 41 | Key: string(bs), 42 | }).Error 43 | if err != nil { 44 | return nil, err 45 | } 46 | s.Logger.Info("generated key", "id", id) 47 | return k, nil 48 | } 49 | -------------------------------------------------------------------------------- /cmd/oatproxy/oatproxy_main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "os" 12 | "time" 13 | 14 | "github.com/labstack/echo/v4" 15 | "github.com/lmittmann/tint" 16 | "github.com/peterbourgon/ff/v3" 17 | "github.com/streamplace/oatproxy/pkg/oatproxy" 18 | ) 19 | 20 | func main() { 21 | err := Run() 22 | if err != nil { 23 | slog.Error("exited uncleanly", "error", err) 24 | os.Exit(1) 25 | } 26 | } 27 | 28 | const UPSTREAM_KEY = "upstream" 29 | const DOWNSTREAM_KEY = "downstream" 30 | 31 | func Run() error { 32 | flag.Set("logtostderr", "true") 33 | fs := flag.NewFlagSet("oatproxy", flag.ExitOnError) 34 | noColor := fs.Bool("no-color", false, "disable colorized logging") 35 | host := fs.String("host", "", "public HTTPS address where this OAuth provider is hosted (ex example.com, no https:// prefix)") 36 | dbPath := fs.String("db", "oatproxy.sqlite3", "path to the database file or postgres connection string") 37 | verbose := fs.Bool("v", false, "enable verbose logging") 38 | scope := fs.String("scope", "atproto transition:generic", "scope to use for the OAuth provider") 39 | clientMetadata := fs.String("client-metadata", "", "JSON client metadata or path to JSON file containing client metadata") 40 | httpAddr := fs.String("http-addr", ":8080", "HTTP address to listen on") 41 | upstreamHost := fs.String("upstream-host", "", "act as a reverse proxy for this upstream host (ex http://localhost:8081)") 42 | defaultPDS := fs.String("default-pds", "", "default PDS to use if no handle is provided") 43 | // version := fs.Bool("version", false, "print version and exit") 44 | 45 | err := ff.Parse( 46 | fs, os.Args[1:], 47 | ff.WithEnvVarPrefix("OATPROXY"), 48 | ) 49 | if err != nil { 50 | return err 51 | } 52 | err = flag.CommandLine.Parse(nil) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if *host == "" { 58 | return fmt.Errorf("host is required") 59 | } 60 | 61 | opts := &tint.Options{ 62 | Level: slog.LevelInfo, 63 | TimeFormat: time.RFC3339, 64 | NoColor: *noColor, 65 | } 66 | if *verbose { 67 | opts.Level = slog.LevelDebug 68 | } 69 | logger := slog.New( 70 | tint.NewHandler(os.Stderr, opts), 71 | ) 72 | 73 | slog.SetDefault(logger) 74 | 75 | store, err := NewStore(*dbPath, logger, *verbose) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | var meta *oatproxy.OAuthClientMetadata 81 | if (*clientMetadata)[0] != '{' { 82 | // path 83 | bs, err := os.ReadFile(*clientMetadata) 84 | if err != nil { 85 | return err 86 | } 87 | meta = &oatproxy.OAuthClientMetadata{} 88 | err = json.Unmarshal(bs, meta) 89 | if err != nil { 90 | return err 91 | } 92 | } else { 93 | // JSON 94 | err = json.Unmarshal([]byte(*clientMetadata), meta) 95 | if err != nil { 96 | return err 97 | } 98 | } 99 | 100 | upstreamKey, err := store.GetKey(UPSTREAM_KEY) 101 | if err != nil { 102 | return err 103 | } 104 | downstreamKey, err := store.GetKey(DOWNSTREAM_KEY) 105 | if err != nil { 106 | return err 107 | } 108 | o := oatproxy.New(&oatproxy.Config{ 109 | Host: *host, 110 | CreateOAuthSession: store.CreateOAuthSession, 111 | UpdateOAuthSession: store.UpdateOAuthSession, 112 | GetOAuthSession: store.GetOAuthSession, 113 | Scope: *scope, 114 | ClientMetadata: meta, 115 | UpstreamJWK: upstreamKey, 116 | DownstreamJWK: downstreamKey, 117 | DefaultPDS: *defaultPDS, 118 | }) 119 | 120 | if *upstreamHost != "" { 121 | reverse := &httputil.ReverseProxy{ 122 | Rewrite: func(r *httputil.ProxyRequest) { 123 | u, err := url.Parse(*upstreamHost) 124 | if err != nil { 125 | logger.Error("failed to parse proxy host", "error", err) 126 | return 127 | } 128 | u.RawPath = r.In.URL.RawPath 129 | u.RawQuery = r.In.URL.RawQuery 130 | logger.Info("proxying request", "url", u) 131 | r.SetURL(u) 132 | }, 133 | } 134 | 135 | reverseEcho := func(c echo.Context) error { 136 | reverse.ServeHTTP(c.Response().Writer, c.Request()) 137 | c.Response().Committed = true 138 | return nil 139 | } 140 | 141 | o.Echo.Any("/*", reverseEcho) 142 | } 143 | 144 | server := &http.Server{ 145 | Addr: *httpAddr, 146 | Handler: o.Echo, 147 | } 148 | 149 | logger.Info("starting server", "addr", *httpAddr) 150 | if err := server.ListenAndServe(); err != nil { 151 | return fmt.Errorf("server error: %w", err) 152 | } 153 | 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /cmd/oatproxy/oatproxy_store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "os" 7 | "time" 8 | 9 | "github.com/lmittmann/tint" 10 | slogGorm "github.com/orandin/slog-gorm" 11 | "github.com/streamplace/oatproxy/pkg/oatproxy" 12 | "gorm.io/driver/sqlite" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | type Store struct { 17 | DB *gorm.DB 18 | Logger *slog.Logger 19 | } 20 | 21 | func NewStore(dbPath string, logger *slog.Logger, verbose bool) (*Store, error) { 22 | gormLogger := slogGorm.New( 23 | slogGorm.WithHandler(tint.NewHandler(os.Stderr, &tint.Options{ 24 | TimeFormat: time.RFC3339, 25 | })), 26 | ) 27 | if verbose { 28 | gormLogger = slogGorm.New( 29 | slogGorm.WithHandler(tint.NewHandler(os.Stderr, &tint.Options{ 30 | TimeFormat: time.RFC3339, 31 | })), 32 | slogGorm.WithTraceAll(), 33 | ) 34 | } 35 | db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ 36 | Logger: gormLogger, 37 | }) 38 | if err != nil { 39 | return nil, err 40 | } 41 | db.AutoMigrate(&oatproxy.OAuthSession{}, &Key{}) 42 | return &Store{DB: db, Logger: logger}, nil 43 | } 44 | 45 | func (s *Store) CreateOAuthSession(id string, session *oatproxy.OAuthSession) error { 46 | return s.DB.Create(session).Error 47 | } 48 | 49 | func (s *Store) GetOAuthSession(id string) (*oatproxy.OAuthSession, error) { 50 | var session oatproxy.OAuthSession 51 | if err := s.DB.Where("downstream_dpop_jkt = ?", id).First(&session).Error; err != nil { 52 | if errors.Is(err, gorm.ErrRecordNotFound) { 53 | return nil, nil 54 | } 55 | return nil, err 56 | } 57 | return &session, nil 58 | } 59 | 60 | func (s *Store) UpdateOAuthSession(id string, session *oatproxy.OAuthSession) error { 61 | res := s.DB.Model(&oatproxy.OAuthSession{}).Where("downstream_dpop_jkt = ?", id).Updates(session) 62 | if res.Error != nil { 63 | return res.Error 64 | } 65 | if res.RowsAffected == 0 { 66 | return errors.New("no rows affected") 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/streamplace/oatproxy 2 | 3 | go 1.24.2 4 | 5 | replace github.com/AxisCommunications/go-dpop => github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 6 | 7 | require ( 8 | github.com/AxisCommunications/go-dpop v1.1.2 9 | github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 10 | github.com/golang-jwt/jwt/v5 v5.2.2 11 | github.com/google/uuid v1.6.0 12 | github.com/labstack/echo/v4 v4.13.3 13 | github.com/lestrrat-go/jwx/v2 v2.1.6 14 | github.com/lmittmann/tint v1.1.0 15 | github.com/orandin/slog-gorm v1.4.0 16 | github.com/peterbourgon/ff/v3 v3.4.0 17 | github.com/streamplace/atproto-oauth-golang v0.0.0-20250521042753-9cfa9e504155 18 | github.com/stretchr/testify v1.10.0 19 | go.opentelemetry.io/otel v1.35.0 20 | gorm.io/driver/sqlite v1.5.7 21 | gorm.io/gorm v1.26.1 22 | ) 23 | 24 | require ( 25 | github.com/carlmjohnson/versioninfo v0.22.5 // indirect 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 27 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 28 | github.com/felixge/httpsnoop v1.0.4 // indirect 29 | github.com/go-logr/logr v1.4.2 // indirect 30 | github.com/go-logr/stdr v1.2.2 // indirect 31 | github.com/goccy/go-json v0.10.3 // indirect 32 | github.com/gogo/protobuf v1.3.2 // indirect 33 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 34 | github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 35 | github.com/hashicorp/golang-lru v1.0.2 // indirect 36 | github.com/ipfs/bbloom v0.0.4 // indirect 37 | github.com/ipfs/go-block-format v0.2.0 // indirect 38 | github.com/ipfs/go-cid v0.4.1 // indirect 39 | github.com/ipfs/go-datastore v0.6.0 // indirect 40 | github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 41 | github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 42 | github.com/ipfs/go-ipfs-util v0.0.3 // indirect 43 | github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 44 | github.com/ipfs/go-ipld-format v0.6.0 // indirect 45 | github.com/ipfs/go-log v1.0.5 // indirect 46 | github.com/ipfs/go-log/v2 v2.5.1 // indirect 47 | github.com/ipfs/go-metrics-interface v0.0.1 // indirect 48 | github.com/jbenet/goprocess v0.1.4 // indirect 49 | github.com/jinzhu/inflection v1.0.0 // indirect 50 | github.com/jinzhu/now v1.1.5 // indirect 51 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 52 | github.com/labstack/gommon v0.4.2 // indirect 53 | github.com/lestrrat-go/blackmagic v1.0.3 // indirect 54 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 55 | github.com/lestrrat-go/httprc v1.0.6 // indirect 56 | github.com/lestrrat-go/iter v1.0.2 // indirect 57 | github.com/lestrrat-go/option v1.0.1 // indirect 58 | github.com/mattn/go-colorable v0.1.13 // indirect 59 | github.com/mattn/go-isatty v0.0.20 // indirect 60 | github.com/mattn/go-sqlite3 v1.14.22 // indirect 61 | github.com/minio/sha256-simd v1.0.1 // indirect 62 | github.com/mr-tron/base58 v1.2.0 // indirect 63 | github.com/multiformats/go-base32 v0.1.0 // indirect 64 | github.com/multiformats/go-base36 v0.2.0 // indirect 65 | github.com/multiformats/go-multibase v0.2.0 // indirect 66 | github.com/multiformats/go-multihash v0.2.3 // indirect 67 | github.com/multiformats/go-varint v0.0.7 // indirect 68 | github.com/opentracing/opentracing-go v1.2.0 // indirect 69 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 70 | github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 71 | github.com/segmentio/asm v1.2.0 // indirect 72 | github.com/spaolacci/murmur3 v1.1.0 // indirect 73 | github.com/valyala/bytebufferpool v1.0.0 // indirect 74 | github.com/valyala/fasttemplate v1.2.2 // indirect 75 | github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 76 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 77 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 78 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 79 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 80 | go.uber.org/atomic v1.11.0 // indirect 81 | go.uber.org/multierr v1.11.0 // indirect 82 | go.uber.org/zap v1.26.0 // indirect 83 | golang.org/x/crypto v0.32.0 // indirect 84 | golang.org/x/net v0.33.0 // indirect 85 | golang.org/x/sys v0.31.0 // indirect 86 | golang.org/x/text v0.21.0 // indirect 87 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 88 | gopkg.in/yaml.v3 v3.0.1 // indirect 89 | lukechampine.com/blake3 v1.2.1 // indirect 90 | ) 91 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 3 | github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4= 4 | github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 5 | github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 6 | github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 13 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 14 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 15 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 16 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 17 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 18 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 19 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 20 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 21 | github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 22 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 23 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 24 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 25 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 26 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 27 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 28 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 29 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 30 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 31 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 34 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 35 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 36 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 37 | github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 38 | github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 39 | github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 40 | github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 41 | github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 42 | github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 43 | github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 44 | github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 45 | github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 46 | github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 47 | github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 48 | github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 49 | github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 50 | github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 51 | github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 52 | github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 53 | github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 54 | github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 55 | github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 56 | github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 57 | github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 58 | github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 59 | github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 60 | github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 61 | github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 62 | github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 63 | github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 64 | github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 65 | github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 66 | github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 67 | github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 68 | github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 69 | github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 70 | github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 71 | github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 72 | github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 73 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 74 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 75 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 76 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 77 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 78 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 79 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 80 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 81 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 82 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 83 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 84 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 85 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 86 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 87 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 88 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 89 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 90 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 91 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 92 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 93 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 94 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 95 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 96 | github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 97 | github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 98 | github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 99 | github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 100 | github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 101 | github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 102 | github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 103 | github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 104 | github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 105 | github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 106 | github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 107 | github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 108 | github.com/lmittmann/tint v1.1.0 h1:0hDmvuGv3U+Cep/jHpPxwjrCFjT6syam7iY7nTmA7ug= 109 | github.com/lmittmann/tint v1.1.0/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 110 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 111 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 112 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 113 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 114 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 115 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 116 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 117 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 118 | github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 119 | github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 120 | github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 121 | github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 122 | github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 123 | github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 124 | github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 125 | github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 126 | github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 127 | github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 128 | github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 129 | github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 130 | github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 131 | github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 132 | github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 133 | github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 134 | github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU= 135 | github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA= 136 | github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= 137 | github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= 138 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 139 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 140 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 141 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 142 | github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 143 | github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 144 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 145 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 146 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 147 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 148 | github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 149 | github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 150 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 151 | github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 152 | github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 153 | github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 154 | github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 155 | github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 156 | github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 157 | github.com/streamplace/atproto-oauth-golang v0.0.0-20250521042753-9cfa9e504155 h1:OAJ2Hh9XQcU77aUdxgT5tTcGKLYzwCXvPWR22V5xV5o= 158 | github.com/streamplace/atproto-oauth-golang v0.0.0-20250521042753-9cfa9e504155/go.mod h1:/AUT+i6CBJJ13AWx89XyvFdSkmDT3IoEg8pl34MaUgs= 159 | github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= 160 | github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 161 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 162 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 163 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 164 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 165 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 166 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 167 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 168 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 169 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 170 | github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 171 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 172 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 173 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 174 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 175 | github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 176 | github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 177 | github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 178 | github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 179 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 180 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 181 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 182 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 183 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 184 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 185 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 186 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 187 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 188 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 189 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 190 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 191 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 192 | go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 193 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 194 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 195 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 196 | go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 197 | go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 198 | go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 199 | go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 200 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 201 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 202 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 203 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 204 | go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 205 | go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 206 | go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 207 | go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 208 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 209 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 210 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 211 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 212 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 213 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 214 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 215 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 216 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 217 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 218 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 219 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 220 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 221 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 222 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 223 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 224 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 225 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 226 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 227 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 228 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 229 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 230 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 231 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 232 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 233 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 237 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 238 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 239 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 240 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 241 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 242 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 243 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 244 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 245 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 246 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 247 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 248 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 249 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 250 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 251 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 252 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 253 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 254 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 255 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 256 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 257 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 258 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 259 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 260 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 261 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 262 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 263 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 264 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 265 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 266 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 267 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 268 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 269 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 270 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 271 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 272 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 273 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 274 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 275 | gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= 276 | gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= 277 | gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= 278 | gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= 279 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 280 | lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 281 | lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= 282 | -------------------------------------------------------------------------------- /pkg/oatproxy/dpop_helpers.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/ed25519" 6 | "crypto/elliptic" 7 | "crypto/rsa" 8 | "encoding/base64" 9 | "encoding/json" 10 | "math/big" 11 | "strings" 12 | 13 | "github.com/AxisCommunications/go-dpop" 14 | "github.com/golang-jwt/jwt/v5" 15 | ) 16 | 17 | // all of this code borrowed from https://github.com/AxisCommunications/go-dpop 18 | // MIT license 19 | func keyFunc(t *jwt.Token) (interface{}, error) { 20 | // Return the required jwkHeader header. See https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 21 | // Used to validate the signature of the DPoP proof. 22 | jwkHeader := t.Header["jwk"] 23 | if jwkHeader == nil { 24 | return nil, dpop.ErrMissingJWK 25 | } 26 | 27 | jwkMap, ok := jwkHeader.(map[string]interface{}) 28 | if !ok { 29 | return nil, dpop.ErrMissingJWK 30 | } 31 | 32 | return parseJwk(jwkMap) 33 | } 34 | 35 | // Parses a JWK and inherently strips it of optional fields 36 | func parseJwk(jwkMap map[string]interface{}) (interface{}, error) { 37 | // Ensure that JWK kty is present and is a string. 38 | kty, ok := jwkMap["kty"].(string) 39 | if !ok { 40 | return nil, dpop.ErrInvalidProof 41 | } 42 | switch kty { 43 | case "EC": 44 | // Ensure that the required fields are present and are strings. 45 | x, ok := jwkMap["x"].(string) 46 | if !ok { 47 | return nil, dpop.ErrInvalidProof 48 | } 49 | y, ok := jwkMap["y"].(string) 50 | if !ok { 51 | return nil, dpop.ErrInvalidProof 52 | } 53 | crv, ok := jwkMap["crv"].(string) 54 | if !ok { 55 | return nil, dpop.ErrInvalidProof 56 | } 57 | 58 | // Decode the coordinates from Base64. 59 | // 60 | // According to RFC 7518, they are Base64 URL unsigned integers. 61 | // https://tools.ietf.org/html/rfc7518#section-6.3 62 | xCoordinate, err := base64urlTrailingPadding(x) 63 | if err != nil { 64 | return nil, err 65 | } 66 | yCoordinate, err := base64urlTrailingPadding(y) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | // Read the specified curve of the key. 72 | var curve elliptic.Curve 73 | switch crv { 74 | case "P-256": 75 | curve = elliptic.P256() 76 | case "P-384": 77 | curve = elliptic.P384() 78 | case "P-521": 79 | curve = elliptic.P521() 80 | default: 81 | return nil, dpop.ErrUnsupportedCurve 82 | } 83 | 84 | return &ecdsa.PublicKey{ 85 | X: big.NewInt(0).SetBytes(xCoordinate), 86 | Y: big.NewInt(0).SetBytes(yCoordinate), 87 | Curve: curve, 88 | }, nil 89 | case "RSA": 90 | // Ensure that the required fields are present and are strings. 91 | e, ok := jwkMap["e"].(string) 92 | if !ok { 93 | return nil, dpop.ErrInvalidProof 94 | } 95 | n, ok := jwkMap["n"].(string) 96 | if !ok { 97 | return nil, dpop.ErrInvalidProof 98 | } 99 | 100 | // Decode the exponent and modulus from Base64. 101 | // 102 | // According to RFC 7518, they are Base64 URL unsigned integers. 103 | // https://tools.ietf.org/html/rfc7518#section-6.3 104 | exponent, err := base64urlTrailingPadding(e) 105 | if err != nil { 106 | return nil, err 107 | } 108 | modulus, err := base64urlTrailingPadding(n) 109 | if err != nil { 110 | return nil, err 111 | } 112 | return &rsa.PublicKey{ 113 | N: big.NewInt(0).SetBytes(modulus), 114 | E: int(big.NewInt(0).SetBytes(exponent).Uint64()), 115 | }, nil 116 | case "OKP": 117 | // Ensure that the required fields are present and are strings. 118 | x, ok := jwkMap["x"].(string) 119 | if !ok { 120 | return nil, dpop.ErrInvalidProof 121 | } 122 | 123 | publicKey, err := base64urlTrailingPadding(x) 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | return ed25519.PublicKey(publicKey), nil 129 | case "OCT": 130 | return nil, dpop.ErrUnsupportedKeyAlgorithm 131 | default: 132 | return nil, dpop.ErrUnsupportedKeyAlgorithm 133 | } 134 | } 135 | 136 | // Borrowed from MicahParks/keyfunc See: https://github.com/MicahParks/keyfunc/blob/master/keyfunc.go#L56 137 | // 138 | // base64urlTrailingPadding removes trailing padding before decoding a string from base64url. Some non-RFC compliant 139 | // JWKS contain padding at the end values for base64url encoded public keys. 140 | // 141 | // Trailing padding is required to be removed from base64url encoded keys. 142 | // RFC 7517 Section 1.1 defines base64url the same as RFC 7515 Section 2: 143 | // https://datatracker.ietf.org/doc/html/rfc7517#section-1.1 144 | // https://datatracker.ietf.org/doc/html/rfc7515#section-2 145 | func base64urlTrailingPadding(s string) ([]byte, error) { 146 | s = strings.TrimRight(s, "=") 147 | return base64.RawURLEncoding.DecodeString(s) 148 | } 149 | 150 | // Strips eventual optional members of a JWK in order to be able to compute the thumbprint of it 151 | // https://datatracker.ietf.org/doc/html/rfc7638#section-3.2 152 | func getThumbprintableJwkJSONbytes(jwk map[string]interface{}) ([]byte, error) { 153 | minimalJwk, err := parseJwk(jwk) 154 | if err != nil { 155 | return nil, err 156 | } 157 | jwkHeaderJSONBytes, err := getKeyStringRepresentation(minimalJwk) 158 | if err != nil { 159 | return nil, err 160 | } 161 | return jwkHeaderJSONBytes, nil 162 | } 163 | 164 | // Returns the string representation of a key in JSON format. 165 | func getKeyStringRepresentation(key interface{}) ([]byte, error) { 166 | var keyParts interface{} 167 | switch key := key.(type) { 168 | case *ecdsa.PublicKey: 169 | // Calculate the size of the byte array representation of an elliptic curve coordinate 170 | // and ensure that the byte array representation of the key is padded correctly. 171 | bits := key.Curve.Params().BitSize 172 | keyCurveBytesSize := bits/8 + bits%8 173 | 174 | keyParts = map[string]interface{}{ 175 | "kty": "EC", 176 | "crv": key.Curve.Params().Name, 177 | "x": base64.RawURLEncoding.EncodeToString(key.X.FillBytes(make([]byte, keyCurveBytesSize))), 178 | "y": base64.RawURLEncoding.EncodeToString(key.Y.FillBytes(make([]byte, keyCurveBytesSize))), 179 | } 180 | case *rsa.PublicKey: 181 | keyParts = map[string]interface{}{ 182 | "kty": "RSA", 183 | "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), 184 | "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), 185 | } 186 | case ed25519.PublicKey: 187 | keyParts = map[string]interface{}{ 188 | "kty": "OKP", 189 | "crv": "Ed25519", 190 | "x": base64.RawURLEncoding.EncodeToString(key), 191 | } 192 | default: 193 | return nil, dpop.ErrUnsupportedKeyAlgorithm 194 | } 195 | 196 | return json.Marshal(keyParts) 197 | } 198 | -------------------------------------------------------------------------------- /pkg/oatproxy/helpers.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/AxisCommunications/go-dpop" 11 | "github.com/golang-jwt/jwt/v5" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | func boolPtr(b bool) *bool { 16 | return &b 17 | } 18 | 19 | func codeUUID(prefix string) string { 20 | uu, err := uuid.NewV7() 21 | if err != nil { 22 | panic(err) 23 | } 24 | return fmt.Sprintf("%s-%s", prefix, uu.String()) 25 | } 26 | 27 | var urnPrefix = "urn:ietf:params:oauth:request_uri:" 28 | 29 | const UUID_LENGTH = 37 30 | 31 | func makeURN(jkt string) string { 32 | uu, err := uuid.NewV7() 33 | if err != nil { 34 | panic(err) 35 | } 36 | return fmt.Sprintf("%s%s-%s", urnPrefix, uu.String(), jkt) 37 | } 38 | 39 | // urn --> jkt, uu 40 | func parseURN(urn string) (string, string, error) { 41 | if !strings.HasPrefix(urn, urnPrefix) { 42 | return "", "", fmt.Errorf("invalid URN: %s", urn) 43 | } 44 | withoutPrefix := urn[len(urnPrefix):] 45 | uu := withoutPrefix[:UUID_LENGTH] 46 | suffix := withoutPrefix[UUID_LENGTH:] 47 | return suffix, uu, nil 48 | } 49 | 50 | func makeState(jkt string) string { 51 | uu, err := uuid.NewV7() 52 | if err != nil { 53 | panic(err) 54 | } 55 | return fmt.Sprintf("%s-%s", uu.String(), jkt) 56 | } 57 | 58 | func parseState(state string) (string, string, error) { 59 | if len(state) < UUID_LENGTH { 60 | return "", "", fmt.Errorf("invalid state: %s", state) 61 | } 62 | uu := state[:UUID_LENGTH] 63 | suffix := state[UUID_LENGTH:] 64 | return suffix, uu, nil 65 | } 66 | 67 | func makeNoncePad() string { 68 | uu, err := uuid.NewV7() 69 | if err != nil { 70 | panic(err) 71 | } 72 | return fmt.Sprintf("noncepad-%s", uu.String()) 73 | } 74 | 75 | // returns jkt, nonce, error 76 | func getJKT(dpopJWT string) (string, *dpop.ProofTokenClaims, error) { 77 | var claims dpop.ProofTokenClaims 78 | token, err := jwt.ParseWithClaims(dpopJWT, &claims, keyFunc) 79 | if err != nil { 80 | return "", nil, err 81 | } 82 | jwk, ok := token.Header["jwk"].(map[string]any) 83 | if !ok { 84 | return "", nil, fmt.Errorf("missing jwk in DPoP JWT header") 85 | } 86 | jwkJSONbytes, err := getThumbprintableJwkJSONbytes(jwk) 87 | if err != nil { 88 | // keyFunc used with parseWithClaims should ensure that this can not happen but better safe than sorry. 89 | return "", nil, errors.Join(dpop.ErrInvalidProof, err) 90 | } 91 | h := sha256.New() 92 | _, err = h.Write(jwkJSONbytes) 93 | if err != nil { 94 | return "", nil, errors.Join(dpop.ErrInvalidProof, err) 95 | } 96 | b64URLjwkHash := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 97 | 98 | return b64URLjwkHash, &claims, nil 99 | } 100 | -------------------------------------------------------------------------------- /pkg/oatproxy/locks.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // NamedLocks provides a way to get a mutex for a given name/key 8 | type NamedLocks struct { 9 | mu sync.Mutex 10 | locks map[string]*sync.Mutex 11 | } 12 | 13 | // NewNamedLocks creates a new NamedLocks instance 14 | func NewNamedLocks() *NamedLocks { 15 | return &NamedLocks{ 16 | locks: make(map[string]*sync.Mutex), 17 | } 18 | } 19 | 20 | // GetLock returns the mutex for the given name, creating it if it doesn't exist 21 | func (n *NamedLocks) GetLock(name string) *sync.Mutex { 22 | n.mu.Lock() 23 | defer n.mu.Unlock() 24 | 25 | lock, exists := n.locks[name] 26 | if !exists { 27 | lock = &sync.Mutex{} 28 | n.locks[name] = lock 29 | } 30 | return lock 31 | } 32 | -------------------------------------------------------------------------------- /pkg/oatproxy/oatproxy.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/lestrrat-go/jwx/v2/jwk" 10 | ) 11 | 12 | type OATProxy struct { 13 | createOAuthSession func(id string, session *OAuthSession) error 14 | updateOAuthSession func(id string, session *OAuthSession) error 15 | userGetOAuthSession func(id string) (*OAuthSession, error) 16 | Echo *echo.Echo 17 | host string 18 | scope string 19 | upstreamJWK jwk.Key 20 | downstreamJWK jwk.Key 21 | slog *slog.Logger 22 | clientMetadata *OAuthClientMetadata 23 | defaultPDS string 24 | locks *NamedLocks 25 | } 26 | 27 | type Config struct { 28 | CreateOAuthSession func(id string, session *OAuthSession) error 29 | UpdateOAuthSession func(id string, session *OAuthSession) error 30 | GetOAuthSession func(id string) (*OAuthSession, error) 31 | Host string 32 | Scope string 33 | UpstreamJWK jwk.Key 34 | DownstreamJWK jwk.Key 35 | Slog *slog.Logger 36 | ClientMetadata *OAuthClientMetadata 37 | DefaultPDS string 38 | } 39 | 40 | func New(conf *Config) *OATProxy { 41 | e := echo.New() 42 | mySlog := conf.Slog 43 | if mySlog == nil { 44 | mySlog = slog.New(slog.NewTextHandler(os.Stderr, nil)) 45 | } 46 | o := &OATProxy{ 47 | createOAuthSession: conf.CreateOAuthSession, 48 | updateOAuthSession: conf.UpdateOAuthSession, 49 | userGetOAuthSession: conf.GetOAuthSession, 50 | Echo: e, 51 | host: conf.Host, 52 | scope: conf.Scope, 53 | upstreamJWK: conf.UpstreamJWK, 54 | downstreamJWK: conf.DownstreamJWK, 55 | slog: mySlog, 56 | clientMetadata: conf.ClientMetadata, 57 | defaultPDS: conf.DefaultPDS, 58 | // todo: this is fine for sqlite but we'll need to do an advisory lock for postgres 59 | locks: NewNamedLocks(), 60 | } 61 | o.Echo.GET("/.well-known/oauth-authorization-server", o.HandleOAuthAuthorizationServer) 62 | o.Echo.GET("/.well-known/oauth-protected-resource", o.HandleOAuthProtectedResource) 63 | o.Echo.GET("/xrpc/com.atproto.identity.resolveHandle", HandleComAtprotoIdentityResolveHandle) 64 | o.Echo.POST("/oauth/par", o.HandleOAuthPAR) 65 | o.Echo.GET("/oauth/authorize", o.HandleOAuthAuthorize) 66 | o.Echo.GET("/oauth/return", o.HandleOAuthReturn) 67 | o.Echo.POST("/oauth/token", o.DPoPNonceMiddleware(o.HandleOAuthToken)) 68 | o.Echo.POST("/oauth/revoke", o.DPoPNonceMiddleware(o.HandleOAuthRevoke)) 69 | o.Echo.GET("/oauth/upstream/client-metadata.json", o.HandleClientMetadataUpstream) 70 | o.Echo.GET("/oauth/upstream/jwks.json", o.HandleJwksUpstream) 71 | o.Echo.GET("/oauth/downstream/client-metadata.json", o.HandleClientMetadataDownstream) 72 | o.Echo.Any("/xrpc/*", o.OAuthMiddleware(o.HandleWildcard)) 73 | o.Echo.Use(o.ErrorHandlingMiddleware) 74 | return o 75 | } 76 | 77 | func (o *OATProxy) Handler() http.Handler { 78 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 | w.Header().Set("Access-Control-Allow-Origin", "*") // todo: ehhhhhhhhhhhh 80 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type,DPoP") 81 | w.Header().Set("Access-Control-Allow-Methods", "*") 82 | w.Header().Set("Access-Control-Expose-Headers", "DPoP-Nonce") 83 | o.Echo.ServeHTTP(w, r) 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_0_metadata.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/labstack/echo/v4" 10 | "github.com/streamplace/atproto-oauth-golang/helpers" 11 | ) 12 | 13 | func (o *OATProxy) HandleOAuthAuthorizationServer(c echo.Context) error { 14 | c.Response().Header().Set("Access-Control-Allow-Origin", "*") 15 | c.Response().Header().Set("Content-Type", "application/json") 16 | c.Response().WriteHeader(200) 17 | json.NewEncoder(c.Response().Writer).Encode(generateOAuthServerMetadata(o.host)) 18 | return nil 19 | } 20 | 21 | func (o *OATProxy) HandleOAuthProtectedResource(c echo.Context) error { 22 | return c.JSON(200, map[string]interface{}{ 23 | "resource": fmt.Sprintf("https://%s", o.host), 24 | "authorization_servers": []string{ 25 | fmt.Sprintf("https://%s", o.host), 26 | }, 27 | "scopes_supported": []string{}, 28 | "bearer_methods_supported": []string{ 29 | "header", 30 | }, 31 | "resource_documentation": "https://atproto.com", 32 | }) 33 | } 34 | 35 | func (o *OATProxy) HandleClientMetadataUpstream(c echo.Context) error { 36 | meta := o.GetUpstreamMetadata() 37 | return c.JSON(200, meta) 38 | } 39 | 40 | func (o *OATProxy) HandleJwksUpstream(c echo.Context) error { 41 | pubKey, err := o.upstreamJWK.PublicKey() 42 | if err != nil { 43 | return echo.NewHTTPError(http.StatusInternalServerError, "could not get public key") 44 | } 45 | return c.JSON(200, helpers.CreateJwksResponseObject(pubKey)) 46 | } 47 | 48 | func (o *OATProxy) HandleClientMetadataDownstream(c echo.Context) error { 49 | redirectURI := c.QueryParam("redirect_uri") 50 | meta, err := o.GetDownstreamMetadata(redirectURI) 51 | if err != nil { 52 | return err 53 | } 54 | return c.JSON(200, meta) 55 | } 56 | 57 | func (o *OATProxy) GetUpstreamMetadata() *OAuthClientMetadata { 58 | meta := *o.clientMetadata 59 | meta.ClientID = fmt.Sprintf("https://%s/oauth/upstream/client-metadata.json", o.host) 60 | meta.JwksURI = fmt.Sprintf("https://%s/oauth/upstream/jwks.json", o.host) 61 | meta.ClientURI = fmt.Sprintf("https://%s", o.host) 62 | meta.TokenEndpointAuthMethod = "private_key_jwt" 63 | meta.ResponseTypes = []string{"code"} 64 | meta.GrantTypes = []string{"authorization_code", "refresh_token"} 65 | meta.DPoPBoundAccessTokens = boolPtr(true) 66 | meta.TokenEndpointAuthSigningAlg = "ES256" 67 | meta.RedirectURIs = []string{fmt.Sprintf("https://%s/oauth/return", o.host)} 68 | return &meta 69 | } 70 | 71 | func generateOAuthServerMetadata(host string) map[string]any { 72 | oauthServerMetadata := map[string]any{ 73 | "issuer": fmt.Sprintf("https://%s", host), 74 | "request_parameter_supported": true, 75 | "request_uri_parameter_supported": true, 76 | "require_request_uri_registration": true, 77 | "scopes_supported": []string{"atproto", "transition:generic", "transition:chat.bsky"}, 78 | "subject_types_supported": []string{"public"}, 79 | "response_types_supported": []string{"code"}, 80 | "response_modes_supported": []string{"query", "fragment", "form_post"}, 81 | "grant_types_supported": []string{"authorization_code", "refresh_token"}, 82 | "code_challenge_methods_supported": []string{"S256"}, 83 | "ui_locales_supported": []string{"en-US"}, 84 | "display_values_supported": []string{"page", "popup", "touch"}, 85 | "authorization_response_iss_parameter_supported": true, 86 | "request_object_encryption_alg_values_supported": []string{}, 87 | "request_object_encryption_enc_values_supported": []string{}, 88 | "jwks_uri": fmt.Sprintf("https://%s/oauth/jwks", host), 89 | "authorization_endpoint": fmt.Sprintf("https://%s/oauth/authorize", host), 90 | "token_endpoint": fmt.Sprintf("https://%s/oauth/token", host), 91 | "token_endpoint_auth_methods_supported": []string{"none", "private_key_jwt"}, 92 | "revocation_endpoint": fmt.Sprintf("https://%s/oauth/revoke", host), 93 | "introspection_endpoint": fmt.Sprintf("https://%s/oauth/introspect", host), 94 | "pushed_authorization_request_endpoint": fmt.Sprintf("https://%s/oauth/par", host), 95 | "require_pushed_authorization_requests": true, 96 | "client_id_metadata_document_supported": true, 97 | "request_object_signing_alg_values_supported": []string{ 98 | "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 99 | "ES256", "ES256K", "ES384", "ES512", "none", 100 | }, 101 | "token_endpoint_auth_signing_alg_values_supported": []string{ 102 | "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 103 | "ES256", "ES256K", "ES384", "ES512", 104 | }, 105 | "dpop_signing_alg_values_supported": []string{ 106 | "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 107 | "ES256", "ES256K", "ES384", "ES512", 108 | }, 109 | } 110 | return oauthServerMetadata 111 | } 112 | 113 | func (o *OATProxy) GetDownstreamMetadata(redirectURI string) (*OAuthClientMetadata, error) { 114 | meta := *o.clientMetadata 115 | meta.ClientID = fmt.Sprintf("https://%s/oauth/downstream/client-metadata.json", o.host) 116 | meta.ClientURI = fmt.Sprintf("https://%s", o.host) 117 | meta.TokenEndpointAuthMethod = "none" 118 | meta.ResponseTypes = []string{"code"} 119 | meta.GrantTypes = []string{"authorization_code", "refresh_token"} 120 | meta.DPoPBoundAccessTokens = boolPtr(true) 121 | meta.ApplicationType = "web" 122 | if redirectURI != "" { 123 | found := false 124 | for _, uri := range meta.RedirectURIs { 125 | if uri == redirectURI { 126 | found = true 127 | break 128 | } 129 | } 130 | if !found { 131 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", redirectURI)) 132 | } 133 | meta.RedirectURIs = []string{redirectURI} 134 | } 135 | 136 | for i, uri := range meta.RedirectURIs { 137 | lie, err := redirectLiar(uri, meta.ClientURI) 138 | if err != nil { 139 | return nil, err 140 | } 141 | meta.RedirectURIs[i] = lie 142 | } 143 | 144 | return &meta, nil 145 | } 146 | 147 | const REDIRECT_LIAR_QUERY_PARAM = "oatproxyActualRedirect" 148 | 149 | func redirectLiar(redirectURI string, clientURI string) (string, error) { 150 | redirectURL, err := url.Parse(redirectURI) 151 | if err != nil { 152 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s", redirectURI)) 153 | } 154 | clientURL, err := url.Parse(clientURI) 155 | if err != nil { 156 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid client_uri: %s", clientURI)) 157 | } 158 | 159 | if redirectURL.Host == clientURL.Host { 160 | return redirectURI, nil 161 | } 162 | 163 | // When redirect URI host doesn't match client URI host, create a special redirect URL 164 | // that points to the client host with the actual redirect as a query parameter 165 | encodedRedirect := url.QueryEscape(redirectURI) 166 | return fmt.Sprintf("https://%s?%s=%s", clientURL.Host, REDIRECT_LIAR_QUERY_PARAM, encodedRedirect), nil 167 | } 168 | 169 | // redirectTruther detects if a URL contains an actualRedirect query parameter 170 | // and returns the real redirect URL if found, otherwise returns the original URL unchanged 171 | func redirectTruther(redirectURI string) (string, error) { 172 | redirectURL, err := url.Parse(redirectURI) 173 | if err != nil { 174 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s", redirectURI)) 175 | } 176 | 177 | // Check if the URL has an actualRedirect query parameter 178 | actualRedirect := redirectURL.Query().Get(REDIRECT_LIAR_QUERY_PARAM) 179 | if actualRedirect == "" { 180 | // No actualRedirect parameter, return the original URL 181 | return redirectURI, nil 182 | } 183 | 184 | // Decode the actualRedirect parameter 185 | decodedRedirect, err := url.QueryUnescape(actualRedirect) 186 | if err != nil { 187 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid actualRedirect parameter: %s", actualRedirect)) 188 | } 189 | 190 | // Validate that the decoded redirect is a valid URL 191 | _, err = url.Parse(decodedRedirect) 192 | if err != nil { 193 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid actualRedirect URL: %s", decodedRedirect)) 194 | } 195 | 196 | return decodedRedirect, nil 197 | } 198 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_1_par.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "slices" 11 | "time" 12 | 13 | "github.com/AxisCommunications/go-dpop" 14 | "github.com/labstack/echo/v4" 15 | "go.opentelemetry.io/otel" 16 | ) 17 | 18 | type PAR struct { 19 | ClientID string `json:"client_id"` 20 | RedirectURI string `json:"redirect_uri"` 21 | CodeChallenge string `json:"code_challenge"` 22 | CodeChallengeMethod string `json:"code_challenge_method"` 23 | State string `json:"state"` 24 | LoginHint string `json:"login_hint"` 25 | ResponseMode string `json:"response_mode"` 26 | ResponseType string `json:"response_type"` 27 | Scope string `json:"scope"` 28 | } 29 | 30 | type PARResponse struct { 31 | RequestURI string `json:"request_uri"` 32 | ExpiresIn int `json:"expires_in"` 33 | } 34 | 35 | var ErrFirstNonce = echo.NewHTTPError(http.StatusBadRequest, "first time seeing this key, come back with a nonce") 36 | 37 | func (o *OATProxy) HandleOAuthPAR(c echo.Context) error { 38 | ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthPAR") 39 | defer span.End() 40 | c.Response().Header().Set("Access-Control-Allow-Origin", "*") 41 | var par PAR 42 | if err := json.NewDecoder(c.Request().Body).Decode(&par); err != nil { 43 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 44 | } 45 | 46 | dpopHeader := c.Request().Header.Get("DPoP") 47 | if dpopHeader == "" { 48 | return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") 49 | } 50 | 51 | resp, err := o.NewPAR(ctx, c, &par, dpopHeader) 52 | if errors.Is(err, ErrFirstNonce) { 53 | res := map[string]interface{}{ 54 | "error": "use_dpop_nonce", 55 | "error_description": "Authorization server requires nonce in DPoP proof", 56 | } 57 | return c.JSON(http.StatusBadRequest, res) 58 | } else if err != nil { 59 | return err 60 | } 61 | return c.JSON(http.StatusCreated, resp) 62 | } 63 | 64 | func (o *OATProxy) NewPAR(ctx context.Context, c echo.Context, par *PAR, dpopHeader string) (*PARResponse, error) { 65 | jkt, claims, err := getJKT(dpopHeader) 66 | if err != nil { 67 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get JKT from DPoP header header=%s: %s", dpopHeader, err)) 68 | } 69 | session, err := o.getOAuthSession(jkt) 70 | if err != nil { 71 | return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to load OAuth session: %s", err)) 72 | } 73 | // special case - if this is the first request, we need to send it back for a new nonce 74 | if session == nil { 75 | _, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{ 76 | Nonce: claims.Nonce, 77 | TimeWindow: &dpopTimeWindow, 78 | }) 79 | if err != nil { 80 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse DPoP header: %s", err)) 81 | } 82 | newNoncePad := makeNoncePad() 83 | err = o.createOAuthSession(jkt, &OAuthSession{ 84 | DownstreamDPoPJKT: jkt, 85 | DownstreamDPoPNoncePad: newNoncePad, 86 | }) 87 | if err != nil { 88 | return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth session: %s", err)) 89 | } 90 | nonces := generateValidNonces(newNoncePad, time.Now()) 91 | // come back later, nerd 92 | c.Response().Header().Set("DPoP-Nonce", nonces[0]) 93 | return nil, ErrFirstNonce 94 | } 95 | nonces := generateValidNonces(session.DownstreamDPoPNoncePad, time.Now()) 96 | if !slices.Contains(nonces, claims.Nonce) { 97 | return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid nonce") 98 | } 99 | proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{ 100 | Nonce: claims.Nonce, 101 | TimeWindow: &dpopTimeWindow, 102 | }) 103 | // Check the error type to determine response 104 | if err != nil { 105 | // if ok := errors.Is(err, dpop.ErrInvalidProof); ok { 106 | // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", nil) 107 | // return 108 | // } 109 | // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", err) 110 | // return 111 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid DPoP proof: %s", err)) 112 | } 113 | if proof.PublicKey() != jkt { 114 | panic("invalid code path: parsed DPoP proof twice and got different keys?!") 115 | } 116 | 117 | clientMetadata, err := o.GetDownstreamMetadata(par.RedirectURI) 118 | if err != nil { 119 | return nil, err 120 | } 121 | if par.ClientID != clientMetadata.ClientID { 122 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid client_id: expected %s, got %s", clientMetadata.ClientID, par.ClientID)) 123 | } 124 | 125 | if !slices.Contains(clientMetadata.RedirectURIs, par.RedirectURI) { 126 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", par.RedirectURI)) 127 | } 128 | 129 | if par.CodeChallengeMethod != "S256" { 130 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid code challenge method: expected S256, got %s", par.CodeChallengeMethod)) 131 | } 132 | 133 | if par.ResponseMode != "query" { 134 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response mode: expected query, got %s", par.ResponseMode)) 135 | } 136 | 137 | if par.ResponseType != "code" { 138 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response type: expected code, got %s", par.ResponseType)) 139 | } 140 | 141 | if par.Scope != o.scope { 142 | return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid scope") 143 | } 144 | 145 | if par.LoginHint == "" && o.defaultPDS == "" { 146 | return nil, echo.NewHTTPError(http.StatusBadRequest, "login hint is required to find your PDS") 147 | } 148 | 149 | if par.State == "" { 150 | return nil, echo.NewHTTPError(http.StatusBadRequest, "state is required") 151 | } 152 | 153 | if par.Scope != o.scope { 154 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid scope (expected %s, got %s)", o.scope, par.Scope)) 155 | } 156 | 157 | realRedirectURI, err := redirectTruther(par.RedirectURI) 158 | if err != nil { 159 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s", err)) 160 | } 161 | 162 | urn := makeURN(jkt) 163 | 164 | err = o.updateOAuthSession(jkt, &OAuthSession{ 165 | DownstreamDPoPJKT: jkt, 166 | DownstreamPARRequestURI: urn, 167 | DownstreamCodeChallenge: par.CodeChallenge, 168 | DownstreamState: par.State, 169 | DownstreamRedirectURI: realRedirectURI, 170 | Handle: par.LoginHint, 171 | }) 172 | if err != nil { 173 | return nil, fmt.Errorf("could not create oauth session: %w", err) 174 | } 175 | c.Response().Header().Set("DPoP-Nonce", nonces[0]) 176 | 177 | resp := &PARResponse{ 178 | RequestURI: urn, 179 | ExpiresIn: int(dpopTimeWindow.Seconds()), 180 | } 181 | 182 | return resp, nil 183 | } 184 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_2_authorize.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | 11 | "github.com/labstack/echo/v4" 12 | oauth "github.com/streamplace/atproto-oauth-golang" 13 | "github.com/streamplace/atproto-oauth-golang/helpers" 14 | "go.opentelemetry.io/otel" 15 | ) 16 | 17 | func (o *OATProxy) HandleOAuthAuthorize(c echo.Context) error { 18 | ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthAuthorize") 19 | defer span.End() 20 | c.Response().Header().Set("Access-Control-Allow-Origin", "*") 21 | requestURI := c.QueryParam("request_uri") 22 | if requestURI == "" { 23 | return echo.NewHTTPError(http.StatusBadRequest, "request_uri is required") 24 | } 25 | clientID := c.QueryParam("client_id") 26 | if clientID == "" { 27 | return echo.NewHTTPError(http.StatusBadRequest, "client_id is required") 28 | } 29 | redirectURL, redirectErr := o.Authorize(ctx, requestURI, clientID) 30 | if redirectErr != nil { 31 | // we're a redirect; if we fail we need to send the user back 32 | jkt, _, err := parseURN(requestURI) 33 | if err != nil { 34 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) 35 | } 36 | 37 | session, err := o.getOAuthSession(jkt) 38 | if err != nil { 39 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) 40 | } 41 | 42 | if session == nil { 43 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no session found for jkt=%s", jkt)) 44 | } 45 | 46 | u, err := url.Parse(session.DownstreamRedirectURI) 47 | if err != nil { 48 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) 49 | } 50 | q := u.Query() 51 | q.Set("error", "authorize_failed") 52 | q.Set("error_description", redirectErr.Error()) 53 | u.RawQuery = q.Encode() 54 | return c.Redirect(http.StatusTemporaryRedirect, u.String()) 55 | } 56 | return c.Redirect(http.StatusTemporaryRedirect, redirectURL) 57 | } 58 | 59 | // downstream --> upstream transition; attempt to send user to the upstream auth server 60 | func (o *OATProxy) Authorize(ctx context.Context, requestURI, clientID string) (string, *echo.HTTPError) { 61 | downstreamMeta, err := o.GetDownstreamMetadata("") 62 | if err != nil { 63 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get downstream metadata: %s", err)) 64 | } 65 | if downstreamMeta.ClientID != clientID { 66 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("client ID mismatch: %s != %s", downstreamMeta.ClientID, clientID)) 67 | } 68 | 69 | jkt, _, err := parseURN(requestURI) 70 | if err != nil { 71 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) 72 | } 73 | 74 | session, err := o.getOAuthSession(jkt) 75 | if err != nil { 76 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) 77 | } 78 | 79 | if session == nil { 80 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no session found for jkt=%s", jkt)) 81 | } 82 | 83 | if session.Status() != OAuthSessionStatePARCreated { 84 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in par-created state: %s", session.Status())) 85 | } 86 | 87 | if session.DownstreamPARRequestURI != requestURI { 88 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("request URI mismatch: %s != %s", session.DownstreamPARRequestURI, requestURI)) 89 | } 90 | 91 | now := time.Now() 92 | session.DownstreamPARUsedAt = &now 93 | err = o.updateOAuthSession(jkt, session) 94 | if err != nil { 95 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 96 | } 97 | 98 | upstreamMeta := o.GetUpstreamMetadata() 99 | oclient, err := oauth.NewClient(oauth.ClientArgs{ 100 | ClientJwk: o.upstreamJWK, 101 | ClientId: upstreamMeta.ClientID, 102 | RedirectUri: upstreamMeta.RedirectURIs[0], 103 | }) 104 | if err != nil { 105 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth client: %s", err)) 106 | } 107 | 108 | var service string 109 | var did string 110 | if session.Handle != "" { 111 | did, err = ResolveHandle(ctx, session.Handle) 112 | if err != nil { 113 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve handle '%s': %s", session.DID, err)) 114 | } 115 | 116 | var handle2 string 117 | service, handle2, err = ResolveService(ctx, did) 118 | if err != nil { 119 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve service for DID '%s': %s", did, err)) 120 | } 121 | if handle2 != session.Handle { 122 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("handle mismatch: %s != %s", handle2, session.Handle)) 123 | } 124 | } else { 125 | service = o.defaultPDS 126 | } 127 | authserver, err := oclient.ResolvePdsAuthServer(ctx, service) 128 | if err != nil { 129 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve PDS auth server for service '%s': %s", service, err)) 130 | } 131 | 132 | authmeta, err := oclient.FetchAuthServerMetadata(ctx, authserver) 133 | if err != nil { 134 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to fetch auth server metadata from '%s': %s", authserver, err)) 135 | } 136 | 137 | k, err := helpers.GenerateKey(nil) 138 | if err != nil { 139 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate DPoP key: %s", err)) 140 | } 141 | 142 | state := makeState(jkt) 143 | 144 | opts := oauth.ParAuthRequestOpts{ 145 | State: state, 146 | } 147 | parResp, err := oclient.SendParAuthRequest(ctx, authserver, authmeta, session.Handle, upstreamMeta.Scope, k, opts) 148 | if err != nil { 149 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to send PAR auth request to '%s': %s", authserver, err)) 150 | } 151 | 152 | jwkJSON, err := json.Marshal(k) 153 | if err != nil { 154 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to marshal DPoP key to JSON: %s", err)) 155 | } 156 | 157 | u, err := url.Parse(authmeta.AuthorizationEndpoint) 158 | if err != nil { 159 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse auth server metadata: %s", err)) 160 | } 161 | u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(upstreamMeta.ClientID), parResp.RequestUri) 162 | str := u.String() 163 | 164 | session.DID = did 165 | session.PDSUrl = service 166 | session.UpstreamState = parResp.State 167 | session.UpstreamAuthServerIssuer = authserver 168 | session.UpstreamPKCEVerifier = parResp.PkceVerifier 169 | session.UpstreamDPoPNonce = parResp.DpopAuthserverNonce 170 | session.UpstreamDPoPPrivateJWK = string(jwkJSON) 171 | 172 | err = o.updateOAuthSession(jkt, session) 173 | if err != nil { 174 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 175 | } 176 | 177 | return str, nil 178 | } 179 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_3_return.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | 12 | "github.com/bluesky-social/indigo/api/atproto" 13 | "github.com/bluesky-social/indigo/xrpc" 14 | "github.com/golang-jwt/jwt/v5" 15 | "github.com/labstack/echo/v4" 16 | "github.com/lestrrat-go/jwx/v2/jwk" 17 | oauth "github.com/streamplace/atproto-oauth-golang" 18 | "go.opentelemetry.io/otel" 19 | ) 20 | 21 | func (o *OATProxy) HandleOAuthReturn(c echo.Context) error { 22 | ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthReturn") 23 | defer span.End() 24 | code := c.QueryParam("code") 25 | iss := c.QueryParam("iss") 26 | state := c.QueryParam("state") 27 | errorCode := c.QueryParam("error") 28 | errorDescription := c.QueryParam("error_description") 29 | var httpError *echo.HTTPError 30 | var redirectURL string 31 | if errorCode != "" { 32 | httpError = echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("%s (%s)", errorDescription, errorCode)) 33 | } else { 34 | redirectURL, httpError = o.Return(ctx, code, iss, state) 35 | } 36 | if httpError != nil { 37 | // we're a redirect; if we fail we need to send the user back 38 | jkt, _, err := parseState(state) 39 | if err != nil { 40 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) 41 | } 42 | 43 | session, err := o.getOAuthSession(jkt) 44 | if err != nil { 45 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) 46 | } 47 | 48 | u, err := url.Parse(session.DownstreamRedirectURI) 49 | if err != nil { 50 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) 51 | } 52 | q := u.Query() 53 | q.Set("error", "return_failed") 54 | q.Set("error_description", httpError.Error()) 55 | u.RawQuery = q.Encode() 56 | return c.Redirect(http.StatusTemporaryRedirect, u.String()) 57 | } 58 | return c.Redirect(http.StatusTemporaryRedirect, redirectURL) 59 | } 60 | 61 | func (o *OATProxy) Return(ctx context.Context, code string, iss string, state string) (string, *echo.HTTPError) { 62 | upstreamMeta := o.GetUpstreamMetadata() 63 | oclient, err := oauth.NewClient(oauth.ClientArgs{ 64 | ClientJwk: o.upstreamJWK, 65 | ClientId: upstreamMeta.ClientID, 66 | RedirectUri: upstreamMeta.RedirectURIs[0], 67 | }) 68 | 69 | jkt, _, err := parseState(state) 70 | if err != nil { 71 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse state: %s", err)) 72 | } 73 | 74 | session, err := o.getOAuthSession(jkt) 75 | if err != nil { 76 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get OAuth session: %s", err)) 77 | } 78 | if session == nil { 79 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no OAuth session found for state: %s", state)) 80 | } 81 | 82 | if session.Status() != OAuthSessionStateUpstream { 83 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in upstream state: %s", session.Status())) 84 | } 85 | 86 | if session.UpstreamState != state { 87 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("state mismatch: %s != %s", session.UpstreamState, state)) 88 | } 89 | 90 | if iss != session.UpstreamAuthServerIssuer { 91 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("issuer mismatch: %s != %s", iss, session.UpstreamAuthServerIssuer)) 92 | } 93 | 94 | key, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) 95 | if err != nil { 96 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to parse DPoP private JWK: %s", err)) 97 | } 98 | 99 | itResp, err := oclient.InitialTokenRequest(ctx, code, iss, session.UpstreamPKCEVerifier, session.UpstreamDPoPNonce, key) 100 | if err != nil { 101 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to request initial token: %s", err)) 102 | } 103 | now := time.Now() 104 | 105 | if session.DID != "" && itResp.Sub != session.DID { 106 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("sub mismatch: %s != %s", itResp.Sub, session.DID)) 107 | } 108 | 109 | if itResp.Scope != upstreamMeta.Scope { 110 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("scope mismatch: %s != %s", itResp.Scope, upstreamMeta.Scope)) 111 | } 112 | 113 | downstreamCode, err := generateAuthorizationCode() 114 | if err != nil { 115 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate downstream code: %s", err)) 116 | } 117 | 118 | expiry := now.Add(time.Second * time.Duration(itResp.ExpiresIn)).UTC() 119 | session.UpstreamAccessToken = itResp.AccessToken 120 | session.UpstreamAccessTokenExp = &expiry 121 | session.UpstreamRefreshToken = itResp.RefreshToken 122 | session.DownstreamAuthorizationCode = downstreamCode 123 | if session.DID == "" { 124 | _, handle, err := ResolveService(ctx, itResp.Sub) 125 | if err != nil { 126 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve service for DID '%s': %s", itResp.Sub, err)) 127 | } 128 | session.DID = itResp.Sub 129 | session.Handle = handle 130 | claims := jwt.RegisteredClaims{} 131 | parser := jwt.NewParser() 132 | _, _, err = parser.ParseUnverified(session.UpstreamAccessToken, &claims) 133 | if err != nil && !errors.Is(err, jwt.ErrTokenUnverifiable) { 134 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse access token: %s", err)) 135 | } 136 | if !strings.HasPrefix(claims.Audience[0], "did:web:") { 137 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid audience: %s", claims.Audience[0])) 138 | } 139 | session.PDSUrl = fmt.Sprintf("https://%s", strings.TrimPrefix(claims.Audience[0], "did:web:")) 140 | } 141 | authArgs := &oauth.XrpcAuthedRequestArgs{ 142 | Did: session.DID, 143 | AccessToken: session.UpstreamAccessToken, 144 | PdsUrl: session.PDSUrl, 145 | Issuer: session.UpstreamAuthServerIssuer, 146 | DpopPdsNonce: session.UpstreamDPoPNonce, 147 | DpopPrivateJwk: key, 148 | } 149 | 150 | xrpcClient := &oauth.XrpcClient{ 151 | OnDpopPdsNonceChanged: func(did, newNonce string) {}, 152 | } 153 | 154 | // brief check to make sure we can actually do stuff 155 | var out atproto.ServerCheckAccountStatus_Output 156 | if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.server.checkAccountStatus", nil, nil, &out); err != nil { 157 | o.slog.Error("failed to check account status", "error", err, "pdsUrl", session.PDSUrl, "issuer", session.UpstreamAuthServerIssuer, "accessToken", session.UpstreamAccessToken) 158 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to check account status: %s", err)) 159 | } 160 | 161 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 162 | if err != nil { 163 | return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 164 | } 165 | 166 | u, err := url.Parse(session.DownstreamRedirectURI) 167 | if err != nil { 168 | return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) 169 | } 170 | q := u.Query() 171 | q.Set("iss", fmt.Sprintf("https://%s", o.host)) 172 | q.Set("state", session.DownstreamState) 173 | q.Set("code", session.DownstreamAuthorizationCode) 174 | u.RawQuery = q.Encode() 175 | 176 | return u.String(), nil 177 | } 178 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_4_token.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "net/http" 10 | "net/url" 11 | "time" 12 | 13 | "github.com/AxisCommunications/go-dpop" 14 | "github.com/golang-jwt/jwt/v5" 15 | "github.com/google/uuid" 16 | "github.com/labstack/echo/v4" 17 | "go.opentelemetry.io/otel" 18 | ) 19 | 20 | type TokenRequest struct { 21 | GrantType string `json:"grant_type"` 22 | RedirectURI string `json:"redirect_uri"` 23 | Code string `json:"code"` 24 | CodeVerifier string `json:"code_verifier"` 25 | ClientID string `json:"client_id"` 26 | RefreshToken string `json:"refresh_token"` 27 | } 28 | 29 | type RevokeRequest struct { 30 | Token string `json:"token"` 31 | ClientID string `json:"client_id"` 32 | } 33 | 34 | type TokenResponse struct { 35 | AccessToken string `json:"access_token"` 36 | TokenType string `json:"token_type"` 37 | RefreshToken string `json:"refresh_token"` 38 | Scope string `json:"scope"` 39 | ExpiresIn int `json:"expires_in"` 40 | Sub string `json:"sub"` 41 | } 42 | 43 | var OAuthTokenExpiry = time.Hour * 24 44 | 45 | var dpopTimeWindow = time.Duration(30 * time.Second) 46 | 47 | func (o *OATProxy) HandleOAuthToken(c echo.Context) error { 48 | ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthToken") 49 | defer span.End() 50 | var tokenRequest TokenRequest 51 | if err := json.NewDecoder(c.Request().Body).Decode(&tokenRequest); err != nil { 52 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request: %s", err)) 53 | } 54 | 55 | dpopHeader := c.Request().Header.Get("DPoP") 56 | if dpopHeader == "" { 57 | return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") 58 | } 59 | 60 | res, err := o.Token(ctx, &tokenRequest, dpopHeader) 61 | if err != nil { 62 | return err 63 | } 64 | jkt, _, err := getJKT(dpopHeader) 65 | if err != nil { 66 | return err 67 | } 68 | sess, err := o.getOAuthSession(jkt) 69 | if err != nil { 70 | return err 71 | } 72 | if sess == nil { 73 | return echo.NewHTTPError(http.StatusBadRequest, "session not found") 74 | } 75 | nonces := generateValidNonces(sess.DownstreamDPoPNoncePad, time.Now()) 76 | c.Response().Header().Set("DPoP-Nonce", nonces[0]) 77 | 78 | return c.JSON(http.StatusOK, res) 79 | } 80 | 81 | func (o *OATProxy) Token(ctx context.Context, tokenRequest *TokenRequest, dpopHeader string) (*TokenResponse, error) { 82 | proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/token"}, dpop.ParseOptions{ 83 | Nonce: "", 84 | TimeWindow: &dpopTimeWindow, 85 | }) 86 | if err != nil { 87 | return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") 88 | } 89 | 90 | jkt := proof.PublicKey() 91 | session, err := o.getOAuthSession(jkt) 92 | if err != nil { 93 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not get oauth session: %s", err)) 94 | } 95 | 96 | if tokenRequest.GrantType == "authorization_code" { 97 | return o.AccessToken(ctx, tokenRequest, session) 98 | } else if tokenRequest.GrantType == "refresh_token" { 99 | return o.RefreshToken(ctx, tokenRequest, session) 100 | } 101 | return nil, echo.NewHTTPError(http.StatusBadRequest, "unsupported grant type") 102 | } 103 | 104 | func (o *OATProxy) AccessToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { 105 | if session.Status() != OAuthSessionStateDownstream { 106 | return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in downstream state: %s", session.Status())) 107 | } 108 | 109 | // Hash the code verifier using SHA-256 110 | hasher := sha256.New() 111 | hasher.Write([]byte(tokenRequest.CodeVerifier)) 112 | codeChallenge := hasher.Sum(nil) 113 | 114 | encodedChallenge := base64.RawURLEncoding.WithPadding(base64.NoPadding).EncodeToString(codeChallenge) 115 | 116 | if session.DownstreamCodeChallenge != encodedChallenge { 117 | return nil, fmt.Errorf("invalid code challenge") 118 | } 119 | 120 | if session.DownstreamAuthorizationCode != tokenRequest.Code { 121 | return nil, fmt.Errorf("invalid authorization code") 122 | } 123 | 124 | accessToken, err := o.generateJWT(session) 125 | if err != nil { 126 | return nil, fmt.Errorf("could not generate access token: %w", err) 127 | } 128 | 129 | refreshToken, err := generateRefreshToken() 130 | if err != nil { 131 | return nil, fmt.Errorf("could not generate refresh token: %w", err) 132 | } 133 | 134 | session.DownstreamAccessToken = accessToken 135 | session.DownstreamRefreshToken = refreshToken 136 | 137 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 138 | if err != nil { 139 | return nil, fmt.Errorf("could not update downstream session: %w", err) 140 | } 141 | 142 | return &TokenResponse{ 143 | AccessToken: accessToken, 144 | TokenType: "DPoP", 145 | RefreshToken: refreshToken, 146 | Scope: "atproto transition:generic", 147 | ExpiresIn: int(OAuthTokenExpiry.Seconds()), 148 | Sub: session.DID, 149 | }, nil 150 | } 151 | 152 | func (o *OATProxy) RefreshToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { 153 | 154 | if session.Status() != OAuthSessionStateReady { 155 | return nil, echo.NewHTTPError(http.StatusBadRequest, "session is not in ready state") 156 | } 157 | 158 | if session.DownstreamRefreshToken != tokenRequest.RefreshToken { 159 | return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid refresh token") 160 | } 161 | 162 | newJWT, err := o.generateJWT(session) 163 | if err != nil { 164 | return nil, fmt.Errorf("could not generate new access token: %w", err) 165 | } 166 | 167 | session.DownstreamAccessToken = newJWT 168 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 169 | if err != nil { 170 | return nil, fmt.Errorf("could not update downstream session: %w", err) 171 | } 172 | 173 | return &TokenResponse{ 174 | AccessToken: newJWT, 175 | TokenType: "DPoP", 176 | RefreshToken: session.DownstreamRefreshToken, 177 | Scope: "atproto transition:generic", 178 | ExpiresIn: int(OAuthTokenExpiry.Seconds()), 179 | Sub: session.DID, 180 | }, nil 181 | } 182 | 183 | func (o *OATProxy) generateJWT(session *OAuthSession) (string, error) { 184 | uu, err := uuid.NewV7() 185 | if err != nil { 186 | return "", err 187 | } 188 | downstreamMeta, err := o.GetDownstreamMetadata("") 189 | if err != nil { 190 | return "", err 191 | } 192 | now := time.Now() 193 | token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ 194 | "jti": uu.String(), 195 | "sub": session.DID, 196 | "exp": now.Add(OAuthTokenExpiry).Unix(), 197 | "iat": now.Unix(), 198 | "nbf": now.Unix(), 199 | "cnf": map[string]any{ 200 | "jkt": session.DownstreamDPoPJKT, 201 | }, 202 | "aud": fmt.Sprintf("did:web:%s", o.host), 203 | "scope": downstreamMeta.Scope, 204 | "client_id": downstreamMeta.ClientID, 205 | "iss": fmt.Sprintf("https://%s", o.host), 206 | }) 207 | 208 | var rawKey any 209 | if err := o.downstreamJWK.Raw(&rawKey); err != nil { 210 | return "", err 211 | } 212 | 213 | tokenString, err := token.SignedString(rawKey) 214 | 215 | if err != nil { 216 | return "", err 217 | } 218 | 219 | return tokenString, nil 220 | } 221 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_5_revoke.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/AxisCommunications/go-dpop" 13 | "github.com/labstack/echo/v4" 14 | "go.opentelemetry.io/otel" 15 | ) 16 | 17 | func (o *OATProxy) HandleOAuthRevoke(c echo.Context) error { 18 | ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthRevoke") 19 | defer span.End() 20 | var revokeRequest RevokeRequest 21 | if err := json.NewDecoder(c.Request().Body).Decode(&revokeRequest); err != nil { 22 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request: %s", err)) 23 | } 24 | dpopHeader := c.Request().Header.Get("DPoP") 25 | if dpopHeader == "" { 26 | return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") 27 | } 28 | err := o.Revoke(ctx, dpopHeader, &revokeRequest) 29 | if err != nil { 30 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not handle oauth revoke: %s", err)) 31 | } 32 | return c.JSON(http.StatusOK, map[string]interface{}{}) 33 | } 34 | 35 | func (o *OATProxy) Revoke(ctx context.Context, dpopHeader string, revokeRequest *RevokeRequest) error { 36 | proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/revoke"}, dpop.ParseOptions{ 37 | Nonce: "", 38 | TimeWindow: &dpopTimeWindow, 39 | }) 40 | if err != nil { 41 | return echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") 42 | } 43 | 44 | session, err := o.getOAuthSession(proof.PublicKey()) 45 | if err != nil { 46 | return fmt.Errorf("could not get downstream session: %w", err) 47 | } 48 | 49 | now := time.Now() 50 | slog.Info("revoking session by user request", "session", session.DownstreamDPoPJKT, "did", session.DID) 51 | session.RevokedAt = &now 52 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 53 | if err != nil { 54 | return fmt.Errorf("could not update downstream session: %w", err) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_middleware.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "net/url" 12 | "slices" 13 | "strings" 14 | "time" 15 | 16 | "github.com/AxisCommunications/go-dpop" 17 | "github.com/golang-jwt/jwt/v5" 18 | "github.com/labstack/echo/v4" 19 | ) 20 | 21 | var OAuthSessionContextKey = oauthSessionContextKeyType{} 22 | 23 | type oauthSessionContextKeyType struct{} 24 | 25 | var OATProxyContextKey = oatproxyContextKeyType{} 26 | 27 | type oatproxyContextKeyType struct{} 28 | 29 | func GetOAuthSession(ctx context.Context) (*OAuthSession, *XrpcClient) { 30 | o, ok := ctx.Value(OATProxyContextKey).(*OATProxy) 31 | if !ok { 32 | return nil, nil 33 | } 34 | session, ok := ctx.Value(OAuthSessionContextKey).(*OAuthSession) 35 | if !ok { 36 | return nil, nil 37 | } 38 | client, err := o.GetXrpcClient(session) 39 | if err != nil { 40 | return nil, nil 41 | } 42 | return session, client 43 | } 44 | 45 | func getMethod(method string) (dpop.HTTPVerb, error) { 46 | switch method { 47 | case "POST": 48 | return dpop.POST, nil 49 | case "GET": 50 | return dpop.GET, nil 51 | } 52 | return "", fmt.Errorf("invalid method") 53 | } 54 | func (o *OATProxy) OAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 55 | return func(c echo.Context) error { 56 | // Set CORS headers 57 | c.Response().Header().Set("Access-Control-Allow-Origin", "*") // todo: ehhhhhhhhhhhh 58 | c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type,DPoP") 59 | c.Response().Header().Set("Access-Control-Allow-Methods", "*") 60 | c.Response().Header().Set("Access-Control-Expose-Headers", "DPoP-Nonce") 61 | 62 | authHeader := c.Request().Header.Get("Authorization") 63 | if authHeader == "" { 64 | return next(c) 65 | } 66 | if !strings.HasPrefix(authHeader, "DPoP ") { 67 | return next(c) 68 | } 69 | token := strings.TrimPrefix(authHeader, "DPoP ") 70 | 71 | dpopHeader := c.Request().Header.Get("DPoP") 72 | if dpopHeader == "" { 73 | return echo.NewHTTPError(http.StatusBadRequest, "missing DPoP header") 74 | } 75 | 76 | dpopMethod, err := getMethod(c.Request().Method) 77 | if err != nil { 78 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid method: %v", err)) 79 | } 80 | 81 | u, err := url.Parse(c.Request().URL.String()) 82 | if err != nil { 83 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid url: %v", err)) 84 | } 85 | u.Scheme = "https" 86 | u.Host = c.Request().Host 87 | u.RawQuery = "" 88 | u.Fragment = "" 89 | 90 | jkt, dpopClaims, err := getJKT(dpopHeader) 91 | if err != nil { 92 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 93 | } 94 | 95 | session, err := o.getOAuthSession(jkt) 96 | if err != nil { 97 | return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("could not get oauth session: %v", err)) 98 | } 99 | if session == nil { 100 | // this can happen for stuff like getFeedSkeleton where they've submitted oauth credentials 101 | // but they're not actually for this server 102 | return next(c) 103 | } 104 | if session.RevokedAt != nil { 105 | return echo.NewHTTPError(http.StatusUnauthorized, "oauth session revoked") 106 | } 107 | 108 | validNonces := generateValidNonces(session.DownstreamDPoPNoncePad, time.Now()) 109 | if !slices.Contains(validNonces, dpopClaims.Nonce) { 110 | c.Response().Header().Set("WWW-Authenticate", `DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof"`) 111 | c.Response().Header().Set("DPoP-Nonce", validNonces[0]) 112 | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 113 | "error": "use_dpop_nonce", 114 | "error_description": "Authorization server requires nonce in DPoP proof", 115 | }) 116 | } 117 | c.Response().Header().Set("DPoP-Nonce", validNonces[0]) 118 | 119 | proof, err := dpop.Parse(dpopHeader, dpopMethod, u, dpop.ParseOptions{ 120 | Nonce: dpopClaims.Nonce, 121 | TimeWindow: &dpopTimeWindow, 122 | }) 123 | if err != nil { 124 | if errors.Is(err, dpop.ErrInvalidProof) { 125 | return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("invalid DPoP proof: %v", err)) 126 | } 127 | return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("error validating proof: %v", err)) 128 | } 129 | 130 | hasher := sha256.New() 131 | hasher.Write([]byte(token)) 132 | hash := hasher.Sum(nil) 133 | accessTokenHash := base64.RawURLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash) 134 | 135 | pubKey, err := o.downstreamJWK.PublicKey() 136 | if err != nil { 137 | return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("could not get access jwk public key: %v", err)) 138 | } 139 | 140 | var pubKeyECDSA ecdsa.PublicKey 141 | err = pubKey.Raw(&pubKeyECDSA) 142 | if err != nil { 143 | return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("could not get access jwk public key: %v", err)) 144 | } 145 | 146 | accessClaims := &dpop.BoundAccessTokenClaims{} 147 | accessTokenJWT, err := jwt.ParseWithClaims(token, accessClaims, func(token *jwt.Token) (any, error) { 148 | return &pubKeyECDSA, nil 149 | }) 150 | if err != nil { 151 | return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("could not parse access token: %v", err)) 152 | } 153 | 154 | err = proof.Validate([]byte(accessTokenHash), accessTokenJWT) 155 | if err != nil { 156 | return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("invalid proof: %v", err)) 157 | } 158 | 159 | err = session.CacheJTI(dpopClaims.ID) 160 | if err != nil { 161 | return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("could not cache jti: %v", err)) 162 | } 163 | 164 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 165 | if err != nil { 166 | return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("could not update oauth session: %v", err)) 167 | } 168 | 169 | // Set session in context 170 | c.Set("session", session) 171 | c.Set("oproxy", o) 172 | 173 | // Also set it in request context for non-echo handlers 174 | ctx := c.Request().Context() 175 | ctx = context.WithValue(ctx, oatproxyContextKeyType{}, o) 176 | ctx = context.WithValue(ctx, OAuthSessionContextKey, session) 177 | c.SetRequest(c.Request().WithContext(ctx)) 178 | return next(c) 179 | } 180 | } 181 | 182 | func (o *OATProxy) DPoPNonceMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 183 | return func(c echo.Context) error { 184 | dpopHeader := c.Request().Header.Get("DPoP") 185 | if dpopHeader == "" { 186 | return echo.NewHTTPError(http.StatusBadRequest, "missing DPoP header") 187 | } 188 | 189 | jkt, _, err := getJKT(dpopHeader) 190 | if err != nil { 191 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 192 | } 193 | 194 | session, err := o.getOAuthSession(jkt) 195 | if err != nil { 196 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 197 | } 198 | 199 | c.Set("session", session) 200 | return next(c) 201 | } 202 | } 203 | 204 | func (o *OATProxy) ErrorHandlingMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 205 | return func(c echo.Context) error { 206 | err := next(c) 207 | if err == nil { 208 | return nil 209 | } 210 | httpError, ok := err.(*echo.HTTPError) 211 | if ok { 212 | o.slog.Error("oauth error", "code", httpError.Code, "message", httpError.Message, "internal", httpError.Internal) 213 | return err 214 | } 215 | o.slog.Error("unhandled error", "error", err) 216 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_nonce.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | const EpochLength int64 = int64(time.Second * 10) 11 | const ValidEpochs = 3 12 | const NonceLength = 16 13 | 14 | // generate the valid nonces for the current epoch and the previous 2 epochs 15 | // unhashed, for testing and stuff 16 | func generateValidNoncesUnhashed(pad string, now time.Time) []string { 17 | epochElapsed := now.UnixNano() % EpochLength 18 | recentEpochStart := now.UnixNano() - epochElapsed 19 | nonces := make([]string, ValidEpochs) 20 | for i := 0; i < ValidEpochs; i++ { 21 | nonces[i] = fmt.Sprintf("%s-%d", pad, recentEpochStart-int64(i)*EpochLength) 22 | } 23 | return nonces 24 | } 25 | 26 | func generateValidNonces(pad string, now time.Time) []string { 27 | if pad == "" { 28 | panic("pad is empty") 29 | } 30 | nonces := generateValidNoncesUnhashed(pad, now) 31 | for i := range nonces { 32 | nonces[i] = hash(nonces[i]) 33 | } 34 | return nonces 35 | } 36 | 37 | func hash(nonce string) string { 38 | hash := sha256.Sum256([]byte(nonce)) 39 | str := hex.EncodeToString(hash[:]) 40 | return str[:NonceLength] 41 | } 42 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_nonce_test.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGenerateValidNonces(t *testing.T) { 11 | now := time.Unix(1747710294, 0) 12 | nonces := generateValidNoncesUnhashed("test", now) 13 | require.Equal(t, []string{ 14 | "test-1747710290000000000", 15 | "test-1747710280000000000", 16 | "test-1747710270000000000", 17 | }, nonces) 18 | 19 | nonces = generateValidNonces("test", now) 20 | require.Equal(t, []string{ 21 | "90ea642bb90fc42a", 22 | "c322613dcfa16b24", 23 | "ba4843b9cac87417", 24 | }, nonces) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/oatproxy/oauth_session.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/lestrrat-go/jwx/v2/jwk" 10 | oauth "github.com/streamplace/atproto-oauth-golang" 11 | ) 12 | 13 | var refreshWhenRemaining = time.Minute * 15 14 | 15 | // OAuthSession stores authentication data needed during the OAuth flow 16 | type OAuthSession struct { 17 | DID string `json:"did" gorm:"column:repo_did;index"` 18 | Handle string `json:"handle" gorm:"column:handle;index"` // possibly also did if they have no handle 19 | PDSUrl string `json:"pds_url" gorm:"column:pds_url;index"` 20 | 21 | // Upstream fields 22 | UpstreamState string `json:"upstream_state" gorm:"column:upstream_state;index"` 23 | UpstreamAuthServerIssuer string `json:"upstream_auth_server_issuer" gorm:"column:upstream_auth_server_issuer"` 24 | UpstreamPKCEVerifier string `json:"upstream_pkce_verifier" gorm:"column:upstream_pkce_verifier"` 25 | UpstreamDPoPNonce string `json:"upstream_dpop_nonce" gorm:"column:upstream_dpop_nonce"` 26 | UpstreamDPoPPrivateJWK string `json:"upstream_dpop_private_jwk" gorm:"column:upstream_dpop_private_jwk;type:text"` 27 | UpstreamAccessToken string `json:"upstream_access_token" gorm:"column:upstream_access_token"` 28 | UpstreamAccessTokenExp *time.Time `json:"upstream_access_token_exp" gorm:"column:upstream_access_token_exp"` 29 | UpstreamRefreshToken string `json:"upstream_refresh_token" gorm:"column:upstream_refresh_token"` 30 | 31 | // Downstream fields 32 | DownstreamDPoPNoncePad string `json:"downstream_dpop_nonce_pad" gorm:"column:downstream_dpop_nonce_pad"` 33 | DownstreamDPoPJKT string `json:"downstream_dpop_jkt" gorm:"column:downstream_dpop_jkt;primaryKey"` 34 | DownstreamAccessToken string `json:"downstream_access_token" gorm:"column:downstream_access_token;index"` 35 | DownstreamRefreshToken string `json:"downstream_refresh_token" gorm:"column:downstream_refresh_token;index"` 36 | DownstreamAuthorizationCode string `json:"downstream_authorization_code" gorm:"column:downstream_authorization_code;index"` 37 | DownstreamState string `json:"downstream_state" gorm:"column:downstream_state"` 38 | DownstreamScope string `json:"downstream_scope" gorm:"column:downstream_scope"` 39 | DownstreamCodeChallenge string `json:"downstream_code_challenge" gorm:"column:downstream_code_challenge"` 40 | DownstreamPARRequestURI string `json:"downstream_par_request_uri" gorm:"column:downstream_par_request_uri"` 41 | DownstreamPARUsedAt *time.Time `json:"downstream_par_used_at" gorm:"column:downstream_par_used_at"` 42 | DownstreamRedirectURI string `json:"downstream_redirect_uri" gorm:"column:downstream_redirect_uri"` 43 | DownstreamJTICache string `json:"downstream_jti_cache" gorm:"column:downstream_jti_cache"` 44 | 45 | // Deprecated and unused 46 | XXDONTUSEDownstreamDPoPNonce string `json:"downstream_dpop_nonce" gorm:"column:downstream_dpop_nonce"` 47 | 48 | RevokedAt *time.Time `json:"revoked_at" gorm:"column:revoked_at"` 49 | CreatedAt time.Time `json:"created_at"` 50 | UpdatedAt time.Time `json:"updated_at"` 51 | } 52 | 53 | // for gorm. this is prettier than "o_auth_sessions" 54 | func (o *OAuthSession) TableName() string { 55 | return "oauth_sessions" 56 | } 57 | 58 | type OAuthSessionStatus string 59 | 60 | const ( 61 | // We've gotten the first request and sent it back for a new nonce 62 | OAuthSessionStatePARPending OAuthSessionStatus = "par-pending" 63 | // PAR has been created, but not yet used 64 | OAuthSessionStatePARCreated OAuthSessionStatus = "par-created" 65 | // PAR has been used, but maybe upstream will fail for some reason 66 | OAuthSessionStatePARUsed OAuthSessionStatus = "par-used" 67 | // PAR has been used, we're waiting to hear back from upstream 68 | OAuthSessionStateUpstream OAuthSessionStatus = "upstream" 69 | // Upstream came back, we've issued the user a code but it hasn't been used yet 70 | OAuthSessionStateDownstream OAuthSessionStatus = "downstream" 71 | // Code has been used, everything is good 72 | OAuthSessionStateReady OAuthSessionStatus = "ready" 73 | // For any reason we're done. Revoked or expired 74 | OAuthSessionStateRejected OAuthSessionStatus = "rejected" 75 | ) 76 | 77 | func (o *OAuthSession) Status() OAuthSessionStatus { 78 | if o.RevokedAt != nil { 79 | return OAuthSessionStateRejected 80 | } 81 | if o.DownstreamAccessToken != "" { 82 | return OAuthSessionStateReady 83 | } 84 | if o.DownstreamAuthorizationCode != "" { 85 | return OAuthSessionStateDownstream 86 | } 87 | if o.UpstreamDPoPPrivateJWK != "" { 88 | return OAuthSessionStateUpstream 89 | } 90 | if o.DownstreamPARUsedAt != nil { 91 | return OAuthSessionStatePARUsed 92 | } 93 | if o.DownstreamPARRequestURI != "" { 94 | return OAuthSessionStatePARCreated 95 | } 96 | bs, _ := json.Marshal(o) 97 | fmt.Printf("unknown oauth session status: %s\n", string(bs)) 98 | // todo: this should never happen, log a warning? panic? 99 | return OAuthSessionStateRejected 100 | } 101 | 102 | type JTICacheEntry struct { 103 | JTI string `json:"jti"` 104 | Time time.Time `json:"time"` 105 | } 106 | 107 | // adds a new JTI to session.DownstreamJTICache. does not save, that's up to the caller. 108 | func (o *OAuthSession) CacheJTI(jti string) error { 109 | if o.DownstreamJTICache == "" { 110 | o.DownstreamJTICache = "[]" 111 | } 112 | 113 | entries := []JTICacheEntry{} 114 | err := json.Unmarshal([]byte(o.DownstreamJTICache), &entries) 115 | if err != nil { 116 | return fmt.Errorf("failed to unmarshal downstream jti cache: %w", err) 117 | } 118 | maxTime := EpochLength * ValidEpochs 119 | 120 | outEntries := []JTICacheEntry{} 121 | for _, entry := range entries { 122 | if entry.JTI == jti { 123 | return fmt.Errorf("jti already used") 124 | } 125 | if time.Since(entry.Time) > time.Duration(maxTime) { 126 | continue 127 | } 128 | outEntries = append(outEntries, entry) 129 | } 130 | 131 | outEntries = append(outEntries, JTICacheEntry{ 132 | JTI: jti, 133 | Time: time.Now(), 134 | }) 135 | 136 | bs, err := json.Marshal(outEntries) 137 | if err != nil { 138 | return fmt.Errorf("failed to marshal downstream jti cache: %w", err) 139 | } 140 | o.DownstreamJTICache = string(bs) 141 | return nil 142 | } 143 | 144 | func (o *OATProxy) RefreshIfNeeded(session *OAuthSession) (*OAuthSession, error) { 145 | return o.getOAuthSession(session.DownstreamDPoPJKT) 146 | } 147 | 148 | func (o *OATProxy) getOAuthSession(jkt string) (*OAuthSession, error) { 149 | lock := o.locks.GetLock(jkt) 150 | lock.Lock() 151 | defer lock.Unlock() 152 | 153 | session, err := o.userGetOAuthSession(jkt) 154 | if err != nil { 155 | return nil, err 156 | } 157 | if session == nil { 158 | return nil, nil 159 | } 160 | if session.Status() != OAuthSessionStateReady { 161 | return session, nil 162 | } 163 | 164 | if session.UpstreamAccessTokenExp.Sub(time.Now()) > refreshWhenRemaining { 165 | return session, nil 166 | } 167 | 168 | // migration! we didn't always have this field. 169 | if session.DownstreamDPoPNoncePad == "" { 170 | session.DownstreamDPoPNoncePad = makeNoncePad() 171 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 172 | if err != nil { 173 | return nil, fmt.Errorf("could not update downstream session: %w", err) 174 | } 175 | } 176 | 177 | upstreamMeta := o.GetUpstreamMetadata() 178 | 179 | oclient, err := oauth.NewClient(oauth.ClientArgs{ 180 | ClientJwk: o.upstreamJWK, 181 | ClientId: upstreamMeta.ClientID, 182 | RedirectUri: upstreamMeta.RedirectURIs[0], 183 | }) 184 | 185 | dpopKey, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) 186 | if err != nil { 187 | return nil, fmt.Errorf("failed to parse upstream dpop private key: %w", err) 188 | } 189 | 190 | // refresh upstream before returning 191 | resp, refreshErr := oclient.RefreshTokenRequest(context.Background(), session.UpstreamRefreshToken, session.UpstreamAuthServerIssuer, session.UpstreamDPoPNonce, dpopKey) 192 | if refreshErr != nil { 193 | // revoke, probably 194 | o.slog.Error("failed to refresh upstream token, revoking downstream session", "error", refreshErr) 195 | now := time.Now() 196 | session.RevokedAt = &now 197 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 198 | if err != nil { 199 | o.slog.Error("after upstream token refresh, failed to revoke downstream session", "error", err) 200 | } 201 | return nil, fmt.Errorf("failed to refresh upstream token: %w", refreshErr) 202 | } 203 | 204 | exp := time.Now().Add(time.Second * time.Duration(resp.ExpiresIn)).UTC() 205 | session.UpstreamAccessToken = resp.AccessToken 206 | session.UpstreamAccessTokenExp = &exp 207 | session.UpstreamRefreshToken = resp.RefreshToken 208 | 209 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 210 | if err != nil { 211 | return nil, fmt.Errorf("failed to update downstream session after upstream token refresh: %w", err) 212 | } 213 | 214 | o.slog.Info("refreshed upstream token", "session", session.DownstreamDPoPJKT, "did", session.DID) 215 | 216 | return session, nil 217 | } 218 | -------------------------------------------------------------------------------- /pkg/oatproxy/resolution.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "strings" 11 | 12 | comatprototypes "github.com/bluesky-social/indigo/api/atproto" 13 | "github.com/bluesky-social/indigo/atproto/syntax" 14 | "github.com/labstack/echo/v4" 15 | "go.opentelemetry.io/otel" 16 | ) 17 | 18 | // mostly borrowed from github.com/streamplace/atproto-oauth-golang, MIT license 19 | func ResolveHandle(ctx context.Context, handle string) (string, error) { 20 | var did string 21 | 22 | _, err := syntax.ParseHandle(handle) 23 | if err != nil { 24 | return "", err 25 | } 26 | 27 | recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle)) 28 | if err == nil { 29 | for _, rec := range recs { 30 | if strings.HasPrefix(rec, "did=") { 31 | did = strings.Split(rec, "did=")[1] 32 | break 33 | } 34 | } 35 | } 36 | 37 | if did == "" { 38 | req, err := http.NewRequestWithContext( 39 | ctx, 40 | "GET", 41 | fmt.Sprintf("https://%s/.well-known/atproto-did", handle), 42 | nil, 43 | ) 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | resp, err := http.DefaultClient.Do(req) 49 | if err != nil { 50 | return "", err 51 | } 52 | defer resp.Body.Close() 53 | 54 | if resp.StatusCode != http.StatusOK { 55 | io.Copy(io.Discard, resp.Body) 56 | return "", fmt.Errorf("unable to resolve handle") 57 | } 58 | 59 | b, err := io.ReadAll(resp.Body) 60 | if err != nil { 61 | return "", err 62 | } 63 | 64 | maybeDid := string(b) 65 | 66 | if _, err := syntax.ParseDID(maybeDid); err != nil { 67 | return "", fmt.Errorf("unable to resolve handle") 68 | } 69 | 70 | did = maybeDid 71 | } 72 | 73 | return did, nil 74 | } 75 | 76 | func ResolveService(ctx context.Context, did string) (string, string, error) { 77 | type Identity struct { 78 | AlsoKnownAs []string `json:"alsoKnownAs"` 79 | Service []struct { 80 | ID string `json:"id"` 81 | Type string `json:"type"` 82 | ServiceEndpoint string `json:"serviceEndpoint"` 83 | } `json:"service"` 84 | } 85 | 86 | var ustr string 87 | if strings.HasPrefix(did, "did:plc:") { 88 | ustr = fmt.Sprintf("https://plc.directory/%s", did) 89 | } else if strings.HasPrefix(did, "did:web:") { 90 | ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")) 91 | } else { 92 | return "", "", fmt.Errorf("did was not a supported did type") 93 | } 94 | 95 | req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil) 96 | if err != nil { 97 | return "", "", err 98 | } 99 | 100 | resp, err := http.DefaultClient.Do(req) 101 | if err != nil { 102 | return "", "", err 103 | } 104 | defer resp.Body.Close() 105 | 106 | if resp.StatusCode != 200 { 107 | io.Copy(io.Discard, resp.Body) 108 | return "", "", fmt.Errorf("could not find identity in plc registry") 109 | } 110 | 111 | var identity Identity 112 | if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { 113 | return "", "", err 114 | } 115 | 116 | var service string 117 | for _, svc := range identity.Service { 118 | if svc.ID == "#atproto_pds" { 119 | service = svc.ServiceEndpoint 120 | } 121 | } 122 | 123 | if service == "" { 124 | return "", "", fmt.Errorf("could not find atproto_pds service in identity services") 125 | } 126 | 127 | handle := did 128 | if len(identity.AlsoKnownAs) > 0 { 129 | handle = identity.AlsoKnownAs[0] 130 | if !strings.HasPrefix(handle, "at://") { 131 | return "", "", fmt.Errorf("handle is not a valid atproto handle: %s", handle) 132 | } 133 | handle = strings.TrimPrefix(handle, "at://") 134 | } 135 | 136 | return service, handle, nil 137 | } 138 | 139 | func HandleComAtprotoIdentityResolveHandle(c echo.Context) error { 140 | ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoIdentityResolveHandle") 141 | defer span.End() 142 | handle := c.QueryParam("handle") 143 | var out *comatprototypes.IdentityResolveHandle_Output 144 | var handleErr error 145 | // func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context,handle string) (*comatprototypes.IdentityResolveHandle_Output, error) 146 | out, handleErr = handleComAtprotoIdentityResolveHandle(ctx, handle) 147 | if handleErr != nil { 148 | return handleErr 149 | } 150 | return c.JSON(200, out) 151 | } 152 | 153 | func handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { 154 | did, err := ResolveHandle(ctx, handle) 155 | if err != nil { 156 | return nil, err 157 | } 158 | return &comatprototypes.IdentityResolveHandle_Output{Did: did}, nil 159 | } 160 | -------------------------------------------------------------------------------- /pkg/oatproxy/token_generation.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func generateRefreshToken() (string, error) { 10 | uu, err := uuid.NewV7() 11 | if err != nil { 12 | return "", err 13 | } 14 | return fmt.Sprintf("refresh-%s", uu.String()), nil 15 | } 16 | 17 | func generateAuthorizationCode() (string, error) { 18 | uu, err := uuid.NewV7() 19 | if err != nil { 20 | return "", err 21 | } 22 | return fmt.Sprintf("code-%s", uu.String()), nil 23 | } 24 | -------------------------------------------------------------------------------- /pkg/oatproxy/types.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | type OAuthClientMetadata struct { 4 | RedirectURIs []string `json:"redirect_uris"` 5 | ResponseTypes []string `json:"response_types,omitempty"` 6 | GrantTypes []string `json:"grant_types,omitempty"` 7 | Scope string `json:"scope,omitempty"` 8 | TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` 9 | TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg,omitempty"` 10 | UserinfoSignedResponseAlg string `json:"userinfo_signed_response_alg,omitempty"` 11 | UserinfoEncryptedResponseAlg string `json:"userinfo_encrypted_response_alg,omitempty"` 12 | JwksURI string `json:"jwks_uri,omitempty"` 13 | ApplicationType string `json:"application_type,omitempty"` // "web" or "native" 14 | SubjectType string `json:"subject_type,omitempty"` // "public" or "pairwise" 15 | RequestObjectSigningAlg string `json:"request_object_signing_alg,omitempty"` 16 | IDTokenSignedResponseAlg string `json:"id_token_signed_response_alg,omitempty"` 17 | AuthorizationSignedResponseAlg string `json:"authorization_signed_response_alg,omitempty"` 18 | AuthorizationEncryptedResponseEnc string `json:"authorization_encrypted_response_enc,omitempty"` 19 | AuthorizationEncryptedResponseAlg string `json:"authorization_encrypted_response_alg,omitempty"` 20 | ClientID string `json:"client_id,omitempty"` 21 | ClientName string `json:"client_name,omitempty"` 22 | ClientURI string `json:"client_uri,omitempty"` 23 | PolicyURI string `json:"policy_uri,omitempty"` 24 | TosURI string `json:"tos_uri,omitempty"` 25 | LogoURI string `json:"logo_uri,omitempty"` 26 | DefaultMaxAge int `json:"default_max_age,omitempty"` 27 | RequireAuthTime *bool `json:"require_auth_time,omitempty"` 28 | Contacts []string `json:"contacts,omitempty"` 29 | TLSClientCertificateBoundAccessTokens *bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` 30 | DPoPBoundAccessTokens *bool `json:"dpop_bound_access_tokens,omitempty"` 31 | AuthorizationDetailsTypes []string `json:"authorization_details_types,omitempty"` 32 | // Jwks *helpers.JwksResponseObject `json:"jwks,omitempty"` 33 | } 34 | -------------------------------------------------------------------------------- /pkg/oatproxy/wildcard.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/bluesky-social/indigo/xrpc" 9 | "github.com/labstack/echo/v4" 10 | "go.opentelemetry.io/otel" 11 | ) 12 | 13 | func (o *OATProxy) HandleWildcard(c echo.Context) error { 14 | ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleWildcard") 15 | defer span.End() 16 | 17 | session, client := GetOAuthSession(ctx) 18 | if session == nil { 19 | return echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 20 | } 21 | 22 | var out map[string]any 23 | 24 | // Get the last path segment in the URL 25 | path := c.Request().URL.Path 26 | segments := strings.Split(path, "/") 27 | lastSegment := segments[len(segments)-1] 28 | 29 | var xrpcType xrpc.XRPCRequestType 30 | var err error 31 | if c.Request().Method == "GET" { 32 | xrpcType = xrpc.Query 33 | queryParams := make(map[string]any) 34 | for k, v := range c.QueryParams() { 35 | for _, vv := range v { 36 | queryParams[k] = vv 37 | } 38 | } 39 | err = client.Do(ctx, xrpcType, "application/json", lastSegment, queryParams, nil, &out) 40 | } else { 41 | xrpcType = xrpc.Procedure 42 | var body map[string]any 43 | if err := c.Bind(&body); err != nil { 44 | return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("invalid body: %s", err)}) 45 | } 46 | err = client.Do(ctx, xrpcType, "application/json", lastSegment, nil, body, &out) 47 | } 48 | 49 | if err != nil { 50 | o.slog.Error("upstream xrpc error", "error", err) 51 | return err 52 | } 53 | 54 | return c.JSON(200, out) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/oatproxy/xrpc_client.go: -------------------------------------------------------------------------------- 1 | package oatproxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/bluesky-social/indigo/xrpc" 9 | oauth "github.com/streamplace/atproto-oauth-golang" 10 | "github.com/labstack/echo/v4" 11 | "github.com/lestrrat-go/jwx/v2/jwk" 12 | ) 13 | 14 | var xrpcClient *oauth.XrpcClient 15 | 16 | type XrpcClient struct { 17 | client *oauth.XrpcClient 18 | authArgs *oauth.XrpcAuthedRequestArgs 19 | } 20 | 21 | func (o *OATProxy) GetXrpcClient(session *OAuthSession) (*XrpcClient, error) { 22 | key, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to parse DPoP private JWK: %w", err) 25 | } 26 | authArgs := &oauth.XrpcAuthedRequestArgs{ 27 | Did: session.DID, 28 | AccessToken: session.UpstreamAccessToken, 29 | PdsUrl: session.PDSUrl, 30 | Issuer: session.UpstreamAuthServerIssuer, 31 | DpopPdsNonce: session.UpstreamDPoPNonce, 32 | DpopPrivateJwk: key, 33 | } 34 | 35 | xrpcClient := &oauth.XrpcClient{ 36 | OnDpopPdsNonceChanged: func(did, newNonce string) { 37 | sess, err := o.getOAuthSession(session.DownstreamDPoPJKT) 38 | if err != nil { 39 | o.slog.Error("failed to get OAuth session in OnDpopPdsNonceChanged", "error", err) 40 | return 41 | } 42 | sess.UpstreamDPoPNonce = newNonce 43 | err = o.updateOAuthSession(session.DownstreamDPoPJKT, sess) 44 | if err != nil { 45 | o.slog.Error("failed to update OAuth session in OnDpopPdsNonceChanged", "error", err) 46 | } 47 | o.slog.Info("updated OAuth session in OnDpopPdsNonceChanged", "session", sess) 48 | }, 49 | } 50 | return &XrpcClient{client: xrpcClient, authArgs: authArgs}, nil 51 | } 52 | 53 | func (c *XrpcClient) Do(ctx context.Context, kind xrpc.XRPCRequestType, inpenc, method string, params map[string]any, bodyobj any, out any) error { 54 | err := c.client.Do(ctx, c.authArgs, kind, inpenc, method, params, bodyobj, out) 55 | if err == nil { 56 | return nil 57 | } 58 | xErr, ok := err.(*xrpc.Error) 59 | if !ok { 60 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 61 | } 62 | return xErr 63 | } 64 | --------------------------------------------------------------------------------