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