Live video on the AT Protocol

oauth: successfully making posts and whatnot!

+232 -13
+5 -2
js/app/components/login/login.tsx
··· 12 12 import { Keyboard } from "react-native"; 13 13 import { useAppDispatch, useAppSelector } from "store/hooks"; 14 14 import { Button, Form, H3, Input, Sheet, Spinner, Text, View } from "tamagui"; 15 + import useStreamplaceNode from "hooks/useStreamplaceNode"; 15 16 16 17 export default function Login() { 17 18 const dispatch = useAppDispatch(); ··· 52 53 ); 53 54 } 54 55 56 + const { url } = useStreamplaceNode(); 57 + 55 58 return ( 56 59 <View 57 60 f={1} ··· 75 78 <Button 76 79 width="100%" 77 80 onPress={async () => { 78 - const agent = new AtpBaseClient(`http://127.0.0.1:38080`); 81 + const agent = new AtpBaseClient(url); 79 82 const res = await agent.place.stream.account.login({ 80 83 handleOrDID: handle, 81 84 }); 82 - console.log(res); 85 + window.location.href = res.data.redirectUrl; 83 86 // await dispatch(login(`https://${pds.url}`)); 84 87 }} 85 88 margin="$4"
+1 -1
pkg/atproto/client_metadata.go
··· 76 76 // } 77 77 78 78 if platform == "web" { 79 - meta.RedirectURIs = []string{fmt.Sprintf("https://%s/login", host)} 79 + meta.RedirectURIs = []string{fmt.Sprintf("https://%s/xrpc/place.stream.account.oauthReturn", host)} 80 80 meta.ApplicationType = "web" 81 81 } else { 82 82 meta.RedirectURIs = []string{fmt.Sprintf("https://%s/api/app-return/%s", host, appBundleId)}
+134 -9
pkg/atproto/oauth.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 6 7 "net/url" 8 + "time" 7 9 10 + "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/lex/util" 14 + "github.com/bluesky-social/indigo/xrpc" 8 15 oauth "github.com/haileyok/atproto-oauth-golang" 9 16 "github.com/haileyok/atproto-oauth-golang/helpers" 17 + "github.com/lestrrat-go/jwx/v2/jwk" 10 18 "stream.place/streamplace/pkg/config" 11 19 "stream.place/streamplace/pkg/log" 20 + "stream.place/streamplace/pkg/model" 12 21 "stream.place/streamplace/pkg/streamplace" 13 22 ) 14 23 15 - func Login(ctx context.Context, cli *config.CLI, input *streamplace.AccountLogin_Input) (*streamplace.AccountDefs_LoginResponse, error) { 24 + func Login(ctx context.Context, cli *config.CLI, input *streamplace.AccountLogin_Input, mod model.Model) (*streamplace.AccountDefs_LoginResponse, error) { 16 25 meta := GetMetadata("longos.iameli.link", "web", "") 17 26 oclient, err := oauth.NewClient(oauth.ClientArgs{ 18 27 ClientJwk: cli.JWK, ··· 21 30 }) 22 31 log.Log(ctx, "OAuth client information", "clientId", meta.ClientID, "redirectUri", meta.RedirectURIs[0]) 23 32 if err != nil { 24 - return nil, err 33 + return nil, fmt.Errorf("failed to create OAuth client: %w", err) 25 34 } 26 35 27 36 // If you already have a did or a URL, you can skip this step 28 37 did, err := resolveHandle(ctx, input.HandleOrDID) // returns did:plc:abc123 or did:web:test.com 29 38 if err != nil { 30 - return nil, err 39 + return nil, fmt.Errorf("failed to resolve handle '%s': %w", input.HandleOrDID, err) 31 40 } 32 41 33 42 // If you already have a URL, you can skip this step 34 43 service, err := resolveService(ctx, did) // returns https://pds.haileyok.com 35 44 if err != nil { 36 - return nil, err 45 + return nil, fmt.Errorf("failed to resolve service for DID '%s': %w", did, err) 37 46 } 38 47 39 48 authserver, err := oclient.ResolvePdsAuthServer(ctx, service) 40 49 if err != nil { 41 - return nil, err 50 + return nil, fmt.Errorf("failed to resolve PDS auth server for service '%s': %w", service, err) 42 51 } 43 52 44 53 authmeta, err := oclient.FetchAuthServerMetadata(ctx, authserver) 45 54 if err != nil { 46 - return nil, err 55 + return nil, fmt.Errorf("failed to fetch auth server metadata from '%s': %w", authserver, err) 47 56 } 48 57 49 58 k, err := helpers.GenerateKey(nil) 50 59 if err != nil { 51 - return nil, err 60 + return nil, fmt.Errorf("failed to generate DPoP key: %w", err) 52 61 } 53 62 54 63 // b, err := json.Marshal(k) ··· 58 67 59 68 parResp, err := oclient.SendParAuthRequest(ctx, authserver, authmeta, input.HandleOrDID, meta.Scope, k) 60 69 if err != nil { 61 - return nil, err 70 + return nil, fmt.Errorf("failed to send PAR auth request to '%s': %w", authserver, err) 62 71 } 63 72 64 73 log.Log(ctx, "parResp", "parResp", parResp) 65 74 66 - u, _ := url.Parse(authmeta.AuthorizationEndpoint) 75 + jwkJSON, err := json.Marshal(k) 76 + if err != nil { 77 + return nil, fmt.Errorf("failed to marshal DPoP key to JSON: %w", err) 78 + } 79 + 80 + u, err := url.Parse(authmeta.AuthorizationEndpoint) 81 + if err != nil { 82 + return nil, fmt.Errorf("failed to parse auth server metadata: %w", err) 83 + } 67 84 u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(meta.ClientID), parResp.RequestUri) 68 85 str := u.String() 69 86 87 + err = mod.CreateOAuthSession(&model.OAuthSession{ 88 + State: parResp.State, 89 + RepoDID: did, 90 + PDSUrl: service, 91 + AuthServerIssuer: authserver, 92 + PKCEVerifier: parResp.PkceVerifier, 93 + DPoPNonce: parResp.DpopAuthserverNonce, 94 + DPoPPrivateJWK: jwkJSON, 95 + }) 96 + if err != nil { 97 + return nil, fmt.Errorf("failed to create OAuth session in database: %w", err) 98 + } 99 + 70 100 return &streamplace.AccountDefs_LoginResponse{ 71 101 RedirectUrl: str, 72 102 }, nil 73 103 } 104 + 105 + var xrpcClient *oauth.XrpcClient 106 + 107 + func getXrpcClient(mod model.Model) *oauth.XrpcClient { 108 + if xrpcClient == nil { 109 + xrpcClient = &oauth.XrpcClient{ 110 + OnDpopPdsNonceChanged: func(did, newNonce string) { 111 + // todo: update the nonce in the database... i guess we only have one session per user? 112 + }, 113 + } 114 + } 115 + return xrpcClient 116 + } 117 + 118 + func HandleOauthReturn(ctx context.Context, cli *config.CLI, code string, iss string, state string, mod model.Model) error { 119 + meta := GetMetadata("longos.iameli.link", "web", "") 120 + oclient, err := oauth.NewClient(oauth.ClientArgs{ 121 + ClientJwk: cli.JWK, 122 + ClientId: meta.ClientID, 123 + RedirectUri: meta.RedirectURIs[0], 124 + }) 125 + 126 + session, err := mod.GetOAuthSessionByState(state) 127 + if err != nil { 128 + return fmt.Errorf("failed to get OAuth session: %w", err) 129 + } 130 + if session == nil { 131 + return fmt.Errorf("no OAuth session found for state: %s", state) 132 + } 133 + 134 + if iss != session.AuthServerIssuer { 135 + return fmt.Errorf("issuer mismatch: %s != %s", iss, session.AuthServerIssuer) 136 + } 137 + 138 + key, err := jwk.ParseKey(session.DPoPPrivateJWK) 139 + if err != nil { 140 + return fmt.Errorf("failed to parse DPoP private JWK: %w", err) 141 + } 142 + 143 + itResp, err := oclient.InitialTokenRequest(ctx, code, iss, session.PKCEVerifier, session.DPoPNonce, key) 144 + if err != nil { 145 + return fmt.Errorf("failed to request initial token: %w", err) 146 + } 147 + now := time.Now() 148 + 149 + if itResp.Sub != session.RepoDID { 150 + return fmt.Errorf("sub mismatch: %s != %s", itResp.Sub, session.RepoDID) 151 + } 152 + 153 + if itResp.Scope != meta.Scope { 154 + return fmt.Errorf("scope mismatch: %s != %s", itResp.Scope, meta.Scope) 155 + } 156 + 157 + expiry := now.Add(time.Second * time.Duration(itResp.ExpiresIn)).UTC() 158 + session.AccessToken = itResp.AccessToken 159 + session.AccessTokenExp = expiry 160 + session.RefreshToken = itResp.RefreshToken 161 + err = mod.UpdateOAuthSession(session) 162 + if err != nil { 163 + return fmt.Errorf("failed to update OAuth session: %w", err) 164 + } 165 + 166 + log.Log(ctx, "itResp", "itResp", itResp) 167 + 168 + authArgs := &oauth.XrpcAuthedRequestArgs{ 169 + Did: session.RepoDID, 170 + AccessToken: session.AccessToken, 171 + PdsUrl: session.PDSUrl, 172 + Issuer: session.AuthServerIssuer, 173 + DpopPdsNonce: session.DPoPNonce, 174 + DpopPrivateJwk: key, 175 + } 176 + 177 + post := bsky.FeedPost{ 178 + Text: "hello from atproto golang oauth client", 179 + CreatedAt: syntax.DatetimeNow().String(), 180 + } 181 + 182 + input := atproto.RepoCreateRecord_Input{ 183 + Collection: "app.bsky.feed.post", 184 + Repo: authArgs.Did, 185 + Record: &util.LexiconTypeDecoder{Val: &post}, 186 + } 187 + 188 + xc := getXrpcClient(mod) 189 + 190 + var out atproto.RepoCreateRecord_Output 191 + if err := xc.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 192 + return err 193 + } 194 + 195 + log.Log(ctx, "out", "out", out) 196 + 197 + return nil 198 + }
+6
pkg/model/model.go
··· 79 79 80 80 CreateChatProfile(ctx context.Context, profile *ChatProfile) error 81 81 GetChatProfile(ctx context.Context, repoDID string) (*ChatProfile, error) 82 + 83 + CreateOAuthSession(session *OAuthSession) error 84 + GetOAuthSessionByState(state string) (*OAuthSession, error) 85 + UpdateOAuthSession(session *OAuthSession) error 86 + DeleteOAuthSession(state string) error 82 87 } 83 88 84 89 func MakeDB(dbURL string) (Model, error) { ··· 135 140 Block{}, 136 141 ChatMessage{}, 137 142 ChatProfile{}, 143 + OAuthSession{}, 138 144 } { 139 145 err = db.AutoMigrate(model) 140 146 if err != nil {
+55
pkg/model/oauth_session.go
··· 1 + package model 2 + 3 + import ( 4 + "time" 5 + 6 + "gorm.io/gorm" 7 + ) 8 + 9 + // OAuthSession stores authentication data needed during the OAuth flow 10 + type OAuthSession struct { 11 + // ID string `gorm:"primarykey"` 12 + State string `gorm:"column:state;primarykey"` 13 + RepoDID string `gorm:"column:repo_did;index"` 14 + PDSUrl string `gorm:"column:pds_url"` 15 + AuthServerIssuer string `gorm:"column:auth_server_issuer"` 16 + PKCEVerifier string `gorm:"column:pkce_verifier"` 17 + DPoPNonce string `gorm:"column:dpop_nonce"` 18 + DPoPPrivateJWK []byte `gorm:"column:dpop_private_jwk;type:text"` 19 + AccessToken string `gorm:"column:access_token"` 20 + AccessTokenExp time.Time `gorm:"column:access_token_exp"` 21 + RefreshToken string `gorm:"column:refresh_token"` 22 + CreatedAt time.Time 23 + UpdatedAt time.Time 24 + DeletedAt gorm.DeletedAt `gorm:"index"` 25 + } 26 + 27 + func (m *DBModel) CreateOAuthSession(session *OAuthSession) error { 28 + return m.DB.Create(session).Error 29 + } 30 + 31 + func (m *DBModel) GetOAuthSessionByState(state string) (*OAuthSession, error) { 32 + var session OAuthSession 33 + err := m.DB.Where("state = ?", state).First(&session).Error 34 + if err != nil { 35 + return nil, err 36 + } 37 + return &session, nil 38 + } 39 + 40 + // func (m *DBModel) GetOAuthSessionByID(id string) (*OAuthSession, error) { 41 + // var session OAuthSession 42 + // err := m.DB.Where("id = ?", id).First(&session).Error 43 + // if err != nil { 44 + // return nil, err 45 + // } 46 + // return &session, nil 47 + // } 48 + 49 + func (m *DBModel) UpdateOAuthSession(session *OAuthSession) error { 50 + return m.DB.Save(session).Error 51 + } 52 + 53 + func (m *DBModel) DeleteOAuthSession(state string) error { 54 + return m.DB.Delete(&OAuthSession{}, "state = ?", state).Error 55 + }
+28 -1
pkg/spxrpc/account.go
··· 3 3 import ( 4 4 "context" 5 5 6 + "github.com/labstack/echo/v4" 7 + "go.opentelemetry.io/otel" 6 8 "stream.place/streamplace/pkg/atproto" 9 + "stream.place/streamplace/pkg/log" 7 10 placestreamtypes "stream.place/streamplace/pkg/streamplace" 8 11 ) 9 12 10 13 func (s *Server) handlePlaceStreamAccountLogin(ctx context.Context, body *placestreamtypes.AccountLogin_Input) (*placestreamtypes.AccountDefs_LoginResponse, error) { 11 - return atproto.Login(ctx, s.cli, body) 14 + return atproto.Login(ctx, s.cli, body, s.model) 15 + } 16 + 17 + func (s *Server) handlePlaceStreamAccountOauthReturn(ctx context.Context, code string, iss string, state string) error { 18 + err := atproto.HandleOauthReturn(ctx, s.cli, code, iss, state, s.model) 19 + if err != nil { 20 + log.Error(ctx, "failed to handle OAuth return", "error", err) 21 + return err 22 + } 23 + return nil 24 + } 25 + 26 + func (s *Server) HandlePlaceStreamAccountOauthReturn(c echo.Context) error { 27 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamAccountOauthReturn") 28 + defer span.End() 29 + code := c.QueryParam("code") 30 + iss := c.QueryParam("iss") 31 + state := c.QueryParam("state") 32 + var handleErr error 33 + // func (s *Server) handlePlaceStreamAccountOauthReturn(ctx context.Context,code string,iss string,state string) (io.Reader, error) 34 + handleErr = s.handlePlaceStreamAccountOauthReturn(ctx, code, iss, state) 35 + if handleErr != nil { 36 + return handleErr 37 + } 38 + return c.Redirect(302, "https://longos.iameli.link/") 12 39 }
+3
pkg/spxrpc/spxrpc.go
··· 29 29 if err != nil { 30 30 return nil, err 31 31 } 32 + 33 + // this one we're handling manually because codegen doesn't support redirects 34 + e.GET("/xrpc/place.stream.account.oauthReturn", s.HandlePlaceStreamAccountOauthReturn) 32 35 return s, nil 33 36 } 34 37