Live video on the AT Protocol
1package oproxy
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/url"
10 "slices"
11
12 "github.com/AxisCommunications/go-dpop"
13 "github.com/labstack/echo/v4"
14 "go.opentelemetry.io/otel"
15)
16
17type PAR struct {
18 ClientID string `json:"client_id"`
19 RedirectURI string `json:"redirect_uri"`
20 CodeChallenge string `json:"code_challenge"`
21 CodeChallengeMethod string `json:"code_challenge_method"`
22 State string `json:"state"`
23 LoginHint string `json:"login_hint"`
24 ResponseMode string `json:"response_mode"`
25 ResponseType string `json:"response_type"`
26 Scope string `json:"scope"`
27}
28
29type PARResponse struct {
30 RequestURI string `json:"request_uri"`
31 ExpiresIn int `json:"expires_in"`
32}
33
34var ErrFirstNonce = echo.NewHTTPError(http.StatusBadRequest, "first time seeing this key, come back with a nonce")
35
36func (o *OProxy) HandleOAuthPAR(c echo.Context) error {
37 ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthPAR")
38 defer span.End()
39 c.Response().Header().Set("Access-Control-Allow-Origin", "*")
40 var par PAR
41 if err := json.NewDecoder(c.Request().Body).Decode(&par); err != nil {
42 return echo.NewHTTPError(http.StatusBadRequest, err.Error())
43 }
44
45 dpopHeader := c.Request().Header.Get("DPoP")
46 if dpopHeader == "" {
47 return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required")
48 }
49
50 resp, err := o.NewPAR(ctx, c, &par, dpopHeader)
51 if errors.Is(err, ErrFirstNonce) {
52 res := map[string]interface{}{
53 "error": "use_dpop_nonce",
54 "error_description": "Authorization server requires nonce in DPoP proof",
55 }
56 return c.JSON(http.StatusBadRequest, res)
57 } else if err != nil {
58 return err
59 }
60 return c.JSON(http.StatusCreated, resp)
61}
62
63func (o *OProxy) NewPAR(ctx context.Context, c echo.Context, par *PAR, dpopHeader string) (*PARResponse, error) {
64 jkt, nonce, err := getJKT(dpopHeader)
65 if err != nil {
66 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get JKT from DPoP header header=%s: %s", dpopHeader, err))
67 }
68 session, err := o.loadOAuthSession(jkt)
69 if err != nil {
70 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to load OAuth session: %s", err))
71 }
72 // special case - if this is the first request, we need to send it back for a new nonce
73 if session == nil {
74 _, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{
75 Nonce: nonce, // normally this would be bad! but on the first request we're revalidating nonce anyway
76 TimeWindow: &dpopTimeWindow,
77 })
78 if err != nil {
79 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse DPoP header: %s", err))
80 }
81 newNonce := makeNonce()
82 err = o.createOAuthSession(jkt, &OAuthSession{
83 DownstreamDPoPJKT: jkt,
84 DownstreamDPoPNonce: newNonce,
85 })
86 if err != nil {
87 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth session: %s", err))
88 }
89 // come back later, nerd
90 c.Response().Header().Set("DPoP-Nonce", newNonce)
91 return nil, ErrFirstNonce
92 }
93 if session.DownstreamDPoPNonce != nonce {
94 return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid nonce")
95 }
96 proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{
97 Nonce: session.DownstreamDPoPNonce,
98 TimeWindow: &dpopTimeWindow,
99 })
100 // Check the error type to determine response
101 if err != nil {
102 // if ok := errors.Is(err, dpop.ErrInvalidProof); ok {
103 // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", nil)
104 // return
105 // }
106 // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", err)
107 // return
108 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid DPoP proof: %s", err))
109 }
110 if proof.PublicKey() != jkt {
111 panic("invalid code path: parsed DPoP proof twice and got different keys?!")
112 }
113
114 clientMetadata, err := o.GetDownstreamMetadata(par.RedirectURI)
115 if err != nil {
116 return nil, err
117 }
118 if par.ClientID != clientMetadata.ClientID {
119 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid client_id: expected %s, got %s", clientMetadata.ClientID, par.ClientID))
120 }
121
122 if !slices.Contains(clientMetadata.RedirectURIs, par.RedirectURI) {
123 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", par.RedirectURI))
124 }
125
126 if par.CodeChallengeMethod != "S256" {
127 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid code challenge method: expected S256, got %s", par.CodeChallengeMethod))
128 }
129
130 if par.ResponseMode != "query" {
131 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response mode: expected query, got %s", par.ResponseMode))
132 }
133
134 if par.ResponseType != "code" {
135 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response type: expected code, got %s", par.ResponseType))
136 }
137
138 if par.Scope != o.scope {
139 return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid scope")
140 }
141
142 if par.LoginHint == "" {
143 return nil, echo.NewHTTPError(http.StatusBadRequest, "login hint is required to find your PDS")
144 }
145
146 if par.State == "" {
147 return nil, echo.NewHTTPError(http.StatusBadRequest, "state is required")
148 }
149
150 if par.Scope != o.scope {
151 return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid scope (expected %s, got %s)", o.scope, par.Scope))
152 }
153
154 urn := makeURN(jkt)
155
156 newNonce := makeNonce()
157
158 err = o.updateOAuthSession(jkt, &OAuthSession{
159 DownstreamDPoPJKT: jkt,
160 DownstreamDPoPNonce: newNonce,
161 DownstreamPARRequestURI: urn,
162 DownstreamCodeChallenge: par.CodeChallenge,
163 DownstreamState: par.State,
164 DownstreamRedirectURI: par.RedirectURI,
165 Handle: par.LoginHint,
166 })
167 if err != nil {
168 return nil, fmt.Errorf("could not create oauth session: %w", err)
169 }
170 c.Response().Header().Set("DPoP-Nonce", newNonce)
171
172 resp := &PARResponse{
173 RequestURI: urn,
174 ExpiresIn: int(dpopTimeWindow.Seconds()),
175 }
176
177 return resp, nil
178}