tangled
alpha
login
or
join now
stream.place
/
streamplace
Live video on the AT Protocol
74
fork
atom
overview
issues
1
pulls
pipelines
oproxy: implemented PAR
Eli Mallon
9 months ago
72edee6a
29811731
+247
-211
8 changed files
expand all
collapse all
unified
split
pkg
atproto
client_metadata.go
oproxy
handlers.go
helpers.go
oauth_downstream.go
oauth_session.go
oauth_upstream.go
oproxy.go
par.go
-55
pkg/atproto/client_metadata.go
···
1
package atproto
2
3
-
import (
4
-
"fmt"
5
-
)
6
-
7
var AllowedPlatforms = []string{"ios", "android", "web"}
8
9
type OAuthClientMetadata struct {
···
37
AuthorizationDetailsTypes []string `json:"authorization_details_types,omitempty"`
38
// Jwks *JWKSet `json:"jwks,omitempty"` // You'll need to define JWKSet type
39
}
40
-
41
-
func boolPtr(b bool) *bool {
42
-
return &b
43
-
}
44
-
45
-
func GetUpstreamMetadata(host string, platform string, appBundleId string) *OAuthClientMetadata {
46
-
meta := &OAuthClientMetadata{
47
-
ClientID: fmt.Sprintf("https://%s/api/atproto-oauth/upstream/%s", host, platform),
48
-
JwksURI: fmt.Sprintf("https://%s/api/atproto-oauth/jwks.json", host),
49
-
ClientURI: fmt.Sprintf("https://%s", host),
50
-
// RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)},
51
-
Scope: "atproto transition:generic",
52
-
TokenEndpointAuthMethod: "private_key_jwt",
53
-
ClientName: "Streamplace",
54
-
ResponseTypes: []string{"code"},
55
-
GrantTypes: []string{"authorization_code", "refresh_token"},
56
-
DPoPBoundAccessTokens: boolPtr(true),
57
-
TokenEndpointAuthSigningAlg: "ES256",
58
-
}
59
-
60
-
if platform == "web" {
61
-
meta.RedirectURIs = []string{fmt.Sprintf("https://%s/api/oauth/return", host)}
62
-
meta.ApplicationType = "web"
63
-
} else {
64
-
meta.RedirectURIs = []string{fmt.Sprintf("https://%s/api/app-return/%s", host, appBundleId)}
65
-
meta.ApplicationType = "native"
66
-
}
67
-
return meta
68
-
}
69
-
70
-
func GetDownstreamMetadata(host string, platform string, appBundleId string) *OAuthClientMetadata {
71
-
meta := &OAuthClientMetadata{
72
-
ClientID: fmt.Sprintf("https://%s/api/atproto-oauth/downstream/%s", host, platform),
73
-
ClientURI: fmt.Sprintf("https://%s", host),
74
-
// RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)},
75
-
Scope: "atproto transition:generic",
76
-
TokenEndpointAuthMethod: "none",
77
-
ClientName: "Streamplace",
78
-
ResponseTypes: []string{"code"},
79
-
GrantTypes: []string{"authorization_code", "refresh_token"},
80
-
DPoPBoundAccessTokens: boolPtr(true),
81
-
}
82
-
if platform == "web" {
83
-
meta.RedirectURIs = []string{fmt.Sprintf("https://%s/login", host)}
84
-
meta.ApplicationType = "web"
85
-
} else {
86
-
meta.RedirectURIs = []string{fmt.Sprintf("https://%s/api/app-return/%s", host, appBundleId)}
87
-
meta.ApplicationType = "native"
88
-
}
89
-
return meta
90
-
}
···
1
package atproto
2
0
0
0
0
3
var AllowedPlatforms = []string{"ios", "android", "web"}
4
5
type OAuthClientMetadata struct {
···
33
AuthorizationDetailsTypes []string `json:"authorization_details_types,omitempty"`
34
// Jwks *JWKSet `json:"jwks,omitempty"` // You'll need to define JWKSet type
35
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+25
-128
pkg/oproxy/handlers.go
···
2
3
import (
4
"encoding/json"
5
-
"errors"
6
"fmt"
7
-
"net"
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
"stream.place/streamplace/pkg/atproto"
17
"stream.place/streamplace/pkg/log"
18
-
"stream.place/streamplace/pkg/model"
19
)
20
21
func (o *OProxy) Handler() http.Handler {
···
26
o.e.GET("/oauth/return", o.HandleOAuthReturn)
27
o.e.POST("/oauth/token", o.HandleOAuthToken)
28
o.e.POST("/oauth/revoke", o.HandleOAuthRevoke)
29
-
o.e.GET("/oauth/upstream/client-metadata.json", o.HandleATProtoOAuthUpstream)
30
-
o.e.GET("/oauth/downstream/client-metadata.json", o.HandleATProtoOAuthDownstream)
31
// prefer to handle this by returning in the metadata blob:
32
// apiRouter.GET("/api/atproto-oauth/jwks.json", a.HandleJWKPublic(ctx))
33
return o.e
34
}
35
36
func (o *OProxy) HandleOAuthAuthorizationServer(c echo.Context) error {
37
-
w.Header().Set("Access-Control-Allow-Origin", "*")
38
-
w.Header().Set("Content-Type", "application/json")
39
-
w.WriteHeader(200)
40
-
json.NewEncoder(w).Encode(generateOAuthServerMetadata("longos.iameli.link"))
0
41
}
42
43
-
func (o *OProxy) HandleATProtoOAuthUpstream(c echo.Context) error {
44
-
host, _, err := net.SplitHostPort(req.Host)
45
-
if err != nil {
46
-
host = req.Host
47
-
}
48
-
if !slices.Contains(atproto.AllowedPlatforms, platform) {
49
-
apierrors.WriteHTTPBadRequest(w, "unsupported platform", nil)
50
-
return
51
-
}
52
-
53
-
meta := atproto.GetUpstreamMetadata(host, platform, a.CLI.AppBundleID)
54
-
bs, err := json.Marshal(meta)
55
-
if err != nil {
56
-
apierrors.WriteHTTPInternalServerError(w, "could not marshal metadata", err)
57
-
return
58
-
}
59
-
w.Header().Set("Content-Type", "application/json")
60
-
w.Write(bs)
61
}
62
63
-
func (o *OProxy) HandleATProtoOAuthDownstream(c echo.Context) error {
64
-
host, _, err := net.SplitHostPort(req.Host)
65
-
if err != nil {
66
-
host = req.Host
67
-
}
68
-
if !slices.Contains(atproto.AllowedPlatforms, platform) {
69
-
apierrors.WriteHTTPBadRequest(w, "unsupported platform", nil)
70
-
return
71
-
}
72
-
73
-
meta := atproto.GetDownstreamMetadata(host, platform, a.CLI.AppBundleID)
74
-
bs, err := json.Marshal(meta)
75
-
if err != nil {
76
-
apierrors.WriteHTTPInternalServerError(w, "could not marshal metadata", err)
77
-
return
78
-
}
79
-
w.Header().Set("Content-Type", "application/json")
80
-
w.Write(bs)
81
-
}
82
-
83
-
func generateOAuthServerMetadata(host string) map[string]any {
84
-
oauthServerMetadata := map[string]any{
85
-
"issuer": fmt.Sprintf("https://%s", host),
86
-
"request_parameter_supported": true,
87
-
"request_uri_parameter_supported": true,
88
-
"require_request_uri_registration": true,
89
-
"scopes_supported": []string{"atproto", "transition:generic", "transition:chat.bsky"},
90
-
"subject_types_supported": []string{"public"},
91
-
"response_types_supported": []string{"code"},
92
-
"response_modes_supported": []string{"query", "fragment", "form_post"},
93
-
"grant_types_supported": []string{"authorization_code", "refresh_token"},
94
-
"code_challenge_methods_supported": []string{"S256"},
95
-
"ui_locales_supported": []string{"en-US"},
96
-
"display_values_supported": []string{"page", "popup", "touch"},
97
-
"authorization_response_iss_parameter_supported": true,
98
-
"request_object_encryption_alg_values_supported": []string{},
99
-
"request_object_encryption_enc_values_supported": []string{},
100
-
"jwks_uri": fmt.Sprintf("https://%s/api/oauth/jwks", host),
101
-
"authorization_endpoint": fmt.Sprintf("https://%s/api/oauth/authorize", host),
102
-
"token_endpoint": fmt.Sprintf("https://%s/api/oauth/token", host),
103
-
"token_endpoint_auth_methods_supported": []string{"none", "private_key_jwt"},
104
-
"revocation_endpoint": fmt.Sprintf("https://%s/api/oauth/revoke", host),
105
-
"introspection_endpoint": fmt.Sprintf("https://%s/api/oauth/introspect", host),
106
-
"pushed_authorization_request_endpoint": fmt.Sprintf("https://%s/api/oauth/par", host),
107
-
"require_pushed_authorization_requests": true,
108
-
"client_id_metadata_document_supported": true,
109
-
"request_object_signing_alg_values_supported": []string{
110
-
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
111
-
"ES256", "ES256K", "ES384", "ES512", "none",
112
-
},
113
-
"token_endpoint_auth_signing_alg_values_supported": []string{
114
-
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
115
-
"ES256", "ES256K", "ES384", "ES512",
116
-
},
117
-
"dpop_signing_alg_values_supported": []string{
118
-
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
119
-
"ES256", "ES256K", "ES384", "ES512",
120
-
},
121
-
}
122
-
return oauthServerMetadata
123
}
124
125
func (o *OProxy) HandleOAuthProtectedResource(c echo.Context) error {
126
-
w.Header().Set("Access-Control-Allow-Origin", "*")
127
-
w.Header().Set("Content-Type", "application/json")
128
-
w.WriteHeader(200)
129
-
json.NewEncoder(w).Encode(map[string]interface{}{
130
-
"resource": "https://longos.iameli.link",
131
"authorization_servers": []string{
132
-
"https://longos.iameli.link",
133
},
134
"scopes_supported": []string{},
135
"bearer_methods_supported": []string{
···
137
},
138
"resource_documentation": "https://atproto.com",
139
})
140
-
return nil
141
}
142
143
func (o *OProxy) HandleOAuthPAR(c echo.Context) error {
144
-
w.Header().Set("Access-Control-Allow-Origin", "*")
145
-
w.Header().Set("Content-Type", "application/json")
146
-
var par model.PAR
147
-
if err := json.NewDecoder(r.Body).Decode(&par); err != nil {
148
-
apierrors.WriteHTTPBadRequest(w, "invalid request", err)
149
-
return
150
}
151
152
-
dpopHeader := r.Header.Get("DPoP")
153
if dpopHeader == "" {
154
-
apierrors.WriteHTTPBadRequest(w, "DPoP header is required", nil)
155
-
return
156
}
157
158
-
thirtySec := time.Duration(30 * time.Second)
159
-
proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: r.Host, Scheme: "https", Path: "/api/oauth/par"}, dpop.ParseOptions{
160
-
Nonce: "",
161
-
TimeWindow: &thirtySec,
162
-
})
163
-
// Check the error type to determine response
164
if err != nil {
165
-
if ok := errors.Is(err, dpop.ErrInvalidProof); ok {
166
-
apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", nil)
167
-
return
168
-
}
169
-
apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", err)
170
-
return
171
}
172
-
173
-
// proof is valid, get public key to associate with access token
174
-
par.JKT = proof.PublicKey()
175
-
176
-
if err := a.Model.CreatePAR(&par); err != nil {
177
-
apierrors.WriteHTTPInternalServerError(w, "could not create par", err)
178
-
return
179
-
}
180
-
resp := par.ToPARResponse()
181
-
w.WriteHeader(201)
182
-
json.NewEncoder(w).Encode(resp)
183
}
184
185
func (o *OProxy) HandleOAuthAuthorize(c echo.Context) error {
···
2
3
import (
4
"encoding/json"
0
5
"fmt"
0
6
"net/http"
7
"net/url"
0
8
"time"
9
0
10
"github.com/labstack/echo/v4"
11
"go.opentelemetry.io/otel"
12
"stream.place/streamplace/pkg/atproto"
13
"stream.place/streamplace/pkg/log"
0
14
)
15
16
func (o *OProxy) Handler() http.Handler {
···
21
o.e.GET("/oauth/return", o.HandleOAuthReturn)
22
o.e.POST("/oauth/token", o.HandleOAuthToken)
23
o.e.POST("/oauth/revoke", o.HandleOAuthRevoke)
24
+
o.e.GET("/oauth/upstream/client-metadata.json", o.HandleClientMetadataUpstream)
25
+
o.e.GET("/oauth/downstream/client-metadata.json", o.HandleClientMetadataDownstream)
26
// prefer to handle this by returning in the metadata blob:
27
// apiRouter.GET("/api/atproto-oauth/jwks.json", a.HandleJWKPublic(ctx))
28
return o.e
29
}
30
31
func (o *OProxy) HandleOAuthAuthorizationServer(c echo.Context) error {
32
+
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
33
+
c.Response().Header().Set("Content-Type", "application/json")
34
+
c.Response().WriteHeader(200)
35
+
json.NewEncoder(c.Response().Writer).Encode(generateOAuthServerMetadata("longos.iameli.link"))
36
+
return nil
37
}
38
39
+
func (o *OProxy) HandleClientMetadataUpstream(c echo.Context) error {
40
+
meta := o.GetUpstreamMetadata()
41
+
return c.JSON(200, meta)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
42
}
43
44
+
func (o *OProxy) HandleClientMetadataDownstream(c echo.Context) error {
45
+
meta := o.GetDownstreamMetadata()
46
+
return c.JSON(200, meta)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
47
}
48
49
func (o *OProxy) HandleOAuthProtectedResource(c echo.Context) error {
50
+
return c.JSON(200, map[string]interface{}{
51
+
"resource": fmt.Sprintf("https://%s", o.host),
0
0
0
52
"authorization_servers": []string{
53
+
fmt.Sprintf("https://%s", o.host),
54
},
55
"scopes_supported": []string{},
56
"bearer_methods_supported": []string{
···
58
},
59
"resource_documentation": "https://atproto.com",
60
})
0
61
}
62
63
func (o *OProxy) HandleOAuthPAR(c echo.Context) error {
64
+
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
65
+
var par PAR
66
+
if err := json.NewDecoder(c.Request().Body).Decode(&par); err != nil {
67
+
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
0
0
68
}
69
70
+
dpopHeader := c.Request().Header.Get("DPoP")
71
if dpopHeader == "" {
72
+
return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required")
0
73
}
74
75
+
resp, err := o.NewPAR(c.Request().Context(), &par, dpopHeader)
0
0
0
0
0
76
if err != nil {
77
+
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
0
0
0
0
0
78
}
79
+
return c.JSON(http.StatusCreated, resp)
0
0
0
0
0
0
0
0
0
0
80
}
81
82
func (o *OProxy) HandleOAuthAuthorize(c echo.Context) error {
+19
pkg/oproxy/helpers.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package oproxy
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"github.com/google/uuid"
7
+
)
8
+
9
+
func boolPtr(b bool) *bool {
10
+
return &b
11
+
}
12
+
13
+
func codeUUID(prefix string) string {
14
+
uu, err := uuid.NewV7()
15
+
if err != nil {
16
+
panic(err)
17
+
}
18
+
return fmt.Sprintf("%s-%s", prefix, uu.String())
19
+
}
+145
pkg/oproxy/oauth_downstream.go
···
5
"crypto/sha256"
6
"encoding/base64"
7
"fmt"
0
0
0
8
"time"
9
0
10
"github.com/golang-jwt/jwt/v5"
11
"github.com/google/uuid"
0
12
"stream.place/streamplace/pkg/config"
13
"stream.place/streamplace/pkg/model"
14
)
···
180
}
181
return fmt.Sprintf("code-%s", uu.String()), nil
182
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
5
"crypto/sha256"
6
"encoding/base64"
7
"fmt"
8
+
"net/http"
9
+
"net/url"
10
+
"slices"
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
"stream.place/streamplace/pkg/config"
18
"stream.place/streamplace/pkg/model"
19
)
···
185
}
186
return fmt.Sprintf("code-%s", uu.String()), nil
187
}
188
+
189
+
func generateOAuthServerMetadata(host string) map[string]any {
190
+
oauthServerMetadata := map[string]any{
191
+
"issuer": fmt.Sprintf("https://%s", host),
192
+
"request_parameter_supported": true,
193
+
"request_uri_parameter_supported": true,
194
+
"require_request_uri_registration": true,
195
+
"scopes_supported": []string{"atproto", "transition:generic", "transition:chat.bsky"},
196
+
"subject_types_supported": []string{"public"},
197
+
"response_types_supported": []string{"code"},
198
+
"response_modes_supported": []string{"query", "fragment", "form_post"},
199
+
"grant_types_supported": []string{"authorization_code", "refresh_token"},
200
+
"code_challenge_methods_supported": []string{"S256"},
201
+
"ui_locales_supported": []string{"en-US"},
202
+
"display_values_supported": []string{"page", "popup", "touch"},
203
+
"authorization_response_iss_parameter_supported": true,
204
+
"request_object_encryption_alg_values_supported": []string{},
205
+
"request_object_encryption_enc_values_supported": []string{},
206
+
"jwks_uri": fmt.Sprintf("https://%s/oauth/jwks", host),
207
+
"authorization_endpoint": fmt.Sprintf("https://%s/oauth/authorize", host),
208
+
"token_endpoint": fmt.Sprintf("https://%s/oauth/token", host),
209
+
"token_endpoint_auth_methods_supported": []string{"none", "private_key_jwt"},
210
+
"revocation_endpoint": fmt.Sprintf("https://%s/oauth/revoke", host),
211
+
"introspection_endpoint": fmt.Sprintf("https://%s/oauth/introspect", host),
212
+
"pushed_authorization_request_endpoint": fmt.Sprintf("https://%s/oauth/par", host),
213
+
"require_pushed_authorization_requests": true,
214
+
"client_id_metadata_document_supported": true,
215
+
"request_object_signing_alg_values_supported": []string{
216
+
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
217
+
"ES256", "ES256K", "ES384", "ES512", "none",
218
+
},
219
+
"token_endpoint_auth_signing_alg_values_supported": []string{
220
+
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
221
+
"ES256", "ES256K", "ES384", "ES512",
222
+
},
223
+
"dpop_signing_alg_values_supported": []string{
224
+
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
225
+
"ES256", "ES256K", "ES384", "ES512",
226
+
},
227
+
}
228
+
return oauthServerMetadata
229
+
}
230
+
231
+
func (o *OProxy) GetDownstreamMetadata() *OAuthClientMetadata {
232
+
meta := &OAuthClientMetadata{
233
+
ClientID: fmt.Sprintf("https://%s/oauth/downstream/client-metadata.json", o.host),
234
+
ClientURI: fmt.Sprintf("https://%s", o.host),
235
+
// RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)},
236
+
Scope: "atproto transition:generic",
237
+
TokenEndpointAuthMethod: "none",
238
+
ClientName: "Streamplace",
239
+
ResponseTypes: []string{"code"},
240
+
GrantTypes: []string{"authorization_code", "refresh_token"},
241
+
DPoPBoundAccessTokens: boolPtr(true),
242
+
RedirectURIs: []string{fmt.Sprintf("https://%s/login", o.host)},
243
+
}
244
+
return meta
245
+
}
246
+
247
+
func (o *OProxy) NewPAR(ctx context.Context, par *PAR, dpopHeader string) (*PARResponse, error) {
248
+
thirtySec := time.Duration(30 * time.Second)
249
+
proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/api/oauth/par"}, dpop.ParseOptions{
250
+
Nonce: "",
251
+
TimeWindow: &thirtySec,
252
+
})
253
+
// Check the error type to determine response
254
+
if err != nil {
255
+
// if ok := errors.Is(err, dpop.ErrInvalidProof); ok {
256
+
// apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", nil)
257
+
// return
258
+
// }
259
+
// apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", err)
260
+
// return
261
+
return nil, err
262
+
}
263
+
264
+
clientMetadata := o.GetDownstreamMetadata()
265
+
if par.ClientID != clientMetadata.ClientID {
266
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid client_id")
267
+
}
268
+
269
+
if !slices.Contains(clientMetadata.RedirectURIs, par.RedirectURI) {
270
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid redirect_uri")
271
+
}
272
+
273
+
if par.CodeChallengeMethod != "S256" {
274
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid code challenge method")
275
+
}
276
+
277
+
if par.ResponseMode != "query" {
278
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid response mode")
279
+
}
280
+
281
+
if par.ResponseType != "code" {
282
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid response type")
283
+
}
284
+
285
+
if par.Scope != o.scope {
286
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid scope")
287
+
}
288
+
289
+
if par.LoginHint == "" {
290
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "login hint is required to find your PDS")
291
+
}
292
+
293
+
if par.State == "" {
294
+
return nil, echo.NewHTTPError(http.StatusBadRequest, "state is required")
295
+
}
296
+
297
+
if par.Scope != o.scope {
298
+
return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid scope (expected %s, got %s)", o.scope, par.Scope))
299
+
}
300
+
301
+
// proof is valid, get public key to use as primary key of oauth session
302
+
jkt := proof.PublicKey()
303
+
uu, err := uuid.NewV7()
304
+
if err != nil {
305
+
panic(err)
306
+
}
307
+
308
+
urn := fmt.Sprintf("urn:ietf:params:oauth:request_uri:%s", uu.String())
309
+
310
+
err = o.createOAuthSession(jkt, &OAuthSession{
311
+
DownstreamDPoPJKT: jkt,
312
+
DownstreamPARRequestURI: urn,
313
+
DownstreamCodeChallenge: par.CodeChallenge,
314
+
DownstreamState: par.State,
315
+
DID: par.LoginHint,
316
+
})
317
+
if err != nil {
318
+
return nil, fmt.Errorf("could not create oauth session: %w", err)
319
+
}
320
+
321
+
resp := &PARResponse{
322
+
RequestURI: urn,
323
+
ExpiresIn: int(thirtySec.Seconds()),
324
+
}
325
+
326
+
return resp, nil
327
+
}
+6
-5
pkg/oproxy/oauth_session.go
···
8
9
// OAuthSession stores authentication data needed during the OAuth flow
10
type OAuthSession struct {
11
-
RepoDID string `gorm:"column:repo_did;index"`
12
-
PDSUrl string `gorm:"column:pds_url"`
13
14
// Upstream fields
15
UpstreamState string `gorm:"column:upstream_state;index"`
···
22
UpstreamRefreshToken string `gorm:"column:upstream_refresh_token"`
23
24
// Downstream fields
25
-
DownstreamPARID string `gorm:"column:downstream_par_id;uniqueIndex"`
26
-
DownstreamPAR *PAR `gorm:"foreignKey:DownstreamPARID"`
27
DownstreamDPoPNonce string `gorm:"column:downstream_dpop_nonce"`
28
-
DownstreamDPoPJKT string `gorm:"column:downstream_dpop_jkt"`
29
DownstreamAccessToken string `gorm:"column:downstream_access_token;index"`
30
DownstreamRefreshToken string `gorm:"column:downstream_refresh_token;index"`
31
DownstreamAuthorizationCode string `gorm:"column:downstream_authorization_code;index"`
0
0
0
0
32
33
RevokedAt *time.Time `gorm:"column:revoked_at"`
34
CreatedAt time.Time
···
8
9
// OAuthSession stores authentication data needed during the OAuth flow
10
type OAuthSession struct {
11
+
DID string `gorm:"column:repo_did;index"`
0
12
13
// Upstream fields
14
UpstreamState string `gorm:"column:upstream_state;index"`
···
21
UpstreamRefreshToken string `gorm:"column:upstream_refresh_token"`
22
23
// Downstream fields
0
0
24
DownstreamDPoPNonce string `gorm:"column:downstream_dpop_nonce"`
25
+
DownstreamDPoPJKT string `gorm:"column:downstream_dpop_jkt;primaryKey"`
26
DownstreamAccessToken string `gorm:"column:downstream_access_token;index"`
27
DownstreamRefreshToken string `gorm:"column:downstream_refresh_token;index"`
28
DownstreamAuthorizationCode string `gorm:"column:downstream_authorization_code;index"`
29
+
DownstreamState string `gorm:"column:downstream_state"`
30
+
DownstreamScope string `gorm:"column:downstream_scope"`
31
+
DownstreamCodeChallenge string `gorm:"column:downstream_code_challenge"`
32
+
DownstreamPARRequestURI string `gorm:"column:downstream_par_request_uri"`
33
34
RevokedAt *time.Time `gorm:"column:revoked_at"`
35
CreatedAt time.Time
+19
pkg/oproxy/oauth_upstream.go
···
14
"github.com/lestrrat-go/jwx/v2/jwk"
15
"stream.place/streamplace/pkg/config"
16
"stream.place/streamplace/pkg/log"
0
17
)
18
19
func Login(ctx context.Context, cli *config.CLI, downstreamPAR *model.PAR, mod model.Model) (string, error) {
···
172
173
return session, nil
174
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
14
"github.com/lestrrat-go/jwx/v2/jwk"
15
"stream.place/streamplace/pkg/config"
16
"stream.place/streamplace/pkg/log"
17
+
"stream.place/streamplace/pkg/model"
18
)
19
20
func Login(ctx context.Context, cli *config.CLI, downstreamPAR *model.PAR, mod model.Model) (string, error) {
···
173
174
return session, nil
175
}
176
+
177
+
func (o *OProxy) GetUpstreamMetadata() *OAuthClientMetadata {
178
+
meta := &OAuthClientMetadata{
179
+
ClientID: fmt.Sprintf("https://%s/api/atproto-oauth/oauth/upstream/client-metadata.json", o.host),
180
+
JwksURI: fmt.Sprintf("https://%s/api/atproto-oauth/jwks.json", o.host),
181
+
ClientURI: fmt.Sprintf("https://%s", o.host),
182
+
// RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)},
183
+
Scope: "atproto transition:generic",
184
+
TokenEndpointAuthMethod: "private_key_jwt",
185
+
ClientName: "Streamplace",
186
+
ResponseTypes: []string{"code"},
187
+
GrantTypes: []string{"authorization_code", "refresh_token"},
188
+
DPoPBoundAccessTokens: boolPtr(true),
189
+
TokenEndpointAuthSigningAlg: "ES256",
190
+
RedirectURIs: []string{fmt.Sprintf("https://%s/oauth/return", o.host)},
191
+
}
192
+
return meta
193
+
}
+19
-10
pkg/oproxy/oproxy.go
···
3
import "github.com/labstack/echo/v4"
4
5
type OProxy struct {
6
-
saveOAuthSession func(id string, session *OAuthSession) error
7
-
loadOAuthSession func(id string) (*OAuthSession, error)
8
-
e *echo.Echo
0
0
0
9
}
10
11
-
type OProxyConfig struct {
12
-
SaveOAuthSession func(id string, session *OAuthSession) error
13
-
LoadOAuthSession func(id string) (*OAuthSession, error)
0
0
0
14
}
15
16
-
func NewOProxy(conf *OProxyConfig) *OProxy {
17
e := echo.New()
18
return &OProxy{
19
-
saveOAuthSession: conf.SaveOAuthSession,
20
-
loadOAuthSession: conf.LoadOAuthSession,
21
-
e: e,
0
0
0
22
}
23
}
···
3
import "github.com/labstack/echo/v4"
4
5
type OProxy struct {
6
+
createOAuthSession func(id string, session *OAuthSession) error
7
+
updateOAuthSession func(id string, session *OAuthSession) error
8
+
loadOAuthSession func(id string) (*OAuthSession, error)
9
+
e *echo.Echo
10
+
host string
11
+
scope string
12
}
13
14
+
type Config struct {
15
+
CreateOAuthSession func(id string, session *OAuthSession) error
16
+
UpdateOAuthSession func(id string, session *OAuthSession) error
17
+
LoadOAuthSession func(id string) (*OAuthSession, error)
18
+
Host string
19
+
Scope string
20
}
21
22
+
func New(conf *Config) *OProxy {
23
e := echo.New()
24
return &OProxy{
25
+
createOAuthSession: conf.CreateOAuthSession,
26
+
updateOAuthSession: conf.UpdateOAuthSession,
27
+
loadOAuthSession: conf.LoadOAuthSession,
28
+
e: e,
29
+
host: conf.Host,
30
+
scope: conf.Scope,
31
}
32
}
+14
-13
pkg/oproxy/par.go
···
1
package oproxy
2
3
-
import "time"
0
0
0
0
0
0
0
0
0
0
4
5
-
type PAR struct {
6
-
ClientID string `json:"client_id" gorm:"column:client_id;index"`
7
-
RedirectURI string `json:"redirect_uri" gorm:"column:redirect_uri"`
8
-
CodeChallenge string `json:"code_challenge" gorm:"column:code_challenge;index"`
9
-
CodeChallengeMethod string `json:"code_challenge_method" gorm:"column:code_challenge_method"`
10
-
State string `json:"state" gorm:"column:state"`
11
-
LoginHint string `json:"login_hint" gorm:"column:login_hint"`
12
-
ResponseMode string `json:"response_mode" gorm:"column:response_mode"`
13
-
ResponseType string `json:"response_type" gorm:"column:response_type"`
14
-
Scope string `json:"scope" gorm:"column:scope"`
15
-
ExpiresAt time.Time `json:"expires_at" gorm:"column:expires_at"`
16
-
JKT string `json:"jkt" gorm:"column:jkt"`
17
}
···
1
package oproxy
2
3
+
type PAR struct {
4
+
ClientID string `json:"client_id"`
5
+
RedirectURI string `json:"redirect_uri"`
6
+
CodeChallenge string `json:"code_challenge"`
7
+
CodeChallengeMethod string `json:"code_challenge_method"`
8
+
State string `json:"state"`
9
+
LoginHint string `json:"login_hint"`
10
+
ResponseMode string `json:"response_mode"`
11
+
ResponseType string `json:"response_type"`
12
+
Scope string `json:"scope"`
13
+
}
14
15
+
type PARResponse struct {
16
+
RequestURI string `json:"request_uri"`
17
+
ExpiresIn int `json:"expires_in"`
0
0
0
0
0
0
0
0
0
18
}